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:

  1. Publish path: getConflictAwareContentEntityViolations() validates a synchronized clone, converged via the (optionally injected) content_translation.synchronizer — the same service Canvas already decorates with ComponentTreeFieldSymmetricalTranslationSynchronizer. The entity that gets saved is deliberately left untouched, so it is synchronized exactly once — by content_translation's presave hook during save(). Synchronizing the to-be-saved entity in place instead would make presave a second pass, and FieldTranslationSynchronizer::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 by testPublishingDeltaShiftingStructuralEditPreservesTranslations()).
  2. Edit path: the real editor reaches auto-save through ClientDataToEntityConverter::convert(), whose entity-form validation persisted the same symmetry violation to the canvas.form_violations store; ::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(): en page + es translation share a component; the non-translatable element (h1 → h2) changes on the default translation only, then is auto-saved and published. Asserts HTTP 200 (previously 422) and that the es copy converged while its translatable text/label are 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 the es translation via the es-prefixed layout endpoint; asserts the violation IS stored and the draft keeps the attempted value instead of being silently converged.

Testing instructions

  • Enable content_translation on canvas_page in symmetric mode (components field: tree synced, inputs translatable).
  • 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
Edited by Ted Bowman

Merge request reports

Loading