authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-06-15 01:25:58-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-14 02:40:44-07:00
log76133659a0900c85d5f2442f79ab61732f3cb22b
tree70dedaec5ff2d935a2a83960bba50fedb494abd5
parent28e66f916134e66c86a480c26af084148e5abaec
signature Commit is signed but in an unrecognized format.

experiment: streaming suspense implementation


6 files changed, 187 insertions(+), 6 deletions(-)

framework/engine/jsx-runtime.ts+1-1
......@@ -35,7 +35,7 @@ declare global {
3535 [name: string]: Record<string, unknown>;
3636 }
3737 interface ElementChildrenAttribute {
38 children: unknown;
38 children: Node;
3939 }
4040 type Element = engine.Node;
4141 type ElementType = keyof IntrinsicElements | engine.Component;
framework/engine/marko-runtime.ts+21-2
......@@ -13,7 +13,7 @@ export const createTemplate = (
1313) => {
1414 const { render } = marko.createTemplate(templateId, renderer);
1515 function wrap(props: Record<string, unknown>, n: number) {
16 // Marko components
16 // Marko Custom Tags
1717 const cloverAsyncMarker = { isAsync: false };
1818 let r: engine.Render | undefined = undefined;
1919 try {
......@@ -21,6 +21,7 @@ export const createTemplate = (
2121 } catch {}
2222 // Support using Marko outside of Clover SSR
2323 if (r) {
24 engine.setCurrentRender(null);
2425 const markoResult = render.call(renderer, {
2526 ...props,
2627 $global: { clover: r, cloverAsyncMarker },
......@@ -48,10 +49,10 @@ export const dynamicTag = (
4849 inputIsArgs?: 1,
4950 serializeReason?: 1 | 0,
5051) => {
51 marko.dynamicTag;
5252 if (typeof tag === "function") {
5353 clover: {
5454 const unwrapped = (tag as any).unwrapped;
55 console.log({ tag, unwrapped });
5556 if (unwrapped) {
5657 tag = unwrapped;
5758 break clover;
......@@ -119,6 +120,23 @@ export function fork(
119120 marko.fork(scopeId, accessor, promise, callback, serializeMarker);
120121}
121122
123export function escapeXML(input: unknown) {
124 // The rationale of this check is that the default toString method
125 // creating `[object Object]` is universally useless to any end user.
126 if (
127 (typeof input === "object" && input &&
128 // only block this if it's the default `toString`
129 input.toString === Object.prototype.toString)
130 ) {
131 throw new Error(
132 `Unexpected object in template placeholder: '` +
133 util.inspect({ name: "clover" }) + "'. " +
134 `To emit a literal '[object Object]', use \${String(value)}`,
135 );
136 }
137 return marko.escapeXML(input);
138}
139
122140interface Async {
123141 isAsync: boolean;
124142}
......@@ -127,3 +145,4 @@ import * as engine from "./ssr.ts";
127145import type { ServerRenderer } from "marko/html/template";
128146import { type Accessor } from "marko/common/types";
129147import * as marko from "#marko/html";
148import * as util from "node:util";
framework/engine/ssr.test.tsx created+20
......@@ -0,0 +1,20 @@
1import { test } from "node:test";
2import * as engine from "./ssr.ts";
3
4test("sanity", (t) => t.assert.equal(engine.ssrSync("gm <3").text, "gm &lt;3"));
5test("simple tree", (t) =>
6 t.assert.equal(
7 engine.ssrSync(
8 <main class={["a", "b"]}>
9 <h1 style="background-color:red">hello world</h1>
10 <p>haha</p>
11 {1}|
12 {0}|
13 {true}|
14 {false}|
15 {null}|
16 {undefined}|
17 </main>,
18 ).text,
19 '<main class="a b"><h1 style=background-color:red>hello world</h1><p>haha</p>1|0|||||</main>',
20 ));
framework/engine/ssr.ts+3-3
......@@ -6,7 +6,7 @@
66// Add-ons to the rendering engine can provide opaque data, And retrieve it
77// within component calls with 'getAddonData'. For example, 'sitegen' uses this
88// to track needed client scripts without introducing patches to the engine.
9type Addons = Record<string | symbol, unknown>;
9export type Addons = Record<string | symbol, unknown>;
1010
1111export function ssrSync(node: Node): Result;
1212export function ssrSync<A extends Addons>(node: Node, addon: A): Result<A>;
......@@ -38,7 +38,7 @@ export function ssrAsync(node: Node, addon: Addons = {}) {
3838}
3939
4040/** Inline HTML into a render without escaping it */
41export function html(rawText: string) {
41export function html(rawText: ResolvedNode): DirectHtml {
4242 return [kDirectHtml, rawText];
4343}
4444
......@@ -80,7 +80,7 @@ export type Element = [
8080 type: string | Component,
8181 props: Record<string, unknown>,
8282];
83export type DirectHtml = [tag: typeof kDirectHtml, html: string];
83export type DirectHtml = [tag: typeof kDirectHtml, html: ResolvedNode];
8484/**
8585 * Components must return a value; 'undefined' is prohibited here
8686 * to avoid functions that are missing a return statement.
framework/engine/suspense.test.tsx created+40
......@@ -0,0 +1,40 @@
1import { test } from "node:test";
2import { renderStreaming, Suspense } from "./suspense.ts";
3
4test("sanity", async (t) => {
5 let resolve: () => void = null!;
6
7 // @ts-expect-error
8 async function AsyncComponent() {
9 await new Promise<void>((done) => resolve = done);
10 return <button>wow!</button>;
11 }
12
13 const example = (
14 <main>
15 <h1>app shell</h1>
16 <Suspense fallback="loading...">
17 <AsyncComponent />
18 </Suspense>
19 <footer>(c) 2025</footer>
20 </main>
21 );
22
23 const iterator = renderStreaming(example);
24 const assertContinue = (actual: unknown, value: unknown) =>
25 t.assert.deepEqual(actual, { done: false, value });
26
27 assertContinue(
28 await iterator.next(),
29 "<template shadowrootmode=open><main><h1>app shell</h1><slot name=suspended_1>loading...</slot><footer>(c) 2025</footer></main></template>",
30 );
31 t.assert.ok(resolve !== null), resolve();
32 assertContinue(
33 await iterator.next(),
34 "<button slot=suspended_1>wow!</button>",
35 );
36 t.assert.deepEqual(
37 await iterator.next(),
38 { done: true, value: {} },
39 );
40});
framework/engine/suspense.ts created+102
......@@ -0,0 +1,102 @@
1// This file implements out-of-order HTML streaming, mimicking the React
2// Suspense API. To use, place Suspense around an expensive async component
3// and render the page with 'renderStreaming'.
4//
5// Implementation of this article:
6// https://lamplightdev.com/blog/2024/01/10/streaming-html-out-of-order-without-javascript/
7//
8// I would link to an article from Next.js or React, but their examples
9// are too verbose and not informative to what they actually do.
10const kState = Symbol("SuspenseState");
11
12interface SuspenseProps {
13 children: ssr.Node;
14 fallback?: ssr.Node;
15}
16
17interface State {
18 nested: boolean;
19 nextId: number;
20 completed: number;
21 pushChunk(name: string, node: ssr.ResolvedNode): void;
22}
23
24export function Suspense({ children, fallback }: SuspenseProps): ssr.Node {
25 const state = ssr.getUserData<State>(kState, () => {
26 throw new Error("Can only use <Suspense> with 'renderStreaming'");
27 });
28 if (state.nested) throw new Error("<Suspense> cannot be nested");
29 const parent = ssr.getCurrentRender()!;
30 const r = ssr.initRender(true, { [kState]: { nested: true } });
31 const resolved = ssr.resolveNode(r, children);
32 if (r.async == 0) return ssr.html(resolved);
33 const name = "suspended_" + (++state.nextId);
34 state.nested = true;
35 const ip: [ssr.ResolvedNode] = [
36 [
37 ssr.kElement,
38 "slot",
39 { name },
40 fallback ? ssr.resolveNode(parent, fallback) : "",
41 ],
42 ];
43 state.nested = false;
44 r.asyncDone = () => {
45 const rejections = r.rejections;
46 if (rejections && rejections.length > 0) throw new Error("TODO");
47 state.pushChunk?.(name, ip[0] = resolved);
48 };
49 return ssr.html(ip);
50}
51
52// TODO: add a User-Agent parameter, which is used to determine if a
53// fallback path must be used.
54// - Before ~2024 needs to use a JS implementation.
55// - IE should probably bail out entirely.
56export async function* renderStreaming<
57 T extends ssr.Addons = Record<never, unknown>,
58>(
59 node: ssr.Node,
60 addon: T = {} as T,
61) {
62 const {
63 text: begin,
64 addon: { [kState]: state, ...addonOutput },
65 } = await ssr.ssrAsync(node, {
66 ...addon,
67 [kState]: {
68 nested: false,
69 nextId: 0,
70 completed: 0,
71 pushChunk: () => {},
72 } satisfies State as State,
73 });
74 if (state.nextId === 0) {
75 yield begin;
76 return addonOutput as unknown as T;
77 }
78 let resolve: (() => void) | null = null;
79 let chunks: string[] = [];
80 state.pushChunk = (slot, node) => {
81 while (node.length === 1 && Array.isArray(node)) node = node[0];
82 if (node[0] === ssr.kElement) {
83 (node as ssr.ResolvedElement)[2].slot = slot;
84 } else {
85 node = [ssr.kElement, "clover-suspense", {
86 style: "display:contents",
87 slot,
88 }, node];
89 }
90 chunks.push(ssr.renderNode(node));
91 resolve?.();
92 };
93 yield `<template shadowrootmode=open>${begin}</template>`;
94 do {
95 await new Promise<void>((done) => resolve = done);
96 yield* chunks;
97 chunks = [];
98 } while (state.nextId < state.completed);
99 return addonOutput as unknown as T;
100}
101
102import * as ssr from "./ssr.ts";