perf(folio): read each frame once

every extractor asked routeOf, and routeOf does five ax.find calls. on
web each find walks the document and every shadow root beneath it, so
the spec cost 110 tree walks a step; homeCards was parsed four times
over. now 5 and once.

keyed on the identity of the state object because both hosts build a new
one per step and hand that one object to every getter, so it cannot
outlive its frame. holding the reference is what keeps that true rather
than likely.
This commit is contained in:
pj committed 2026-08-15 12:45:16 +05:30
1 parent ade3e77ff5
commit 63d80f293e
3 files changed
+95 -5

No files matched your search

+22
View File
@@ -33,6 +33,28 @@ export function routeOfFrame<R extends string>(
return shown;
}
// A reading taken once per frame, keyed on the identity of the state object the
// reading came off.
//
// Both hosts build a NEW state object per step and hand that one object to
// every extractor: goja's PushSnapshot builds it through stateObject
// (internal/verifier/worker.go), the web runtime's evaluateExtractors through
// buildState (pkg/spec/src/web-runtime.ts). So the object IS the frame, and a
// value cached against it cannot outlive the frame it was read on. Holding the
// reference is what keeps that true rather than merely likely: the cached state
// cannot be collected, so no later step's state can be the same object.
//
// Worth having because these readings walk the whole tree. On web every
// `find` is a querySelectorAll across the document and each shadow root
// beneath it, and the spec takes a dozen of them per step off one frame.
export function oncePerFrame<S extends object, T>(read: (frame: S) => T): (frame: S) => T {
let last: { frame: S; value: T } | null = null;
return frame => {
if (last === null || last.frame !== frame) last = { frame, value: read(frame) };
return last.value;
};
}
// Reads the Home screen's own TOTAL BALANCE node and advances the carrier the
// spec holds between Home visits.
//
+9 -5
View File
@@ -22,6 +22,7 @@ import {
createdAccountHasNonZeroBalance,
homeAccountsOf,
homeTxnCountsOf,
oncePerFrame,
parseDollarCents,
parseTypedAmount,
readHomeCards,
@@ -45,9 +46,12 @@ type Route = keyof typeof SCREENS;
// The screen this frame shows, or null when it does not show exactly one: see
// routeOfFrame, which owns that rule and the reason for it. Everything below
// takes its answer from here, so no two readings can disagree about which
// screen the app is on.
const routeOf = (s: State): Route | null =>
routeOfFrame<Route>(SCREENS, tag => s.ax.find({ testTag: tag }) != null);
// screen the app is on. Every extractor asks, so the answer is read once per
// frame: see oncePerFrame for why that stays fresh.
const routeOf = oncePerFrame(
(s: State): Route | null =>
routeOfFrame<Route>(SCREENS, tag => s.ax.find({ testTag: tag }) != null),
);
// An element is a reading, and a target, only when the route says we are on its
// screen. Scoping a find to the screen's own node is not enough on a transition
@@ -73,7 +77,7 @@ const route = extract<Route | null>("route", routeOf);
//
// Everything that comes off the card list shares this parse so the readings
// cannot disagree with each other about what was on screen.
const homeCards = (s: State): CardReading[] =>
const homeCards = oncePerFrame((s: State): CardReading[] =>
allOn("home", "AccountCard")(s).map(card => ({
name: cardAccountName({
childText: card.find({ testTag: "AccountName" })?.text,
@@ -88,7 +92,7 @@ const homeCards = (s: State): CardReading[] =>
childText: card.find({ testTag: "AccountTxnCount" })?.text,
cardText: card.text,
}),
}));
})));
// Total balance: Home's own TOTAL BALANCE node, which the app computes over
// every account rather than over the cards that happen to be laid out inside