From ecdf9a77f1f1fb0fc24f2cc65667088fa1383663 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sat, 16 Aug 2025 11:27:30 -0700 Subject: [PATCH] start working on PPR --- framework/engine/prerender.tsx | 152 +++++++++++++++++++++++++++++++++ framework/engine/render.ts | 13 +-- 2 files changed, 160 insertions(+), 5 deletions(-) create mode 100644 framework/engine/prerender.tsx diff --git a/framework/engine/prerender.tsx b/framework/engine/prerender.tsx new file mode 100644 index 0000000..21c1772 --- /dev/null +++ b/framework/engine/prerender.tsx @@ -0,0 +1,152 @@ +// Partial pre-rendering allows a component with dynamic properties to +// have its static components rendered statically in a two pass render. +// The pre-rendering pass takes in `placeholder` objects (eg, request +// context or view data) and captures which components access those +// states, serializing the inputs alongside. This process tracks what +// file and export name the component came from so that rendering may +// resume from another process. + +/** Placeholders can be passed around in component props, + * but throw a `PlaceholderError` when trying to read it. */ +export function placeholder(id: string): T { + return new Proxy({ [kPlaceholder]: id }, throwOnInteractHandlers) as T; +} + +class PlaceholderError extends Error { + /* @ts-expect-error */ + declare stack: NodeJS.CallSite[]; + + constructor(public id: string) { + super("This action must be delayed to runtime."); + + const _prepareStackTrace = Error.prepareStackTrace; + Error.prepareStackTrace = (_, stack) => stack; + void (/* volatile */ this.stack); + Error.prepareStackTrace = _prepareStackTrace; + } +} + +const kPlaceholder = Symbol("Placeholder"); +function throwSelf(target: { [kPlaceholder]: string }): never { + throw new PlaceholderError(target[kPlaceholder]); +} +const throwOnInteractHandlers: ProxyHandler<{ [kPlaceholder]: string }> = { + get: (t, k) => (k === kPlaceholder ? t[kPlaceholder] : throwSelf(t)), + apply: throwSelf, + construct: throwSelf, + defineProperty: throwSelf, + deleteProperty: throwSelf, + getOwnPropertyDescriptor: throwSelf, + getPrototypeOf: throwSelf, + has: throwSelf, + isExtensible: throwSelf, + ownKeys: throwSelf, + preventExtensions: throwSelf, + set: throwSelf, + setPrototypeOf: throwSelf, +}; +function isToken(token: unknown): string | null { + if (!!token && typeof token === "object") { + const value = (token as { [kPlaceholder]: unknown })[kPlaceholder]; + if (typeof value === "string") return value; + } + return null; +} + +interface State extends render.State { + prerenderStack: Frame[]; +} +interface Frame {} + +export function Component({ meow }: { meow: number }) { + return ( +
+

app shell

+ +
+ ); +} + +export function Thing({ bwaa }: { bwaa: number }) { + return
Thing is {bwaa * 2}
; +} + +export async function main() { + prepare(("meow")} />); +} + +interface Slot { + file: string; + key: string; + props: unknown; +} + +function prepare(node: render.Node) { + const token = "\0" + crypto.randomUUID().slice(0, 8); + const slots: Slot[] = []; + const state = render.init(true, {}); + state.resolveNode = (s, node) => { + try { + return render.resolveNode(s, node); + } catch (err) { + if (err instanceof PlaceholderError) { + // If not a component element, forward the error higher. + if ( + !Array.isArray(node) || + node[0] != render.kElement || + typeof node[1] !== "function" + ) + throw err; + const fn = node[1]; + // Locate the file in the stack trace. Since it was + // called directly by `resolveNode`, it should be here. + const frame = UNWRAP(err.stack.find((f) => f.getFunction() === fn)); + // Use reflection to get the module's export name. + const fileName = UNWRAP(frame.getFileName()); + const module = UNWRAP(require.cache[fileName]); + const key = Object.entries(module.exports) + .find(([, v]) => v === fn)?.[0] + ?.toString(); + if (!key) { + throw new Error( + `To enable partial pre-rendering of <${fn.name} />, ` + + `export it in ${incr.toRel(fileName)} so resuming will be ` + + `able to import and reference it directly.`, + ); + } + slots.push({ file: incr.toRel(fileName), key, props: node[2] }); + // Return a 'render token' in place. The output stream is split on these. + return token + (slots.length - 1).toString().padStart(4, "0"); + } + throw err; + } + }; + + const statics = []; + const resumes = []; + { + const treeWithPpr = state.resolveNode(state, node); + const baseShell = render.stringifyNode(treeWithPpr); + const regexp = new RegExp(`${token}(\\d{4})`, "g"); + let last = 0; + for (const match of baseShell.matchAll(regexp)) { + const { + index, + [0]: { length }, + [1]: slotIndexString, + } = match; + const slotIndex = parseInt(UNWRAP(slotIndexString), 10); + const slot = UNWRAP(slots[slotIndex], `Slot ${slotIndex}`); + resumes.push(slot); + statics.push(baseShell.slice(last, index)); + last = index + length; + } + const end = baseShell.slice(last); + if (end) statics.push(end); + } + + console.log({ parts: statics, slots }); +} + +import * as render from "#engine/render"; +import * as incr from "../incremental.ts"; diff --git a/framework/engine/render.ts b/framework/engine/render.ts index 727c364..af4e647 100644 --- a/framework/engine/render.ts +++ b/framework/engine/render.ts @@ -76,6 +76,8 @@ export interface State { rejections: unknown[] | null; /** Add-ons to the rendering engine store state here */ addon: Addons; + /** Can be overridden to inject logic. */ + resolveNode: typeof resolveNode, } export const kElement = Symbol("Element"); @@ -101,7 +103,7 @@ export type Element = [ export type Raw = [tag: typeof kRaw, html: ResolvedNode]; /** Components must return a value; 'undefined' is prohibited here * to avoid functions that are missing a return statement. */ -export type Component = (props: Record) => Exclude; +export type Component = (props: any) => Exclude; /** Emitted by JSX runtime */ export interface SrcLoc { fileName: string; @@ -132,7 +134,7 @@ export function resolveNode(r: State, node: unknown): ResolvedNode { const placeholder: InsertionPoint = [null]; r.async += 1; node - .then((result) => void (placeholder[0] = resolveNode(r, result))) + .then((result) => void (placeholder[0] = r.resolveNode(r, result))) // Intentionally catching errors in `resolveNode` .catch((e) => (r.rejections ??= []).push(e)) .finally(() => { @@ -154,7 +156,7 @@ export function resolveNode(r: State, node: unknown): ResolvedNode { if (typeof tag === "function") { current = r; try { - return resolveNode(r, tag(props)); + return r.resolveNode(r, tag(props)); } catch (e) { const { 4: src } = node; if (e && typeof e === "object") (e as { src?: string }).src = src; @@ -165,11 +167,11 @@ export function resolveNode(r: State, node: unknown): ResolvedNode { } if (typeof tag !== "string") throw new Error("Unexpected " + inspect(type)); const children = props?.children; - if (children) return [kElement, tag, props, resolveNode(r, children)]; + if (children) return [kElement, tag, props, r.resolveNode(r, children)]; return node; } if (type === kRaw) return node[1]; - return node.map((elem) => resolveNode(r, elem)); + return node.map((elem) => r.resolveNode(r, elem)); } export type ResolvedNode = @@ -280,6 +282,7 @@ export function init(allowAsync: boolean, addon: Addons): State { rejections: null, asyncDone: null, addon, + resolveNode, }; }