Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/web/src/studio-modules/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,24 @@ export type StudioChannelModule<
Out extends ModuleMessage = ModuleMessage,
> = (context: StudioChannelContext<In, Out>) => void | Promise<void>;

/**
* 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<void>;

/** Types a module's default export: `export default defineStudioModule<In, Out>((channel) => …)`. */
export function defineStudioModule<
In extends ModuleMessage = ModuleMessage,
Expand Down
60 changes: 60 additions & 0 deletions apps/web/src/studio-modules/html-swap.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -21,6 +22,7 @@ function viewTransitions(
}

afterEach(() => {
Reflect.deleteProperty(window, STUDIO_PAGE_REFRESH);
globalThis.fetch = realFetch;
viewTransitions(null);
document.documentElement.removeAttribute("data-theme");
Expand Down Expand Up @@ -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 = "<h1>Kept</h1>";
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);
});
});
});
30 changes: 27 additions & 3 deletions apps/web/src/studio-modules/html-swap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<HtmlSwapIn, HtmlSwapOut>((channel) => {
let current: AbortController | null = null;
/**
Expand All @@ -106,6 +120,16 @@ export default defineStudioModule<HtmlSwapIn, HtmlSwapOut>((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",
Expand Down
12 changes: 8 additions & 4 deletions apps/web/src/studio-modules/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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 };

Expand Down
Loading