152 lines
4.8 KiB
TypeScript
152 lines
4.8 KiB
TypeScript
// 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";
|