Repository navigation
Conversation
Every draft GET (the index and each file) now carries a fresh ?r=<nonce>. delivery.decocms.com answers a missing object with a 404 that has no Cache-Control, and Cloudflare keeps it (cf-cache-status: HIT, age up to ~180 s). A draft file read once before its save landed then read as missing for minutes, and the preview silently showed production for that block. R2 ignores the query; Cloudflare keys on it, so the read always reaches storage (the draft objects are no-cache anyway). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A loopback draft host (a local Studio or test server over plain http) has no CDN in front, and some match the request URL exactly (the nextjs-binding and tanstack-worker smoke draft servers did): the cache-busting nonce goes on https reads only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tlgimenes
force-pushed
the
fix/draft-read-no-cdn-cache
branch
from
November 10, 2026 19:06
cffcdf5 to
97aefba
Compare
Contributor
Author
|
Closing: fixed server-side instead. A Cloudflare cache rule on the decocms.com zone ("delivery.decocms.com: bypass cache for CMS drafts", |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Every draft GET in the v8 draft reader (
packages/blocks/src/v8/draftChanges.ts: the draft index and each file it lists) now carries a fresh?r=<nonce>, so no shared cache can answer it.Why
delivery.decocms.comanswers a missing object with a 404 that has noCache-Control, and Cloudflare caches it (cf-cache-status: HIT,ageclimbing to ~180 s). If the SDK reads a draft file before its save has landed, every later read of that URL gets the cached 404 for about 3 minutes. Because the draft index already lists the name, the preview quietly shows production for that block, with nothing logged.This happened in production on 2026-11-10 (
osklenbr-faststore-studio, draftSWYdvLFwqQ5t8D60VODzJQ):index.jsonnamingFooter-Islandwas written at 07:30:18Z andFooter-Island.jsononly at 07:30:44Z. The ~26 s gap was a stalled R2 PUT between two back-to-back writes, and the cached 404 kept the block on production for minutes after the file landed.Studio keeps its write order: the draft index first, then the file (a deliberate design decision). So a listed name with no file can be a save still in flight for a moment. The nonce makes sure the first read after the file lands sees it, instead of a 404 that Cloudflare cached during that moment.
How the CDN behaves today:
GET …/drafts/<slug>/nope-x1.jsontwice404 MISS, then404 HIT age: 0(404s are cached)GET …/Footer-Island.json200 REVALIDATED, etagdeabf5b2…GET …/Footer-Island.json?r=abc1200 MISS: same etag and body (R2 ignores the query; Cloudflare keys on it)Draft objects are
no-cache, max-age=0, must-revalidate, so every read already went to storage. The nonce adds no extra origin requests. It does add origin bytes: an existing object now comes back as a full200from R2 where Cloudflare used to revalidate it with a conditional request (see Known limitations).Loopback draft hosts (plain
http: a local Studio or a test server, which never sit behind a CDN) are read at their plain URL, without the nonce. Some of them match the request URL exactly, as the nextjs-binding and tanstack-worker smoke draft servers do.Why no retry for a listed name whose file is a 404
I looked at adding one short retry. The code says it isn't worth it:
adoptHostedSaveinapps/web/src/components/sections-editor/content-protocol-api.ts). By then the index and file are both in R2, so the reload's nonce'd read sees the file. The pointer's version doesn't pin content (fetchDraftalways reads the current index and files), so if the editor navigates or reloads inside the preview while a save is in flight, that read can still see the new index without the file and show production for that block. The reload thatadoptHostedSavetriggers fixes it.draftToken(apps/api/src/hosted/cms-drafts.ts). It hashes the index ETag and the ETag of every listed file that exists, so it changes as soon as the file appears. A second editor's poll then moves its pointer and reloads its preview, and with the nonce that reload reads the file. Without the nonce, it would have read the cached 404.So the reader keeps treating a 404 as "not in the draft" (production shows), and the pointer moving is what heals it.
Spike (real R2 + delivery.decocms.com)
Script:
race-spike.mjson BugMachine (~/cas-explore/race-spike.mjs, logs in~/cas-explore/spike-race/). It writes a throwaway draft the way Studio does:index.jsonnamingBarra promocionalfirst, then the body (PromoBar text replaced with a marker) 20 s later. It polls every 2 s.Run 1, storefront (before fix):
https://osklenbr.vtex.app/?__draft=delivery.decocms.com/sites/osklenbr-faststore-studio/drafts/spike-race-1/index.json@spike1404 cf=HIT age=…179the whole time.Run 2, SDK before vs after (
fetchDraftfrom@decocms/blocks@8.12.0, as in osklen's node_modules, vs this branch'sdist), same draft shape (spike-race-2):404 cf=HITup toage=179.Spike objects were left in place on purpose (nothing deleted without an OK):
sites/osklenbr-faststore-studio/drafts/spike-race-1/index.jsonsites/osklenbr-faststore-studio/drafts/spike-race-1/Barra%20promocional.jsonsites/osklenbr-faststore-studio/drafts/spike-race-2/index.jsonsites/osklenbr-faststore-studio/drafts/spike-race-2/Barra%20promocional.jsonTests
cms.test.ts: every https draft GET carries a distinct?r=nonce and the object address is unchanged. A loopback host is read at its plain URL.cms.test.ts,__conformance__/{delivery,sdk,campaigns}.test.tsnow compare URLs with the nonce removed.tests/worker-hosted.test.ts("a preview request reads its draft's folder once") counted draft GETs withendsWith, so it broke on the nonce. It now compares URLs with the nonce removed, like the others.main6492e9c (linear history, no merge commits): three commits, head97aefba9. On BugMachine, on that tree:vitest run packages/blocks/src/v8 tests/1840 passed, 5 skipped;bun run buildclean.main76baef6 (after feat(blocks): allow a 5 s CMS check interval; 1 s jitter #805, feat(nextjs,tanstack): v8-only StudioBridge and DecoDocument, Cypress-safe "." shortcut #806, feat(nextjs,tanstack): let Studio's HTML swap re-render the page through its router #808), on BugMachine:vitest run packages/blocks/: 71 files, 2285 passed, 126 skipped.vitest run tests/ examples/tanstack-worker-smoke/src: 12 files, 201 passed.bun run build,bun run typecheckandbun run lintare clean. Lint's one warning is already onmain.bun run smokepasses in bothexamples/tanstack-worker-smokeandexamples/nextjs-binding-smoke.cffcdf5d:bun run buildis clean, andvitest run tests/ packages/blocks/src/v8/cms.test.ts packages/blocks/src/v8/__conformance__passes (26 files, 865 tests).mainhas it too.Known limitations
MISSwith the same weak etag. If someone later adds an "ignore query string" Cache Rule there, the fix would silently stop working./sites/*/drafts/*would fix the stale 404 for every reader, including older SDKs and custom readers. This PR changes no Cloudflare settings. A custompreview.draftHostsbehind a CDN that drops query strings (for example, CloudFront without query forwarding) won't benefit, and a host that rejects unknown query params would break.patch-fetchusesoriginFetch.Not merged — waiting for review.
🤖 Generated with Claude Code