Compare commits
1 commit
Author | SHA1 | Date | |
---|---|---|---|
ecdf9a77f1 |
3 changed files with 161 additions and 6 deletions
152
framework/engine/prerender.tsx
Normal file
152
framework/engine/prerender.tsx
Normal 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";
|
|
@ -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<any, any>) => Exclude<Node, undefined>;
|
||||
export type Component = (props: any) => Exclude<Node, undefined>;
|
||||
/** 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -92,7 +92,7 @@ export const fonts: sg.Font[] = [
|
|||
name: "AT Name Sans Display Hairline",
|
||||
sources: [
|
||||
"ATNameSansDisplay-Hairline.woff2",
|
||||
path.join(fontRoot, "/ArrowType/AT Name Sans Display/ATNameSansDisplay-Hairline.woff2"),
|
||||
path.join(fontRoot, "/ArrowType/Recursive/Recursive_VF_1.085.ttf"),
|
||||
],
|
||||
subsets: [
|
||||
{
|
||||
|
|
Loading…
Reference in a new issue