Skip to content

fix(admin): let the editor recover from a save conflict instead of resending a refused token - #2902

Open
danielmlr wants to merge 2 commits into
emdash-cms:mainfrom
danielmlr:fix/editor-conflict-recovery
Open

fix(admin): let the editor recover from a save conflict instead of resending a refused token#2902
danielmlr wants to merge 2 commits into
emdash-cms:mainfrom
danielmlr:fix/editor-conflict-recovery

Conversation

@danielmlr

@danielmlr danielmlr commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Restores saving in the content editor after someone else changes the same entry.
The first refused save used to leave the rejected revision token from #2860 in
place, so every later save and autosave resent it and the writer could not get
their work in at all.

The editor now takes the server's current token when a save comes back as a
conflict, leaves the form untouched, and shows a banner offering to save over
the newer version; the banner stays until a save succeeds. When the recovery
read fails the refused token stays, since dropping it would make the next save
a blind write, so the banner goes and the error is reported. Discarding clears
it too. Saves that do not conflict are unchanged.

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added and reviewed the user-facing changeset (if this PR changes a published package)
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/... (n/a: bug fix)
  • I have included screenshots below if this PR changes the UI

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Opus 5

Screenshots / test output

The notice in the content editor after another writer saved the same entry. What
the writer typed is still in the field, and the action offers to put it over the
newer version.

The content editor with a red conflict banner above the fields, reading "This entry changed somewhere else after you opened it" and offering a Save anyway button. The title field still holds the text the writer typed.

The same notice in the Arabic locale. The banner and its action follow the
right-to-left direction without any change to the markup.

The same conflict banner in the Arabic locale, mirrored right to left with the Save anyway button on the left of the banner.

…sending a refused token

The first refused save left the rejected revision token from emdash-cms#2860 in place, so
every later save and autosave resent it and the writer could not get their work
in at all.

The editor now takes the server's current token when a save comes back as a
conflict, and suspends autosave for that entry until the writer decides.
Without the suspension the queued autosave writes over the other version as
soon as the token is refreshed, which is the silent overwrite the token was
added to prevent.

When the recovery read fails there is no newer version to offer, so the notice
goes and the save error is reported rather than swallowed. Discarding the
draft takes the notice down as well.
@changeset-bot

changeset-bot Bot commented Sep 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: de610f7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 17 packages
Name Type
@emdash-cms/admin Patch
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
create-emdash Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/release-service Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions Bot added review/needs-review No maintainer or bot review yet area/admin size/L labels Sep 4, 2026
@pkg-pr-new

pkg-pr-new Bot commented Sep 4, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@2902

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@2902

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@2902

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@2902

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@2902

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@2902

emdash

npm i https://pkg.pr.new/emdash@2902

create-emdash

npm i https://pkg.pr.new/create-emdash@2902

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@2902

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@2902

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@2902

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@2902

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@2902

@emdash-cms/registry-moderation

npm i https://pkg.pr.new/@emdash-cms/registry-moderation@2902

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@2902

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@2902

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@2902

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@2902

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@2902

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@2902

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@2902

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@2902

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@2902

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@2902

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@2902

commit: de610f7

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approach judgment: This is the right fix for the right problem. Letting the editor detect a 409 CONFLICT, fetch the server’s current _rev, pause autosave, and offer an explicit “save over” action matches EmDash’s opt-in concurrency model and prevents the rejected token from being retried blindly. The changeset is user-facing and specific, the UI strings are Lingui-wrapped, and the new RTL-safe Kumo Banner avoids directional markup.

What I checked: I read the diff and the full changed files, traced the _rev flow from fetchContent/updateContent through revisionTokensRef, verified the server returns CONFLICT for stale revision checks, reviewed the test file against the existing browser-test utilities, and checked the changeset against .changeset/README.md.

Headline conclusion: the implementation is sound and the tests cover the main regression paths (autosave pauses, token updates after a successful recovery read, failure when the read fails, and discarding the draft clears the notice). There is one real bug in the conflict banner action: it clears the notice before it knows the save can actually start, which can leave the editor without the conflict affordance when submitSave is blocked.

Comment on lines +927 to +930
onClick={() => {
onDismissSaveConflict?.();
submitSave();
}}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[needs fixing] The Save anyway action dismisses the conflict banner before submitSave() has actually attempted the save. submitSave() can return early when another save is in flight, a URL field is invalid, or an unsupported Portable Text mark is present, so the banner can disappear while the stale token and conflict remain.

Move the save attempt before dismissing; the parent mutations already clear conflictedEntryId on success, so the banner will still go away once the save is confirmed.

Suggested change
onClick={() => {
onDismissSaveConflict?.();
submitSave();
}}
onClick={() => {
submitSave();
onDismissSaveConflict?.();
}}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken, and the diagnosis is right, but the suggested patch does not change the
behaviour it describes. Reordering the two calls still runs
onDismissSaveConflict?.() unconditionally, so an early return from
submitSave() still leaves the writer without the banner.

There is also a worse consequence than a lost affordance. A banner still on
screen means recoverFromSaveConflict has already swapped the entry's token for
the server's current _rev; a failed read clears the notice instead. Clearing
hasSaveConflict lifts the autosave guard, and nothing raises it back. Not at
once, since the conditions that made submitSave return early guard autosave
too, but the first autosave after one of them clears writes against a token the
server now accepts, and the other version is gone without anyone choosing it.
That is what the suspension was added for.

The comment's prose has the fix its patch does not: both content mutations
already clear conflictedEntryId in onSuccess. So the action only saves now.
The notice goes when a save is confirmed and stays whenever one is not, whether
submitSave returned early, the request failed, or the retry hit a second
conflict. That left onDismissSaveConflict without a caller, so it is gone too.

This is a separate commit, with a test that fails on the old handler: it puts
an invalid URL in a field, clicks Save anyway, and asserts the banner is still
there and no second PUT went out.

The Save anyway action cleared the notice and then called submitSave, which
returns early when a save is in flight, a URL field is invalid, or an
unsupported Portable Text mark is present. On that path the writer lost the
banner without a save being attempted.

Worse than losing the affordance: the recovery read has already refreshed the
revision token, so clearing hasSaveConflict also lifts the autosave block. The
next keystroke autosaves against the fresh token and succeeds, overwriting the
other writer silently, which is what the suspension exists to prevent.

The action now only saves. Both content mutations already clear
conflictedEntryId in onSuccess, so the notice goes when the save is confirmed
and stays whenever it is not. That leaves onDismissSaveConflict without a
caller, so it is removed.
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/needs-review No maintainer or bot review yet labels Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/admin review/needs-rereview Author pushed changes since the last review size/L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant