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 };