fix(web): concurrent section form saves keep every edit and never type into someone else's text
Fixes for the site editor's section form when two people edit the same block (#7909's stale-save protection), found while testing a v7 site with Fast Preview under load. #7909's guarantee is unchanged: a save built on an outdated copy is still refused by the server. Where both people changed the same value, the user now chooses whose version to keep (see below).
What was wrong
- An edit could be lost after a refused save. Under load the form's autosave could send a second save while the first was still on its way.
mutate()only reports the outcome of the latest call, so when the first save was refused, the form's starting point went back to the unsaved first edit. The replay on the stored block then counted the user's own edit as someone else's change and kept the stored value. The user's edit was lost, and the toast named the wrong field. - Keystrokes were appended to the other person's text. When a merge kept the other editor's value of the field being typed in, the form showed it at once, so the next keystrokes landed after it ("Picks A again" + "holk") and that mix was saved as the user's edit. The reload after the last retry did the same.
- The toasts. The conflict toast named fields by their code name ("title") instead of the form's label ("Headline"), said "the rest of your changes were saved" even when nothing else had changed, and one action could show the same toast twice.
- What the user typed after "The latest version was kept" was lost. The field still showed their text, but the form's baseline stayed the refused copy: their next keystrokes (or the same text typed again) were merged against it again and dropped with a second toast, and the field flipped to the other editor's value when they left it.
- "AbortError: Transition was skipped" was logged as uncaught in the preview after quick successive saves: only the view transition's
finishedpromise was handled, notreadyorupdateCallbackDone.
What changed
- A section form sends one save at a time. The next one is built once the one on the wire has landed or been refused (
mutateAsync, so every outcome is heard). A retry after a refusal reads the form's value when it runs, and while it waits out its backoff (#8000), the form's next saves wait behind it. - While focus is in the open form, a merge that kept another editor's value of a field the user changed doesn't replace that field under the cursor. The form keeps showing what they type and re-reads the stored section once they leave the form. Merges that kept every edit still show at once.
- Meanwhile the form's baseline is the merge it saved, and
FormBase.displayedholds what it shows instead. A value the user changes after the notice (even typed back to the same text) is saved over the stored value they were told about; a value they don't touch keeps the stored value; a change from elsewhere after that is still refused by #7909's check. Nothing is ever a mix of both texts. - The toast uses the form's field labels, says "The latest version was kept." when nothing else of the user's was left to save, and every outcome of one change shares one toast.
- The preview's view transition settles all three promises.
Same-field conflicts: the user chooses (PO decision 2026-10-16)
PO: "instead of a toast, add a dialog with accept mine vs theirs and maybe show the diff? … Instead of using these heuristics, let the user decide". This replaces the same-field toast and the heuristics that served it.
-
Values only one side changed still merge silently (with #7909's "This block was updated while you were editing" toast), now item by item inside lists, so two people editing different fields of the same slide no longer clash.
-
Values both sides changed to different things: nothing of the form's is written. Its saves for that section wait, and one dialog opens (never a toast, never two dialogs: a second waiting choice opens after the first is settled):
┌ Someone else changed this while you were editing ─────────────── x ┐ │ You both changed the fields below. Choose which version to keep │ │ for each one. Their version stays saved until you apply your │ │ choice. │ │ [Keep all mine] [Use all theirs] │ │ Headline │ │ ┌ (o) Your version ──────────┐ ┌ ( ) Their version ─────────────┐ │ │ │ [Summer] sale on all shoes │ │ [Winter] sale on all shoes │ │ │ └────────────────────────────┘ └────────────────────────────────┘ │ │ Slides › 1 › Image │ │ ┌ ( ) Your version ──────────┐ ┌ ( ) Their version ─────────────┐ │ │ │ [thumbnail] │ │ [thumbnail] │ │ │ └────────────────────────────┘ └────────────────────────────────┘ │ │ Choose a version for each field to apply. [Decide later] [Apply] │ └────────────────────────────────────────────────────────────────────┘Fields are named by their form labels. Text shows a word-level diff (the words the other version lacks are highlighted, rich text without tags); images show thumbnails; lists show their size and first item titles; small objects show their fields; Yes/No for booleans. Who or when isn't shown on v7 (PO decision, see the next section).
-
Apply writes one save of the chosen values over their version, guarded by that version (Fast Preview
expect= its fingerprint), and the form shows the result. If yet another change landed meanwhile, the save is refused and the same dialog comes back with the newer version ("Their version changed again while this was open."), keeping the choices whose two versions didn't change. It never retries silently. -
Closing without choosing ("Decide later", Escape, x): their version stays stored; the user's edits stay in the form, unsaved, with a calm notice above the editor ("Your changes to this section aren't saved yet: someone else changed it too." + "Choose versions"). Anything they type meanwhile joins their version and waits too. If they move to another section or page, the choice keeps its place: "Choose versions" still saves to that section. This is the simplest behaviour that never drops an edit and never writes over theirs without a choice.
-
Typing while the dialog is open: it is modal (focus is trapped), so nothing reaches the form behind it.
-
Removed: the "Changed while you were editing … The latest version was kept" toasts,
mergedFormDisplay"on-leave" andFormBase.displayed/markEditedSinceDisplayed/formValueOnBase(typing on after the toast saved over theirs). Kept: one save at a time per form, the retry backoff, and the on-leave reload for a section replaced or a content sync while typing (not same-field specific). -
A form save's page, page variant and section variant are now fixed when it is scheduled (
FormSaveTarget), so a retry or a later choice writes to the section it was made for. -
Copy in en and pt-BR (
sectionsEditor.formConflict.*); unit testsform-conflict.test.ts(merge plan, choices, pointers, previews, word diff) andform-conflict-dialog.test.tsx(labels, diff marks, images, lists, per-field / all choices, Apply gating, Decide later, the notice).
Items 2 and 4 above (the on-leave display, typing on after the toast) are superseded by this dialog.
Who made the other change: not on v7 (PO 2026-10-16)
PO (final): "only have metadata and changed by on v8 hosted". The dialog on main shows no "changed by" line. 12633c0d8 reverts 46fe47593 and 9dcf8fb74: the changed-by route, commit-history reading, the copied history client and relative-time helper, and the e2e stub and config changes. This branch's tree equals b20846afd again.
Tests
12633c0d8(2026-10-16) reverts the who/when commits; the tree equalsb20846afd, whose checks are below.- e2e
site-editor-stale-form: "a form that saves again while its refused save is on the wire keeps both edits" "typing on after a save kept the other editor's value saves what the user typed, never mixed with that value" and "typing the same text again after the latest version was kept saves it" are new; "an edit to a value someone else also changed…" now checks the label, the shorter copy and the stored value after the user leaves the field. On a production build: 11/11 with the fixes (twice, 4 workers); without them the new tests fail (the stored headline stays "Published headline"). Toasts are counted as they appear and the waits allow for the #8000 backoff, so a loaded machine doesn't fail them. - Unit tests: field labels (
conflict-fields.test.ts),mergedFormDisplay/focusInside/formValueOnBase/markEditedSinceDisplayed(form-base.test.ts), the view transition (cms-editor-script.test.ts). - Same-field dialog (
d97094779,d6bd6b995,b20846afd):apps/webandpackages/e2etypecheck (tsc, single-threaded), oxlint and biome are clean; sections-editor + i18n unit tests 933 pass. e2e on a production web build (4 workers, clean database per run):site-editor-stale-form12/12 twice (keep mine, decide later then use all theirs, mixed choices, different fields with no dialog, a third change while the dialog is open).b20846afdfixes the Keep mine case's check of the form behind the modal dialog (a modal dialog hides the rest of the page from role locators; it failed on the v8 branch's first run). - Before the dialog:
tsc --noEmit(apps/web), oxlint and biome are clean;bun testfor sections-editor and the preview: 912 pass (sections-editor).
The same fixes are on the Blocks v8 stack (#7888); this PR brings them to v7 sites without waiting for it.
🤖 Generated with Claude Code
https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
Summary by cubic
Fixes the site editor's section form so two people editing the same block never lose an edit or type into someone else's text. #7909's stale-save guarantee is unchanged: a save built on an outdated copy is still refused.
Bug Fixes
- A form sends one save at a time; the next is built once the one on the wire lands or is refused, and a retry reads the form's latest value.
- Conflict toasts use the form's field labels, say "The latest version was kept." when nothing else was left to save, and one change shows one toast.
- Rich text in the conflict dialog is read with
DOMParser'stextContentinstead of regex tag stripping and entity decoding (CodeQL: incomplete multi-character sanitization, double unescaping). Without a DOM, tags are dropped until none is left and each entity is decoded once. - The preview's view transition settles all three promises, so rapid saves no longer log an uncaught "AbortError: Transition was skipped".
Same-field conflicts: the user chooses (PO decision 2026-10-16)
- When the user and someone else changed the same value to different things, nothing of the form's is written and one modal dialog asks, value by value, whose version to keep (word diff for text, thumbnail for images, summaries for lists and objects), with per-field and "Keep all mine" / "Use all theirs" choices.
- Apply saves the chosen values guarded by the version the dialog showed; a newer change refuses it and re-asks with that version, keeping the choices whose two versions didn't change. "Decide later", Escape, or the close button keeps their version stored and the user's edits in the form, with a "Choose versions" notice.
- Values only one side changed still merge silently, now item by item inside lists, so two people editing different fields of the same slide no longer clash.
- A save's page, page variant and section variant are fixed when scheduled, so a choice made after the user moved on still writes to its section.
- Copy is in en and pt-BR (
sectionsEditor.formConflict.*); the new and changedsite-editor-stale-forme2e cases cover keeping mine, deciding later then using theirs, mixed choices, different fields with no dialog, and a third change while the dialog is open (the keep-mine case finds the form behind the modal dialog withincludeHidden).