// 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 ``; 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 "#engine/render";