From cf095e501d82160152655b6f79b9cc1d6a05bf79 Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 10 Nov 2026 13:26:04 -0300 Subject: [PATCH] feat(web): show a save through the page's router when its binding offers 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 --- apps/web/src/studio-modules/channel.ts | 18 ++++++ apps/web/src/studio-modules/html-swap.test.ts | 60 +++++++++++++++++++ apps/web/src/studio-modules/html-swap.ts | 30 +++++++++- apps/web/src/studio-modules/messages.ts | 12 ++-- 4 files changed, 113 insertions(+), 7 deletions(-) diff --git a/apps/web/src/studio-modules/channel.ts b/apps/web/src/studio-modules/channel.ts index d7b6ebe0b5..eb3f2a6749 100644 --- a/apps/web/src/studio-modules/channel.ts +++ b/apps/web/src/studio-modules/channel.ts @@ -118,6 +118,24 @@ export type StudioChannelModule< Out extends ModuleMessage = ModuleMessage, > = (context: StudioChannelContext) => void | Promise; +/** + * The `window` property a binding sets when its framework can show a save by + * re-rendering the page in place (Next.js: `router.refresh()`): a + * {@link StudioPageRefresh}. Studio's HTML swap calls it instead of replacing + * the document, whose new scripts never run, so the page's client components + * would stop at their server markup (a footer revealed on scroll, a carousel). + * A page without it gets the document swap. + */ +export const STUDIO_PAGE_REFRESH = "__decoStudioRefresh"; + +/** + * Shows `url` (absolute, on the page's own origin: the page's URL, or a new one + * whose draft pointer moved) from the server without reloading, keeping the + * page's client state; resolves once the new content is on screen, rejects + * when it can't show it (Studio then reloads the frame). + */ +export type StudioPageRefresh = (url: string) => Promise; + /** Types a module's default export: `export default defineStudioModule((channel) => …)`. */ export function defineStudioModule< In extends ModuleMessage = ModuleMessage, diff --git a/apps/web/src/studio-modules/html-swap.test.ts b/apps/web/src/studio-modules/html-swap.test.ts index 090f0364c6..3fe83db775 100644 --- a/apps/web/src/studio-modules/html-swap.test.ts +++ b/apps/web/src/studio-modules/html-swap.test.ts @@ -1,6 +1,7 @@ import { setupComponentTest } from "../../test/setup"; setupComponentTest(); import { afterEach, describe, expect, it } from "bun:test"; +import { STUDIO_PAGE_REFRESH } from "./channel"; import htmlSwap from "./html-swap"; import { HTML_SWAP_EVENT, type HtmlSwapOut } from "./messages"; import { startModule } from "./test-channel"; @@ -21,6 +22,7 @@ function viewTransitions( } afterEach(() => { + Reflect.deleteProperty(window, STUDIO_PAGE_REFRESH); globalThis.fetch = realFetch; viewTransitions(null); document.documentElement.removeAttribute("data-theme"); @@ -171,4 +173,62 @@ describe("the HTML swap module", () => { { type: "html-swap:end", id: 2 }, ]); }); + + describe("a page that refreshes itself (its binding set STUDIO_PAGE_REFRESH)", () => { + it("hands it the URL instead of swapping the document, reporting start then end", async () => { + document.body.innerHTML = "

Kept

"; + const kept = document.querySelector("h1"); + const fetched = serve(NEXT); + const asked: string[] = []; + Reflect.set(window, STUDIO_PAGE_REFRESH, async (url: string) => { + asked.push(url); + }); + const phases: string[] = []; + const onSwap = (e: Event) => phases.push((e as CustomEvent).detail.phase); + addEventListener(HTML_SWAP_EVENT, onSwap); + const { sent, receive } = await startModule(htmlSwap); + const next = new URL("?__draft=b", location.href).href; + receive({ type: "html-swap:refresh", id: 4, url: next }); + await until(sent, "html-swap:end", 4); + removeEventListener(HTML_SWAP_EVENT, onSwap); + expect(asked).toEqual([next]); + expect(fetched).toEqual([]); + expect(phases).toEqual(["start", "end"]); + // React's nodes stay where they are. + expect(document.querySelector("h1")).toBe(kept); + expect(sent).toEqual([ + { type: "html-swap:start", id: 4 }, + { type: "html-swap:end", id: 4 }, + ]); + }); + + it("reports an error when it fails, so Studio reloads the frame", async () => { + Reflect.set(window, STUDIO_PAGE_REFRESH, async () => { + throw new Error("The page answered 500"); + }); + const { sent, receive } = await startModule(htmlSwap); + receive({ type: "html-swap:refresh", id: 6 }); + await until(sent, "html-swap:error", 6); + expect(sent.at(-1)).toEqual({ + type: "html-swap:error", + id: 6, + message: "The page answered 500", + }); + }); + + it("never hands it another origin's URL", async () => { + let called = false; + Reflect.set(window, STUDIO_PAGE_REFRESH, async () => { + called = true; + }); + const { sent, receive } = await startModule(htmlSwap); + receive({ + type: "html-swap:refresh", + id: 8, + url: "https://elsewhere.example/", + }); + await until(sent, "html-swap:error", 8); + expect(called).toBe(false); + }); + }); }); diff --git a/apps/web/src/studio-modules/html-swap.ts b/apps/web/src/studio-modules/html-swap.ts index ef7ba8b14b..86d59ca678 100644 --- a/apps/web/src/studio-modules/html-swap.ts +++ b/apps/web/src/studio-modules/html-swap.ts @@ -12,11 +12,19 @@ * reports nothing. The other modules in the page hear the swap through * {@link HTML_SWAP_EVENT} on `window` (the wireframe re-attaches its outline). * - * The window, its listeners and the modules survive the swap; the new HTML's + * A page whose binding sets its own refresh (`window[STUDIO_PAGE_REFRESH]`: + * Next.js and TanStack re-render through their router) gets that instead, so + * React stays attached and its client components keep working. Otherwise the + * window, its listeners and the modules survive the swap, but the new HTML's * scripts don't run (as with the in-place render v7 sites get), so what the - * page's scripts made interactive stays as it was until the next load. + * page's scripts made interactive stays at its server markup until the next + * load. */ -import { defineStudioModule } from "./channel"; +import { + defineStudioModule, + STUDIO_PAGE_REFRESH, + type StudioPageRefresh, +} from "./channel"; import { HTML_SWAP_EVENT, type HtmlSwapEventDetail, @@ -83,6 +91,12 @@ function replaceDocument(doc: Document, html: string) { root.innerHTML = next.documentElement.innerHTML; } +/** The page's own refresh, when its binding set one. */ +function pageRefreshOf(win: Window): StudioPageRefresh | null { + const value: unknown = Reflect.get(win, STUDIO_PAGE_REFRESH); + return typeof value === "function" ? (value as StudioPageRefresh) : null; +} + export default defineStudioModule((channel) => { let current: AbortController | null = null; /** @@ -106,6 +120,16 @@ export default defineStudioModule((channel) => { if (target.origin !== location.origin) { throw new Error(`Not this page's origin: ${target.origin}`); } + const pageRefresh = pageRefreshOf(window); + if (pageRefresh) { + await pageRefresh(target.href); + if (!live()) return; + shown = target.href; + current = null; + announce("end"); + channel.send({ type: "html-swap:end", id }); + return; + } const res = await fetch(target.href, { headers: { accept: "text/html" }, credentials: "same-origin", diff --git a/apps/web/src/studio-modules/messages.ts b/apps/web/src/studio-modules/messages.ts index 5e9c6c3aaf..211be8012f 100644 --- a/apps/web/src/studio-modules/messages.ts +++ b/apps/web/src/studio-modules/messages.ts @@ -8,9 +8,11 @@ * - `wireframe`: outlines the page's editable sections on hover, reports the * hovered and the clicked one, and keeps the page from navigating away * while the Blocks panel edits it. - * - `html-swap`: after a save, fetches the page's new HTML and swaps the - * document inside a View Transition (at once where the API is missing), - * reporting start and end, so the preview's progress bar tracks the swap. + * - `html-swap`: after a save, shows the page's new content: through the + * page's own router when its binding offers one (`STUDIO_PAGE_REFRESH`), + * else by fetching its new HTML and swapping the document inside a View + * Transition (at once where the API is missing), reporting start and end, + * so the preview's progress bar tracks it. * * Every message type is the module's name, `:`, the message. */ @@ -84,7 +86,9 @@ export type HtmlSwapIn = * Show the page's current content: fetch its HTML again and swap it in. * With `url` (a new URL of the page: a save moved the draft pointer in it), * that URL's HTML, on the page's own origin only; a later refresh without - * one reads it again. The page's address stays as it loaded. + * one reads it again. The page's address stays as it loaded, unless the + * page refreshes itself through its router (`STUDIO_PAGE_REFRESH`), which + * moves it to that URL. */ { type: "html-swap:refresh"; id: number; url?: string };