feat(cms): Fast Preview — pull-based draft rendering - #5363
Draft
tlgimenes wants to merge 12 commits into
Draft
Conversation
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
marked this pull request as draft
July 28, 2026 21:17
…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>
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>
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.
Studio half of pull-based Fast Preview: the preview iframe goes to the site's real page on
productionUrlwith?__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).decocms/blocks7.28.0-beta.1deco-sites/faststore-filaWhy the push model had to go
The daemon POSTed a multi-MB decofile to
/live/previewsper 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-MBJSON.parseon the single-threaded loop.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-previewmoved under/_sandbox, then deleted with the push model —/_decowas a one-route namespace that shadowed the proxied dev server. TheproductionUrlplumbed Studio→daemon solely to feed it is deleted too.Studio UI
buildDraftPreviewUrl→ real page URL + pointer;fastPreviewReadykeys off the version; the version replaces the cache-busting nonce (a save mints a version → URL changes → frame re-navigates).previewUrlseeded from the SANDBOX_START claim (was: 60s-stale entity query invalidated only at dev-serverrunning); decofile query re-driven when the daemon lifecycle first reaches a tree-ready phase (was: cold-start 502, never retried); sharedisWorkingTreeReadyPhaseso daemon and Studio agree on what "tree exists" means.deviceHint).Testing
Full
bun run checkclean; ~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
metadata.fastPreview; the framework side has its own default-off env — neither activates on upgrade alone.@decocms/blocksignores the pointer and silently renders published content.*.preview-studio.decocms.com) still pending — also where the CDN no-store question gets settled./live/previewsGET so that route can be retired; convergeuseDecofileonto/_sandbox/decofile(removes the dev-server dependency + invalidation choreography); draft binding for/deco/invoke(deferred sections render published).