Skip to content

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

Merged
tlgimenes merged 3 commits into
mainfrom
feat/nextjs-studio-soft-refresh
Nov 10, 2026
Merged

tlgimenes merged 3 commits into
mainfrom
feat/nextjs-studio-soft-refresh

Conversation

@tlgimenes

@tlgimenes tlgimenes commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

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 feat(web): show a save through the page's router when its binding offers one 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

tlgimenes and others added 3 commits November 10, 2026 13:25
…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>
@tlgimenes
tlgimenes requested a review from a team November 10, 2026 18:24
@tlgimenes
tlgimenes merged commit 76baef6 into main Nov 10, 2026
4 checks passed
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 8.16.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant