Skip to content

v8 N-15: preview a forced variant through the draft pointer; dev reload recipes - #618

Draft
tlgimenes wants to merge 1 commit into
v8-14-tsc-buildfrom
v8-15-variant-preview
Draft

tlgimenes wants to merge 1 commit into
v8-14-tsc-buildfrom
v8-15-variant-preview

Conversation

@tlgimenes

@tlgimenes tlgimenes commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

Studio's variant tabs now preview variant N on a v8 site. They do it through the ?__draft= preview pointer. There is no new request API and no header: v8 never read x-deco-matchers-override.

Design

  • Address. A forced variant is <savedBlock>@<jsonPath>=<index>, like v7's [email protected]. It names the saved block that holds the multivariate, the JSON path to the multivariate inside it, and the variant index. It travels in the pointer's query as a reserved __variant parameter, URL-encoded, one per forced variant. Example of a local deco serve pointer: 127.0.0.1:4547/rpc?__variant=pages-home%40sections.5%3D1@local.
  • parseDraftPointer / formatDraftPointer (packages/blocks/src/v8/draft.ts). Parsing lifts the __variant parameters out of path into a new DraftPointer.variants, and formatting appends them. A malformed __variant rejects the whole pointer.
  • cms.forDraft(pointer) (cms.ts, content.ts, variants.ts) applies the forced variants to the snapshot it reads. This works even when the source has no drafts, such as the content module. Each addressed multivariate keeps only the forced variant, with rule true, so no rule runs. The snapshot is copied only along the addressed paths. An address that no longer exists is ignored.
  • Loaders get the pointer without the forced variants, so every variant of one draft shares one load and one cache entry.
  • forRelease never forces anything. Only preview requests can force a variant. Variants are not access control, so a variant forced from the URL is fine, and the docs say so.
  • Template code, not package code. A v8 site needs two pieces for local editing: cms.ts taking a new content module through import.meta.hot, and a dev Vite plugin that reloads open pages when .deco/blocks.gen.ts changes. Both match what storefront-tanstack does. They are documented as recipes and added to the migration skill's gotchas (.agents/skills/deco-v7-to-v8-migration/reference/gotchas.md). No helper was added to a package.

Evidence

  • bun run check passes (typecheck, biome, knip, secrets audit). bun run test: 1762 passed, 104 skipped. New tests are in draft.test.ts, variants.test.ts and __conformance__/sdk.test.ts.
  • Tested end to end with Studio (feat/blocks-v8-support, feat: support Blocks v8 (content protocol) behind a flag studio#7728) and storefront-tanstack (feat/next-major, unchanged) linked to this branch, driven with Playwright. A Banner in section 5 of pages-home was made multivariate in Studio:
Step Preview ?__draft= Banner shows
Section not opened off "Register and Save Big!" (normal rules)
Variant 2 tab 127.0.0.1:4547/rpc?__variant=pages-home%40sections.5%3D1@local "Variant TWO title edited"
Back to variant 1 tab same pointer, ending %3D0 "Register and Save Big!"
Edit variant 2's title, then save reloads with the same =1 pointer "Variant TWO after a save"

After the content was put back, the old pointer still returned a 200 with the saved content, so a stale variant address is ignored.

  • Dev-stack note: createCMS keeps one CMS per process on globalThis, so a linked dev server keeps old CMS code until its worker restarts. Touch wrangler.jsonc or restart vite dev. A site that upgrades the package is not affected.

Open question

Going back from a variant tab to the section list keeps that variant forced. Studio does this on purpose ("Merely entering/auto-selecting a section must not re-navigate the iframe"), and v7 behaves the same way. Dropping the forced variant on leaving the section would be a small Studio change.

Docs: deco-sites/docs-tanstack#4 (releases-and-drafts "Preview a variant", api-reference DraftPointer.variants, matchers-and-variants, studio-compatibility, content-protocol, and the TanStack Start and Next.js guides).


Stack: v8 ← … ← 14 ← 15

🤖 Generated with Claude Code

https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig


Summary by cubic

Studio's variant tabs now preview variants on a v8 site through the ?__draft= pointer instead of a request header. A forced variant is a __variant query parameter — <block>@<jsonPath>=<index>, URL-encoded — naming the saved block, the JSON path inside it, and the variant index. parseDraftPointer lifts it out of path into DraftPointer.variants; a malformed one rejects the whole pointer. cms.forDraft applies the forced variants to the snapshot it reads, even when the source has no drafts; each addressed multivariate keeps only the forced variant with rule true, so no rule runs, and a stale address is ignored. Loaders get the pointer without the variants, so every variant of one draft shares one load and cache entry. forRelease never forces anything.

Local editing recipes

  • cms.ts must accept a new content module through import.meta.hot, and a Vite plugin must reload pages when .deco/blocks.gen.ts changes; both are documented as recipes in the migration skill's gotchas.
  • createCMS keeps one CMS per process on globalThis, so a linked dev server keeps old CMS code until a worker restart (touch wrangler.jsonc or restart vite dev).

Written for commit f25d72f. Summary will update on new commits.

Review in cubic

Studio's variant tabs preview variant N through the ?__draft= pointer
instead of a request header. The pointer's query carries reserved
__variant parameters, `<block>@<path>=<index>` URL-encoded: the saved
block that holds the multivariate, its JSON path inside that block, and
the variant to show.

- parseDraftPointer lifts them out of `path` into `variants`;
  formatDraftPointer appends them. A malformed one rejects the pointer.
- cms.forDraft applies them to the snapshot it reads, even over a source
  with no drafts (the content module): each addressed multivariate keeps
  only the forced variant with rule `true`, so no rule runs. The snapshot
  is copied along the addressed paths only; a stale address is ignored.
- Loaders get the pointer without them, so every variant of one draft
  shares one load and one cache entry. forRelease never forces anything.
- Migration skill gotchas: the two template pieces local editing needs
  (cms.ts taking a new content module via import.meta.hot, and a Vite
  plugin reloading pages when .deco/blocks.gen.ts changes).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig
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