Skip to content

fix(admin): stop saving an outdated rich text body over newer content - #4418

Merged
khoinguyenpham04 merged 3 commits into
mainfrom
noah/silly-euclid-c11b7a
Oct 30, 2026
Merged

khoinguyenpham04 merged 3 commits into
mainfrom
noah/silly-euclid-c11b7a

Conversation

@khoinguyenpham04

@khoinguyenpham04 khoinguyenpham04 commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes the entry editor silently saving an outdated rich text body over newer content.

The rich text editor reads its value only when it mounts. When the edit page picked up a newer version of an entry while the form had no unsaved edits, the form took the new data and the newer _rev, but the body kept the old document:

  1. Open a post whose body is "Hello".
  2. Save "Hello world" somewhere else: another tab (the edit lease is per user), a plugin's ctx.content.update, or an API write.
  3. The first tab refetches. The title updates and the page adopts the newer _rev, but the body still shows "Hello".
  4. Type "!". Autosave sends "Hello!" with the newer _rev, the server accepts it, and "world" is gone without a conflict notice.

Discard changes had the same shape: the body kept the discarded text, and the next keystroke brought it back.

Changes:

  • ContentEditor remounts the field editors (the fieldsGeneration key that restore already uses) when it takes the item's data and a rich text value changed, including rich text inside blocks fields: on background reads, discards, and a save's reload that brings a different value (another writer's save landing between the save and its reload, or a content:beforeSave change).
  • After a save, the decision is per field. A rich text or blocks field with edits the save didn't send keeps its editor and its form value, so the form stays dirty and the next save sends them under the save's _rev. If the entry changed elsewhere in the meantime, that save gets the conflict notice instead of overwriting it.
  • The router marks a discard's reset (resetEditorItem(id, { discarded: true })), so a failed discard can't change how a later save's reset is handled.
  • patchAutosaveQueries cancels and re-reads a content read that is in flight when an autosave lands, as it already does for the revision lists. Otherwise a read sent before the autosave could land after it and reload the body with the older text.
  • An open image or gallery block panel closes when the editors remount, since it acts on the editor being replaced.

Found while reading the editor code. The new tests reproduce it on main.

Type of change

  • Bug fix
  • Feature (requires an accepted feature plan or RFC)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Your connection to this change

  • I use EmDash and ran into the problem this PR addresses
  • I maintain a tool, dependency, or service that EmDash uses, and this change concerns that integration
  • A Maintainer approved this specific change before I opened the PR

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): not applicable (no new strings)
  • I have added and reviewed the user-facing changeset (if this PR changes a published package)
  • New features link to their merged design PR: not applicable (bug fix)
  • I have included screenshots below if this PR changes the UI: not applicable (no visual change)

AI assistance disclosure

  • This PR includes AI-generated code or text — model/tool: Claude Opus 5.5

Screenshots / test output

Not applicable: no visual change.

New tests in editor-refetch.test.tsx (route level, real editor) and ContentEditor.test.tsx. On main:

× saves the next body edit on top of a body a background read brought in
  AssertionError: expected 'Draft body!' to be 'Body saved in another tab!'
× saves the next body edit on top of a newer body a save's reload brought in
  AssertionError: expected 'Draft body!' to be 'Body saved in another tab!'
× shows a newer body from a save's reload when the title changed during the save
  AssertionError: expected 'Draft body' to be 'Body saved in another tab'
× saves body text typed during a save under the save's revision
  AssertionError: expected 'Draft body saved' to be 'Draft body saved and more'
× shows the conflict notice when body text typed during a save meets a newer save
  Cannot find element with locator: getByRole('button', { name: 'Save anyway' })
× replaces unsaved body edits after a discard
  AssertionError: expected 'Draft body with unsaved words' to be 'Published body'
× keeps the editor the writer types in when a save's reload replaces another field
  AssertionError: expected 'Draft summary' to be 'Summary saved in another tab'
× saves the next block text edit on top of a block a background read brought in
  AssertionError: expected 'Draft callout!' to be 'Callout saved in another tab!'
× closes the block panel when a read replaces the body

"keeps the body's own autosave when an older read lands after it" passes on main and fails without the router change. With this change the admin suite passes (258 files, 3,751 tests).

The rich text editor reads its value only when it mounts. When the edit
page picked up a newer version of an entry while the form had no unsaved
edits, the form took the new data and the newer _rev, but the body kept
the old document, so the next keystroke autosaved it over the newer
version without a conflict. Discard changes had the same shape.

Remount the field editors when the form takes a changed Portable Text
value on a background read or a discard, and keep them on resets that
follow the writer's own save. Mark discard resets in the router, close an
open block panel on remount, and cancel and re-read a content read that
is in flight when an autosave lands, so a late older read can't bring
earlier text back.
@changeset-bot

changeset-bot Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e39a0e6

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

This PR includes changesets to release 12 packages
Name Type
@emdash-cms/admin Patch
emdash Patch
@emdash-cms/cloudflare Patch
@emdash-cms/plugin-test Patch
@emdash-cms/sandbox-workerd 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/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

@pkg-pr-new

pkg-pr-new Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-test

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-test@4418

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/registry-loader

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-loader@4418

@emdash-cms/registry-moderation

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

@emdash-cms/registry-verification

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

upgrade-emdash

npm i https://pkg.pr.new/emdash-cms/emdash/upgrade-emdash@4418

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-ai-search

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-ai-search@4418

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: e39a0e6

@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.

The approach is sound: reusing the existing fieldsGeneration remount key so the mount-initialized rich text editor picks up replacement content, and cancelling stale reads, fits the existing restore design. The two reviewers agree on this. They differ on whether it is complete. One found nothing to change, having checked the effect branches, the discard flow, the tests and the changeset. The other found one gap: a read after an explicit save is assumed to hold the writer's own body. If another writer's newer body arrives in that read, the editor can keep the older document while the route takes the newer _rev, and the next keystroke would overwrite it without a conflict. That finding is kept as needs_fixing. The first reviewer did not address this case and treated the own-save exemption as correct, because it keeps text typed while a save is in flight.


Reviewed by anthropic/claude-opus-5-5 and openai/gpt-6.1-sol.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview URL: https://noah-silly-euclid-c11b7a.try.emdashcms.com, https://noah-silly-euclid-c11b7a-emdash-playground.emdash-cms.workers.dev (commit e39a0e6)

This URL reflects your latest Preview deployment

Preview Deployments by commit

Status Deployment URL Commit Updated (UTC) See this deployment's details
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://b3241a80.try.emdashcms.com, https://b3241a80-emdash-playground.emdash-cms.workers.dev e39a0e6 2026-10-28T10:26:38.754Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://df4c1112.try.emdashcms.com, https://df4c1112-emdash-playground.emdash-cms.workers.dev ece6057 2026-10-26T15:55:33.615Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://e694de39.try.emdashcms.com, https://e694de39-emdash-playground.emdash-cms.workers.dev c055b8a 2026-10-26T15:36:09.307Z Visit the dashboard ↗

A save's reload can carry another writer's save that landed first, or a
content:beforeSave change. The editor kept its older body while the form
and the route took the newer version, so the next keystroke saved over
it. Keep the editor only while the form has edits newer than the last
save, which is what text typed during a save leaves behind.
@khoinguyenpham04 khoinguyenpham04 added the bot:review Trigger an emdashbot code review on this PR label Oct 26, 2026

@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.

The approach is sound. Remounting the field editors through the existing fieldsGeneration key fits a rich text editor that only reads its value on mount. Cancelling and re-reading stale content reads also fits the existing design. Both reviewers approve and raise no findings. The earlier finding is fixed: a save's reload now remounts the editor when the body changed and nothing was typed after the save. The editor is kept only when edits are newer than the save. A route-level test covers another writer's body arriving in a save's reload, and a second test covers typing during a save. I did not resurrect the earlier own-save exemption finding, because both reviewers confirm it is resolved at this head.


Reviewed by anthropic/claude-opus-5-5 and openai/gpt-6.1-sol.

@emdashbot emdashbot Bot added review/needs-review No maintainer or bot review yet and removed bot:review Trigger an emdashbot code review on this PR review/needs-review No maintainer or bot review yet labels Oct 26, 2026

@danielmlr danielmlr 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.

Thanks for this! A few cases still get through:

  1. I think the keep-or-replace call needs to happen per rich text field rather than for the whole form. At the moment the !isDirtyRef.current check keeps the body editor whenever any field is dirty. So if the title is edited while a save is in progress, and another tab changes the body in that window, the editor holds on to the old body while the form takes the newer one. The next keystroke in the body then saves the old text over it. Keeping the editor only when that field has edits the save didn't send, and replacing it otherwise, would close that gap.
  2. When an editor is kept, the form probably wants to keep its own value for that field rather than item.data. Right now the form gets the reloaded body and counts as saved, so the next keystroke goes out with the newer _rev. That can overwrite another tab's body without a conflict notice. With the form left dirty, the edit would go out with the old _rev instead. A save from another tab in between would then show the conflict notice, and otherwise the edit just goes through. It would also stop text typed during a save from showing as Saved.
  3. Rich text inside a blocks field has the same problem, though it isn't new with this PR. changesPortableText only looks at top-level portableText fields, so a block's rich text keeps its old text after a background read or a discard, and saves it back on the next keystroke. Comparing blocks values as well should be enough. I'm happy for this one to be a follow-up if you'd rather keep the PR focused.
  4. It'd be great to have route tests for these in editor-refetch.test.tsx, like the ones already there: a title edit during a save, typing in the body during a save, and a block's rich text after a background read.

@emdashbot emdashbot Bot added review/needs-review No maintainer or bot review yet and removed review/needs-review No maintainer or bot review yet labels Oct 28, 2026
@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels Oct 28, 2026
A save's reload kept every rich text editor whenever any field was
dirty, so a title edit during the save let another tab's newer body
slip past a stale editor. Rich text typed during the save showed as
saved, and the next keystroke went out under the newer _rev.

Decide per field: one with edits the save didn't send keeps its editor
and its form value, so the form stays dirty and a conflicting save gets
the conflict notice; any other takes the reloaded value and remounts on
its own. Treat blocks fields like rich text, since rich text inside a
block also reads its value once.
@github-actions github-actions Bot added review/needs-rereview New commits since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Oct 28, 2026

@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.

The approach remains sound: per-field remounting refreshes read-once editors without replacing an editor that holds newer local edits.

Reviewed the follow-up diff, save/reset and dirty-baseline handling, field keys, added route-level regressions, and updated changeset. The earlier save-reload finding remains addressed. The follow-up covers independent rich text fields, rich text inside blocks, edits made during a save, and the subsequent conflict path. I found no new issues to fix in the changed code.

Static review only; I did not run tests, builds, or lint.


Follow-up review by openai/gpt-6.1-sol, scoped to the changes since the last review.

@danielmlr danielmlr 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.

Thanks! One thing I think is worth fixing before you merge:

Saving after picking an image in a block remounts the whole blocks field, so collapsed blocks open up again. Main doesn't do that, and it's a pretty common flow. The server sends image values back with their keys in a different order, and changedReadOnceFields compares with JSON.stringify, so it sees a change that isn't there. I think comparing with equalJsonValues from PortableTextEditor.tsx, which ignores key order, would fix it. A route test for this would be great too.

@emdashbot emdashbot Bot added review/needs-rereview New commits since the last review and removed review/needs-rereview New commits since the last review labels Oct 28, 2026
@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-rereview New commits since the last review labels Oct 28, 2026
@khoinguyenpham04
khoinguyenpham04 merged commit 34fc9e0 into main Oct 30, 2026
44 checks passed
@khoinguyenpham04
khoinguyenpham04 deleted the noah/silly-euclid-c11b7a branch October 30, 2026 15:46
@emdashbot emdashbot Bot mentioned this pull request Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants