diff --git a/src/libs/Navigation/AppNavigator/FreezeWrapper/getIsScreenBlurred/index.ts b/src/libs/Navigation/AppNavigator/FreezeWrapper/getIsScreenBlurred/index.ts index 0b51e817a0ba..12e12543c4cc 100644 --- a/src/libs/Navigation/AppNavigator/FreezeWrapper/getIsScreenBlurred/index.ts +++ b/src/libs/Navigation/AppNavigator/FreezeWrapper/getIsScreenBlurred/index.ts @@ -3,7 +3,26 @@ import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; function getIsScreenBlurred(state: NavigationState, currentRouteKey: string) { const lastFullScreenRoute = state.routes.findLast((route) => isFullScreenName(route.name)); - return lastFullScreenRoute?.key !== currentRouteKey; + + if (!lastFullScreenRoute) { + return false; + } + + // Direct key match — screen is focused. + if (lastFullScreenRoute.key === currentRouteKey) { + return false; + } + + // Key mismatch fallback: check if the current route and the focused route are the same navigator type. + // This handles the key-reuse optimization in useCustomRootStackNavigatorState where the rendered + // route keeps an old canonical key while the real navigation state has a newer key for the same + // navigator type. Without this check, FreezeWrapper would incorrectly freeze the active screen. + const currentRouteInState = state.routes.find((r) => r.key === currentRouteKey); + if (currentRouteInState?.name === lastFullScreenRoute.name) { + return false; + } + + return true; } export default getIsScreenBlurred; diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts index b9a8ad120b0c..f4833a3281c6 100644 --- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers.ts @@ -138,9 +138,8 @@ function handlePushFullscreenAction( return null; } - const lastFullScreenRoute = stateWithNavigator.routes.at(-1); - // Transitioning to all central screens in each split should be animated + const lastFullScreenRoute = stateWithNavigator.routes.at(-1); if (lastFullScreenRoute?.key && targetScreen && !SCREENS_WITH_NAVIGATION_TAB_BAR.includes(targetScreen)) { screensWithEnteringAnimation.add(lastFullScreenRoute.key); } diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/useCustomRootStackNavigatorState/index.android.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/useCustomRootStackNavigatorState/index.android.ts index 88a7135aa0b3..789a412b9bb3 100644 --- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/useCustomRootStackNavigatorState/index.android.ts +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/useCustomRootStackNavigatorState/index.android.ts @@ -1,18 +1,14 @@ import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; import type {CustomStateHookProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import buildOptimizedRoutes from './reuseNavigatorKey'; // This is an optimization to keep mounted only last few screens in the stack. export default function useCustomRootStackNavigatorState({state}: CustomStateHookProps) { const lastSplitIndex = state.routes.findLastIndex((route) => isFullScreenName(route.name)); - let indexToSlice = Math.max(0, lastSplitIndex); - const hasPrevRoute = lastSplitIndex > 0; - const isPrevFullScreen = isFullScreenName(state.routes.at(lastSplitIndex - 1)?.name); - // If the route before the last full screen is e.g. RHP, we should leave it in the rendered routes, - // as there may be display issues (blank screen) when navigating back and recreating that route to render. - if (hasPrevRoute && !isPrevFullScreen) { - indexToSlice = lastSplitIndex - 1; - } + // Always preserve the previous fullscreen route so React can reuse the mounted navigator component on navigation. + const indexToSlice = Math.max(0, lastSplitIndex - 1); const routesToRender = state.routes.slice(indexToSlice, state.routes.length); - return {...state, routes: routesToRender, index: routesToRender.length - 1}; + const remappedRoutes = buildOptimizedRoutes(routesToRender, state); + return {...state, routes: remappedRoutes, index: remappedRoutes.length - 1}; } diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/useCustomRootStackNavigatorState/index.ios.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/useCustomRootStackNavigatorState/index.ios.ts index 0351251d6871..ae5a7f2a85e5 100644 --- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/useCustomRootStackNavigatorState/index.ios.ts +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/useCustomRootStackNavigatorState/index.ios.ts @@ -4,6 +4,7 @@ import {SPLIT_TO_SIDEBAR} from '@libs/Navigation/linkingConfig/RELATIONS'; import type {CustomStateHookProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {NavigationRoute, SplitNavigatorName} from '@libs/Navigation/types'; import NAVIGATORS from '@src/NAVIGATORS'; +import buildOptimizedRoutes from './reuseNavigatorKey'; // Swiping back on iOS does not work properly when the preloaded route has gestureEnabled set to false. // Therefore, on screens where swiping should work, preloadedRoutes will be an empty array during rendering to ensure swiping works properly. @@ -36,7 +37,8 @@ function getShouldHidePreloadedRoutes(route?: NavigationRoute) { export default function useCustomRootStackNavigatorState({state}: CustomStateHookProps) { const lastSplitIndex = state.routes.findLastIndex((route) => isFullScreenName(route.name)); const routesToRender = state.routes.slice(Math.max(0, lastSplitIndex - 1), state.routes.length); - const stateToRender = {...state, routes: routesToRender, index: routesToRender.length - 1}; + const remappedRoutes = buildOptimizedRoutes(routesToRender, state); + const stateToRender = {...state, routes: remappedRoutes, index: remappedRoutes.length - 1}; if (getShouldHidePreloadedRoutes(stateToRender.routes.at(-1))) { return {...stateToRender, preloadedRoutes: []}; } diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/useCustomRootStackNavigatorState/index.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/useCustomRootStackNavigatorState/index.ts index 88a7135aa0b3..ec5fec7ed29b 100644 --- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/useCustomRootStackNavigatorState/index.ts +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/useCustomRootStackNavigatorState/index.ts @@ -1,5 +1,6 @@ import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; import type {CustomStateHookProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import buildOptimizedRoutes from './reuseNavigatorKey'; // This is an optimization to keep mounted only last few screens in the stack. export default function useCustomRootStackNavigatorState({state}: CustomStateHookProps) { @@ -13,6 +14,9 @@ export default function useCustomRootStackNavigatorState({state}: CustomStateHoo if (hasPrevRoute && !isPrevFullScreen) { indexToSlice = lastSplitIndex - 1; } + const routesToRender = state.routes.slice(indexToSlice, state.routes.length); - return {...state, routes: routesToRender, index: routesToRender.length - 1}; + const remappedRoutes = buildOptimizedRoutes(routesToRender, state); + + return {...state, routes: remappedRoutes, index: remappedRoutes.length - 1}; } diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/useCustomRootStackNavigatorState/reuseNavigatorKey.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/useCustomRootStackNavigatorState/reuseNavigatorKey.ts new file mode 100644 index 000000000000..b886d865327d --- /dev/null +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/useCustomRootStackNavigatorState/reuseNavigatorKey.ts @@ -0,0 +1,101 @@ +import type {ParamListBase, StackNavigationState} from '@react-navigation/native'; +import {screensWithEnteringAnimation} from '@libs/Navigation/AppNavigator/createRootStackNavigator/GetStateForActionHandlers'; +import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; + +type StateRoutes = StackNavigationState['routes']; + +type RemappedStateRoute = StateRoutes[number] & {originalKey?: string}; + +/** + * Tracks the mapping from a new route's key (originalKey) to the mounted navigator's key. + * This allows getMinimalAction to drill into the mounted navigator's preserved state + * when looking up the nested state for a route that is rendered via a remapped key. + * + * Example: when Inbox.key = key1 is remapped to Inbox.key = key2, remappedKeyMap[key2] = key1. + */ +const remappedKeyMap: Record = {}; + +function getRemappedNavigatorKey(originalKey: string): string | undefined { + return remappedKeyMap[originalKey]; +} + +/** + * For each fullscreen navigator in routesToRender, checks whether a navigator + * with the same name is already mounted in the current navigation state. + * + * If such a route exists (with a different key), the new route is replaced + * with the existing one while applying the new params. + * + * This preserves the original route key so React can reuse the already-mounted + * navigator instance. The existing navigator's internal state is kept intact so its + * navigation history is not lost. useNavigateOnParamsChange will forward-navigate + * to the correct screen based on the updated params. + * + * The actual navigation state is not mutated — this only affects what gets rendered. + */ +function buildOptimizedRoutes(routesToRender: StateRoutes, state: StackNavigationState): RemappedStateRoute[] { + // Rebuild the mapping from scratch on every call so it always reflects the current render state. + for (const key of Object.keys(remappedKeyMap)) { + delete remappedKeyMap[key]; + } + + return routesToRender.map((route) => { + const previousRoute = state.routes.at(-2); + + // Skip if this is not a fullscreen navigator or if we're already inside the same navigator + if (!isFullScreenName(route.name) || previousRoute?.name === route.name) { + return route; + } + + // Look for an already mounted navigator with the same name but a different key + const existingRoute = state.routes.find((r) => r.name === route.name && r.key !== route.key); + + if (!existingRoute) { + return route; + } + + // Prevent rendering two routes with the same key + if (routesToRender.some((r) => r.key === existingRoute.key)) { + return route; + } + + const existingRouteIndexInFull = state.routes.findIndex((r) => r.key === existingRoute.key); + const currentRouteIndexInFull = state.routes.findIndex((r) => r.key === route.key); + const routesBetween = state.routes.slice(existingRouteIndexInFull + 1, currentRouteIndexInFull); + + // Prevent reordering of native views: skip remap if there are routes between existingRoute + // and the current route in the full state that are also being rendered. Remapping in this + // case would swap their positions in the array and cause a blank screen in the native stack. + const hasIntermediateRenderedRoutes = routesBetween.some((routeBetween) => routesToRender.some((r) => r.key === routeBetween.key)); + + if (hasIntermediateRenderedRoutes) { + return route; + } + + // Move the entering-animation marker to the reused route key + // so the animation runs on the existing navigator instance + if (screensWithEnteringAnimation.has(route.key)) { + screensWithEnteringAnimation.delete(route.key); + screensWithEnteringAnimation.add(existingRoute.key); + } + + // Track that route.key is rendered via existingRoute.key so that getMinimalAction + // can look up the mounted navigator's preserved state. + remappedKeyMap[route.key] = existingRoute.key; + + return { + ...existingRoute, // Reuse the mounted navigator (preserve key so React doesn't unmount it) + // Do not clear state — keeping the existing navigator's internal state preserves its + // navigation history. useNavigateOnParamsChange will forward-navigate to the correct + // screen based on the updated params, so history is extended rather than reset. + params: { + ...route.params, // Apply params from the incoming navigation action + }, + originalKey: route.key, + }; + }); +} + +export type {RemappedStateRoute}; +export {getRemappedNavigatorKey}; +export default buildOptimizedRoutes; diff --git a/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts b/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts index 397388f73693..7cc4b0dd4a1a 100644 --- a/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts +++ b/src/libs/Navigation/AppNavigator/createSplitNavigator/SplitRouter.ts @@ -75,7 +75,8 @@ function adaptStateIfNecessary({state, options: {sidebarScreen, defaultCentralSc const previousSameNavigator = rootState?.routes.filter((route) => route.name === parentRoute.name).at(-2); // If we have optimization for not rendering all split navigators, then last selected option may not be in the state. In this case state has to be read from the preserved state. - const previousSameNavigatorState = previousSameNavigator?.state ?? (previousSameNavigator?.key ? getPreservedNavigatorState(previousSameNavigator.key) : undefined); + const previousSameNavigatorKey = (previousSameNavigator as {originalKey?: string} | undefined)?.originalKey ?? previousSameNavigator?.key; + const previousSameNavigatorState = previousSameNavigator?.state ?? (previousSameNavigatorKey ? getPreservedNavigatorState(previousSameNavigatorKey) : undefined); const previousSelectedCentralScreen = previousSameNavigatorState?.routes && previousSameNavigatorState.routes.length > 1 ? previousSameNavigatorState.routes.at(-1)?.name : undefined; @@ -136,7 +137,8 @@ function SplitRouter(options: SplitNavigatorRouterOptions) { }, getInitialState({routeNames, routeParamList, routeGetIdList}: RouterConfigOptions) { - const initialState = getPreservedNavigatorState(options.parentRoute.key) ?? stackRouter.getInitialState({routeNames, routeParamList, routeGetIdList}); + const parentRouteKey: string = (options.parentRoute as {originalKey?: string}).originalKey ?? options.parentRoute.key; + const initialState = getPreservedNavigatorState(parentRouteKey) ?? stackRouter.getInitialState({routeNames, routeParamList, routeGetIdList}); const maybeAdaptedState = adaptStateIfNecessary({state: initialState, options}); // If we needed to modify the state we need to rehydrate it to get keys for new routes. diff --git a/src/libs/Navigation/AppNavigator/createSplitNavigator/useNavigateOnParamsChange.ts b/src/libs/Navigation/AppNavigator/createSplitNavigator/useNavigateOnParamsChange.ts new file mode 100644 index 000000000000..276b5bf146aa --- /dev/null +++ b/src/libs/Navigation/AppNavigator/createSplitNavigator/useNavigateOnParamsChange.ts @@ -0,0 +1,49 @@ +import {deepEqual} from 'fast-equals'; +import {useEffect, useRef} from 'react'; +import type {CustomEffectsHookProps} from '@libs/Navigation/PlatformStackNavigation/types'; + +/** + * When buildOptimizedRoutes remaps a new route to reuse an existing navigator component, + * the component keeps its old internal navigation state but receives new parentRoute.params + * pointing to a different screen. This hook detects that change and dispatches a navigate + * action within the split navigator so the correct screen is shown. + * + * Without this, clicking a sub-tab (e.g. wallet) while already inside a split navigator + * would change the URL but leave the displayed screen unchanged. + */ +function useNavigateOnParamsChange({navigation, parentRoute, state}: CustomEffectsHookProps) { + // Initialized to undefined (not the current params) so that the first render also checks + // whether the navigator's initial state matches the target screen. This is required for + // freshly-mounted navigators whose getInitialState may not navigate to the correct screen. + const lastHandledParamsRef = useRef | undefined>(undefined); + + // No dependency array — runs after every render. + // The ref guard ensures we only act when params actually change. + useEffect(() => { + const currentParams = parentRoute?.params; + + if (lastHandledParamsRef.current === currentParams) { + return; + } + + lastHandledParamsRef.current = currentParams; + + const screenToNavigate = currentParams && 'screen' in currentParams ? (currentParams.screen as string) : undefined; + + if (!screenToNavigate) { + return; + } + + const screenParams = currentParams && 'params' in currentParams ? (currentParams.params as Record) : undefined; + + // Already showing the correct screen with the same params — nothing to do. + const lastRoute = state.routes.at(-1); + if (lastRoute?.name === screenToNavigate && deepEqual(lastRoute?.params, screenParams)) { + return; + } + + navigation.navigate(screenToNavigate, screenParams); + }); +} + +export default useNavigateOnParamsChange; diff --git a/src/libs/Navigation/helpers/getPathFromState.ts b/src/libs/Navigation/helpers/getPathFromState.ts index fa48471b6f8d..09a6de0e096e 100644 --- a/src/libs/Navigation/helpers/getPathFromState.ts +++ b/src/libs/Navigation/helpers/getPathFromState.ts @@ -1,7 +1,7 @@ import {findFocusedRoute, getPathFromState as RNGetPathFromState} from '@react-navigation/native'; import type {NavigationState, PartialState} from '@react-navigation/routers'; -import {linkingConfig} from '@libs/Navigation/linkingConfig'; -import {normalizedConfigs} from '@libs/Navigation/linkingConfig/config'; +import {getRemappedNavigatorKey} from '@libs/Navigation/AppNavigator/createRootStackNavigator/useCustomRootStackNavigatorState/reuseNavigatorKey'; +import {config, normalizedConfigs} from '@libs/Navigation/linkingConfig/config'; import type {DynamicRouteSuffix} from '@src/ROUTES'; import type {Screen} from '@src/SCREENS'; import {dynamicRoutePaths} from './isDynamicRouteSuffix'; @@ -21,8 +21,53 @@ function isDynamicRouteScreen(screenName: Screen): boolean { return dynamicRoutePaths.has(screenPath as DynamicRouteSuffix); } +/** + * When buildOptimizedRoutes remaps a new route (e.g. RSN-key3) to reuse an existing + * mounted navigator (RSN-key1), the new route is never mounted under its own key and + * its embedded state in the root navigation state remains undefined. This causes + * getPathFromState to serialize route.params directly, producing a broken URL. + * + * This function patches the state by copying the mounted navigator's current state to + * the logical (unmounted) route so URL generation can drill into the correct nested state. + */ +function patchStateWithRemappedStates(state: State): State { + if (!('routes' in state) || !state.routes) { + return state; + } + + let changed = false; + const routes = (state as NavigationState).routes; + + const patchedRoutes = routes.map((route) => { + if (route.state) { + return route; + } + + const remappedKey = getRemappedNavigatorKey(route.key); + if (!remappedKey) { + return route; + } + + const remappedRoute = routes.find((r) => r.key === remappedKey); + if (!remappedRoute?.state) { + return route; + } + + changed = true; + return {...route, state: remappedRoute.state}; + }); + + if (!changed) { + return state; + } + + return {...(state as NavigationState), routes: patchedRoutes}; +} + const getPathFromState = (state: State): string => { - const focusedRoute = findFocusedRoute(state); + const patchedState = patchStateWithRemappedStates(state); + + const focusedRoute = findFocusedRoute(patchedState); const screenName = focusedRoute?.name ?? ''; // Handle dynamic route screens that require special path that is placed in state @@ -31,7 +76,7 @@ const getPathFromState = (state: State): string => { } // For regular routes, use React Navigation's default path generation - const path = RNGetPathFromState(state, linkingConfig.config); + const path = RNGetPathFromState(patchedState, config); return path; }; diff --git a/src/libs/Navigation/helpers/linkTo/getMinimalAction.ts b/src/libs/Navigation/helpers/linkTo/getMinimalAction.ts index 9eab2f6f8717..28f1fde85fdb 100644 --- a/src/libs/Navigation/helpers/linkTo/getMinimalAction.ts +++ b/src/libs/Navigation/helpers/linkTo/getMinimalAction.ts @@ -13,19 +13,26 @@ type MinimalAction = { * * @param action action generated by getActionFromState * @param state The root state + * @param getStateForRouteKey optional fallback to look up a navigator's state by route key + * when the route has no embedded state in the root state (e.g. because it is rendered + * via a remapped key from buildOptimizedRoutes and never mounted under its own key). * @returns minimalAction minimal action is the action that we should dispatch */ -function getMinimalAction(action: NavigationAction, state: NavigationState): MinimalAction { +function getMinimalAction(action: NavigationAction, state: NavigationState, getStateForRouteKey?: (key: string) => State | undefined): MinimalAction { let currentAction: NavigationAction = action; let currentState: State | undefined = state; let currentTargetKey: string | undefined; while (currentAction.payload && 'name' in currentAction.payload && currentState?.routes[currentState.index ?? -1].name === currentAction.payload.name) { - if (!currentState?.routes[currentState.index ?? -1].state) { + // Cast to a known shape so TypeScript can type .key and .state without falling back to any. + const currentRoute = currentState?.routes[currentState.index ?? -1] as {key: string; state?: State} | undefined; + const routeState = currentRoute?.state ?? getStateForRouteKey?.(currentRoute?.key ?? ''); + + if (!routeState) { break; } - currentState = currentState?.routes[currentState.index ?? -1].state; + currentState = routeState; currentTargetKey = currentState?.key; const payload = currentAction.payload as ActionPayload; diff --git a/src/libs/Navigation/helpers/linkTo/index.ts b/src/libs/Navigation/helpers/linkTo/index.ts index 63126c40e1c7..c84e27a0a4a2 100644 --- a/src/libs/Navigation/helpers/linkTo/index.ts +++ b/src/libs/Navigation/helpers/linkTo/index.ts @@ -1,6 +1,8 @@ import {getActionFromState} from '@react-navigation/core'; import type {NavigationContainerRef, NavigationState, PartialState} from '@react-navigation/native'; import {findFocusedRoute, StackActions} from '@react-navigation/native'; +import {getRemappedNavigatorKey} from '@libs/Navigation/AppNavigator/createRootStackNavigator/useCustomRootStackNavigatorState/reuseNavigatorKey'; +import {getPreservedNavigatorState} from '@libs/Navigation/AppNavigator/createSplitNavigator/usePreserveNavigatorState'; import {getMatchingFullScreenRoute, isFullScreenName} from '@libs/Navigation/helpers/getAdaptedStateFromPath'; import getStateFromPath from '@libs/Navigation/helpers/getStateFromPath'; import normalizePath from '@libs/Navigation/helpers/normalizePath'; @@ -182,6 +184,13 @@ export default function linkTo(navigation: NavigationContainerRef { + const mountedKey = getRemappedNavigatorKey(key) ?? key; + return getPreservedNavigatorState(mountedKey); + }; + + const {action: minimalAction} = getMinimalAction(action, navigation.getRootState(), getStateForRouteKey); navigation.dispatch(minimalAction); } diff --git a/src/libs/Navigation/linkingConfig/index.ts b/src/libs/Navigation/linkingConfig/index.ts index 4eec394d5bcb..65f20eb3870e 100644 --- a/src/libs/Navigation/linkingConfig/index.ts +++ b/src/libs/Navigation/linkingConfig/index.ts @@ -1,12 +1,14 @@ /* eslint-disable @typescript-eslint/naming-convention */ import type {LinkingOptions} from '@react-navigation/native'; import getAdaptedStateFromPath from '@libs/Navigation/helpers/getAdaptedStateFromPath'; +import getPathFromState from '@libs/Navigation/helpers/getPathFromState'; import type {RootNavigatorParamList} from '@libs/Navigation/types'; import {config} from './config'; import prefixes from './prefixes'; const linkingConfig: LinkingOptions = { getStateFromPath: getAdaptedStateFromPath, + getPathFromState, prefixes, config, }; diff --git a/src/libs/Navigation/navigationRef.ts b/src/libs/Navigation/navigationRef.ts index 17742e5d0937..87adf1c246ee 100644 --- a/src/libs/Navigation/navigationRef.ts +++ b/src/libs/Navigation/navigationRef.ts @@ -3,4 +3,6 @@ import type {NavigationRef} from './types'; const navigationRef: NavigationRef = createNavigationContainerRef(); +// document.navigationRef = navigationRef; + export default navigationRef;