experiment: streaming suspense implementation

This commit is contained in:
chloe caruso 2025-06-15 01:25:58 -07:00
parent 50d245569c
commit c5113954a8
6 changed files with 187 additions and 6 deletions

View file

@ -35,7 +35,7 @@ declare global {
[name: string]: Record<string, unknown>;
}
interface ElementChildrenAttribute {
children: unknown;
children: Node;
}
type Element = engine.Node;
type ElementType = keyof IntrinsicElements | engine.Component;

View file

@ -13,7 +13,7 @@ export const createTemplate = (
) => {
const { render } = marko.createTemplate(templateId, renderer);
function wrap(props: Record<string, unknown>, n: number) {
// Marko components
// Marko Custom Tags
const cloverAsyncMarker = { isAsync: false };
let r: engine.Render | undefined = undefined;
try {
@ -21,6 +21,7 @@ export const createTemplate = (
} catch {}
// Support using Marko outside of Clover SSR
if (r) {
engine.setCurrentRender(null);
const markoResult = render.call(renderer, {
...props,
$global: { clover: r, cloverAsyncMarker },
@ -48,10 +49,10 @@ export const dynamicTag = (
inputIsArgs?: 1,
serializeReason?: 1 | 0,
) => {
marko.dynamicTag;
if (typeof tag === "function") {
clover: {
const unwrapped = (tag as any).unwrapped;
console.log({ tag, unwrapped });
if (unwrapped) {
tag = unwrapped;
break clover;
@ -119,6 +120,23 @@ export function fork(
marko.fork(scopeId, accessor, promise, callback, serializeMarker);
}
export function escapeXML(input: unknown) {
// The rationale of this check is that the default toString method
// creating `[object Object]` is universally useless to any end user.
if (
(typeof input === "object" && input &&
// only block this if it's the default `toString`
input.toString === Object.prototype.toString)
) {
throw new Error(
`Unexpected object in template placeholder: '` +
util.inspect({ name: "clover" }) + "'. " +
`To emit a literal '[object Object]', use \${String(value)}`,
);
}
return marko.escapeXML(input);
}
interface Async {
isAsync: boolean;
}
@ -127,3 +145,4 @@ import * as engine from "./ssr.ts";
import type { ServerRenderer } from "marko/html/template";
import { type Accessor } from "marko/common/types";
import * as marko from "#marko/html";
import * as util from "node:util";

View file

@ -0,0 +1,20 @@
import { test } from "node:test";
import * as engine from "./ssr.ts";
test("sanity", (t) => t.assert.equal(engine.ssrSync("gm <3").text, "gm &lt;3"));
test("simple tree", (t) =>
t.assert.equal(
engine.ssrSync(
<main class={["a", "b"]}>
<h1 style="background-color:red">hello world</h1>
<p>haha</p>
{1}|
{0}|
{true}|
{false}|
{null}|
{undefined}|
</main>,
).text,
'<main class="a b"><h1 style=background-color:red>hello world</h1><p>haha</p>1|0|||||</main>',
));

View file

@ -6,7 +6,7 @@
// Add-ons to the rendering engine can provide opaque data, And retrieve it
// within component calls with 'getAddonData'. For example, 'sitegen' uses this
// to track needed client scripts without introducing patches to the engine.
type Addons = Record<string | symbol, unknown>;
export type Addons = Record<string | symbol, unknown>;
export function ssrSync(node: Node): Result;
export function ssrSync<A extends Addons>(node: Node, addon: A): Result<A>;
@ -38,7 +38,7 @@ export function ssrAsync(node: Node, addon: Addons = {}) {
}
/** Inline HTML into a render without escaping it */
export function html(rawText: string) {
export function html(rawText: ResolvedNode): DirectHtml {
return [kDirectHtml, rawText];
}
@ -80,7 +80,7 @@ export type Element = [
type: string | Component,
props: Record<string, unknown>,
];
export type DirectHtml = [tag: typeof kDirectHtml, html: string];
export type DirectHtml = [tag: typeof kDirectHtml, html: ResolvedNode];
/**
* Components must return a value; 'undefined' is prohibited here
* to avoid functions that are missing a return statement.

View file

@ -0,0 +1,40 @@
import { test } from "node:test";
import { renderStreaming, Suspense } from "./suspense.ts";
test("sanity", async (t) => {
let resolve: () => void = null!;
// @ts-expect-error
async function AsyncComponent() {
await new Promise<void>((done) => resolve = done);
return <button>wow!</button>;
}
const example = (
<main>
<h1>app shell</h1>
<Suspense fallback="loading...">
<AsyncComponent />
</Suspense>
<footer>(c) 2025</footer>
</main>
);
const iterator = renderStreaming(example);
const assertContinue = (actual: unknown, value: unknown) =>
t.assert.deepEqual(actual, { done: false, value });
assertContinue(
await iterator.next(),
"<template shadowrootmode=open><main><h1>app shell</h1><slot name=suspended_1>loading...</slot><footer>(c) 2025</footer></main></template>",
);
t.assert.ok(resolve !== null), resolve();
assertContinue(
await iterator.next(),
"<button slot=suspended_1>wow!</button>",
);
t.assert.deepEqual(
await iterator.next(),
{ done: true, value: {} },
);
});

View file

@ -0,0 +1,102 @@
// 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";