From c5113954a88f919cf04c2652ece20ffafd7928e2 Mon Sep 17 00:00:00 2001 From: chloe caruso Date: Sun, 15 Jun 2025 01:25:58 -0700 Subject: [PATCH] experiment: streaming suspense implementation --- framework/engine/jsx-runtime.ts | 2 +- framework/engine/marko-runtime.ts | 23 ++++++- framework/engine/ssr.test.tsx | 20 ++++++ framework/engine/ssr.ts | 6 +- framework/engine/suspense.test.tsx | 40 +++++++++++ framework/engine/suspense.ts | 102 +++++++++++++++++++++++++++++ 6 files changed, 187 insertions(+), 6 deletions(-) create mode 100644 framework/engine/ssr.test.tsx create mode 100644 framework/engine/suspense.test.tsx create mode 100644 framework/engine/suspense.ts diff --git a/framework/engine/jsx-runtime.ts b/framework/engine/jsx-runtime.ts index d0b1385..f1b5c73 100644 --- a/framework/engine/jsx-runtime.ts +++ b/framework/engine/jsx-runtime.ts @@ -35,7 +35,7 @@ declare global { [name: string]: Record; } interface ElementChildrenAttribute { - children: unknown; + children: Node; } type Element = engine.Node; type ElementType = keyof IntrinsicElements | engine.Component; diff --git a/framework/engine/marko-runtime.ts b/framework/engine/marko-runtime.ts index 7bfe732..e063754 100644 --- a/framework/engine/marko-runtime.ts +++ b/framework/engine/marko-runtime.ts @@ -13,7 +13,7 @@ export const createTemplate = ( ) => { const { render } = marko.createTemplate(templateId, renderer); function wrap(props: Record, 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"; diff --git a/framework/engine/ssr.test.tsx b/framework/engine/ssr.test.tsx new file mode 100644 index 0000000..0434258 --- /dev/null +++ b/framework/engine/ssr.test.tsx @@ -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 <3")); +test("simple tree", (t) => + t.assert.equal( + engine.ssrSync( +
+

hello world

+

haha

+ {1}| + {0}| + {true}| + {false}| + {null}| + {undefined}| +
, + ).text, + '

hello world

haha

1|0|||||
', + )); diff --git a/framework/engine/ssr.ts b/framework/engine/ssr.ts index 7dbefe3..70843ee 100644 --- a/framework/engine/ssr.ts +++ b/framework/engine/ssr.ts @@ -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; +export type Addons = Record; export function ssrSync(node: Node): Result; export function ssrSync(node: Node, addon: A): Result; @@ -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, ]; -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. diff --git a/framework/engine/suspense.test.tsx b/framework/engine/suspense.test.tsx new file mode 100644 index 0000000..96adbc4 --- /dev/null +++ b/framework/engine/suspense.test.tsx @@ -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((done) => resolve = done); + return ; + } + + const example = ( +
+

app shell

+ + + +
(c) 2025
+
+ ); + + const iterator = renderStreaming(example); + const assertContinue = (actual: unknown, value: unknown) => + t.assert.deepEqual(actual, { done: false, value }); + + assertContinue( + await iterator.next(), + "", + ); + t.assert.ok(resolve !== null), resolve(); + assertContinue( + await iterator.next(), + "", + ); + t.assert.deepEqual( + await iterator.next(), + { done: true, value: {} }, + ); +}); diff --git a/framework/engine/suspense.ts b/framework/engine/suspense.ts new file mode 100644 index 0000000..9435364 --- /dev/null +++ b/framework/engine/suspense.ts @@ -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(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";