// 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);
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(" 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,
>(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 `${begin}`;
do {
await new Promise((done) => (resolve = done));
yield* chunks;
chunks = [];
} while (state.nextId < state.completed);
return addonOutput as unknown as T;
}
import * as render from "./render.ts";