Problem/Motivation

An instance keeps one revision history for all its translations. InstanceStorage::getAllRevisionsQuery() filters by instance ID only, and ::undo() / ::redo() switch the default revision, which carries every language at once.

Steps to reproduce

  1. Edit a text in German, then edit a text in English.
  2. Back in German, click undo.

Result: the English edit is reverted, the German one is kept. The history list also mixes revisions of every language.

Proposed resolution

  • A step of a language is a revision that changed it, as core's revision_translation_affected flag says. A text edit is a step of its language, a structural edit a step of every language.
  • Undo and redo add a revision that sets the current language back or forward one step, and store the step they went to in a new translatable field, history_step. The other languages keep their values.
  • Undoing a structural change still affects every language: structure is shared by symmetric translation.
  • getPast(), getFuture() and a new getPresent() list the steps of the current language. Undo and redo return the current translation.
  • An update hook installs the field. Kernel tests reproduce the steps above.
CommentFileSizeAuthor
#10 translation-affected.png36.39 KBpdureau
Command icon Show commands

Start within a Git clone of the project using the version control instructions.

Or, if you do not have SSH keys set up on git.drupalcode.org:

Comments

mogtofu33 created an issue. See original summary.

mogtofu33’s picture

Assigned: Unassigned » pdureau
Issue summary: View changes
Status: Active » Needs review
Issue tags: +display_builder-1.0.0-beta9, +AI-accelerated

Result

Implemented on the issue branch 3625017-undo-redo-lanquage, for review.

The proposed resolution was incomplete. Undo "as a new revision" leaves no revision above the default one, so redo has nothing to go forward to. It needed a small model, and a new field.

The model

  • A step of a language is a revision where core sets revision_translation_affected for that language. A text edit is a step of its own language. A structural edit is a step of every language, since the structure is shared.
  • Undo and redo add a revision. It sets the sources of the language being edited to the ones of the target step, and leaves the other languages as they are. It stores the revision ID of that step in a new translatable, revisionable field, history_step.
  • An edit clears history_step on every translation it changes, in InstanceStorage::doPreSave().
  • Past and future are read by walking back from the last edit of the language. A step that was undone, then left by a new edit, drops out, as in any editor.

Changes

  • New base field history_step on the instance, installed by display_builder_update_11107(). It changes stored data, so it belongs in beta9, see #3624068: Remove the beta update hooks at RC1, with 1.0.0-beta10 as the required stepping stone.
  • Undo and redo no longer move the default revision. The revision they add is logged "Undo: ..." or "Redo: ...", with the current user as author.
  • Undo and redo return the translation they were given. They returned the default one, so a translated builder rendered the default language after an undo.
  • New HistoryInterface::getPresent(): the step the language shows. The History log uses it for its present row, which showed the last revision whatever language made it.
  • A revision that changes nothing is saved, but is no step. Only setNewPresent() with the hash check disabled, on identical data, does that. No production caller does.
  • The history cap of 20 counts the revisions of every language together, undo and redo included.
  • An instance left undone by the previous code keeps revisions above its default one. They are ignored, and dropped at its next save.

Undo in a translation

The synchronized sources field makes the default language the authority on structure: a translation reads the default structure, with its own texts on top. A structural edit made in the default language leaves the stored rows of the translation as they were. So when German undoes a node added in English, the field cascades nothing: the stored German rows did not change.

InstanceStorage handles it. For an undo in a translation, it gives the default language the structure of the target step with its own texts, through SourceValueList::buildTranslationsMap() and SourceTree::applyTranslations(). That mirrors the private SourceValueList::cascadeStructureToDefault() of UI Patterns. A public method for it in UI Patterns would replace those lines.

Tests

  • 10 kernel tests in InstanceHistoryTranslationTest: the steps to reproduce of the summary, undo and redo over several steps of one language while the other keeps its edits, an edit after an undo dropping the future of its language only, a structural undo from each language, the present step, and the History log.
  • 1 kernel test for the update: the field is installed as the entity type defines it, and an instance goes back a step afterwards.
  • The kernel and unit suites pass, and so do the history, toolbar, publish and both multilingual Playwright specs.

For review

  1. Is this the right model? The alternative was "a step belongs to the language it was made in". I dropped it: undoing a text edit could then silently revert a structural change another language made in between.
  2. Undo in German of a component added in English removes it in English too, as the summary proposes. English then has an "Undo: ..." step, which it can undo. Is that acceptable?
  3. A text edit in the default language is a step for a translation that has no text of its own for that node, since what the translation shows changed. Is that acceptable?
  4. Log wording: "Undo: @step" and "Redo: @step"?
  5. Publish still writes every language at once. Should it follow, one language at a time? A separate issue if so.
pdureau’s picture

Assigned: pdureau » mogtofu33

Code review

OK, I guess. There are some surprising stuff, especially the history_step integer base field in Instance entity, but I have nothing else to propose. Just smelling something I hope we will not regret later.

Feature review

  1. Tested with 2 languages (EN & DE) on a Page Layout.
  2. 10:44: I add a textfield in EN with "English" value. A step is added to history.
  3. 10:45: I translate this textfield in DE with "Deutsch" value. A step is added to history.
  4. I go back to English: I don't see the 10:45 step in history
  5. 10:46: I update Englsih value with "EN 2". A step is added to history.
  6. I go back to German: I don't see the 10:46 step in history
  7. I undo, it adds a "I go back to English: I don't see the 10:45 step in history" step in 10:48

This "undo" step, works as described in comment #4, but is very surprising and is subverting the concept of undo/redo.

Maybe it is the cost to pay to have something working with tarnslations, but I have noticed than we have the same "undo" step when doing undo in a non translated display.

Is this the right model? The alternative was "a step belongs to the language it was made in". I dropped it: undoing a text edit could then silently revert a structural change another language made in between.

I don't know yet, I am still confused about such a choice. I may need time to process.

Jean, are you the one asking the "For review" questions or is it Claude? If it is the latter, what would be your answers/positions?

mogtofu33’s picture

Assigned: mogtofu33 » pdureau

Thanks!

The "Undo" step in the log was a bug at the history cap, now fixed. Undo and redo never show in the log, translated display or not. The cap is now 20 steps per language. Retest on a fresh page layout: one already hit by the bug may lose its older steps.

On history_step: core reverts a translation the same way, with a new revision that merges that language from the old revision with the others from the default one, see ContentEntityStorageBase::createRevision(). Core has no redo though. Redo needs to know which step an undo revision stands for, per language, and that is the field. It has no getter, so it can change later without breaking contrib.

Displays without translation share the code path on purpose: a display can get its first translation anytime. Nothing differs for the editor.

The model, by example: German edits a text, English adds a component, German clicks undo. Here undo removes the component in both languages, then a second undo reverts the German text. With "a step belongs to the language it was made in", that undo would revert the German text by loading a revision older than the component, so the component would silently disappear in both languages.

Claude wrote the questions. My answers:

  1. Yes.
  2. Yes, the structure is shared.
  3. Yes, what German shows changed.
  4. Yes.
  5. No, a follow-up if someone needs it.
pdureau’s picture

Review in progress...

Check the different revisions logs logic (as visible in the island dropdown):

For default language For other languages
Non translatable operation 🌞 Visible 🌚 Not visible
Translatable operation in default language 🌞 Visible To test
Translatable operation in current language To test To test
Translatable operation in other language To test To test

Careful with publishing, it seems it changes what is shown.

There are still "undo" steps...

Is this review the opportunity to restore the "Language" col which has been removed from #3555110: Symmetric translation?

pdureau’s picture

Discussed with Jean during last weekly: if the strange current beahviour is the "normal" Druapl behaviour when an entity is both revisionable and translatable, let's keep like that for now and postpone this ticket post 1.0.0.

If we introduced this strange behaviour, let's address it ASAP.

pdureau’s picture

Status: Needs review » Needs work

Opening a new branch to test the "normal" Drupal Core behavior for translatable revisionable entities.

pdureau’s picture

StatusFileSize
new36.39 KB

In Drupal 11, revisions belong to the entity, not to individual translations. There’s no separate revision ID per language. All translations share one revision history. So, saving any translation with “Create new revision” enabled creates a new revision for the whole entity.

However, each translation row in a revision has a revision_translation_affected flag. It’s TRUE only for the languages whose values actually changed in that save, and this is introducing interesting stuff in Revision UI:

  • The Revisions tab uses this flag to filter the list per language. When you view revisions for the French translation, you only see revisions where French was affected, even though every revision technically contains French
  • The revision UI has a “Revert translation” action. A checkbox, “Revert content shared among translations”, controls whether untranslatable fields are reverted too. It’s only available on the default translation, or when untranslatable fields are editable there.

Let's check how it works and how we can leverage this.

Tested with both:

Taxonomy term, Tag vocabulary

  • Translatable fields: "Name" and "Changed"
  • Non translatable fields: "Description", "Published" and "URL Alias"

I have also hooked VersionHistoryController to show if a translation is the only one "affected" or not:

It is a temporary generic alteration for now, but we may need than in Display Builder Revision Log.

Step /taxonomy/term/1/revisions /fr/taxonomy/term/1/revisions
I create the entity
  • 19 Oct 2026 - 09:29 by Anonymous (not verified) Current revision
We are not supposed to access the page yet: InvalidArgumentException: Invalid translation language (fr) specified.
I translate the entity
  • 19 Oct 2026 - 09:29 by Anonymous (not verified)
  • 19 Oct 2026 - 09:29 by Anonymous (not verified): Weird the date stays the one from original language
I create a new revision from original language changing only a translatable field
  • 19 Oct 2026 - 09:34 by admin English affected
  • 19 Oct 2026 - 09:29 by Anonymous (not verified)
  • 19 Oct 2026 - 09:29 by Anonymous (not verified)
I create a new revision from original language changing a non translatable field
  • 19 Oct 2026 - 09:37 by admin
  • 19 Oct 2026 - 09:34 by admin English affected
  • 19 Oct 2026 - 09:29 by Anonymous (not verified)
  • 19 Oct 2026 - 09:37 by admin
  • 19 Oct 2026 - 09:29 by Anonymous (not verified)
I create a new revision from translation, changing a translatable field
  • 19 Oct 2026 - 09:37 by admin
  • 19 Oct 2026 - 09:34 by admin English affected
  • 19 Oct 2026 - 09:29 by Anonymous (not verified)
  • 19 Oct 2026 - 09:39 by admin French affected
  • 19 Oct 2026 - 09:37 by admin
  • 19 Oct 2026 - 09:29 by Anonymous (not verified)

Let's check revert now:

Step /taxonomy/term/1/revisions /fr/taxonomy/term/1/revisions
I revert from a French affected revision to a "normal" one There is no default revision anymore:
  • 19 Oct 2026 - 09:37 by admin
  • 19 Oct 2026 - 09:34 by admin English affected
  • 19 Oct 2026 - 09:29 by Anonymous (not verified)
It adds a revision (default behavior outside of "Content Moderation" module), but it is weird this revision is also "Affected":
  • 19 Oct 2026 - 10:27 by admin. Copy of the revision from Mon, 19 Oct 2026 - 09:37. French affected
  • 19 Oct 2026 - 09:39 by admin French affected
  • 19 Oct 2026 - 09:37 by admin
  • 19 Oct 2026 - 09:29 by Anonymous (not verified)

So, Core revisions are weird, indeed:

  • Changed field is not updated while being a translatable field
  • Some revisions made by authenticated user are attributed to Anonymous
  • Reverting to a non affected revision create an affected revision

But there are interesting stuff to explore.

Display Builder instance

Adding this to check directly in Drupal UI (without our specific stuff):

links: [
  'version-history' => '/admin/structure/display-builder/instances/{display_builder_instance}/revisions',
],

Our revisions management is very different from Core:

Core Current Display Builder
UI Revision UI shows only revisions of current language HistoryButton shows revisions of all languages
Revert from affected to non affected Create new affected revision ✅ Normal revision switch
Revert from affected to affected, same language Create new affected revision ✅ Normal revision switch
Revert from affected to affected, different language Impossible, not available in UI ⚠️ Possible, but target revision become non affected
Revert from non affected to non affected Create new affected revision ✅ Normal revision switch
Revert from non affected to affected Create new affected revision ⚠️ Target revision become non affected

pdureau’s picture

Assigned: pdureau » mogtofu33
Status: Needs work » Needs review

Here is the proposal : https://git.drupalcode.org/project/display_builder/-/merge_requests/488

Finally, I am proposing to assume we have 2 big differences with Drupal Core revision management (while being closer to the Core Content Moderation" module):

  • because of the storage strategy: undo/redo revert without creating new revisions
  • because of the UX (undo/redo buttons instead of a "revert" button on each revision row): we can revert from revision of any language to revision of any language

Assuming this, we just need 2 changes to fix our revisions management:

  • fix a bug where a target revision become non affected
  • make this obvious in UI by adding a "Language" col in HistoryButton

If you are OK with the current MR to review, I can add an extra commit to display the Language col only when there are translations, in order to not make the UI too busy.

pdureau’s picture

beta9 has been released. Changing the tag.

pdureau’s picture

Assigned: mogtofu33 » Unassigned

Rebased. Open to anybody to review.

csakiistvan’s picture

Assigned: Unassigned » csakiistvan
csakiistvan’s picture

Assigned: csakiistvan » Unassigned
Status: Needs review » Reviewed & tested by the community

✅ Tested and works — applied MR !488, rebuilt caches, then on a fresh page layout edited a Textfield in EN, FR, EN again and clicked undo from /fr/: the history now shows a Language column, and the FR-only step keeps revision_translation_affected NULL for EN after undo, where without the fix it flipped to 1. The flag bug is fixed; undo from FR still reverts the EN edit, as proposed with MR !488.

Prerequisites
  • Languages en + fr, content_translation and config_translation enabled; a new page layout duplicated from test_empty.
  • Flags checked with SELECT revision, langcode, revision_translation_affected FROM display_builder_instance_field_revision.
Actual results
Step 1.0.x MR !488
Edit the text in FR (flags of that revision) fr = 1, en = NULL fr = 1, en = NULL
Edit the text in EN, then open the history in EN ⚠️ FR step listed, nothing tells it apart ✅ Language column shows French / English
Undo from /fr/ (flags of the FR revision, now default) ❌ en flipped to 1 ✅ en kept NULL
Undo from /fr/ (texts) ⚠️ EN edit reverted, FR kept ⚠️ EN edit reverted, FR kept (unchanged by design)
Notes
  • The issue summary still proposes per-language undo with a history_step field; MR !488 takes the narrower route from its proposal comment, so the IS needs an update before commit.
Code review
  • MR !488 at 3579098, pipeline green; testUndoAndRedoKeepTranslationAffectedFlags() covers the flag regression.
pdureau’s picture

Assigned: Unassigned » mogtofu33

The issue summary still proposes per-language undo with a history_step field; MR !488 takes the narrower route from its proposal comment, so the IS needs an update before commit.

Indeed. The proposal diverges from initial issue summary in purpose. Not having history_step is a benefit. Flowing naturally in the revision log is an other.

pdureau changed the visibility of the branch 3625017-undo-redo-lanquage to hidden.