diff --git a/src/Edge/NativeComponent.php b/src/Edge/NativeComponent.php index c10235a9..1e4f598d 100644 --- a/src/Edge/NativeComponent.php +++ b/src/Edge/NativeComponent.php @@ -91,6 +91,9 @@ abstract class NativeComponent protected ?NavigationIntent $nativeNavigationIntent = null; + /** True while dispatching a system back (type 8) from native navigation. */ + protected bool $nativeAnsweringSystemBack = false; + protected ?NativeRouter $nativeRouter = null; protected array $nativeParams = []; @@ -2019,6 +2022,23 @@ public function onBackPressed(): void $this->back(); } + /** + * Dispatch a system back — the hardware button, the system back + * chevron, or an edge-swipe pop. While it runs, back() knows the + * event came from native navigation and skips its farewell publish; + * see back() for why that matters. + */ + public function handleSystemBack(): void + { + $this->nativeAnsweringSystemBack = true; + + try { + $this->onBackPressed(); + } finally { + $this->nativeAnsweringSystemBack = false; + } + } + public static function registerDumpHandler(): void { if (self::$dumpHandlerRegistered) { @@ -2365,7 +2385,7 @@ public function runLoop(): void continue; } - $this->onBackPressed(); + $this->handleSystemBack(); continue; } @@ -2528,7 +2548,18 @@ public function back(): static } $this->nativeNavigationIntent = new NavigationIntent(NavigationIntent::BACK); - $this->publishFinalState(); + + // The farewell frame keeps a PHP-initiated pop looking fresh while + // the native side animates it. Answering a SYSTEM back is the + // opposite situation: native navigation may already have removed + // this screen and shrunk its path, so a farewell frame reaches the + // coordinator as an unknown URI — which its reconciliation can only + // read as a brand-new push. Skip it to avoid reintroducing a screen + // that native navigation is backing away from. + if (! $this->nativeAnsweringSystemBack) { + $this->publishFinalState(); + } + $this->stop(); return $this; diff --git a/src/Testing/TestableComponent.php b/src/Testing/TestableComponent.php index 864ed4f8..ac3d89e3 100644 --- a/src/Testing/TestableComponent.php +++ b/src/Testing/TestableComponent.php @@ -497,7 +497,7 @@ public function pressBack(): static { $this->startInteraction(); - $this->guard(fn () => $this->component->onBackPressed()); + $this->guard(fn () => $this->component->handleSystemBack()); return $this->afterInteraction(); } @@ -647,7 +647,7 @@ public function follow(): static public function goBack(): TestableComponent { if ($this->component->getNavigationIntent() === null) { - $this->guard(fn () => $this->component->onBackPressed()); + $this->guard(fn () => $this->component->handleSystemBack()); } $intent = $this->component->getNavigationIntent(); @@ -1423,8 +1423,8 @@ protected function startInteraction(): void /** * Post-interaction re-render, mirroring the runloop: a component that - * set a navigation intent has stopped (its final state was already - * published by publishFinalState()); otherwise render the next frame. + * set a navigation intent has stopped (and may have published a farewell + * frame as part of that navigation); otherwise render the next frame. */ protected function afterInteraction(): static { diff --git a/tests/Feature/SystemBackFarewellTest.php b/tests/Feature/SystemBackFarewellTest.php new file mode 100644 index 00000000..b47fbb08 --- /dev/null +++ b/tests/Feature/SystemBackFarewellTest.php @@ -0,0 +1,107 @@ +farewellFrames++; + } + }; +} + +it('publishes a farewell frame for a PHP-initiated back', function () { + $screen = farewellScreen(); + + $screen->back(); + + expect($screen->getNavigationIntent()?->type)->toBe(NavigationIntent::BACK) + ->and($screen->farewellFrames)->toBe(1); +}); + +it('skips the farewell frame when answering a system back', function () { + $screen = farewellScreen(); + + $screen->handleSystemBack(); + + expect($screen->getNavigationIntent()?->type)->toBe(NavigationIntent::BACK) + ->and($screen->farewellFrames)->toBe(0); +}); + +it('resets the system-back flag when the back handler throws', function () { + $screen = new class extends NativeComponent + { + public int $farewellFrames = 0; + + public function render(): Element + { + return Column::make(); + } + + protected function publishFinalState(): void + { + $this->farewellFrames++; + } + + public function onBackPressed(): void + { + throw new RuntimeException('boom'); + } + }; + + try { + $screen->handleSystemBack(); + } catch (RuntimeException) { + // expected + } + + $screen->back(); + + expect($screen->farewellFrames)->toBe(1); +}); + +it('skips the farewell frame for a harness back press', function () { + $bridge = Native::fakeBridge(); + + $screen = Native::test(DetailScreen::class); + $mounted = count($bridge->publishes); + + $screen->pressBack()->assertWentBack(); + + expect($bridge->publishes)->toHaveCount($mounted); +}); + +it('publishes a farewell frame for a PHP-initiated harness back', function () { + $bridge = Native::fakeBridge(); + + $screen = Native::test(DetailScreen::class); + $mounted = count($bridge->publishes); + + $screen->tap('Go back')->assertWentBack(); + + expect($bridge->publishes)->toHaveCount($mounted + 1); +});