Disclosure: The investigation behind this report was done by me (dbraendle) together with two AI coding assistants (Claude and Codex). Most claims – the vanilla reproduction, the Hook trace, the test matrix, the version verifications, the 4.0.0 verification – have been hands-on verified. Some specific phrasings about TYPO3 core internals are based on code reading and observed outputs rather than an exhaustive read of the relevant DataHandler methods. Please flag anywhere that needs tightening; I am happy to re-verify.
TL;DR
When a content element is dropped at the top of a container's child column via the BE Page Module, the new sorting can be computed around the container record's own top-level sorting instead of around the existing children. If container.sorting > min(child.sorting), the dropped element ends up after all existing children rather than at the top.
For top-of-column drops, the effective DataHandler target becomes -container_uid. In the rendered Page Module top dropzone, JavaScript can send the positive page uid together with colPos / tx_container_parent; the Hook then rewrites this through ContainerService::getNewContentElementAtTopTargetInColumn(), whose fallback returns -container_uid. A command that already reaches DataHandler with target = -container_uid fails in the same way.
Reproducible on a vanilla TYPO3 v13.4.30 + b13/container 3.2.3 + container-example install with no other extensions. The Page Module shows the element correctly placed via Optimistic UI right after the drag, then on hard reload it springs back to the bottom – this is the "headline jumps back" symptom that editors keep reporting.
Environment
- TYPO3 v13.4.30
- b13/container 3.2.3
- Also affects 4.0.0 for this code path.
drag-drop.js is unchanged between 3.2.3 and 4.0.0; CommandMapBeforeStartHook.php and ContainerService.php do have diffs, but the relevant target > 0 gate and the -container_uid fallback remain. I also reproduced the direct target=-container_uid case on 4.0.0.
- Tags checked:
3.2.3: bc3e47686115fa98ed9dd439ec507547bd3d59ea
4.0.0: d8bf0314d68faf231b891d77149e5aa9843e6d2a
- b13/container-example 2.0.0
- PHP 8.4 in the original repro setup
- Database: confirmed on SQLite (vanilla repro) and MariaDB 10.11 (production data)
- Live workspace (also reproducible in Draft workspaces – not workspace-specific)
Steps to reproduce on a vanilla install
Setup data (raw SQL on a fresh install):
-- Root + test page
INSERT INTO pages (pid, doktype, title, slug, perms_userid, perms_user, perms_everybody, is_siteroot)
VALUES (0, 1, 'Root', '/', 1, 31, 31, 1); -- uid=1
INSERT INTO pages (pid, doktype, title, slug, perms_userid, perms_user, perms_everybody)
VALUES (1, 1, 'Test', '/test', 1, 31, 31); -- uid=2
-- Container with HIGH sorting (= what naturally happens when a new container is
-- added to a page that already has many records)
INSERT INTO tt_content (pid, sorting, colPos, tx_container_parent, CType, header)
VALUES (2, 10000, 0, 0, 'b13-2cols-with-header-container', 'Container'); -- uid=1
-- Existing children with LOW sortings (= MOVE_POINTERs in workspaces, or live
-- records that were sibling-anchored at low positions during prior editing)
INSERT INTO tt_content (pid, sorting, colPos, tx_container_parent, CType, header)
VALUES (2, 100, 200, 1, 'header', 'Child A'); -- uid=2
INSERT INTO tt_content (pid, sorting, colPos, tx_container_parent, CType, header)
VALUES (2, 200, 200, 1, 'header', 'Child B'); -- uid=3
-- An element we will try to move to the TOP of the container
INSERT INTO tt_content (pid, sorting, colPos, tx_container_parent, CType, header)
VALUES (2, 10500, 200, 1, 'header', 'TestSubject'); -- uid=4
State after setup (container.sorting=10000 > min(child.sorting)=100):
uid=1 sort=10000 colPos=0 parent=0 Container
uid=2 sort=100 colPos=200 parent=1 Child A
uid=3 sort=200 colPos=200 parent=1 Child B
uid=4 sort=10500 colPos=200 parent=1 TestSubject
Now run a DataHandler move that represents the failing target shape:
$cmd = [
'tt_content' => [
4 => [
'move' => [
'action' => 'paste',
'target' => -1, // = -container_uid
'update' => [
'colPos' => 200,
'tx_container_parent' => 1,
'sys_language_uid' => 0,
],
],
],
],
];
$dh = GeneralUtility::makeInstance(DataHandler::class);
$dh->start([], $cmd);
$dh->process_cmdmap();
I also reproduced the top-drop Hook path with a positive page target:
$cmd['tt_content'][4]['move']['target'] = $testPageUid; // uid of the page that contains the container
With update.colPos=200 and update.tx_container_parent=1, the Hook rewrites this to the same effective target = -1 fallback, producing the same bad sorting.
Expected
TestSubject should appear at the top of the container's children. Its new sorting should be lower than Child A.sorting=100, e.g. around 50.
Actual
uid=1 sort=10000 colPos=0 parent=0 Container
uid=2 sort=100 colPos=200 parent=1 Child A
uid=3 sort=200 colPos=200 parent=1 Child B
uid=4 sort=10250 colPos=200 parent=1 TestSubject <- appears at bottom
TestSubject got sorting = 10250 = (container.sorting + previous_TestSubject.sorting) / 2. In the children list (ORDER BY sorting) it appears after all real children.
Root cause analysis
The failure is caused by a target that resolves to the container record itself (-container_uid) while the moved record is then updated into the container child column. DataHandler calculates the sort number from the target anchor's pid and sorting, not from update.colPos / update.tx_container_parent.
Path 1: top-of-column drop is rewritten to target = -container_uid
In Resources/Public/JavaScript/Overrides/drag-drop.js, the drop handler computes the target as:
const i = t.closest(Identifiers.content).dataset.uid;
d = void 0 === i
? parseInt(t.closest("[data-page]").dataset.page, 10) // page uid
: 0 - parseInt(i, 10); // -closest content uid
The top-of-column dropzone rendered by b13/container is inside the container column wrapper, but its nearest .t3js-page-ce is the top dropzone wrapper from Resources/Private/Partials/PageLayout/Grid/ColumnHeader.html, which has data-page instead of data-uid. So the command can enter the Hook with a positive page target and update.tx_container_parent.
The Hook CommandMapBeforeStartHook::rewriteCommandMapTargetForTopAtContainer has the condition:
if (
isset($value['update']['tx_container_parent']) &&
$value['update']['tx_container_parent'] > 0 &&
isset($value['update']['colPos']) &&
$value['update']['colPos'] > 0 &&
$value['target'] > 0
) { ... }
For that positive-target top-drop command, the Hook fires and calls:
$target = $this->containerService->getNewContentElementAtTopTargetInColumn(
$container,
(int)$value['update']['colPos']
);
For the first configured container column, this returns the fallback -container_uid.
Path 2: a command that already has target = -container_uid is not corrected
If a command already reaches the Hook with target = -container_uid, rewriteCommandMapTargetForTopAtContainer() does not fire because it requires $value['target'] > 0.
The companion Hook rewriteCommandMapTargetForAfterContainer() also skips this case because abs(target) === update.tx_container_parent triggers the // elements in container have already correct target short-circuit.
The command goes to DataHandler unchanged.
The fallback in ContainerService is the broken target
ContainerService::getNewContentElementAtTopTargetInColumn starts with:
public function getNewContentElementAtTopTargetInColumn(
Container $container, int $targetColPos
): int {
$target = -$container->getUid(); // DEFAULT FALLBACK
$previousRecord = null;
foreach ($allColumns as $colPos) {
if ($colPos === $targetColPos && $previousRecord !== null) {
$target = -(int)$previousRecord['uid'];
}
$children = $container->getChildrenByColPos($colPos);
if (!empty($children)) {
$previousRecord = array_pop($children);
}
}
return $target;
}
The non-fallback path requires the target column to be not the first configured container column and at least one preceding configured column to have children. Otherwise the fallback -container_uid is returned and DataHandler resolves it around the container record's own page-level sorting.
For single-column containers (container_100-style) and for drops in the first column of multi-column containers, this fallback always wins.
Verified by adding temporary file_put_contents logging into the Hook and observing the trace:
=== processCmdmap_beforeStart ===
rewriteTopAtContainer id=4 op=move target=2 parent=1 colPos=200
-> condition met, calling buildContainer(1)
-> rewrote target to -1
DataHandler sorting behavior
TYPO3 v13.4 DataHandler::moveRecord() calls getSortNumber($table, $sourceUid, $destination).
For a negative destination, getSortNumber():
- loads the target anchor record by
uid = abs($destination),
- uses that anchor's
pid and sorting,
- finds the next record in the same
pid sorted by sorting,
- returns a new sort number between the anchor and that next record, or
anchor.sorting + sortIntervals.
It does not scope that calculation by the moved record's final colPos or tx_container_parent. Those fields are applied afterwards through the paste update datamap. This is why the final record can be in (parent=container_uid, colPos=child_colPos) while carrying a sort value calculated from the page-level anchor.
For the vanilla T1 data:
target anchor = container uid=1, sorting=10000
next page-level sorting in the same pid = TestSubject's previous sorting=10500
new sorting = 10000 + floor((10500 - 10000) / 2) = 10250
Why the bug only manifests under sort inversion
When target = -container_uid, DataHandler calculates the new sorting near container.sorting.
- If
container.sorting < min(child.sorting) (the normal case for a container with low/early sorting), the resulting value can still be below all children, so the bug is hard to notice.
- If
container.sorting > min(child.sorting), the resulting value is above the existing low-sorting children, so the element appears at the bottom.
The inversion container.sorting > min(child.sorting) arises naturally in editorial workflows where:
- a new container is created on an already-populated page (it gets a relatively high sorting), then
- existing low-sort records are moved into it (their workspace MOVE_POINTERs keep low sortings), or
- existing records inside the container are sibling-anchored at low positions during prior editing.
This combination is common in Workspaces (NEW container + MOVE_POINTER children) but not Workspace-specific. The vanilla repro above is purely in Live workspace.
What I tested
This bug surfaced on a production page in our project (TYPO3 v13.4.27 + container 3.2.3, real Workspace 2 with hundreds of records on the affected page). To get from "an editor says elements jump back to the bottom" to the reproduction above, I went through the following:
-
DB snapshot + sys_history audit on the affected page (a container_100 with 8 mixed children, 3 of them stuck in the high-sort range). The history of each affected element showed only editor-triggered DataHandler writes – no automatic re-corruption.
-
Built a CLI debug tool (binder:cnt show|list|move|set|history|hooks|create|delete) that drives DataHandler directly in any workspace context, prints before/after state, and bypasses the Page Module's Optimistic UI so that what I observe is what the DB really stores. The tool runs DataHandler with the same command shapes used by the Page Module / Hook paths, which lets me reproduce drops deterministically without dragging.
-
Deterministic tests on the affected page using that tool. The relevant findings:
target=-container_uid produces a sorting in the page-level range around the container record. With the inversion, that is outside the children range.
- A positive page target with
update.tx_container_parent can be rewritten by the Hook to the same -container_uid fallback.
target=-sibling_uid (between-siblings drops) produces a sorting in the children's range and works correctly.
- Repeated drops on the same element converge toward
container.sorting: 21175 -> 21146 -> 21132.
- Forcing
container.sorting = 100 (via direct SQL, mirroring the editor workaround of "drag the container to the very top of the page") immediately restores the invariant – the next drop-at-top lands at 298, top of children.
- Forcing
container.sorting = 30000 on a previously healthy container reproduces the bug there – the bug is purely numerical.
-
Hook execution trace. Added temporary file_put_contents logging into CommandMapBeforeStartHook (in the project's vendor/b13/container). Confirmed: for a synthetic positive-target cmd the Hook runs, calls getNewContentElementAtTopTargetInColumn, and gets back -container_uid from the fallback path. For a command already using target=-container_uid, the Hook condition target > 0 is not met and no correction happens.
-
Reproduction on a second page in the same project (a simple page with no Workspace overlays) by forcing the inversion on a healthy container via direct SQL. Same result, confirms it's not page- or data-specific.
-
Container catalog on the production page: 23 of 36 containers on that page satisfy container.sorting > min(child.sorting). Editors had been hitting the bug across multiple containers, not one.
-
Eliminated alternative explanations:
- Project-specific DataHandler Hooks: dumped the full Hook stack (
$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass']). The only project Hook on Cmdmap is for tx_news and doesn't touch tt_content.
- sys_history "lost": initially looked like the affected container had been inserted without DataHandler. Turned out the project runs the standard
TableGarbageCollectionTask on sys_history with 181-day retention – older entries simply pruned.
- In my production workspace case,
container:sorting --apply <pid> did not fix the affected workspace-only containers because the CLI run operated in Live workspace. On the vanilla live reproduction, the command does detect and normalize the artificial inversion, so this is not a general limitation of the algorithm.
-
Vanilla reproduction (the steps above). Fresh composer create-project typo3/cms-base-distribution:^13 + composer require b13/container:3.2.3 b13/container-example, SQLite, admin user only. Same DataHandler command, same result: sorting=10250, element at the bottom of the children list. I repeated the direct target=-container_uid probe with b13/container 4.0.0 and got the same result.
Across all of this the failure mode is consistent: when target = -container_uid reaches DataHandler, whether directly or via the Hook's fallback, and container.sorting > min(child.sorting), the moved record's new sorting is computed around the container record's own sorting and can end up below the intended visual position.
Possible fix directions
Three plausible places, in decreasing order of intrusiveness:
-
ContainerService::getNewContentElementAtTopTargetInColumn - change the fallback. Instead of returning -container_uid for a non-empty target column, compute a target/sorting strategy that places the moved record before the first child of the target column. Only keep a synthetic container/page-level target when the target column is genuinely empty.
-
CommandMapBeforeStartHook::rewriteCommandMapTargetForTopAtContainer - also handle commands that already arrive with target < 0 && abs(target) == update.tx_container_parent (= effective drop-at-top of own children). Compute and inject a target that resolves into the children's sort space.
-
JS-side override in drag-drop.js - detect the "drop at top of container's child column" case and send a command shape that cannot resolve sorting around the container record itself. Less centralized, requires keeping the JS in sync with future TYPO3 changes.
I'm happy to follow up with a PR once a preferred direction is identified.
Side notes
- Sibling-anchored drops (
target = -sibling_uid) work correctly even under inversion (verified on vanilla, tests T2/T3).
- In the vanilla live repro,
container:sorting --apply <pid> detects and normalizes the artificial inversion. My production observation that it did not help appears to be tied to Workspace-only containers and Live-workspace CLI execution, not to the live repair algorithm in general.
- A natural "first-aid" mitigation for the affected production page is to normalize
container.sorting to min(child.sorting) - delta per container – this restores the invariant so existing children stay in place and new drop-at-top operations land correctly. It does not address the root cause; new containers added to populated pages can re-introduce the inversion.
TL;DR
When a content element is dropped at the top of a container's child column via the BE Page Module, the new
sortingcan be computed around the container record's own top-levelsortinginstead of around the existing children. Ifcontainer.sorting > min(child.sorting), the dropped element ends up after all existing children rather than at the top.For top-of-column drops, the effective DataHandler target becomes
-container_uid. In the rendered Page Module top dropzone, JavaScript can send the positive page uid together withcolPos/tx_container_parent; the Hook then rewrites this throughContainerService::getNewContentElementAtTopTargetInColumn(), whose fallback returns-container_uid. A command that already reaches DataHandler withtarget = -container_uidfails in the same way.Reproducible on a vanilla TYPO3 v13.4.30 + b13/container 3.2.3 + container-example install with no other extensions. The Page Module shows the element correctly placed via Optimistic UI right after the drag, then on hard reload it springs back to the bottom – this is the "headline jumps back" symptom that editors keep reporting.
Environment
drag-drop.jsis unchanged between 3.2.3 and 4.0.0;CommandMapBeforeStartHook.phpandContainerService.phpdo have diffs, but the relevanttarget > 0gate and the-container_uidfallback remain. I also reproduced the directtarget=-container_uidcase on 4.0.0.3.2.3:bc3e47686115fa98ed9dd439ec507547bd3d59ea4.0.0:d8bf0314d68faf231b891d77149e5aa9843e6d2aSteps to reproduce on a vanilla install
Setup data (raw SQL on a fresh install):
State after setup (
container.sorting=10000 > min(child.sorting)=100):Now run a DataHandler move that represents the failing target shape:
I also reproduced the top-drop Hook path with a positive page target:
With
update.colPos=200andupdate.tx_container_parent=1, the Hook rewrites this to the same effectivetarget = -1fallback, producing the same bad sorting.Expected
TestSubjectshould appear at the top of the container's children. Its newsortingshould be lower thanChild A.sorting=100, e.g. around50.Actual
TestSubjectgotsorting = 10250 = (container.sorting + previous_TestSubject.sorting) / 2. In the children list (ORDER BY sorting) it appears after all real children.Root cause analysis
The failure is caused by a target that resolves to the container record itself (
-container_uid) while the moved record is then updated into the container child column. DataHandler calculates the sort number from the target anchor'spidandsorting, not fromupdate.colPos/update.tx_container_parent.Path 1: top-of-column drop is rewritten to
target = -container_uidIn
Resources/Public/JavaScript/Overrides/drag-drop.js, the drop handler computes the target as:The top-of-column dropzone rendered by b13/container is inside the container column wrapper, but its nearest
.t3js-page-ceis the top dropzone wrapper fromResources/Private/Partials/PageLayout/Grid/ColumnHeader.html, which hasdata-pageinstead ofdata-uid. So the command can enter the Hook with a positive page target andupdate.tx_container_parent.The Hook
CommandMapBeforeStartHook::rewriteCommandMapTargetForTopAtContainerhas the condition:For that positive-target top-drop command, the Hook fires and calls:
For the first configured container column, this returns the fallback
-container_uid.Path 2: a command that already has
target = -container_uidis not correctedIf a command already reaches the Hook with
target = -container_uid,rewriteCommandMapTargetForTopAtContainer()does not fire because it requires$value['target'] > 0.The companion Hook
rewriteCommandMapTargetForAfterContainer()also skips this case becauseabs(target) === update.tx_container_parenttriggers the// elements in container have already correct targetshort-circuit.The command goes to DataHandler unchanged.
The fallback in
ContainerServiceis the broken targetContainerService::getNewContentElementAtTopTargetInColumnstarts with:The non-fallback path requires the target column to be not the first configured container column and at least one preceding configured column to have children. Otherwise the fallback
-container_uidis returned and DataHandler resolves it around the container record's own page-level sorting.For single-column containers (
container_100-style) and for drops in the first column of multi-column containers, this fallback always wins.Verified by adding temporary
file_put_contentslogging into the Hook and observing the trace:DataHandler sorting behavior
TYPO3 v13.4
DataHandler::moveRecord()callsgetSortNumber($table, $sourceUid, $destination).For a negative destination,
getSortNumber():uid = abs($destination),pidandsorting,pidsorted bysorting,anchor.sorting + sortIntervals.It does not scope that calculation by the moved record's final
colPosortx_container_parent. Those fields are applied afterwards through the paste update datamap. This is why the final record can be in(parent=container_uid, colPos=child_colPos)while carrying a sort value calculated from the page-level anchor.For the vanilla T1 data:
Why the bug only manifests under sort inversion
When
target = -container_uid, DataHandler calculates the new sorting nearcontainer.sorting.container.sorting < min(child.sorting)(the normal case for a container with low/early sorting), the resulting value can still be below all children, so the bug is hard to notice.container.sorting > min(child.sorting), the resulting value is above the existing low-sorting children, so the element appears at the bottom.The inversion
container.sorting > min(child.sorting)arises naturally in editorial workflows where:This combination is common in Workspaces (NEW container + MOVE_POINTER children) but not Workspace-specific. The vanilla repro above is purely in Live workspace.
What I tested
This bug surfaced on a production page in our project (TYPO3 v13.4.27 + container 3.2.3, real Workspace 2 with hundreds of records on the affected page). To get from "an editor says elements jump back to the bottom" to the reproduction above, I went through the following:
DB snapshot + sys_history audit on the affected page (a
container_100with 8 mixed children, 3 of them stuck in the high-sort range). The history of each affected element showed only editor-triggered DataHandler writes – no automatic re-corruption.Built a CLI debug tool (
binder:cnt show|list|move|set|history|hooks|create|delete) that drives DataHandler directly in any workspace context, prints before/after state, and bypasses the Page Module's Optimistic UI so that what I observe is what the DB really stores. The tool runs DataHandler with the same command shapes used by the Page Module / Hook paths, which lets me reproduce drops deterministically without dragging.Deterministic tests on the affected page using that tool. The relevant findings:
target=-container_uidproduces a sorting in the page-level range around the container record. With the inversion, that is outside the children range.update.tx_container_parentcan be rewritten by the Hook to the same-container_uidfallback.target=-sibling_uid(between-siblings drops) produces a sorting in the children's range and works correctly.container.sorting:21175 -> 21146 -> 21132.container.sorting = 100(via direct SQL, mirroring the editor workaround of "drag the container to the very top of the page") immediately restores the invariant – the next drop-at-top lands at298, top of children.container.sorting = 30000on a previously healthy container reproduces the bug there – the bug is purely numerical.Hook execution trace. Added temporary
file_put_contentslogging intoCommandMapBeforeStartHook(in the project'svendor/b13/container). Confirmed: for a synthetic positive-target cmd the Hook runs, callsgetNewContentElementAtTopTargetInColumn, and gets back-container_uidfrom the fallback path. For a command already usingtarget=-container_uid, the Hook conditiontarget > 0is not met and no correction happens.Reproduction on a second page in the same project (a simple page with no Workspace overlays) by forcing the inversion on a healthy container via direct SQL. Same result, confirms it's not page- or data-specific.
Container catalog on the production page: 23 of 36 containers on that page satisfy
container.sorting > min(child.sorting). Editors had been hitting the bug across multiple containers, not one.Eliminated alternative explanations:
$GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['t3lib/class.t3lib_tcemain.php']['processCmdmapClass']). The only project Hook on Cmdmap is fortx_newsand doesn't touchtt_content.TableGarbageCollectionTaskonsys_historywith 181-day retention – older entries simply pruned.container:sorting --apply <pid>did not fix the affected workspace-only containers because the CLI run operated in Live workspace. On the vanilla live reproduction, the command does detect and normalize the artificial inversion, so this is not a general limitation of the algorithm.Vanilla reproduction (the steps above). Fresh
composer create-project typo3/cms-base-distribution:^13+composer require b13/container:3.2.3 b13/container-example, SQLite, admin user only. Same DataHandler command, same result:sorting=10250, element at the bottom of the children list. I repeated the directtarget=-container_uidprobe with b13/container 4.0.0 and got the same result.Across all of this the failure mode is consistent: when
target = -container_uidreaches DataHandler, whether directly or via the Hook's fallback, andcontainer.sorting > min(child.sorting), the moved record's new sorting is computed around the container record's own sorting and can end up below the intended visual position.Possible fix directions
Three plausible places, in decreasing order of intrusiveness:
ContainerService::getNewContentElementAtTopTargetInColumn- change the fallback. Instead of returning-container_uidfor a non-empty target column, compute a target/sorting strategy that places the moved record before the first child of the target column. Only keep a synthetic container/page-level target when the target column is genuinely empty.CommandMapBeforeStartHook::rewriteCommandMapTargetForTopAtContainer- also handle commands that already arrive withtarget < 0 && abs(target) == update.tx_container_parent(= effective drop-at-top of own children). Compute and inject a target that resolves into the children's sort space.JS-side override in
drag-drop.js- detect the "drop at top of container's child column" case and send a command shape that cannot resolve sorting around the container record itself. Less centralized, requires keeping the JS in sync with future TYPO3 changes.I'm happy to follow up with a PR once a preferred direction is identified.
Side notes
target = -sibling_uid) work correctly even under inversion (verified on vanilla, tests T2/T3).container:sorting --apply <pid>detects and normalizes the artificial inversion. My production observation that it did not help appears to be tied to Workspace-only containers and Live-workspace CLI execution, not to the live repair algorithm in general.container.sortingtomin(child.sorting) - deltaper container – this restores the invariant so existing children stay in place and new drop-at-top operations land correctly. It does not address the root cause; new containers added to populated pages can re-introduce the inversion.