diff --git a/CHANGELOG.md b/CHANGELOG.md index 56dbbe8..c05f7c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,78 @@ Full module documentation: [hexdocs.pm/mob](https://hexdocs.pm/mob). ## [Unreleased] +Everything below is unreleased work from the MOB-124 rendering-performance +epic. Nothing here has shipped to Hex. + +### Added +- **`Mob.RenderStats` — per-frame render instrumentation** (MOB-125). Records + the user's `render/1`, tree expansion, component reconcile, the renderer's + prepare walk, `register_tap`, `:json.encode`, and `set_root` as seen from the + BEAM, plus node count, `register_tap` call count and payload bytes. Off by + default behind a `:persistent_term` flag; readable over dist with + `Mob.RenderStats.summary/0`, which reports p50/p95/max with the sample size + `n` per stage. `verify_taps/1` enables an opt-in second walk that cross-checks + the tap count. See `decisions/2026-09-01-render-instrumentation.md`, including + why `total_us` must not be compared against a frame budget. + +### Performance +- **`:scroll` can build its content lazily, with `lazy: true`** (MOB-128). A + column that is the direct content of a **vertical** scroll uses `LazyVStack` + rather than `VStack`, so only the rows on screen are built. Opt-in: rows below + the fold are never built, so `Mob.Test.element_frames` / `tap_id` cannot + address them and `scroll_to(:bottom)` under-scrolls, exactly as for + `lazy_list`. A `row` under a horizontal scroll, and anything deeper than a + scroll's direct child, stay eager. + + This is the **iOS** half. It is verified to render identically to the eager + path but its win is **not** independently measured on iOS — the equivalent + Android change (in `mob_new`) measures a 500-row screen going from 498.9 ms to + 115.8 ms of main-thread work per frame. See + `decisions/2026-09-02-lazy-scroll-on-ios.md`. +- **iOS `set_root` is 47% faster on a dense screen** (MOB-135). The native + deserialiser probed ~100 prop keys into every node's props regardless of node + type — 104 probe sites, 99 distinct keys, 8 type guards — to read the three to + five props a node actually carries. It now enumerates each node's own props + once and resolves keys to slots. On a 200-row screen (1627 nodes, 207 KB): + `set_root` 7625 → 4040 µs, whole frame 13002 → 9403 µs. Purely + native-internal; no wire-format change. +- **`register_tap` no longer logs once per exhausted call** (MOB-133). On a + screen with more than `MAX_TAP_HANDLES` (256) interactive elements, the + exhaustion path called `NSLog` synchronously per overflowing node — 359 times + per frame on a 200-row screen, 13 ms of a 27 ms frame. The count is now + reported once per frame from `set_root`, taking `register_tap` from 13004 µs + to 81 µs. +- **`clear_taps` frees only the slots that were used**, instead of walking all + 256 every frame. + +### Fixed +- **`ErlNifEnv` leak on the rescued render path** (MOB-133). Bounding + `clear_taps` by a high-water mark that only `set_root` wrote leaked one + `ErlNifEnv` per tap, per frame, whenever a render raised between `clear_taps` + and `set_root` — a path `Mob.Sender.commit/1` deliberately rescues, so it + accumulated silently. `register_tap` now maintains the mark. See + `decisions/2026-09-02-register-tap-owns-the-table-high-water-mark.md`. +- **`tap_exhausted_count` no longer leaks across frames.** It was reset only + inside `set_root`'s reporting branch, so a frame that overflowed and then + failed carried its count into the next frame's report. Reset in `clear_taps` + now. The iOS increment also moved inside the tap mutex, matching Zig. +- **`Mob.Sender` no longer discards render-stat frames at navigation + boundaries.** Both activation paths deleted a queued tree and threw its + measurement away with it, so dropped frames were undercounted at exactly the + transitions the epic measures. +- **Staged render-stat frames are swept by age**, so a screen killed between + `hand_off/1` and `Mob.Sender.render/5` cannot leave an entry nothing claims. + +### Known issues (found while measuring, not fixed here) +- **Throttle/debounce config never reaches native on either platform** + (MOB-134). iOS calls `mob_set_throttle_config` from the prop deserialiser, but + resolves the handle against the pre-swap tap table, so it always misses; + Android never calls it at all. Gestures behave as if every app used the + built-in defaults. +- **256-element interactive cap still bites** (MOB-133). A 200-row screen + registers 615 handlers; 359 of them get handle `-1` and silently do not + respond. Only the logging was fixed. + ## [0.7.38] - 2026-08-31 ### Fixed diff --git a/android/jni/mob_nif.zig b/android/jni/mob_nif.zig index 0b6818e..a4399c6 100644 --- a/android/jni/mob_nif.zig +++ b/android/jni/mob_nif.zig @@ -996,6 +996,12 @@ const ComponentHandle = extern struct { // after a row of buttons). With the swap a concurrent send always sees a // complete table (old or new), never a partial one. var tap_tables: [2][MAX_TAP_HANDLES]TapHandle = std.mem.zeroes([2][MAX_TAP_HANDLES]TapHandle); +// How many slots of each table were actually written, so clearTaps only walks +// those rather than the whole cap on every frame. +var tap_table_used: [2]usize = .{ 0, 0 }; +// Exhausted registrations in the frame being built. Counted rather than logged +// per call — see the note in register_tap. +var tap_exhausted_count: c_int = 0; var tap_active: usize = 0; // index of the table readers resolve against var tap_active_count: c_int = 0; // committed handle count in the active table var tap_table_generations: [2]u32 = .{ 0, 0 }; @@ -1653,10 +1659,28 @@ export fn nif_set_root( } } } + // Snapshot now, log after the mutex is released. The log call writes + // synchronously, and holding tap_mutex across it would block concurrent + // mob_send_* — reintroducing, once per frame, exactly the cost this change + // removed from the per-call path. + const exhausted_this_frame = tap_exhausted_count; + tap_exhausted_count = 0; + tap_active = 1 - tap_active; tap_active_count = tap_build_count; tap_table_generations[tap_active] = tap_build_generation; erts.enif_mutex_unlock(tap_mutex); + + if (exhausted_this_frame > 0) { + // One line per frame rather than one per overflowing node. The count is + // the useful number: it says how many interactive elements are silently + // inert, which the per-call line never made obvious. + loge_nif( + "register_tap: pool exhausted (cap={d}) — {d} interactive element(s) in this frame have no handler and will not respond", + .{ MAX_TAP_HANDLES, exhausted_this_frame }, + ); + } + const transition_cstr: [*:0]const u8 = @ptrCast(&transition); var attached: c_int = 0; @@ -1705,7 +1729,12 @@ export fn nif_register_tap( // no-op on an out-of-range handle, so -1 is a safe "no handler // wired up" sentinel here — the interactive prop silently does // nothing instead of taking the screen down. - loge_nif("register_tap: pool exhausted (cap={d}) — returning unhandled sentinel", .{MAX_TAP_HANDLES}); + // Deliberately not logged here. This is reached once per interactive + // node beyond the cap — measured on iOS at 359 times per frame on a + // 200-row screen — and the log call writes synchronously. On iOS that + // logging alone was 47% of the frame. Reported once per frame from + // set_root instead. + tap_exhausted_count += 1; return erts.enif_make_int(env, -1); } @@ -1724,6 +1753,14 @@ export fn nif_register_tap( slot.tag = erts.enif_make_copy(slot.tag_env, tag_term); slot.identity_start_generation = tap_build_generation; tap_build_count += 1; + // The high-water mark has to be raised HERE, not in set_root. clear_taps + // frees exactly `used` slots, and a frame can register taps and then never + // reach set_root — Mob.Renderer.render/4 calls clear_taps, then prepare, + // then :json.encode, then set_root, and Mob.Sender.commit/1 rescues anything + // that raises in between. Recording the mark only at set_root left those + // slots' tag_envs uncleared and unreachable: one leaked ErlNifEnv per tap, + // per failed frame, forever, on a path deliberately designed to survive. + tap_table_used[@intCast(1 - tap_active)] = @intCast(tap_build_count); return erts.enif_make_int(env, handle); } @@ -1746,7 +1783,8 @@ export fn nif_clear_taps( // frame. The freshly built table is swapped in at set_root. const build = &tap_tables[1 - tap_active]; var i: usize = 0; - while (i < MAX_TAP_HANDLES) : (i += 1) { + const used = tap_table_used[@intCast(1 - tap_active)]; + while (i < used) : (i += 1) { const h = &build[i]; if (h.tag_env != null) { erts.enif_free_env(h.tag_env); @@ -1763,7 +1801,13 @@ export fn nif_clear_taps( h.last_y = 0; h.seq = 0; } + tap_table_used[@intCast(1 - tap_active)] = 0; tap_build_count = 0; + // Reset here, not only in set_root. set_root reports and clears the count, + // but a frame that overflows and then never reaches set_root would otherwise + // carry its overflow into the next frame's report — which claims to describe + // "this frame". clear_taps is the one entry point every frame runs. + tap_exhausted_count = 0; return erts.ok(env); } diff --git a/decisions/2026-09-01-render-instrumentation.md b/decisions/2026-09-01-render-instrumentation.md new file mode 100644 index 0000000..b85516a --- /dev/null +++ b/decisions/2026-09-01-render-instrumentation.md @@ -0,0 +1,70 @@ +# Measuring the render pipeline before changing it + +- Date: 2026-09-01 +- Status: accepted +- Implements: MOB-125, first step of MOB-124 +- Builds on: `2026-08-28-sender-serialises-render.md` + +## Context + +MOB-124 proposed four fixes for rendering performance — retained native trees, +stable identity, lazy scroll containers, and LiveView-style wire patching. They +attack four different stages, and the pipeline had never been measured on a +device. Picking between them on intuition is how weeks go into the wrong one. + +## Decision + +`Mob.RenderStats` records per-frame stage timings, readable over dist. It is off +by default behind a `:persistent_term` flag, stores into a bounded ETS ring +owned by a GenServer, and reports p50/p95/max with the sample size `n` — not a +mean, because frame cost is not normally distributed and the tail is what a user +feels as stutter. + +Three things about its design were not obvious and were each got wrong first. + +**A frame spans two processes.** `Mob.Screen.Server.paint/4` runs `render/1`, +expansion and reconcile in the screen process, then casts to `Mob.Sender`, which +runs prepare, encode and `set_root`. Timing state in the process dictionary +therefore cannot span a frame. The screen hands its partial frame to the sender +with `hand_off/1`, and the sender resumes it before committing. The frame is +**paired with its tree when the render cast is dequeued**, not looked up again at +flush time: `Mob.Sender.sync/1` is called from the router, a different process, +so a flush can land between a screen's two casts and would otherwise commit tree +N-1 while holding frame N. + +**The ETS table needs an owner process.** Creating it inside `enable/0` makes it +owned by whoever called — over `:rpc.call/4` that is a transient process, so the +table dies the instant enabling returns and every later write goes nowhere. + +**`taps` comes from the call counter, not a tree walk.** Recounting handle-valued +props on the finished tree cost 120 ns per node — about 90% of the meter's whole +overhead — to reproduce a number `accumulate/2` already had exactly, for free, as +the calls happened. The walk survives behind `verify_taps/1` as an opt-in +cross-check, because the two disagreeing is how a counting bug announces itself. + +## What `total_us` is not + +It is stamped in the screen process and closed in the sender, so it spans two +casts and the sender's mailbox, and it includes the meter's own cost. On a +physical device it was observed **exceeding an externally measured frame by +several milliseconds** — only possible because it covers time outside the frame. + +It must not be compared against a frame budget or used to compare +configurations. For that, sum the stages, or measure from outside: drive one +render and block on `Mob.Sender.sync/1`. That external method is what the epic +now uses for verification, and it is the reason two published sets of numbers +had to be retracted. + +## Consequences + +The instrument was wrong in nine ways across two adversarial reviews before it +was right, and four of those defects had already produced published conclusions: +percentiles one rank high (`round/1` where nearest-rank wants `ceil/1 - 1`, which +made p95 the single worst frame for any run under 20), `taps` undercounting +12-fold, dropped frames polluting the byte and duration percentiles, and stage +percentiles computed over different populations without saying so. + +The lesson worth keeping is that a meter used to rank work needs the same +adversarial treatment as the work — and that every fix needs a test which fails +when that fix alone is reverted. Checking that one at a time caught two "fixes" +that changed nothing. diff --git a/decisions/2026-09-02-lazy-scroll-on-ios.md b/decisions/2026-09-02-lazy-scroll-on-ios.md new file mode 100644 index 0000000..a1d7c46 --- /dev/null +++ b/decisions/2026-09-02-lazy-scroll-on-ios.md @@ -0,0 +1,73 @@ +# :scroll builds its content lazily (iOS) + +- Date: 2026-09-02 +- Status: accepted +- Implements: MOB-128, part of MOB-124 +- Companion: `mob_new/decisions/2026-09-02-lazy-scroll-on-android.md` + +## Context + +The Android half of MOB-128 measured a 200-row screen at 134 ms of main-thread +work per update, 107 ms of it recomposition, against 45 ms for the whole +BEAM-plus-NIF pipeline. Making `:scroll` lazy took the main-thread cost to 82 ms +and made it flat in list length. + +iOS has the same shape: `case .scroll:` wrapped its children in an eager +`VStack`/`HStack`, so every child of a scroll was built whether or not it was on +screen. Only the dedicated `lazyList` node type used `LazyVStack`. + +## Decision + +A column that is the direct content of a **vertical** `scroll` builds its +children with `LazyVStack` instead of `VStack`, **when that scroll opted in with +`lazy: true`**. Everywhere else the stacks stay eager. + +**Opt-in**, matching Android, because laziness has observable consequences +beyond speed. Rows below the fold are never built, so they never register a +frame and `Mob.Test.element_frames` / `tap_id` cannot address them. And a +`LazyVStack`'s `contentSize` reflects only built rows and grows as you scroll, +so `nif_scroll_info`'s `contentSize - bounds` under-reports: `scroll_to(:bottom)` +under-scrolls and `screenshot_tour/3` truncates. `lazy_list` already makes that +trade explicitly; applying it silently to every scroll would change harness +behaviour under apps that never asked for it. + +**Vertical only.** A `LazyVStack` under a horizontal `ScrollView` would be lazy +on the wrong axis — the vertical axis there is bounded and never scrolls, so +rows below the fold would never be built at all rather than built on demand. + +**The column is made lazy, not the scroll's own stack.** Mob screens are written +`scroll > column > rows`, so the scroll's own stack has exactly one child and +making it lazy would buy nothing — the column underneath is the stack with 200 +children. + +**And the column is kept, not flattened away.** Android flattens the column and +uses its children as the list items, guarded by a check that the column's props +are layout-neutral. That guard is cheap there because props are a map. On iOS +`MobNode` exposes typed properties — padding, background, alignment, borders, +corner radius, `nativeViewId` — so an exhaustive "is this column neutral" test +would be a long list, and **missing one entry would silently drop something the +user can see**. Passing a `lazyContainer` flag down one level instead keeps every +modifier on the column exactly where it was. + +`MobEitherStack` exists because SwiftUI cannot choose between `VStack` and +`LazyVStack` inside one expression. The `if` produces two different view +identities, which is fine here: `lazy` is fixed for a given node's position in +the tree, so it never flips for a live view. + +## Status of the evidence + +The change is **verified correct** — a 200-row screen renders identically to the +eager path on the simulator, including multi-line wrapping labels, text fields, +toggles and buttons. + +The **win is not measured on iOS**. Android has `dumpsys gfxinfo framestats`, +which reports per-frame main-thread cost directly; iOS has no equivalent that can +be read from a script, and `set_root` dispatches to the main thread +asynchronously, so no BEAM-side instrument can see the work. The simulator runs +on a development Mac and is not representative of a phone, which is exactly the +mistake that made the first round of this epic's numbers worthless. + +So this is parity-by-construction on the mechanism proven on Android, not an +independently measured iOS result. Measuring it needs main-thread instrumentation +on a physical device — a `CATransaction` completion timer around `setRoot` would +do it — and that is worth doing before claiming an iOS number. diff --git a/decisions/2026-09-02-prop-key-dispatch.md b/decisions/2026-09-02-prop-key-dispatch.md new file mode 100644 index 0000000..023490c --- /dev/null +++ b/decisions/2026-09-02-prop-key-dispatch.md @@ -0,0 +1,62 @@ +# The native deserialiser resolves prop keys in one pass + +- Date: 2026-09-02 +- Status: accepted +- Implements: MOB-135, arising from MOB-124 measurement +- Builds on: `2026-09-01-render-instrumentation.md` + +## Context + +Measured on a 200-row screen (1627 nodes, 207 KB payload), `set_root` was the +largest single stage of a frame on both platforms. Splitting it open on iOS: + +| phase | µs | +|---|---| +| `NSData` copy of the payload | 22 | +| `NSJSONSerialization` parse | 1345 | +| **`mob_node_from_dict`** | **5900** | +| frame-id collect + adopt | 70 | + +Converting an already-parsed `NSDictionary` into `MobNode` objects cost 4.4x +what parsing the JSON did. That ratio was the anomaly. + +The cause: `mob_node_from_dict` probed every prop key it knows into every node's +`props`, regardless of node type — **104 probe sites over 99 distinct keys, with +only 8 guarded by a node-type check**. At 207 KB across 1627 nodes a node +averages 127 bytes, roughly 39 of which is the `{"type","props","children"}` +skeleton, so a typical node carries three to five props and paid ~100 hashed +lookups to find them. Each probe rehashes the literal's bytes (CFString caches +nothing) and, on a hit, runs a character compare because the parsed key is a +different object from the literal. + +The parse touches each byte once. The conversion did constant work per node with +no relationship to node size. That is the whole gap. + +## Decision + +Enumerate the node's own props once, resolve each key to a slot through a +`dispatch_once` table, and let the deserialiser read slots. + +**Statement order is preserved, and that is the reason for an indexed array +rather than a switch inside the enumeration.** Prop precedence depends on order +in three places — `text` before `value` for a text field, generic `width`/`height` +before canvas, generic `corner_radius` before sheet. A switch would have +reordered those and broken them silently. + +Measured, iOS simulator, same screen: `set_root` 7625 → 4040 µs, whole frame +13002 → 9403 µs. A 28% frame reduction, purely native-internal — no wire-format +change and no Elixir coordination. + +## Not done here + +A single-pass SAX parse straight into `MobNode`, skipping the intermediate +`NSDictionary` entirely, has a higher ceiling (an estimated 0.9-1.2 ms replacing +7.25 ms) but means owning a JSON parser — escapes, surrogate pairs, number forms, +UTF-8 validation, depth limits on adversarial input. Its marginal gain over this +change is about 1.1 ms, so it is not worth the risk yet. + +Android has the same root cause by a different mechanism: no probe amplification, +but triple materialisation (Java `String`, an `org.json` tree, a second map copy +per node, then the node class). One principle fixes both — never materialise a +generic key/value object graph; scan the wire bytes once and dispatch each key by +its bytes into the typed node representation. Android remains unfixed. diff --git a/decisions/2026-09-02-register-tap-owns-the-table-high-water-mark.md b/decisions/2026-09-02-register-tap-owns-the-table-high-water-mark.md new file mode 100644 index 0000000..fd4e19a --- /dev/null +++ b/decisions/2026-09-02-register-tap-owns-the-table-high-water-mark.md @@ -0,0 +1,54 @@ +# register_tap owns the tap table's high-water mark + +- Date: 2026-09-02 +- Status: accepted +- Implements: MOB-133 +- Builds on: `2026-08-27-frame-registry-purge-by-id.md` + +## Context + +`nif_clear_taps` walked all `MAX_TAP_HANDLES` (256) slots every frame to free +the previous frame's `ErlNifEnv`s, even when the frame had used four. Bounding +the loop by a recorded high-water mark is the obvious fix, and it was made — +with `set_root` as the only writer of that mark. + +That is wrong, and the reason is worth recording because the failure is silent. + +## Decision + +`nif_register_tap` maintains `tap_table_used`, not `nif_set_root`. + +A frame can register taps and never reach `set_root`. `Mob.Renderer.render/4` +runs `clear_taps`, then `prepare` (one `register_tap` per handler prop), then +`:json.encode`, then `set_root` — and `Mob.Sender.commit/1` **rescues** anything +that raises in between, deliberately, so one screen's bad render cannot freeze +every other screen by taking down the sender. + +So the rescued path leaked one `ErlNifEnv` per tap, per failed frame, forever, on +a path built to survive. A simulation of the verbatim logic showed 4900 live envs +after 50 failed 100-tap frames, and zero with the bound restored. + +The rule: **the function that writes a slot is the function that must record it +was written.** Anything else assumes a later stage always runs. + +`tap_exhausted_count` had the same shape — reset only inside `set_root`'s +reporting branch, so a frame that overflowed and then failed carried its count +into the next frame's report, which claims to describe "this frame". It resets in +`clear_taps` now, the one entry point every frame runs. + +## The logging that started this + +The exhaustion path called `LOGE` once per exhausted call. On a 200-row screen +that is 359 synchronous system-log writes per frame: **13 ms of a 27 ms frame, +47% of the total.** It read as "tap registration is the bottleneck" and nearly +redirected MOB-124. Counting and reporting once per frame took `register_tap` +from 13004 µs to 81 µs and halved the frame on its own. + +A per-call log on a per-node path is not a diagnostic, it is the bottleneck. + +## Still open + +The cap itself. 615 handlers against 256 slots means 359 interactive elements +per frame get handle `-1` and silently do not respond. Raising or virtualising +the pool is unresolved; `MAX_TAP_HANDLES` is tied to the 8 slot bits in +`tap_handle_codec`, so raising it trades generation bits for slot bits. diff --git a/ios/MobNode.h b/ios/MobNode.h index 5082c9f..d913a2b 100644 --- a/ios/MobNode.h +++ b/ios/MobNode.h @@ -175,6 +175,8 @@ NS_ASSUME_NONNULL_BEGIN // Layout behaviour @property(nonatomic) CGFloat layoutWeight; // positive = expand on a row/column's main axis @property(nonatomic) BOOL fillWidth; // fill parent width (default NO; button default YES) +// scroll only: build content lazily (LazyVStack). Opt-in — see MOB-128. +@property(nonatomic) BOOL lazyContent; @property(nonatomic) BOOL fillHeight; // fill parent height (default NO) — used for full-screen overlays/dialogs @property(nonatomic) CGFloat cornerRadius; // rounded corners in pt (default 0) diff --git a/ios/MobNode.m b/ios/MobNode.m index 9bc8132..9dc9849 100644 --- a/ios/MobNode.m +++ b/ios/MobNode.m @@ -37,6 +37,7 @@ - (instancetype)init { _fixedHeight = 0.0; _layoutWeight = 0.0; _fillWidth = NO; + _lazyContent = NO; _cornerRadius = 0.0; _nativeViewHandle = -1; // -1 = no native component slot assigned (MOB-100) _sheetCornerRadius = -1.0; // -1 = unset — use the system default sheet corner radius diff --git a/ios/MobRootView.swift b/ios/MobRootView.swift index ad0c147..4596d2f 100644 --- a/ios/MobRootView.swift +++ b/ios/MobRootView.swift @@ -247,17 +247,39 @@ extension MobNode { struct MobNodeView: View { let node: MobNode private let layoutWeightAxis: MobLayoutWeightAxis? - - init(node: MobNode, layoutWeightAxis: MobLayoutWeightAxis? = nil) { + // Set only for the direct children of a VERTICAL `scroll` that opted in with + // `lazy: true`. Everywhere else the stacks stay eager. + // + // Opt-in because laziness has observable consequences beyond speed: rows + // below the fold are never built, so they never register a frame and + // `Mob.Test.element_frames` / `tap_id` cannot address them, and a + // `LazyVStack`'s `contentSize` only reflects built rows — so + // `scroll_to(:bottom)` under-scrolls and `screenshot_tour` truncates. + // `lazy_list` already makes that trade explicitly; silently applying it to + // every scroll would change harness behaviour under apps that never asked. + private let lazyContainer: Bool + + init( + node: MobNode, + layoutWeightAxis: MobLayoutWeightAxis? = nil, + lazyContainer: Bool = false + ) { self.node = node self.layoutWeightAxis = layoutWeightAxis + self.lazyContainer = lazyContainer } var body: some View { Group { switch node.nodeType { case .column: - VStack(alignment: .leading, spacing: 0) { + // Mob screens are written scroll > column > rows, so the column + // inside a scroll is where the rows actually live. Making the + // scroll's own stack lazy would buy nothing — this is the stack + // that has 200 children. Rendering the column itself lazily keeps + // every one of its modifiers below intact, which flattening the + // column away would not. + MobEitherStack(lazy: lazyContainer, alignment: .leading) { ForEach(Array(node.childNodes.enumerated()), id: \.offset) { _, child in MobNodeView(node: child, layoutWeightAxis: .vertical) } @@ -385,12 +407,21 @@ struct MobNodeView: View { ScrollView(axes, showsIndicators: node.showIndicator) { if isHorizontal { HStack(alignment: .top, spacing: 0) { - ForEach(Array(node.childNodes.enumerated()), id: \.offset) { _, child in MobNodeView(node: child) } + // Never lazy here. A LazyVStack under a HORIZONTAL + // ScrollView would be lazy on the wrong axis: the + // vertical axis is bounded and never scrolls, so + // anything below the fold would never be built at + // all rather than built on demand. + ForEach(Array(node.childNodes.enumerated()), id: \.offset) { _, child in + MobNodeView(node: child) + } } .frame(maxHeight: .infinity, alignment: .topLeading) } else { VStack(alignment: .leading, spacing: 0) { - ForEach(Array(node.childNodes.enumerated()), id: \.offset) { _, child in MobNodeView(node: child) } + ForEach(Array(node.childNodes.enumerated()), id: \.offset) { _, child in + MobNodeView(node: child, lazyContainer: node.lazyContent) + } } .frame(maxWidth: .infinity, alignment: .leading) } @@ -2025,3 +2056,31 @@ struct MobScrollObserver: ViewModifier { } } } + +// A VStack that can be lazy without duplicating the call site. SwiftUI has no +// way to pick between VStack and LazyVStack at runtime inside one expression, +// and @ViewBuilder's `if` produces two different view identities — which is +// fine here because `lazy` is fixed for a given node's position in the tree. +struct MobEitherStack: View { + let lazy: Bool + let alignment: HorizontalAlignment + @ViewBuilder let content: Content + + init( + lazy: Bool, + alignment: HorizontalAlignment, + @ViewBuilder content: () -> Content + ) { + self.lazy = lazy + self.alignment = alignment + self.content = content() + } + + var body: some View { + if lazy { + LazyVStack(alignment: alignment, spacing: 0) { content } + } else { + VStack(alignment: alignment, spacing: 0) { content } + } + } +} diff --git a/ios/mob_nif.m b/ios/mob_nif.m index 459aff6..7f15f6f 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -106,6 +106,14 @@ void mob_set_startup_error(const char *error) { static int tap_handle_next = 0; // active committed count (readers' bound) static int tap_build_count = 0; // cursor into the building table static uint32_t tap_table_generations[2] = {0, 0}; +// How many slots of each table were actually written, so clear_taps only walks +// those. Walking all MAX_TAP_HANDLES every frame is wasted work at any cap and +// would scale with the cap if it were ever raised. +static int tap_table_used[2] = {0, 0}; +// Exhausted registrations in the frame being built. Counted rather than logged +// per call: a dense screen overflows the pool hundreds of times per frame, and +// NSLog is a synchronous write to the system log. +static int tap_exhausted_count = 0; static uint32_t tap_build_generation = 0; static ErlNifMutex *tap_mutex = NULL; @@ -735,6 +743,246 @@ static void mob_send_change_float(int handle, double value) { return [UIColor colorWithRed:r green:g blue:b alpha:a]; } +// ── Prop key dispatch ──────────────────────────────────────────────────────── +// mob_node_from_dict used to probe every one of these keys into a node's +// `props` dictionary, for every node, regardless of node type: ~100 hashed +// lookups to retrieve the three to five props a typical node actually carries. +// Measured at 5.9ms of a 7.7ms set_root on a 1627-node tree — more than four +// times what parsing the whole JSON payload cost in the first place. +// +// Instead the node's own props are enumerated once, each key resolved to a +// slot, and the deserialiser reads slots. The statement order below is +// unchanged, so the three places where prop precedence depends on it (text +// before value for a text field, generic width/height before canvas, generic +// corner_radius before sheet) behave exactly as they did. That is the reason +// for an indexed array rather than a switch inside the enumeration. +typedef NS_ENUM(NSUInteger, MobPropKey) { + MOB_PROP_accessibility_id, + MOB_PROP_accessibility_label, + MOB_PROP_accessibility_role, + MOB_PROP_active, + MOB_PROP_align, + MOB_PROP_allow, + MOB_PROP_autoplay, + MOB_PROP_axis, + MOB_PROP_background, + MOB_PROP_border_color, + MOB_PROP_border_width, + MOB_PROP_color, + MOB_PROP_component_handle, + MOB_PROP_content_mode, + MOB_PROP_controls, + MOB_PROP_corner_radius, + MOB_PROP_detents, + MOB_PROP_disabled, + MOB_PROP_drag_indicator_color, + MOB_PROP_drag_indicator_height, + MOB_PROP_drag_indicator_rail_height, + MOB_PROP_drag_indicator_width, + MOB_PROP_draw, + MOB_PROP_facing, + MOB_PROP_fade_on_scroll, + MOB_PROP_fill_height, + MOB_PROP_fill_width, + MOB_PROP_font, + MOB_PROP_font_weight, + MOB_PROP_glass, + MOB_PROP_height, + MOB_PROP_id, + MOB_PROP_italic, + MOB_PROP_lazy, + MOB_PROP_keyboard, + MOB_PROP_letter_spacing, + MOB_PROP_line_height, + MOB_PROP_loop, + MOB_PROP_max, + MOB_PROP_min, + MOB_PROP_module, + MOB_PROP_name, + MOB_PROP_offset_x, + MOB_PROP_offset_y, + MOB_PROP_on_blur, + MOB_PROP_on_change, + MOB_PROP_on_compose, + MOB_PROP_on_dismiss, + MOB_PROP_on_double_tap, + MOB_PROP_on_drag, + MOB_PROP_on_end_reached, + MOB_PROP_on_focus, + MOB_PROP_on_long_press, + MOB_PROP_on_pinch, + MOB_PROP_on_pointer_move, + MOB_PROP_on_rotate, + MOB_PROP_on_scroll, + MOB_PROP_on_scroll_began, + MOB_PROP_on_scroll_ended, + MOB_PROP_on_scroll_settled, + MOB_PROP_on_scrolled_past, + MOB_PROP_on_select, + MOB_PROP_on_submit, + MOB_PROP_on_swipe, + MOB_PROP_on_swipe_down, + MOB_PROP_on_swipe_left, + MOB_PROP_on_swipe_right, + MOB_PROP_on_swipe_up, + MOB_PROP_on_tab_select, + MOB_PROP_on_tap, + MOB_PROP_on_top_reached, + MOB_PROP_padding, + MOB_PROP_padding_bottom, + MOB_PROP_padding_left, + MOB_PROP_padding_right, + MOB_PROP_padding_top, + MOB_PROP_parallax, + MOB_PROP_placeholder, + MOB_PROP_placeholder_color, + MOB_PROP_return_key, + MOB_PROP_scrolled_past_threshold, + MOB_PROP_secure, + MOB_PROP_shader, + MOB_PROP_show_indicator, + MOB_PROP_show_url, + MOB_PROP_size, + MOB_PROP_src, + MOB_PROP_sticky_when_scrolled_past, + MOB_PROP_tabs, + MOB_PROP_text, + MOB_PROP_text_align, + MOB_PROP_text_color, + MOB_PROP_text_size, + MOB_PROP_thickness, + MOB_PROP_title, + MOB_PROP_uniforms, + MOB_PROP_url, + MOB_PROP_value, + MOB_PROP_weight, + MOB_PROP_width, + MOB_PROP__COUNT +}; + +static NSDictionary *mob_prop_slots(void) { + static NSDictionary *slots = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + // Designated initializers: each entry names the slot it fills, so the + // enum and this table cannot drift apart. They are two independently + // ordered lists of 99 strings joined by index — insert a key mid-enum and + // append it here, the natural mistake when the two are a hundred lines + // apart, and every slot after the insertion point reads a different + // prop's value on every node. This makes that unrepresentable. + NSString *const names[MOB_PROP__COUNT] = { + [MOB_PROP_accessibility_id] = @"accessibility_id", + [MOB_PROP_accessibility_label] = @"accessibility_label", + [MOB_PROP_accessibility_role] = @"accessibility_role", + [MOB_PROP_active] = @"active", + [MOB_PROP_align] = @"align", + [MOB_PROP_allow] = @"allow", + [MOB_PROP_autoplay] = @"autoplay", + [MOB_PROP_axis] = @"axis", + [MOB_PROP_background] = @"background", + [MOB_PROP_border_color] = @"border_color", + [MOB_PROP_border_width] = @"border_width", + [MOB_PROP_color] = @"color", + [MOB_PROP_component_handle] = @"component_handle", + [MOB_PROP_content_mode] = @"content_mode", + [MOB_PROP_controls] = @"controls", + [MOB_PROP_corner_radius] = @"corner_radius", + [MOB_PROP_detents] = @"detents", + [MOB_PROP_disabled] = @"disabled", + [MOB_PROP_drag_indicator_color] = @"drag_indicator_color", + [MOB_PROP_drag_indicator_height] = @"drag_indicator_height", + [MOB_PROP_drag_indicator_rail_height] = @"drag_indicator_rail_height", + [MOB_PROP_drag_indicator_width] = @"drag_indicator_width", + [MOB_PROP_draw] = @"draw", + [MOB_PROP_facing] = @"facing", + [MOB_PROP_fade_on_scroll] = @"fade_on_scroll", + [MOB_PROP_fill_height] = @"fill_height", + [MOB_PROP_fill_width] = @"fill_width", + [MOB_PROP_font] = @"font", + [MOB_PROP_font_weight] = @"font_weight", + [MOB_PROP_glass] = @"glass", + [MOB_PROP_height] = @"height", + [MOB_PROP_id] = @"id", + [MOB_PROP_italic] = @"italic", + [MOB_PROP_lazy] = @"lazy", + [MOB_PROP_keyboard] = @"keyboard", + [MOB_PROP_letter_spacing] = @"letter_spacing", + [MOB_PROP_line_height] = @"line_height", + [MOB_PROP_loop] = @"loop", + [MOB_PROP_max] = @"max", + [MOB_PROP_min] = @"min", + [MOB_PROP_module] = @"module", + [MOB_PROP_name] = @"name", + [MOB_PROP_offset_x] = @"offset_x", + [MOB_PROP_offset_y] = @"offset_y", + [MOB_PROP_on_blur] = @"on_blur", + [MOB_PROP_on_change] = @"on_change", + [MOB_PROP_on_compose] = @"on_compose", + [MOB_PROP_on_dismiss] = @"on_dismiss", + [MOB_PROP_on_double_tap] = @"on_double_tap", + [MOB_PROP_on_drag] = @"on_drag", + [MOB_PROP_on_end_reached] = @"on_end_reached", + [MOB_PROP_on_focus] = @"on_focus", + [MOB_PROP_on_long_press] = @"on_long_press", + [MOB_PROP_on_pinch] = @"on_pinch", + [MOB_PROP_on_pointer_move] = @"on_pointer_move", + [MOB_PROP_on_rotate] = @"on_rotate", + [MOB_PROP_on_scroll] = @"on_scroll", + [MOB_PROP_on_scroll_began] = @"on_scroll_began", + [MOB_PROP_on_scroll_ended] = @"on_scroll_ended", + [MOB_PROP_on_scroll_settled] = @"on_scroll_settled", + [MOB_PROP_on_scrolled_past] = @"on_scrolled_past", + [MOB_PROP_on_select] = @"on_select", + [MOB_PROP_on_submit] = @"on_submit", + [MOB_PROP_on_swipe] = @"on_swipe", + [MOB_PROP_on_swipe_down] = @"on_swipe_down", + [MOB_PROP_on_swipe_left] = @"on_swipe_left", + [MOB_PROP_on_swipe_right] = @"on_swipe_right", + [MOB_PROP_on_swipe_up] = @"on_swipe_up", + [MOB_PROP_on_tab_select] = @"on_tab_select", + [MOB_PROP_on_tap] = @"on_tap", + [MOB_PROP_on_top_reached] = @"on_top_reached", + [MOB_PROP_padding] = @"padding", + [MOB_PROP_padding_bottom] = @"padding_bottom", + [MOB_PROP_padding_left] = @"padding_left", + [MOB_PROP_padding_right] = @"padding_right", + [MOB_PROP_padding_top] = @"padding_top", + [MOB_PROP_parallax] = @"parallax", + [MOB_PROP_placeholder] = @"placeholder", + [MOB_PROP_placeholder_color] = @"placeholder_color", + [MOB_PROP_return_key] = @"return_key", + [MOB_PROP_scrolled_past_threshold] = @"scrolled_past_threshold", + [MOB_PROP_secure] = @"secure", + [MOB_PROP_shader] = @"shader", + [MOB_PROP_show_indicator] = @"show_indicator", + [MOB_PROP_show_url] = @"show_url", + [MOB_PROP_size] = @"size", + [MOB_PROP_src] = @"src", + [MOB_PROP_sticky_when_scrolled_past] = @"sticky_when_scrolled_past", + [MOB_PROP_tabs] = @"tabs", + [MOB_PROP_text] = @"text", + [MOB_PROP_text_align] = @"text_align", + [MOB_PROP_text_color] = @"text_color", + [MOB_PROP_text_size] = @"text_size", + [MOB_PROP_thickness] = @"thickness", + [MOB_PROP_title] = @"title", + [MOB_PROP_uniforms] = @"uniforms", + [MOB_PROP_url] = @"url", + [MOB_PROP_value] = @"value", + [MOB_PROP_weight] = @"weight", + [MOB_PROP_width] = @"width"}; + NSMutableDictionary *m = [NSMutableDictionary dictionaryWithCapacity:MOB_PROP__COUNT]; + for (NSUInteger i = 0; i < MOB_PROP__COUNT; i++) { + // A gap means an enum entry with no name: that prop would never + // resolve and would read as absent on every node, silently. + NSCAssert(names[i] != nil, @"MobPropKey %lu has no name", (unsigned long)i); + m[names[i]] = @(i); + } + slots = [m copy]; + }); + return slots; +} + static MobNode *mob_node_from_dict(NSDictionary *dict) { if (![dict isKindOfClass:[NSDictionary class]]) return nil; @@ -790,8 +1038,25 @@ static void mob_send_change_float(int handle, double value) { node.nodeType = MobNodeTypeSheet; NSDictionary *props = dict[@"props"]; + + // One pass over the props this node actually has, rather than one probe per + // key it might have had. Unknown keys are ignored, exactly as an absent + // probe was. A nil or non-dictionary `props` leaves every slot nil, which is + // what `props[@"..."]` returned before. + id pv[MOB_PROP__COUNT]; + memset(pv, 0, sizeof(pv)); + + if ([props isKindOfClass:[NSDictionary class]]) { + NSDictionary *slots = mob_prop_slots(); + for (NSString *key in props) { + NSNumber *slot = slots[key]; + if (slot) + pv[slot.unsignedIntegerValue] = props[key]; + } + } + if ([props isKindOfClass:[NSDictionary class]]) { - id text = props[@"text"]; + id text = pv[MOB_PROP_text]; if (text) node.text = [text isKindOfClass:[NSString class]] ? text : [text description]; @@ -800,59 +1065,59 @@ static void mob_send_change_float(int handle, double value) { // to `node.text` so MobTextField sees it as initialText. If both // `text:` and `value:` are passed, `value:` wins. if (node.nodeType == MobNodeTypeTextField) { - id valueText = props[@"value"]; + id valueText = pv[MOB_PROP_value]; if (valueText) node.text = [valueText isKindOfClass:[NSString class]] ? valueText : [valueText description]; } - id padding = props[@"padding"]; + id padding = pv[MOB_PROP_padding]; if (padding) node.padding = [padding doubleValue]; - id paddingTop = props[@"padding_top"]; + id paddingTop = pv[MOB_PROP_padding_top]; if (paddingTop) node.paddingTop = [paddingTop doubleValue]; - id paddingRight = props[@"padding_right"]; + id paddingRight = pv[MOB_PROP_padding_right]; if (paddingRight) node.paddingRight = [paddingRight doubleValue]; - id paddingBottom = props[@"padding_bottom"]; + id paddingBottom = pv[MOB_PROP_padding_bottom]; if (paddingBottom) node.paddingBottom = [paddingBottom doubleValue]; - id paddingLeft = props[@"padding_left"]; + id paddingLeft = pv[MOB_PROP_padding_left]; if (paddingLeft) node.paddingLeft = [paddingLeft doubleValue]; - id textSize = props[@"text_size"]; + id textSize = pv[MOB_PROP_text_size]; if (textSize) node.textSize = [textSize doubleValue]; - id fontFamily = props[@"font"]; + id fontFamily = pv[MOB_PROP_font]; if ([fontFamily isKindOfClass:[NSString class]]) node.fontFamily = fontFamily; - id fontWeight = props[@"font_weight"]; + id fontWeight = pv[MOB_PROP_font_weight]; if (fontWeight) node.fontWeight = [fontWeight description]; - id textAlign = props[@"text_align"]; + id textAlign = pv[MOB_PROP_text_align]; if (textAlign) node.textAlign = [textAlign description]; - id italic = props[@"italic"]; + id italic = pv[MOB_PROP_italic]; if (italic) node.italic = [italic boolValue]; - id lineHeight = props[@"line_height"]; + id lineHeight = pv[MOB_PROP_line_height]; if (lineHeight) node.lineHeight = [lineHeight doubleValue]; - id letterSpacing = props[@"letter_spacing"]; + id letterSpacing = pv[MOB_PROP_letter_spacing]; if (letterSpacing) node.letterSpacing = [letterSpacing doubleValue]; - id tabDefs = props[@"tabs"]; + id tabDefs = pv[MOB_PROP_tabs]; if ([tabDefs isKindOfClass:[NSArray class]]) node.tabDefs = tabDefs; - id activeTab = props[@"active"]; + id activeTab = pv[MOB_PROP_active]; if (activeTab) node.activeTab = [activeTab description]; - id onTabSelect = props[@"on_tab_select"]; + id onTabSelect = pv[MOB_PROP_on_tab_select]; if (onTabSelect && [onTabSelect isKindOfClass:[NSNumber class]]) { int handle = [onTabSelect intValue]; node.onTabSelect = ^(NSString *tabId) { @@ -860,63 +1125,63 @@ static void mob_send_change_float(int handle, double value) { }; } - id bg = props[@"background"]; + id bg = pv[MOB_PROP_background]; if (bg) node.backgroundColor = color_from_argb((long)[bg longLongValue]); - id borderColor = props[@"border_color"]; + id borderColor = pv[MOB_PROP_border_color]; if (borderColor) node.borderColor = color_from_argb((long)[borderColor longLongValue]); - id borderWidth = props[@"border_width"]; + id borderWidth = pv[MOB_PROP_border_width]; if (borderWidth) node.borderWidth = [borderWidth doubleValue]; - id textColor = props[@"text_color"]; + id textColor = pv[MOB_PROP_text_color]; if (textColor) node.textColor = color_from_argb((long)[textColor longLongValue]); - id color = props[@"color"]; + id color = pv[MOB_PROP_color]; if (color) node.color = color_from_argb((long)[color longLongValue]); - id thickness = props[@"thickness"]; + id thickness = pv[MOB_PROP_thickness]; if (thickness) node.thickness = [thickness doubleValue]; - id fixedSize = props[@"size"]; + id fixedSize = pv[MOB_PROP_size]; if (fixedSize) node.fixedSize = [fixedSize doubleValue]; - id axis = props[@"axis"]; + id axis = pv[MOB_PROP_axis]; if ([axis isKindOfClass:[NSString class]]) node.axis = axis; // `align` plays two roles depending on node type — the Mob renderer // sets the same string and the iOS side picks the relevant // interpretation per case (rowAlign for HStack, boxAlign for ZStack). - id alignProp = props[@"align"]; + id alignProp = pv[MOB_PROP_align]; if ([alignProp isKindOfClass:[NSString class]]) { node.rowAlign = alignProp; node.boxAlign = alignProp; } - id offsetX = props[@"offset_x"]; + id offsetX = pv[MOB_PROP_offset_x]; if (offsetX) node.offsetX = [offsetX doubleValue]; - id offsetY = props[@"offset_y"]; + id offsetY = pv[MOB_PROP_offset_y]; if (offsetY) node.offsetY = [offsetY doubleValue]; - id showIndicator = props[@"show_indicator"]; + id showIndicator = pv[MOB_PROP_show_indicator]; if (showIndicator) node.showIndicator = [showIndicator boolValue]; - id value = props[@"value"]; + id value = pv[MOB_PROP_value]; if (value) node.value = [value doubleValue]; - id onTap = props[@"on_tap"]; + id onTap = pv[MOB_PROP_on_tap]; if (onTap && [onTap isKindOfClass:[NSNumber class]]) { int handle = [onTap intValue]; node.onTap = ^{ @@ -924,7 +1189,7 @@ static void mob_send_change_float(int handle, double value) { }; } - id placeholder = props[@"placeholder"]; + id placeholder = pv[MOB_PROP_placeholder]; if (placeholder) node.placeholder = [placeholder isKindOfClass:[NSString class]] ? placeholder @@ -933,25 +1198,25 @@ static void mob_send_change_float(int handle, double value) { // Icon name — logical key (e.g. "settings"), resolved to an SF Symbol // by MobIconView at render time. iOS-only string parsing here. if (node.nodeType == MobNodeTypeIcon) { - id iconName = props[@"name"]; + id iconName = pv[MOB_PROP_name]; if (iconName) node.iconName = [iconName isKindOfClass:[NSString class]] ? iconName : [iconName description]; } - id keyboardType = props[@"keyboard"]; + id keyboardType = pv[MOB_PROP_keyboard]; if ([keyboardType isKindOfClass:[NSString class]]) node.keyboardTypeStr = keyboardType; - id returnKey = props[@"return_key"]; + id returnKey = pv[MOB_PROP_return_key]; if ([returnKey isKindOfClass:[NSString class]]) node.returnKeyStr = returnKey; - id secure = props[@"secure"]; + id secure = pv[MOB_PROP_secure]; if ([secure isKindOfClass:[NSNumber class]]) node.isSecure = [secure boolValue]; - id onFocus = props[@"on_focus"]; + id onFocus = pv[MOB_PROP_on_focus]; if (onFocus && [onFocus isKindOfClass:[NSNumber class]]) { int handle = [onFocus intValue]; node.onFocus = ^{ @@ -959,7 +1224,7 @@ static void mob_send_change_float(int handle, double value) { }; } - id onBlur = props[@"on_blur"]; + id onBlur = pv[MOB_PROP_on_blur]; if (onBlur && [onBlur isKindOfClass:[NSNumber class]]) { int handle = [onBlur intValue]; node.onBlur = ^{ @@ -967,7 +1232,7 @@ static void mob_send_change_float(int handle, double value) { }; } - id onSubmit = props[@"on_submit"]; + id onSubmit = pv[MOB_PROP_on_submit]; if (onSubmit && [onSubmit isKindOfClass:[NSNumber class]]) { int handle = [onSubmit intValue]; node.onSubmit = ^{ @@ -975,7 +1240,7 @@ static void mob_send_change_float(int handle, double value) { }; } - id onCompose = props[@"on_compose"]; + id onCompose = pv[MOB_PROP_on_compose]; if (onCompose && [onCompose isKindOfClass:[NSNumber class]]) { int handle = [onCompose intValue]; node.onCompose = ^(NSString *text, NSString *phase) { @@ -984,7 +1249,7 @@ static void mob_send_change_float(int handle, double value) { }; } - id onSelect = props[@"on_select"]; + id onSelect = pv[MOB_PROP_on_select]; if (onSelect && [onSelect isKindOfClass:[NSNumber class]]) { int handle = [onSelect intValue]; node.onSelect = ^{ @@ -993,7 +1258,7 @@ static void mob_send_change_float(int handle, double value) { } // ── Gestures (Batch 4) ── - id onLongPress = props[@"on_long_press"]; + id onLongPress = pv[MOB_PROP_on_long_press]; if (onLongPress && [onLongPress isKindOfClass:[NSNumber class]]) { int handle = [onLongPress intValue]; node.onLongPress = ^{ @@ -1001,7 +1266,7 @@ static void mob_send_change_float(int handle, double value) { }; } - id onDoubleTap = props[@"on_double_tap"]; + id onDoubleTap = pv[MOB_PROP_on_double_tap]; if (onDoubleTap && [onDoubleTap isKindOfClass:[NSNumber class]]) { int handle = [onDoubleTap intValue]; node.onDoubleTap = ^{ @@ -1009,7 +1274,7 @@ static void mob_send_change_float(int handle, double value) { }; } - id onSwipe = props[@"on_swipe"]; + id onSwipe = pv[MOB_PROP_on_swipe]; if (onSwipe && [onSwipe isKindOfClass:[NSNumber class]]) { int handle = [onSwipe intValue]; node.onSwipe = ^(NSString *direction) { @@ -1017,7 +1282,7 @@ static void mob_send_change_float(int handle, double value) { }; } - id onSwipeLeft = props[@"on_swipe_left"]; + id onSwipeLeft = pv[MOB_PROP_on_swipe_left]; if (onSwipeLeft && [onSwipeLeft isKindOfClass:[NSNumber class]]) { int handle = [onSwipeLeft intValue]; node.onSwipeLeft = ^{ @@ -1025,7 +1290,7 @@ static void mob_send_change_float(int handle, double value) { }; } - id onSwipeRight = props[@"on_swipe_right"]; + id onSwipeRight = pv[MOB_PROP_on_swipe_right]; if (onSwipeRight && [onSwipeRight isKindOfClass:[NSNumber class]]) { int handle = [onSwipeRight intValue]; node.onSwipeRight = ^{ @@ -1033,7 +1298,7 @@ static void mob_send_change_float(int handle, double value) { }; } - id onSwipeUp = props[@"on_swipe_up"]; + id onSwipeUp = pv[MOB_PROP_on_swipe_up]; if (onSwipeUp && [onSwipeUp isKindOfClass:[NSNumber class]]) { int handle = [onSwipeUp intValue]; node.onSwipeUp = ^{ @@ -1041,7 +1306,7 @@ static void mob_send_change_float(int handle, double value) { }; } - id onSwipeDown = props[@"on_swipe_down"]; + id onSwipeDown = pv[MOB_PROP_on_swipe_down]; if (onSwipeDown && [onSwipeDown isKindOfClass:[NSNumber class]]) { int handle = [onSwipeDown intValue]; node.onSwipeDown = ^{ @@ -1065,7 +1330,7 @@ static void mob_send_change_float(int handle, double value) { } \ } while (0) - id onScroll = props[@"on_scroll"]; + id onScroll = pv[MOB_PROP_on_scroll]; if ([onScroll isKindOfClass:[NSNumber class]]) { int handle = [onScroll intValue]; MOB_APPLY_THROTTLE(handle, @"scroll_config"); @@ -1076,7 +1341,7 @@ static void mob_send_change_float(int handle, double value) { }; } - id onDrag = props[@"on_drag"]; + id onDrag = pv[MOB_PROP_on_drag]; if ([onDrag isKindOfClass:[NSNumber class]]) { int handle = [onDrag intValue]; MOB_APPLY_THROTTLE(handle, @"drag_config"); @@ -1085,7 +1350,7 @@ static void mob_send_change_float(int handle, double value) { }; } - id onPinch = props[@"on_pinch"]; + id onPinch = pv[MOB_PROP_on_pinch]; if ([onPinch isKindOfClass:[NSNumber class]]) { int handle = [onPinch intValue]; MOB_APPLY_THROTTLE(handle, @"pinch_config"); @@ -1094,7 +1359,7 @@ static void mob_send_change_float(int handle, double value) { }; } - id onRotate = props[@"on_rotate"]; + id onRotate = pv[MOB_PROP_on_rotate]; if ([onRotate isKindOfClass:[NSNumber class]]) { int handle = [onRotate intValue]; MOB_APPLY_THROTTLE(handle, @"rotate_config"); @@ -1103,7 +1368,7 @@ static void mob_send_change_float(int handle, double value) { }; } - id onPointerMove = props[@"on_pointer_move"]; + id onPointerMove = pv[MOB_PROP_on_pointer_move]; if ([onPointerMove isKindOfClass:[NSNumber class]]) { int handle = [onPointerMove intValue]; MOB_APPLY_THROTTLE(handle, @"pointer_config"); @@ -1115,7 +1380,7 @@ static void mob_send_change_float(int handle, double value) { #undef MOB_APPLY_THROTTLE // ── Batch 5 Tier 2: semantic single-fire scroll events ── - id onScrollBegan = props[@"on_scroll_began"]; + id onScrollBegan = pv[MOB_PROP_on_scroll_began]; if ([onScrollBegan isKindOfClass:[NSNumber class]]) { int handle = [onScrollBegan intValue]; node.onScrollBegan = ^{ @@ -1123,7 +1388,7 @@ static void mob_send_change_float(int handle, double value) { }; } - id onScrollEnded = props[@"on_scroll_ended"]; + id onScrollEnded = pv[MOB_PROP_on_scroll_ended]; if ([onScrollEnded isKindOfClass:[NSNumber class]]) { int handle = [onScrollEnded intValue]; node.onScrollEnded = ^{ @@ -1131,7 +1396,7 @@ static void mob_send_change_float(int handle, double value) { }; } - id onScrollSettled = props[@"on_scroll_settled"]; + id onScrollSettled = pv[MOB_PROP_on_scroll_settled]; if ([onScrollSettled isKindOfClass:[NSNumber class]]) { int handle = [onScrollSettled intValue]; node.onScrollSettled = ^{ @@ -1139,7 +1404,7 @@ static void mob_send_change_float(int handle, double value) { }; } - id onTopReached = props[@"on_top_reached"]; + id onTopReached = pv[MOB_PROP_on_top_reached]; if ([onTopReached isKindOfClass:[NSNumber class]]) { int handle = [onTopReached intValue]; node.onTopReached = ^{ @@ -1147,120 +1412,124 @@ static void mob_send_change_float(int handle, double value) { }; } - id onScrolledPast = props[@"on_scrolled_past"]; + id onScrolledPast = pv[MOB_PROP_on_scrolled_past]; if ([onScrolledPast isKindOfClass:[NSNumber class]]) { int handle = [onScrolledPast intValue]; node.onScrolledPast = ^{ mob_send_scrolled_past(handle); }; } - id scrolledPastThreshold = props[@"scrolled_past_threshold"]; + id scrolledPastThreshold = pv[MOB_PROP_scrolled_past_threshold]; if (scrolledPastThreshold) { node.scrolledPastThreshold = [scrolledPastThreshold doubleValue]; } // ── Batch 5 Tier 3: native-side scroll-driven UI configs ── // Pass-through to the SwiftUI layer; never round-trips to BEAM. - id parallax = props[@"parallax"]; + id parallax = pv[MOB_PROP_parallax]; if ([parallax isKindOfClass:[NSDictionary class]]) { node.parallaxConfig = parallax; } - id fadeOnScroll = props[@"fade_on_scroll"]; + id fadeOnScroll = pv[MOB_PROP_fade_on_scroll]; if ([fadeOnScroll isKindOfClass:[NSDictionary class]]) { node.fadeOnScrollConfig = fadeOnScroll; } - id stickyConfig = props[@"sticky_when_scrolled_past"]; + id stickyConfig = pv[MOB_PROP_sticky_when_scrolled_past]; if ([stickyConfig isKindOfClass:[NSDictionary class]]) { node.stickyWhenScrolledPastConfig = stickyConfig; } - id checked = props[@"value"]; + id checked = pv[MOB_PROP_value]; if (checked && node.nodeType == MobNodeTypeToggle) { // value is a boolean atom serialised as "true"/"false" node.checked = [[checked description] isEqualToString:@"true"] || ([checked isKindOfClass:[NSNumber class]] && [checked boolValue]); } - id minVal = props[@"min"]; + id minVal = pv[MOB_PROP_min]; if (minVal) node.minValue = [minVal doubleValue]; - id maxVal = props[@"max"]; + id maxVal = pv[MOB_PROP_max]; if (maxVal) node.maxValue = [maxVal doubleValue]; - id src = props[@"src"]; + id src = pv[MOB_PROP_src]; if ([src isKindOfClass:[NSString class]]) node.src = src; - id contentMode = props[@"content_mode"]; + id contentMode = pv[MOB_PROP_content_mode]; if ([contentMode isKindOfClass:[NSString class]]) node.contentModeStr = contentMode; - id fixedWidth = props[@"width"]; + id fixedWidth = pv[MOB_PROP_width]; if (fixedWidth) node.fixedWidth = [fixedWidth doubleValue]; - id fixedHeight = props[@"height"]; + id fixedHeight = pv[MOB_PROP_height]; if (fixedHeight) node.fixedHeight = [fixedHeight doubleValue]; - id layoutWeight = props[@"weight"]; + id layoutWeight = pv[MOB_PROP_weight]; if (layoutWeight) node.layoutWeight = [layoutWeight doubleValue]; - id cornerRadius = props[@"corner_radius"]; + id cornerRadius = pv[MOB_PROP_corner_radius]; if (cornerRadius) node.cornerRadius = [cornerRadius doubleValue]; // Liquid Glass opt-in — set by Mob.Renderer when the active theme // has `glass: true`. MobBox swaps a solid background for // `.glassEffect()` on iOS 26+, or `.ultraThinMaterial` on iOS 17–25. - id useGlass = props[@"glass"]; + id useGlass = pv[MOB_PROP_glass]; if (useGlass) node.useGlass = [useGlass boolValue]; - id fillWidth = props[@"fill_width"]; + id lazyContent = pv[MOB_PROP_lazy]; + if ([lazyContent isKindOfClass:[NSNumber class]]) + node.lazyContent = [lazyContent boolValue]; + + id fillWidth = pv[MOB_PROP_fill_width]; if (fillWidth) node.fillWidth = [fillWidth boolValue]; - id fillHeight = props[@"fill_height"]; + id fillHeight = pv[MOB_PROP_fill_height]; if (fillHeight) node.fillHeight = [fillHeight boolValue]; - id placeholderColor = props[@"placeholder_color"]; + id placeholderColor = pv[MOB_PROP_placeholder_color]; if (placeholderColor) node.placeholderColor = color_from_argb((long)[placeholderColor longLongValue]); - id videoAutoplay = props[@"autoplay"]; + id videoAutoplay = pv[MOB_PROP_autoplay]; if (videoAutoplay) node.videoAutoplay = [videoAutoplay boolValue]; - id videoLoop = props[@"loop"]; + id videoLoop = pv[MOB_PROP_loop]; if (videoLoop) node.videoLoop = [videoLoop boolValue]; - id videoControls = props[@"controls"]; + id videoControls = pv[MOB_PROP_controls]; if (videoControls) node.videoControls = [videoControls boolValue]; - id cameraFacing = props[@"facing"]; + id cameraFacing = pv[MOB_PROP_facing]; if ([cameraFacing isKindOfClass:[NSString class]]) node.cameraFacing = cameraFacing; // canvas props - id canvasDraw = props[@"draw"]; + id canvasDraw = pv[MOB_PROP_draw]; if ([canvasDraw isKindOfClass:[NSArray class]]) node.canvasOps = canvasDraw; - id canvasW = props[@"width"]; + id canvasW = pv[MOB_PROP_width]; if (canvasW && node.nodeType == MobNodeTypeCanvas) node.canvasWidth = [canvasW doubleValue]; - id canvasH = props[@"height"]; + id canvasH = pv[MOB_PROP_height]; if (canvasH && node.nodeType == MobNodeTypeCanvas) node.canvasHeight = [canvasH doubleValue]; // gpu_view props: shader (string OR %{ios: "..."} map) + uniforms map. // Map form is the "I already have hand-tuned MSL" escape hatch. if (node.nodeType == MobNodeTypeGpuView) { - id shader = props[@"shader"]; + id shader = pv[MOB_PROP_shader]; if ([shader isKindOfClass:[NSString class]]) { node.gpuShaderMSL = shader; } else if ([shader isKindOfClass:[NSDictionary class]]) { @@ -1269,7 +1538,7 @@ static void mob_send_change_float(int handle, double value) { node.gpuShaderMSL = iosShader; } - id uniforms = props[@"uniforms"]; + id uniforms = pv[MOB_PROP_uniforms]; if ([uniforms isKindOfClass:[NSArray class]] || [uniforms isKindOfClass:[NSDictionary class]]) node.gpuUniforms = uniforms; @@ -1287,29 +1556,29 @@ static void mob_send_change_float(int handle, double value) { // needs its own sentinel here (unlike other node types, where 0 and // unset render identically). if (node.nodeType == MobNodeTypeSheet) { - id sheetCornerRadius = props[@"corner_radius"]; + id sheetCornerRadius = pv[MOB_PROP_corner_radius]; if (sheetCornerRadius) node.sheetCornerRadius = [sheetCornerRadius doubleValue]; - id detents = props[@"detents"]; + id detents = pv[MOB_PROP_detents]; if ([detents isKindOfClass:[NSArray class]]) node.sheetDetents = detents; - id indicatorColor = props[@"drag_indicator_color"]; + id indicatorColor = pv[MOB_PROP_drag_indicator_color]; if (indicatorColor) node.dragIndicatorColor = color_from_argb((long)[indicatorColor longLongValue]); - id indicatorWidth = props[@"drag_indicator_width"]; + id indicatorWidth = pv[MOB_PROP_drag_indicator_width]; if (indicatorWidth) node.dragIndicatorWidth = [indicatorWidth doubleValue]; - id indicatorHeight = props[@"drag_indicator_height"]; + id indicatorHeight = pv[MOB_PROP_drag_indicator_height]; if (indicatorHeight) node.dragIndicatorHeight = [indicatorHeight doubleValue]; - id indicatorRailHeight = props[@"drag_indicator_rail_height"]; + id indicatorRailHeight = pv[MOB_PROP_drag_indicator_rail_height]; if (indicatorRailHeight) node.dragIndicatorRailHeight = [indicatorRailHeight doubleValue]; - id onDismiss = props[@"on_dismiss"]; + id onDismiss = pv[MOB_PROP_on_dismiss]; if (onDismiss && [onDismiss isKindOfClass:[NSNumber class]]) { int handle = [onDismiss intValue]; node.onDismiss = ^{ @@ -1319,33 +1588,33 @@ static void mob_send_change_float(int handle, double value) { } // webview props - id webViewUrl = props[@"url"]; + id webViewUrl = pv[MOB_PROP_url]; if ([webViewUrl isKindOfClass:[NSString class]]) node.webViewUrl = webViewUrl; - id webViewAllow = props[@"allow"]; + id webViewAllow = pv[MOB_PROP_allow]; if ([webViewAllow isKindOfClass:[NSString class]]) node.webViewAllow = webViewAllow; - id webViewShowUrl = props[@"show_url"]; + id webViewShowUrl = pv[MOB_PROP_show_url]; if (webViewShowUrl) node.webViewShowUrl = [webViewShowUrl boolValue]; - id webViewTitle = props[@"title"]; + id webViewTitle = pv[MOB_PROP_title]; if ([webViewTitle isKindOfClass:[NSString class]]) node.webViewTitle = webViewTitle; // native_view props - id nativeViewModule = props[@"module"]; + id nativeViewModule = pv[MOB_PROP_module]; if ([nativeViewModule isKindOfClass:[NSString class]]) node.nativeViewModule = nativeViewModule; - id nativeViewId = props[@"id"]; + id nativeViewId = pv[MOB_PROP_id]; if ([nativeViewId isKindOfClass:[NSString class]]) node.nativeViewId = nativeViewId; - id nativeViewHandle = props[@"component_handle"]; + id nativeViewHandle = pv[MOB_PROP_component_handle]; if (nativeViewHandle) node.nativeViewHandle = [nativeViewHandle intValue]; if (node.nodeType == MobNodeTypeNativeView) node.nativeViewProps = props; - id onEndReached = props[@"on_end_reached"]; + id onEndReached = pv[MOB_PROP_on_end_reached]; if (onEndReached && [onEndReached isKindOfClass:[NSNumber class]]) { int handle = [onEndReached intValue]; node.onTap = ^{ @@ -1356,7 +1625,7 @@ static void mob_send_change_float(int handle, double value) { // For slider, value is the initial position (re-uses node.value property) // text_field initial text re-uses node.text property - id onChange = props[@"on_change"]; + id onChange = pv[MOB_PROP_on_change]; if (onChange && [onChange isKindOfClass:[NSNumber class]]) { int handle = [onChange intValue]; switch (node.nodeType) { @@ -1380,22 +1649,22 @@ static void mob_send_change_float(int handle, double value) { } } - id accessibilityId = props[@"accessibility_id"]; + id accessibilityId = pv[MOB_PROP_accessibility_id]; if ([accessibilityId isKindOfClass:[NSString class]]) { node.accessibilityId = accessibilityId; } - id accessibilityLabel = props[@"accessibility_label"]; + id accessibilityLabel = pv[MOB_PROP_accessibility_label]; if ([accessibilityLabel isKindOfClass:[NSString class]]) { node.accessibilityLabel = accessibilityLabel; } - id accessibilityRole = props[@"accessibility_role"]; + id accessibilityRole = pv[MOB_PROP_accessibility_role]; if ([accessibilityRole isKindOfClass:[NSString class]]) { node.accessibilityRole = accessibilityRole; } - id disabled = props[@"disabled"]; + id disabled = pv[MOB_PROP_disabled]; if ([disabled isKindOfClass:[NSNumber class]]) { node.disabled = [disabled boolValue]; } @@ -2225,6 +2494,13 @@ static ERL_NIF_TERM nif_set_root(ErlNifEnv *env, int argc, const ERL_NIF_TERM ar enif_compare(previous[slot].tag, build[slot].tag) == 0) build[slot].identity_start_generation = previous[slot].identity_start_generation; } + // Snapshot now, log after the mutex is released. NSLog writes synchronously + // to the system log, and holding tap_mutex across it would block concurrent + // mob_send_* on the main thread — reintroducing, once per frame, exactly the + // cost this whole change removed from the per-call path. + int exhausted_this_frame = tap_exhausted_count; + tap_exhausted_count = 0; + tap_active = 1 - tap_active; tap_handles = tap_tables[tap_active]; tap_handle_next = tap_build_count; @@ -2243,6 +2519,15 @@ static ERL_NIF_TERM nif_set_root(ErlNifEnv *env, int argc, const ERL_NIF_TERM ar if (strcmp(transition, "none") != 0) mob_bump_frame_generation(); + if (exhausted_this_frame > 0) { + // One line per frame rather than one per overflowing node. The count is + // the useful number anyway: it says how many interactive elements are + // silently inert, which the per-call line never made obvious. + LOGE(@"register_tap: pool exhausted (cap=%d) — %d interactive element(s) in this " + @"frame have no handler and will not respond", + MAX_TAP_HANDLES, exhausted_this_frame); + } + NSString *transitionStr = [NSString stringWithUTF8String:transition]; [[MobViewModel shared] setRoot:node transition:transitionStr]; @@ -2269,6 +2554,11 @@ static ERL_NIF_TERM nif_register_tap(ErlNifEnv *env, int argc, const ERL_NIF_TER enif_mutex_lock(tap_mutex); if (tap_build_count >= MAX_TAP_HANDLES) { + // Counted under the mutex, like the Zig side: set_root reads and resets + // this under the same lock. Mob.Sender serialises every caller today, so + // an unguarded read-modify-write would be benign — but nothing else in + // this file leans on that, and it should not start here. + tap_exhausted_count++; enif_mutex_unlock(tap_mutex); // MOB-100 follow-up: this used to be enif_make_badarg(env), which // crashed Mob.Renderer.render/3 (and the whole screen process) the @@ -2279,8 +2569,11 @@ static ERL_NIF_TERM nif_register_tap(ErlNifEnv *env, int argc, const ERL_NIF_TER // mob_send_tap et al. above), so -1 is a safe "no handler wired up" // sentinel here — the interactive prop silently does nothing // instead of taking the screen down. - LOGE(@"register_tap: pool exhausted (cap=%d) — returning unhandled sentinel", - MAX_TAP_HANDLES); + // Deliberately not logged here. This is reached once per interactive + // node beyond the cap — measured at 359 times per frame on a 200-row + // screen — and NSLog writes synchronously to the system log. That + // logging alone was 13ms of a 27ms frame, 47% of the whole frame. The + // count is reported once per frame from set_root instead. return enif_make_int(env, -1); } TapHandle *build = tap_tables[1 - tap_active]; @@ -2302,6 +2595,14 @@ static ERL_NIF_TERM nif_register_tap(ErlNifEnv *env, int argc, const ERL_NIF_TER build[slot].tag = enif_make_copy(build[slot].tag_env, tag_term); build[slot].identity_start_generation = tap_build_generation; tap_build_count++; + // The high-water mark has to be raised HERE, not in set_root. clear_taps + // frees exactly `used` slots, and a frame can register taps and then never + // reach set_root — Mob.Renderer.render/4 calls clear_taps, then prepare, + // then :json.encode, then set_root, and Mob.Sender.commit/1 rescues anything + // that raises in between. Recording the mark only at set_root left those + // slots' tag_envs uncleared and unreachable: one leaked ErlNifEnv per tap, + // per failed frame, forever, on a path deliberately designed to survive. + tap_table_used[1 - tap_active] = tap_build_count; enif_mutex_unlock(tap_mutex); return enif_make_int(env, handle); @@ -2317,7 +2618,8 @@ static ERL_NIF_TERM nif_clear_taps(ErlNifEnv *env, int argc, const ERL_NIF_TERM // table intact so concurrent mob_send_* keep resolving the last committed // frame. The freshly built table is swapped in at set_root. TapHandle *build = tap_tables[1 - tap_active]; - for (int i = 0; i < MAX_TAP_HANDLES; i++) { + int used = tap_table_used[1 - tap_active]; + for (int i = 0; i < used; i++) { if (build[i].tag_env) { enif_free_env(build[i].tag_env); build[i].tag_env = NULL; @@ -2333,6 +2635,12 @@ static ERL_NIF_TERM nif_clear_taps(ErlNifEnv *env, int argc, const ERL_NIF_TERM build[i].last_y = 0; build[i].seq = 0; } + tap_table_used[1 - tap_active] = 0; + // Reset here, not only in set_root. set_root reports and clears the count, + // but a frame that overflows and then never reaches set_root would otherwise + // carry its overflow into the next frame's report — which claims to describe + // "this frame". clear_taps is the one entry point every frame runs. + tap_exhausted_count = 0; tap_build_count = 0; enif_mutex_unlock(tap_mutex); return enif_make_atom(env, "ok"); diff --git a/lib/mob/render_stats.ex b/lib/mob/render_stats.ex new file mode 100644 index 0000000..4f7082d --- /dev/null +++ b/lib/mob/render_stats.ex @@ -0,0 +1,538 @@ +defmodule Mob.RenderStats do + @moduledoc """ + Per-frame timing for the render pipeline, readable from a connected node. + + Exists because every proposal in the rendering-performance epic (MOB-124) is a + guess without it. The pipeline has never been measured on a device: nobody + knows whether a dense screen spends its time in the user's `render/1`, in tree + expansion, in JSON encoding, or inside `set_root` — and the four candidate + fixes attack four different ones of those. + + ## A frame spans two processes + + This is the thing that makes the implementation less obvious than it looks. + `Mob.Screen.Server.paint/4` runs the user's `render/1`, the expansion passes + and the component reconcile in the **screen's** process — then hands the tree + to `Mob.Sender` as a *cast*, so `prepare`, `:json.encode` and `set_root` run + in the **sender's** process. A process-dictionary accumulator started by the + screen is simply not there when the renderer looks for it, and the first cut + of this module recorded nothing at all on device for exactly that reason. + + So the screen times its stages, `hand_off/1` sends the partial frame to the + sender, and the sender resumes it before committing. Frames the sender drops + — superseded by a newer tree, or belonging to a screen that is not active — + are recorded with `committed: false` rather than discarded, because BEAM-side + work that gets thrown away is worth knowing about. + + ## Cost when disabled + + `time/2` reads a `:persistent_term` and returns; `accumulate/2` reads the + process dictionary and returns. Neither allocates a record. + + The honest cost is dominated by `accumulate/2`, not by the six `time/2` sites: + it wraps every `register_tap` call, so it runs once per *registered handler* — + 615 times on the 200-row benchmark, not six. It also allocates a closure the + direct call did not. Measured on a development Mac, 29.2 ns per call before + and 35.8 ns after, so ~4 us per dense frame here and plausibly 20-40 us on a + phone. Against a 27 ms frame that is under 0.2%, but it is not free, and it + ships on every frame of every app. Note also that pdict lookup cost grows with + dictionary size (14.6 ns at ~10 entries, 30.3 ns at 200). + + ## Using it + + From a connected node (`mix mob.connect --no-iex`, then a script): + + :rpc.call(node, Mob.RenderStats, :enable, []) + # ... drive the app ... + :rpc.call(node, Mob.RenderStats, :summary, []) + + `summary/0` returns percentiles per stage. `frames/0` returns the raw records, + newest first, for when a percentile hides the thing you are looking for. + + ## What the stages mean + + * `render_us` — the user's `render/1` + * `expand_us` — `Mob.Composite`, `Mob.List` and `Mob.Component` expansion + * `reconcile_us` — `Mob.ComponentRegistry.reconcile/2` + * `prepare_us` — the renderer's tree walk: prop resolution, theme token + lookup, and one `register_tap` per handler prop + * `register_tap_us` — the `register_tap` calls alone. **Nested inside + `prepare_us`**, not a sibling of it; adding the two double-counts. + * `encode_us` — `:json.encode` plus `iodata_to_binary` + * `set_root_us` — the `set_root` NIF as seen from the BEAM, so it includes the + dirty-scheduler hop, which is the honest number from the caller's side + + Each percentile carries the `n` it was computed over, because the stages do + not share a population: `register_tap_us` exists only on frames that + registered a handler, so a run mixing dense and tap-free screens computes it + over a much smaller sample than `prepare_us`. Comparing their p50s without + looking at `n` compares two different sets of frames. + + `taps` is the number of `register_tap` calls, taken from the counter + `accumulate/2` maintains — not from a walk. `nodes` still needs a walk of the + prepared tree, which runs **after** every timed stage and after `total_us` is + stamped, so it cannot inflate any of them. `verify_taps/1` adds a second walk + that recounts handle-valued props into `taps_walked`, as a cross-check. + + ## What `total_us` is not + + It is stamped in the screen process before `render/1` and closed in the sender + after `set_root`, so it spans two `GenServer.cast`s and however long the frame + waited in the sender's mailbox — and it includes the meter's own cost. On a + physical device it has been observed exceeding an externally measured frame by + several milliseconds, which is only possible because it covers time outside + the frame. + + Use it within a single run, never against a frame budget and never to compare + configurations. For that, sum the stages, or measure from outside: drive one + render and block on `Mob.Sender.sync/1`. The per-stage numbers are honest + because each is timed in isolation. + """ + + @table __MODULE__ + @flag {__MODULE__, :enabled} + @verify {__MODULE__, :verify_taps} + @frame {__MODULE__, :frame} + @max_frames 500 + + @doc """ + Start recording. Idempotent. + + Starts a process to own the ETS table. Without one the table belongs to + whoever called `enable/0` first — over `:rpc.call/4` that is a transient + process, so the table dies the instant enabling returns and every later write + goes nowhere. + """ + @spec enable() :: :ok | {:error, term()} + def enable do + case GenServer.start(__MODULE__, [], name: __MODULE__) do + {:ok, _pid} -> + :persistent_term.put(@flag, true) + :ok + + {:error, {:already_started, _pid}} -> + :persistent_term.put(@flag, true) + :ok + + {:error, reason} -> + # Leave the flag off rather than recording into a table that does not + # exist: `store/1` would silently succeed and every frame would vanish. + {:error, reason} + end + end + + @doc """ + Stop recording. Frames already collected are kept. + + Also clears `verify_taps/1`, so a later `enable/0` starts with the cross-check + off. Both are switches this module owns, and leaving a diagnostic armed across + an enable/disable cycle is the more surprising of the two behaviours. + """ + @spec disable() :: :ok + def disable do + :persistent_term.put(@flag, false) + :persistent_term.put(@verify, false) + :ok + end + + @doc """ + Also walk each finished tree and record `taps_walked`, an independent count of + the handle-valued props in it. + + Off by default, and deliberately so: the walk costs about 120 ns per node — + 90% of the meter's whole overhead on a dense screen — to recompute a number + `register_tap_us_n` already has. Turn it on when the question is whether the + counting itself is right, not when the question is where the time goes. A + `taps_walked` that disagrees with `taps` means one of the two is buggy. + """ + @spec verify_taps(boolean()) :: :ok + def verify_taps(on?) when is_boolean(on?) do + :persistent_term.put(@verify, on?) + :ok + end + + @doc "Whether the tap cross-check walk is on." + @spec verify_taps?() :: boolean() + def verify_taps?, do: :persistent_term.get(@verify, false) + + @doc "Whether recording is on." + @spec enabled?() :: boolean() + def enabled?, do: :persistent_term.get(@flag, false) + + @doc "Discard every recorded frame." + @spec reset() :: :ok + def reset do + if :ets.whereis(@table) != :undefined, do: :ets.delete_all_objects(@table) + :ok + end + + @doc "Recorded frames, newest first." + @spec frames() :: [map()] + def frames do + if :ets.whereis(@table) == :undefined do + [] + else + read_frames() + end + end + + defp read_frames do + @table + |> :ets.tab2list() + |> Enum.sort_by(&elem(&1, 0), :desc) + |> Enum.map(&elem(&1, 1)) + end + + @doc """ + Percentiles per stage across the recorded frames. + + Reports p50, p95 and max rather than a mean: frame cost is not normally + distributed, and the tail is what a user experiences as stutter. + """ + @spec summary() :: map() + def summary do + case frames() do + [] -> + %{frames: 0} + + frames -> + # Durations only. `register_tap_us_n` is a count, and a count with a p50 + # sitting in a map of microseconds invites being read as one. Note also + # that `register_tap_us` is nested INSIDE `prepare_us` — the two must not + # be added together. + stages = [ + :render_us, + :expand_us, + :reconcile_us, + :prepare_us, + :register_tap_us, + :encode_us, + :set_root_us, + :total_us + ] + + # Percentiles come from committed frames only. A dropped frame never ran + # prepare/encode/set_root, and its total_us is screen-side work plus + # however long it waited in the sender — pooling the two makes a p50 + # that describes neither. The counts stay visible so `frames: 40` can + # never be read as 40 rendered frames when 31 of them were thrown away. + {committed, dropped} = Enum.split_with(frames, & &1.committed) + + %{ + frames: length(frames), + committed: length(committed), + dropped: length(dropped), + screens: frames |> Enum.map(& &1.screen) |> Enum.uniq(), + nodes: percentiles(committed, :nodes), + taps: percentiles(committed, :taps), + bytes: percentiles(committed, :bytes), + stages: Map.new(stages, &{&1, percentiles(committed, &1)}), + register_tap_calls: percentiles(committed, :register_tap_us_n), + taps_walked: percentiles(committed, :taps_walked), + dropped_total_us: percentiles(dropped, :total_us) + } + end + end + + # ── Recording ───────────────────────────────────────────────────────────── + + @doc """ + Begin a frame. Returns a token to thread through, or `nil` when disabled. + + The accumulator lives in the process dictionary because the whole pipeline — + the screen's `paint/4` and the renderer it calls — runs in one screen process, + and threading a struct through `Mob.Renderer`'s public API to carry timings + would put measurement scaffolding in a shipped signature. + """ + @spec start_frame(module(), term()) :: :ok + def start_frame(screen, transition) do + if enabled?() do + Process.put(@frame, %{screen: screen, transition: transition, started: now()}) + end + + :ok + end + + @doc "Record a stage's duration by timing `fun`. Runs `fun` either way." + @spec time(atom(), (-> result)) :: result when result: term() + def time(stage, fun) do + if enabled?() && Process.get(@frame) do + t0 = now() + result = fun.() + add(stage, now() - t0) + result + else + fun.() + end + end + + @doc """ + Take the frame in progress out of this process, for handing to another. + + Returns `nil` when disabled or when no frame is open. + """ + @spec take_frame() :: map() | nil + def take_frame, do: Process.delete(@frame) + + @doc """ + Hand the frame in progress to `Mob.Sender`, which finishes it. + + Sent as its own cast rather than threaded through `Mob.Sender.render/5,6`: + those are the shipped render entry points and one of them is already probed + with `function_exported?/3` for version skew, so widening them to carry + measurement scaffolding would be the wrong trade. Ordering holds because both + messages come from the same process to the same mailbox. + """ + @spec hand_off(term()) :: :ok + def hand_off(ref) do + case take_frame() do + nil -> :ok + frame -> GenServer.cast(Mob.Sender, {:render_stats, ref, frame}) + end + end + + @doc "Install a frame taken from another process." + @spec resume_frame(map() | nil) :: :ok + def resume_frame(nil) do + # Erase, not no-op. `finish/2` is the only thing that clears the key, and it + # is skipped whenever the render raises — a path `Mob.Sender.commit/1` + # exists specifically to rescue. A leftover frame would otherwise be resumed + # against a later, unrelated tree and recorded with a total_us that is + # mostly the gap between two frames. + Process.delete(@frame) + :ok + end + + def resume_frame(frame) do + Process.put(@frame, frame) + :ok + end + + @doc """ + Record a frame whose tree was never committed. + + A superseded or inactive tree still cost the BEAM everything up to the + hand-off, and a render pipeline that throws away half its work is a finding + rather than a detail. + """ + @spec drop_frame(map() | nil) :: :ok + def drop_frame(nil), do: :ok + + def drop_frame(frame) do + if enabled?(), do: do_drop_frame(frame), else: :ok + end + + defp do_drop_frame(frame) do + store( + frame + |> Map.drop([:started]) + |> Map.merge(%{ + total_us: now() - frame.started, + nodes: nil, + taps: nil, + bytes: nil, + committed: false + }) + ) + end + + @doc """ + Time `fun` and add it to a running total for this frame. + + For work that happens many times per frame — one `register_tap` per + interactive node — where the sum is what matters, not each call. + """ + @spec accumulate(atom(), (-> result)) :: result when result: term() + def accumulate(key, fun) do + case enabled?() && Process.get(@frame) do + frame when not is_map(frame) -> + fun.() + + frame -> + t0 = now() + result = fun.() + elapsed = now() - t0 + count_key = :"#{key}_n" + + Process.put( + @frame, + frame + |> Map.update(key, elapsed, &(&1 + elapsed)) + |> Map.update(count_key, 1, &(&1 + 1)) + ) + + result + end + end + + @doc "Add a measured value to the frame in progress." + @spec add(atom(), number()) :: :ok + def add(key, value) do + case Process.get(@frame) do + nil -> + :ok + + frame -> + Process.put(@frame, Map.put(frame, key, value)) + :ok + end + end + + @doc """ + Close the frame, counting the prepared tree and storing the record. + + `tree` is the prepared tree and `bytes` the encoded payload. The node and tap + walk happens here, after every timed stage, so it cannot inflate them. + """ + @spec finish(term(), non_neg_integer()) :: :ok + def finish(tree, bytes) do + case enabled?() && Process.get(@frame) do + frame when not is_map(frame) -> + # Still clear: recording may have been disabled mid-frame, and a frame + # left behind would be resumed against a later tree. + Process.delete(@frame) + :ok + + frame -> + Process.delete(@frame) + # Stamp the total BEFORE walking the tree, or the count inflates the + # number it is meant to describe. + total_us = now() - frame.started + nodes = count_nodes(tree, 0) + + # `register_tap_us_n` is an exact count of the NIF calls, incremented as + # they happen and costing nothing extra. Walking the finished tree to + # recount them was 90% of the meter's entire overhead — 120 ns per node, + # nearly all of it the per-node prop scan — to reproduce a number already + # in hand. The walk survives as an opt-in cross-check (`verify_taps`), + # because the two disagreeing is how a counting bug announces itself. + taps = Map.get(frame, :register_tap_us_n, 0) + + record = + frame + |> Map.drop([:started]) + |> Map.merge(%{ + total_us: total_us, + nodes: nodes, + taps: taps, + bytes: bytes, + committed: true + }) + + record = + if verify_taps?(), + do: Map.put(record, :taps_walked, count_handles(tree, 0)), + else: record + + store(record) + end + end + + # ── Internals ───────────────────────────────────────────────────────────── + + defp now, do: System.monotonic_time(:microsecond) + + defp store(record) do + if :ets.whereis(@table) == :undefined do + :ok + else + do_store(record) + end + end + + defp do_store(record) do + :ets.insert(@table, {System.unique_integer([:monotonic]), record}) + + # Ring rather than unbounded: this runs on a memory-constrained device and a + # long measurement session would otherwise grow without limit. + if :ets.info(@table, :size) > @max_frames do + case :ets.first(@table) do + :"$end_of_table" -> :ok + oldest -> :ets.delete(@table, oldest) + end + end + + :ok + end + + # The prepared tree is a map with string keys by this point. Every handler prop + # holds a handle the renderer got from one `register_tap` call, so counting + # handle-valued props counts NIF calls. Counting interactive *nodes* instead + # would undercount: one node carrying `on_tap` and `on_long_press` makes two + # calls. `taps` is therefore directly comparable to `register_tap_us_n`, and + # the two disagreeing means one of them has a bug. + defp count_nodes(node, acc) when is_map(node) do + children = Map.get(node, "children") || Map.get(node, :children) || [] + Enum.reduce(children, acc + 1, &count_nodes/2) + end + + defp count_nodes(_other, acc), do: acc + + defp count_handles(node, acc) when is_map(node) do + children = Map.get(node, "children") || Map.get(node, :children) || [] + Enum.reduce(children, acc + handle_count(node), &count_handles/2) + end + + defp count_handles(_other, acc), do: acc + + # Every prop `Mob.Renderer.register_handler/2` writes. Kept exhaustive on + # purpose: a missing name silently undercounts, which is how the first version + # of this reported 1 tap on a tree that made 12 calls. + @handle_props MapSet.new(~w(on_tap on_change on_focus on_blur on_submit on_dismiss on_select + on_scroll on_drag on_pinch on_rotate on_long_press on_double_tap + on_swipe on_swipe_left on_swipe_right on_swipe_up on_swipe_down + on_compose on_end_reached on_tab_select on_pointer_move + on_scroll_began on_scroll_ended on_scroll_settled on_top_reached + on_scrolled_past)) + + # Walk the node's own props rather than probing for all handler names: a node + # carries a handful of props, so this turns ~27 map lookups per node into ~4 + # set lookups. On a 780-node tree that is the difference between the meter + # costing more than the render and costing a fraction of it. + defp handle_count(node) do + props = Map.get(node, "props") || Map.get(node, :props) || %{} + + Enum.count(props, fn {key, value} -> + is_integer(value) and MapSet.member?(@handle_props, key) + end) + end + + defp percentiles(frames, key) do + values = frames |> Enum.map(&Map.get(&1, key)) |> Enum.reject(&is_nil/1) |> Enum.sort() + + case values do + [] -> + nil + + _ -> + # `n` travels with the numbers because stages do not share a sample. + # `register_tap_us` only exists on frames that registered a handler, so a + # run mixing dense and tap-free screens computes it over a different (and + # much smaller) population than `prepare_us` — which reads as + # "register_tap costs 50x prepare" unless the counts are visible. + %{n: length(values), p50: at(values, 0.5), p95: at(values, 0.95), max: List.last(values)} + end + end + + # Nearest-rank: the smallest value at or above the q-th fraction of the sample. + # `round/1` here would return one rank too high at every q — a p50 that is the + # 60th percentile at n=10, and a p95 that is literally the worst frame for any + # n under 21, which is exactly the range a short measurement run lands in. + defp at(sorted, q) do + n = length(sorted) + index = clamp(ceil(q * n) - 1, 0, n - 1) + Enum.at(sorted, index) + end + + defp clamp(value, low, high), do: value |> max(low) |> min(high) + + # ── Table owner ─────────────────────────────────────────────────────────── + + use GenServer + + @impl GenServer + def init(_opts) do + :ets.new(@table, [:named_table, :public, :ordered_set, write_concurrency: true]) + {:ok, %{}} + end +end diff --git a/lib/mob/renderer.ex b/lib/mob/renderer.ex index 1ae454f..af160ea 100644 --- a/lib/mob/renderer.ex +++ b/lib/mob/renderer.ex @@ -244,13 +244,15 @@ defmodule Mob.Renderer do nif.clear_taps() nif.set_transition(transition) + prepared = Mob.RenderStats.time(:prepare_us, fn -> prepare(tree, nif, platform, ctx) end) + json = - tree - |> prepare(nif, platform, ctx) - |> :json.encode() - |> IO.iodata_to_binary() + Mob.RenderStats.time(:encode_us, fn -> + prepared |> :json.encode() |> IO.iodata_to_binary() + end) - nif.set_root(json) + Mob.RenderStats.time(:set_root_us, fn -> nif.set_root(json) end) + Mob.RenderStats.finish(prepared, byte_size(json)) {:ok, :json_tree} end @@ -259,7 +261,17 @@ defmodule Mob.Renderer do # hard-wired screen process in one place instead of ~35. Mob.Listener.handler/1 # returns the target unchanged when no listener is running, which is what the # renderer's own tests rely on. See Mob.Listener. - defp register_handler(nif, target), do: nif.register_tap(Mob.Listener.handler(target)) + defp register_handler(nif, target) do + # Timed separately from the rest of prepare: prepare dominates the frame on + # a dense screen, and it does two very different jobs — pure-Elixir prop and + # theme resolution, and one register_tap NIF call per interactive node. + # Which of the two it is decides what the fix even looks like. + # Resolve outside the timed closure: Mob.Listener.handler/1 does a whereis + # and a tuple allocation, which is not the NIF and is a meaningful share of + # the sub-microsecond per-call baseline this number is compared against. + handler = Mob.Listener.handler(target) + Mob.RenderStats.accumulate(:register_tap_us, fn -> nif.register_tap(handler) end) + end @doc "Return the full color palette map (token → ARGB integer)." @spec colors() :: %{atom() => non_neg_integer()} diff --git a/lib/mob/screen/server.ex b/lib/mob/screen/server.ex index 3f024ad..15df55c 100644 --- a/lib/mob/screen/server.ex +++ b/lib/mob/screen/server.ex @@ -334,15 +334,25 @@ defmodule Mob.Screen.Server do platform = socket.__mob__.platform list_renderers = Map.get(socket.__mob__, :list_renderers, %{}) + Mob.RenderStats.start_frame(state.module, transition) + + raw = Mob.RenderStats.time(:render_us, fn -> state.module.render(socket.assigns) end) + {tree, active_component_keys} = - state.module.render(socket.assigns) - # Third expansion pass FIRST: pure-Elixir composites may themselves emit - # nodes / native_view components for the later passes. - |> Mob.Composite.expand(self()) - |> Mob.List.expand(list_renderers, self()) - |> Mob.Component.expand(self(), platform) - - Mob.ComponentRegistry.reconcile(self(), active_component_keys) + Mob.RenderStats.time(:expand_us, fn -> + raw + # Third expansion pass FIRST: pure-Elixir composites may themselves emit + # nodes / native_view components for the later passes. + |> Mob.Composite.expand(self()) + |> Mob.List.expand(list_renderers, self()) + |> Mob.Component.expand(self(), platform) + end) + + Mob.RenderStats.time(:reconcile_us, fn -> + Mob.ComponentRegistry.reconcile(self(), active_component_keys) + end) + + Mob.RenderStats.hand_off(state.ref) if activation_token && function_exported?(Mob.Sender, :render, 6) do Mob.Sender.render(state.ref, tree, platform, state.nif, transition, activation_token) diff --git a/lib/mob/sender.ex b/lib/mob/sender.ex index 6f2319a..33d9191 100644 --- a/lib/mob/sender.ex +++ b/lib/mob/sender.ex @@ -74,7 +74,11 @@ defmodule Mob.Sender do """ @type screen_ref :: reference() | atom() - defstruct active: nil, pending: %{}, reserved_transition: nil, activation_gate: nil + defstruct active: nil, + pending: %{}, + reserved_transition: nil, + activation_gate: nil, + frames: %{} @doc "Start the sender. Named, so there is exactly one." @spec start_link(keyword()) :: GenServer.on_start() @@ -183,6 +187,26 @@ defmodule Mob.Sender do {:ok, %__MODULE__{active: Keyword.get(opts, :active)}} end + # Drop a queued tree AND record its frame. Both activation paths throw a + # pending tree away, and the frame paired with it measured real BEAM work that + # produced no pixels — which is exactly what `committed: false` is for. Losing + # it here would make the meter undercount dropped frames at navigation + # boundaries, the transitions MOB-124 is most interested in. + defp discard_pending(pending, ref) do + case Map.pop(pending, ref) do + {{_tree, _platform, _nif, _transition, frame}, rest} -> + Mob.RenderStats.drop_frame(frame) + rest + + # Anything else is a pending entry written by a previous version of this + # module. mob's dev loop reloads code onto a running BEAM, so the sender + # can meet state it did not write; crashing here would take down the one + # process every screen renders through. + {_other, rest} -> + rest + end + end + @impl GenServer def handle_call({:activate, ref, transition}, _from, state) do reserved_transition = if transition == :none, do: nil, else: {ref, transition} @@ -190,7 +214,7 @@ defmodule Mob.Sender do # An inactive screen may have queued a repaint just before activation. # That tree predates the navigation boundary and must not become the first # frame of the newly active screen; the router requests a fresh paint next. - pending = Map.delete(state.pending, ref) + pending = discard_pending(state.pending, ref) {:reply, :ok, %{state | active: ref, pending: pending, reserved_transition: reserved_transition}} @@ -202,7 +226,7 @@ defmodule Mob.Sender do state = state |> Map.put(:active, ref) - |> Map.put(:pending, Map.delete(state.pending, ref)) + |> Map.put(:pending, discard_pending(state.pending, ref)) |> Map.put(:reserved_transition, nil) |> Map.put(:activation_gate, {ref, token, transition}) @@ -222,6 +246,16 @@ defmodule Mob.Sender do {:noreply, %{state | active: ref, reserved_transition: nil}} end + # Staged, not paired: the screen process casts its stats immediately before the + # render they describe, so this frame belongs to the very next `:render` for + # `ref`. Pairing happens there, not here, so that a frame and the tree it + # measured travel together through coalescing and flush. + def handle_cast({:render_stats, ref, frame}, state) do + # A frame already staged for this ref described a render that never arrived. + Mob.RenderStats.drop_frame(Map.get(state.frames, ref)) + {:noreply, %{state | frames: Map.put(state.frames, ref, frame)}} + end + def handle_cast({:render, ref, tree, platform, nif, transition}, state) do handle_cast({:render, ref, tree, platform, nif, transition, nil}, state) end @@ -230,16 +264,20 @@ defmodule Mob.Sender do {:render, ref, tree, platform, nif, transition, activation_token}, %{activation_gate: {ref, expected_token, reserved}} = state ) do + {frame, frames} = Map.pop(state.frames, ref) + if activation_token == expected_token do transition = if transition == :none, do: reserved, else: transition - pending = Map.put(state.pending, ref, {tree, platform, nif, transition}) + pending = put_pending(state.pending, ref, {tree, platform, nif, transition, frame}) send(self(), :flush) - {:noreply, %{state | pending: pending, activation_gate: nil}} + {:noreply, %{state | pending: pending, activation_gate: nil, frames: frames}} else # This render began before the router activated the screen. The router's # tokened paint follows it from the same screen process, so dropping it # prevents a stale target frame from consuming the navigation boundary. - {:noreply, state} + # Its frame goes with it, or it would be resumed against a later tree. + Mob.RenderStats.drop_frame(frame) + {:noreply, %{state | frames: frames}} end end @@ -252,9 +290,25 @@ defmodule Mob.Sender do {transition, reserved_transition} = take_transition(state.pending, state.reserved_transition, ref, transition) - pending = Map.put(state.pending, ref, {tree, platform, nif, transition}) + {frame, frames} = Map.pop(state.frames, ref) + pending = put_pending(state.pending, ref, {tree, platform, nif, transition, frame}) send(self(), :flush) - {:noreply, %{state | pending: pending, reserved_transition: reserved_transition}} + + {:noreply, + %{state | pending: pending, reserved_transition: reserved_transition, frames: frames}} + end + + # A superseded tree's frame is real work that was paid for but never shown. + defp put_pending(pending, ref, payload) do + case Map.fetch(pending, ref) do + {:ok, {_tree, _platform, _nif, _transition, superseded}} -> + Mob.RenderStats.drop_frame(superseded) + + :error -> + :ok + end + + Map.put(pending, ref, payload) end defp take_transition(pending, {ref, reserved}, ref, :none), @@ -268,7 +322,7 @@ defmodule Mob.Sender do defp carry_transition(pending, ref, :none) do case Map.fetch(pending, ref) do - {:ok, {_tree, _platform, _nif, superseded}} -> superseded + {:ok, {_tree, _platform, _nif, superseded, _frame}} -> superseded :error -> :none end end @@ -281,15 +335,55 @@ defmodule Mob.Sender do def handle_info(_message, state), do: {:noreply, state} defp flush(state) do - case Map.fetch(state.pending, state.active) do - {:ok, payload} -> commit(payload) - :error -> :ok + {committed, rest} = Map.pop(state.pending, state.active) + + case committed do + {tree, platform, nif, transition, frame} -> + Mob.RenderStats.resume_frame(frame) + commit({tree, platform, nif, transition}) + + nil -> + :ok end # Everything else waiting belongs to a screen that is not active. Dropping # it is deliberate: by the time such a screen becomes active it will have # re-rendered, so committing a queued tree would only show a stale frame. - %{state | pending: %{}} + # Their BEAM-side cost was still paid, so record it rather than losing it. + Enum.each(rest, fn + {_ref, {_t, _p, _n, _tr, frame}} -> Mob.RenderStats.drop_frame(frame) + {_ref, _pre_reload_shape} -> :ok + end) + + # Staged frames survive a flush. The render cast that pairs a staged frame + # with its tree may still be in the mailbox behind the `:flush` message, so + # clearing here would throw away a frame whose render is about to arrive. + # + # They are not self-limiting, though. A screen process killed between + # `hand_off/1` and `Mob.Sender.render/5` — a narrow window, but a real one — + # leaves an entry no later cast will ever claim, and nothing else removes it. + # Sweeping by age bounds the map and records the work rather than losing it. + %{state | pending: %{}, frames: sweep_stale(state.frames)} + end + + # A staged frame is claimed by the render cast that follows it from the same + # process, so anything still waiting after this long belongs to a screen that + # is never going to send one. + @stale_frame_us 5_000_000 + + defp sweep_stale(frames) when map_size(frames) == 0, do: frames + + defp sweep_stale(frames) do + cutoff = System.monotonic_time(:microsecond) - @stale_frame_us + + Enum.reduce(frames, frames, fn {ref, frame}, acc -> + if is_map(frame) and Map.get(frame, :started, cutoff) < cutoff do + Mob.RenderStats.drop_frame(frame) + Map.delete(acc, ref) + else + acc + end + end) end defp commit({tree, platform, nif, transition}) do diff --git a/mix.exs b/mix.exs index 47eced3..e7b3fab 100644 --- a/mix.exs +++ b/mix.exs @@ -199,7 +199,7 @@ defmodule Mob.MixProject do Mob.Audio, Mob.Motion ], - "Testing & Debugging": [Mob.Test, Mob.ScreenCase], + "Testing & Debugging": [Mob.Test, Mob.ScreenCase, Mob.RenderStats], Tooling: [Mob.Formatter], Internals: [Mob.Dist, Mob.NativeLogger, Mob.List, Mob.Sigil] ] diff --git a/test/mob/native_box_accessibility_test.exs b/test/mob/native_box_accessibility_test.exs index 394d37b..71af7c2 100644 --- a/test/mob/native_box_accessibility_test.exs +++ b/test/mob/native_box_accessibility_test.exs @@ -12,11 +12,23 @@ defmodule Mob.NativeBoxAccessibilityTest do assert header =~ "NSString *accessibilityLabel" assert header =~ "NSString *accessibilityRole" assert header =~ "BOOL disabled" - assert nif =~ ~s|props[@"accessibility_label"]| + # The deserialiser resolves prop keys to slots in one pass rather than + # probing each key, so the contract is "accessibility_label" being in the slot table + # AND the node builder reading that slot. + assert nif =~ ~s|@"accessibility_label"| + assert nif =~ "pv[MOB_PROP_accessibility_label]" assert nif =~ "node.accessibilityLabel = accessibilityLabel" - assert nif =~ ~s|props[@"accessibility_role"]| + # The deserialiser resolves prop keys to slots in one pass rather than + # probing each key, so the contract is "accessibility_role" being in the slot table + # AND the node builder reading that slot. + assert nif =~ ~s|@"accessibility_role"| + assert nif =~ "pv[MOB_PROP_accessibility_role]" assert nif =~ "node.accessibilityRole = accessibilityRole" - assert nif =~ ~s|props[@"disabled"]| + # The deserialiser resolves prop keys to slots in one pass rather than + # probing each key, so the contract is "disabled" being in the slot table + # AND the node builder reading that slot. + assert nif =~ ~s|@"disabled"| + assert nif =~ "pv[MOB_PROP_disabled]" assert nif =~ "node.disabled = [disabled boolValue]" end diff --git a/test/mob/native_layout_weight_test.exs b/test/mob/native_layout_weight_test.exs index 3b9297a..6ef5b1c 100644 --- a/test/mob/native_layout_weight_test.exs +++ b/test/mob/native_layout_weight_test.exs @@ -12,7 +12,11 @@ defmodule Mob.NativeLayoutWeightTest do assert header =~ "CGFloat layoutWeight" assert implementation =~ "_layoutWeight = 0.0" - assert nif =~ ~s|props[@"weight"]| + # The deserialiser resolves prop keys to slots in one pass rather than + # probing each key, so the contract is "weight" being in the slot table + # AND the node builder reading that slot. + assert nif =~ ~s|@"weight"| + assert nif =~ "pv[MOB_PROP_weight]" assert nif =~ "node.layoutWeight = [layoutWeight doubleValue]" end diff --git a/test/mob/render_stats_test.exs b/test/mob/render_stats_test.exs new file mode 100644 index 0000000..2bbb885 --- /dev/null +++ b/test/mob/render_stats_test.exs @@ -0,0 +1,549 @@ +defmodule Mob.RenderStatsTest do + @moduledoc """ + The measurement infrastructure for MOB-124. + + Every proposal in that epic is gated on these numbers, so a subtly wrong + meter would send the whole thing in the wrong direction. Tested for the two + properties that matter: it costs nothing when off, and it counts correctly + when on. + """ + use ExUnit.Case, async: false + + alias Mob.RenderStats + + setup do + RenderStats.disable() + RenderStats.reset() + on_exit(fn -> RenderStats.disable() end) + :ok + end + + defp frame(overrides \\ %{}) do + RenderStats.start_frame(Some.Screen, :none) + RenderStats.time(:render_us, fn -> :ok end) + Enum.each(overrides, fn {k, v} -> RenderStats.add(k, v) end) + RenderStats.finish(%{"type" => "text", "props" => %{}, "children" => []}, 42) + end + + describe "when disabled" do + test "records nothing" do + frame() + assert RenderStats.frames() == [] + assert RenderStats.summary() == %{frames: 0} + end + + test "time/2 still runs the function and returns its value" do + # The whole pipeline is wrapped in time/2. If it short-circuited when off, + # disabling the meter would disable rendering. + assert RenderStats.time(:render_us, fn -> :computed end) == :computed + end + + test "leaves nothing in the process dictionary" do + frame() + refute Enum.any?(Process.get(), &match?({{Mob.RenderStats, _}, _}, &1)) + end + end + + describe "when enabled" do + setup do + RenderStats.enable() + :ok + end + + test "records one frame per finish" do + frame() + frame() + assert length(RenderStats.frames()) == 2 + end + + test "carries the screen and transition" do + RenderStats.start_frame(My.Screen, :push) + RenderStats.finish(%{}, 0) + + assert [%{screen: My.Screen, transition: :push}] = RenderStats.frames() + end + + test "times a stage and stores it" do + RenderStats.start_frame(Some.Screen, :none) + RenderStats.time(:encode_us, fn -> Process.sleep(5) end) + RenderStats.finish(%{}, 0) + + assert [%{encode_us: encode}] = RenderStats.frames() + assert encode >= 4_000, "expected at least ~5ms, got #{encode}us" + end + + test "total spans the whole frame, not one stage" do + RenderStats.start_frame(Some.Screen, :none) + RenderStats.time(:render_us, fn -> Process.sleep(3) end) + RenderStats.time(:encode_us, fn -> Process.sleep(3) end) + RenderStats.finish(%{}, 0) + + assert [%{total_us: total, render_us: render}] = RenderStats.frames() + assert total > render + end + + test "frames come back newest first" do + RenderStats.start_frame(First, :none) + RenderStats.finish(%{}, 0) + RenderStats.start_frame(Second, :none) + RenderStats.finish(%{}, 0) + + assert [%{screen: Second}, %{screen: First}] = RenderStats.frames() + end + + test "reset/0 discards everything" do + frame() + RenderStats.reset() + assert RenderStats.frames() == [] + end + + test "a frame without start_frame is ignored rather than crashing" do + # finish/2 runs on every render; if the meter was enabled mid-frame there + # is no accumulator, and that must not take the screen down. + assert RenderStats.finish(%{}, 0) == :ok + assert RenderStats.frames() == [] + end + end + + describe "counting the prepared tree" do + setup do + RenderStats.verify_taps(true) + on_exit(fn -> RenderStats.verify_taps(false) end) + :ok + end + + setup do + RenderStats.enable() + :ok + end + + defp tree(children), do: %{"type" => "column", "props" => %{}, "children" => children} + defp leaf(props \\ %{}), do: %{"type" => "text", "props" => props, "children" => []} + + test "counts every node including the root" do + RenderStats.start_frame(S, :none) + RenderStats.finish(tree([leaf(), leaf(), tree([leaf()])]), 0) + + assert [%{nodes: 5}] = RenderStats.frames() + end + + test "counts interactive nodes by their resolved handle" do + # The renderer has already replaced each handler with an integer handle by + # the time the tree is counted, which is exactly what register_tap emitted. + RenderStats.start_frame(S, :none) + RenderStats.finish(tree([leaf(%{"on_tap" => 0}), leaf(), leaf(%{"on_change" => 3})]), 0) + + assert [%{taps_walked: 2}] = RenderStats.frames() + end + + test "an unresolved handler is not counted as a tap" do + # -1 is the pool-exhausted sentinel, and a raw pid means prepare never ran. + RenderStats.start_frame(S, :none) + RenderStats.finish(tree([leaf(%{"on_tap" => -1}), leaf(%{"on_tap" => self()})]), 0) + + assert [%{taps_walked: 1}] = RenderStats.frames(), "only the integer handle counts" + end + + test "records the payload size it was given" do + RenderStats.start_frame(S, :none) + RenderStats.finish(tree([]), 4096) + + assert [%{bytes: 4096}] = RenderStats.frames() + end + end + + describe "summary/0" do + setup do + RenderStats.enable() + :ok + end + + test "reports percentiles rather than a mean" do + # Frame cost is not normally distributed and the tail is what a user feels + # as stutter, so the summary has to surface it. + for us <- [1, 1, 1, 1, 1, 1, 1, 1, 1, 500] do + RenderStats.start_frame(S, :none) + RenderStats.add(:render_us, us) + RenderStats.finish(%{}, 0) + end + + summary = RenderStats.summary() + assert summary.frames == 10 + assert summary.stages.render_us.p50 == 1 + assert summary.stages.render_us.max == 500 + end + + test "p50 is the median and p95 is not just the maximum" do + # Ranks, on distinct values, so an off-by-one cannot hide. The previous + # version of this file used nine identical values and could not fail. + # `round/1` instead of `ceil/1` gives p50 == 11 and p95 == 20 here: every + # reported median one rank high, and every p95 equal to the single worst + # frame for any run under ~21 frames. + for us <- 1..20 do + RenderStats.start_frame(S, :none) + RenderStats.add(:render_us, us) + RenderStats.finish(%{}, 0) + end + + %{p50: p50, p95: p95, max: max} = RenderStats.summary().stages.render_us + + assert p50 == 10 + assert p95 == 19 + assert max == 20 + end + + test "percentiles exclude frames that were never committed" do + # A dropped frame never ran prepare/encode/set_root and its total_us is + # mostly queueing. Pooling it with real frames makes a p50 that describes + # neither, and `bytes: 0` for a drop would drag the byte percentiles to + # zero while still reading as a measurement. + RenderStats.start_frame(S, :none) + RenderStats.add(:render_us, 100) + RenderStats.finish(%{}, 5000) + + for _ <- 1..9 do + RenderStats.start_frame(S, :none) + RenderStats.add(:render_us, 1) + RenderStats.drop_frame(RenderStats.take_frame()) + end + + summary = RenderStats.summary() + + assert summary.frames == 10 + assert summary.committed == 1 + assert summary.dropped == 9 + assert summary.bytes == %{n: 1, p50: 5000, p95: 5000, max: 5000} + assert summary.stages.render_us.p50 == 100 + assert %{p50: _, p95: _, max: _} = summary.dropped_total_us + end + + test "lists the screens measured" do + RenderStats.start_frame(A, :none) + RenderStats.finish(%{}, 0) + RenderStats.start_frame(B, :none) + RenderStats.finish(%{}, 0) + + assert Enum.sort(RenderStats.summary().screens) == [A, B] + end + + test "a stage never recorded is nil rather than zero" do + # Zero would read as "this stage is free", which is a different claim. + RenderStats.start_frame(S, :none) + RenderStats.add(:render_us, 10) + RenderStats.finish(%{}, 0) + + assert RenderStats.summary().stages.set_root_us == nil + end + end + + describe "a frame that crosses the screen/sender boundary" do + # The bug this exists to prevent: paint/4 opens the frame in the SCREEN + # process, then hands the tree to Mob.Sender as a cast, so prepare, encode + # and set_root run in the SENDER process. A process-dictionary accumulator + # does not travel, and the first version of this module recorded nothing at + # all on device because of it. + defmodule StubNif do + def clear_taps, do: :ok + def set_transition(_), do: :ok + def register_tap(_), do: 0 + def set_root(_json), do: :ok + end + + setup do + for name <- [Mob.Sender], pid = Process.whereis(name), do: GenServer.stop(pid) + {:ok, sender} = Mob.Sender.start_link(active: :the_screen) + on_exit(fn -> if Process.alive?(sender), do: GenServer.stop(sender) end) + + RenderStats.enable() + RenderStats.reset() + :ok + end + + defp paint_from_another_process(ref) do + # Stands in for Mob.Screen.Server.paint/4: time the screen-side stages, + # hand the frame over, then cast the render — from a process that is not + # the sender. + task = + Task.async(fn -> + RenderStats.start_frame(Some.Screen, :none) + RenderStats.time(:render_us, fn -> :ok end) + RenderStats.hand_off(ref) + Mob.Sender.render(ref, %{type: :text, props: %{}, children: []}, :ios, StubNif, :none) + end) + + Task.await(task) + Mob.Sender.sync() + end + + test "the frame survives the hand-off and records every stage" do + paint_from_another_process(:the_screen) + + assert [frame] = RenderStats.frames() + assert frame.screen == Some.Screen + assert frame.committed == true + + for stage <- [:render_us, :prepare_us, :encode_us, :set_root_us] do + assert is_integer(Map.get(frame, stage)), + "#{stage} missing — the frame did not survive the process hop" + end + end + + test "records the payload the sender actually encoded" do + paint_from_another_process(:the_screen) + assert [%{bytes: bytes, nodes: 1}] = RenderStats.frames() + assert bytes > 0 + end + + test "a tree the sender drops is recorded as uncommitted, not lost" do + # Its BEAM-side cost was paid either way; a pipeline throwing away half its + # work is a finding rather than a detail. + paint_from_another_process(:some_other_screen) + + assert [%{committed: false, screen: Some.Screen}] = RenderStats.frames() + end + end + + describe "through the real paint path" do + # The test above proves the hand-off mechanism works. This one proves + # Mob.Screen.Server.paint/4 actually uses it — removing the hand_off call + # from paint/4 passes the mechanism test and fails this one, which is the + # difference between testing a function and testing the system. + defmodule RealNif do + def platform, do: :android + def safe_area, do: {0.0, 0.0, 0.0, 0.0} + def take_launch_notification, do: :none + def clear_taps, do: :ok + def set_transition(_), do: :ok + def register_tap(_), do: 0 + def set_root(_json), do: :ok + end + + defmodule CounterScreen do + use Mob.Screen + def mount(_p, _s, socket), do: {:ok, Mob.Socket.assign(socket, :n, 0)} + + def render(assigns) do + %{type: :text, props: %{text: "n=#{assigns.n}"}, children: []} + end + + def handle_event("bump", _, socket), + do: {:noreply, Mob.Socket.assign(socket, :n, socket.assigns.n + 1)} + end + + defmodule DemoApp do + @behaviour Mob.App + import Mob.App + def navigation(_), do: stack(:home, root: Mob.RenderStatsTest.CounterScreen) + end + + setup do + services = [Mob.Sender, Mob.Listener, Mob.ComponentRegistry, Mob.Nav.Registry] + for name <- services, pid = Process.whereis(name), do: safe_stop(pid) + + {:ok, _} = Mob.ComponentRegistry.start_link() + {:ok, _} = Mob.Nav.Registry.start_link(DemoApp) + + RenderStats.enable() + RenderStats.reset() + + {:ok, router} = Mob.Router.start_root(CounterScreen, %{}, nif: RealNif) + + on_exit(fn -> + safe_stop(router) + for name <- services, pid = Process.whereis(name), do: safe_stop(pid) + end) + + %{router: router} + end + + defp safe_stop(pid) do + GenServer.stop(pid) + catch + :exit, _ -> :ok + end + + test "a real render records a complete frame", %{router: router} do + RenderStats.reset() + Mob.Screen.dispatch(router, "bump", %{}) + Mob.Sender.sync() + + assert [frame | _] = RenderStats.frames() + assert frame.screen == CounterScreen + assert frame.committed == true + assert frame.nodes == 1 + + for stage <- [:render_us, :expand_us, :reconcile_us, :prepare_us, :encode_us, :set_root_us] do + assert is_integer(Map.get(frame, stage)), + "#{stage} was not recorded through the real paint path" + end + end + end + + describe "bounded storage" do + test "keeps the most recent frames and drops the oldest" do + # A long measurement session on a memory-constrained device must not grow + # without limit. + RenderStats.enable() + + for i <- 1..520 do + RenderStats.start_frame(:"s#{i}", :none) + RenderStats.finish(%{}, 0) + end + + frames = RenderStats.frames() + assert length(frames) <= 500 + assert hd(frames).screen == :s520, "newest must survive" + end + end + + describe "tap counting" do + setup do + RenderStats.verify_taps(true) + on_exit(fn -> RenderStats.verify_taps(false) end) + :ok + end + + setup do + RenderStats.enable() + :ok + end + + defp node_with(props), do: %{"type" => "row", "props" => props, "children" => []} + + test "counts register_tap calls, not interactive nodes" do + # One node carrying three handlers makes three NIF calls. Counting nodes + # reported 1 here, which made `taps` disagree with `register_tap_us_n` + # and put the per-call cost derived from it out by the same factor. + RenderStats.start_frame(S, :none) + + RenderStats.finish( + node_with(%{"on_tap" => 1, "on_long_press" => 2, "on_double_tap" => 3}), + 0 + ) + + assert [%{taps_walked: 3}] = RenderStats.frames() + end + + test "counts the scroll and swipe handlers the renderer registers" do + # These nine were missing from the original prop set, so any scrolling + # screen — the exact case MOB-128 is about — undercounted silently. + props = + Map.new( + ~w(on_swipe_left on_swipe_right on_swipe_up on_swipe_down on_scroll_began + on_scroll_ended on_scroll_settled on_top_reached on_scrolled_past), + &{&1, 7} + ) + + RenderStats.start_frame(S, :none) + RenderStats.finish(node_with(props), 0) + + assert [%{taps_walked: 9}] = RenderStats.frames() + end + + test "agrees with the count accumulate/2 observes" do + # The two are independent: one walks the finished tree, the other counts + # calls as they happen. They are in the same summary, so a disagreement + # means one is wrong and nobody can tell which. + RenderStats.start_frame(S, :none) + for _ <- 1..3, do: RenderStats.accumulate(:register_tap_us, fn -> :ok end) + RenderStats.finish(node_with(%{"on_tap" => 1, "on_change" => 2, "on_blur" => 3}), 0) + + assert [%{taps: 3, taps_walked: 3, register_tap_us_n: 3}] = RenderStats.frames() + end + end + + describe "taps come from the call counter, not a tree walk" do + setup do + RenderStats.enable() + :ok + end + + test "taps is recorded without walking the tree for handles" do + # The walk was 90% of the meter's overhead, recomputing a number + # accumulate/2 already had. With the cross-check off, a tree full of + # handle-valued props contributes nothing to `taps` — only real calls do. + RenderStats.start_frame(S, :none) + for _ <- 1..4, do: RenderStats.accumulate(:register_tap_us, fn -> :ok end) + + RenderStats.finish( + %{"type" => "row", "props" => %{"on_tap" => 1, "on_blur" => 2}, "children" => []}, + 0 + ) + + assert [frame] = RenderStats.frames() + assert frame.taps == 4 + refute Map.has_key?(frame, :taps_walked) + end + + test "a frame that registered nothing reports zero rather than crashing" do + # accumulate/2 never ran, so :register_tap_us_n is absent from the frame. + RenderStats.start_frame(S, :none) + RenderStats.finish(%{"type" => "text", "props" => %{}, "children" => []}, 0) + + assert [%{taps: 0}] = RenderStats.frames() + end + + test "percentiles carry the sample size they were computed over" do + # Stages do not share a population: register_tap_us only exists on frames + # that registered a handler. Without n, a p50 over one frame and a p50 over + # forty read identically. + for i <- 1..4 do + RenderStats.start_frame(S, :none) + RenderStats.add(:render_us, i) + if i == 1, do: RenderStats.accumulate(:register_tap_us, fn -> :ok end) + RenderStats.finish(%{}, 0) + end + + summary = RenderStats.summary() + assert summary.stages.render_us.n == 4 + assert summary.stages.register_tap_us.n == 1 + end + end + + describe "recording stops when disabled mid-frame" do + test "finish/2 records nothing and leaves no frame behind" do + # Only time/2 checked the flag, so an operator who disabled the meter + # kept getting records for any frame already in flight. + RenderStats.enable() + RenderStats.start_frame(S, :none) + RenderStats.disable() + RenderStats.finish(%{}, 777) + + RenderStats.enable() + assert RenderStats.frames() == [] + assert RenderStats.take_frame() == nil + end + + test "accumulate/2 still runs the function" do + RenderStats.enable() + RenderStats.start_frame(S, :none) + RenderStats.disable() + assert RenderStats.accumulate(:register_tap_us, fn -> :computed end) == :computed + end + end + + describe "resume_frame/1" do + setup do + RenderStats.enable() + :ok + end + + test "clears a leftover frame rather than no-opping" do + # A render that raises skips finish/2 and leaves its frame in the process + # dictionary. Treating resume_frame(nil) as a no-op let that frame be + # closed against the next tree, producing one record spanning two frames + # whose total_us is mostly the gap between them. + # No start_frame in between: that is the shape of the real path. The + # sender resumes whatever frame the screen handed it — nil, when the + # previous render raised before hand_off — and then commits, and the + # commit's finish/2 is what closes the frame in the pdict. + RenderStats.start_frame(StaleScreen, :push) + RenderStats.add(:render_us, 999) + + RenderStats.resume_frame(nil) + RenderStats.finish(%{"type" => "text", "props" => %{}, "children" => []}, 1234) + + assert RenderStats.frames() == [] + end + end +end diff --git a/test/mob/sender_test.exs b/test/mob/sender_test.exs index 9d82514..aba0650 100644 --- a/test/mob/sender_test.exs +++ b/test/mob/sender_test.exs @@ -143,6 +143,151 @@ defmodule Mob.SenderTest do end end + describe "render stats travel with the tree they measured" do + # The screen process casts its frame separately from the render it + # describes. Binding the two when the render cast is dequeued — rather than + # looking the frame up again at flush time — is what keeps a frame from + # being attributed to a tree it did not measure. + setup do + Mob.RenderStats.enable() + Mob.RenderStats.reset() + on_exit(fn -> Mob.RenderStats.disable() end) + :ok + end + + defp labelled_frame(screen) do + Mob.RenderStats.start_frame(screen, :none) + Mob.RenderStats.take_frame() + end + + defp recorded do + for f <- Mob.RenderStats.frames(), do: {f.screen, f.committed} + end + + test "a superseded tree's frame is dropped, not committed against the newer tree" do + state = %Sender{active: :home} + + {:noreply, state} = Sender.handle_cast({:render_stats, :home, labelled_frame(A)}, state) + + {:noreply, state} = + Sender.handle_cast({:render, :home, tree("first"), :ios, RecordingNif, :none}, state) + + {:noreply, state} = Sender.handle_cast({:render_stats, :home, labelled_frame(B)}, state) + + {:noreply, state} = + Sender.handle_cast({:render, :home, tree("second"), :ios, RecordingNif, :none}, state) + + {:noreply, _state} = Sender.handle_info(:flush, state) + + assert [json] = committed_texts() + assert json =~ "second" + assert Enum.sort(recorded()) == [{A, false}, {B, true}] + end + + test "a flush between a frame and its render does not pair it with the older tree" do + # Mailbox: stats(A), render(treeA), stats(B), flush, render(treeB). The + # flush is what `Mob.Sender.sync/1` triggers, and it is called from the + # router — a different process — so it can land anywhere. Resolving the + # frame at flush time committed treeA while holding frame B. + state = %Sender{active: :home} + + {:noreply, state} = Sender.handle_cast({:render_stats, :home, labelled_frame(A)}, state) + + {:noreply, state} = + Sender.handle_cast({:render, :home, tree("treeA"), :ios, RecordingNif, :none}, state) + + {:noreply, state} = Sender.handle_cast({:render_stats, :home, labelled_frame(B)}, state) + {:noreply, state} = Sender.handle_info(:flush, state) + + assert [first] = committed_texts() + assert first =~ "treeA" + assert recorded() == [{A, true}] + + {:noreply, state} = + Sender.handle_cast({:render, :home, tree("treeB"), :ios, RecordingNif, :none}, state) + + {:noreply, _state} = Sender.handle_info(:flush, state) + + assert [_, second] = committed_texts() + assert second =~ "treeB" + assert Enum.sort(recorded()) == [{A, true}, {B, true}] + end + + test "activating a screen records the queued frame it throws away" do + # Both activation paths delete a pending tree: it predates the navigation + # boundary and must not become the new screen's first frame. The BEAM work + # that built it was still paid for, and navigation boundaries are exactly + # the transitions this epic is measuring, so it has to be recorded. + state = %Sender{active: :other} + + {:noreply, state} = Sender.handle_cast({:render_stats, :home, labelled_frame(A)}, state) + + {:noreply, state} = + Sender.handle_cast({:render, :home, tree("stale"), :ios, RecordingNif, :none}, state) + + {:reply, :ok, state} = Sender.handle_call({:activate, :home, :push}, self(), state) + + assert state.pending == %{} + assert recorded() == [{A, false}] + end + + test "activate_frame records the queued frame it throws away" do + state = %Sender{active: :other} + + {:noreply, state} = Sender.handle_cast({:render_stats, :home, labelled_frame(A)}, state) + + {:noreply, state} = + Sender.handle_cast({:render, :home, tree("stale"), :ios, RecordingNif, :none}, state) + + {:reply, _token, state} = + Sender.handle_call({:activate_frame, :home, :push}, self(), state) + + assert state.pending == %{} + assert recorded() == [{A, false}] + end + + test "a staged frame whose render never arrives is swept, not leaked" do + # A screen killed between hand_off/1 and Mob.Sender.render/5 leaves a + # staged frame no cast will ever claim. Nothing else removes it, so the + # map grew without bound and the work was silently lost rather than + # recorded as dropped. + old = %{started: System.monotonic_time(:microsecond) - 10_000_000, screen: Dead} + state = %Sender{active: :home, frames: %{dead_ref: old}} + + {:noreply, state} = Sender.handle_info(:flush, state) + + assert state.frames == %{} + assert recorded() == [{Dead, false}] + end + + test "a freshly staged frame survives a flush" do + # The render that pairs it may still be in the mailbox behind :flush. + state = %Sender{active: :home, frames: %{live_ref: labelled_frame(A)}} + + {:noreply, state} = Sender.handle_info(:flush, state) + + assert Map.has_key?(state.frames, :live_ref) + assert recorded() == [] + end + + test "a render dropped by the activation gate drops its frame with it" do + # The gate returns state untouched on a token mismatch. A frame staged for + # that ref would otherwise sit there until some later render claimed it. + state = %Sender{active: :home, activation_gate: {:home, :expected, :push}} + + {:noreply, state} = Sender.handle_cast({:render_stats, :home, labelled_frame(A)}, state) + + {:noreply, state} = + Sender.handle_cast( + {:render, :home, tree("stale"), :ios, RecordingNif, :none, :wrong_token}, + state + ) + + assert state.frames == %{} + assert recorded() == [{A, false}] + end + end + describe "coalescing preserves the transition" do test "an immediate first paint cannot overtake its navigation transition" do start_sender(:home)