Skip to content

MOB-103: element_frames liveness — tree membership isn't rendered - #89

Merged
GenericJam merged 4 commits into
masterfrom
fix/mob-103-frame-registry-liveness
Aug 27, 2026
Merged

MOB-103: element_frames liveness — tree membership isn't rendered#89
GenericJam merged 4 commits into
masterfrom
fix/mob-103-frame-registry-liveness

Conversation

@GenericJam

Copy link
Copy Markdown
Owner

Follow-up to MOB-102 (#87). Not ready to merge — see Verification.

Problem

MOB-102 replaced nif_set_root's wipe with a purge-by-id. That fixed static elements vanishing, but swapped the predicate: Mob.Test.element_frames/1 documents rendered on screen, purge-by-id implements present in the BEAM tree. Three divergences:

  1. In the tree, not laid out.LazyVStack rows scrolled out of range, inactive TabView tabs, dismissed sheet content — the id stays in the tree so the purge keeps it, and onChange never fires again, so the last on-screen frame is reported forever. Scroll a 500-row list to row 200 and tap_id(node, "row_3") taps whatever is at those coordinates now. Pre-MOB-102 that was {:error, :not_found}MOB-102 turned a safe failure into a silently wrong action.
  2. Teardown race narrowed, not closed.mob_adopt_frame_ids runs on the NIF thread; MobViewModel.setRoot is DispatchQueue.main.async. Outgoing screens animate out under .move, firing onChange the whole way, re-registering at mid-animation coordinates after the purge ran.
  3. Trackers aren't bound to an :id. Every ForEach keys children by index while the registry is keyed by :id, so a list delete shifts ids onto trackers that keep their identity.

Fix

Keeps purge-by-id, adds what it can't express:

  • Compare-and-delete on .onDisappearmob_register_frame returns a monotonic seq; a tracker can only delete an entry it still owns. Deliberately on the unregistration side; MOB-102's four rejected attempts were all registration-side.
  • Generation gatingnif_set_root bumps a frame generation on identity-destroying transitions, in lockstep with navVersion. Superseded writes are refused, which is the only thing that makes the shared-:id nav case safe. Read via a plain C call, not @Environment/@ObservedObject — a rejection gate, not a repopulation trigger.
  • Register on .onAppear and on .onChange(of: id) — makes disappear/reappear self-healing regardless of whether initial: re-fires, and handles the index-keyed-ForEach divergence order-independently.
  • Bookkeeping in a reference box rather than @State scalars; nif_element_frames serializes outside the lock; mob_nif.m imports the bridging header so signature drift is a compile error.

Adversarial review

The first commit was reviewed adversarially; it found divergences 1 and 3 above as provable defects that reintroduced MOB-102's own bug, plus docs claiming more verification than had been done. The second commit fixes those and corrects the claims. Details in the commit message and decisions/2026-08-27-frame-registry-liveness.md.

Verification

Done:

  • test/native/ — committed, runnable (make -C test/native run), 20 assertions including both MOB-102 non-regression cases, the shared-:id nav case, and the list-delete case. It reproduces the registry functions rather than linking the shipped ones, so it checks the algorithm, not the binary.
  • ios/mob_nif.m compiles clean (clang -fsyntax-only -fobjc-arc, iOS 26.5 sim SDK, MobApp-Swift.h stubbed since swiftc generates it during the real build).
  • clang-format, swiftlint (one pre-existing force_cast on master), 1111 Elixir tests — though the only Elixir change here is a docstring.

Not done, and it decides whether this is a fix or a regression: whether .onDisappear fires for a LazyVStack row leaving the render window (and whether LazyVStack destroys those rows at all rather than retaining them), whether a TabView round trip re-registers, and whether app backgrounding fires a spurious .onDisappear that could empty the registry. None is answerable from source; there is no XCTest target here. Needs a simulator pass reading element_frames/1 over dist at each step.

No version bump — CHANGELOG entry is under [Unreleased].

🤖 Generated with Claude Code

GenericJamand others added 3 commits August 26, 2026 23:04
MOB-102 (0.7.29) replaced nif_set_root's unconditional mob_clear_frames()
with a purge-by-id. That fixed static elements vanishing, but swapped the
predicate: element_frames/1 documents "rendered on screen" while purge-by-id
implements "present in the BEAM tree". Two gaps, both from PR #87 review.
1. In the tree but not laid out. LazyVStack discards far-offscreen rows,
TabView keeps every tab's subtree in the tree while showing one, and a
dismissed sheet's content stays mounted. Their ids survive the purge and
onChange never fires again, so the last on-screen frame was reported
forever — tap_id then taps whatever occupies those coordinates now. Before
MOB-102 the wipe made this {:error, :not_found}; MOB-102 turned a safe
failure into a silently wrong action.
2. The teardown race is narrowed, not closed. mob_adopt_frame_ids runs
synchronously on the NIF thread, but MobViewModel.setRoot is dispatched
async to main — so an outgoing screen is still animating out under
.transition(navTransition(...)) after its ids were purged, and its
GeometryReader re-registers it at mid-animation coordinates.
Fix, keeping purge-by-id:
- mob_register_frame returns a monotonic write seq; MobFrameTracker holds it
in @State and hands it back on .onDisappear via mob_unregister_frame(id,
seq). Compare-and-delete, so an outgoing screen can't remove an entry an
incoming screen just claimed under the same :id. Deliberately on the
unregistration side — all four mechanisms MOB-102 tried and rejected were
attempts to make registration race-proof against removal timing.
- nif_set_root retains the live id set; writes for ids absent from it are
ignored, killing the mid-animation re-registration for non-shared ids.
- Seqs live in a side table so nif_element_frames keeps JSON-encoding the
registry directly — the {"id":[x,y,w,h]} wire shape is unchanged.
Also: document what counts as rendered on element_frames/1 (the docstring
still promised "every rendered element") plus the settle caveat, and add the
MOB-102 entry the 0.7.29 changelog never got despite the behaviour being
user-visible.
Verified: ios/mob_nif.m compiles clean (clang -fsyntax-only -fobjc-arc, iOS
26.5 sim SDK); registry algorithm covered by an executable harness asserting
all 12 behaviours including the MOB-102 non-regression case; 1111 Elixir
tests, format and credo clean. NOT verified: whether .onDisappear fires for
a LazyVStack row leaving the render window / a TabView tab switching away —
needs a device run, no XCTest target in this repo.
No version bump — CHANGELOG entry is under [Unreleased].
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…med docs
An adversarial review of the first commit found two source-provable bugs in
the SwiftUI half, both of which reintroduced the very failure MOB-102 exists
to prevent (a still-visible element reported missing). Verified both against
the source before acting.
1. Trackers aren't bound to an :id. Every ForEach in MobRootView.swift keys
children by index (id: \.offset) while the registry is keyed by :id, so
deleting a list item shifts each later id onto a tracker that keeps its
view identity. With same-height rows the tracker's own frame value doesn't
change, so nothing fired — and the departing tracker's .onDisappear
compare-and-deleted the SURVIVING element's entry, which then never
re-registered. Broken on the commonest list operation.
Fixed by re-registering on .onChange(of: id). Order-independent: whichever
of the teardown and the re-registration lands first, the survivor ends up
correctly registered at its new position. Keying the seven ForEach sites on
nativeViewId would make this structural rather than incidental and is the
better long-term fix, but it changes SwiftUI identity semantics across every
list (@State preservation, animations), so it wants its own change and its
own device pass — recorded as a known gap in the decision doc.
2. The shared-:id nav case was stated as a guarantee but wasn't one.
navTransition is .move for push/pop, so the outgoing screen's global frames
change continuously and it keeps firing onChange as it slides out. When both
screens tag an element with the same :id, that id IS in the new tree, so the
tree-membership gate doesn't reject it: the outgoing screen re-claims the id
after the incoming one, then its .onDisappear deletes it — with the winner
decided nondeterministically by intra-frame update order. "Until the
surviving element next moves" is never, for a static button.
Fixed with generation gating. A non-"none" transition is what bumps
navVersion, and MobRootView keys the tree on .id(currentNavVersion), so all
identities are rebuilt; nif_set_root bumps a frame generation in lockstep,
trackers capture it on appear, and superseded writes are refused. The
refused write returns seq 0 and unregister no-ops on 0, so the outgoing
teardown can't delete the incoming entry either. Read through a plain C
call, not @Environment/@ObservedObject — it's a rejection gate, not a
repopulation trigger, which is what separates it from MOB-102's four
rejected attempts.
Also from the review:
- Re-register on .onAppear. Re-registration previously relied on
onChange(initial: true) firing again, which is not guaranteed when SwiftUI
preserved view identity across the disappearance — a TabView tab demonstrably
preserves @State across switches, so a tab could have been permanently
unreportable after one round trip.
- Move per-tracker bookkeeping off @State scalars into a reference box. The
writes happen inside a layout-driven callback once per display frame per
element during a transition (the "modifying state during view update" shape),
and the seq must stay readable during the teardown transaction that reads it.
- nif_element_frames snapshots under the lock and serializes outside it. The
main thread takes that lock on every frame write and the docs tell callers to
poll this NIF.
- mob_nif.m now imports MobDemo-Bridging-Header.h so signature drift is a
compile error. C has no name mangling, so before this the void -> uint64_t
change was checked by nothing and a future revert would have silently fed
Swift a garbage return register.
- element_frames/1 docs note the reorder gap rather than promising more than
the implementation delivers.
- The retroactive MOB-102 bullet in the frozen [0.7.29] section is marked as
added after release, so the changelog stays a record of what each release
actually shipped.
Verification, corrected — the previous commit message overstated this:
- test/native/ now holds the harness as a committed, runnable target
(make -C test/native run), 20 assertions including both MOB-102
non-regression cases, the shared-:id nav case and the list-delete case. It
reproduces the registry functions rather than linking the shipped ones, so
it checks the algorithm, not the binary. That limitation is stated in the
file.
- ios/mob_nif.m compiles clean under clang -fsyntax-only -fobjc-arc against
the iOS 26.5 simulator SDK, with MobApp-Swift.h stubbed (it's generated by
swiftc during the real build) — the earlier "compiles clean" claim omitted
that the stub was required.
- clang-format and swiftlint clean (the one force_cast warning is pre-existing
on master). The 1111 Elixir tests pass but exercise none of this; the only
Elixir change is a docstring.
STILL UNVERIFIED, and it is what decides whether this is a fix or a
regression: whether .onDisappear fires for a LazyVStack row leaving the render
window (and whether LazyVStack destroys such rows at all rather than retaining
them), whether a TabView round trip re-registers, and whether app backgrounding
fires a spurious .onDisappear that could empty the registry. None is answerable
from source and there is no XCTest target here. Needs a simulator pass reading
Mob.Test.element_frames/1 over dist at each step.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… practice
The adversarial review's verdict was that no amount of source reading decides
whether this change is a fix or a regression, and it was right. Verified on
both an iPhone 17 Pro simulator (iOS 26.4) and a physical iPhone (iOS 26.5.2),
using a generated probe app — a 300-row :list plus two screens deliberately
tagging a button with the same :id — read over dist with
Mob.Test.element_frames/1.
Divergence 1, the headline. Only the visible window is ever registered (12 of
300 rows on the simulator, 10 on the phone), so LazyVStack does destroy
far-offscreen rows and .onDisappear does fire for them — the load-bearing
assumption the review flagged as unverified, and it holds. After scrolling well
past it, row_3 is absent on BOTH targets. Under MOB-102 alone that id stays in
the tree, so nothing would have dropped it and tap_id would have tapped
whatever now occupies those coordinates. Rows scrolled back into range
re-register, so the cycle is self-healing.
MOB-102 non-regression: probe_title and shared_btn, static and unmoved,
survived all of that without re-registering.
Divergence 2, shared :id across a nav push — the case the review called
nondeterministic. The incoming screen's frame is what's reported (simulator
y=512 vs the outgoing y=112; phone y=470.5 vs 70.5), stable across repeated
reads seconds after the animation settles, and restored exactly on pop. Neither
deleted nor left at mid-animation coordinates.
Backgrounding: a background/foreground cycle leaves the registry intact, so the
"one cycle could empty the registry" failure mode doesn't occur.
Still unverified: MobTabView. A tab switching away keeps its subtree in the
tree and TabView preserves view identity across switches, so whether .onAppear
re-fires on re-selection is NOT settled by the lazy-list result, which destroys
and rebuilds instead. The probe couldn't reach that path — tab_bar/1 navigation
didn't render a tab bar in the generated app — so it keeps its own note rather
than being quietly folded into the verified set.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@GenericJam

Copy link
Copy Markdown
OwnerAuthor

Device-verified — the headline claim now holds in practice

The adversarial review's verdict was that no amount of source reading decides whether this is a fix or a regression. Ran it on both an iPhone 17 Pro simulator (iOS 26.4) and a physical iPhone (iOS 26.5.2), using a generated probe app (300-row :list, plus two screens deliberately tagging a button with the same :id), read over dist with Mob.Test.element_frames/1.

ScenarioSimulatorPhysical iPhone
Divergence 1 — lazy-list row scrolled out of rangerow_3 absentrow_3 absent
MOB-102 non-regression — static unmoved element✅ survives✅ survives
Divergence 2 — shared :id across a nav push✅ y=512 (incoming), not 112✅ y=470.5 (incoming), not 70.5
Background / foreground✅ registry intact
MobTabView tab switch❌ not reached❌ not reached

The load-bearing assumption holds. Only the visible window is ever registered — 12 of 300 rows on the simulator, 10 on the phone — so LazyVStack really does destroy far-offscreen rows and .onDisappear really does fire for them. That was the review's biggest "if this is wrong, nothing is fixed while the docs claim it is" risk. Rows scrolled back into range re-register, so the cycle is self-healing.

Worth being explicit about why row_3's absence is attributable to this change: under MOB-102 alone its id is still in the tree (all 300 items are), so the purge would never drop it and nothing else would either.

Divergence 2 — the one the review called nondeterministic — reports the incoming screen's frame on both targets, stable across repeated reads seconds after the animation settles, and restores exactly on pop. Neither deleted nor left at mid-animation coordinates.

Still open

MobTabView. A tab switching away keeps its subtree in the tree, and TabView preserves view identity across switches, so whether .onAppear re-fires on re-selection is not settled by the lazy-list result — that path destroys and rebuilds instead. The probe couldn't reach it (tab_bar/1 navigation didn't render a tab bar in the generated app), so it stays documented as open rather than being folded into the verified set.

The one case the lazy-list result could not settle. A tab switching away keeps
its subtree in the tree and TabView preserves view identity across switches, so
whether .onAppear re-fires on re-selection was genuinely open — the review's
worry was that a tab could be permanently unreportable after one round trip.
Verified on the simulator with a real tab_bar render node: with tab A active
only tab_a_marker is registered; switching to B drops it and registers
tab_b_marker; switching back restores tab_a_marker. Repeatable in both
directions. .onAppear does re-fire, and the deactivated tab's element is
dropped rather than left stale.
Two findings worth recording while getting there:
- The reachable path is the tab_bar RENDER NODE (props.tabs + props.active),
not Mob.App.tab_bar/1 navigation — that helper did not render a tab bar in
the generated app.
- Mob.Renderer has no on_tab_select handler, so that prop reaches native as a
raw {pid, tag} tuple and kills the screen process on serialize. Tab selection
has to be driven from Elixir through `active`. Separate bug, not this
change's; noted in the decision doc.
MobTabView is the only scenario left simulator-only: the phone had rebound dist
to its USB link-local address (169.254.1.100), which dist can't route to, and
clearing that needs the cable physically unplugged. Every other scenario ran on
both targets and agreed exactly, and the mechanism under test is SwiftUI
behaviour rather than anything device-specific.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@GenericJam

Copy link
Copy Markdown
OwnerAuthor

MobTabView verified — last open scenario closes

StepRegistered ids
start (tab A active)tab_a_marker only
switch → Btab_b_marker only — A dropped
switch → Atab_a_marker only — A restored
switch → Btab_b_marker only

Repeatable in both directions. .onAppeardoes re-fire on tab re-selection, so a tab round trip is not permanently unreportable — that was the review's specific worry, and it doesn't occur. The deactivated tab's element is dropped rather than left stale.

Two things found getting there, both recorded in the decision doc:

  • The reachable path is the tab_barrender node (props.tabs + props.active), not Mob.App.tab_bar/1 navigation.
  • Mob.Renderer has no on_tab_select handler, so that prop reaches native as a raw {pid, tag} tuple and kills the screen process on serialize. Tab selection must be driven from Elixir via active. Separate pre-existing bug, not this change's — worth its own issue.

MobTabView is the only scenario left simulator-only: the phone had rebound dist to its USB link-local address (169.254.1.100), which dist can't route to. Every other scenario ran on both targets and agreed exactly.

@GenericJam
GenericJam merged commit f103411 into masterAug 27, 2026
3 checks passed
@GenericJam
GenericJam deleted the fix/mob-103-frame-registry-liveness branch August 27, 2026 06:05
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@GenericJam