Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions src/Edge/NativeComponent.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -2365,7 +2385,7 @@ public function runLoop(): void

continue;
}
$this->onBackPressed();
$this->handleSystemBack();

continue;
}
Expand Down Expand Up @@ -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;
Expand Down
8 changes: 4 additions & 4 deletions src/Testing/TestableComponent.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
{
Expand Down
107 changes: 107 additions & 0 deletions tests/Feature/SystemBackFarewellTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?php

use Native\Mobile\Edge\Element;
use Native\Mobile\Edge\Elements\Column;
use Native\Mobile\Edge\NativeComponent;
use Native\Mobile\Edge\NavigationIntent;
use Native\Mobile\Testing\Native;
use Tests\Fixtures\Edge\DetailScreen;

/**
* back() publishes one farewell frame so a PHP-initiated pop has fresh
* content to show while the native side animates it. A system back
* (hardware button, back chevron, edge-swipe pop) is the opposite case:
* native navigation may already have removed the departing screen and
* shrunk its path, so its farewell frame can arrive as an unknown URI —
* reconciled as a brand-new push. These tests pin the split: farewell for
* PHP-initiated backs, none when answering a system back.
*/
function farewellScreen(): NativeComponent
{
return new class extends NativeComponent
{
public int $farewellFrames = 0;

public function render(): Element
{
return Column::make();
}

protected function publishFinalState(): void
{
$this->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);
});
Loading