Skip to content

Commit 239a794

Browse files
authored
Merge pull request Expensify#90044 from software-mansion-labs/korytko/improve-replace-rhp
Fix reveal navigation under RHP + re-land reveal workspace route under RHP before dismissing on workspace creation
2 parents 122081b + d6a3bd0 commit 239a794

8 files changed

Lines changed: 928 additions & 60 deletions

File tree

src/CONST/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6445,6 +6445,9 @@ const CONST = {
64456445

64466446
NAVIGATION: {
64476447
CUSTOM_HISTORY_ENTRY_SIDE_PANEL: 'CUSTOM_HISTORY-SIDE_PANEL',
6448+
// Fake history entry used to keep browser Back behavior correct after revealing a route under an RHP.
6449+
// addRootHistoryRouterExtension owns when this is added, carried forward, and removed.
6450+
CUSTOM_HISTORY_ENTRY_REVEAL_PADDING: 'CUSTOM_HISTORY-REVEAL_PADDING',
64486451
ACTION_TYPE: {
64496452
REPLACE: 'REPLACE',
64506453
PUSH: 'PUSH',

src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,39 @@ const MODAL_ROUTES_TO_DISMISS = new Set<string>([
5151

5252
const screensWithEnteringAnimation = new Set<string>();
5353

54+
// RN's deep-link initial-state hint keys, per `getStateFromParams` in
55+
// @react-navigation/core/src/useNavigationBuilder.tsx. Stripped only when `params.screen` is
56+
// set so legitimate user keys (e.g. `path`, `initial`) on non-hydrated routes survive.
57+
const STALE_DEEP_LINK_PARAM_KEYS = new Set(['state', 'screen', 'params', 'path', 'initial']);
58+
59+
/** Removes the RN deep-link hint chain from `route.params` when triggered by `params.screen`. */
60+
function withSanitizedDeepLinkParams<R extends {params?: unknown}>(route: R, focusParams: Record<string, unknown> | undefined): R {
61+
const rParamsRecord = route.params as Record<string, unknown> | undefined;
62+
63+
// RN stores nested deep-link instructions under params.screen/params.params.
64+
const looksLikeDeepLinkInitialState = !!rParamsRecord && typeof rParamsRecord.screen === 'string';
65+
const shouldSanitizeExistingParams = looksLikeDeepLinkInitialState && !!rParamsRecord;
66+
67+
// Remove only RN's hint keys; keep any real params that were stored next to them.
68+
const sanitizedExistingParams = shouldSanitizeExistingParams ? Object.fromEntries(Object.entries(rParamsRecord).filter(([key]) => !STALE_DEEP_LINK_PARAM_KEYS.has(key))) : rParamsRecord;
69+
const hasSanitizedExistingParams = !!sanitizedExistingParams && Object.keys(sanitizedExistingParams).length > 0;
70+
const fallbackParams = hasSanitizedExistingParams ? sanitizedExistingParams : undefined;
71+
72+
// The new focused tab params win; otherwise keep the cleaned existing params.
73+
const nextParams = focusParams ?? fallbackParams;
74+
75+
if (nextParams !== undefined) {
76+
return {...route, params: nextParams};
77+
}
78+
if (looksLikeDeepLinkInitialState) {
79+
// If params only contained stale RN hints, remove params entirely.
80+
const routeWithoutParams = {...route};
81+
delete (routeWithoutParams as {params?: unknown}).params;
82+
return routeWithoutParams;
83+
}
84+
return {...route};
85+
}
86+
5487
/**
5588
* Stores the original TAB_NAVIGATOR route before a tab-switch pre-insertion
5689
* (handleReplaceFullscreenUnderRHP). Restored on cancel by handleRemoveFullscreenUnderRHP,
@@ -380,9 +413,11 @@ function handleReplaceFullscreenUnderRHP(
380413
const prependedRoutes = [sidebarRoute, ...(newNestedRoutes ?? [])];
381414
mergedNestedState = {...focusedTargetTab.state, routes: prependedRoutes, index: prependedRoutes.length - 1};
382415
}
416+
// Strip any RN deep-link hint chain from `r.params`; otherwise RN would run a
417+
// follow-up NAVIGATE from it and override the `state` we splice below.
418+
const sanitizedRoute = withSanitizedDeepLinkParams(r, focusedTargetTab.params as Record<string, unknown> | undefined);
383419
return {
384-
...r,
385-
...(focusedTargetTab.params !== undefined ? {params: focusedTargetTab.params} : {}),
420+
...sanitizedRoute,
386421
...(mergedNestedState !== undefined ? {state: mergedNestedState as typeof r.state} : {}),
387422
};
388423
});
@@ -542,4 +577,6 @@ export {
542577
handleToggleSidePanelWithHistoryAction,
543578
getPreInsertedOriginalTabRoute,
544579
clearPreInsertedOriginalTabRoute,
580+
// Exported for unit-test access; not used outside of testing.
581+
withSanitizedDeepLinkParams,
545582
};

src/libs/Navigation/AppNavigator/routerExtensions/addRootHistoryRouterExtension.ts

Lines changed: 62 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,31 @@
1-
import type {CommonActions, ParamListBase, PartialState, Router, RouterConfigOptions, StackActionType} from '@react-navigation/native';
2-
import type {RemoveFullscreenUnderRHPActionType, ReplaceFullscreenUnderRHPActionType, RootStackNavigatorAction} from '@libs/Navigation/AppNavigator/createRootStackNavigator/types';
1+
import type {ParamListBase, PartialState, Router, RouterConfigOptions} from '@react-navigation/native';
2+
import Log from '@libs/Log';
3+
import type {RootStackNavigatorAction} from '@libs/Navigation/AppNavigator/createRootStackNavigator/types';
34
import type {PlatformStackNavigationState, PlatformStackRouterFactory, PlatformStackRouterOptions} from '@libs/Navigation/PlatformStackNavigation/types';
45
import CONST from '@src/CONST';
6+
import {
7+
applyRevealPaddingOffset,
8+
getFrozenHistoryStateForRemoveFullscreenUnderRHP,
9+
getFrozenHistoryStateForReplaceFullscreenUnderRHP,
10+
getRevealDismissState,
11+
isDismissModalAction,
12+
isRemoveFullscreenUnderRHPAction,
13+
isReplaceFullscreenUnderRHPAction,
14+
} from './addRootHistoryRouterExtensionUtils';
15+
import type {PendingReveal, RootHistoryState} from './addRootHistoryRouterExtensionUtils';
516
import {enhanceStateWithHistory} from './utils';
617

7-
function isReplaceFullscreenUnderRHPAction(action: RootStackNavigatorAction): action is ReplaceFullscreenUnderRHPActionType {
8-
return action.type === CONST.NAVIGATION.ACTION_TYPE.REPLACE_FULLSCREEN_UNDER_RHP;
9-
}
10-
11-
function isRemoveFullscreenUnderRHPAction(action: RootStackNavigatorAction): action is RemoveFullscreenUnderRHPActionType {
12-
return action.type === CONST.NAVIGATION.ACTION_TYPE.REMOVE_FULLSCREEN_UNDER_RHP;
13-
}
14-
15-
/**
16-
* Higher-order function that extends a React Navigation stack router with history
17-
* management for the root stack navigator.
18-
*
19-
* It maintains a `history` array mirroring the routes and handles two concerns:
20-
*
21-
* 1. **Side panel** – preserves the CUSTOM_HISTORY_ENTRY_SIDE_PANEL entry through
22-
* rehydration so the side panel open/close state survives navigation state rebuilds.
23-
*
24-
* 2. **REPLACE/REMOVE_FULLSCREEN_UNDER_RHP** - freezes the history array for these
25-
* actions so that useLinking sees historyDelta=0 and does NOT push/pop any browser
26-
* history entries for these intermediate state changes. The correct browser history
27-
* update happens later when DISMISS_MODAL pops the RHP in the next animation frame.
28-
*/
18+
/** Manages root `state.history` for side-panel + reveal flows; per-branch rationale inline. */
2919
function addRootHistoryRouterExtension<RouterOptions extends PlatformStackRouterOptions = PlatformStackRouterOptions>(
3020
originalRouter: PlatformStackRouterFactory<ParamListBase, RouterOptions>,
3121
) {
32-
return (options: RouterOptions): Router<PlatformStackNavigationState<ParamListBase>, CommonActions.Action | StackActionType> => {
22+
return (options: RouterOptions): Router<PlatformStackNavigationState<ParamListBase>, RootStackNavigatorAction> => {
3323
const router = originalRouter(options);
3424

25+
// RHP snapshot taken on REPLACE; matching DISMISS must equal all three fields (key,
26+
// routes depth, history depth) to commit the reveal freeze.
27+
let pendingReveal: PendingReveal | null = null;
28+
3529
const getInitialState = (configOptions: RouterConfigOptions) => {
3630
const state = router.getInitialState(configOptions);
3731
return enhanceStateWithHistory(state);
@@ -41,6 +35,7 @@ function addRootHistoryRouterExtension<RouterOptions extends PlatformStackRouter
4135
const state = router.getRehydratedState(partialState, configOptions);
4236
const stateWithInitialHistory = enhanceStateWithHistory(state);
4337

38+
// Preserve trailing side-panel sentinel through state rebuilds.
4439
if (state.history?.at(-1) === CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_SIDE_PANEL) {
4540
stateWithInitialHistory.history = [...stateWithInitialHistory.history, CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_SIDE_PANEL];
4641
return stateWithInitialHistory;
@@ -49,24 +44,56 @@ function addRootHistoryRouterExtension<RouterOptions extends PlatformStackRouter
4944
return stateWithInitialHistory;
5045
};
5146

52-
const getStateForAction = (state: PlatformStackNavigationState<ParamListBase>, action: CommonActions.Action | StackActionType, configOptions: RouterConfigOptions) => {
47+
// Centralizes the `PartialState | FullState` cast to `getRehydratedState`'s input shape.
48+
function rehydrate(newState: PartialState<RootHistoryState> | RootHistoryState, configOptions: RouterConfigOptions) {
49+
return getRehydratedState(newState as PartialState<RootHistoryState>, configOptions);
50+
}
51+
52+
const getStateForAction = (state: RootHistoryState, action: RootStackNavigatorAction, configOptions: RouterConfigOptions) => {
53+
// Snapshot is stale if its RHP key vanished via a non-DISMISS path.
54+
if (pendingReveal && !state.routes.some((r) => r.key === pendingReveal?.rhpKey)) {
55+
Log.hmmm('[addRootHistoryRouterExtension] pending reveal RHP no longer in routes; clearing snapshot', {pendingReveal});
56+
pendingReveal = null;
57+
}
58+
5359
const newState = router.getStateForAction(state, action, configOptions);
5460

5561
if (!newState) {
5662
return null;
5763
}
5864

59-
// For REPLACE/REMOVE_FULLSCREEN_UNDER_RHP we intentionally preserve the original
60-
// history array so that useLinking sees historyDelta=0 and does NOT push/pop any
61-
// browser history entries for these intermediate state changes.
62-
if ((isReplaceFullscreenUnderRHPAction(action) || isRemoveFullscreenUnderRHPAction(action)) && state.history) {
63-
// @ts-expect-error newState can be partial but getRehydratedState handles it correctly.
64-
const rehydrated = getRehydratedState(newState, configOptions);
65-
return {...rehydrated, history: state.history};
65+
// REPLACE: capture pending reveal + freeze history (intermediate frame; useLinking historyDelta=0).
66+
if (isReplaceFullscreenUnderRHPAction(action)) {
67+
const result = getFrozenHistoryStateForReplaceFullscreenUnderRHP(state, newState, configOptions, pendingReveal, rehydrate);
68+
pendingReveal = result.pendingReveal;
69+
return result.state;
70+
}
71+
72+
// REMOVE: cancel path; clear snapshot + freeze history (same rationale as REPLACE).
73+
if (isRemoveFullscreenUnderRHPAction(action)) {
74+
const result = getFrozenHistoryStateForRemoveFullscreenUnderRHP(state, newState, configOptions, rehydrate);
75+
if (state.history) {
76+
pendingReveal = null;
77+
}
78+
return result;
79+
}
80+
81+
// DISMISS that completes the reveal: pad new history to pre-DISMISS length so
82+
// useLinking sees historyDelta=0 and just `history.replace`s the current entry,
83+
// preserving the prior fullscreen browser entry. (RN 7.x useLinking semantics.)
84+
if (isDismissModalAction(action) && pendingReveal && state.history) {
85+
const result = getRevealDismissState(state, newState, configOptions, pendingReveal, rehydrate);
86+
pendingReveal = result.pendingReveal;
87+
if (result.state) {
88+
return result.state;
89+
}
6690
}
6791

68-
// @ts-expect-error newState may be partial, but getRehydratedState handles both partial and full states correctly.
69-
return getRehydratedState(newState, configOptions);
92+
// Default: re-apply the offset (single source of truth = leading sentinels in
93+
// state.history). addPushParamsRouterExtension keeps all string entries, so
94+
// reveal-padding sentinels survive PUSH_PARAMS / GO_BACK / POP / RESET dispatches.
95+
const rehydrated = rehydrate(newState, configOptions);
96+
return applyRevealPaddingOffset(state, rehydrated);
7097
};
7198

7299
return {
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import type {ParamListBase, PartialState, RouterConfigOptions} from '@react-navigation/native';
2+
import Log from '@libs/Log';
3+
import type {
4+
DismissModalActionType,
5+
RemoveFullscreenUnderRHPActionType,
6+
ReplaceFullscreenUnderRHPActionType,
7+
RootStackNavigatorAction,
8+
} from '@libs/Navigation/AppNavigator/createRootStackNavigator/types';
9+
import type {PlatformStackNavigationState} from '@libs/Navigation/PlatformStackNavigation/types';
10+
import CONST from '@src/CONST';
11+
import NAVIGATORS from '@src/NAVIGATORS';
12+
import type {CustomHistoryEntry} from './types';
13+
14+
type RootHistoryState = PlatformStackNavigationState<ParamListBase>;
15+
type PendingReveal = {rhpKey: string; routesLengthAtCapture: number; historyLengthAtCapture: number};
16+
type RehydrateRootHistoryState = (newState: PartialState<RootHistoryState> | RootHistoryState, configOptions: RouterConfigOptions) => RootHistoryState;
17+
18+
function isReplaceFullscreenUnderRHPAction(action: RootStackNavigatorAction): action is ReplaceFullscreenUnderRHPActionType {
19+
return action.type === CONST.NAVIGATION.ACTION_TYPE.REPLACE_FULLSCREEN_UNDER_RHP;
20+
}
21+
22+
function isRemoveFullscreenUnderRHPAction(action: RootStackNavigatorAction): action is RemoveFullscreenUnderRHPActionType {
23+
return action.type === CONST.NAVIGATION.ACTION_TYPE.REMOVE_FULLSCREEN_UNDER_RHP;
24+
}
25+
26+
function isDismissModalAction(action: RootStackNavigatorAction): action is DismissModalActionType {
27+
return action.type === CONST.NAVIGATION.ACTION_TYPE.DISMISS_MODAL;
28+
}
29+
30+
function isRightModalNavigatorRouteName(name: string | undefined): boolean {
31+
return name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR;
32+
}
33+
34+
/** Centralizes the cast from PlatformStackNavigationState's `unknown[]` history slot to our typed array. */
35+
function asCustomHistory(history: unknown[] | undefined): CustomHistoryEntry[] | undefined {
36+
return history as CustomHistoryEntry[] | undefined;
37+
}
38+
39+
/** Counts the leading `CUSTOM_HISTORY_ENTRY_REVEAL_PADDING` sentinels in a history array. */
40+
function countLeadingRevealPadding(history: CustomHistoryEntry[] | undefined): number {
41+
if (!history?.length) {
42+
return 0;
43+
}
44+
let count = 0;
45+
// Count how many fake history slots we previously added to keep browser history aligned.
46+
for (const entry of history) {
47+
if (entry === CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_REVEAL_PADDING) {
48+
count += 1;
49+
} else {
50+
break;
51+
}
52+
}
53+
return count;
54+
}
55+
56+
/** Returns a fresh history array with `offset` reveal-padding sentinels prepended. */
57+
function buildPaddedHistory(baseHistory: CustomHistoryEntry[], offset: number): CustomHistoryEntry[] {
58+
if (offset <= 0) {
59+
return [...baseHistory];
60+
}
61+
const padding = new Array<CustomHistoryEntry>(offset).fill(CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_REVEAL_PADDING);
62+
return [...padding, ...baseHistory];
63+
}
64+
65+
function getFrozenHistoryStateForReplaceFullscreenUnderRHP(
66+
state: RootHistoryState,
67+
newState: PartialState<RootHistoryState> | RootHistoryState,
68+
configOptions: RouterConfigOptions,
69+
pendingReveal: PendingReveal | null,
70+
rehydrate: RehydrateRootHistoryState,
71+
): {pendingReveal: PendingReveal | null; state: RootHistoryState} {
72+
if (!state.history) {
73+
Log.hmmm('[addRootHistoryRouterExtension] REPLACE_FULLSCREEN_UNDER_RHP arrived with undefined state.history; reveal will not freeze');
74+
return {pendingReveal, state: rehydrate(newState, configOptions)};
75+
}
76+
77+
// Capture the RHP that should be dismissed in the second half of the reveal.
78+
const topRoute = state.routes.at(-1);
79+
const postReplaceRoutesLength = (newState.routes ?? state.routes).length;
80+
const nextPendingReveal =
81+
topRoute && isRightModalNavigatorRouteName(topRoute.name)
82+
? {
83+
rhpKey: topRoute.key,
84+
routesLengthAtCapture: postReplaceRoutesLength,
85+
historyLengthAtCapture: state.history.length,
86+
}
87+
: pendingReveal;
88+
89+
// Use the new routes but freeze old history for this hidden intermediate frame.
90+
// Defensive copy of state.history (shared reference; freeze must not alias).
91+
return {pendingReveal: nextPendingReveal, state: {...rehydrate(newState, configOptions), history: [...state.history]}};
92+
}
93+
94+
function getFrozenHistoryStateForRemoveFullscreenUnderRHP(
95+
state: RootHistoryState,
96+
newState: PartialState<RootHistoryState> | RootHistoryState,
97+
configOptions: RouterConfigOptions,
98+
rehydrate: RehydrateRootHistoryState,
99+
): RootHistoryState {
100+
if (!state.history) {
101+
Log.hmmm('[addRootHistoryRouterExtension] REMOVE_FULLSCREEN_UNDER_RHP arrived with undefined state.history');
102+
return rehydrate(newState, configOptions);
103+
}
104+
105+
// Cancel path: remove the pre-inserted screen while keeping browser history still.
106+
return {...rehydrate(newState, configOptions), history: [...state.history]};
107+
}
108+
109+
function getRevealDismissState(
110+
state: RootHistoryState,
111+
newState: PartialState<RootHistoryState> | RootHistoryState,
112+
configOptions: RouterConfigOptions,
113+
pendingReveal: PendingReveal,
114+
rehydrate: RehydrateRootHistoryState,
115+
): {pendingReveal: PendingReveal | null; state?: RootHistoryState} {
116+
// This is the stack before closing the RHP; the top route is the one DISMISS removes.
117+
const dismissingTopKey = state.routes.at(-1)?.key;
118+
const depthMatches = state.routes.length === pendingReveal.routesLengthAtCapture;
119+
const historyDepthMatches = state.history?.length === pendingReveal.historyLengthAtCapture;
120+
121+
// Apply the reveal fix only when this DISMISS closes the same RHP we snapshotted.
122+
if (dismissingTopKey === pendingReveal.rhpKey && depthMatches && historyDepthMatches) {
123+
const rehydrated = rehydrate(newState, configOptions);
124+
const rehydratedHistory = asCustomHistory(rehydrated.history) ?? [];
125+
// rehydratedHistory already includes any trailing SIDE_PANEL sentinel,
126+
// so it does not inflate the computed offset.
127+
const lengthDelta = (state.history?.length ?? 0) - rehydratedHistory.length;
128+
if (lengthDelta > 0) {
129+
Log.hmmm(`[addRootHistoryRouterExtension] reveal committed; freezing history with offset ${lengthDelta}`);
130+
// Keep the pre-dismiss history length so web linking replaces instead of going back.
131+
return {pendingReveal: null, state: {...rehydrated, history: buildPaddedHistory(rehydratedHistory, lengthDelta)}};
132+
}
133+
Log.hmmm('[addRootHistoryRouterExtension] reveal committed with non-positive lengthDelta; no freeze', {lengthDelta});
134+
return {pendingReveal: null};
135+
}
136+
137+
if (dismissingTopKey === pendingReveal.rhpKey) {
138+
// Same RHP, but the stack/history changed unexpectedly; skip the special reveal fix.
139+
Log.hmmm('[addRootHistoryRouterExtension] reveal snapshot key matched but depth diverged; clearing without freeze', {
140+
capturedRoutesLength: pendingReveal.routesLengthAtCapture,
141+
currentRoutesLength: state.routes.length,
142+
capturedHistoryLength: pendingReveal.historyLengthAtCapture,
143+
currentHistoryLength: state.history?.length,
144+
});
145+
return {pendingReveal: null};
146+
}
147+
148+
return {pendingReveal};
149+
}
150+
151+
function applyRevealPaddingOffset(state: RootHistoryState, rehydrated: RootHistoryState): RootHistoryState {
152+
// Regular navigation rebuilds history from routes; put back any fake slots already in use.
153+
const offset = countLeadingRevealPadding(asCustomHistory(state.history));
154+
if (offset > 0) {
155+
return {...rehydrated, history: buildPaddedHistory(asCustomHistory(rehydrated.history) ?? [], offset)};
156+
}
157+
return rehydrated;
158+
}
159+
160+
export type {PendingReveal, RehydrateRootHistoryState, RootHistoryState};
161+
export {
162+
applyRevealPaddingOffset,
163+
getFrozenHistoryStateForRemoveFullscreenUnderRHP,
164+
getFrozenHistoryStateForReplaceFullscreenUnderRHP,
165+
getRevealDismissState,
166+
isDismissModalAction,
167+
isRemoveFullscreenUnderRHPAction,
168+
isReplaceFullscreenUnderRHPAction,
169+
};

0 commit comments

Comments
 (0)