Skip to content

Keep a system back that arrives while the next screen is mounting - #483

Closed
simonhamp wants to merge 1 commit into
mainfrom
fix/471-fast-nav-desync
Closed

simonhamp wants to merge 1 commit into
mainfrom
fix/471-fast-nav-desync

Conversation

@simonhamp

Copy link
Copy Markdown
Member

Fixes #471

What was wrong

A #[Lazy] screen publishes its placeholder before mount() runs (NativeRouter::loop(), line 494), so native pushes the screen straight away. If the user pops it with the back chevron or an edge swipe while mount() is still busy, native sends a system back (type 8) and it waits in the event queue.

When mount() returns, the screen's first render calls flushDeferredTransition() (NativeComponent.php:2717), which calls nativephp_element_reset() (NativeRouter.php:133). The reset empties the whole event queue (event_queue_clear_locked() in the runtime's nphp_element.c), so the back is thrown away. The runtime even logs it: Element reset discarded 1 undelivered event(s).

PHP then publishes the screen the user already left. The coordinator sees a URI that isn't on its path and pushes it again (NavigationCoordinator.swift:126). PHP now sits on the chat while the user has tried to leave it, and nothing ever corrects that, because the back PHP needed is gone. On the simulator the chat just slides back in. The device report (list under the chat's nav bar, dead taps) is most likely the same thing with the re-push landing mid-animation. I couldn't get that exact picture on the simulator.

What changed

  • flushDeferredTransition() now drains the queue just before the reset and keeps events that aren't bound to the outgoing screen: system back, native events, hot reload and shutdown. Taps and other callback events are still dropped, as before, so a tap meant for the old screen can't hit a callback on the new one.
  • The runloop takes held events before it waits for new ones.
  • When a system back is held, the screen doesn't publish its first frame, and publishFinalState() skips the farewell frame. Either one would push the screen back on.

Android uses the same router, and its BackHandler pops the path before PHP answers, same as iOS. So Android had the same lost back and gets the same fix. I checked that by reading the code, not on a device. The Android symptom would be the chat coming back rather than a stuck screen.

Other open PRs

I applied each one to main and ran the same repro against it. None of them fix this.

What to watch

  • Native events that arrive during mount() are now delivered instead of dropped. That covers deep links, async task completions and #[On] events. I think that's what everyone expected already, but it is a behaviour change.
  • The drain calls nativephp_element_wait_event(0) until it returns null. On device and under Jump that returns straight away when the queue is empty.
  • There is still a window of a few milliseconds between the drain and the publish. A back that lands in it is still delivered, but the chat may flash back once before PHP handles it.

Testing

Test app: a list and a chat in one native stack, with the chat's mount() blocking for 1.2s. There are three variants: blocking, 0.3s, and #[Lazy] with 1.2s. iPhone 17 Pro simulator, iOS 26.2.

  • On main, a back during a lazy chat's mount was lost 10 of 10 times. After each one the chat came back on its own a moment later, and the runtime logged the discarded event. Five-trip bursts that ended on a back left the chat on screen in 5 of 6 runs.
  • With this branch, 0 of 10 were lost. Twelve-trip bursts with the chevron, the edge swipe and a mix of both, with gaps of 0ms, 150ms and 400ms, always ended on the list, and the list's tap counter went up after every run. Ten-trip bursts on the blocking and 0.3s chats behaved the same.
  • Normal navigation still works: push and pop by chevron and swipe, a three-deep stack and back out, pull-to-refresh on the chat, switching between two tabs and leaving them.
  • Pest: 1135 passed (4 new), 4 risky, 3 skipped, same as main apart from the new tests. Pint is clean. PHPStan reports the same 3 errors as main, all in RunsAndroid.php.

🤖 Generated with Claude Code

A #[Lazy] screen's placeholder is published before mount() runs, so
native pushes it straight away and the user can pop it while mount() is
still busy (a network call, say). Native sends a system back, but it
sits in the event queue until mount() returns. The router then calls
flushDeferredTransition(), and its nativephp_element_reset() empties the
queue before the screen's first real frame. The back was thrown away,
PHP stayed on the screen the user had left, and that frame pushed the
screen back on. On the simulator the chat just slides back in after the
user has left it. The device report in #471 (list under the chat's nav
bar, taps do nothing) is most likely the same drift, with the re-push
landing while the pop is still animating.

flushDeferredTransition() now drains the queue before the reset and
keeps the events that aren't bound to the outgoing screen: system back,
native events, hot reload and shutdown. Taps and other callback events
are still dropped, as before. The runloop handles held events before it
waits for new ones. When a system back is held, the screen skips its
first frame and back() skips its farewell frame, since either would push
the screen back on.

Android runs the same router and pops its path the same way before PHP
answers, so it gets the fix too.

Fixes #471

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 19, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 28cf13c2-7b9d-4630-9abb-9f783b9651bf

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@simonhamp

Copy link
Copy Markdown
Member Author

Closing as this is solving for problems with #[Lazy] that go away if we just encourage the use of AsyncTasks instead

@simonhamp simonhamp closed this Oct 4, 2026
Sign up for free to 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.

[Masterclass] iOS: fast navigation leaves the app stuck - wrong nav bar appears and taps stop working

1 participant