Compare commits

...

1 commit
master ... ppr

Author SHA1 Message Date
ecdf9a77f1 start working on PPR 2025-08-16 11:27:30 -07:00
2 changed files with 160 additions and 5 deletions

View file

@ -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<T extends object>(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 (
<div>
<h1>app shell</h1>
<Thing bwaa={meow} />
</div>
);
}
export function Thing({ bwaa }: { bwaa: number }) {
return <div>Thing is {bwaa * 2}</div>;
}
export async function main() {
prepare(<Component meow={placeholder<number>("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";

View file

@ -76,6 +76,8 @@ export interface State {
rejections: unknown[] | null; rejections: unknown[] | null;
/** Add-ons to the rendering engine store state here */ /** Add-ons to the rendering engine store state here */
addon: Addons; addon: Addons;
/** Can be overridden to inject logic. */
resolveNode: typeof resolveNode,
} }
export const kElement = Symbol("Element"); export const kElement = Symbol("Element");
@ -101,7 +103,7 @@ export type Element = [
export type Raw = [tag: typeof kRaw, html: ResolvedNode]; export type Raw = [tag: typeof kRaw, 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. */
export type Component = (props: Record<any, any>) => Exclude<Node, undefined>; export type Component = (props: any) => Exclude<Node, undefined>;
/** Emitted by JSX runtime */ /** Emitted by JSX runtime */
export interface SrcLoc { export interface SrcLoc {
fileName: string; fileName: string;
@ -132,7 +134,7 @@ export function resolveNode(r: State, node: unknown): ResolvedNode {
const placeholder: InsertionPoint = [null]; const placeholder: InsertionPoint = [null];
r.async += 1; r.async += 1;
node node
.then((result) => void (placeholder[0] = resolveNode(r, result))) .then((result) => void (placeholder[0] = r.resolveNode(r, result)))
// Intentionally catching errors in `resolveNode` // Intentionally catching errors in `resolveNode`
.catch((e) => (r.rejections ??= []).push(e)) .catch((e) => (r.rejections ??= []).push(e))
.finally(() => { .finally(() => {
@ -154,7 +156,7 @@ export function resolveNode(r: State, node: unknown): ResolvedNode {
if (typeof tag === "function") { if (typeof tag === "function") {
current = r; current = r;
try { try {
return resolveNode(r, tag(props)); return r.resolveNode(r, tag(props));
} catch (e) { } catch (e) {
const { 4: src } = node; const { 4: src } = node;
if (e && typeof e === "object") (e as { src?: string }).src = src; 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)); if (typeof tag !== "string") throw new Error("Unexpected " + inspect(type));
const children = props?.children; 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; return node;
} }
if (type === kRaw) return node[1]; if (type === kRaw) return node[1];
return node.map((elem) => resolveNode(r, elem)); return node.map((elem) => r.resolveNode(r, elem));
} }
export type ResolvedNode = export type ResolvedNode =
@ -280,6 +282,7 @@ export function init(allowAsync: boolean, addon: Addons): State {
rejections: null, rejections: null,
asyncDone: null, asyncDone: null,
addon, addon,
resolveNode,
}; };
} }