Skip to content

fix(admin): ratchet content write revision forward on clean refetches - #3831

Open
emdashbot[bot] wants to merge 1 commit into
mainfrom
bot/fix-3829
Open

emdashbot[bot] wants to merge 1 commit into
mainfrom
bot/fix-3829

Conversation

@emdashbot

@emdashbot emdashbot Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes #3829.

ContentEditPage stored _rev only on the initial load and kept the cached older token after TanStack Query refetched a newer clean entry. SEO and other auxiliary saves then sent the stale token, producing 409 CONFLICT.

Fix

  • ContentEditor now exposes onDirtyChange and reports its dirty state to the page.
  • ContentEditPage decodes _rev (format base64("version:updatedAt")) and ratchets the stored write token forward only while the editor is clean and the fetched revision is newer. This preserves genuine conflict protection for unsaved edits and prevents a delayed old GET from overwriting a token from a newer successful write.

Tests

Added ContentEditPage – revision token synchronization coverage in packages/admin/tests/router.test.tsx:

  1. Adopts a newer revision after a clean refetch and sends it on SEO save.
  2. Keeps the base revision while the editor is dirty.
  3. Does not downgrade to a stale revision after a successful write.

Verification

  • tests/router.test.tsx + tests/editor-save-conflict.test.tsx: 44 passed.
  • pnpm run typecheck (admin): passed.
  • pnpm build (root): passed.
  • pnpm format: applied.
  • pnpm lint:quick: zero diagnostics.
  • @emdash-cms/admin changeset added.

Closes #3829.

Try the candidate against your own site with the preview build:

npm i https://pkg.pr.new/emdash@bot/fix-3829

Opened automatically by emdashbot. A maintainer must review before merge.

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/...
  • I have included screenshots below if this PR changes the UI

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: emdashbot + Kimi K2.7 Code

Screenshots / test output

@changeset-bot

changeset-bot Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: fe55832

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

@cloudflare-workers-and-pages

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

Copy link
Copy Markdown

🚀 Deploying Preview to Cloudflare 🚀

Preview URL: https://bot-fix-3829.try.emdashcms.com, https://bot-fix-3829-emdash-playground.emdash-cms.workers.dev (commit fe55832)

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://e40e6799.try.emdashcms.com, https://e40e6799-emdash-playground.emdash-cms.workers.dev fe55832 2026-10-04T20:09:16.983Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://de4aa319.try.emdashcms.com, https://de4aa319-emdash-playground.emdash-cms.workers.dev 3040550 2026-10-04T19:25:25.952Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://d15920b5.try.emdashcms.com, https://d15920b5-emdash-playground.emdash-cms.workers.dev 423890f 2026-10-04T18:39:39.549Z Visit the dashboard ↗
  • Build: Success ✅
  • Deployment: Success ✅

View logs ↗
https://289b9087.try.emdashcms.com, https://289b9087-emdash-playground.emdash-cms.workers.dev 2e1f231 2026-10-03T20:02:55.649Z Visit the dashboard ↗

@emdashbot emdashbot Bot left a comment

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.

The PR fixes the reported stale-revision downgrade by ratcheting _rev tokens by decoded version and suppressing adoption while the editor is dirty. The core logic is sound and well tested for the happy-path and explicit dirty/clean cases. I found two issues worth addressing before merge, neither of which is a hard blocker:

  1. Race in dirty-state propagation. The editor reports dirty changes through useEffect, so the parent can still see isEditorDirty === false for a brief window after the editor has actually become dirty. Because the ContentEditPage gates token adoption on !isEditorDirty, a poorly timed refetch (window focus, background invalidation, or an out-of-order response) can adopt an older server _rev before the dirty signal arrives. This leaves the same class of bug the PR is fixing, just narrower.

  2. Fragile coupling to the server’s opaque _rev format. The admin client now decodes _rev with atob and assumes the exact server encoding. If the server ever changes the format (base64url, extra fields, non-base64), decodeContentRev returns null for all newer tokens and the admin will refuse to ratchet forward, sticking to a stale token until the user hits a conflict and uses the recovery path. Centralizing the decoder (or having the server expose a monotonic version field) would make the admin less tightly coupled to the token serialization.

Both are relatively contained; the second is a maintainability/forward-compat risk and the first is a real but edge-case timing window.

Comment on lines +757 to +760
const onDirtyChangeRef = React.useRef(onDirtyChange);
onDirtyChangeRef.current = onDirtyChange;
React.useEffect(() => {
onDirtyChangeRef.current?.(isDirty);

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.

[needs fixing] onDirtyChange is fired from a useEffect. That means the parent only learns the editor is dirty one React commit after the editor computed isDirty === true.

In ContentEditPage, the token-adoption check in router.tsx depends on !isEditorDirty:

if (
  rawItem?._rev &&
  !isEditorDirty &&
  adoptNewerRevision(...)
) { ... }

So if a refetch resolves in the brief window after the user makes a change but before this effect notifies the parent, the page can still adopt an older _rev while the editor has unsaved local changes — undermining the fix.

Options:

  • Flush the dirty signal earlier/asynchronously in a way the parent can observe before the adoption logic runs. A synchronous ref or a callback invoked from the same event path that dirties the editor would close the window more reliably than useEffect/useLayoutEffect.
  • At minimum, document the remaining race and add a test that schedules a refetch between the editor-dirtying event and the parent re-render.

Comment thread packages/admin/src/router.tsx Outdated
Comment on lines +1007 to +1010
}
if (rawItem && !revisionTokensRef.current.has(rawItem.id)) {
if (
rawItem?._rev &&
!isEditorDirty &&

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.

[suggestion] The adoption guard mutates revisionTokensRef.current directly during render:

if (
  rawItem?._rev &&
  !isEditorDirty &&
  adoptNewerRevision(...)
) {
  revisionTokensRef.current.set(rawItem.id, rawItem._rev);
}

Mutating refs during render is a side effect and can be unsafe with React 18/19 concurrent features (tearing, double invocation in Strict Mode). If the timing requirement is only that the next user-triggered save sees the new token, moving this into a useLayoutEffect would be safer and still run before paint/event handlers. If an even earlier guarantee is needed, consider deriving the candidate token in event handlers instead.

Comment thread packages/admin/src/router.tsx Outdated
Comment on lines +285 to +291

/**
* Decode an EmDash content `_rev` token into its version and updatedAt.
* Tokens are opaque to callers but have a stable base64("version:updatedAt")
* format generated by the server.
*/
function decodeContentRev(rev: string): DecodedContentRev | null {

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.

[suggestion] The admin client now hardcodes the server’s opaque _rev format (base64("version:updatedAt")) and decodes it with atob. This couples the admin to the server token serialization.

If the server format changes (e.g., base64url, additional fields, or non-base64 encoding), decodeContentRev will return null for every newer token, adoptNewerRevision will refuse to adopt, and the editor will stay on a stale token until a conflict forces recovery.

Prefer reusing the server’s own decoding helper if @emdash-cms/core is available to the admin bundle, or ask the API to expose a stable monotonic version field (e.g., _revVersion) so the client can ratchet without knowing the token encoding.

@pkg-pr-new

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

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

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

@emdash-cms/auth

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

@emdash-cms/auth-atproto

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

@emdash-cms/blocks

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

@emdash-cms/cloudflare

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

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

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

emdash

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

create-emdash

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

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

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

@emdash-cms/plugin-cli

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

@emdash-cms/plugin-test

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

@emdash-cms/plugin-types

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

@emdash-cms/registry-client

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

@emdash-cms/registry-lexicons

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

@emdash-cms/registry-loader

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

@emdash-cms/registry-moderation

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

@emdash-cms/registry-verification

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

@emdash-cms/sandbox-workerd

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

@emdash-cms/x402

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

@emdash-cms/plugin-ai-moderation

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

@emdash-cms/plugin-atproto

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

@emdash-cms/plugin-audit-log

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

@emdash-cms/plugin-color

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

@emdash-cms/plugin-embeds

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

@emdash-cms/plugin-field-kit

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

@emdash-cms/plugin-forms

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

@emdash-cms/plugin-webhook-notifier

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

commit: fe55832

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

The reporter has confirmed the original OG-image fix. Before merging, please add a regression using the real ContentEditor: make unsaved edits, refetch a newer server revision, and verify that the local edits remain, the original write revision is retained, and saving detects the conflict. Include the case where a local edit and a refetch result arrive together.
The current dirty-editor test mocks ContentEditor, so it does not exercise real form synchronization or dirty-state propagation. From reading the real editor’s synchronization effect, a refetch can replace form data and reset its saved baseline. Please fix any failures exposed by this regression and keep the existing clean-refetch and stale-read tests passing.

@emdashbot

emdashbot Bot commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Completed

Revised and published fix for #3829. Branch bot/fix-3829; all targeted checks pass.

Outcome: Revised and published the fix for #3829 in response to maintainer review feedback on PR #3831.

What changed:

  • ContentEditor now notifies ContentEditPage of dirty state synchronously from the edit event handlers (field, slug, byline, reference, plugin draft patch) instead of from a useEffect, closing the race where a refetch could be adopted before the parent learned the editor was dirty.
  • Revision-token adoption was moved out of render into a useLayoutEffect to avoid mutating refs during render.
  • The ratchet now uses the server-returned entry `version` instead of decoding the opaque `_rev` token, removing the admin's dependency on the server's base64 token encoding.
  • All write paths (save, autosave, publish, unpublish, discard, restore, conflict recovery, schedule) record both the token and its version.
  • Regression tests were updated to use per-response versions and to signal dirty state synchronously from the mock editor.

Verification:

  • `pnpm exec vitest run tests/router.test.tsx tests/editor-save-conflict.test.tsx tests/components/ContentEditor.test.tsx tests/components/ContentEditorMemo.test.tsx` → 188 passed.
  • `pnpm typecheck` (admin) → clean.
  • `pnpm lint:quick` → zero diagnostics.
  • `pnpm build` (root) and `pnpm build` (admin) → succeeded.
  • `pnpm format` applied.

The candidate is published to `bot/fix-3829` (commit `423890faffa14636d982ea9e7327f2df7f921b43`).

Mode: revise

View live dashboard · Run: 6c39b8c8-278f-4bfc-aadb-cb436952d067

@danielmlr danielmlr self-assigned this Oct 4, 2026
@emdashbot

emdashbot Bot commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Completed

Investigate issue #3829 in revise mode: address maintainer review comments by documenting the synchronous dirty signal and adding a regression test for the refetch-between-dirty-event race.

  • Read AGENTS.md and current candidate
  • Inspect git status and diff
  • Run focused tests and checks
  • Identify and implement needed revisions
  • Verify and report result

Outcome: Revised the candidate for #3829 to address the maintainer review feedback on PR #3831.

What changed in this revision:

  • Added inline comments explaining why ContentEditor emits the dirty=true signal synchronously from every editing event path (via `markDirty`), and why ContentEditPage reads a ref rather than only React state when deciding whether a refreshed revision is safe to adopt.
  • Added a regression test that marks the editor dirty and immediately refetches before the parent re-renders, asserting that the older base revision is still sent for the SEO save. This closes the window the reviewer flagged between an editor-dirtying event and the parent’s adoption layout effect.

The underlying fix was already present in the applied candidate and remains unchanged:

  • ContentEditPage stores both the write token and the server’s monotonic `version` (added to the admin `ContentItem` interface) and ratchets the token forward only while the editor is clean and the fetched version is newer. This avoids parsing the opaque `_rev` string and prevents delayed old GETs from downgrading the token after a successful write.

Verification:

  • `pnpm --filter @emdash-cms/admin test --run tests/router.test.tsx tests/editor-save-conflict.test.tsx` → 45 tests passed (previously 44).
  • `pnpm --filter @emdash-cms/admin typecheck` → passed.
  • `pnpm lint:quick` → 0 diagnostics.
  • `pnpm format` → applied.
  • `pnpm --filter @emdash-cms/admin build` → succeeded.
  • `pnpm lint:json` / `pnpm --silent lint:json | jq …` could not complete because `tsgolint headless` is killed by SIGKILL / times out in this environment (out-of-memory). The non-type-aware lint run is clean.
  • A full root `pnpm build` was interrupted by a sandbox runtime update after the admin edits; an earlier full root build passed before these final revisions, and the admin package build passed afterward.

No unrelated files were modified.

Mode: revise

View live dashboard · Run: c57ef867-512e-4ca7-85f0-ec4ff0c8245d

@emdashbot

emdashbot Bot commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Completed

Revise the cached-revision-token fix for #3829 to ratchet by versions decoded from _rev instead of relying on a server version field, keeping clean-refetch adoption, dirty retention, and non-downgrade protection.

  • Replace version-field ratchet with _rev decoding
  • Drop optional version from admin ContentItem
  • Update regression tests to exercise decoded-version ratchet
  • Run typecheck, focused tests, lint, format, and build
  • Publish revised candidate branch

Outcome: Revised the candidate fix for #3829. The previous implementation ratcheted the write token using a newly exposed `version` field on the admin `ContentItem` type. This revision removes that API-surface change and instead decodes the version from the opaque `_rev` token (`base64("version:updatedAt")`) directly in the admin router.

What changed:

  • `packages/admin/src/router.tsx`: added `decodeRevVersion()` and `shouldAdoptRevision()`. `ContentEditPage` now stores the decoded version of the current write token and only adopts a refetched `_rev` while the editor is clean and the fetched version is newer. All token updates flow through `recordWriteRevision()`, which decodes from `_rev`.
  • `packages/admin/src/lib/api/content.ts`: removed the optional `version` field that was added in the first candidate; `_rev` remains the only concurrency token exposed to the admin.
  • `packages/admin/tests/router.test.tsx`: updated the new regression tests to drive the ratchet entirely through `_rev` values (rev1 → rev2 → rev3), with no `version` property on the mocked items.

Verification:

  • `pnpm --filter @emdash-cms/admin typecheck` passed.
  • Focused test run of `router.test.tsx`, `editor-save-conflict.test.tsx`, `publish-autosave-race.test.tsx`, `unpublish-discard-revision-token.test.tsx`, `published-at-rev.test.tsx`, and `reference-autosave-cache.test.tsx`: 86 tests passed.
  • `pnpm build` from the root passed.
  • `pnpm format` applied.
  • `pnpm lint:quick` passed zero diagnostics.
  • `pnpm exec oxlint --type-aware -f json packages/admin/src packages/admin/tests` passed zero diagnostics.
  • The root `pnpm lint:json` still terminates with SIGKILL in this environment (OOM), consistent with the previous run; the scoped type-aware lint confirms no new diagnostics.

Published to `bot/fix-3829` (commit `fe558320371f86d99b7228a10d51896a78b62c37`).

Mode: revise

View live dashboard · Run: 08979f5b-fb02-42bf-bf29-55ceeecdc5c1

@emdashbot

emdashbot Bot commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Timed out

Implementing: use item.version, guard ContentEditor refetch while dirty, add real-ContentEditor regression tests. Now verifying.

  • Inspect repo, AGENTS.md, and current candidate
  • Identify needed revisions from maintainer feedback
  • Implement revised fix and tests
  • Run focused tests, typecheck, lint, format
  • Publish candidate branch
  • Report result

Outcome: The run stopped at its execution deadline before it could provide a checkpoint summary.

The conversation and workspace are saved. A maintainer can continue with `@emdashbot retry`.

Mode: revise

View live dashboard · Run: 4bcfab30-15dd-40b2-a394-ff1fd9adf013

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Admin: selecting an OG image sends a cached revision after a clean entry refetch

1 participant