31 KiB
Contract: Steam React framework (P2, rt.react)
Owner: P2. Files: device/rt/02-react.js (module react), device/rt/03-react-lab.js (module react-lab, tests only). Source of truth for the mechanisms: docs/phase2/capabilities/steam-react.md (SR). Steam build at the time of writing: 11094443.
Status of this contract: v3 (2026-10-07, review R1 fixes; v2 was code complete and tested live: docs/phase2/wp/P2.md). Signatures below are stable; additions are appended, never renamed. A change that breaks a caller is announced in docs/phase2/wp/P2.md under "Contract changes" before it lands. What changed since v1 is in §11.
0. Rules for callers (read first)
- Only from a runtime module. Use
rt.reactinside your module'sinstall(rt)(declaredeps: ['react']) or later from your own handlers. Never at file load time.rt.reactis the module's API, put on the runtime object by P1'srt.expose('react', api)whilereactis installed, so it is the same object asrt.use('react')(and__LGS_RT.reactin a lab step). - Fail closed. Every call that needs Steam's modules first runs
rt.react.ready(). If a required finder or the route switch is missing it throws, before anything is patched. Let it throw out of yourinstall(): the loader marks your module failed and your area falls back to its T1 form (PLAN §5.2 rung 3). - Nothing persists. Everything lives in SharedJSContext's heap.
lgs offcallsremove(), which restores every fiber, holder and listener and reportspatchedLeft(must be 0). - New routes live under
/library/lgs/(SR §3.4: a stale history entry then falls back to Steam's library, not an empty page).routes.add()refuses anything else. - Every interactive element is a
Focusableor a Steam control inside the page'sFocusabletree (SR §7 "Focus traps"); the page root has anonCancel(ui.Pagedoes this for you). - Launch-type calls go through
rt.react.actions, never straight toSteamClient. In test mode they are logged, never run (§8). This is how the never-list is kept while 20 agents test. - Strings drawn by T3 come from
rt.react.ui.loc()(Steam's localization); English fallbacks only when the UI language starts withen(PLAN §1.15). - Tests: call
rt.react.nav.focusRoot()before any D-pad sequence (SR §4vr-null-tree). - A
patch.byPropswrap must not call React hooks. It runs inside the live component's render, whose hook list is fixed; an extra hook crashes that component. Render a child component of your own (it may use hooks) from the wrap's output instead.
1. Module, flag, lifecycle
__LGS_RT.define({ name: 'react', deps: [], flag: 'react', install(rt), remove() })
- Flag
react: the kill switch for every T3 feature, and the module's P1 install gate (define({flag: 'react'}); P1'sBUILTIN_FLAGShasreact: true). Set to an off value (lgs flags react=0, or--flags react=falsein a lab step), the module is removed, so every module withdeps: ['react']is blocked and its area falls back to T1.ready()also refuses (lgs-react: flag react is off) while the flag reads off. install(rt)is cheap (0.1 ms measured): it creates the API, exposes it asrt.react, and patches nothing. The source scan (≈ 160–200 ms, SR §5) runs once, on the first call that needs it (ready(), or any route, patch, component, data or action call). If no T3 caller is enabled, nothing is scanned and nothing is patched.remove()(called bylgs offin reverse load order): hides our menus and modals, leaves our routes (Back, then/library/homereplace), restores every patched fiber, holder and history listener, drops the source cache, and returns{patchedLeft, restored, routeAfter}.- Self-healing: if React remounts the route switch, the history listener re-patches it before the router renders into one of our paths or overridden paths (SR §3.4).
| Call | Returns |
|---|---|
rt.react.ready() |
true, or throws Error('lgs-react: …') naming what is missing. Idempotent. A missing required finder is sticky until rt.react.reset() (tests). A transient failure (Steam's UI still mounting: main route switch not found, no React fiber in the main window, webpack not loaded) has err.transient === true, err.retryAfterMs and err.attempt: the same error is thrown until the backoff ends (1, 2, 4, 8, 16, then every 30 s), and the next ready() after it retries the switch lookup without scanning again (RX-RETRY). Your own install() still fails closed meanwhile; let the error out of install() unchanged (or as cause): P1 re-installs a module whose install failed with a transient error, after retryAfterMs, up to 4 times (runtime.md, REQ P2->P1) |
rt.react.isReady |
boolean, no side effects |
rt.react.status() |
{version, installed, ready, error, scanMs, factories, finders: {Name: {module, candidates, required, found}}, switchFibers, patchedLive, routes: [...], overrides: [...], patches: [{id, count, live, kinds, pending, late}], patchedLeft, jsxHooked, watching, lateTimers, actions: {mode, reasons, logged}}, plus errorTransient, retryInMs, attempts while ready() is failing. watching: the history and window listeners are on (anything registered, a pending optional patch included); lateTimers: late-attach timers still scheduled. patchedLeft counts our functions in Steam's objects right now (0 when nothing is registered, and after removal) |
rt.react.version |
integer, bumped on every contract-visible change |
2. Steam's modules and finders
After ready():
| Name | What |
|---|---|
rt.react.React, rt.react.jsx, rt.react.jsxs, rt.react.Fragment |
Steam's React 19 instance and jsx runtime. Always use these, never a second React |
rt.react.ReactDOM |
createPortal, flushSync |
rt.react.router |
{Route, Switch, matchPath} (react-router v5) |
rt.react.Routes |
Steam's route-path table (Routes.Library.Home(), Routes.Settings.System(), Routes.Library.App.Root(appid), Routes.GamepadUI.Zoo.Root() …) |
rt.react.M |
every resolved finder by name (table below) |
Your own finders. rt.react.find(spec) runs over the cached factory sources (no second full stringify), requires only modules whose source contains every needle, smallest first, and returns the first pick that is truthy:
const { mods, where, counts } = rt.react.find({
QamVr: [['QuickAccess_Tab_Perf_PerfRecordTracking'], (ex, u) => u.pick(ex, (v, src) => …)],
});
u=rt.react.util:fnSrc(v)(source of a function, classprototype.render, forwardRef.renderor memo.type; it reads through our trampolines, so a predicate that checks the source still matches a component we already patched),pick(exports, test(v, src, key)),has(src, ...needles).- Rule (SR §3.2): a finder must match exactly one module. If
counts[name] > 1, tighten its needles before relying on it; each extra candidate is a module that could be executed early. - The source cache behind
find()is kept 60 s after the last scan orfind(), then dropped; a laterfind()scans again (≈ 80 ms). - Live on build 11094443: all 25 built-in finders have exactly 1 candidate (
status().finders). The scan searches one anchor needle per finder (its longest) and checks the others only on the anchor's modules: 143–165 ms on a busy device (76–94 ms when idle). Your own finders cost about 5 ms per distinct anchor needle when the cache must be rebuilt. - Required finders (fail closed):
React,jsx,RoutePaths,Focusable,DialogButton,GamepadPage. Everything else is optional and isnullwhen missing; check before use.
3. Steam's components (re-exports)
rt.react.c (also spread on rt.react itself):
Focusable, DialogButton, DialogButtonPrimary, Field, ToggleField, SliderField, DropdownField, GamepadPage, ScrollPanel, ScrollPanelGroup, Menu, MenuItem, MenuSeparator, ConfirmModal, showModal, showContextMenu.
Props reference: SR §3.6 (GamepadPage, Focusable navigation props, gamepad events, flow-children values, EGamepadButton codes). Optional components may be null; render a T1-equivalent or skip.
4. Routes
4.1 rt.react.routes.add(path, Component, opts?) → handle
path: a react-router v5 path string under/library/lgs/(params allowed:/library/lgs/folder/:id). Anything else throws.Componentreceives{match: {path, url, params, isExact}, location}; it re-renders on every navigation of the main window.- Rendered inside Steam's own
<Route>wrapper type (the one Steam uses for/zoo), so it gets Steam's header, footer legend and transitions, and insideui.ErrorBoundary(a failure shows a page with a focused Back button; Steam's other routes are untouched). opts.exact(defaultfalse, react-router semantics). Our routes are matched before Steam's, most specific (longest path) first.opts.owner(a string) is informational.handle = {path, remove()}.remove()leaves the route first if the main window is on it.- Two packages may not add the same path: the second
add()throws.
4.2 rt.react.routes.override(path, fn, opts?) → handle
path: thepathprop of one of Steam's top-level<Route>elements, exactly as Steam declares it (usert.react.Routes, e.g.Routes.Library.Home()). If no such element exists in the switch, it throws.fn(steamChildren, {match, location})returns what the route renders instead. It is called on every render of the route: return an element, do no work. KeepsteamChildrenreachable when you redesign a whole page (SR §3.5).steamChildrenis Steam's own element for that route. Where Steam declares the route's children as a function of the route props (/library/app/:appid/achievements), it is that function's result for the current props, andmatchis the router's real match. Where Steam declares no children (/chat, and the["/", "/index.html", "/sp.html"]entry), it isnull.- Steam's top-level paths on build 11094443, in order:
/oobe,/login,/createaccount,/account,/invites,/error,/library/downloads,/library/home,/app/:appid/properties,/console,/standalonecontrollerconfigurator,/app/:appid/controllerconfigurator,/library/app/:appid/achievements,/library/app/:appid,/library,/decksetup,/workshop,/chat,/media,/settings,/colorsettings,/search,/zoo,/about,/accessibility,/gameapiosk, the root entry,/controller/bindinput/:controllerIdx,/controller/devicesupport/:controllerIdx,/controller/calibration/:controllerIdx,/notes/app/:appid/:noteid?.
- The result is wrapped in
ui.ErrorBoundarywhose fallback issteamChildren: a failing override degrades to Steam's own page. - If the main window is on
path, it is re-entered once (Navigate(path, true)) so the override applies.handle.remove()does the same so Steam's page comes back. - One override per path; a second one throws.
- Sub-routes of a top-level route (for example
/app/:appid/controllerconfigurator/summary) are rendered by Steam's children; an override of the top-level path wraps all of them.
4.3 Other
rt.react.routes.list()→{added: [path], overridden: [path]}.rt.react.nav: plain navigation helpers, not actions (never logged):route(),go(path, replace?),back(),focusRoot()(FocusApplicationRoot()),inst()(Steam's main window instance),win()(main windowwindow). Usenav.gofor navigation inside our own views (sections, folders); useactions.navigatefor the user-intent jumps listed in §8.
5. Patches by props shape
5.1 rt.react.patch.byProps(id, predicate, wrap, opts?) → handle
For components whose source is hidden (mobx observer, memo, minified), found by the shape of their props (SR §3.2).
id: unique string,'<pkg>.<what>'(for example'c2b.plus').predicate(props, fiber): tested on mounted fibers of function, class, forwardRef and memo components (tags 0, 1, 11, 14, 15) across SharedJSContext's single React root (main window and every popup).opts.tags(an array) narrows the tags.wrap(orig): returns the replacement render function, called with the samethisand arguments asorig:(props, refOrContext)for function/forwardRef/memo components; no arguments for a classrender(usethis.props). Typical "after" patch:
rt.react.patch.byProps('c2b.plus', rt.react.patch.targets.plusButton, (orig) => function (props, r) {
const out = orig.call(this, props, r);
return out; // modify or wrap the output here
});
- What is patched (
handle.kindsnames it per component):
| Kind | Component | How |
|---|---|---|
simplememo, memo |
memo(fn) (mobx observer components are this) |
the memo's .type, plus every live fiber's type |
fwd |
forwardRef |
its .render |
class |
class component | prototype.render |
fn |
a bare function component (no shared holder) | every live fiber's type, and while the patch exists Steam's element factories (jsx, jsxs, createElement of Steam's React) substitute our trampoline for that function, so a remount keeps the patch (RX-FN: PagedSettings). The first render after patching, and the first after removal, may remount the live instance once (React sees a new element type) |
- One stable trampoline per component carries every layer, so adding or removing a layer never changes the type React sees.
- Layering: several packages may patch the same component; wraps compose in registration order and each handle removes only its own layer. Every named target in §5.2 matches a component that is already patched (RX-OPT: a second layer on
appButtons). - Fail closed: throws if nothing matches, or if more than
opts.max(default 1) distinct components match. opts.optional: true(for targets mounted only on some routes:appButtons,pagedSettings): when nothing matches, returns a handle withcount: 0,pending: trueand keeps looking: on every navigation of the main window, when a popup window is added or shown, and in a bounded series of scoped timers 120, 400, 1000 and 2500 ms after each of those events and after the registration itself (the history event comes before React commits the new route). A pending handle keeps the watchers on by itself, so it works as a package's only registration (RX-OPT). Once attached,count,kindsandliveare filled in on the same handle andpatch.list()showslate(the event that attached it). A pending handle that later matches more thanmaxstays pending and logs the error.handle = {id, count, live, kinds, refresh(), remove()}:countdistinct components,livemounted fibers switched at once.- Re-render:
rt.react.patch.rerender(handle)forces one render of the live instances, for patches that must show at once. Returns{forced, skipped, how: {class, observer}}: a class instance getsforceUpdate(); a mobx observer gets mobx's own update path (a newstateVersion, thenonStoreChange(), exactly what an observable change does). Other components cannot be forced from outside (skipped); they show the patch at their next own render. rt.react.patch.list()→[{id, count, live, kinds, pending}].
5.2 Named predicates (rt.react.patch.targets)
| Name | Shape | Used by |
|---|---|---|
plusButton |
props keys exactly ['allowLaunchProgram']: the bar's "+" button, a mobx observer memo (tag 15) in the bar window. It owns the "+" popup (its hooks hold the popup handle) |
C2b (HA §4). RX-2: 1 component, rerender forced, bar and popup unchanged by a no-op wrap |
pagedSettings |
Array.isArray(props.pages) and a page with route === Routes.Settings.System(): a bare function with props {title, pages} (27 page objects {visible, title, icon, route, content} on build 11094443) |
C6a (SET P-S1). RX-FN: 1 component (kind fn), survives leaving and re-entering Settings, page unchanged by a no-op wrap |
statusPill |
a function component with no props whose first DOM node has Steam's QuickAccessButton class (read from the bar's CSS module, optional finder BarClasses, so it does not depend on the theme's class index; rt.sel('%{QuickAccessButton}') is the fallback); it renders Steam's bar popup button element ({refBarPopupHandle, popupContents, interactionType, popupAlignment, onPopupVisibilityChange, tooltip, children}), whose props a wrap may change |
C3b (CC3). RX-TARGETS: 1 component (kind fn, an observer through hooks, so rerender forces it), bar unchanged by a no-op wrap |
appButtons |
the game page's action row: a forwardRef with overview, details, onGameInfoToggle, bShowingLaunchDetails and no onNav (its parent PlaySection has the same props plus onNav), whose source names .ActionRow. Its output holds the %{AppButtons} Focusable row |
C5a (GP §4.12, AT-T3). RX-TARGETS |
Packages that discover a new stable predicate send it to P2 as a REQ so it lands here.
5.3 Fiber utilities (rt.react.fiber)
of(domNode), root() (the committed HostRoot), walk(visit) (depth-first over every mounted fiber; visit returning true stops), findAll(predicate(props, fiber), {max}) → fibers, closest(domNode, predicate) → nearest ancestor fiber, props(domNode) → the memoizedProps of the nearest component fiber, firstHost(fiber) → the first DOM node it renders. Read-only.
6. UI helpers (rt.react.ui)
| Helper | What |
|---|---|
ui.Page |
Component. GamepadPage (scrollable: false unless given) whose root is a Focusable with onCancel (default nav.back()) and onCancelActionDescription = Steam's "Back". Props: onCancel, className (root), rootProps (more props for the root Focusable), pageProps (passed to GamepadPage), name (for the error log), children. Includes ui.ErrorBoundary |
ui.ErrorBoundary |
Component {name, fallback, onError, children}. On error: logs to the runtime log, calls onError(err) if given, and renders fallback if given, else the error page: a root spanning the page (48 px side insets) with the title in Title 2 (English UI only, D-P2-6) and a focused 240 × 60 Back capsule on the centre line (.lgs-react-fail, .lgs-react-fail-title, .lgs-react-fail-back; P4 tokens with their values as fallbacks; RX-FAIL) |
ui.menu(content, anchor?, opts?) |
Steam's showContextMenu. content is a React element or an array of {label, onSelected, tone, disabled, checked}; opts.label the title. Returns the instance (.Hide()). In the main window it is Steam's centred gamepad sheet, with Steam's own Cancel |
ui.modal(element, opts?) |
Steam's showModal(element, window, opts.options); opts.window defaults to the main window. Returns a Promise of the instance (.Close()). Focus goes back to the control that had it when the modal closes through Steam's closeModal (your onCancel/B, ConfirmModal's OK and Cancel): Steam parks focus in vr-null-tree after a modal in VR, so P2 activates the page's nav tree again and focuses its last node, if the route did not change and nothing else took focus (RX-3; opts.restoreFocus: false opts out). Your element is wrapped in a small host that forwards every prop Steam passes. Overlays the route without unmounting it (RX-3: the route's root stays the same node; only Steam's focus-dependent nodes such as %{FastScrollOverlay} leave while focus is in the modal). Steam passes your element a closeModal prop. Call nav.focusRoot() in tests before D-pad input |
ui.confirm(opts) |
ui.modal of Steam's ConfirmModal: {title, description, okText, cancelText, middleText, onOK, onCancel, onMiddle, alert}; a Promise of the instance |
ui.loc(token, ...args) |
Steam's localized string for #Token, or null when it does not exist. With args, %1$s … %9$s are filled from them (Steam's LocalizeString ignores extra arguments; REQ C2a->P2 #11); a placeholder without an argument stays |
ui.text(token, english) |
loc(token), else english only when the UI language starts with en, else null (PLAN §1.15) |
ui.lang() |
Steam's preferred UI locale ('english' when unreadable) |
ui.style(css) |
An element: a <style> inside your page's tree (lives and dies with it, SR §3.6). ui.Style is the component ({css}) |
7. Data helpers (rt.react.data, read-only)
| Helper | Returns |
|---|---|
data.app(appid) |
Steam's app overview or null |
data.collection(id) |
a Steam collection (collectionStore.GetCollection) or null; data.stores() → {collectionStore, appStore, urlStore} |
data.installedGames({limit, sort}) |
[{appid, name, overview}], sort: 'recent' (default) or 'name' |
data.recentGames(limit) |
from recentAppsCollection |
data.art(appidOrOverview) |
{portrait: [urls], hero, logo, header, icon, custom: {hero, logo, landscape, portrait}}, cached /assets/ URLs first (SR §3.7) |
data.useNonSteamApps({enabled, refresh, includeLiquidGlass}) |
Hook. Steam's own scan and filter of the "+ > Launch Program" list (developer mode honoured). null while scanning, then [{key, name, exePath, cmdline, iconUrl, isLiquidGlass, raw}]. key is strCmdline (SR §4). The Liquid Glass entry is left out unless includeLiquidGlass (HA-10) |
data.useDevMode() |
Hook: Steam's developer-mode flag |
data.filterPrograms(list, devMode, {includeLiquidGlass}) |
Steam's filter on a raw scan list (same row shape as the hook) |
data.isLiquidGlass(rawEntry) |
true for the Liquid Glass switch entry (exe …/glass-shell/device/lgs or the name "Liquid Glass") |
8. Actions (rt.react.actions)
The only way T3 views launch or leave. Each returns {fn, arg, mode, reason} with mode one of executed, logged, refused.
| Action | Steam's call (the same one Steam's own control makes) |
|---|---|
launchNonSteam(cmdline, ev?) |
SteamClient.Apps.LaunchNonSteamApp(cmdline) ("+" popup row) |
primary(appid, ev?) |
Steam's primary action for the app (the library tile menu's first item: Play, Install, Update, Resume), run exactly as that menu runs it: the same two functions, client mostavailable, launch source 1000 (library), and the window of ev.currentTarget (else the main window) |
desktopWindow(windowId, ev?) |
Steam's DashboardDesktopWindowClicked({window_id}) ("+" popup window row) |
navigate(path, opts?, ev?) |
Navigate(path, opts.replace): user-intent jumps (a game page, Steam's library tabs) |
Test mode (log only, never run) is on when any of these holds:
- the runtime's action-logger test switch is on (P1,
contracts/runtime.md), orrt.react.actions.test(true)was called; - the flag
actionsLiveis nottrue. Built-in default false for the whole build; V1 sets it indefaults.jsonat release (PLAN §1.17 safe default); evis a DOM mouse or pointer event withisTrusted === false(a synthetic lab click; a real laser click is trusted), except Steam's own programmatic click: an untrustedPointerEventclickwithpointerType ''(pointerId -1). That is what Steam'sDialogButton(onClick) andMenuItem(onSelected, soui.menuitems) dispatch for the gamepad's A (HTMLElement.click()), the gamepad path, so it does not count. Lab clicks areMouseEvents (P10'sL.click) and stay synthetic; a locked lab step is in test mode anyway through P10's action logger (reason 1), whatever its clicks look like (RX-7 gamepad phase);- the test state cannot be read (fail closed).
refused means Steam's handler was not found: nothing runs and nothing is guessed.
Pass the event. Give the action the event your control received: a DialogButton's onClick(ev), a MenuItem's or ui.menu item's onSelected(ev), a Focusable's onActivate(ev) (a vgp_onok CustomEvent on A). All three are the user's own laser or gamepad press and run in the shipped state (actionsLive: true); only lab clicks and the test switches log instead.
| Helper | What |
|---|---|
actions.mode(ev?) |
{mode: 'live' | 'test', reasons: [...]} (with ev, as an action with that event would see it) |
actions.primaryInfo(appid) |
{appid, name, action, label, reason}: the primary action Steam's tile menu would show ('Play', 'Install', …) and its localized label, without running anything |
actions.log |
ring of the last 200 {t, fn, arg, mode, reason} |
actions.onLog(cb) |
subscribe; returns unsubscribe |
actions.handler(name) |
the Steam function name would call (identity checks, HA AT-4) |
actions.test(on) |
test hook: force test mode on or off for a locked step (P10's --flags and P1's rt.test set it too) |
9. Lab route (03-react-lab.js, module react-lab, flag reactLab, default off)
Tests only. Installed only inside a locked step (--flags reactLab).
| Call | What |
|---|---|
rt.react.lab.fixture(name, factory) |
registers factory(rt) → element (Steam's component with fake props) |
await rt.react.lab.open(name | element) |
adds /library/lgs/lab, navigates there, mounts the fixture inside its own ui.ErrorBoundary; resolves {mounted, error, route, nodes}: a fixture that throws gives mounted: false and the error message (C7 labels it "unverified"; RX-OPEN) |
await rt.react.lab.openRaw(name | element) |
the fixture as the whole page of a real added route, /library/lgs/lab/raw (no lab wrapper): what a package's routes.add() page gets; resolves {route, failPage} |
await rt.react.lab.close() |
leaves the route and removes it; resolves {routeLeft, nodesLeft} |
await rt.react.lab.selftest(opts) |
RX-1: SR §6's selftest on a test page at /library/lgs/lab/selftest |
await rt.react.lab.run(id) |
runs one of P2's acceptance tests ('RX-1' … 'RX-7') and returns its report |
Built-in fixtures: ConfirmModal (no-op props), buttons (DialogButton, DialogButtonPrimary), fields (ToggleField, SliderField with local state only), boom (a component that throws while rendering).
More of the lab API: lab.fixtures() (names), lab.tests (runner ids), lab.events (what the selftest page logged), lab.where() (where .gpfocus is, in lab terms), lab.press('A'|'B'|'X'|'Y'|'MENU') (dispatches a gamepad button only while focus is inside the lab page or its own menu and main is the active focus context; otherwise it throws).
Runners (lab.run(id, L); pass the lab helpers L from glass.py js): RX-1 … RX-7 (PLAN §2.3), and nine for builds the RX list does not cover: RX-OV (overrides), RX-HEAL (self-healing), RX-FN (bare-function patch, PagedSettings), RX-REMOVE (the module's whole removal), RX-TARGETS (statusPill, appButtons), RX-OPT (optional patches as the only registration, layering on appButtons), RX-RETRY (transient ready() failure retried), RX-FAIL (the error page on a real added route), RX-OPEN (lab.open() error reporting). Run them as python glass.py js --flags reactLab "__LGS_RT.react.lab.run('RX-1', L)"; RX-5, RX-6, RX-REMOVE, RX-OPT and RX-RETRY refuse to run while another module has live routes or patches.
10. Failure and fallback summary
| Failure | What happens |
|---|---|
| A required finder misses | ready() throws (sticky); nothing patched; every caller's install() throws; T1 everywhere |
| The route switch or the main window's fiber is not there yet (UI mounting) | ready() throws a transient error; nothing patched; retried by the next ready() after the backoff |
| An optional finder misses | that export is null; callers skip or degrade |
| A route component throws | ui.ErrorBoundary: focused Back page (added routes) or Steam's own page (overrides) |
| React remounts the switch | re-patched on the next navigation into our paths |
patch.byProps target not found |
throws (or count: 0, pending, with optional; attached when it mounts) |
| Steam's action handler not found | refused, nothing runs |
lgs off |
remove(); patchedLeft: 0 |
10.1 Test hooks (rt.react.test, lab only)
| Hook | What |
|---|---|
test.breakFinder(name) |
The next ready() treats that finder as missing (null clears). With rt.react.reset() this is RX-5 |
test.countPatchedLeft() |
Our functions still on fibers (type or elementType), holders, Steam's element factories |
test.cycle() |
The module's own removal (what lgs off runs), then a fresh unscanned state; returns the removal report. Every handle is dead afterwards |
test.dropSwitchPatch() |
Puts the original type back on the route switch fiber, as a React remount would (RX-HEAL) |
test.noSwitch(on) |
While on, ready() sees no route switch, as while Steam's UI mounts (a transient failure; RX-RETRY). Cleared by reset() |
rt.react.reset() |
Forgets a failed scan; refuses while routes, overrides or patches are live |
11. Changelog
| Version | Date | Change |
|---|---|---|
| v1 | 2026-10-07 | First version (M1) |
| v3 | 2026-10-07 | Review R1 fixes. Behaviour changes a caller may notice: (1) Steam's own programmatic click (untrusted PointerEvent click, pointerType '': A on a DialogButton or MenuItem) is no longer a synthetic event, so actions run from the gamepad in the shipped state (§8); (2) an optional patch keeps looking when it is the only registration, with late-attach timers after each navigation (§5.1); (3) a transient ready() failure is retried after a backoff instead of being sticky (§1); (4) ui.loc fills %N$s from its arguments (§6); (5) util.fnSrc reads through our trampolines (layering on appButtons). (6) ui.modal gives focus back to the control that had it when the modal closes (Steam left it parked in vr-null-tree; opts.restoreFocus: false opts out). Additions: ErrorBoundary.onError; the error page spans the page and uses P4 tokens; status().watching/lateTimers/errorTransient/retryInMs/attempts, patch.list()[].late; lab.open() returns {mounted, error, route, nodes}, lab.openRaw, fixture boom, runners RX-OPT, RX-RETRY, RX-FAIL, RX-OPEN; test.noSwitch. rt.react.version is 3 (it stayed 1 through v2 by mistake) |
| v2 | 2026-10-07 | Code complete and tested live. rt.react comes from P1's rt.expose. Flag react is the module's install gate (§1, REQ P1->P2). Rule 9 (no hooks in wraps). Overrides: function children and null steamChildren (§4.2). Patch kinds incl. bare functions through element substitution; rerender semantics; patch.list() (§5.1). All four named targets filled and verified (§5.2). fiber.walk, fiber.firstHost; ui.text, ui.lang, ui.Style, Page.rootProps/name; data.isLiquidGlass; actions.mode(ev), actions.primaryInfo; lab API and runners (§9); test hooks (§10.1). DropdownField is not in the fields fixture |