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>;
|
[name: string]: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
interface ElementChildrenAttribute {
|
interface ElementChildrenAttribute {
|
||||||
children: unknown;
|
children: Node;
|
||||||
}
|
}
|
||||||
type Element = engine.Node;
|
type Element = engine.Node;
|
||||||
type ElementType = keyof IntrinsicElements | engine.Component;
|
type ElementType = keyof IntrinsicElements | engine.Component;
|
||||||
|
|
|
@ -13,7 +13,7 @@ export const createTemplate = (
|
||||||
) => {
|
) => {
|
||||||
const { render } = marko.createTemplate(templateId, renderer);
|
const { render } = marko.createTemplate(templateId, renderer);
|
||||||
function wrap(props: Record<string, unknown>, n: number) {
|
function wrap(props: Record<string, unknown>, n: number) {
|
||||||
// Marko components
|
// Marko Custom Tags
|
||||||
const cloverAsyncMarker = { isAsync: false };
|
const cloverAsyncMarker = { isAsync: false };
|
||||||
let r: engine.Render | undefined = undefined;
|
let r: engine.Render | undefined = undefined;
|
||||||
try {
|
try {
|
||||||
|
@ -21,6 +21,7 @@ export const createTemplate = (
|
||||||
} catch {}
|
} catch {}
|
||||||
// Support using Marko outside of Clover SSR
|
// Support using Marko outside of Clover SSR
|
||||||
if (r) {
|
if (r) {
|
||||||
|
engine.setCurrentRender(null);
|
||||||
const markoResult = render.call(renderer, {
|
const markoResult = render.call(renderer, {
|
||||||
...props,
|
...props,
|
||||||
$global: { clover: r, cloverAsyncMarker },
|
$global: { clover: r, cloverAsyncMarker },
|
||||||
|
@ -48,10 +49,10 @@ export const dynamicTag = (
|
||||||
inputIsArgs?: 1,
|
inputIsArgs?: 1,
|
||||||
serializeReason?: 1 | 0,
|
serializeReason?: 1 | 0,
|
||||||
) => {
|
) => {
|
||||||
marko.dynamicTag;
|
|
||||||
if (typeof tag === "function") {
|
if (typeof tag === "function") {
|
||||||
clover: {
|
clover: {
|
||||||
const unwrapped = (tag as any).unwrapped;
|
const unwrapped = (tag as any).unwrapped;
|
||||||
|
console.log({ tag, unwrapped });
|
||||||
if (unwrapped) {
|
if (unwrapped) {
|
||||||
tag = unwrapped;
|
tag = unwrapped;
|
||||||
break clover;
|
break clover;
|
||||||
|
@ -119,6 +120,23 @@ export function fork(
|
||||||
marko.fork(scopeId, accessor, promise, callback, serializeMarker);
|
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 {
|
interface Async {
|
||||||
isAsync: boolean;
|
isAsync: boolean;
|
||||||
}
|
}
|
||||||
|
@ -127,3 +145,4 @@ import * as engine from "./ssr.ts";
|
||||||
import type { ServerRenderer } from "marko/html/template";
|
import type { ServerRenderer } from "marko/html/template";
|
||||||
import { type Accessor } from "marko/common/types";
|
import { type Accessor } from "marko/common/types";
|
||||||
import * as marko from "#marko/html";
|
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
|
// Add-ons to the rendering engine can provide opaque data, And retrieve it
|
||||||
// within component calls with 'getAddonData'. For example, 'sitegen' uses this
|
// within component calls with 'getAddonData'. For example, 'sitegen' uses this
|
||||||
// to track needed client scripts without introducing patches to the engine.
|
// 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(node: Node): Result;
|
||||||
export function ssrSync<A extends Addons>(node: Node, addon: A): Result<A>;
|
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 */
|
/** Inline HTML into a render without escaping it */
|
||||||
export function html(rawText: string) {
|
export function html(rawText: ResolvedNode): DirectHtml {
|
||||||
return [kDirectHtml, rawText];
|
return [kDirectHtml, rawText];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -80,7 +80,7 @@ export type Element = [
|
||||||
type: string | Component,
|
type: string | Component,
|
||||||
props: Record<string, unknown>,
|
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
|
* Components must return a value; 'undefined' is prohibited here
|
||||||
* to avoid functions that are missing a return statement.
|
* 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