sitegen/framework/engine/suspense.ts
clover caruso f1d4be2553 feat: dynamic page regeneration (#24)
the asset system is reworked to support "dynamic" entries, where each
entry is a separate file on disk containing the latest generation's
headers+raw+gzip+zstd. when calling view.regenerate, it will look for
pages that had "export const regenerate" during generation, and render
those pages using the view system, but then store the results as assets
instead of sending as a response.

pages configured as regenerable are also bundled as views, using the
non-aliasing key "page:${page.id}". this cannot alias because file
paths may not contain a colon.
2025-08-11 22:43:27 -07:00

102 lines
3.1 KiB
TypeScript

// This file implements out-of-order HTML streaming, mimicking the React
// Suspense API. To use, place Suspense around an expensive async component
// and render the page with 'renderStreaming'.
//
// Implementation of this article:
// https://lamplightdev.com/blog/2024/01/10/streaming-html-out-of-order-without-javascript/
//
// I would link to an article from Next.js or React, but their examples
// are too verbose and not informative to what they actually do.
const userData = render.userData<null | State>(() => null);
interface SuspenseProps {
children: render.Node;
fallback?: render.Node;
}
interface State {
nested: boolean;
nextId: number;
completed: number;
pushChunk(name: string, node: render.ResolvedNode): void;
}
export function Suspense({ children, fallback }: SuspenseProps): render.Node {
const state = userData.get();
if (!state) return children;
if (state.nested) throw new Error("<Suspense> cannot be nested");
const parent = UNWRAP(render.current);
const r = render.initRender(true, { [userData.key]: { nested: true } });
const resolved = render.resolveNode(r, children);
if (r.async == 0) return render.raw(resolved);
const name = "suspended_" + ++state.nextId;
state.nested = true;
const ip: [render.ResolvedNode] = [
render.resolvedElement(
"slot",
{ name },
fallback ? render.resolveNode(parent, fallback) : "",
),
];
state.nested = false;
r.asyncDone = () => {
const rejections = r.rejections;
if (rejections && rejections.length > 0) throw new Error("TODO");
state.pushChunk?.(name, ip[0] = resolved);
};
return render.raw(ip);
}
// TODO: add a User-Agent parameter, which is used to determine if a
// fallback path must be used.
// - Before ~2024 needs to use a JS implementation.
// - IE should probably bail out entirely.
export async function* renderStreaming<
T extends render.Addons = Record<never, unknown>,
>(node: render.Node, addon: T = {} as T) {
const {
text: begin,
addon: { [userData.key]: state, ...addonOutput },
} = await render.async(node, {
...addon,
[userData.key]: {
nested: false,
nextId: 0,
completed: 0,
pushChunk: () => {},
} satisfies State as State,
});
if (state.nextId === 0) {
yield begin;
return addonOutput as unknown as T;
}
let resolve: (() => void) | null = null;
let chunks: string[] = [];
state.pushChunk = (slot, node) => {
while (node.length === 1 && Array.isArray(node)) node = node[0];
if (node[0] === render.kElement) {
(node as render.ResolvedElement)[2].slot = slot;
} else {
node = [
render.kElement,
"clover-suspense",
{
style: "display:contents",
slot,
},
node,
];
}
chunks.push(render.stringifyNode(node));
resolve?.();
};
yield `<template shadowrootmode=open>${begin}</template>`;
do {
await new Promise<void>((done) => (resolve = done));
yield* chunks;
chunks = [];
} while (state.nextId < state.completed);
return addonOutput as unknown as T;
}
import * as render from "#engine/render";