Skip to content

Commit c72539d

Browse files
authored
feat(Android, Stack v5): allow for nested container pop via JS (#3612)
## Description Previously it has not been possible to pop nested container via JS. When there was only a single screen left in the JS stack state, the reducer would deny removing it and would just ignore the operation printing a warning that pop operation requires at least two screens. Currently, in such scenario the parent `StackContainer` (JS component) will be asked to pop whole screen that hosts the nested container. ## Changes This is achieved by changes in stack container state reducer. Previously it operated directly on stack, but now I need to somehow communicate that an action has not been performed directly on the container and must be forwarded to parent container. We can not use the `parentNavigation` directly in the reducer, because: 1. reducer should be pure, 2. we can not dispatch an action directly to the parent container during render (and this is when the state reducing takes place), React treats it as an error. Therefore, I see two possibilities here. Either we move all the `StackContainer` state to some centralized place (similarly to what react-navigation does with `NavigationContainer`) or we stay with current approach (each `StackContainer` owns its state) but we emit this intent as an side effect. Right now I went with second option. Reducer, when it find it makes sense, not only updates the stack state, but also produces side effects to be performed. Currently, there is only single side effect: `PopContainerStackNavigationEffect`, but I found it low cost to make it a bit more generic. Reducer produces the side effect, which is later consumed by the new `useParentNavigationEffect` hook. Important thing to note in this implementation is the referential stability of the effects array. The state object id changes every time `state.stack` or `state.effects` change. However, `state.effects` object id stays the same as long as there is no change to prevent excessive rendering. Given a nested stack, with only a single screen in the nested stack, the flow is as follows: 1. `navigation.pop` is dispatched to the nested stack, this triggers render. 2. Reducer detects that it has only a single screen & can not perform the action, therefore it produces new state with new effects array containing the `PopContainerStackNavigationEffect`. 3. The `useParentNavigationEffect` schedules the `useEffect`, which is executed after this render. 4. The `useEffect` is executed triggering render of the outer container and consuming the effect (trigger render of the inner container). 5. New render starts from the outer container -> it performs the navigation action, detaching the screen that hosts nested container 6. During the same render as in 5., nested container clears its `state.effects`. 7. After the dismiss completes, `pop-completed` operation is dispatched by event listeners & hosting screen & nested container are cleared from the state (they no longer render). > [!note] > Thing to note here, is that the nested stack's only screen, when it's > being dismissed it sends `onDismissed(isNativeDismiss=true)` event, > because it is dismissed & it's activityMode has never been changed to > `detached`. I don't think this is causing any problems right now, > but it is definitely something to be aware of. What happens if there is only a single screen in outer stack & it hosts the nested stack? Nothing. Renders are triggered, but there is no one to handle that operation. ## Visual documentation | before | after | | -- | -- | | <video src=https://github.com/user-attachments/assets/836181fb-4f94-488f-93bd-27a58a3e2f20 alt="before" /> | <video src=https://github.com/user-attachments/assets/84997062-07d6-4a48-878b-08d04e3891be alt="after" /> | ## Test plan I'm using `TestStackNesting`. Just as the video shows. ## Checklist - [x] Included code example that can be used to test this change. - [x] Updated / created local changelog entries in relevant test files. - [x] For visual changes, included screenshots / GIFs / recordings documenting the change. - [x] For API changes, updated relevant public types. - [x] Ensured that CI passes (all but Android lint - I'll fix it separately one day)
1 parent 5a6f842 commit c72539d

6 files changed

Lines changed: 224 additions & 82 deletions

File tree

apps/src/shared/gamma/containers/stack/StackContainer.tsx

Lines changed: 34 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ import { Stack } from 'react-native-screens/experimental';
33
import type {
44
NavigationAction,
55
StackContainerProps,
6+
StackNavigationState,
67
StackRouteConfig,
7-
StackState,
88
} from './StackContainer.types';
99
import {
10-
determineFirstRoute,
10+
determineInitialNavigationState,
1111
navigationStateReducerWithLogging,
1212
} from './reducer';
1313
import { useStackOperationMethods } from './hooks/useStackOperationMethods';
@@ -19,21 +19,26 @@ import {
1919
type NativeComponentGenericRef,
2020
useRenderDebugInfo,
2121
} from 'react-native-screens/private';
22+
import { useParentNavigationEffect } from './hooks/useParentNavigationEffect';
2223

2324
export function StackContainer({ routeConfigs }: StackContainerProps) {
2425
useSanitizeRouteConfigs(routeConfigs);
2526

26-
const [stackState, navActionDispatch]: [
27-
StackState,
27+
const [stackNavState, navActionDispatch]: [
28+
StackNavigationState,
2829
React.Dispatch<NavigationAction>,
2930
] = React.useReducer(
3031
navigationStateReducerWithLogging,
3132
routeConfigs,
32-
determineFirstRoute,
33+
determineInitialNavigationState,
3334
);
3435

3536
const navMethods = useStackOperationMethods(navActionDispatch, routeConfigs);
3637

38+
// If reducer produced a parent action, we need to dispatch it
39+
// as an effect, because we can not modify the state during the render phase.
40+
useParentNavigationEffect(navMethods, stackNavState.effects);
41+
3742
const hostRef =
3843
useRenderDebugInfo<NativeComponentGenericRef>('StackContainer');
3944

@@ -55,29 +60,31 @@ export function StackContainer({ routeConfigs }: StackContainerProps) {
5560

5661
return (
5762
<Stack.Host ref={hostRef}>
58-
{stackState.map(({ Component, options, activityMode, routeKey }) => {
59-
const stackNavigationContext: StackNavigationContextPayload = {
60-
routeKey,
61-
push: navMethods.pushAction,
62-
pop: navMethods.popAction,
63-
preload: navMethods.preloadAction,
64-
batch: navMethods.batchAction,
65-
};
63+
{stackNavState.stack.map(
64+
({ Component, options, activityMode, routeKey }) => {
65+
const stackNavigationContext: StackNavigationContextPayload = {
66+
routeKey,
67+
push: navMethods.pushAction,
68+
pop: navMethods.popAction,
69+
preload: navMethods.preloadAction,
70+
batch: navMethods.batchAction,
71+
};
6672

67-
return (
68-
<Stack.Screen
69-
key={routeKey}
70-
{...options}
71-
activityMode={activityMode}
72-
screenKey={routeKey}
73-
onDismiss={onScreenDismissed}
74-
onNativeDismiss={onScreenNativelyDismissed}>
75-
<StackNavigationContext.Provider value={stackNavigationContext}>
76-
<Component />
77-
</StackNavigationContext.Provider>
78-
</Stack.Screen>
79-
);
80-
})}
73+
return (
74+
<Stack.Screen
75+
key={routeKey}
76+
{...options}
77+
activityMode={activityMode}
78+
screenKey={routeKey}
79+
onDismiss={onScreenDismissed}
80+
onNativeDismiss={onScreenNativelyDismissed}>
81+
<StackNavigationContext.Provider value={stackNavigationContext}>
82+
<Component />
83+
</StackNavigationContext.Provider>
84+
</Stack.Screen>
85+
);
86+
},
87+
)}
8188
</Stack.Host>
8289
);
8390
}

apps/src/shared/gamma/containers/stack/StackContainer.types.tsx

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import React from 'react';
22
import { StackScreenProps } from 'react-native-screens/experimental';
33

4+
/// Route definition
5+
46
export type StackRouteOptions = Omit<
57
StackScreenProps,
68
'children' | 'activityMode' | 'screenKey'
@@ -12,21 +14,24 @@ export type StackRouteConfig = {
1214
options: StackRouteOptions;
1315
};
1416

15-
export type StackContainerProps = {
16-
routeConfigs: StackRouteConfig[];
17-
};
18-
1917
export type StackRoute = StackRouteConfig & {
2018
activityMode: StackScreenProps['activityMode'];
2119
routeKey: StackScreenProps['screenKey'];
2220
};
2321

22+
/// StackContainer props
23+
24+
export type StackContainerProps = {
25+
routeConfigs: StackRouteConfig[];
26+
};
27+
2428
export type PushActionMethod = (routeName: string) => void;
2529
export type PopActionMethod = (routeKey: string) => void;
2630
export type PopCompletedActionMethod = (routeKey: string) => void;
2731
export type PopNativeActionMethod = (routeKey: string) => void;
2832
export type PreloadActionMethod = (routeName: string) => void;
2933
export type BatchActionMethod = (actions: BatchableNavigationAction[]) => void;
34+
export type ClearEffectsActionMethod = () => void;
3035

3136
export type NavigationActionMethods = {
3237
pushAction: PushActionMethod;
@@ -35,10 +40,22 @@ export type NavigationActionMethods = {
3540
popNativeAction: PopNativeActionMethod;
3641
preloadAction: PreloadActionMethod;
3742
batchAction: BatchActionMethod;
43+
clearEffectsAction: ClearEffectsActionMethod;
3844
};
3945

4046
export type StackState = StackRoute[];
4147

48+
export type StackNavigationState = {
49+
stack: StackState;
50+
effects: StackNavigationEffect[];
51+
};
52+
53+
export type StackNavigationEffect = PopContainerStackNavigationEffect;
54+
55+
type PopContainerStackNavigationEffect = {
56+
type: 'pop-container';
57+
};
58+
4259
export type NavigationActionPush = {
4360
type: 'push';
4461
routeName: string;
@@ -74,6 +91,13 @@ export type NavigationActionBatch = {
7491
actions: Exclude<NavigationAction, NavigationActionBatch>[];
7592
};
7693

94+
// TODO: We need to separate navigation actions exposed to user from internal
95+
// state manipulation done in stack container on the type level.
96+
export type NavigationActionClearEffects = {
97+
type: 'clear-effects';
98+
ctx: NavigationActionContext;
99+
};
100+
77101
export type NavigationActionContext = {
78102
routeConfigs: StackRouteConfig[];
79103
};
@@ -89,4 +113,5 @@ export type NavigationAction =
89113
| NavigationActionPopCompleted
90114
| NavigationActionNativePop
91115
| NavigationActionPreload
116+
| NavigationActionClearEffects
92117
| NavigationActionBatch;
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import React from 'react';
2+
import { StackNavigationContext } from '../contexts/StackNavigationContext';
3+
import type {
4+
NavigationActionMethods,
5+
StackNavigationEffect,
6+
} from '../StackContainer.types';
7+
8+
export function useParentNavigationEffect(
9+
navActionMethods: NavigationActionMethods,
10+
effects: StackNavigationEffect[] | undefined,
11+
) {
12+
const parentNavigation = React.useContext(StackNavigationContext);
13+
14+
const consumeEffect = React.useEffectEvent(
15+
(effect: StackNavigationEffect) => {
16+
if (effect.type !== 'pop-container') {
17+
throw new Error(`[Stack] Unrecognized effect type: ${effect.type}`);
18+
}
19+
if (parentNavigation) {
20+
console.log(
21+
`[Stack] Delegating pop action to parent container for key ${parentNavigation.routeKey}`,
22+
);
23+
parentNavigation.pop(parentNavigation.routeKey);
24+
}
25+
},
26+
);
27+
28+
const clearEffects = React.useEffectEvent(() => {
29+
navActionMethods.clearEffectsAction();
30+
});
31+
32+
// We want to trigger this effect only when effects change.
33+
React.useEffect(() => {
34+
if (effects && effects.length > 0) {
35+
effects.forEach(consumeEffect);
36+
clearEffects();
37+
}
38+
}, [effects]);
39+
}

apps/src/shared/gamma/containers/stack/hooks/useStackOperationMethods.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from 'react';
22
import {
33
BatchableNavigationAction,
44
BatchActionMethod,
5+
ClearEffectsActionMethod,
56
NavigationAction,
67
NavigationActionContext,
78
NavigationActionMethods,
@@ -74,6 +75,10 @@ export function useStackOperationMethods(
7475
[dispatch, actionContext],
7576
);
7677

78+
const clearEffectsAction: ClearEffectsActionMethod = React.useCallback(() => {
79+
dispatch({ type: 'clear-effects', ctx: actionContext });
80+
}, [dispatch, actionContext]);
81+
7782
const aggregateValue = React.useMemo(() => {
7883
return {
7984
pushAction,
@@ -82,6 +87,7 @@ export function useStackOperationMethods(
8287
popNativeAction,
8388
preloadAction,
8489
batchAction,
90+
clearEffectsAction,
8591
};
8692
}, [
8793
pushAction,
@@ -90,6 +96,7 @@ export function useStackOperationMethods(
9096
popNativeAction,
9197
preloadAction,
9298
batchAction,
99+
clearEffectsAction,
93100
]);
94101

95102
return aggregateValue;

0 commit comments

Comments
 (0)