experiment: streaming suspense implementation
This commit is contained in:
parent
50d245569c
commit
c5113954a8
6 changed files with 187 additions and 6 deletions
|
@ -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;
|
||||
|
|
|
@ -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";
|
||||
|
|
20
framework/engine/ssr.test.tsx
Normal file
20
framework/engine/ssr.test.tsx
Normal 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 <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>',
|
||||
));
|
|
@ -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.
|
||||
|
|
40
framework/engine/suspense.test.tsx
Normal file
40
framework/engine/suspense.test.tsx
Normal 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: {} },
|
||||
);
|
||||
});
|
102
framework/engine/suspense.ts
Normal file
102
framework/engine/suspense.ts
Normal 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";
|
Loading…
Reference in a new issue