The cell-load ledger — how much work did a screen's CELLS do, and how much of it was waste?
Why this exists as a second ledger rather than more fields on screenLoad
screenReadiness.bs answers "when did this screen become usable and when did it stop changing" — a question with a beginning and an end, whose state lives on the SCREEN's m. This answers a different one: how many times were cells bound, and how much network did that cost. Two things force it apart:
- Cells are separate components, so they cannot reach the screen's
mat all.screenReadiness's own header names this constraint and takes the same way out (a field the parent observes) forExtrasRowList.contentReady. - The interesting behaviour happens AFTER settle. A cell rebind storm during scrolling is invisible to a ledger that closes when the screen stops loading.
It deliberately keeps every other convention screenLoad established: namespaced free functions, the #if perfTiming gate INSIDE each sub so a shipped build compiles them to empty calls, one uniform emit shape so scripts/measurements.js needs ONE entry covering every screen present and future, and an instrumentUs that measures the instrument's own footprint rather than arguing about it.
Where the counters live, and why the content root
On the RowList / MarkupGrid content root — the same node initTextureManager already hangs loadedRowRange on, and the one every cell already holds a reference to (m.contentRoot). Three reasons it is the right home and not m.global:
- Thread-local. All SceneGraph component code runs on the render thread, so a cell incrementing a content-root field never crosses a boundary. MEASURED on device rather than assumed, on HomeRows' root specifically because a Task also writes that node: 100 writes to a locally-created node took 351 us, 100 to the content root 379 us — ~3.8 us per write, a ratio of 1.08. A rendezvous would be orders of magnitude. ⚠️ An earlier version of this note said
m.globalis "main-thread-owned and WOULD cross". That is WRONG and was refuted by measurement 2026-08-23:m.globalis RENDER-owned, so from a cell (render thread) it costs 2.0 us against a local node's 1.7 us — no crossing at all. It is 93 us from a Task thread. The content root is still the right home, on the two grounds below plus per-screen scoping; only the threading rationale was false. Seedocs/architecture/threading.md. - Race-free without any locking. The render thread is single-threaded, so read-modify-write across cells cannot interleave.
- Free live readout. Node fields are readable over ODC, so a probe can watch the counters mid-scroll without waiting for an emit. There is no second mechanism.
The pop-in half — a race, not a count
appearances / popIns / popInsCold and the loadMs trio answer a different question from the counters above, and it is the one the texture buffer exists for. The buffer (±2 rows, ±1 column) loads a cell's image BEFORE the cell is on screen so the user never watches a placeholder turn into a poster. Nothing observed whether it wins that race: every counter above measures work DONE, none measures work done IN TIME.
A pop-in is scored across two events, deliberately:
appeared()— the cell is on screen. If its image is already there, nothing is pending and the race was won. If not, a pop-in is PENDING.loadSucceeded()— the image arrived. A pending pop-in resolves HERE, which is why the pending flag exists rather than a counter bump at step 1.
Resolving at step 2 is what keeps a broken image out of the number. An image that never arrives is not pop-in — the user sees a glyph, not a fade-in — and loadsFailed already counts it. Scoring at appearance time instead would make ExtrasRowList, where 117 of 140 loads fail against a real server, read as a total buffer failure when the buffer had nothing to do with it.
popInsCold splits the pop-ins by whether a request was even in flight when the cell appeared (isTextureUnloaded). Cold means the buffer never started the load — the cell came from outside the managed range, so the user outran the buffer's DEPTH. Not-cold means the buffer started in time and the network was slower, which more depth cannot fix. Two different remedies, one counter apart.
loadMs / loadMsCount / loadMsMax time each request from issue to ready. That is the image_load_time term in the buffer's own adequacy condition — buffer_depth × time_per_scroll_step >= image_load_time — and it is measured for EVERY load, including buffer preloads that never became visible, which are exactly the ones a depth decision needs. loadMsCount is carried rather than reusing loadsSucceeded because the two differ: a "ready" with no matching issue (Roku re-reporting a URI that was never re-requested) is a success with no interval to record. Dividing by the wrong count would be the inference-by-subtraction this ledger already refuses once.
Lifecycle — no per-screen call sites
Bound to the texture-manager lifecycle, which all five cell-bearing screens already drive: initTextureManager attaches the counters, hideTextureManager / destroyTextureManager emit them. Every content root already receives both, though often from its PARENT (Home hides HomeRows' root; ItemDetails hides and destroys ExtrasRowList's). So instrumenting a sixth screen costs nothing here — using the texture manager is the opt-in.
⚠️ Known limit, stated rather than hidden: a screen that REPLACES its content root starts a fresh set of counters, and the old root's counts die unemitted. BaseGridView does this at five call sites (refresh, filter change, …) and SearchRow at two. So a grid's line covers "since the last rebuild", not "since the screen opened". Fixable by emitting before the replace; deliberately not done in the first cut because it needs five call sites and the limit is visible in the numbers rather than silent.
- Source
Members
(static, constant) FROM_CONTENT
Bind triggers. Which one fired is the whole point of splitting them: content is a cell being recycled onto new data (legitimate), size is a re-render caused by a layout change alone. MEASURED 2026-08-20 on ItemDetails + Home: 289 binds, ALL of them content and none size. So bindsFromSize is expected to be ZERO and is worth keeping precisely as a standing invariant — if it ever goes positive, a layout change has started re-issuing image requests, which is a regression with no other symptom.
- Default Value
- content
- Source
(static, constant) FROM_SIZE
- Default Value
- size
- Source
(static, constant) UNLOAD_RANGE
Which mechanism evicted a resident texture. The texture manager has TWO, they answer to different dials, and the total cannot tell them apart: range - the cell fell outside the managed vertical range (loadedRowRange +/- 2 rows) and renderTracking decided. Driven by how far the user scrolled DOWN, and it fires on every screen including grids. window - the horizontal item window inside a VISIBLE row evicted the cell, which only happens on a RowList row holding more than TEXTURE_BUFFER_THRESHOLD items. This is the ONLY signal that horizontal windowing ran at all. The split exists because the total is dominated by range — Home reads ~57 unloads on a cell sweep with Limit: 16, where no row is long enough for window to be reachable — so "did raising the per-row limit engage windowing?" is unanswerable from unloads. Both are emitted rather than one plus the total, so neither is derived by subtraction.
- Default Value
- range
- Source
(static, constant) UNLOAD_WINDOW
- Default Value
- window
- Source
(static, constant) WIPE_BIND
Which path wiped a failure glyph back to the loading state. Both are visible flicker, but they are not equally bad and the split is what tells them apart: a bind wipe lands on a cell whose content is changing anyway, while a reload wipe hits a cell that is SITTING STILL showing a stable glyph — which is what reads to a user as blinking for no reason.
- Default Value
- bind
- Source
(static, constant) WIPE_RELOAD
- Default Value
- reload
- Source
Methods
(static) appeared(contentRoot, hasTexture, isCold) → {void}
The cell is on screen. One half of the pop-in race; loadSucceeded is the other.
hasTexture is the caller's read of its own poster's loadStatus, and it is deliberately NOT trusted alone. A request issued and not yet resolved means the texture currently on screen belongs to the cell's PREVIOUS item while loadStatus still reads "ready" from it — established behaviour, not supposition: JRRowItem.renderItem's same-URI shortcut exists precisely because Roku leaves loadStatus alone until a new request resolves. Trusting it alone would under-count exactly the recycled-cell case pop-in is about.
Counted at most ONCE per EPISODE, an episode being bind -> visible -> gone. Ungated otherwise, deliberately: an appearance is idempotent within an episode, so calling this from both renderItem and onRenderTrackingChanged cannot double-count, and first paint legitimately happens before activateTextureManager runs. The episode is closed only by bind and departed, and it is departed — not this — that carries the state gate keeping a spurious compositor flip from re-arming it.
| Name | Type | Description |
|---|---|---|
contentRoot | object | |
hasTexture | boolean | |
isCold | boolean |
- Source
- Type:
- void
(static) attach(contentRoot, component) → {void}
Attach counters to a content root. Called by initTextureManager, so every screen that uses the texture manager is instrumented without its own call site.
BrighterScript does not schema-check the dynamic fields below against ContentNode's declared interface. screenLoad.begin and for the same reason: the screen an operator navigated to is established by the nav, not by the app.
| Name | Type | Description |
|---|---|---|
contentRoot | object | the RowList / MarkupGrid content root. Typed |
component | string | the COMPONENT's name, e.g. "homeRows". Same convention as |
- Source
- Type:
- void
(static) bind(contentRoot, trigger, isRedundant) → {void}
One cell bind. isRedundant is the cell's own answer to "did I just bind this exact item at this exact size again?" — kept on the CELL because its previous bind is free to remember there and would cost a node round trip to store here.
| Name | Type | Description |
|---|---|---|
contentRoot | object | |
trigger | string | |
isRedundant | boolean |
- Source
- Type:
- void
(static) bump(contentRoot, field) → {void}
Increment one counter. Extracted so the single-counter call sites above do not each repeat the guard, and so the instrument's own cost is accumulated in one place rather than in each of them, where it could drift. A counter that bumps a TOTAL and a SPLIT together (bind, unload) does it inline instead — one event should clock the probe once, not twice. Deliberately no call-site COUNT here: the number this comment used to carry said nine against an actual four, which is what a hand-maintained enumeration does. grep -n 'bump(contentRoot' source/utils/cellLoad.bs answers it.
| Name | Type | Description |
|---|---|---|
contentRoot | object | |
field | string |
- Source
- Type:
- void
(static) cellState() → {object}
Per-CELL instrument state, cached on the caller's m exactly as probe() is.
One AA rather than four bare m fields so every flag is a real boolean from its first read. An unset m.someFlag is Invalid, and if Invalid is a &h18 type mismatch rather than a falsy check — the fault class behind the EnableFallbackFont crash. Initialising the whole set together makes that unreachable by construction instead of by every call site remembering a guard.
- Source
- Type:
- object
(static) countItems(contentRoot) → {integer}
Total ITEMS under a content root, which is the denominator of the rebind rate and is NOT getChildCount(). The two content shapes disagree about what a child is: a MarkupGrid root is flat, so its children ARE the items, while a RowList root is two level and its children are ROWS. Using the child count for both would have divided Home's binds by its ~10 rows instead of its ~68 items and published a "rebind rate" an order of magnitude wrong — in a field that looks equally plausible either way.
Detected by shape rather than by a flag the caller has to pass correctly: a root whose first child has children of its own is hierarchical. Walked once per emit, never per bind.
| Name | Type | Description |
|---|---|---|
contentRoot | object |
- Source
- Type:
- integer
(static) departed(contentRoot) → {void}
The cell LEFT the screen. Ends the appearance episode, so a later return counts as a NEW appearance rather than being swallowed by the once-per-bind gate.
🚨 This is what makes the re-entry race measurable at all, and its absence silently hollowed out the metric. A RowList / MarkupGrid does NOT rebind a cell that scrolls off and back onto the SAME item — onItemContentChanged never fires — so with the gate re-arming only on bind, every scroll-back was skipped. Measured on cellSweepGrid: binds 28 against 28 items, i.e. nothing was rebound all sweep, while 7 reloads happened; all 7 re-entry races went unevaluated and the ledger still published a confident-looking popInsReload 1. The one thing the buffer exists to win was the one thing not being counted.
A pending pop-in is dropped here rather than carried: a cell that appeared without its image and then went away was never watched fading in, so a late arrival is not a pop-in.
⚠️ CALLER'S OBLIGATION: only call this while the texture manager is "active". A renderTracking of "none" during init is a layout recalculation and during hidden is visible=false propagating — neither is a departure, and closing the episode on one makes the screen's return score a fresh appearance nobody saw. Both cell components gate the call for this; evaluateTextureState refuses the same two states already. The INVARIANT is what to remember — on a resume with no scrolling, every appearance has to be paid for by a bind. Measured before the gate against the demo server: 18 appearances against 10 binds, all non-pop-ins, inflating the denominator that popIns / appearances divides by. The counts are that fixture's; the bound is the app's, and it is gated in tests/rta/specs/cell-load.spec.js.
⚠️ Deliberately NOT probed into instrumentUs, unlike every other entry point here. Measured on cellSweepGrid against a real server (.177, n=5, same fixture and identical workload in both arms): adding the probe took instrumentUs 7371 -> 8411 µs, +1040 µs / +14.1%. Absolute µs are that fixture's; the RATIO is the point — for a body that is two AA assignments and no node write. That delta IS the probe — an roTimespan pair plus a render-thread read-modify-write on cellLoadInstrumentUs — so probing here would make the instrument meaningfully more expensive to report a figure that is almost entirely the measurement apparatus. instrumentUs is therefore a LOWER BOUND by this much.
| Name | Type | Description |
|---|---|---|
contentRoot | object |
- Source
- Type:
- void
(static) emit(contentRoot) → {void}
Emit the session's counters and reset them. Called by hideTextureManager (the user navigated away — a complete viewing session) and destroyTextureManager (teardown). Emitting on BOTH is why cellLoadEmitted exists: a screen that is hidden and then destroyed must publish one line, not two, and a second line of zeroes would read as a real session in which nothing happened.
| Name | Type | Description |
|---|---|---|
contentRoot | object |
- Source
- Type:
- void
(static) glyphWipe(contentRoot, source) → {void}
A failure glyph was reset to the loading state. See WIPE_* for why the source matters.
| Name | Type | Description |
|---|---|---|
contentRoot | object | |
source | string |
- Source
- Type:
- void
(static) loadErrored(contentRoot) → {void}
An image request came back failed. loadsFailed far exceeding the number of distinct broken images is the signature of a retry with no memory of the last failure.
⚠️ NAMED loadErrored RATHER THAN loadFailed, and it must stay that way. Rooibos's transpiler mistakes a namespaced call whose method name it reads as an assertion for a call ON THE TEST SUITE, and rewrites its own bookkeeping against the NAMESPACE instead of m — cellLoad.currentAssertLineNumber = 148, cellLoad_done(). That is a compile-time rewrite producing code that crashes with "Use of uninitialized variable" at runtime, and only in the spec, so the app is fine while the suite reports a crash it cannot explain. The emitted LOG FIELD is still loadsFailed — the wire format is what scripts/measurements.js reads and it is unaffected by this rename.
| Name | Type | Description |
|---|---|---|
contentRoot | object |
- Source
- Type:
- void
(static) loadStarted(contentRoot, isReloadopt) → {void}
An image request was issued (the poster URI was assigned a real URL). Also starts this cell's load clock — see the pop-in section of the header for why the interval matters more than the count.
| Name | Type | Attributes | Default | Description |
|---|---|---|---|---|
contentRoot | object | |||
isReload | boolean | <optional> | false |
- Source
- Type:
- void
(static) loadSucceeded(contentRoot) → {void}
An image request came back ready. Its job is not the count itself but the RESIDUAL: loadsStarted - (loadsFailed + loadsSucceeded) is the number of requests still in flight when the session was emitted, and at a quiescent boundary it should be zero. Without it, an outstanding request is indistinguishable from a success — every "successful loads" figure was an inference subtracting one counter from another, with nothing able to check it. See waitCellsQuiet in tests/rta/lib/steps.js, which watches this counter for the same reason it watches loadsFailed.
| Name | Type | Description |
|---|---|---|
contentRoot | object |
- Source
- Type:
- void
(static) loadTimer() → {object}
The cell's LOAD clock, distinct from probe() because it spans a network round trip rather than a single call — one roTimespan cannot carry two marks.
- Source
- Type:
- object
(static) probe() → {object}
The instrument's own stopwatch, created ONCE per cell and reused.
m here is the CALLING component's — these are namespaced free functions, the same mechanism screenReadiness.bs relies on — so this caches on the cell, which is exactly the right lifetime: one per pooled cell rather than one per event.
Measured before this existed, on a Home load: 15458 us across ~706 counter calls, or ~22 us each, against ~3.8 us for the field write the call is actually making. So the STOPWATCH was five times the cost of the thing it was timing. screenReadiness.bs warns about precisely this ("a fresh roTimespan per ledger call would make the measurement of the overhead a significant part of the overhead") and this repeated it anyway; instrumentUs is what caught it, which is the argument for carrying that field at all.
- Source
- Type:
- object
(static) reload(contentRoot) → {void}
| Name | Type | Description |
|---|---|---|
contentRoot | object |
- Source
- Type:
- void
(static) reset(contentRoot) → {void}
Zero the counters for the next session. cellLoadEmitted is cleared LAST so a hidden screen that is shown again starts a fresh, emittable session.
| Name | Type | Description |
|---|---|---|
contentRoot | object |
- Source
- Type:
- void
(static) unload(contentRoot, reason) → {void}
A resident texture was released. reason says which mechanism did it — see UNLOAD_* for why the split is the whole point of this counter.
| Name | Type | Description |
|---|---|---|
contentRoot | object | |
reason | string |
- Source
- Type:
- void