Repository navigation
feat(web): show a save through the page's router when its binding offers one - #8435
Merged
Merged
Conversation
…ers one Studio's HTML swap replaced the document of a Blocks v8 page after each save. React kept the old nodes and the new scripts never ran, so the page's client components stopped at their server markup (a footer revealed on scroll stayed hidden). When the page sets window.__decoStudioRefresh (STUDIO_PAGE_REFRESH, from the @decocms/nextjs and @decocms/tanstack bindings), html-swap hands it the URL and reports start and end around it; a failure reports an error, so Studio reloads the frame. Pages without it keep the document swap. channel.ts is the new byte-identical copy of @decocms/blocks/protocol's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tlgimenes
enabled auto-merge (squash)
November 10, 2026 18:25
tlgimenes
added a commit
to decocms/blocks
that referenced
this pull request
Nov 10, 2026
…ugh its router (#808) ## 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](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
decocms Bot
pushed a commit
that referenced
this pull request
Nov 10, 2026
PR: #8435 feat(web): show a save through the page's router when its binding offers one Bump type: minor - decocms (apps/api/package.json): 4.567.0 -> 4.568.0 Deploy-Scope: web
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.
What is this contribution about?
After each save in the site editor, Studio's HTML swap replaces the document of a Blocks v8 page. React keeps the old nodes and the new HTML's scripts never run, so the page's client components stop at their server markup. On a Next.js storefront, the footer (revealed on scroll by a client component) stayed hidden after any edit.
When the page sets
window.__decoStudioRefresh(STUDIO_PAGE_REFRESHin@decocms/blocks/protocol, set by the@decocms/nextjsand@decocms/tanstackbindings),html-swaphands it the URL instead of swapping the document:html-swap:start/endand theHTML_SWAP_EVENTphases, so the progress bar and the wireframe behave as before.html-swap:error, so Studio reloads the frame.Pages without the hook (bindings not upgraded yet) keep the document swap.
channel.tsis the new byte-identical copy of@decocms/blocks/protocol's (cmpchecked). The bindings' side is decocms/blocks#808.How did you verify your code works?
bun run verifypasses: 13276 tests, 0 failed.tsc --noEmitinapps/webis clean.apps/web/src/studio-modules/html-swap.test.ts:html-swap:error.data-footer-reveal="hidden".router.refresh()instead (what the binding's hook calls) kept it revealed, on the same node, and scroll still toggled it.How to Test
Review Checklist
🤖 Generated with Claude Code
Summary by cubic
Fixes the site editor's save flow so a Blocks v8 page keeps its client components working after an edit. The previous document swap replaced the HTML but React kept the old nodes and the new scripts never ran, leaving server-rendered-only components (like a footer revealed on scroll) stuck at their initial markup.
Now, when the page's binding sets
window.__decoStudioRefresh(STUDIO_PAGE_REFRESH, used by the Next.js and TanStack bindings),html-swaphands it the target URL so the page re-renders through its own router instead of swapping the document. Pages without the hook keep the old document swap.Behavior details
HTML_SWAP_EVENTphases still fire around the router refresh, so the progress bar and wireframe behave as before.html-swap:errorand Studio reloads the frame; the page's origin check runs before the hook is invoked.channel.tsnow carries a byte-identical copy ofSTUDIO_PAGE_REFRESHfrom@decocms/blocks/protocol; the bindings' side ships in feat(nextjs,tanstack): let Studio's HTML swap re-render the page through its router blocks#808.Written for commit cf095e5. Summary will update on new commits.