fix: #3591820 Synchronize non-translatable component inputs before validating auto-save publish
Closes #3591820 (closed)
What this MR does
ApiAutoSaveController::post() validated the entity before
content_translation's presave field synchronizer ran. Because the auto-save
snapshot applied in applyAutoSaveTranslationSnapshots() only carries the
edited (default) translation, a change to a non-translatable ("symmetric")
component input — an image width/loading, a section padding_bottom, a
heading's element, etc. — left non-default translations stale at validation
time, and ComponentTreeSymmetricalTranslationConstraint rejected the publish
with a 422.
Two fixes, one per validation point:
- Publish path:
getConflictAwareContentEntityViolations()validates a synchronized clone, converged via the (optionally injected)content_translation.synchronizer— the same service Canvas already decorates withComponentTreeFieldSymmetricalTranslationSynchronizer. The entity that gets saved is deliberately left untouched, so it is synchronized exactly once — bycontent_translation's presave hook duringsave(). Synchronizing the to-be-saved entity in place instead would make presave a second pass, andFieldTranslationSynchronizer::synchronizeItems()is not idempotent after a structural (insert/reorder) edit: the second pass re-maps already-synchronized deltas and stamps the wrong component's inputs onto the translations (covered bytestPublishingDeltaShiftingStructuralEditPreservesTranslations()). - Edit path: the real editor reaches auto-save through
ClientDataToEntityConverter::convert(), whose entity-form validation persisted the same symmetry violation to thecanvas.form_violationsstore;::post()re-attaches stored violations at publish, so the publish still 422'd even once the entity itself converged.convert()now synchronizes before validating — but only when the default translation is being edited: the synchronization source is the default translation, so a non-default draft is itself a synchronization target and would be silently overwritten with the default's values instead of failing validation. (The Canvas UI does not currently allow editing non-default translations; the HTTP API does.)
No-op for asymmetric translations (the decorated synchronizer only acts on
fields in symmetric mode) and when content_translation is not installed
(optional dependency, injected via @?). Note: the guards use
$entity->isTranslatable() where presave uses
ContentTranslationManager::isEnabled(); equivalent unless a third party
forces translatable = TRUE without content_translation enablement.
AI-Generated: Yes (Used Claude Code to diagnose the root cause, write the fix and kernel test coverage, analyze the double-synchronization corruption that prompted the clone re-roll, and compare against the alternative approach in !1408 (closed)).
Test coverage
Four new tests in ApiAutoSaveControllerTranslationTest:
testPublishingSharedSymmetricInputEdit():enpage +estranslation share a component; the non-translatableelement(h1 → h2) changes on the default translation only, then is auto-saved and published. Asserts HTTP 200 (previously 422) and that theescopy converged while its translatabletext/labelare preserved.testPublishingDeltaShiftingStructuralEditPreservesTranslations(): inserts a component before two existing ones (shifting their deltas) across a publish. Asserts each translation keeps its own translated inputs. Fails against any in-place pre-validation synchronization of the saved entity.testEditingSharedSymmetricInputStoresNoFormViolation(): drives the same edit through the real editor path (layout POST →ClientDataToEntityConverter::convert()); asserts no symmetry violation is persisted and the subsequent publish succeeds.testEditingNonDefaultTranslationDoesNotConvergeDraft(): edits a non-translatable input on theestranslation via thees-prefixed layout endpoint; asserts the violation IS stored and the draft keeps the attempted value instead of being silently converged.
Testing instructions
- Enable
content_translationoncanvas_pagein symmetric mode (componentsfield:treesynced,inputstranslatable). - Create a page with one component; add a non-default-language translation of the same component.
- In the default-language editor, change a non-translatable input on that
component instance (e.g. an image
width) and let auto-save run. - Publish. Confirm it succeeds (previously 422'd) and the translation's copy of the non-translatable input converged.
- Confirm the translation's own translatable inputs are unchanged.
- Insert a new component before existing ones (or reorder), publish, and confirm the translation's inputs are still associated with their own components.
-
composer run phpunit -- tests/src/Kernel/ApiAutoSaveControllerTranslationTest.php