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; 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 // Reads the Home screen's own TOTAL BALANCE node and advances the carrier the
// spec holds between Home visits. // spec holds between Home visits.
// //
+9 -5
View File
@@ -22,6 +22,7 @@ import {
createdAccountHasNonZeroBalance, createdAccountHasNonZeroBalance,
homeAccountsOf, homeAccountsOf,
homeTxnCountsOf, homeTxnCountsOf,
oncePerFrame,
parseDollarCents, parseDollarCents,
parseTypedAmount, parseTypedAmount,
readHomeCards, 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 // 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 // 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 // takes its answer from here, so no two readings can disagree about which
// screen the app is on. // screen the app is on. Every extractor asks, so the answer is read once per
const routeOf = (s: State): Route | null => // frame: see oncePerFrame for why that stays fresh.
routeOfFrame<Route>(SCREENS, tag => s.ax.find({ testTag: tag }) != null); 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 // 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 // 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 // Everything that comes off the card list shares this parse so the readings
// cannot disagree with each other about what was on screen. // 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 => ({ allOn("home", "AccountCard")(s).map(card => ({
name: cardAccountName({ name: cardAccountName({
childText: card.find({ testTag: "AccountName" })?.text, childText: card.find({ testTag: "AccountName" })?.text,
@@ -88,7 +92,7 @@ const homeCards = (s: State): CardReading[] =>
childText: card.find({ testTag: "AccountTxnCount" })?.text, childText: card.find({ testTag: "AccountTxnCount" })?.text,
cardText: card.text, cardText: card.text,
}), }),
})); })));
// Total balance: Home's own TOTAL BALANCE node, which the app computes over // 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 // every account rather than over the cards that happen to be laid out inside
@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { oncePerFrame, routeOfFrame } from "../../../examples/folio/sanderling/predicates.ts";
// oncePerFrame is what stops the folio spec re-walking the accessibility tree
// once per extractor, and the whole of its safety is that the state object is a
// fresh one every step (goja's stateObject, web's buildState). These tests pin
// both halves: the same frame is read once, a different frame is a different
// answer. A cache that outlived its frame would freeze every reading the spec
// takes and the properties over them would go quietly vacuous.
const SCREENS = { login: "LoginScreen", home: "HomeScreen" } as const;
interface Frame {
present: readonly string[];
finds: number;
}
const frameShowing = (...present: readonly string[]): Frame => ({ present, finds: 0 });
const routeOf = oncePerFrame((frame: Frame) =>
routeOfFrame(SCREENS, tag => {
frame.finds++;
return frame.present.includes(tag);
}),
);
test("one frame is walked once, however many readings ask", () => {
const home = frameShowing("HomeScreen");
assert.equal(routeOf(home), "home");
assert.equal(routeOf(home), "home");
assert.equal(routeOf(home), "home");
assert.equal(home.finds, 2);
});
test("a new frame is a new answer", () => {
const home = frameShowing("HomeScreen");
const login = frameShowing("LoginScreen");
assert.equal(routeOf(home), "home");
assert.equal(routeOf(login), "login");
assert.equal(login.finds, 2);
});
test("a transition frame is not answered off the frame before it", () => {
assert.equal(routeOf(frameShowing("HomeScreen")), "home");
assert.equal(routeOf(frameShowing("HomeScreen", "LoginScreen")), null);
});
test("returning to an earlier frame re-reads it", () => {
const home = frameShowing("HomeScreen");
routeOf(home);
routeOf(frameShowing("LoginScreen"));
assert.equal(routeOf(home), "home");
assert.equal(home.finds, 4);
});
// What makes memoizing the card list worth more than memoizing the route: the
// three readings taken off it share one parse instead of three.
test("a frame's reading is handed back by identity", () => {
const cardsOf = oncePerFrame((frame: Frame) => frame.present.map(tag => ({ tag })));
const home = frameShowing("HomeScreen");
assert.equal(cardsOf(home), cardsOf(home));
assert.notEqual(cardsOf(home), cardsOf(frameShowing("HomeScreen")));
});