Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
97a5844
reusing navigators
sumo-slonik Feb 23, 2026
959070d
Merge branch 'main' into feature/kuba-nowakowski/reuse_navigators
sumo-slonik Feb 23, 2026
752fba8
clean code
sumo-slonik Feb 23, 2026
5df907f
fix browser history
sumo-slonik Feb 23, 2026
f8a61f0
fix ios undefined state issue
sumo-slonik Feb 24, 2026
2ba3f99
Merge branch 'main' into feature/kuba-nowakowski/reuse_navigators
sumo-slonik Feb 24, 2026
f9f7710
fix ios undefined state issue
sumo-slonik Feb 24, 2026
593186c
reusage of navigators
sumo-slonik Feb 27, 2026
bf33da1
migrate state editing only to place where it is rendering
sumo-slonik Feb 27, 2026
9d76506
remove unnecessary changes with broke history
sumo-slonik Feb 27, 2026
3a4a22e
empty screens after returning to scream
sumo-slonik Feb 27, 2026
fdcb6af
work in progress
sumo-slonik Feb 27, 2026
d8ebd3f
working history but with week performance
sumo-slonik Mar 2, 2026
70e834a
performance is great again, last bug when returning to split navigato…
sumo-slonik Mar 2, 2026
09ee767
performance is great again, fix bug when navigating on the same split…
sumo-slonik Mar 2, 2026
7b147fe
Merge branch 'feature/kuba-nowakowski/reusing_navigators_wihout_navig…
sumo-slonik Mar 2, 2026
a7b7423
refactor reuseNavigatorKey function
sumo-slonik Mar 2, 2026
c79f24e
fix prettier and eslint, remove unnecessary hook from split navigator
sumo-slonik Mar 2, 2026
1a91968
remove changes from jest test
sumo-slonik Mar 2, 2026
cc72fa1
Merge branch 'main' into feature/kuba-nowakowski/reuse_navigators
sumo-slonik Mar 6, 2026
3863f2a
block pushing duplicate of split navigator
sumo-slonik Mar 6, 2026
a61eadf
refactor code
sumo-slonik Mar 9, 2026
6d61724
npm run prettier
sumo-slonik Mar 9, 2026
165359e
fix empty tab on mobile ios
sumo-slonik Mar 9, 2026
8419336
fix prettier
sumo-slonik Mar 9, 2026
d9d0fc6
wip
sumo-slonik Mar 10, 2026
3049e59
wip
sumo-slonik Mar 10, 2026
9dfa356
resolve state problem
sumo-slonik Mar 11, 2026
f8d1742
working on performance
sumo-slonik Mar 11, 2026
31dba0b
working solution, but not full performance gain
sumo-slonik Mar 11, 2026
fa56030
working on performance gain
sumo-slonik Mar 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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};
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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: []};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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};
}
Original file line number Diff line number Diff line change
@@ -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<ParamListBase>['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<string, string> = {};

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<ParamListBase>): 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;
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown> | 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<string, unknown>) : 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;
53 changes: 49 additions & 4 deletions src/libs/Navigation/helpers/getPathFromState.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand All @@ -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;
};
Expand Down
13 changes: 10 additions & 3 deletions src/libs/Navigation/helpers/linkTo/getMinimalAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading