102 lines
3 KiB
TypeScript
102 lines
3 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";
|