screenLoad

The screen readiness ledger — when did a screen PAINT, and when did it stop CHANGING?

Why a ledger rather than a per-screen clock

A screen does not become ready at one moment. ItemDetails paints its springboard from one metadata fetch and then keeps filling: the extras rows arrive from a serial task chain, a Series swaps a loading placeholder for its Resume button, a Person swaps one for Shuffle, a Playlist grows a Watched button, a Movie grows a Trailer button, a Season back-fills ratings from its parent series, an Audio item fills lyrics. Which of those apply depends on the item type, so between two and four fills are outstanding at paint time and NO single "second milestone" is correct for all nine item types the screen serves.

So a screen does not declare WHICH milestone it has. It declares each outstanding fill, and the two milestones fall out of the ledger:

paint - the screen rendered something the user can look at and act on. settled - every fill the screen declared has resolved; it has stopped changing.

This is the split the mature platforms publish rather than choose between: the web's first-contentful-paint against network-idle, and Android's time-to-initial-display against reportFullyDrawn(). Both make the same admission — the framework can see the first frame, but only the APP knows when the content the user came for landed. Roku gives us neither for free, so this is the minimum that earns both numbers.

The two rules a call site has to know

  1. Declare every fill BEFORE marking paint. Every fill on every screen instrumented so far is discoverable synchronously in the same handler that paints, so this costs nothing and it keeps settled from firing in a gap between two declarations. A fill declared after paint but BEFORE settle is still honoured and simply extends the run; one declared after settle is ignored, because the run is over (see pending). Neither is warned about — the post-settle case is the ordinary user-driven refresh, not a defect.
  2. Every pending gets exactly one resolve, on every path. An error path that returns early without resolving leaves the screen permanently unsettled, which reports as a MISSING settled line rather than a wrong one. That is the intended failure: scripts/measure.js counts the lines it saw per sample, so a screen that never settles is reported unmeasurable instead of being quietly averaged in on its paint number alone.

Two classes, decided mechanically

A fill is content (it changes what the screen SAYS) or texture (an image or texture load). The class is not a judgment about whether the user notices it — that question has a different answer at every call site and drifts. It falls out of what resolved the fill: a texture loadStatus callback is texture, data arriving is content. content is the default so the common call site stays one argument.

⚠️ contentMs and textureMs are SUMS OF CONCURRENT WAITS, not shares of the load. Fills are declared together and run in parallel, so their durations overlap and their total can EXCEED the wall clock: measured on .177, a Series detail settled in 845 ms with contentMs 878. Neither is a bug and neither is a percentage — dividing one by settledMs is meaningless and will sometimes exceed 100%. The wall clock is settledMs alone; the sums say how much waiting the screen did, and slowestContent says which single wait to go and look at.

Both classes are recorded and reported separately, and content is what a comparison headlines. Recording both is deliberate and it is the irreversible half of the choice: a texture timing that is noisy because the device cache was warm can be ignored later, but a fill that was never recorded can never be joined to a series taken before it existed. This project has already paid for that once — see the deviceKey note in scripts/measurements.js, where four runs can never join a series because a selection key was added after they were taken.

Cost

The gate is INSIDE each sub rather than at every call site. scripts/harden-prod-manifest.js forces perfTiming=false into every release artifact, so a shipped build compiles these to empty subs — one call and no allocation per fill — and an instrumented screen reads as four plain lines instead of four #if blocks. That is the whole reason the ledger is worth having over per-screen clocks: adding a screen is a handful of ordinary-looking statements, and scripts/measurements.js never changes.

⚠️ That holds for the screenLoad.* calls themselves and NOT for a fill whose signal has to cross a component boundary. A child that cannot reach the screen's ledger marks a field instead — ExtrasRowList.contentReady, SearchRow.contentReady — and the field write plus the parent's observeField are ordinary app code that ships. The cost is small and thread-local (both ends are on the render thread, so no rendezvous), but it is not zero and it is not compiled out, so a cross-component fill is the one kind that is worth declaring only when the milestone is genuinely worth having.

State lives on the CALLER's m (these are namespaced free functions, so m is the calling component's), which is what keeps two screens loading at once from sharing a ledger — a chained navigation mounts a fresh component per screen, so each gets its own.

⚠️ "The caller's m" holds for a plain function in a component's scope, which is every call site there is today. It does NOT hold inside a BrighterScript CLASS method: there m is the class instance, while a global function it calls sees the enclosing scope's m, so a class instrumenting itself would write one ledger and read another. Read through state() rather than m.screenLoad and the distinction stops mattering.

Members

(static, constant) CONTENT

A fill that changes what the screen says. The default.

Default Value
  • content

(static, constant) TEXTURE

An image / texture load. Passed explicitly.

Default Value
  • texture

Methods

(static) begin(component) → {void}

begin: open a ledger for one load of this component. Restarts any ledger already open — a re-load (a refresh, a second item opened without leaving the screen) is a new run, not a continuation of the last one.

NOT an entry in tests/rta/screens.js: one component serves many screens there (itemDetails backs all nine *Details entries), and the app has no way to know which of them the operator navigated to. The RTA screen name is established by --nav DRIVING there, and measure.js records the two separately for exactly that reason — conflating them under one word screen is what let a movieDetails series and a seriesDetails series compare as one population.

Parameters:
NameTypeDescription
componentstring

the COMPONENT's name, e.g. "itemDetails". Deliberately

Returns:
Type: 
void

(static) buildFlags() → {string}

The build flags the sample was taken under, stamped by the app itself so a number can never be silently compared against one measured in a distorting build — a debug=true build attaches rawApiData to every transformed item. Provenance belongs in the sample, not in someone's memory of which manifest was checked out.

ENABLE_RTA is deliberately NOT here: it is the third flag that can move a measurement (it makes the on-device ODC component resident) and the app cannot self-report it usefully — scripts/measure.js derives it from the deploy it performed. See the note in scripts/measurements.js.

Returns:
Type: 
string

(static) cancel(id) → {void}

cancel: withdraw a declared fill that turned out not to start, WITHOUT recording it.

Exists so a caller can declare a fill BEFORE the call that may or may not begin it, which is the only ordering that is safe when the resolve could in principle land synchronously inside that call. ItemDetails' logo is the case: a Poster only loads when its uri actually changes, and whether it changed is knowable only after setItemLogo returns — but if a warm texture cache could deliver loadStatus synchronously during the assignment, declaring afterwards would let the resolve outrun its own pending and strand the screen unsettled forever.

Roku documents loadStatus as a fetch-and-decode progression and says nothing about whether a same-thread observer is delivered synchronously, so rather than assert an answer, declare-then-cancel makes the ordering correct under BOTH. A cancelled fill is not a zero-millisecond fill: it never happened, so it must not inflate the fill count or the class totals.

Parameters:
NameTypeDescription
idstring
Returns:
Type: 
void

(static) paint(variantopt) → {void}

paint: the screen has rendered. Emits the paint line.

Stop the clock at the END of the handler that renders the content — the moment all render work is submitted and the loading spinner comes down. That is "content ready", not time-to-photons; the frame itself lands a beat later. The honest alternative is the first renderTracking callback, which is closer to what the eye sees but fires repeatedly and needs a per-screen rule for which fire counts — which is exactly the per-screen knowledge this ledger exists to remove.

serves several: the item type for ItemDetails, the library type for a grid. It is what lets a sample be attributed rather than guessed at by position — a chained navigation mounts ItemDetails more than once per launch (a Season is reached THROUGH its Series), so "the first sample in the window" is not the one asked for.

Parameters:
NameTypeAttributesDefaultDescription
variantstring<optional>
""

what KIND of thing this load was, when one screen

Returns:
Type: 
void

(static) pending(id, fillClassopt) → {void}

pending: declare one outstanding async fill. See rule 1 in the header — declare every fill before calling paint.

It is what the settled line names as the slowest, so it should say what was being waited on rather than which task ran.

Parameters:
NameTypeAttributesDefaultDescription
idstring

a short stable name for the fill, e.g. "extras", "trailer".

fillClassstring<optional>
"content"

screenLoad.CONTENT (default) or screenLoad.TEXTURE.

Returns:
Type: 
void

(static) resolve(id) → {void}

resolve: one declared fill has landed. When the last one lands on a painted screen, the settled line is emitted.

A resolve with no matching pending on an OPEN run is reported: it means the two sides have drifted, and an unbalanced ledger publishes a settled time describing a different set of fills than the reader will assume.

After settle, though, it is silent — mirroring pending. The run is over, and the resolves that arrive next belong to work nobody opened a ledger for: a return from playback re-runs the trailer check, the refresh button re-runs the extras chain, and a Season re-sets its logo when the first one never became visible. All three are ordinary user actions, all three happen in every perfTiming build, and warning on them would fire the drift alarm routinely — which trains the reader to ignore the one signal that is supposed to mean something is genuinely wrong.

Parameters:
NameTypeDescription
idstring
Returns:
Type: 
void

(static) settleIfDone() → {void}

Emit the settled pair once every declared fill has resolved on a painted screen.

Emits at most once per run. A fill that lands after the last one — a straggler from a cancelled chain, a second callback on one task — must not publish a second, later settled time for the same load: two lines that disagree, with nothing on the wire to say which one the reader meant.

Returns:
Type: 
void

(static) slowestOrNone(id) → {string}

Parameters:
NameTypeDescription
idstring
Returns:
Type: 
string

(static) state() → {object}

The open ledger, or invalid. The one supported way to READ the state.

Exists because "the caller's m" is not the same m everywhere, and the difference is invisible until something reads back what it wrote. A plain function in a component's scope — every call site in ItemDetails — shares its m with the namespaced functions it calls, which is what makes the ledger work at all. A BrighterScript CLASS method does not: its m is the class instance, while a global function it calls sees the enclosing scope's m, so a class that instrumented itself would write one ledger and read another and never notice.

Reaching through this accessor is correct under both, because it resolves m exactly where the writes did. Callers should not poke at m.screenLoad directly; the suite in tests/source/unit/utils/screenReadiness.spec.bs found this the hard way.

Returns:
Type: 
object

(static) variantOrNone() → {string}

The wire must never carry an empty token where a name belongs: a line reading variant ms 812 collapses two spaces and shifts every field after it, so the pattern would either fail to match or match the wrong group. none is a value; an empty string is a hole.

Returns:
Type: 
string