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 {...@@ -35,7 +35,7 @@ declare global {
35 [name: string]: Record<string, unknown>; 35 [name: string]: Record<string, unknown>;
36 } 36 }
37 interface ElementChildrenAttribute { 37 interface ElementChildrenAttribute {
38 children: unknown; 38 children: Node;
39 } 39 }
40 type Element = engine.Node; 40 type Element = engine.Node;
41 type ElementType = keyof IntrinsicElements | engine.Component; 41 type ElementType = keyof IntrinsicElements | engine.Component;
framework/engine/marko-runtime.ts+21-2
...@@ -13,7 +13,7 @@ export const createTemplate = (...@@ -13,7 +13,7 @@ export const createTemplate = (
13) => { 13) => {
14 const { render } = marko.createTemplate(templateId, renderer); 14 const { render } = marko.createTemplate(templateId, renderer);
15 function wrap(props: Record<string, unknown>, n: number) { 15 function wrap(props: Record<string, unknown>, n: number) {
16 // Marko components 16 // Marko Custom Tags
17 const cloverAsyncMarker = { isAsync: false }; 17 const cloverAsyncMarker = { isAsync: false };
18 let r: engine.Render | undefined = undefined; 18 let r: engine.Render | undefined = undefined;
19 try { 19 try {
...@@ -21,6 +21,7 @@ export const createTemplate = (...@@ -21,6 +21,7 @@ export const createTemplate = (
21 } catch {} 21 } catch {}
22 // Support using Marko outside of Clover SSR 22 // Support using Marko outside of Clover SSR
23 if (r) { 23 if (r) {
24 engine.setCurrentRender(null);
24 const markoResult = render.call(renderer, { 25 const markoResult = render.call(renderer, {
25 ...props, 26 ...props,
26 $global: { clover: r, cloverAsyncMarker }, 27 $global: { clover: r, cloverAsyncMarker },
...@@ -48,10 +49,10 @@ export const dynamicTag = (...@@ -48,10 +49,10 @@ export const dynamicTag = (
48 inputIsArgs?: 1, 49 inputIsArgs?: 1,
49 serializeReason?: 1 | 0, 50 serializeReason?: 1 | 0,
50) => { 51) => {
51 marko.dynamicTag;
52 if (typeof tag === "function") { 52 if (typeof tag === "function") {
53 clover: { 53 clover: {
54 const unwrapped = (tag as any).unwrapped; 54 const unwrapped = (tag as any).unwrapped;
55 console.log({ tag, unwrapped });
55 if (unwrapped) { 56 if (unwrapped) {
56 tag = unwrapped; 57 tag = unwrapped;
57 break clover; 58 break clover;
...@@ -119,6 +120,23 @@ export function fork(...@@ -119,6 +120,23 @@ export function fork(
119 marko.fork(scopeId, accessor, promise, callback, serializeMarker); 120 marko.fork(scopeId, accessor, promise, callback, serializeMarker);
120} 121}
121 122
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
122interface Async { 140interface Async {
123 isAsync: boolean; 141 isAsync: boolean;
124} 142}
...@@ -127,3 +145,4 @@ import * as engine from "./ssr.ts";...@@ -127,3 +145,4 @@ import * as engine from "./ssr.ts";
127import type { ServerRenderer } from "marko/html/template"; 145import type { ServerRenderer } from "marko/html/template";
128import { type Accessor } from "marko/common/types"; 146import { type Accessor } from "marko/common/types";
129import * as marko from "#marko/html"; 147import * 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 @@...@@ -6,7 +6,7 @@
6// Add-ons to the rendering engine can provide opaque data, And retrieve it 6// Add-ons to the rendering engine can provide opaque data, And retrieve it
7// within component calls with 'getAddonData'. For example, 'sitegen' uses this 7// within component calls with 'getAddonData'. For example, 'sitegen' uses this
8// to track needed client scripts without introducing patches to the engine. 8// to track needed client scripts without introducing patches to the engine.
9type Addons = Record<string | symbol, unknown>; 9export type Addons = Record<string | symbol, unknown>;
10 10
11export function ssrSync(node: Node): Result; 11export function ssrSync(node: Node): Result;
12export function ssrSync<A extends Addons>(node: Node, addon: A): Result<A>; 12export function ssrSync<A extends Addons>(node: Node, addon: A): Result<A>;
...@@ -38,7 +38,7 @@ export function ssrAsync(node: Node, addon: Addons = {}) {...@@ -38,7 +38,7 @@ export function ssrAsync(node: Node, addon: Addons = {}) {
38} 38}
39 39
40/** Inline HTML into a render without escaping it */ 40/** Inline HTML into a render without escaping it */
41export function html(rawText: string) { 41export function html(rawText: ResolvedNode): DirectHtml {
42 return [kDirectHtml, rawText]; 42 return [kDirectHtml, rawText];
43} 43}
44 44
...@@ -80,7 +80,7 @@ export type Element = [...@@ -80,7 +80,7 @@ export type Element = [
80 type: string | Component, 80 type: string | Component,
81 props: Record<string, unknown>, 81 props: Record<string, unknown>,
82]; 82];
83export type DirectHtml = [tag: typeof kDirectHtml, html: string]; 83export type DirectHtml = [tag: typeof kDirectHtml, html: ResolvedNode];
84/** 84/**
85 * Components must return a value; 'undefined' is prohibited here 85 * Components must return a value; 'undefined' is prohibited here
86 * to avoid functions that are missing a return statement. 86 * 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";