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