|
| 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