Skip to content

BUGFIX: Sync the node type before moving the node in other dimensions - #103

Open
gradinarufelix wants to merge 3 commits into
2.0from
bugfix-sync-node-type-before-move
Open

BUGFIX: Sync the node type before moving the node in other dimensions#103
gradinarufelix wants to merge 3 commits into
2.0from
bugfix-sync-node-type-before-move

Conversation

@gradinarufelix

@gradinarufelix gradinarufelix commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Problem

Changing a node's type and moving the node in the same publication aborts the whole publication with

Neos\ContentRepository\Exception\NodeConstraintException #1404648124
Cannot move Node /new-node-2@live;language=en[Neos.Neos:FallbackNode]
        into Node /new-node-1/main@live;language=en[Neos.Neos:ContentCollection]

nodeType is a plain string column on NodeData, so it exists once per dimension variant. After the
editor retypes the node in the default language, only that row carries the new type; the other
language rows still carry the old name. If that node type was refactored away, NodeTypeManager
silently resolves the old name to Neos.Neos:FallbackNode, which only supertypes Neos.Neos:Node
and therefore fails any constraints.nodeTypes allowlist.

NodeTranslationService::syncNode() moved the target-language variant before repairing its node
type, and Node::moveInto() resolves $this->getNodeType() at call time to validate it against the
new parent. Nothing catches the exception: it escapes syncNode()translateNodes()
PersistenceManager::persistAll() and the editor loses the entire publication.

The ordering has been this way since v2.1.0 (d5746d8) — the move block was inserted above
pre-existing setNodeType() code, so it looks unintentional rather than deliberate.

Fix

BUGFIX: Sync the node type before moving the node in other dimensions

  1. $targetNode->setNodeType($sourceNode->getNodeType()) moves up, directly after adoptNode() and
    before the move block, so the constraint check sees the new type. This is safe:
    Node::setNodeType() performs no constraint validation, creates or removes no tethered child
    nodes, and touches neither path nor index. setIndex() deliberately stays after the move,
    because moveInto() assigns POSITION_LAST first.

  2. A guard skips the preset, with a warning, when the source node type itself cannot be resolved.
    Without it the hoist is not enough — see below.

TASK: Restore the recursion prevention flag if the node sync fails

recursionPreventionEnabled was set to false at the top of the preset loop and back to true only
after it, so any exception inside the loop left it disabled for the rest of the request and silently
disarmed AroundMoveNodeDataAspect. The loop is now wrapped in try/finally that restores the
previous value — the same shape afterAdoptNode() already uses (33f4020), which skipped
syncNode() at the time.

Why the hoist alone is not enough

Node::setNodeType() early-returns on NodeType object identity, and NodeTypeManager hands out
one shared Neos.Neos:FallbackNode instance for every unresolvable name. So when the source node
type is unresolvable too — node moved but never retyped — the hoisted setNodeType() is a no-op and
moveInto() throws exactly as before. Hence the guard, which continues to the next preset instead
of writing the type, moving, or translating.

A note on the guard's predicate

The guard uses $sourceNode->getNodeType()->getName() === $this->fallbackNodeTypeName. The obvious
sharper test — comparing the stored name from getNodeTypeNameWithoutFallback() against the resolved
one, the way Neos\Neos\Fusion\Helper\NodeHelper::nodeType() does — was implemented and does not
work here
:

NodeData::similarize() copies nodeType via ObjectAccess::getProperty($source, 'nodeType')
without forceDirectAccess (only creationDateTime is force-direct), so ObjectAccess prefers the
getNodeType() accessor, gets the resolved NodeType, and the matching setter writes
$nodeType->getName() back into the column. Node::materializeNodeData() and
Workspace::replaceNodeData() both call it, so the dead name has already been replaced by
Neos.Neos:FallbackNode before syncNode() ever sees the node. Instrumented run of the second test:

PROBE preset=en stored=…Testing:RefactoredAwayNodeType  resolved=…Testing:RefactoredAwayNodeType  path=/new-node-2
PROBE preset=en stored=Neos.Neos:FallbackNode           resolved=Neos.Neos:FallbackNode           path=/new-node-1/new-node-2

With that predicate the guard never fires and the second test fails. !hasNodeType($storedName)
fails for the same reason. Comparing against the configured fallback is the only signal that
survives to this point in the code. The cost is that a node genuinely of the configured fallback type
is skipped too; under the shipped default (Neos.Neos:FallbackNode) those are broken leftovers
anyway, and installations that point fallbackNodeType at a usable type cannot distinguish the two
cases at all once similarize() has run.

Tests

Two functional tests in Tests/Functional/ContentRepository/NodeTranslationServiceTest.php, plus a
Configuration/Testing/NodeTypes.yaml fixture with a tethered main Neos.Neos:ContentCollection
whose constraints.nodeTypes denies the wildcard ('*': ~) and allows one type. The tethered shape
matters: it routes the check through NodeType::allowsGrandchildNodeType(), which is the path the
production failure takes — a plain allowsChildNodeType fixture does not reproduce it. The node type
is made to disappear via the documented NodeTypeManager::overrideNodeTypes() hook and restored in
tearDown().

  • nodeWithRemovedNodeTypeInGermanIsRetypedBeforeItIsMovedInEnglish — retype plus move in one
    publication. Verified to fail on 2.0 with NodeConstraintException #1404648124, passes here.
    It asserts on the English NodeData directly (including getDimensionValues()), so a dimension
    fallback cannot make it pass by reading the German row.
  • nodeWithRemovedNodeTypeInGermanIsNotSyncedIntoEnglish — move without retype. Asserts no
    exception, the English variant stays where it was, its stored node type name is untouched, and a
    warning is logged. Verified to fail if the guard is removed but the hoist kept.

Run against Neos 8.4.7 / Flow 8.4.4 on PHP 8.3: 15 tests, 94 assertions, 1 failure —
removedNodeInGermanIsAlsoRemovedInEnglish, which fails identically on unmodified 2.0 in the same
environment and is unrelated to this change. composer test:style and composer test:unit are green;
composer test:stan reports 4 errors, all pre-existing in Classes/Ui/Changes/*.php and unrelated
to this change — they are fixed separately in #104. None in NodeTranslationService.php.

For the reviewer

  • test:functional is not part of CI. .github/workflows/build.yml runs composer test, which
    is style + stan + unit only, so the two new tests will not run on this PR. Wiring the functional
    suite in needs a Neos installation in the workflow and felt out of scope here — happy to add it in
    a separate PR if you want it.
  • Branch name does not follow the BUGFIX/… convention. Say the word and I will rename.
  • Known trade-off: the guard skips the whole per-preset body, including the cases where no move is
    required. For a node with an unresolvable type that is only edited or reordered, v2.7.1 synced the
    hidden flags, index and properties harmlessly; this branch logs and skips instead. Most visible in
    ./flow translation:sync, which walks the tree and never moves anything. A narrower guard that
    skipped only setNodeType() and the move would preserve that behaviour — I went with the broader
    skip because syncing a node whose type no longer exists is questionable either way, but I am happy
    to narrow it.
  • Deliberately not in this PR: per-node try/catch isolation in translateNodes() (it turns
    publication failures into log lines and deserves its own discussion), sorting of the publish batch,
    and reconciling auto-created child nodes after a type change.
  • 3.0 is unaffected — Classes/ContentRepository/NodeTranslationService.php does not exist there
    and sync is not implemented for Neos 9 yet (Reimplement sync for Neos 9 #54).

@gradinarufelix

Copy link
Copy Markdown
Collaborator Author

The red CI here is not caused by this PR. I exported the base commit (cd0946b, untouched 2.0) and ran the exact workflow recipe on it — composer require neos/neos ^8.3 --no-install && composer install && composer test — and it fails with the same four phpstan errors in Classes/Ui/Changes/*.php, which this branch does not touch. 2.0 is simply red at the moment; the last run there was green on 02.07.2026 and nothing has been pushed since.

Cause and fix are in #104. Once that is merged I will rebase this branch and CI should go green.

@mficzel mficzel left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fine by verifying only sync node is affected ...

The node type is stored per dimension variant, so a node that is retyped
and moved in the same publication still carries the old node type name in
the other dimensions. Moving validates the node type the moved node
currently has against the constraints of the new parent, so the sync moved
the target variant while it still resolved to Neos.Neos:FallbackNode and
aborted the whole publication with a NodeConstraintException.

Sync the node type before the move so the constraint check sees the new
node type. If the source node type cannot be resolved either, skip the
preset with a warning instead, as writing the fallback node type would
destroy the node type name the target dimension still has stored.
The flag was reset only after the preset loop, so any exception inside the
loop left it disabled for the rest of the request and silently disarmed the
AroundMoveNodeDataAspect. Restore the previous value in a finally block, the
same way afterAdoptNode already does.
@mficzel
mficzel force-pushed the bugfix-sync-node-type-before-move branch from d5be3bb to 2ee1ecf Compare August 25, 2026 13:00

@mficzel mficzel left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally fine but after a rebase on 2.0 with your ci fixes the teste for 8.3 now fail.
Once this is adjusted it can be merged and released.

@gradinarufelix

Copy link
Copy Markdown
Collaborator Author

Fixed it. I was accessing a method to get the typed node type in the node data that was introduced only in 8.4. Replaced it with a direct access.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants