diff --git a/src/CONST/index.ts b/src/CONST/index.ts index ea3c1dfcb8ec..4edbb7f37771 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6479,9 +6479,6 @@ const CONST = { NAVIGATION: { CUSTOM_HISTORY_ENTRY_SIDE_PANEL: 'CUSTOM_HISTORY-SIDE_PANEL', - // Fake history entry used to keep browser Back behavior correct after revealing a route under an RHP. - // addRootHistoryRouterExtension owns when this is added, carried forward, and removed. - CUSTOM_HISTORY_ENTRY_REVEAL_PADDING: 'CUSTOM_HISTORY-REVEAL_PADDING', ACTION_TYPE: { REPLACE: 'REPLACE', PUSH: 'PUSH', diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts index 8f8b75fe3907..19e4d521ea94 100644 --- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts @@ -51,39 +51,6 @@ const MODAL_ROUTES_TO_DISMISS = new Set([ const screensWithEnteringAnimation = new Set(); -// RN's deep-link initial-state hint keys, per `getStateFromParams` in -// @react-navigation/core/src/useNavigationBuilder.tsx. Stripped only when `params.screen` is -// set so legitimate user keys (e.g. `path`, `initial`) on non-hydrated routes survive. -const STALE_DEEP_LINK_PARAM_KEYS = new Set(['state', 'screen', 'params', 'path', 'initial']); - -/** Removes the RN deep-link hint chain from `route.params` when triggered by `params.screen`. */ -function withSanitizedDeepLinkParams(route: R, focusParams: Record | undefined): R { - const rParamsRecord = route.params as Record | undefined; - - // RN stores nested deep-link instructions under params.screen/params.params. - const looksLikeDeepLinkInitialState = !!rParamsRecord && typeof rParamsRecord.screen === 'string'; - const shouldSanitizeExistingParams = looksLikeDeepLinkInitialState && !!rParamsRecord; - - // Remove only RN's hint keys; keep any real params that were stored next to them. - const sanitizedExistingParams = shouldSanitizeExistingParams ? Object.fromEntries(Object.entries(rParamsRecord).filter(([key]) => !STALE_DEEP_LINK_PARAM_KEYS.has(key))) : rParamsRecord; - const hasSanitizedExistingParams = !!sanitizedExistingParams && Object.keys(sanitizedExistingParams).length > 0; - const fallbackParams = hasSanitizedExistingParams ? sanitizedExistingParams : undefined; - - // The new focused tab params win; otherwise keep the cleaned existing params. - const nextParams = focusParams ?? fallbackParams; - - if (nextParams !== undefined) { - return {...route, params: nextParams}; - } - if (looksLikeDeepLinkInitialState) { - // If params only contained stale RN hints, remove params entirely. - const routeWithoutParams = {...route}; - delete (routeWithoutParams as {params?: unknown}).params; - return routeWithoutParams; - } - return {...route}; -} - /** * Stores the original TAB_NAVIGATOR route before a tab-switch pre-insertion * (handleReplaceFullscreenUnderRHP). Restored on cancel by handleRemoveFullscreenUnderRHP, @@ -413,11 +380,9 @@ function handleReplaceFullscreenUnderRHP( const prependedRoutes = [sidebarRoute, ...(newNestedRoutes ?? [])]; mergedNestedState = {...focusedTargetTab.state, routes: prependedRoutes, index: prependedRoutes.length - 1}; } - // Strip any RN deep-link hint chain from `r.params`; otherwise RN would run a - // follow-up NAVIGATE from it and override the `state` we splice below. - const sanitizedRoute = withSanitizedDeepLinkParams(r, focusedTargetTab.params as Record | undefined); return { - ...sanitizedRoute, + ...r, + ...(focusedTargetTab.params !== undefined ? {params: focusedTargetTab.params} : {}), ...(mergedNestedState !== undefined ? {state: mergedNestedState as typeof r.state} : {}), }; }); @@ -577,6 +542,4 @@ export { handleToggleSidePanelWithHistoryAction, getPreInsertedOriginalTabRoute, clearPreInsertedOriginalTabRoute, - // Exported for unit-test access; not used outside of testing. - withSanitizedDeepLinkParams, }; diff --git a/src/libs/Navigation/AppNavigator/routerExtensions/addRootHistoryRouterExtension.ts b/src/libs/Navigation/AppNavigator/routerExtensions/addRootHistoryRouterExtension.ts index 4b34589ad849..a51336d7a00a 100644 --- a/src/libs/Navigation/AppNavigator/routerExtensions/addRootHistoryRouterExtension.ts +++ b/src/libs/Navigation/AppNavigator/routerExtensions/addRootHistoryRouterExtension.ts @@ -1,31 +1,37 @@ -import type {ParamListBase, PartialState, Router, RouterConfigOptions} from '@react-navigation/native'; -import Log from '@libs/Log'; -import type {RootStackNavigatorAction} from '@libs/Navigation/AppNavigator/createRootStackNavigator/types'; +import type {CommonActions, ParamListBase, PartialState, Router, RouterConfigOptions, StackActionType} from '@react-navigation/native'; +import type {RemoveFullscreenUnderRHPActionType, ReplaceFullscreenUnderRHPActionType, RootStackNavigatorAction} from '@libs/Navigation/AppNavigator/createRootStackNavigator/types'; import type {PlatformStackNavigationState, PlatformStackRouterFactory, PlatformStackRouterOptions} from '@libs/Navigation/PlatformStackNavigation/types'; import CONST from '@src/CONST'; -import { - applyRevealPaddingOffset, - getFrozenHistoryStateForRemoveFullscreenUnderRHP, - getFrozenHistoryStateForReplaceFullscreenUnderRHP, - getRevealDismissState, - isDismissModalAction, - isRemoveFullscreenUnderRHPAction, - isReplaceFullscreenUnderRHPAction, -} from './addRootHistoryRouterExtensionUtils'; -import type {PendingReveal, RootHistoryState} from './addRootHistoryRouterExtensionUtils'; import {enhanceStateWithHistory} from './utils'; -/** Manages root `state.history` for side-panel + reveal flows; per-branch rationale inline. */ +function isReplaceFullscreenUnderRHPAction(action: RootStackNavigatorAction): action is ReplaceFullscreenUnderRHPActionType { + return action.type === CONST.NAVIGATION.ACTION_TYPE.REPLACE_FULLSCREEN_UNDER_RHP; +} + +function isRemoveFullscreenUnderRHPAction(action: RootStackNavigatorAction): action is RemoveFullscreenUnderRHPActionType { + return action.type === CONST.NAVIGATION.ACTION_TYPE.REMOVE_FULLSCREEN_UNDER_RHP; +} + +/** + * Higher-order function that extends a React Navigation stack router with history + * management for the root stack navigator. + * + * It maintains a `history` array mirroring the routes and handles two concerns: + * + * 1. **Side panel** – preserves the CUSTOM_HISTORY_ENTRY_SIDE_PANEL entry through + * rehydration so the side panel open/close state survives navigation state rebuilds. + * + * 2. **REPLACE/REMOVE_FULLSCREEN_UNDER_RHP** - freezes the history array for these + * actions so that useLinking sees historyDelta=0 and does NOT push/pop any browser + * history entries for these intermediate state changes. The correct browser history + * update happens later when DISMISS_MODAL pops the RHP in the next animation frame. + */ function addRootHistoryRouterExtension( originalRouter: PlatformStackRouterFactory, ) { - return (options: RouterOptions): Router, RootStackNavigatorAction> => { + return (options: RouterOptions): Router, CommonActions.Action | StackActionType> => { const router = originalRouter(options); - // RHP snapshot taken on REPLACE; matching DISMISS must equal all three fields (key, - // routes depth, history depth) to commit the reveal freeze. - let pendingReveal: PendingReveal | null = null; - const getInitialState = (configOptions: RouterConfigOptions) => { const state = router.getInitialState(configOptions); return enhanceStateWithHistory(state); @@ -35,7 +41,6 @@ function addRootHistoryRouterExtension | RootHistoryState, configOptions: RouterConfigOptions) { - return getRehydratedState(newState as PartialState, configOptions); - } - - const getStateForAction = (state: RootHistoryState, action: RootStackNavigatorAction, configOptions: RouterConfigOptions) => { - // Snapshot is stale if its RHP key vanished via a non-DISMISS path. - if (pendingReveal && !state.routes.some((r) => r.key === pendingReveal?.rhpKey)) { - Log.hmmm('[addRootHistoryRouterExtension] pending reveal RHP no longer in routes; clearing snapshot', {pendingReveal}); - pendingReveal = null; - } - + const getStateForAction = (state: PlatformStackNavigationState, action: CommonActions.Action | StackActionType, configOptions: RouterConfigOptions) => { const newState = router.getStateForAction(state, action, configOptions); if (!newState) { return null; } - // REPLACE: capture pending reveal + freeze history (intermediate frame; useLinking historyDelta=0). - if (isReplaceFullscreenUnderRHPAction(action)) { - const result = getFrozenHistoryStateForReplaceFullscreenUnderRHP(state, newState, configOptions, pendingReveal, rehydrate); - pendingReveal = result.pendingReveal; - return result.state; - } - - // REMOVE: cancel path; clear snapshot + freeze history (same rationale as REPLACE). - if (isRemoveFullscreenUnderRHPAction(action)) { - const result = getFrozenHistoryStateForRemoveFullscreenUnderRHP(state, newState, configOptions, rehydrate); - if (state.history) { - pendingReveal = null; - } - return result; - } - - // DISMISS that completes the reveal: pad new history to pre-DISMISS length so - // useLinking sees historyDelta=0 and just `history.replace`s the current entry, - // preserving the prior fullscreen browser entry. (RN 7.x useLinking semantics.) - if (isDismissModalAction(action) && pendingReveal && state.history) { - const result = getRevealDismissState(state, newState, configOptions, pendingReveal, rehydrate); - pendingReveal = result.pendingReveal; - if (result.state) { - return result.state; - } + // For REPLACE/REMOVE_FULLSCREEN_UNDER_RHP we intentionally preserve the original + // history array so that useLinking sees historyDelta=0 and does NOT push/pop any + // browser history entries for these intermediate state changes. + if ((isReplaceFullscreenUnderRHPAction(action) || isRemoveFullscreenUnderRHPAction(action)) && state.history) { + // @ts-expect-error newState can be partial but getRehydratedState handles it correctly. + const rehydrated = getRehydratedState(newState, configOptions); + return {...rehydrated, history: state.history}; } - // Default: re-apply the offset (single source of truth = leading sentinels in - // state.history). addPushParamsRouterExtension keeps all string entries, so - // reveal-padding sentinels survive PUSH_PARAMS / GO_BACK / POP / RESET dispatches. - const rehydrated = rehydrate(newState, configOptions); - return applyRevealPaddingOffset(state, rehydrated); + // @ts-expect-error newState may be partial, but getRehydratedState handles both partial and full states correctly. + return getRehydratedState(newState, configOptions); }; return { diff --git a/src/libs/Navigation/AppNavigator/routerExtensions/addRootHistoryRouterExtensionUtils.ts b/src/libs/Navigation/AppNavigator/routerExtensions/addRootHistoryRouterExtensionUtils.ts deleted file mode 100644 index f7e3b7a2099e..000000000000 --- a/src/libs/Navigation/AppNavigator/routerExtensions/addRootHistoryRouterExtensionUtils.ts +++ /dev/null @@ -1,169 +0,0 @@ -import type {ParamListBase, PartialState, RouterConfigOptions} from '@react-navigation/native'; -import Log from '@libs/Log'; -import type { - DismissModalActionType, - RemoveFullscreenUnderRHPActionType, - ReplaceFullscreenUnderRHPActionType, - RootStackNavigatorAction, -} from '@libs/Navigation/AppNavigator/createRootStackNavigator/types'; -import type {PlatformStackNavigationState} from '@libs/Navigation/PlatformStackNavigation/types'; -import CONST from '@src/CONST'; -import NAVIGATORS from '@src/NAVIGATORS'; -import type {CustomHistoryEntry} from './types'; - -type RootHistoryState = PlatformStackNavigationState; -type PendingReveal = {rhpKey: string; routesLengthAtCapture: number; historyLengthAtCapture: number}; -type RehydrateRootHistoryState = (newState: PartialState | RootHistoryState, configOptions: RouterConfigOptions) => RootHistoryState; - -function isReplaceFullscreenUnderRHPAction(action: RootStackNavigatorAction): action is ReplaceFullscreenUnderRHPActionType { - return action.type === CONST.NAVIGATION.ACTION_TYPE.REPLACE_FULLSCREEN_UNDER_RHP; -} - -function isRemoveFullscreenUnderRHPAction(action: RootStackNavigatorAction): action is RemoveFullscreenUnderRHPActionType { - return action.type === CONST.NAVIGATION.ACTION_TYPE.REMOVE_FULLSCREEN_UNDER_RHP; -} - -function isDismissModalAction(action: RootStackNavigatorAction): action is DismissModalActionType { - return action.type === CONST.NAVIGATION.ACTION_TYPE.DISMISS_MODAL; -} - -function isRightModalNavigatorRouteName(name: string | undefined): boolean { - return name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR; -} - -/** Centralizes the cast from PlatformStackNavigationState's `unknown[]` history slot to our typed array. */ -function asCustomHistory(history: unknown[] | undefined): CustomHistoryEntry[] | undefined { - return history as CustomHistoryEntry[] | undefined; -} - -/** Counts the leading `CUSTOM_HISTORY_ENTRY_REVEAL_PADDING` sentinels in a history array. */ -function countLeadingRevealPadding(history: CustomHistoryEntry[] | undefined): number { - if (!history?.length) { - return 0; - } - let count = 0; - // Count how many fake history slots we previously added to keep browser history aligned. - for (const entry of history) { - if (entry === CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_REVEAL_PADDING) { - count += 1; - } else { - break; - } - } - return count; -} - -/** Returns a fresh history array with `offset` reveal-padding sentinels prepended. */ -function buildPaddedHistory(baseHistory: CustomHistoryEntry[], offset: number): CustomHistoryEntry[] { - if (offset <= 0) { - return [...baseHistory]; - } - const padding = new Array(offset).fill(CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_REVEAL_PADDING); - return [...padding, ...baseHistory]; -} - -function getFrozenHistoryStateForReplaceFullscreenUnderRHP( - state: RootHistoryState, - newState: PartialState | RootHistoryState, - configOptions: RouterConfigOptions, - pendingReveal: PendingReveal | null, - rehydrate: RehydrateRootHistoryState, -): {pendingReveal: PendingReveal | null; state: RootHistoryState} { - if (!state.history) { - Log.hmmm('[addRootHistoryRouterExtension] REPLACE_FULLSCREEN_UNDER_RHP arrived with undefined state.history; reveal will not freeze'); - return {pendingReveal, state: rehydrate(newState, configOptions)}; - } - - // Capture the RHP that should be dismissed in the second half of the reveal. - const topRoute = state.routes.at(-1); - const postReplaceRoutesLength = (newState.routes ?? state.routes).length; - const nextPendingReveal = - topRoute && isRightModalNavigatorRouteName(topRoute.name) - ? { - rhpKey: topRoute.key, - routesLengthAtCapture: postReplaceRoutesLength, - historyLengthAtCapture: state.history.length, - } - : pendingReveal; - - // Use the new routes but freeze old history for this hidden intermediate frame. - // Defensive copy of state.history (shared reference; freeze must not alias). - return {pendingReveal: nextPendingReveal, state: {...rehydrate(newState, configOptions), history: [...state.history]}}; -} - -function getFrozenHistoryStateForRemoveFullscreenUnderRHP( - state: RootHistoryState, - newState: PartialState | RootHistoryState, - configOptions: RouterConfigOptions, - rehydrate: RehydrateRootHistoryState, -): RootHistoryState { - if (!state.history) { - Log.hmmm('[addRootHistoryRouterExtension] REMOVE_FULLSCREEN_UNDER_RHP arrived with undefined state.history'); - return rehydrate(newState, configOptions); - } - - // Cancel path: remove the pre-inserted screen while keeping browser history still. - return {...rehydrate(newState, configOptions), history: [...state.history]}; -} - -function getRevealDismissState( - state: RootHistoryState, - newState: PartialState | RootHistoryState, - configOptions: RouterConfigOptions, - pendingReveal: PendingReveal, - rehydrate: RehydrateRootHistoryState, -): {pendingReveal: PendingReveal | null; state?: RootHistoryState} { - // This is the stack before closing the RHP; the top route is the one DISMISS removes. - const dismissingTopKey = state.routes.at(-1)?.key; - const depthMatches = state.routes.length === pendingReveal.routesLengthAtCapture; - const historyDepthMatches = state.history?.length === pendingReveal.historyLengthAtCapture; - - // Apply the reveal fix only when this DISMISS closes the same RHP we snapshotted. - if (dismissingTopKey === pendingReveal.rhpKey && depthMatches && historyDepthMatches) { - const rehydrated = rehydrate(newState, configOptions); - const rehydratedHistory = asCustomHistory(rehydrated.history) ?? []; - // rehydratedHistory already includes any trailing SIDE_PANEL sentinel, - // so it does not inflate the computed offset. - const lengthDelta = (state.history?.length ?? 0) - rehydratedHistory.length; - if (lengthDelta > 0) { - Log.hmmm(`[addRootHistoryRouterExtension] reveal committed; freezing history with offset ${lengthDelta}`); - // Keep the pre-dismiss history length so web linking replaces instead of going back. - return {pendingReveal: null, state: {...rehydrated, history: buildPaddedHistory(rehydratedHistory, lengthDelta)}}; - } - Log.hmmm('[addRootHistoryRouterExtension] reveal committed with non-positive lengthDelta; no freeze', {lengthDelta}); - return {pendingReveal: null}; - } - - if (dismissingTopKey === pendingReveal.rhpKey) { - // Same RHP, but the stack/history changed unexpectedly; skip the special reveal fix. - Log.hmmm('[addRootHistoryRouterExtension] reveal snapshot key matched but depth diverged; clearing without freeze', { - capturedRoutesLength: pendingReveal.routesLengthAtCapture, - currentRoutesLength: state.routes.length, - capturedHistoryLength: pendingReveal.historyLengthAtCapture, - currentHistoryLength: state.history?.length, - }); - return {pendingReveal: null}; - } - - return {pendingReveal}; -} - -function applyRevealPaddingOffset(state: RootHistoryState, rehydrated: RootHistoryState): RootHistoryState { - // Regular navigation rebuilds history from routes; put back any fake slots already in use. - const offset = countLeadingRevealPadding(asCustomHistory(state.history)); - if (offset > 0) { - return {...rehydrated, history: buildPaddedHistory(asCustomHistory(rehydrated.history) ?? [], offset)}; - } - return rehydrated; -} - -export type {PendingReveal, RehydrateRootHistoryState, RootHistoryState}; -export { - applyRevealPaddingOffset, - getFrozenHistoryStateForRemoveFullscreenUnderRHP, - getFrozenHistoryStateForReplaceFullscreenUnderRHP, - getRevealDismissState, - isDismissModalAction, - isRemoveFullscreenUnderRHPAction, - isReplaceFullscreenUnderRHPAction, -}; diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index 4c1378eb2227..4428aca85df0 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -1007,11 +1007,9 @@ function revealRouteBeforeDismissingModal(route: Route, options?: {afterTransiti }); // Nested rAF: the first frame commits the route insertion, the second // frame starts the dismiss. This ensures React processes the two dispatches - // in separate renders so the dismiss animation is preserved. On narrow, - // wait for the hidden destination transition first so the RHP slides out - // over the final page instead of briefly revealing the previous page. + // in separate renders so the dismiss animation is preserved. requestAnimationFrame(() => { - dismissModal({afterTransition: options?.afterTransition, waitForTransition: getIsNarrowLayout()}); + dismissModal({afterTransition: options?.afterTransition}); }); }); } diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts index e8d8d9c49ccf..a9bc43b27ae9 100644 --- a/src/libs/actions/App.ts +++ b/src/libs/actions/App.ts @@ -12,7 +12,6 @@ import clearWorkboxRecoveryCaches from '@libs/clearWorkboxRecoveryCaches'; import DateUtils from '@libs/DateUtils'; import Log from '@libs/Log'; import getCurrentUrl from '@libs/Navigation/currentUrl'; -import willRouteNavigateToRHP from '@libs/Navigation/helpers/willRouteNavigateToRHP'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import isTrackOnboardingChoice from '@libs/OnboardingUtils'; import {isPublicRoom, isValidReport} from '@libs/ReportUtils'; @@ -634,16 +633,9 @@ function createWorkspaceWithPolicyDraftAndNavigateToIt(params: CreateWorkspaceWi if (transitionFromOldDot) { Navigation.navigate(routeToNavigate); } else if (Navigation.isTopmostRouteModalScreen()) { - // `revealRouteBeforeDismissingModal` only works for fullscreen targets. Modal targets - // (e.g. workspace confirmation success) still need to open after the current RHP closes. - if (willRouteNavigateToRHP(routeToNavigate)) { - Navigation.dismissModal({ - afterTransition: () => Navigation.navigate(routeToNavigate), - }); - return; - } - - Navigation.revealRouteBeforeDismissingModal(routeToNavigate); + Navigation.dismissModal({ + afterTransition: () => Navigation.navigate(routeToNavigate), + }); } else { Navigation.navigate(routeToNavigate, {forceReplace: true}); } diff --git a/tests/unit/Navigation/createRootStackNavigator/withSanitizedDeepLinkParams.test.ts b/tests/unit/Navigation/createRootStackNavigator/withSanitizedDeepLinkParams.test.ts deleted file mode 100644 index 2c4d08714380..000000000000 --- a/tests/unit/Navigation/createRootStackNavigator/withSanitizedDeepLinkParams.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import {withSanitizedDeepLinkParams} from '@libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers'; - -describe('withSanitizedDeepLinkParams', () => { - describe('deep-link chain detection (gated on `params.screen` presence)', () => { - it('strips the full RN initial-state chain when `params.screen` is set, preserving non-chain keys', () => { - const route = { - key: 'k-1', - name: 'WorkspaceSplit', - params: { - screen: 'WorkspaceOverview', - params: {nested: true}, - path: '/workspace/123', - initial: false, - state: {routes: [{name: 'X'}]}, - policyID: 'p1', - customFlag: 'preserve-me', - }, - }; - - const result = withSanitizedDeepLinkParams(route, undefined); - - expect(result.params).toEqual({policyID: 'p1', customFlag: 'preserve-me'}); - expect('params' in result).toBe(true); - }); - - it('OMITS `params` from the result entirely when `params.screen` is set AND every key is part of the chain', () => { - // Identity / shape regression: previously this case silently set `params: undefined`, - // flipping `'params' in result` from false to true downstream. New behaviour is to - // remove the property entirely. - const route = { - key: 'k-1', - name: 'WorkspaceSplit', - params: { - screen: 'WorkspaceOverview', - initial: false, - path: '/workspace/123', - }, - }; - - const result = withSanitizedDeepLinkParams(route, undefined); - - expect('params' in result).toBe(false); - expect(result.params).toBeUndefined(); - }); - - it('PRESERVES `params` as-is when `params.screen` is absent, even if other "chain-like" keys are present', () => { - // False-positive defense: legitimate user-set `path`, `initial`, etc. on a route - // that was NOT created by deep-link hydration must survive untouched. - const route = { - key: 'k-1', - name: 'CustomScreen', - params: { - path: 'user/data', // user data, not a deep-link path hint - initial: true, // user data, not RN's initial flag - customKey: 'preserve-me', - }, - }; - - const result = withSanitizedDeepLinkParams(route, undefined); - - expect(result.params).toEqual(route.params); - expect('params' in result).toBe(true); - }); - - it('PRESERVES `params` containing the legitimate user key `pop` when no `screen` is set', () => { - // Specific regression: the prior `STALE_DEEP_LINK_PARAM_KEYS` included `'pop'`, - // which is too generic - some screens may use `pop` as a real param name. The - // shape-detection (gated on `screen`) means `pop` is now safely preserved here. - const route = { - key: 'k-1', - name: 'AnimatedScreen', - params: {pop: true, foo: 1}, - }; - - const result = withSanitizedDeepLinkParams(route, undefined); - - expect(result.params).toEqual({pop: true, foo: 1}); - }); - }); - - describe('focus-supplied params take precedence', () => { - it('writes `focusParams` even when no deep-link chain is present on the route', () => { - const route = { - key: 'k-1', - name: 'CustomScreen', - params: {existing: 'value'}, - }; - - const result = withSanitizedDeepLinkParams(route, {newKey: 'newValue'}); - - expect(result.params).toEqual({newKey: 'newValue'}); - }); - - it('writes `focusParams` and ignores the existing chain when both are present', () => { - const route = { - key: 'k-1', - name: 'CustomScreen', - params: {screen: 'X', initial: false, custom: 'preserve'}, - }; - - const result = withSanitizedDeepLinkParams(route, {policyID: 'p1'}); - - expect(result.params).toEqual({policyID: 'p1'}); - }); - }); - - describe('identity-preservation when no rewrite is required', () => { - it('does not introduce a `params` property when both `r.params` and `focusParams` are absent', () => { - const route: {key: string; name: string; params?: Record} = {key: 'k-1', name: 'CustomScreen'}; - - const result = withSanitizedDeepLinkParams(route, undefined); - - expect('params' in result).toBe(false); - expect(result.params).toBeUndefined(); - }); - - it('does not introduce a `params: undefined` shape when chain is present but missing the `screen` trigger', () => { - // Edge: `path` and `initial` set without `screen` is NOT a deep-link hint per RN, - // so no stripping happens; existing params survive verbatim. - const route = { - key: 'k-1', - name: 'CustomScreen', - params: {path: '/foo', initial: true}, - }; - - const result = withSanitizedDeepLinkParams(route, undefined); - - expect(result.params).toEqual({path: '/foo', initial: true}); - expect('params' in result).toBe(true); - }); - }); -}); diff --git a/tests/unit/Navigation/routerExtensions/addRootHistoryRouterExtension.test.ts b/tests/unit/Navigation/routerExtensions/addRootHistoryRouterExtension.test.ts index 19c2173abe7b..d09f66109b36 100644 --- a/tests/unit/Navigation/routerExtensions/addRootHistoryRouterExtension.test.ts +++ b/tests/unit/Navigation/routerExtensions/addRootHistoryRouterExtension.test.ts @@ -1,30 +1,15 @@ -import type {NavigationRoute, ParamListBase, PartialState, Router, RouterConfigOptions, StackNavigationState} from '@react-navigation/native'; -import type {RootStackNavigatorAction} from '@libs/Navigation/AppNavigator/createRootStackNavigator/types'; +import type {CommonActions, NavigationRoute, ParamListBase, PartialState, Router, RouterConfigOptions, StackActionType, StackNavigationState} from '@react-navigation/native'; import addRootHistoryRouterExtension from '@libs/Navigation/AppNavigator/routerExtensions/addRootHistoryRouterExtension'; import type {CustomHistoryEntry} from '@libs/Navigation/AppNavigator/routerExtensions/types'; import type {PlatformStackRouterOptions} from '@libs/Navigation/PlatformStackNavigation/types'; import CONST from '@src/CONST'; -import NAVIGATORS from '@src/NAVIGATORS'; -import type {Route} from '@src/ROUTES'; type TestState = StackNavigationState & {history?: CustomHistoryEntry[]}; const SIDE_PANEL = CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_SIDE_PANEL; -const REVEAL_PADDING = CONST.NAVIGATION.CUSTOM_HISTORY_ENTRY_REVEAL_PADDING; -const REPLACE_FULLSCREEN_UNDER_RHP = CONST.NAVIGATION.ACTION_TYPE.REPLACE_FULLSCREEN_UNDER_RHP; -const REMOVE_FULLSCREEN_UNDER_RHP = CONST.NAVIGATION.ACTION_TYPE.REMOVE_FULLSCREEN_UNDER_RHP; -const DISMISS_MODAL = CONST.NAVIGATION.ACTION_TYPE.DISMISS_MODAL; - -// Per-describe counter; assigned in `beforeEach` so each test deterministically gets -// `prefix-1`, `prefix-2`, ... regardless of test ordering or filtering. -let testKeyCounter = 0; -function nextTestKey(prefix: string): string { - testKeyCounter += 1; - return `${prefix}-${testKeyCounter}`; -} -function makeRoute(name: string, key?: string, params?: Record): NavigationRoute { - return {key: key ?? nextTestKey(name), name, params} as NavigationRoute; +function makeRoute(name: string, key: string, params?: Record): NavigationRoute { + return {key, name, params} as NavigationRoute; } function makeState(routes: Array>, overrides?: Partial): TestState { @@ -46,9 +31,11 @@ const CONFIG_OPTIONS: RouterConfigOptions = { routeGetIdList: {}, }; -function createMockRouterFactory(actionHandler?: (state: TestState, action: RootStackNavigatorAction) => TestState | null) { +type StackRouterAction = CommonActions.Action | StackActionType; + +function createMockRouterFactory(actionHandler?: (state: TestState, action: StackRouterAction) => TestState | null) { const mockRouterFactory = jest.fn((routerOptions: PlatformStackRouterOptions) => { - const baseRouter: Router = { + const baseRouter: Router = { type: 'stack', getInitialState(configOptions: RouterConfigOptions): TestState { @@ -75,14 +62,14 @@ function createMockRouterFactory(actionHandler?: (state: TestState, action: Root return state; }, - getStateForAction(state: TestState, action: RootStackNavigatorAction): TestState | null { + getStateForAction(state: TestState, action: StackRouterAction): TestState | null { if (actionHandler) { return actionHandler(state, action); } if (action.type === 'NAVIGATE') { const payload = action.payload as {name: string; params?: Record}; - const newRoute = makeRoute(payload.name, nextTestKey(payload.name), payload.params); + const newRoute = makeRoute(payload.name, `${payload.name}-key-${Date.now()}`, payload.params); return makeState([...state.routes, newRoute]); } @@ -104,40 +91,7 @@ function asRouteEntry(entry: CustomHistoryEntry): NavigationRoute; } -// Typed action factories. The extension only inspects `action.type` (and snapshots the top -// route's key on REPLACE), so the payload shape is a no-op for these tests; we use the -// minimum-shape `Route` placeholder ({name: '/'}) to satisfy the production action contract -// without forcing test code to fabricate full route objects. -const PLACEHOLDER_ROUTE = '/' as Route; -const makeReplaceAction = (): RootStackNavigatorAction => ({type: REPLACE_FULLSCREEN_UNDER_RHP, payload: {route: PLACEHOLDER_ROUTE}}); -const makeRemoveAction = (): RootStackNavigatorAction => ({type: REMOVE_FULLSCREEN_UNDER_RHP, payload: {expectedRouteName: 'X'}}); -const makeDismissAction = (): RootStackNavigatorAction => ({type: DISMISS_MODAL}); -const makeNavigateAction = (name: string): RootStackNavigatorAction => ({type: 'NAVIGATE', payload: {name}}); - -// Tiny mirror of `countLeadingRevealPadding` from production. We don't import the production -// helper because it lives inside the extension's module closure and is not exported; this -// re-implementation is identical by inspection. If the production sentinel ever changes, -// `REVEAL_PADDING` (which we DO import) is the single point of update for both copies. -function countLeadingPadding(history: CustomHistoryEntry[] | unknown[] | undefined): number { - if (!history) { - return 0; - } - let count = 0; - for (const entry of history) { - if (entry === REVEAL_PADDING) { - count += 1; - } else { - break; - } - } - return count; -} - describe('addRootHistoryRouterExtension', () => { - beforeEach(() => { - testKeyCounter = 0; - }); - it('getInitialState attaches a history array mirroring routes', () => { const factory = createMockRouterFactory(); const enhancedRouter = addRootHistoryRouterExtension(factory)({} as PlatformStackRouterOptions); @@ -212,7 +166,7 @@ describe('addRootHistoryRouterExtension', () => { const initialState = enhancedRouter.getInitialState(CONFIG_OPTIONS); - const navigateAction: RootStackNavigatorAction = { + const navigateAction: StackRouterAction = { type: 'NAVIGATE', payload: {name: 'ScreenB'}, }; @@ -228,7 +182,7 @@ describe('addRootHistoryRouterExtension', () => { } }); - it('getStateForAction preserves original history shape for REPLACE_FULLSCREEN_UNDER_RHP so useLinking sees historyDelta=0', () => { + it('getStateForAction preserves original history for REPLACE_FULLSCREEN_UNDER_RHP so useLinking sees historyDelta=0', () => { const routeA = makeRoute('ScreenA', 'a-1'); const routeB = makeRoute('ScreenB', 'b-1'); const routeC = makeRoute('ScreenC', 'c-1'); @@ -249,12 +203,14 @@ describe('addRootHistoryRouterExtension', () => { const state = makeState([routeA, routeB, routeC], {history: initialHistory}); - const newState = enhancedRouter.getStateForAction(state, makeReplaceAction(), CONFIG_OPTIONS); + const replaceAction: StackRouterAction = { + type: CONST.NAVIGATION.ACTION_TYPE.REPLACE_FULLSCREEN_UNDER_RHP, + } as unknown as StackRouterAction; + + const newState = enhancedRouter.getStateForAction(state, replaceAction, CONFIG_OPTIONS); expect(newState).not.toBeNull(); - // Defensive copy is returned (not the same reference) but content must match. - expect(newState?.history).toEqual(initialHistory); - expect(newState?.history).not.toBe(initialHistory); + expect(newState?.history).toBe(initialHistory); }); it('getStateForAction returns null when the base router returns null', () => { @@ -263,7 +219,7 @@ describe('addRootHistoryRouterExtension', () => { const initialState = enhancedRouter.getInitialState(CONFIG_OPTIONS); - const action: RootStackNavigatorAction = { + const action: StackRouterAction = { type: 'NAVIGATE', payload: {name: 'ScreenB'}, }; @@ -271,450 +227,4 @@ describe('addRootHistoryRouterExtension', () => { const result = enhancedRouter.getStateForAction(initialState, action, CONFIG_OPTIONS); expect(result).toBeNull(); }); - - describe('reveal protocol (REPLACE_FULLSCREEN_UNDER_RHP + DISMISS_MODAL)', () => { - // Helper that builds a router whose actionHandler implements the minimum shape of the - // reveal protocol: REPLACE swaps the under-modal route, DISMISS pops the top route, - // REMOVE undoes the prior REPLACE. We use `{...state, routes, index}` (instead of - // `makeState(...)`) so unknown fields like `history` are preserved through the spread, - // mirroring what RN's StackRouter does in production. - function withRoutes(state: TestState, routes: TestState['routes']): TestState { - return {...state, routes, routeNames: routes.map((r) => r.name), index: routes.length - 1}; - } - function makeRevealRouter(replacement: NavigationRoute) { - return createMockRouterFactory((state, action) => { - if (action.type === REPLACE_FULLSCREEN_UNDER_RHP) { - return withRoutes(state, [replacement, ...state.routes.slice(1)]); - } - if (action.type === REMOVE_FULLSCREEN_UNDER_RHP) { - return state; - } - if (action.type === DISMISS_MODAL) { - return withRoutes(state, state.routes.slice(0, -1)); - } - if (action.type === 'NAVIGATE') { - const payload = action.payload as {name: string; params?: Record}; - const newRoute = makeRoute(payload.name, nextTestKey(payload.name), payload.params); - return withRoutes(state, [...state.routes, newRoute]); - } - return state; - }); - } - - it('freezes history length on DISMISS_MODAL after a reveal so useLinking sees historyDelta=0', () => { - // Pre-reveal state: routes=[TabA, RHP], history reflects 3 prior browser entries - // (e.g. /home → /workspaces → opened the new-workspace RHP). - const tabA = makeRoute('TabA', 'tab-a-1'); - const tabB = makeRoute('TabB', 'tab-b-1'); - const rhp = makeRoute(NAVIGATORS.RIGHT_MODAL_NAVIGATOR, 'rhp-1'); - - const enhancedRouter = addRootHistoryRouterExtension(makeRevealRouter(tabB))({} as PlatformStackRouterOptions); - - const stateWithRHP = makeState([tabA, rhp], { - history: [ - {key: 'tab-a-1', name: 'TabA'}, - {key: 'tab-a-1', name: 'TabA'}, - {key: 'rhp-1', name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR}, - ] as CustomHistoryEntry[], - }); - - const afterReplace = enhancedRouter.getStateForAction(stateWithRHP, makeReplaceAction(), CONFIG_OPTIONS); - expect(afterReplace).not.toBeNull(); - expect(afterReplace?.history?.length).toBe(3); - - const afterDismiss = enhancedRouter.getStateForAction(afterReplace as TestState, makeDismissAction(), CONFIG_OPTIONS); - expect(afterDismiss).not.toBeNull(); - // Routes shrunk by 1 (modal popped)… - expect(afterDismiss?.routes.length).toBe(1); - // …but history.length is held at the pre-dismiss length so useLinking's historyDelta = 0. - expect(afterDismiss?.history?.length).toBe(3); - // The phantom entries are now sentinel padding at the front, NOT stale route mirrors. - expect(countLeadingPadding(afterDismiss?.history)).toBe(2); - // Trailing entries reflect the new (post-dismiss) routes. - expect(asRouteEntry(afterDismiss?.history?.at(-1) as CustomHistoryEntry).key).toBe('tab-b-1'); - }); - - it('maintains the reveal-induced length offset across subsequent inner navigations', () => { - const tabA = makeRoute('TabA', 'tab-a-1'); - const tabB = makeRoute('TabB', 'tab-b-1'); - const rhp = makeRoute(NAVIGATORS.RIGHT_MODAL_NAVIGATOR, 'rhp-1'); - - const enhancedRouter = addRootHistoryRouterExtension(makeRevealRouter(tabB))({} as PlatformStackRouterOptions); - - const stateWithRHP = makeState([tabA, rhp], { - history: [ - {key: 'tab-a-1', name: 'TabA'}, - {key: 'tab-a-1', name: 'TabA'}, - {key: 'rhp-1', name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR}, - ] as CustomHistoryEntry[], - }); - - const afterReplace = enhancedRouter.getStateForAction(stateWithRHP, makeReplaceAction(), CONFIG_OPTIONS) as TestState; - const afterDismiss = enhancedRouter.getStateForAction(afterReplace, makeDismissAction(), CONFIG_OPTIONS) as TestState; - - // Sanity: offset of 2. - expect(countLeadingPadding(afterDismiss.history)).toBe(2); - expect(afterDismiss.history?.length).toBe(3); - - // Inner NAVIGATE - routes grow by 1; offset must persist. - const afterNavigate = enhancedRouter.getStateForAction(afterDismiss, makeNavigateAction('TabC'), CONFIG_OPTIONS) as TestState; - expect(afterNavigate.routes.length).toBe(2); - expect(afterNavigate.history?.length).toBe(4); - expect(countLeadingPadding(afterNavigate.history)).toBe(2); - }); - - it('does NOT freeze when DISMISS_MODAL fires without a pending reveal', () => { - const tabA = makeRoute('TabA', 'tab-a-1'); - const rhp = makeRoute(NAVIGATORS.RIGHT_MODAL_NAVIGATOR, 'rhp-1'); - - const factory = createMockRouterFactory((state, action) => { - if (action.type === DISMISS_MODAL) { - return makeState(state.routes.slice(0, -1)); - } - return state; - }); - const enhancedRouter = addRootHistoryRouterExtension(factory)({} as PlatformStackRouterOptions); - - const stateWithRHP = makeState([tabA, rhp], { - history: [ - {key: 'tab-a-1', name: 'TabA'}, - {key: 'rhp-1', name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR}, - ] as CustomHistoryEntry[], - }); - - const afterDismiss = enhancedRouter.getStateForAction(stateWithRHP, makeDismissAction(), CONFIG_OPTIONS) as TestState; - - expect(afterDismiss.routes.length).toBe(1); - expect(afterDismiss.history?.length).toBe(1); - expect(countLeadingPadding(afterDismiss.history)).toBe(0); - }); - - it('cancels cleanly on REMOVE_FULLSCREEN_UNDER_RHP - no offset is left behind', () => { - // Reveal protocol's "abort" path: REPLACE happens, then REMOVE rolls it back, no - // commit. After a subsequent normal modal dismissal we must NOT freeze, NOT pad. - const tabA = makeRoute('TabA', 'tab-a-1'); - const tabB = makeRoute('TabB', 'tab-b-1'); - const rhp = makeRoute(NAVIGATORS.RIGHT_MODAL_NAVIGATOR, 'rhp-1'); - - const factory = createMockRouterFactory((state, action) => { - if (action.type === REPLACE_FULLSCREEN_UNDER_RHP) { - return makeState([tabB, ...state.routes.slice(1)]); - } - if (action.type === REMOVE_FULLSCREEN_UNDER_RHP) { - return makeState([tabA, ...state.routes.slice(1)]); - } - if (action.type === DISMISS_MODAL) { - return makeState(state.routes.slice(0, -1)); - } - return state; - }); - const enhancedRouter = addRootHistoryRouterExtension(factory)({} as PlatformStackRouterOptions); - - const stateWithRHP = makeState([tabA, rhp], { - history: [ - {key: 'tab-a-1', name: 'TabA'}, - {key: 'rhp-1', name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR}, - ] as CustomHistoryEntry[], - }); - - const afterReplace = enhancedRouter.getStateForAction(stateWithRHP, makeReplaceAction(), CONFIG_OPTIONS) as TestState; - const afterRemove = enhancedRouter.getStateForAction(afterReplace, makeRemoveAction(), CONFIG_OPTIONS) as TestState; - - // REMOVE freezes too (same intermediate-frame rationale as REPLACE). - expect(afterRemove.history?.length).toBe(2); - expect(countLeadingPadding(afterRemove.history)).toBe(0); - - // Now dismiss the modal normally - must NOT engage the freeze logic. - const afterDismiss = enhancedRouter.getStateForAction(afterRemove, makeDismissAction(), CONFIG_OPTIONS) as TestState; - expect(afterDismiss.routes.length).toBe(1); - expect(afterDismiss.history?.length).toBe(1); - expect(countLeadingPadding(afterDismiss.history)).toBe(0); - }); - - it('handles two consecutive reveal sequences - offset accumulates correctly via leading sentinels', () => { - const tabA = makeRoute('TabA', 'tab-a-1'); - const tabB = makeRoute('TabB', 'tab-b-1'); - const tabC = makeRoute('TabC', 'tab-c-1'); - const rhp1 = makeRoute(NAVIGATORS.RIGHT_MODAL_NAVIGATOR, 'rhp-1'); - const rhp2 = makeRoute(NAVIGATORS.RIGHT_MODAL_NAVIGATOR, 'rhp-2'); - - // Keyed replacements per call so test handler can pick the right one. - let replaceTarget: NavigationRoute = tabB; - - const factory = createMockRouterFactory((state, action) => { - if (action.type === REPLACE_FULLSCREEN_UNDER_RHP) { - return makeState([replaceTarget, ...state.routes.slice(1)]); - } - if (action.type === DISMISS_MODAL) { - return makeState(state.routes.slice(0, -1)); - } - return state; - }); - const enhancedRouter = addRootHistoryRouterExtension(factory)({} as PlatformStackRouterOptions); - - // First reveal: starting state has 3 history entries. - const initialState = makeState([tabA, rhp1], { - history: [ - {key: 'tab-a-1', name: 'TabA'}, - {key: 'tab-a-1', name: 'TabA'}, - {key: 'rhp-1', name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR}, - ] as CustomHistoryEntry[], - }); - - const afterFirstReplace = enhancedRouter.getStateForAction(initialState, makeReplaceAction(), CONFIG_OPTIONS) as TestState; - const afterFirstDismiss = enhancedRouter.getStateForAction(afterFirstReplace, makeDismissAction(), CONFIG_OPTIONS) as TestState; - expect(afterFirstDismiss.history?.length).toBe(3); - expect(countLeadingPadding(afterFirstDismiss.history)).toBe(2); - - // User opens a second RHP: routes grow by 1, history grows by 1 (offset preserved). - const stateWithRhp2 = makeState([tabB, rhp2], {history: [...(afterFirstDismiss.history ?? []), {key: 'rhp-2', name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR} as CustomHistoryEntry]}); - // Second reveal: replace TabB → TabC, then dismiss rhp2. - replaceTarget = tabC; - const afterSecondReplace = enhancedRouter.getStateForAction(stateWithRhp2, makeReplaceAction(), CONFIG_OPTIONS) as TestState; - const afterSecondDismiss = enhancedRouter.getStateForAction(afterSecondReplace, makeDismissAction(), CONFIG_OPTIONS) as TestState; - - // Pre-second-dismiss history length was 4 (2 padding + tabB + rhp2). Post-dismiss - // routes length is 1 (tabC). Offset must be 4 - 1 = 3. - expect(afterSecondDismiss.routes.length).toBe(1); - expect(afterSecondDismiss.history?.length).toBe(4); - expect(countLeadingPadding(afterSecondDismiss.history)).toBe(3); - expect(asRouteEntry(afterSecondDismiss.history?.at(-1) as CustomHistoryEntry).key).toBe('tab-c-1'); - }); - - it('does NOT commit a reveal when an unrelated modal is dismissed between REPLACE and the matching DISMISS', () => { - // Regression: previously the counter was decremented based on "topRoute is not RIGHT_MODAL_NAVIGATOR", - // which was incomplete and caused leak-or-misfire. Key-based pairing is robust to non-RHP - // modal types interleaving - only the dismissal of the snapshotted RHP key commits. - const tabA = makeRoute('TabA', 'tab-a-1'); - const tabB = makeRoute('TabB', 'tab-b-1'); - const rhp = makeRoute(NAVIGATORS.RIGHT_MODAL_NAVIGATOR, 'rhp-real'); - const otherModal = makeRoute(NAVIGATORS.RIGHT_MODAL_NAVIGATOR, 'rhp-other'); - - const factory = createMockRouterFactory((state, action) => { - if (action.type === REPLACE_FULLSCREEN_UNDER_RHP) { - return makeState([tabB, ...state.routes.slice(1)]); - } - if (action.type === DISMISS_MODAL) { - return makeState(state.routes.slice(0, -1)); - } - if (action.type === 'NAVIGATE') { - const payload = action.payload as {name: string; params?: Record}; - const newRoute = payload.name === 'OTHER_MODAL' ? otherModal : makeRoute(payload.name, nextTestKey(payload.name), payload.params); - return makeState([...state.routes, newRoute]); - } - return state; - }); - const enhancedRouter = addRootHistoryRouterExtension(factory)({} as PlatformStackRouterOptions); - - const stateWithRHP = makeState([tabA, rhp], { - history: [ - {key: 'tab-a-1', name: 'TabA'}, - {key: 'rhp-real', name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR}, - ] as CustomHistoryEntry[], - }); - - const afterReplace = enhancedRouter.getStateForAction(stateWithRHP, makeReplaceAction(), CONFIG_OPTIONS) as TestState; - // Open an *unrelated* modal on top of the in-flight reveal. - const stateAfterOpeningOtherModal = enhancedRouter.getStateForAction(afterReplace, makeNavigateAction('OTHER_MODAL'), CONFIG_OPTIONS) as TestState; - expect(stateAfterOpeningOtherModal.routes.length).toBe(3); - expect(stateAfterOpeningOtherModal.routes.at(-1)?.key).toBe('rhp-other'); - - // Dismiss the OTHER modal - must NOT commit the in-flight reveal (key mismatch), - // i.e. no offset is set, history shrinks naturally. - const afterDismissOther = enhancedRouter.getStateForAction(stateAfterOpeningOtherModal, makeDismissAction(), CONFIG_OPTIONS) as TestState; - expect(afterDismissOther.routes.length).toBe(2); - expect(countLeadingPadding(afterDismissOther.history)).toBe(0); - - // Now dismiss the *real* RHP - that's the protocol's second frame; commit the reveal. - const afterDismissReal = enhancedRouter.getStateForAction(afterDismissOther, makeDismissAction(), CONFIG_OPTIONS) as TestState; - expect(afterDismissReal.routes.length).toBe(1); - expect(countLeadingPadding(afterDismissReal.history)).toBeGreaterThan(0); - }); - - it('clears the pending reveal snapshot if the snapshotted RHP disappears via some other path (sanity reset)', () => { - // If something else dismisses the RHP key we were tracking (without going through DISMISS_MODAL), - // a subsequent unrelated DISMISS_MODAL must NOT freeze. - const tabA = makeRoute('TabA', 'tab-a-1'); - const tabB = makeRoute('TabB', 'tab-b-1'); - const rhp = makeRoute(NAVIGATORS.RIGHT_MODAL_NAVIGATOR, 'rhp-1'); - const otherRhp = makeRoute(NAVIGATORS.RIGHT_MODAL_NAVIGATOR, 'rhp-2'); - - const factory = createMockRouterFactory((state, action) => { - if (action.type === REPLACE_FULLSCREEN_UNDER_RHP) { - return makeState([tabB, ...state.routes.slice(1)]); - } - if (action.type === DISMISS_MODAL) { - return makeState(state.routes.slice(0, -1)); - } - if (action.type === 'NAVIGATE') { - const payload = action.payload as {name: string}; - if (payload.name === '__POP_RHP__') { - // Simulate a non-DISMISS_MODAL action that removes the snapshotted RHP. - return makeState(state.routes.filter((r) => r.key !== 'rhp-1')); - } - if (payload.name === '__OPEN_OTHER_RHP__') { - return makeState([...state.routes, otherRhp]); - } - return state; - } - return state; - }); - const enhancedRouter = addRootHistoryRouterExtension(factory)({} as PlatformStackRouterOptions); - - const stateWithRHP = makeState([tabA, rhp], { - history: [ - {key: 'tab-a-1', name: 'TabA'}, - {key: 'rhp-1', name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR}, - ] as CustomHistoryEntry[], - }); - - // REPLACE snapshots rhp-1. - const afterReplace = enhancedRouter.getStateForAction(stateWithRHP, makeReplaceAction(), CONFIG_OPTIONS) as TestState; - // Some external action removes rhp-1 without going through DISMISS_MODAL. - const afterPop = enhancedRouter.getStateForAction(afterReplace, makeNavigateAction('__POP_RHP__'), CONFIG_OPTIONS) as TestState; - expect(afterPop.routes.find((r) => r.key === 'rhp-1')).toBeUndefined(); - - // Open a different RHP. - const afterSecondRhpOpen = enhancedRouter.getStateForAction(afterPop, makeNavigateAction('__OPEN_OTHER_RHP__'), CONFIG_OPTIONS) as TestState; - // Dismiss it. Snapshot was cleared by the sanity reset, so this must NOT freeze. - const afterSecondRhpDismiss = enhancedRouter.getStateForAction(afterSecondRhpOpen, makeDismissAction(), CONFIG_OPTIONS) as TestState; - expect(countLeadingPadding(afterSecondRhpDismiss.history)).toBe(0); - }); - - it('does NOT freeze when DISMISS_MODAL produces a non-positive lengthDelta (degenerate case)', () => { - // If routes and history are already in lockstep at REPLACE-time (no real history depth - // beyond the routes themselves), DISMISS_MODAL produces lengthDelta <= 0 and we must - // skip the freeze - no padding, history shrinks naturally. - const tabA = makeRoute('TabA', 'tab-a-1'); - const tabB = makeRoute('TabB', 'tab-b-1'); - const rhp = makeRoute(NAVIGATORS.RIGHT_MODAL_NAVIGATOR, 'rhp-1'); - - const enhancedRouter = addRootHistoryRouterExtension(makeRevealRouter(tabB))({} as PlatformStackRouterOptions); - - // History exactly mirrors routes (no real depth beyond the modal). - const stateWithRHP = makeState([tabA, rhp], { - history: [ - {key: 'tab-a-1', name: 'TabA'}, - {key: 'rhp-1', name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR}, - ] as CustomHistoryEntry[], - }); - - const afterReplace = enhancedRouter.getStateForAction(stateWithRHP, makeReplaceAction(), CONFIG_OPTIONS) as TestState; - const afterDismiss = enhancedRouter.getStateForAction(afterReplace, makeDismissAction(), CONFIG_OPTIONS) as TestState; - - // pre-dismiss state.history.length = 2; post-dismiss rehydrated.history.length = 1. - // lengthDelta = 1 → freeze with offset 1 (one sentinel padding entry). - // This validates the boundary: we still pad when delta is exactly 1. - expect(afterDismiss.routes.length).toBe(1); - expect(afterDismiss.history?.length).toBe(2); - expect(countLeadingPadding(afterDismiss.history)).toBe(1); - }); - - it('correctly accounts for trailing CUSTOM_HISTORY_ENTRY_SIDE_PANEL when computing the offset', () => { - // Side-panel sentinel is a string entry appended to history. It must NOT inflate - // the reveal offset - getRehydratedState already preserves it on the new state, so - // lengthDelta is computed against rehydrated.history.length (which includes the - // sentinel) rather than rehydrated.routes.length. - const tabA = makeRoute('TabA', 'tab-a-1'); - const tabB = makeRoute('TabB', 'tab-b-1'); - const rhp = makeRoute(NAVIGATORS.RIGHT_MODAL_NAVIGATOR, 'rhp-1'); - - const enhancedRouter = addRootHistoryRouterExtension(makeRevealRouter(tabB))({} as PlatformStackRouterOptions); - - // Pre-reveal: 2 prior entries + RHP + SIDE_PANEL → length 4. - const stateWithRHP = makeState([tabA, rhp], { - history: [{key: 'tab-a-1', name: 'TabA'}, {key: 'tab-a-1', name: 'TabA'}, {key: 'rhp-1', name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR}, SIDE_PANEL] as CustomHistoryEntry[], - }); - - const afterReplace = enhancedRouter.getStateForAction(stateWithRHP, makeReplaceAction(), CONFIG_OPTIONS) as TestState; - const afterDismiss = enhancedRouter.getStateForAction(afterReplace, makeDismissAction(), CONFIG_OPTIONS) as TestState; - - // Post-dismiss: routes=[TabB], rehydrated.history=[TabB-mirror, SIDE_PANEL] (length 2). - // lengthDelta = 4 - 2 = 2 sentinel padding entries. Final shape: - // [PAD, PAD, TabB-mirror, SIDE_PANEL] (length 4). - expect(afterDismiss.history?.length).toBe(4); - expect(countLeadingPadding(afterDismiss.history)).toBe(2); - expect(afterDismiss.history?.at(-1)).toBe(SIDE_PANEL); - }); - - it('rejects the freeze when state.history.length diverges between REPLACE and the matching DISMISS', () => { - // Defends against root-level dispatches that grow `state.history` without changing - // routes between the two reveal frames (e.g. an in-flight history-mutating action). - // If history.length does not match the captured snapshot, the freeze is rejected to - // avoid producing a wrong offset; DISMISS proceeds with the natural shrink. - const tabA = makeRoute('TabA', 'tab-a-1'); - const tabB = makeRoute('TabB', 'tab-b-1'); - const rhp = makeRoute(NAVIGATORS.RIGHT_MODAL_NAVIGATOR, 'rhp-1'); - - const enhancedRouter = addRootHistoryRouterExtension(makeRevealRouter(tabB))({} as PlatformStackRouterOptions); - - const stateWithRHP = makeState([tabA, rhp], { - history: [ - {key: 'tab-a-1', name: 'TabA'}, - {key: 'tab-a-1', name: 'TabA'}, - {key: 'rhp-1', name: NAVIGATORS.RIGHT_MODAL_NAVIGATOR}, - ] as CustomHistoryEntry[], - }); - - const afterReplace = enhancedRouter.getStateForAction(stateWithRHP, makeReplaceAction(), CONFIG_OPTIONS) as TestState; - - // Simulate an out-of-band history mutation: append an extra route entry without - // changing routes. Pre-DISMISS state.history.length is now 4 instead of the - // captured 3. - const tamperedState: TestState = { - ...afterReplace, - history: [...(afterReplace.history ?? []), {key: 'rogue-1', name: 'Rogue'} as unknown as CustomHistoryEntry], - }; - - const afterDismiss = enhancedRouter.getStateForAction(tamperedState, makeDismissAction(), CONFIG_OPTIONS) as TestState; - - // Freeze is rejected (history depth mismatch); routes shrink naturally, - // history is rebuilt from routes (no padding sentinels). - expect(afterDismiss.routes.length).toBe(1); - expect(countLeadingPadding(afterDismiss.history)).toBe(0); - }); - - it('lets a fresh state install via getRehydratedState shed leading reveal-padding sentinels', () => { - // resetRoot / popstate scenarios install a new state via getRehydratedState. If the - // installed partial state does NOT carry padding sentinels in its history, the - // resulting state's history is rebuilt from routes by enhanceStateWithHistory and - // the offset dies naturally - this is the documented contract. - const factory = createMockRouterFactory(); - const enhancedRouter = addRootHistoryRouterExtension(factory)({} as PlatformStackRouterOptions); - - const partialState = { - routes: [{name: 'ScreenA', key: 'a-1'}], - stale: true as const, - // partial state arrives WITHOUT padding sentinels (i.e., a fresh resetRoot). - }; - - const rehydrated = enhancedRouter.getRehydratedState(partialState as PartialState, CONFIG_OPTIONS); - - // No padding survived rehydration - the history mirrors the routes only. - expect(countLeadingPadding(rehydrated.history)).toBe(0); - expect(rehydrated.history?.length).toBe(1); - }); - - it('drops leading reveal-padding sentinels when a partial state carries them through getRehydratedState', () => { - // Symmetric to the previous test: even if a resetRoot installs state whose history - // includes leading reveal-padding sentinels (e.g. a captured snapshot), - // enhanceStateWithHistory rebuilds history from routes and drops that offset at - // the rehydration boundary. - const factory = createMockRouterFactory(); - const enhancedRouter = addRootHistoryRouterExtension(factory)({} as PlatformStackRouterOptions); - - const partialState = { - routes: [{name: 'ScreenA', key: 'a-1'}], - stale: true as const, - history: [REVEAL_PADDING, REVEAL_PADDING, {key: 'a-1', name: 'ScreenA'}] as CustomHistoryEntry[], - }; - - const rehydrated = enhancedRouter.getRehydratedState(partialState as PartialState, CONFIG_OPTIONS); - - // enhanceStateWithHistory rebuilds history from routes by default, so leading - // padding is dropped at the rehydration boundary - this is the conservative - // outcome (offset dies on resetRoot, browser history not over-padded). - expect(countLeadingPadding(rehydrated.history)).toBe(0); - }); - }); });