Skip to content

fix(web): concurrent section form saves keep every edit and never type into someone else's text - #8027

Open
tlgimenes wants to merge 13 commits into
mainfrom
fix/site-editor-concurrent-form-saves
Open

tlgimenes wants to merge 13 commits into
mainfrom
fix/site-editor-concurrent-form-saves

Conversation

@tlgimenes

@tlgimenes tlgimenes commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. "AbortError: Transition was skipped" was logged as uncaught in the preview after quick successive saves: only the view transition's finished promise was handled, not ready or updateCallbackDone.

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 (fix(sections-editor): don't mistake a stale read of your own saves for someone else's change #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.displayed holds 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 fix(decofile): stop stale editor copies from reverting other drafts' block changes #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 fix(decofile): stop stale editor copies from reverting other drafts' block changes #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" and FormBase.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 tests form-conflict.test.ts (merge plan, choices, pointers, previews, word diff) and form-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 equals b20846afd, 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 fix(sections-editor): don't mistake a stale read of your own saves for someone else's change #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/web and packages/e2e typecheck (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-form 12/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). b20846afd fixes 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 test for 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's textContent instead 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 changed site-editor-stale-form e2e 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 with includeHidden).

Written for commit f27c612. Summary will update on new commits.

View guided diff Turn on auto-fix

tlgimenes and others added 3 commits October 16, 2026 09:41
…rtError

Quick successive saves skip the running view transition; its ready and
updateCallbackDone promises then reject with "Transition was skipped" and
only finished was handled. The swap now settles all three.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
…e can't drop the next edit

Under load the form's autosave could send a second save while a first one
was still on its way. When the first was refused as stale, the second was
built on a copy that never landed: mutate() reported only its outcome, its
starting point was put back to the unsaved first edit, and the replay on
the stored block then counted the user's own edit as a conflict and kept
the stored value. The user's edit was lost and the toast named the wrong
field (seen on a v7 site with two people on one block).

- A section form now has one save on the wire at a time; the next is built
  once that one lands or is refused (mutateAsync, so every outcome is
  heard). A retry after a refusal reads the form's value when it runs.
- The conflict toast names fields by their label in the form ("Headline",
  not "title"), says "The latest version was kept." when nothing else of
  the form's was left to save, and every outcome of one change shares one
  toast.
- With main's #8000, a refused save is retried with backoff (up to 3
  times) before the form reloads; while a retry waits out its backoff the
  form's next saves wait behind it, as behind a save on the wire, and the
  retry carries the form's latest value.

Tests: e2e "a form that saves again while its refused save is on the wire
keeps both edits" (fails before: the headline goes back to the stored one
with a "Changed while you were editing: headline" toast); the same-field
e2e now expects the label and the shorter copy; unit tests for the labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
Under load the form's autosave fires while the user is still typing. When
the save comes back merged and the merge kept another editor's value of
the field being typed in, the form showed that value at once, so the next
keystrokes landed after it ("Picks A again" + "holk" ...) and the mix was
saved as the user's edit, over the other editor's value. The reload after
the last retry re-seeded the form under the cursor the same way.

While focus is in the open form, those now wait: the form keeps showing
what the user types (its saves keep merging onto the stored section, so
the field keeps the stored value) and re-reads the stored section once
the user leaves the form and its own saves have settled. Merges that kept
every edit of the form still show at once.

Tests: e2e "typing on after a save kept the other editor's value does not
add the keystrokes to that value" (fails before: the field reads
"Published headline more"); the same-field e2e checks the stored value
after the user leaves the field; unit tests for mergedFormDisplay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
tlgimenes and others added 5 commits October 16, 2026 14:15
When a save kept another editor's value of the field the user is typing in,
the form keeps showing their text until they leave it (05d4ae5), but its
baseline stayed the refused copy. Their next keystrokes were merged against
that copy again, lost to the stored value with a second toast, and the field
went back to the stored value when they left it: the edit was gone.

The form's baseline is now the merge it saved, and FormBase.displayed holds
what the form shows instead. A field the user changes after the notice is
saved over the stored value they were told about; a field they leave alone
keeps the stored value; a change from elsewhere after that is still caught
by #7909's check. Nothing is ever a mix of both texts.

Tests: e2e "typing on after a save kept the other editor's value saves what
the user typed, never mixed with that value" (fails before: the stored
headline stays "Published headline"); unit tests for formValueOnBase and
displayedOverBase. The spec's toast and poll waits allow for main's backoff
retry (#8000) under load, and toasts are counted as they appear.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
…pt" saves it

After a save kept another editor's value of the field the user is in, the
field still shows the user's text (until they leave the form). A user who
insists, selecting it and typing the same text again, ended with the value
the form already showed, so nothing counted as changed: the save kept the
stored value and the field flipped to it when they left.

Every value the user changes after the merge is now marked in
FormBase.displayed, so it is theirs even when it ends as it was shown.
Values they don't touch still keep the stored value.

Tests: e2e "typing the same text again after the latest version was kept
saves it" (fails before: the stored headline stays "Published headline");
unit tests for markEditedSinceDisplayed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
"a form that saves again while its refused save is on the wire keeps both
edits" counted the toasts on screen after a poll of up to 30 s; on a busy
machine the toast had already expired (count 0). Toasts are now counted by
text as they appear, as in the typing-on test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
…keep

When the user and someone else changed the same values of a section, the
form no longer keeps the stored value with a toast and the "on-leave"
heuristics. Nothing of the form's is written: its saves wait, and one dialog
("Someone else changed this while you were editing") lists each value by
its form label, "Your version" next to "Their version" (a word diff for
text, a thumbnail for images, a summary for lists and objects), with "Keep
mine" / "Use theirs" per field and "Keep all mine" / "Use all theirs".
Apply saves the choice against the stored version the dialog showed (the
save's `expect`); a newer one refuses it and the same choice is asked again
with that version. "Decide later" keeps their version stored and the
user's edits in the form, unsaved, with a "Choose versions" notice. Values
only one side changed still merge silently, now item by item within lists.

Removed: the "Changed while you were editing" toasts, mergedFormDisplay
"on-leave", FormBase.displayed (typing on after the toast saving over
theirs). A save's page, page variant and section variant are fixed when it
is scheduled, so a choice made after the user moved on still writes there.

(Port of 731befa from the v8 stack, without the v8 parts.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
site-editor-stale-form: keep mine, decide later then use theirs, mixed
per-field choices, different fields merge with no dialog, a third change
while the dialog is open asks again with the newer version. The hero
fixture gains a Subtitle field.

(Port of c63ee03 from the v8 stack, without the deco-serve case.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
Comment thread apps/web/src/components/sections-editor/form-conflict.ts Fixed
Comment thread apps/web/src/components/sections-editor/form-conflict.ts Fixed
tlgimenes and others added 5 commits October 16, 2026 16:57
The Keep mine case checked the headline after typing into the open dialog
with the form's role locator. The dialog is modal, so everything outside it
is aria-hidden and the locator found nothing ("element(s) not found", the
form showing "Draft headline" behind the dialog in the screenshot). The
check now includes hidden elements.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
…, and when

GET /api/:org/decofile/:project/:branch/changed-by?block=<key> (1 to 5 keys,
signed-in members only) answers { changedBy: { [key]: { name?, email?,
avatarUrl?, at, you? } } } for the "someone else changed this" dialog. The
editor asks once per version the dialog shows, never per poll.

Nothing new is stored: the block file's latest commit on the draft branch
(insights listCommits with a new `path` filter, limit 1: one GitHub call per
block) gives the Co-authored-by trailer the commit coalescer appends, else
the commit author when it is a person, and the commit time. The identity is
mapped to the org member (name, avatar) by email, else the git name; `you`
when it is the viewer. Unknown parts are left out.

Insights commits now carry the message body (where the trailers are), and
insightsClientForProjectRepo opens a project's history client, as on the
Blocks v8 line (#7887).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
…nge, and when

Under the dialog's title: "Changed by Ana Souza · 3 minutes ago" with the
member's avatar and the exact time on hover; "You, in another tab or window
· …" for the viewer's own change; "Changed … ago" when only the time is
known. No line while it isn't known (never "unknown"). en and pt-BR.

A Fast Preview session asks Studio's history (GET …/changed-by) once per
version of theirs the dialog shows, never per poll: "Their version changed
again" reads who and when again. A sandbox session shows nothing. The
conflict remembers the block its save writes (a saved block, else the page).
formatRelativeTime comes from the Blocks v8 line (#7888).

e2e (written, not run here): stale-form asserts "You, in another tab or
window" for the same user's other save and "Changed by Dev Person" after a
developer's push while the dialog is open. The GitHub stub gains the
GraphQL commit history (as on #7888) with a path filter, commit dates and
authors, and an admin push; the API's GraphQL URL points at the stub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
This reverts commits 9dcf8fb and 46fe475. PO (final): "only have
metadata and changed by on v8 hosted": a v7 (Fast Preview) dialog shows no
"changed by" line. The dialog itself stays. Gone with it: the changed-by
route, commit trailer/author parsing, insights' path filter and message
body, insightsClientForProjectRepo and formatRelativeTime copied for it, the
GitHub stub's history and push, and the GraphQL URL in the e2e config.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
…ver regex sanitizing

CodeQL flagged the tag stripping and entity decoding (incomplete multi-character
sanitization, double unescaping). Text now comes from DOMParser's textContent;
without a DOM, tags are dropped until none is left and each entity is decoded once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu
@tlgimenes
tlgimenes marked this pull request as ready for review October 17, 2026 17:38

This branch has not been deployed

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

Labels

claude PR authored by a coding agent

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants