From 63d80f293e45df33fc71772686b69926218df03e Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 15 Aug 2026 12:45:16 +0530 Subject: [PATCH] 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. --- examples/folio/sanderling/predicates.ts | 22 +++++++ examples/folio/sanderling/spec.ts | 14 ++-- pkg/spec/test/folio-per-frame-reading.test.ts | 64 +++++++++++++++++++ 3 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 pkg/spec/test/folio-per-frame-reading.test.ts diff --git a/examples/folio/sanderling/predicates.ts b/examples/folio/sanderling/predicates.ts index f476238..e7ea34b 100644 --- a/examples/folio/sanderling/predicates.ts +++ b/examples/folio/sanderling/predicates.ts @@ -33,6 +33,28 @@ export function routeOfFrame( 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(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. // diff --git a/examples/folio/sanderling/spec.ts b/examples/folio/sanderling/spec.ts index f0c9b2d..a403d51 100644 --- a/examples/folio/sanderling/spec.ts +++ b/examples/folio/sanderling/spec.ts @@ -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(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(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", 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 diff --git a/pkg/spec/test/folio-per-frame-reading.test.ts b/pkg/spec/test/folio-per-frame-reading.test.ts new file mode 100644 index 0000000..bf41da6 --- /dev/null +++ b/pkg/spec/test/folio-per-frame-reading.test.ts @@ -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"))); +});