Repository navigation
feat(nextjs,tanstack): let Studio's HTML swap re-render the page through its router - #808
Merged
Merged
Conversation
…ugh its router
After a save, Studio's HTML swap replaced the whole document. React kept the
old nodes and the new HTML's scripts never ran, so every client component
stopped at its server markup: a footer revealed on scroll stayed hidden, a
carousel stopped.
The protocol now names a window hook, STUDIO_PAGE_REFRESH, that a binding
sets so Studio shows a save through the page's router instead:
- @decocms/nextjs: StudioBridge renders StudioRefresh ("use client"), which
calls router.refresh() for the page's URL, or router.replace() for a moved
draft pointer, and resolves when the transition is over.
- @decocms/tanstack: DecoDocument renders StudioRefresh, which reruns the
loaders with router.invalidate(). A moved draft pointer is first fetched as
a document, for the draft cookie the loaders' server functions carry.
A page without the hook keeps the document swap.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ution requires Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pointer A route whose loader deps include `__draft` already loads on the move to the save's URL; invalidating every match after it loaded the page a second time, and the preview rendered the save twice. Only matches the navigation didn't load are invalidated now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merged
4 tasks done
|
🎉 This PR is included in version 8.16.0 🎉 The release is available on:
Your semantic-release bot 📦🚀 |
This was referenced Nov 10, 2026
tlgimenes
added a commit
that referenced
this pull request
Nov 10, 2026
## Why With #808 live, a save in Studio's site editor re-renders a TanStack page through its router. After every load the router either restores the position saved for the URL or scrolls to the top, and `router.invalidate()` has no `resetScroll` option. So an editor working on the footer was scrolled back to the top on every save. The navigation path already passed `resetScroll: false`, but the invalidate after it, and the refresh for the page's own URL, didn't. ## What - `StudioRefresh` runs each refresh with `router.options.scrollRestoration` set to a function that answers `false`. The router reads that as "leave the scroll alone", for both restoring and resetting. The site's own setting comes back two frames after the last render, so a later navigation scrolls as usual. - Overlapping refreshes share one override. A failed refresh also gives the setting back. - Next.js isn't affected: `router.refresh()` and `router.replace(…, { scroll: false })` already keep the scroll. ## Verification - On a deployed TanStack storefront scrolled to the footer (y≈7500), the page's own `__decoStudioRefresh(location.href)` went to y=0. Running the same `router.invalidate()` with `scrollRestoration` overridden like this kept it near the footer, and the page rendered the new data. - New tests in `StudioRefresh.test.tsx`: restoration answers `false` during the loads of both paths and is back to the site's value afterwards, including after a failed refresh. - `bun run check` passes. `vitest run packages/tanstack`: 194 pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Keeps the page's scroll position when Studio shows a save on TanStack pages. Previously, re-rendering the page through `router.invalidate()` made the router restore the URL's saved position or scroll to the top, so an editor working on the footer jumped back up on every save. `StudioRefresh` now sets `router.options.scrollRestoration` to a function answering `false` while the refresh runs, which the router reads as "leave the scroll alone". The site's own setting is restored two frames after the last render; overlapping refreshes share the override, and a failed refresh also gives it back. Next.js was already unaffected, since `router.refresh()` and `router.replace(…, { scroll: false })` keep the scroll. <sup>Written for commit fb9a7a9. Summary will update on new commits.</sup> <a href="https://cubic.dev/pr/decocms/blocks/pull/809?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="View guided diff" src="https://www.cubic.dev/buttons/review-in-cubic-light.svg"></picture></a> <a href="https://www.cubic.dev/action/auto-fix/pr/decocms/blocks/809?returnTo=https%3A%2F%2Fgithub.com%2Fdecocms%2Fblocks%2Fpull%2F809&source=description" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/turn-on-auto-fix-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/turn-on-auto-fix-light.svg"><img alt="Turn on auto-fix" src="https://www.cubic.dev/buttons/turn-on-auto-fix-light.svg"></picture></a> <!-- End of auto-generated description by cubic. --> Co-authored-by: Claude Opus 5.5 <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.
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
@decocms/blocks/protocol):STUDIO_PAGE_REFRESH("__decoStudioRefresh") andStudioPageRefresh, awindowhook a binding sets so Studio shows a save through the page's router. Studio vendorschannel.tsbyte for byte.@decocms/nextjs:StudioBridgerendersStudioRefresh("use client"). It callsrouter.refresh()for the page's own URL, orrouter.replace()for a moved draft pointer (the proxy reads?__draft=on the RSC request), and resolves when the transition is over.@decocms/tanstack:DecoDocumentrendersStudioRefresh, which reruns the loaders withrouter.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__draftalready reloaded, and invalidating it again rendered every save twice in the preview.@decocms/nextjsimportsnext/navigation.js, because the build's NodeNext resolution rejects the extensionless specifier.Verification
bun run checkpasses (typecheck, lint, knip). The only warning is the existingbiome.jsondeprecation.bun run test: 3246 passed. One failure,tel-23inobservability.test.ts, passed when run alone. It's timing-based and unrelated to this change.packages/nextjs/src/StudioRefresh.test.tsxandpackages/tanstack/src/react/StudioRefresh.test.tsxcover refresh vs. replace/navigate, ordering, the error and other-origin cases, and unmount.studio.test.tsin both bindings checks that the name matches the protocol's.__draftin its loader deps, each save in a local Studio now runs the page's loader once (twice before).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 todata-footer-reveal="hidden"and stayed there.🤖 Generated with Claude Code