feat(nextjs,tanstack): let Studio's HTML swap re-render the page through its router

Merged
TL
8h ago
+383-3

Why

After a save, Studio's HTML swap replaces the whole document of a v8 page. React keeps the old nodes and the new HTML's scripts never run, so every client component stops at its server markup. Seen on a Next.js storefront: the footer is revealed on scroll by a client component, and after any edit in the site editor it stayed hidden for good.

What

  • Protocol (@decocms/blocks/protocol): STUDIO_PAGE_REFRESH ("__decoStudioRefresh") and StudioPageRefresh, a window hook a binding sets so Studio shows a save through the page's router. Studio vendors channel.ts byte for byte.
  • @decocms/nextjs: StudioBridge renders StudioRefresh ("use client"). It calls router.refresh() for the page's own URL, or router.replace() for a moved draft pointer (the proxy reads ?__draft= on the RSC request), and resolves when the transition is over.
  • @decocms/tanstack: DecoDocument renders StudioRefresh, which reruns the loaders with router.invalidate(). For a moved draft pointer it first fetches the new link as a document, for the draft cookie that the loaders' server functions carry, then navigates. Only the matches that navigation didn't load are invalidated: a route whose loader deps include __draft already reloaded, and invalidating it again rendered every save twice in the preview.
  • Both bindings keep their own copy of the name, held equal to the protocol's by a test, as with the Studio origins. The hook is set unframed too, so no code compares the window with its parent (the Cypress frame-busting rewrite).
  • @decocms/nextjs imports next/navigation.js, because the build's NodeNext resolution rejects the extensionless specifier.
  • A page without the hook keeps the document swap. The Studio side is decocms/studio#8435.

Verification

  • bun run check passes (typecheck, lint, knip). The only warning is the existing biome.json deprecation.
  • bun run test: 3246 passed. One failure, tel-23 in observability.test.ts, passed when run alone. It's timing-based and unrelated to this change.
  • New tests: packages/nextjs/src/StudioRefresh.test.tsx and packages/tanstack/src/react/StudioRefresh.test.tsx cover refresh vs. replace/navigate, ordering, the error and other-origin cases, and unmount. studio.test.ts in both bindings checks that the name matches the protocol's.
  • Spike on a TanStack storefront: with __draft in its loader deps, each save in a local Studio now runs the page's loader once (twice before).
  • Spike on a live Next.js storefront: window.next.router.refresh() after scrolling to the footer kept the footer revealed, with the same DOM node. Scrolling up and down still hid and revealed it, so React was still attached. With Studio's document swap, the same page's footer went to data-footer-reveal="hidden" and stayed there.

šŸ¤– Generated with Claude Code