Skip to content

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

Closed

Description

@onvarga

Description

Selecting an OG image from the media library can fail with HTTP 409 CONFLICT even when the content editor shows Saved and there are no unsaved content edits.

The editor can mount with a cached entry, then fetch and display the newer server entry. Its separate revision token remains the cached older token. The immediately triggered SEO save therefore sends an outdated _rev despite having already read the current record.

Expected: when a clean editor adopts the refreshed entry, the next SEO save uses the revision associated with that entry. Genuine conflicts involving unsaved edits must remain protected.

Observed: the OG save fails and the editor displays the generic conflict banner. A full browser page reload followed by selecting the same image succeeds.

Steps to reproduce

  1. Open an existing entry in a collection with SEO enabled, without changing content fields. This seeds the admin query cache.
  2. Navigate back to the collection list using the SPA navigation.
  3. Update the same entry through another authorized context, for example changing only the SEO noIndex setting. Keep the first admin SPA open so its cached older entry remains available.
  4. Allow the cached query to become stale, then reopen the entry from that same admin SPA. Wait for the content GET to return the newer revision. The editor still shows Saved.
  5. Select a different OG image from the media library.
  6. Inspect the content PUT: it sends the older cached revision rather than the revision from the completed GET and receives 409 CONFLICT.
  7. Fully reload the entry page and select the same image again: the correct revision is sent and the image is saved.

Environment

  • emdash: 1.1.0
  • @emdash-cms/admin: 1.1.0
  • OS: macOS
  • Reproduction runtime: Node 24.15.0 and SQLite
  • Original reported runtime: Cloudflare Workers / D1
  • Reproduction used the unchanged installed admin frontend and real EmDash content get/update handlers against a separate local content copy. Authentication and unrelated endpoints were supplied by a local harness; edit-lock endpoint behavior and background media maintenance were excluded.
  • The same initialization condition remains in main at 4c5aa726a2d858f125109d7727af34d645c5b874. Main was inspected, not separately executed in this reproduction.

Screenshots

Gallery content editor showing the conflict banner after selecting an OG image

Logs / error output

Sanitized sequence from the isolated reproduction:

GET content/<collection>/<entry> -> version 166
Editor: Saved; no user content edits
PUT content/<collection>/<entry> with seo.image and _rev encoding version 165
HTTP 409 CONFLICT: Content has been modified since last read (version conflict)

After full browser page reload:
GET -> version 166
PUT seo.image with revision 166 -> HTTP 200, version 167
Read-back: selected OG image persisted; content fields unchanged; no draft created

Root cause and proposed fix direction

In packages/admin/src/router.tsx, ContentEditPage initializes revisionTokensRef only while the entry id is absent from the map. Subsequent successful query refreshes do not synchronize the token. updateMutation supplies the token from that map, and handleSeoChange uses that mutation immediately.

Source references on current main:

Suggested approach: associate the write revision with the entry snapshot actually adopted by the form. Synchronize it when a clean editor adopts a newer snapshot, while preserving the base revision for unsaved content. Do not blindly overwrite the token on every refetch: that could permit unsaved stale content to overwrite a newer entry or let a delayed read replace a newer successful-write token.

Suggested regression coverage:

  • Cached old entry followed by a fresh read in a clean editor: OG save sends the fresh revision and succeeds.
  • The same refresh while content is dirty: retain the existing conflict protection and the user's edits.
  • A delayed old GET must not replace the token returned by a newer successful write.
  • Preserve conflict recovery, Save anyway, publish/unpublish/discard/restore behavior, and entry/locale identity.

Related but different conflict recovery changes: #2902 and #3118. This report concerns the token retained after a successful clean refetch, before any conflict occurs.

Activity

  1. emdashbot commented on Oct 3, 2026

    @emdashbot
    Contributor

    Completed

    Triage issue #3829: cached revision token after clean refetch causes OG image save 409.

    • Read and understand the reported bug
    • Read AGENTS.md repository guidance
    • Inspect token lifecycle in admin router
    • Assess fix scope and regression coverage
    • Report triage disposition

    Outcome: Confirmed bug in `packages/admin/src/router.tsx`. `ContentEditPage` initializes `revisionTokensRef` only when the entry id is absent from the map (`if (rawItem && !revisionTokensRef.current.has(rawItem.id))`), so a cached older `_rev` is retained after TanStack Query refetches a newer entry in a clean editor. `updateMutation` then sends the stale `_rev`, and `handleSeoChange` triggers it immediately, producing the reported 409 CONFLICT.

    Scope is bounded: synchronize the token when a clean editor adopts a fresher `rawItem` snapshot, while preserving the base token when the editor is dirty and guarding against delayed old GETs overwriting a newer successful-write token (`_rev` encodes `version:updatedAt`, so version comparison is available). Likely needs a dirty-state callback from `ContentEditor` to `ContentEditPage` plus the existing token map. Existing `packages/admin/tests/editor-save-conflict.test.tsx` and `packages/admin/tests/router.test.tsx` provide regression scaffolding; add tests for clean-refetch OG save success, dirty-refetch conflict protection, and non-downgrade after write.

    Mode: triage

    View live dashboard · Run: 7afd0c2b-0f97-4930-b00e-88f8fcabbaae

  2. emdashbot commented on Oct 3, 2026

    @emdashbot
    Contributor

    Completed

    Fix cached revision token bug in admin ContentEditPage

    • Read AGENTS.md and inspect router.tsx
    • Implement token synchronization fix
    • Add failing regression tests
    • Run focused tests and typecheck
    • Publish candidate

    Outcome: Fixed the cached revision token bug in the admin ContentEditPage (#3829).

    Root cause: `ContentEditPage` only stored `rawItem._rev` on initial load. After TanStack Query refetched a newer clean entry, the stale cached token remained, so SEO/auxiliary saves sent the old `_rev` and received 409 CONFLICT.

    Fix:

    • Added `onDirtyChange` to `ContentEditor` so the page knows when the form is dirty.
    • `ContentEditPage` now decodes `_rev` (format `base64("version:updatedAt")`) and ratchets the stored write token forward only while the editor is clean and the fetched version is newer. This preserves conflict protection for unsaved edits and prevents a delayed old GET from overwriting a newer successful-write token.
    • Added regression tests in `router.test.tsx` for clean-refetch adoption, dirty retention, and non-downgrade after write.

    Verification: 44 tests passed (router + editor-save-conflict), admin typecheck passed, root build passed, format applied, lint:quick zero diagnostics. `lint:json` passed standalone but the chained jq invocation was killed by SIGKILL (likely OOM/environment). Published to `bot/fix-3829`.

    Mode: work

    View live dashboard · Run: 18774c42-2136-4f5f-9cc1-0fc53e521f9c

  3. emdashbot commented on Oct 3, 2026

    @emdashbot
    Contributor

    I opened PR #3831 with a candidate change.

    Fixed the cached revision token bug in the admin ContentEditPage (#3829).

    Root cause: ContentEditPage only stored rawItem._rev on initial load. After TanStack Query refetched a newer clean entry, the stale cached token remained, so SEO/auxiliary saves sent the old _rev and received 409 CONFLICT.

    Fix:

    • Added onDirtyChange to ContentEditor so the page knows when the form is dirty.
    • ContentEditPage now decodes _rev (format base64("version:updatedAt")) and ratchets the stored write token forward only while the editor is clean and the fetched version is newer. This preserves conflict protection for unsaved edits and prevents a delayed old GET from overwriting a newer successful-write token.
    • Added regression tests in router.test.tsx for clean-refetch adoption, dirty retention, and non-downgrade after write.

    Verification: 44 tests passed (router + editor-save-conflict), admin typecheck passed, root build passed, format applied, lint:quick zero diagnostics. lint:json passed standalone but the chained jq invocation was killed by SIGKILL (likely OOM/environment). Published to bot/fix-3829.

    Try the change against your own site:

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

    Or try the candidate in a ready-to-use playground:

    Open the playground preview

    @onvarga could you try this? Reply naturally to say whether it works or explain what still needs to change.

    A maintainer reviews the pull request before merge; requested changes start another candidate revision.

    Fix branch: bot/fix-3829 · Artifacts branch: bot/artifacts-3829

  4. onvarga commented on Oct 3, 2026

    @onvarga
    Author

    Confirmed: the candidate fix resolves the originally reported OG image selection issue on our site.

    We installed the preview from PR #3831, pinned to commit 2e1f2310f15b20966181d03e1a511906db5a9092, on both localhost and our Cloudflare Workers/D1 DEV environment. After testing the original workflow, I can confirm that selecting an OG image from the media library now works without the false conflict warning.

    This confirms the original reported workflow; it does not constitute a separate verification of all concurrent-edit scenarios.

    Thank you for the quick diagnosis and fix!

  5. emdashbot commented on Oct 3, 2026

    @emdashbot
    Contributor

    Thanks for confirming the change works! I've marked the issue as verified, and a maintainer will review PR #3831.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions