Skip to content

Pop every level the iOS back history menu pops - #616

Merged
simonhamp merged 1 commit into
mainfrom
fix/multi-level-system-back
Oct 26, 2026
Merged

simonhamp merged 1 commit into
mainfrom
fix/multi-level-system-back

Conversation

@simonhamp

@simonhamp simonhamp commented Oct 26, 2026 •

Copy link
Copy Markdown
Member

Long-pressing the back button on iOS opens a menu of the screens below. Picking one more than a level down only went back one screen.

iOS pops all the levels in one go, and onPathChange() in both coordinators already posts one system back per level. PHP answered the first, which ends the top screen's runloop. The rest were still queued when the next screen drew its first frame, and flushDeferredTransition() drops everything except async completions. So PHP ended up one screen down, published that screen at its depth, and the coordinator pushed it back on top of the screen the user picked.

The router now counts the system backs queued behind the one that just popped a screen. Each screen below answers one through handleSystemBack() before it resumes, so the screens in between never run onResume() or draw. It stops at the root, because native navigation can't pop past it and a back there would end the session (or minimise the app on Android). It also stops at a screen whose onBackPressed() keeps the user there.

No Swift or Kotlin changes.

Worth a look: on Android the same code now pops once for each hardware back press that queues up during a transition. Today those extra presses are dropped.

Testing

tests/Feature/SystemBackHistoryTest.php runs the real router loop with backs queued: a three-level pop, a pop that would go past the root, and a screen that refuses the back.

I also checked it on an iOS 27 simulator with a tab layout and four pushed screens, using press and drag on the back button to pick Level 1 from Level 4:

  • On main, iOS posts three backs, PHP answers one and publishes Level 3, and the coordinator pushes it back on (path becomes [p1, p3, p3]). You end up on Level 3.
  • On this branch the edge-nav log shows the stack go 5, 4, 3, 2 in about 2 ms, Levels 3 and 2 are never resumed, and Level 1 shows.

On main the mismatch also loses history: the coordinator fills the gap by repeating the screen PHP published, so the back menu afterwards only lists some of the levels.

🤖 Generated with Claude Code

Long-pressing the back button on iOS opens a history menu, and picking an
entry pops several screens at once. Native navigation posts one system back
per level, but only the first was answered: the rest were dropped when the
next screen drew its first frame, so PHP went back one screen and native
navigation pushed it back on.

The router now counts the system backs queued behind the one that popped a
screen and has each screen below answer one before it resumes, so the
screens in between never draw. The root never answers one, and a screen
whose onBackPressed() keeps the user there ends the run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@simonhamp
simonhamp merged commit 9974391 into main Oct 26, 2026
6 checks passed
@simonhamp
simonhamp deleted the fix/multi-level-system-back branch October 26, 2026 16:40
gwleuverink added a commit that referenced this pull request Nov 2, 2026
Covers mobile PRs #525, #527, #528, #560, #605, #608, #609, #611, #613, #614, #615, #616, #617, #619, #621, #622, #623, #624, #625, #626, #627, #628, #629, #632, #633, #634, #635 and #636, and the commits on main without a PR: 4082538 (bounded()'s docblock), 49528ba (the Android logging fix from a fork), b23aba9, 2a773cc and b9316b6 (security policy, issue template and README links). Main b107ed9..767a7c8, 33 commits, 4.6.1 among them.

Ported to core on branch sync/mobile-main-2026-11-01, which this merge needs: #608 (scrollVirtualListTo()), #625 with 4082538 (snap points on a gesture area), #621 (:context-menu on a pressable, matched through core's ConvertsToElement as :menu is), #617's farewell frame through a hook, and #614's ValidatesInput and FieldErrors, which move to core. The old names of those two resolve to core's through the alias shim.

Kept on this branch: #616 in full. EVENT_SYSTEM_BACK sits on NativeComponent next to EVENT_HOT_RELOAD and EVENT_SHUTDOWN, the router half is in NativeRouter, and InteractsWithAppShell's back button handler checks the constant, as main's runLoop() does. #617's element types: NativeComponent answers core's typesClosedInFarewellFrame() hook with bottom_sheet and modal. BroadcastsGlobally extends core's marker and #609's DispatchableFromNative.

Removed again in the merge: HasVirtualListWindow, GestureArea and Pressable (main changed them, core has them), and ValidatesInput and FieldErrors (main added them, core has them). Main's import of the old dispatcher name came out of DispatchEventFromAppController, where it clashed with core's.

Adapted for the extraction: BroadcastsGloballyNamesTest expects DispatchableFromNative among the old marker's interfaces, as #609 makes it.

#609 is merged as main has it and is not ported.

Needs NativePHP/core#18.

Tests, all on PHP 8.4.27, with dev dependencies updated for #628 (Laravel 12.70.2, Pest 3.8.7): mobile on this merge against the core sync branch (445049b), 2,467 passed, 4 risky, 4 skipped. PHPStan clean. Mobile main (767a7c8), 2,412 passed, 4 risky, 4 skipped. Desktop main (fd40580) against the core sync branch, test:php 2,240 passed, as against core main. Desktop #57 (977a2af), 2,245 passed against the earlier core sync head (59c73fc), which differs only by the constant moved to mobile; not rerun.
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.

1 participant