Skip to content

feat(cms): Fast Preview — pull-based draft rendering - #5363

Draft
tlgimenes wants to merge 12 commits into
mainfrom
feat/draft-preview
Draft

feat(cms): Fast Preview — pull-based draft rendering#5363
tlgimenes wants to merge 12 commits into
mainfrom
feat/draft-preview

Conversation

@tlgimenes

@tlgimenes tlgimenes commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Studio half of pull-based Fast Preview: the preview iframe goes to the site's real page on productionUrl with ?__draft=<host[:port]>@<version>, and the sandbox daemon serves the draft decofile the site pulls. Supersedes #5232 (closed by a branch rename; described the pre-redesign push model).

Repo PR
decocms/blocks #409 — published as 7.28.0-beta.1
deco-sites/faststore-fila #271 — first adopter

Why the push model had to go

The daemon POSTed a multi-MB decofile to /live/previews per navigation. Only deco's own runtime honours a POST render (Next renders on GET); it produced a single statically-rendered component — no hydration, no navigation, client components invoked server-side; and the payload was re-sent every time. Pull inverts it: the site fetches the decofile once per version and renders its own route.

Daemon

  • GET /_sandbox/decofile — merged working-tree .deco/blocks/*, content-addressed (weak ETag, 304 on match). Unauthenticated by design, beside /events//scripts//idle: the fetcher is an arbitrary production server with no daemon token. Raw-text merge — no multi-MB JSON.parse on the single-threaded loop.
  • Filename stems decode until stable: repos carry double-encoded names (Compre%2520Junto.json); a single decode produced keys no reference resolves.
  • decofile: { version } on the events stream — emitted when the working tree lands (post-clone; a clone writes nothing through the daemon, so a write-only signal never fires) and on .deco/blocks/* writes (debounced, deduped by hash). Version comes from the same function as the route's ETag, so Studio's pointer and the framework's cache key cannot drift.
  • /_deco/fast-preview moved under /_sandbox, then deleted with the push model — /_deco was a one-route namespace that shadowed the proxied dev server. The productionUrl plumbed Studio→daemon solely to feed it is deleted too.

Studio UI

  • buildDraftPreviewUrl → real page URL + pointer; fastPreviewReady keys off the version; the version replaces the cache-busting nonce (a save mints a version → URL changes → frame re-navigates).
  • Boot UX: Fast Preview shares main's published-site fallback — published site + waking pill hold the canvas until the draft is renderable; the blocking "Cloning… / Installing packages…" overlay is gone from this path. Three underlying fixes: previewUrl seeded from the SANDBOX_START claim (was: 60s-stale entity query invalidated only at dev-server running); decofile query re-driven when the daemon lifecycle first reaches a tree-ready phase (was: cold-start 502, never retried); shared isWorkingTreeReadyPhase so daemon and Studio agree on what "tree exists" means.
  • Open-in-new-tab restored on the Fast Preview surface — the draft URL is an ordinary shareable page URL now (opened without deviceHint).

Testing

Full bun run check clean; ~1700 unit tests incl. new daemon e2e (route contract: 404-no-blocks, ETag stability, 304/200 revalidation, unauthenticated access, double-encoded keys, version event == ETag). Verified live against a desktop sandbox + the fila beta: cold boot with no overlay, save→auto-refresh, two threads with isolated drafts, PDP/landing/home drafts.

Rollout / not yet done

  • Gated on existing metadata.fastPreview; the framework side has its own default-off env — neither activates on upgrade alone.
  • Not mergeable until blocks#409 ships a non-beta: an older @decocms/blocks ignores the pointer and silently renders published content.
  • Staging pass on a real cluster sandbox (*.preview-studio.decocms.com) still pending — also where the CDN no-store question gets settled.
  • Follow-ups deliberately out of scope: migrate Studio's section previews off /live/previews GET so that route can be retired; converge useDecofile onto /_sandbox/decofile (removes the dev-server dependency + invalidation choreography); draft binding for /deco/invoke (deferred sections render published).

Deco Studio and others added 7 commits July 28, 2026 16:50
Adds Fast Preview: render the current working-tree draft against the linked
site's production `/live/previews` WITHOUT waiting for (or using) the dev server.

- New agent-settings CMS section: Preview (production/preview URL + Fast Preview
  switch) + Publishing (extracted PublishPolicyField). Copy in en + pt-br.
  `metadata.fastPreview` added to the schema.
- Rendering goes through a new PUBLIC daemon route `GET /_deco/fast-preview`
  (packages/sandbox/daemon): merges `.deco/blocks/*` (dep-free, no dev server),
  POSTs it server-side to `${productionUrl}/live/previews/<component>` (which
  already accepts a JSON `__decofile` body — no production change, no CORS, no
  URL cap), and returns the HTML with an injected `<base href>` so assets resolve
  against production.
- `productionUrl` threaded to the daemon config (Workload → SANDBOX_START →
  buildConfigPayload / link-daemon → daemon `Application.productionUrl`).
- Web: the Fast Preview frame loads the daemon route (`previewUrl` origin) via
  `buildFastPreviewDaemonUrl`. Fast Preview NEVER renders the published site — it
  shows the daemon draft or a booting overlay while the sandbox provisions.
- Save → refresh: on a `.deco/*` write the daemon emits `file-changed`; the
  events gate is relaxed for Fast Preview and the handler bumps a nonce (new
  daemon URL → the frame re-navigates → the daemon re-reads the saved blocks). No
  decofile refetch (the daemon reads disk; refetching could revert an optimistic
  edit).
- URL bar follows the iframe domain (buildPreviewLabel); open-in-new-tab hidden
  off the sandbox surface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fast Preview renders the draft from the sandbox daemon, which is up shortly
after the clone — but the preview still sat behind a blocking overlay until
the dev server reported `running`, i.e. through the whole install. That is
precisely the wait the feature exists to skip.

Three causes, all upstream of the display decision:

- `previewUrl` (the daemon origin) was read only from the entity's
  `sandboxMap`, a collection query with a 60s staleTime whose only
  lifecycle-driven invalidation fires at `running` — even though
  SANDBOX_START already returns it at claim time. Seed it from the claim
  response, key-scoped to `virtualMcpId::branch` so a stale claim can never
  paint another branch's sandbox, and dropped on a terminal claim failure so
  the errored card still wins.

- The decofile (which supplies the page key) 502s while the repo is still
  cloning and is deliberately never retried, so nothing re-drove it until
  `running`. Re-invalidate when the daemon lifecycle first reaches a phase
  that guarantees a working tree (`installing` onward) — the read regenerates
  `.deco/blocks.gen.json` from `.deco/blocks/*.json` with no dev server.

- With neither input, the canvas fell to a blocking overlay. Fast Preview now
  shares main's published-site fallback: the published site plus the waking
  pill hold the canvas until the draft render is renderable, then it swaps
  in. Fast Preview no longer changes which surface is shown, only when it is
  ready — the draft after the clone, versus the dev server at `running`.

`iframeBase` stays the published URL in every production-mode case so the
base is always navigable and the URL-bar label does not jump origins
mid-boot; the draft URL is layered over it.

Inverts the test that pinned the old blocking-overlay rule, and corrects the
`metadata.fastPreview` doc, which claimed the sandbox surface takes over for
interactivity once it is up — it never did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ecofile

Two changes to the daemon's public surface, both in service of pull-based
draft rendering.

`/_deco/fast-preview` moves to `/_sandbox/fast-preview`. The `/_deco` prefix
was a one-route namespace with no stated contract, and it shadowed the proxied
dev server — a storefront serving its own `/_deco/*` would have been silently
intercepted. `/_sandbox` already has a public, unauthenticated tier
(`/events`, `/scripts`, `/idle`) that the browser talks to directly, which is
exactly where a browser-loaded iframe route belongs; `entry.ts` matches it
ahead of the bearer-token gate for the same reason `/events` is.

`GET /_sandbox/decofile` is new: it serves the merged working-tree
`.deco/blocks/*` so a production site can PULL the draft and render its own
real pages against it, instead of the daemon POSTing a multi-MB decofile into
`/live/previews`. That push model only works on deco's own runtime — Next.js
and most frameworks render on GET only — and it re-sent the whole payload on
every navigation. The response is content-addressed (weak ETag over the merged
bytes, 304 on match) so callers cache per version and re-fetch only when the
draft actually changes.

Returns the merged object as raw text, no JSON.parse round-trip: this payload
is routinely >3MB and the daemon runs a single-threaded event loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Match `foreignSandbox`: a caller that knows nothing about Fast Preview gets
exactly the pre-existing behaviour, so tests and call sites added on main
don't have to thread flags they don't care about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Studio needs the draft's content version to build a pull-based preview pointer
(`<handle>@<version>`), and polling for it would mean re-fetching a multi-MB
payload just to read its hash.

The daemon now emits `decofile: { version }` when a `.deco/blocks/*.json` write
lands, so Studio can rebuild the pointer on save instead. The version comes
from `readDecofile`, the same function the route serves as its `ETag` — one
definition, so a consumer's pointer and the framework's cache key cannot
disagree.

Kept off the hot path deliberately: only `.deco/blocks/*.json` writes trigger
it, it runs after the existing 300ms file-changed debounce (a burst of saves
costs one merge), the merge is async and deduped, and an unchanged hash
re-emits nothing. A failed probe warns and is swallowed rather than taking the
watcher down.

Adds a black-box e2e for the route: 404 with no blocks, merged body + content
ETag + no-store, unauthenticated access, ETag stability vs change, and the
304/200 revalidation pair.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Studio now sends the iframe to the REAL page on `productionUrl` carrying a
`?__draft=<handle>@<version>` pointer, which the site's framework resolves by
pulling the merged working-tree decofile from `/_sandbox/decofile`. The daemon
no longer renders anything.

Why the push model had to go: it POSTed the decofile to `/live/previews`, which
only deco's own runtime honours — Next.js and most frameworks render on GET
only — and it could only ever produce a single statically-rendered component,
with no hydration and no navigation. Going through the site's own route fixes
all three.

The `currentPageKey` dependency disappears with it, and that is the interesting
part: the preview no longer waits on the decofile query at all. That query is
what used to strand the whole surface behind a cold-start 502 while the sandbox
installed, since it needed a page key just to build a URL. The site does its own
routing now.

`fastPreviewReady` is fed off the draft version instead. The version also
replaces the cache-busting nonce: a save emits a new version, which changes the
URL, which re-navigates the frame — so the reload handler yields in draft mode
rather than racing that with a stale URL.

Deletes the daemon's fast-preview route, and with it the `productionUrl` that
was threaded from Studio through the config payload to the daemon purely to
feed it. Nothing daemon-side reads it any more; Studio builds the URL itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six black-box cases against the spawned daemon: 404 with no blocks, merged
body + content ETag + no-store, unauthenticated access, ETag stability vs
change, the 304/200 revalidation pair, and the `decofile` version event.

The event test asserts the value it announces is the SAME one the route serves
as its ETag — that equality is the whole contract, since Studio puts it in a
draft pointer and the framework caches on it.

It writes through the daemon's own `/_sandbox/write` rather than straight to
disk. That is how the CMS actually saves a block, and it signals
`onWorkingTreeWrite` directly. A bare fs write depends on
BranchStatusMonitor's watcher, which `ensureWatch()` only starts after
`compute()` succeeds — i.e. once the repo is a real git checkout — so it never
fires in a bare workspace.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tlgimenes
tlgimenes marked this pull request as draft July 28, 2026 21:17
tlgimenes and others added 3 commits July 28, 2026 18:44
…y on write

After a clone the preview stayed on "Starting your preview" forever, even
though the working tree — and therefore a renderable draft — was already on
disk.

`decofile: { version }` was emitted only from the file-changed path, i.e. only
when a `.deco/blocks/*.json` WRITE was observed. A clone doesn't write through
the daemon: git puts the files there, and BranchStatusMonitor's watcher only
starts once `compute()` succeeds, which is after the clone. So on a fresh
sandbox no version was ever announced, Studio never got one, `fastPreviewReady`
stayed false, and the waking pill never cleared.

Announce on the lifecycle transition into a phase that guarantees a checked-out
tree, so the initial version is published as soon as one exists. Writes keep
announcing subsequent versions as before, and the unchanged-hash guard means
the two paths can't double-emit.

`isWorkingTreeReadyPhase` moves into the shared daemon event types and Studio
imports it instead of keeping its own copy: the daemon uses it to decide when a
draft version exists, Studio to decide when to re-drive the CMS queries. Those
answers must agree, and two definitions would eventually disagree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The button was gated to the sandbox iframe because the Fast Preview surface
was an ephemeral `/live/previews` render on the daemon origin — not a link
worth handing to anyone.

Pull-based draft preview removes that objection: the surface is now the site's
own page URL carrying `?__draft=<handle>@<version>`, so the opened tab renders
exactly the same draft. Ungate it.

Opens the page WITHOUT `deviceHint`. That param exists to make the embedded
frame mimic the selected device; it has no business in a URL someone pastes to
a colleague.

Also corrects the `iframeSrc` comment, which still described the daemon render
injecting a `<base href>` — there is no daemon render any more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Real repos carry both single- and double-encoded block filenames
(`Compre%20Junto.json`, `Compre%2520Junto.json`). The decofile merge decoded
the stem exactly once, so a double-encoded file was keyed `Compre%20Junto` —
a key no `__resolveType` reference resolves, silently corrupting the merged
draft for any block whose name needed encoding twice.

Decode until the value stops changing, mirroring the frontend's documented
decodeBlockKeyFromFileStem fallback: single-encoded stems are unaffected, an
invalid-encoding stem keeps its last decodable form, and the e2e asserts both
encodings of the same name merge under the real key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tlgimenes
tlgimenes requested a review from guitavano July 29, 2026 00:41
tlgimenes and others added 2 commits July 28, 2026 22:25
The token becomes `<host[:port]>@<version>`, built from `previewUrl`'s
authority instead of a bare sandbox handle. The site validates the authority
against its configured preview-API domains and derives the scheme itself, so
the token still never carries a URL — and the desktop link's per-run port now
travels in the token, eliminating the suffix-override dance local dev needed.

Pairs with @decocms/blocks 7.28.0-beta.3 (token v2 + env renames).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Switching threads (or reloading, or a second viewer) opens a fresh events
stream long after tree-land announced the draft version — and `decofile` only
fired on transitions, so that client never learned it and Fast Preview sat on
"start your preview" forever. The handshake already replays lifecycle, logs,
scripts, tasks, status, and branch; now it replays the cached decofile
version too, and when none is cached (a daemon restarted over an
already-cloned tree) it asks the owner to compute + broadcast one — after
registering the client, so the announcement reaches it.

The live-announce e2e now asserts on a version that differs from the
pre-write state, since "a decofile event arrived" no longer distinguishes the
replay from the announce.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant