2025-07-07 20:58:02 -07:00
|
|
|
// 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.
|
2025-08-02 19:22:07 -07:00
|
|
|
const userData = render.userData<null | State>(() => null);
|
2025-07-07 20:58:02 -07:00
|
|
|
|
|
|
|
interface SuspenseProps {
|
2025-08-02 19:22:07 -07:00
|
|
|
children: render.Node;
|
|
|
|
fallback?: render.Node;
|
2025-07-07 20:58:02 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
interface State {
|
|
|
|
nested: boolean;
|
|
|
|
nextId: number;
|
|
|
|
completed: number;
|
2025-08-02 19:22:07 -07:00
|
|
|
pushChunk(name: string, node: render.ResolvedNode): void;
|
2025-07-07 20:58:02 -07:00
|
|
|
}
|
|
|
|
|
2025-08-02 19:22:07 -07:00
|
|
|
export function Suspense({ children, fallback }: SuspenseProps): render.Node {
|
|
|
|
const state = userData.get();
|
|
|
|
if (!state) return children;
|
2025-07-07 20:58:02 -07:00
|
|
|
if (state.nested) throw new Error("<Suspense> cannot be nested");
|
2025-08-02 19:22:07 -07:00
|
|
|
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;
|
2025-07-07 20:58:02 -07:00
|
|
|
state.nested = true;
|
2025-08-02 19:22:07 -07:00
|
|
|
const ip: [render.ResolvedNode] = [
|
|
|
|
render.resolvedElement(
|
2025-07-07 20:58:02 -07:00
|
|
|
"slot",
|
|
|
|
{ name },
|
2025-08-02 19:22:07 -07:00
|
|
|
fallback ? render.resolveNode(parent, fallback) : "",
|
|
|
|
),
|
2025-07-07 20:58:02 -07:00
|
|
|
];
|
|
|
|
state.nested = false;
|
|
|
|
r.asyncDone = () => {
|
|
|
|
const rejections = r.rejections;
|
|
|
|
if (rejections && rejections.length > 0) throw new Error("TODO");
|
2025-08-02 19:22:07 -07:00
|
|
|
state.pushChunk?.(name, (ip[0] = resolved));
|
2025-07-07 20:58:02 -07:00
|
|
|
};
|
2025-08-02 19:22:07 -07:00
|
|
|
return render.raw(ip);
|
2025-07-07 20:58:02 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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<
|
2025-08-02 19:22:07 -07:00
|
|
|
T extends render.Addons = Record<never, unknown>,
|
|
|
|
>(node: render.Node, addon: T = {} as T) {
|
2025-07-07 20:58:02 -07:00
|
|
|
const {
|
|
|
|
text: begin,
|
2025-08-02 19:22:07 -07:00
|
|
|
addon: { [userData.key]: state, ...addonOutput },
|
|
|
|
} = await render.async(node, {
|
2025-07-07 20:58:02 -07:00
|
|
|
...addon,
|
2025-08-02 19:22:07 -07:00
|
|
|
[userData.key]: {
|
2025-07-07 20:58:02 -07:00
|
|
|
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];
|
2025-08-02 19:22:07 -07:00
|
|
|
if (node[0] === render.kElement) {
|
|
|
|
(node as render.ResolvedElement)[2].slot = slot;
|
2025-07-07 20:58:02 -07:00
|
|
|
} else {
|
2025-08-02 19:22:07 -07:00
|
|
|
node = [
|
|
|
|
render.kElement,
|
|
|
|
"clover-suspense",
|
|
|
|
{
|
|
|
|
style: "display:contents",
|
|
|
|
slot,
|
|
|
|
},
|
|
|
|
node,
|
|
|
|
];
|
2025-07-07 20:58:02 -07:00
|
|
|
}
|
2025-08-02 19:22:07 -07:00
|
|
|
chunks.push(render.stringifyNode(node));
|
2025-07-07 20:58:02 -07:00
|
|
|
resolve?.();
|
|
|
|
};
|
|
|
|
yield `<template shadowrootmode=open>${begin}</template>`;
|
|
|
|
do {
|
2025-08-02 19:22:07 -07:00
|
|
|
await new Promise<void>((done) => (resolve = done));
|
2025-07-07 20:58:02 -07:00
|
|
|
yield* chunks;
|
|
|
|
chunks = [];
|
|
|
|
} while (state.nextId < state.completed);
|
|
|
|
return addonOutput as unknown as T;
|
|
|
|
}
|
|
|
|
|
2025-08-02 19:22:07 -07:00
|
|
|
import * as render from "./render.ts";
|