// 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(kState, () => { throw new Error("Can only use with 'renderStreaming'"); }); if (state.nested) throw new Error(" 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, >( 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 ``; do { await new Promise((done) => resolve = done); yield* chunks; chunks = []; } while (state.nextId < state.completed); return addonOutput as unknown as T; } import * as ssr from "./ssr.ts";