sitegen/framework/engine/suspense.ts

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 kState = Symbol("SuspenseState");
interface SuspenseProps {
children: ssr.Node;
fallback?: ssr.Node;
}
interface State {
nested: boolean;
nextId: number;
completed: number;
pushChunk(name: string, node: ssr.ResolvedNode): void;
}
export function Suspense({ children, fallback }: SuspenseProps): ssr.Node {
const state = ssr.getUserData<State>(kState, () => {
throw new Error("Can only use <Suspense> with 'renderStreaming'");
});
if (state.nested) throw new Error("<Suspense> cannot be nested");
const parent = ssr.getCurrentRender()!;
const r = ssr.initRender(true, { [kState]: { nested: true } });
const resolved = ssr.resolveNode(r, children);
if (r.async == 0) return ssr.html(resolved);
const name = "suspended_" + (++state.nextId);
state.nested = true;
const ip: [ssr.ResolvedNode] = [
[
ssr.kElement,
"slot",
{ name },
fallback ? ssr.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 ssr.html(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 ssr.Addons = Record<never, unknown>,
>(
node: ssr.Node,
addon: T = {} as T,
) {
const {
text: begin,
addon: { [kState]: state, ...addonOutput },
} = await ssr.ssrAsync(node, {
...addon,
[kState]: {
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] === ssr.kElement) {
(node as ssr.ResolvedElement)[2].slot = slot;
} else {
node = [ssr.kElement, "clover-suspense", {
style: "display:contents",
slot,
}, node];
}
chunks.push(ssr.renderNode(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 ssr from "./ssr.ts";