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.