Skip to content

MOB-102: fix element_frames staleness after a no-op-frame rerender - #87

Merged
GenericJam merged 2 commits into
masterfrom
fix/mob-102-frame-registry-staleness
Aug 27, 2026
Merged

MOB-102: fix element_frames staleness after a no-op-frame rerender#87
GenericJam merged 2 commits into
masterfrom
fix/mob-102-frame-registry-staleness

Conversation

@GenericJam

Copy link
Copy Markdown
Owner

Summary

  • nif_set_root wiped the whole element_frames registry on every render and relied on MobFrameTracker's onChange(of: frame) to repopulate every surviving element — but that only refires on a genuine value change, so a static, unmoved element stayed wiped until something eventually moved it.
  • Fix: purge by id instead of wiping everything. nif_set_root parses the incoming tree first, collects its live :id set, and removes only registry entries absent from that set. A surviving element's entry is never touched.
  • MobRootView.swift is unchanged — the whole fix lives in ios/mob_nif.m.
  • Three "force every surviving element to re-register on every render" approaches were tried and rejected first, all failing the same way (a removed element's stale frame survived its own removal, since SwiftUI evaluates a reactive trigger for a view one more time before pruning it). Full trail in decisions/2026-08-27-frame-registry-purge-by-id.md.

Test plan

  • mix test — 1111 passed
  • mix format --check-formatted / mix credo --strict / mix compile --warnings-as-errors clean
  • xcrun clang-format --dry-run -Werror ios/mob_nif.m clean
  • Device-verified on iOS 17 Pro simulator via a throwaway two-element repro screen + NSLog-instrumented native build watched live via xcrun simctl spawn <udid> log stream — confirmed: a static element survives an unrelated sibling's rerender, a removed element's frame disappears on the same render that removes it. No XCTest target exists in this repo for native iOS changes; this is documented manual verification per CLAUDE.md.

Linear: MOB-102

🤖 Generated with Claude Code

GenericJamand others added 2 commits August 26, 2026 22:17
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
nif_set_root wiped the whole frame registry on every render, relying
on MobFrameTracker's onChange(of: frame) to repopulate every surviving
element -- but that onChange only refires when the frame VALUE
changes, so a static, unmoved element stayed wiped until something
eventually moved it. Mob.Test/element_frames would report a
still-visible, unmoved element as missing.
Fix: purge by id instead of wiping everything. nif_set_root now parses
the incoming tree first, collects its live :id set, and removes only
registry entries absent from that set -- a surviving element's entry
is never touched, so there's nothing to race and nothing that needs to
"repopulate." MobRootView.swift is unchanged.
Three "make every surviving element re-register on every render"
variations were tried and rejected first (ObservedObject-keyed
onChange, Environment-keyed onChange, forced .id() reconstruction) --
all shared the same failure, confirmed via an NSLog-instrumented
device build: an @Published/@Environment change reaches a view mid-
removal one more time before SwiftUI prunes it, so a removed element's
stale frame survived its own removal. See
decisions/2026-08-27-frame-registry-purge-by-id.md for the full
device-verification trail.
Device-verified: iOS 17 Pro simulator, a throwaway two-element repro
screen (one static, one removed by a later tap) driven via Mob.Test-
equivalent RPC. No XCTest target exists in this repo for native iOS
changes (see CLAUDE.md) -- this is documented manual verification, not
CI-enforced.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@GenericJam
GenericJam merged commit 66a119e into masterAug 27, 2026
4 checks passed
@GenericJam
GenericJam deleted the fix/mob-102-frame-registry-staleness branch August 27, 2026 04:46
GenericJam added a commit that referenced this pull request Aug 27, 2026
* MOB-103: element_frames liveness — tree membership isn't rendered
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>
* MOB-103: address adversarial review — two provable defects + overclaimed 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>
* MOB-103: record device verification — the headline claim now holds in 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>
* MOB-103: MobTabView verified — the last open scenario closes
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>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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