feat: @decocms/blocks/preview, the draft badge and the Studio bridge

TL
2h ago
+1044-1

Stacked on #694 (P2, feat/blocks-image). This is P3 of the v8 framework packages plan.

What it adds

A new subpath, @decocms/blocks/preview, with two components. Neither has framework code, and neither holds a list of Studio's origins: @decocms/blocks exports no Studio origins and no draft names (P1 was reverted after the PO's review). The bindings own those. @decocms/tanstack's document mounts both components and passes Studio's origins in (P4). A Next.js site renders both in its root layout with the origins from @decocms/nextjs (P5).

DraftBadge({ pointer, exitHref? })

  • v7's DraftPreviewBadge, unchanged on screen: the "Preview mode" pill with "Exit preview" and "Share preview".
  • "use client". It renders nothing in three cases: when pointer is empty, on the server, and inside a frame (Studio's site editor already shows its own chrome).
  • exitHref overrides the default exit link, which is the current URL with ?__draft=off.
  • The badge gets the __draft parameter name from draft.ts. That module exports the name to the badge only; the package does not export it.
  • The inlined Deco mark is now a 36px PNG (about 2KB). v7 inlined the 720px original (about 22KB) into every client bundle that mounted the badge.

StudioBridge({ site, trustedOrigins, editShortcut?, nonce? })

  • v7's LiveControls as one static inline <script>. Its settings travel in data-* attributes.
  • No hooks, so a Next.js Server Component can render it.
  • Why a script and not an effect: Studio posts to the frame on its load event, which can come before React hydrates.
  • trustedOrigins is required, and it is the whole list the bridge answers. It uses the CSP source form: https://host, or https://*.host for any name under it, with the same scheme and port. The binding passes Studio's origins. An empty list answers nothing.
  • In a frame, it answers only messages from the parent window whose origin is in trustedOrigins. It does not trust the page's own origin: under a test runner that proxies the site, the runner's own messages would navigate the page.
  • Messages, checked against what Studio v8 sends today:
    • editor::inject { args: { script } } is what Studio's Fast Preview and Local frames post. The injected script then handles the cms-editor::* messages itself.
    • It also keeps v7's scrollToComponent and editor::rerender (same-origin URLs only).
  • v7's "." / Ctrl+Shift+E shortcut, which opens the page in Studio's /choose-editor on a page that isn't framed, is on by default (PO, 2026-10-15, 280f1aa8), as in v7, on TanStack and Next alike. editShortcut={false} turns it off.

Tests

  • preview/preview.test.tsx (jsdom) covers the badge and the bridge.
    • Badge: its links, its SSR output, framed and unframed rendering, the menu, and copying the share link.
    • Bridge: its script runs as a browser runs it. The cases are:
      • editor::inject from Studio;
      • a table of origins checked against Studio's list as a binding passes it;
      • an empty list answering nothing, not even Studio or the page's own origin;
      • any given origin, exact and wildcard;
      • a message from a window other than the parent;
      • an unframed page;
      • malformed messages;
      • scrollToComponent;
      • running only once;
      • the shortcut: on by default, off with an explicit false (no data-shortcut, "." and Ctrl+Shift+E ignored), never inside Studio's frame, never while typing in a field.
  • preview/exports.test.ts checks four things: the exports entry, the exact runtime names, that the badge module starts with "use client", and that the bridge has no hooks.
  • browserBundle.test.ts bundles /preview with esbuild for a browser target and a workerd target.
    • Each bundle's inputs must be only preview/*, draft.ts and React. studio.ts is no longer allowed, and it no longer exists.
    • The bundles must contain no node: built-ins, createCMS or AsyncLocalStorage.
  • Shortcut default (280f1aa8, on top of #694 merged in 16d05320): bun run check, bun run build, skills:check and skills:readme:check pass; plain Node imports all 25 built exports and deco --help runs. bun run test (2 GB): 2,180 passed, 3 failed: DS-2 (fails on main too; fixed separately) and two schema.test.ts timeouts under full-suite load (58/58 alone). Preview tests 29/29; reverting the default fails three of them.
  • Earlier round: bun run check, bun run build, skills:check and skills:readme:check pass.
  • bun run test: 2,153 passed and 4 failed.
    • One failure is DS-2 in delivery.cli.test.ts (known).
    • The other three are timeouts under full-suite load: se-03 in cli.test.ts and two in schema.test.ts. Run alone, cli.test.ts and schema.test.ts pass (182/182).

For the PO

  • The plan's signatures changed.
    • StudioBridge renders a <script>; the plan had it returning null.
    • The Next.js root does not need to re-export it as a "use client" module.
    • DraftBadge accepts a null pointer, so it can be mounted on every page.
    • trustedOrigins is now required, because the origins belong to the binding.
  • The "." shortcut is on by default (decided 2026-10-15). Follow-up above: DecoDocument in #696 still defaults editShortcut to false and must pass the new default through; @decocms/nextjs's StudioBridge (#697) passes props through, so it inherits it (its README line should say so).
  • v7's *.deco.cx admin origins are no longer trusted. The bindings pass Studio's origins only.

"." opens the page on screen (2026-10-16, 3d32c406, 32bdd30e)

  • The shortcut sent pathTemplate=/* on every page, so Studio's /choose-editor could only guess the page. The bridge now sends the page's pathTemplate (and pageId when known, as v7 did), read at the keypress so a client-side navigation sends the page then on screen. It comes from the bridge's new page prop (a layout that re-renders per page: @decocms/tanstack's document) or, winning, from the last StudioPage the page rendered (a Next.js root layout doesn't re-render per page).
  • StudioPage({ pathTemplate, id? }) is new: a <meta name="deco:page"> that React places in the <head>, so it adds nothing to the page's own markup.
  • The shortcut already ran on a page that isn't framed. The module comment said the bridge "does nothing unless the page is framed", which was true only of Studio's messages; it now says so. In a frame, it still answers only trustedOrigins.
  • Signed-in check: v7's LiveControls had none on the store page, and neither does this. Studio's /choose-editor is auth-gated and sends a visitor who isn't signed in to its login and back.
  • Tests: a plain "." on the store page; the page's template and id from the prop; a StudioPage on the page winning, read at the keypress; attributes changed after the script ran; "." typed in an input, textarea, select and contenteditable ignored; StudioPage hoisted into the head. Each fails without its change.
  • Checked in a browser on five locally built sites (three TanStack, two Next): the exact choose-editor URL with each page's template, "." in a field, the badge (shown, kept across client-side navigation and reloads, hidden in a frame, exit clears the cookie, absent on a host that may not preview), and Studio's injected editor script outlining, selecting and scrolling to sections.

Main refresh (2026-10-17, 4e244e17)

  • Merged P2 0103f85f into this branch (normal merge, no conflicts), then merged up the stack to M2 #707.
  • Gates at the top of the stack (M2 5131a3c1): bun run check, bun run build, skills:check, skills:readme:check pass. bun run test (2 GB memory cap): 122 files pass, 1 skipped; 2,745 tests pass, 95 skipped, 0 fail. DS-2 now passes (fixed on main by #709); no timeouts this run.
  • CI on this PR's new head: all checks pass.

🤖 Generated with Claude Code
https://claude.ai/code/session_016ZsmzpZ2zahRBoATBb2tBu