diff --git a/apps/src/tests/single-feature-tests/stack-v5/index.ts b/apps/src/tests/single-feature-tests/stack-v5/index.ts
index f8dd9a5e45..6f08d9925f 100644
--- a/apps/src/tests/single-feature-tests/stack-v5/index.ts
+++ b/apps/src/tests/single-feature-tests/stack-v5/index.ts
@@ -4,6 +4,7 @@ import type { ScenarioGroup } from '@apps/tests/shared/helpers';
// scenario group consumed by the selection menu.
import TestStackPreventNativeDismissSingleStack from './prevent-native-dismiss-single-stack';
import TestStackPreventNativeDismissNestedStack from './prevent-native-dismiss-nested-stack';
+import TestStackLifecycleEvents from './test-stack-lifecycle-events';
import TestStackAnimationAndroid from './test-animation-android';
import TestStackSimpleNav from './test-stack-simple-nav';
import TestStackSubviewsAndroid from './test-stack-subviews-android';
@@ -24,6 +25,7 @@ import TestStackHeaderSelectiveUpdates from './test-stack-header-selective-updat
// under a name for direct rendering (e.g. from App.tsx or e2e harnesses).
export { default as TestStackPreventNativeDismissSingleStack } from './prevent-native-dismiss-single-stack';
export { default as TestStackPreventNativeDismissNestedStack } from './prevent-native-dismiss-nested-stack';
+export { default as TestStackLifecycleEvents } from './test-stack-lifecycle-events';
export { default as TestStackAnimationAndroid } from './test-animation-android';
export { default as TestStackSimpleNav } from './test-stack-simple-nav';
export { default as TestStackSubviewsAndroid } from './test-stack-subviews-android';
@@ -41,6 +43,7 @@ export { default as TestStackToolbarNestedMenu } from './test-stack-toolbar-nest
const scenarios = {
TestStackPreventNativeDismissSingleStack,
TestStackPreventNativeDismissNestedStack,
+ TestStackLifecycleEvents,
TestStackAnimationAndroid,
TestStackSimpleNav,
TestStackSubviewsAndroid,
diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-lifecycle-events/index.tsx b/apps/src/tests/single-feature-tests/stack-v5/test-stack-lifecycle-events/index.tsx
new file mode 100644
index 0000000000..ec650d8a3a
--- /dev/null
+++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-lifecycle-events/index.tsx
@@ -0,0 +1,179 @@
+import React, { useCallback } from 'react';
+import { scenarioDescription } from './scenario-description';
+import { createScenario } from '@apps/tests/shared/helpers';
+import { StyleSheet, Text, View } from 'react-native';
+import {
+ StackContainer,
+ useStackNavigationContext,
+} from '@apps/shared/gamma/containers/stack';
+import { CenteredLayoutView } from '@apps/shared/CenteredLayoutView';
+import { Colors } from '@apps/shared/styling';
+import { ToastProvider, useToast } from '@apps/shared';
+import { StackNavigationButtons } from '@apps/tests/shared/components/stack-v5/StackNavigationButtons';
+
+function TestStackLifecycleEvents() {
+ return (
+
+
+
+ );
+}
+
+function useMakeLifecycleCallbacks() {
+ const toast = useToast();
+
+ return useCallback(
+ (screenName: string) => ({
+ onWillAppear: () =>
+ toast.push({
+ message: `${screenName}: onWillAppear`,
+ backgroundColor: Colors.GreenLight60,
+ }),
+ onDidAppear: () =>
+ toast.push({
+ message: `${screenName}: onDidAppear`,
+ backgroundColor: Colors.BlueLight100,
+ }),
+ onWillDisappear: () =>
+ toast.push({
+ message: `${screenName}: onWillDisappear`,
+ backgroundColor: Colors.NavyLight60,
+ }),
+ onDidDisappear: () =>
+ toast.push({
+ message: `${screenName}: onDidDisappear`,
+ backgroundColor: Colors.NavyLight100,
+ }),
+ }),
+ [toast],
+ );
+}
+
+function StackSetup() {
+ const makeCallbacks = useMakeLifecycleCallbacks();
+
+ return (
+
+ );
+}
+
+function HomeScreen() {
+ return (
+
+
+
+
+ );
+}
+
+function AScreen() {
+ return (
+
+
+
+
+ );
+}
+
+function NestedStackScreen() {
+ const makeCallbacks = useMakeLifecycleCallbacks();
+
+ return (
+
+ );
+}
+
+function NestedHomeScreen() {
+ return (
+
+
+
+
+ );
+}
+
+function NestedAScreen() {
+ return (
+
+
+
+
+ );
+}
+
+function RouteInformation(props: { routeName: string }) {
+ const routeKey = useStackNavigationContext().routeKey;
+
+ return (
+
+ Name: {props.routeName}
+ Key: {routeKey}
+
+ );
+}
+
+const styles = StyleSheet.create({
+ routeInformation: {
+ color: 'black',
+ fontSize: 20,
+ fontWeight: 'bold',
+ },
+});
+
+export default createScenario(
+ TestStackLifecycleEvents,
+ scenarioDescription,
+);
diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-lifecycle-events/scenario-description.ts b/apps/src/tests/single-feature-tests/stack-v5/test-stack-lifecycle-events/scenario-description.ts
new file mode 100644
index 0000000000..93ab0952b8
--- /dev/null
+++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-lifecycle-events/scenario-description.ts
@@ -0,0 +1,10 @@
+import type { ScenarioDescription } from '@apps/tests/shared/helpers';
+
+export const scenarioDescription: ScenarioDescription = {
+ name: 'Stack lifecycle events',
+ key: 'test-stack-lifecycle-events',
+ details: 'Verify lifecycle events (onWillAppear, etc.) fire on stack navigation',
+ platforms: ['android', 'ios'],
+ e2eCoverage: 'tbd',
+ smokeTest: false,
+};
diff --git a/apps/src/tests/single-feature-tests/stack-v5/test-stack-lifecycle-events/scenario.md b/apps/src/tests/single-feature-tests/stack-v5/test-stack-lifecycle-events/scenario.md
new file mode 100644
index 0000000000..3613c846b3
--- /dev/null
+++ b/apps/src/tests/single-feature-tests/stack-v5/test-stack-lifecycle-events/scenario.md
@@ -0,0 +1,301 @@
+# Test Scenario: Stack lifecycle events
+
+## Details
+
+**Description:** Verifies that `onWillAppear`, `onDidAppear`,
+`onWillDisappear`, and `onDidDisappear` fire in the correct order on stack
+navigation, covering push, pop via the **Pop** button, pop via the **header
+back button**, and pop via the **native back gesture / system back**. The same
+push and pop checks are then repeated **inside a nested stack** and **across
+the nested-stack boundary** (popping the whole container back to the outer
+stack), exercising every dismissal method available at each level.
+
+**OS test creation version:** iOS: 18.6 and 26.2, Android: API Level 36.
+
+## E2E test
+
+- **TBD:** E2E test has to be implemented in the future.
+
+## Prerequisites
+
+- iOS simulator or device (iPhone)
+- Android emulator or device
+
+## Android launch
+
+- To test the native-back / gesture-back pop flows, run the screen
+ **directly** by editing [apps/App.tsx](../../../../../App.tsx): import and
+ render `TestStackLifecycleEvents` as the root component instead of
+ `Example`, e.g.:
+
+ ```tsx
+ import { TestStackLifecycleEvents as Example } from './src/tests/single-feature-tests';
+ ```
+
+ With the gamma `StackContainer` at the root, native back and gesture-back
+ interact with the stack directly. When the screen is nested inside the
+ example app's own navigation, native back navigates out to the
+ system/selection menu instead of popping the stack (issue
+ [#1459](https://github.com/software-mansion/react-native-screens-labs/issues/1459)),
+ which is why Android is tested via the direct launch.
+
+- The system gesture-back requires **Gesture navigation** to be enabled on
+ the emulator/device. If it is not already set, enable it manually in
+ **Settings → Navigation mode → select Gesture navigation**.
+
+## Note
+
+- **iOS and Android fire a fundamentally different set of events**, so verify
+ each platform against its own column below.
+
+ **Android** — only the screen entering or leaving the stack fires; the
+ screen underneath stays silent:
+ - **Push (Y pushed over X):** only the pushed screen fires —
+ `Y: onWillAppear`, then `Y: onDidAppear`. X fires nothing.
+ - **Pop (Y popped, back to X):** only the popped screen fires —
+ `Y: onWillDisappear`, then `Y: onDidDisappear`. X fires nothing.
+
+ **iOS** — both screens fire; push and pop share the same interleaving —
+ the leaving screen's disappear brackets the entering screen's appear
+ (`willDisappear` → `willAppear` → `didDisappear` → `didAppear`):
+ - **Push (Y pushed over X)** — X is leaving, Y is entering:
+ 1. `X: onWillDisappear`
+ 2. `Y: onWillAppear`
+ 3. `X: onDidDisappear`
+ 4. `Y: onDidAppear`
+ - **Pop (Y popped, back to X)** — Y is leaving, X is entering:
+ 1. `Y: onWillDisappear`
+ 2. `X: onWillAppear`
+ 3. `Y: onDidDisappear`
+ 4. `X: onDidAppear`
+
+- **Nested stack:** pushing the `NestedStack` route also mounts its inner
+ stack's initial screen (`NestedHome`), so the appearance events are
+ **duplicated** — both `NestedStack` and `NestedHome` fire their appear
+ events on both platforms. The same duplication applies on pop.
+
+ Navigation **inside** the nested stack (`NestedHome` ↔ `NestedA`) fires only
+ the inner screens and behaves exactly like a top-level push/pop — the outer
+ `NestedStack` route and `Home` stay silent. Crossing the **boundary** back
+ out (`NestedStack` → `Home`) pops the whole container and fires the
+ duplicated events described above.
+
+- The dismissal method (Pop button, header back button, or native back
+ gesture / system back) must **not** change which events fire — all three
+ produce the same pop event set for the same transition. This holds at every
+ level: the top-level stack, the inner nested stack, and the container
+ boundary. At the container boundary there is **no header back button** (the
+ `NestedStack` route's header is hidden and `NestedHome` is the inner root),
+ so that crossing is exercised only via the Pop button and the native
+ gesture / system back — both of which pop the whole container.
+
+- Toasts stack and dismiss automatically. To dismiss a toast manually, tap
+ it. Toast background colors by event type: `onWillAppear` — green,
+ `onWillDisappear` — light navy, `onDidAppear` — light blue,
+ `onDidDisappear` — dark navy.
+
+## Steps
+
+### Baseline
+
+1. Launch the app and navigate to **Stack lifecycle events**.
+
+- [ ] The **Home** screen is visible with buttons **Push A** and **Push
+ NestedStack**. Two toasts appear for the initial Home appearance
+ (both platforms):
+ - `Home: onWillAppear`
+ - `Home: onDidAppear`
+
+---
+
+### Push — Home → A
+
+2. Tap **Push A**.
+
+- [ ] Screen **A** (header title "A") is pushed.
+
+ **iOS** — four toasts:
+ 1. `Home: onWillDisappear`
+ 2. `A: onWillAppear`
+ 3. `Home: onDidDisappear`
+ 4. `A: onDidAppear`
+
+ **Android** — two toasts (only the pushed screen fires):
+ 1. `A: onWillAppear`
+ 2. `A: onDidAppear`
+
+---
+
+### Pop via header back button — A → Home
+
+3. On screen **A**, tap the **header back button** (top-left).
+
+- [ ] Screen **Home** is shown again.
+
+ **iOS** — four toasts:
+ 1. `A: onWillDisappear`
+ 2. `Home: onWillAppear`
+ 3. `A: onDidDisappear`
+ 4. `Home: onDidAppear`
+
+ **Android** — two toasts (only the popped screen fires):
+ 1. `A: onWillDisappear`
+ 2. `A: onDidDisappear`
+
+---
+
+### Pop via native back gesture / system back — A → Home
+
+4. Tap **Push A** again. Then dismiss screen **A** using the **native back
+ gesture** (iOS: swipe from the left screen edge) or the **Android system
+ back** (gesture / hardware button).
+
+- [ ] Screen **Home** is shown again. The same pop toasts appear as in step 3
+ (iOS: four; Android: two), confirming the native gesture / system back
+ produces the identical pop event set.
+
+---
+
+### Pop via Pop button — A → Home
+
+5. Tap **Push A**, then on screen **A** tap the **Pop** button.
+
+- [ ] Screen **Home** is shown again. The same pop toasts appear as in step 3
+ (iOS: four; Android: two). The Pop button, the header back button
+ (step 3), and the native gesture (step 4) all produce the identical pop
+ event set.
+
+---
+
+### Nested stack — push
+
+6. From **Home**, tap **Push NestedStack**.
+
+- [ ] The **NestedStack** route is pushed and its inner stack shows
+ **NestedHome** (buttons **Push NestedA**, **Pop**). The appearance events
+ are **duplicated** across the outer route and the nested initial screen —
+ both `NestedStack` and `NestedHome` fire.
+
+ **iOS** — six toasts (both containers' `onWillAppear` fire before the
+ previous screen's `onDidDisappear`):
+ 1. `Home: onWillDisappear`
+ 2. `NestedStack: onWillAppear`
+ 3. `NestedHome: onWillAppear`
+ 4. `Home: onDidDisappear`
+ 5. `NestedStack: onDidAppear`
+ 6. `NestedHome: onDidAppear`
+
+ **Android** — four toasts (`Home` fires nothing):
+ 1. `NestedStack: onWillAppear`
+ 2. `NestedHome: onWillAppear`
+ 3. `NestedStack: onDidAppear`
+ 4. `NestedHome: onDidAppear`
+
+---
+
+### Nested stack — inner push — NestedHome → NestedA
+
+7. On **NestedHome**, tap **Push NestedA**.
+
+- [ ] Screen **NestedA** (header title "NestedA") is pushed **inside the
+ nested stack**. Only the inner screens fire — the outer `NestedStack` route
+ and `Home` stay silent — so this behaves exactly like a top-level push
+ (step 2):
+
+ **iOS** — four toasts:
+ 1. `NestedHome: onWillDisappear`
+ 2. `NestedA: onWillAppear`
+ 3. `NestedHome: onDidDisappear`
+ 4. `NestedA: onDidAppear`
+
+ **Android** — two toasts (only the pushed screen fires):
+ 1. `NestedA: onWillAppear`
+ 2. `NestedA: onDidAppear`
+
+---
+
+### Nested stack — inner pop via header back button — NestedA → NestedHome
+
+8. On screen **NestedA**, tap the **header back button** (top-left).
+
+- [ ] Screen **NestedHome** is shown again inside the nested stack. Only the
+ inner screens fire — this is the inner mirror of the top-level pop (step 3):
+
+ **iOS** — four toasts:
+ 1. `NestedA: onWillDisappear`
+ 2. `NestedHome: onWillAppear`
+ 3. `NestedA: onDidDisappear`
+ 4. `NestedHome: onDidAppear`
+
+ **Android** — two toasts (only the popped screen fires):
+ 1. `NestedA: onWillDisappear`
+ 2. `NestedA: onDidDisappear`
+
+---
+
+### Nested stack — inner pop via native gesture — NestedA → NestedHome
+
+9. Tap **Push NestedA** again. Then dismiss screen **NestedA** using the
+ **native back gesture** (iOS: swipe from the left screen edge) or the
+ **Android system back** (gesture / hardware button).
+
+- [ ] Screen **NestedHome** is shown again inside the nested stack. The same
+ inner pop toasts appear as in step 8 (iOS: four; Android: two), confirming
+ the native gesture / system back produces the identical inner pop event set.
+
+---
+
+### Nested stack — inner pop via Pop button — NestedA → NestedHome
+
+10. Tap **Push NestedA**, then on screen **NestedA** tap the **Pop** button.
+
+- [ ] Screen **NestedHome** is shown again inside the nested stack. The same
+ inner pop toasts appear as in step 8 (iOS: four; Android: two). The Pop
+ button, the header back button (step 8), and the native gesture (step 9) all
+ produce the identical inner pop event set.
+
+---
+
+### Nested stack — pop to Home via native gesture — NestedStack → Home
+
+11. Back on **NestedHome** (the nested stack's initial screen), dismiss the
+ screen using the **native back gesture** (iOS: swipe from the left screen
+ edge) or the **Android system back** (gesture / hardware button).
+
+- [ ] Screen **Home** is shown again. This pops the whole **NestedStack**
+ container, so the appearance events are **duplicated** — both `NestedStack`
+ and `NestedHome` fire their disappear events (the mirror of the push in
+ step 6):
+
+ **iOS** — six toasts (both containers' `onWillDisappear` fire before the
+ entering screen's `onWillAppear`):
+ 1. `NestedHome: onWillDisappear`
+ 2. `NestedStack: onWillDisappear`
+ 3. `Home: onWillAppear`
+ 4. `NestedHome: onDidDisappear`
+ 5. `NestedStack: onDidDisappear`
+ 6. `Home: onDidAppear`
+
+ **Android** — four toasts (`Home` fires nothing):
+ 1. `NestedHome: onWillDisappear`
+ 2. `NestedStack: onWillDisappear`
+ 3. `NestedHome: onDidDisappear`
+ 4. `NestedStack: onDidDisappear`
+
+---
+
+### Nested stack — pop to Home via Pop button — NestedStack → Home
+
+12. From **Home**, tap **Push NestedStack** again. Then on **NestedHome** tap
+ the **Pop** button.
+
+- [ ] Screen **Home** is shown again. Because `NestedHome` is the nested
+ stack's only (root) screen, the Pop button pops the whole **NestedStack**
+ container rather than a screen within it, so the same container-pop toasts
+ appear as in step 11 (iOS: six; Android: four).
+
+ The Pop button and the native gesture (step 11) produce the identical
+ container-pop event set. There is **no header back button** at this
+ boundary — the `NestedStack` route's header is hidden and `NestedHome` is
+ the inner root — so the container pop is exercised only via these two
+ methods.