import { CommonActions, findFocusedRoute, getActionFromState as getActionFromStateDefault, getPathFromState as getPathFromStateDefault, getStateFromPath as getStateFromPathDefault, type InitialState, type NavigationContainerRef, type NavigationState, type ParamListBase, type PartialState, useNavigationIndependentTree, } from '@react-navigation/core'; import isEqual from 'fast-deep-equal'; import * as React from 'react'; import { createMemoryHistory } from './createMemoryHistory'; import { ServerContext } from './ServerContext'; import type { LinkingOptions } from './types'; type ResultState = ReturnType; /** * History delta already applied by the browser when handling `popstate` * The value 'replace' means the delta is unknown, so we can only replace */ type PopStateDelta = number | 'replace'; const getRoutesUntilIndex = (state: NavigationState) => state.routes.slice(0, state.index + 1); /** * Get the state object to construct the path from. * * We use the root state as the base, and fallback to existing `route.state`. * If a navigator mounts later, this ensures we don't remove it from URL. * It could happen during suspense, hydration, conditional rendering etc. */ const getStateForPath = ( state: NavigationState | PartialState, fallbackState: InitialState | undefined ): InitialState => { const index = state.index ?? state.routes.length - 1; const route = state.routes[index]; if (route == null) { return state; } const fallbackRoute = fallbackState?.routes[index]; if ( (fallbackRoute != null && 'key' in fallbackRoute && typeof fallbackRoute?.key === 'string' && typeof route?.key === 'string' && fallbackRoute.key !== route.key) || route.name !== fallbackRoute?.name ) { return state; } const childState = route.state ? getStateForPath(route.state, fallbackRoute.state) : fallbackRoute.state; if (route.state === childState) { return state; } return { ...state, routes: state.routes.map((item) => item === route ? { ...route, state: childState } : item ), }; }; /** * Calculate history length from navigator history or active routes. */ const getHistoryLength = (state: NavigationState): number => state.history ? state.history.length : getRoutesUntilIndex(state).length; /** * Find the matching navigation state that changed between 2 navigation states * e.g.: a -> b -> c -> d and a -> b -> c -> e -> f, if history in b changed, b is the matching state */ const findMatchingState = ( a: T | undefined, b: T | undefined ): [T | undefined, T | undefined] => { if (a === undefined || b === undefined || a.key !== b.key) { return [undefined, undefined]; } const aHistoryLength = getHistoryLength(a); const bHistoryLength = getHistoryLength(b); const aRoute = a.routes[a.index]; const bRoute = b.routes[b.index]; if (aRoute == null || bRoute == null) { return [a, b]; } const aChildState = aRoute.state as T | undefined; const bChildState = bRoute.state as T | undefined; // Stop here if this is the state object that changed: // - history length is different // - focused routes are different // - one of them doesn't have child state // - child state keys are different if ( aHistoryLength !== bHistoryLength || aRoute.key !== bRoute.key || aChildState === undefined || bChildState === undefined || aChildState.key !== bChildState.key ) { return [a, b]; } return findMatchingState(aChildState, bChildState); }; /** * Check if the state change is popping the last route or history entry. */ const isPoppingLastEntry = ( current: NavigationState, record: NavigationState ): boolean => { const currentRoute = current.routes[current.index]; const recordRoute = record.routes[record.index]; if (currentRoute == null || recordRoute == null) { return false; } if (current.history && record.history) { return current.history.length === record.history.length + 1; } const currentRoutes = getRoutesUntilIndex(current); const recordRoutes = getRoutesUntilIndex(record); if (currentRoutes.length === recordRoutes.length + 1) { return recordRoutes.every((route, i) => { const currentRoute = currentRoutes[i]; return currentRoute != null && route.key === currentRoute.key; }); } return false; }; /** * Run async function in series as it's called. */ export const series = (cb: () => Promise) => { let queue = Promise.resolve(); const callback = () => { // eslint-disable-next-line promise/no-callback-in-promise queue = queue.then(cb).catch((e) => { console.error(e); }); }; return callback; }; const linkingHandlers: symbol[] = []; type Options = LinkingOptions; export function useLinking( ref: React.RefObject | null>, { enabled = true, config, getStateFromPath = getStateFromPathDefault, getPathFromState = getPathFromStateDefault, getActionFromState = getActionFromStateDefault, }: Options ) { const independent = useNavigationIndependentTree(); React.useEffect(() => { if (process.env.NODE_ENV === 'production') { return undefined; } if (independent) { return undefined; } if (enabled !== false && linkingHandlers.length) { console.error( [ 'Looks like you have configured linking in multiple places. This is likely an error since deep links should only be handled in one place to avoid conflicts. Make sure that:', "- You don't have multiple NavigationContainers in the app each with 'linking' enabled", '- Only a single instance of the root component is rendered', ] .join('\n') .trim() ); } const handler = Symbol(); if (enabled !== false) { linkingHandlers.push(handler); } return () => { const index = linkingHandlers.indexOf(handler); if (index > -1) { linkingHandlers.splice(index, 1); } }; }, [enabled, independent]); const [history] = React.useState(createMemoryHistory); // We store these options in ref to avoid re-creating getInitialState and re-subscribing listeners // This lets user avoid wrapping the items in `React.useCallback` or `React.useMemo` // Not re-creating `getInitialState` is important coz it makes it easier for the user to use in an effect const enabledRef = React.useRef(enabled); const configRef = React.useRef(config); const getStateFromPathRef = React.useRef(getStateFromPath); const getPathFromStateRef = React.useRef(getPathFromState); const getActionFromStateRef = React.useRef(getActionFromState); React.useEffect(() => { enabledRef.current = enabled; configRef.current = config; getStateFromPathRef.current = getStateFromPath; getPathFromStateRef.current = getPathFromState; getActionFromStateRef.current = getActionFromState; }); const validateRoutesNotExistInRootState = React.useCallback( (state: ResultState, rootState: NavigationState | undefined) => { // Make sure that the routes in the state exist in the root navigator // Otherwise there's an error in the linking configuration return state?.routes.some((r) => !rootState?.routeNames.includes(r.name)); }, [] ); const server = React.useContext(ServerContext); const getInitialState = React.useCallback(() => { let value: ResultState | undefined; if (enabledRef.current) { const location = server?.location ?? (typeof window !== 'undefined' ? window.location : undefined); const path = location ? location.pathname + location.search : undefined; if (path) { try { value = getStateFromPathRef.current(path, configRef.current); } catch (e) { console.error(e); value = undefined; } } } const thenable = { then(onfulfilled?: (state: ResultState | undefined) => void) { return Promise.resolve(onfulfilled ? onfulfilled(value) : value); }, catch() { return thenable; }, }; return thenable as PromiseLike; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const previousIndexRef = React.useRef(undefined); const previousStateRef = React.useRef(undefined); const pendingPopStateDeltaRef = React.useRef( undefined ); React.useEffect(() => { previousIndexRef.current = history.index; return history.listen(() => { const navigation = ref.current; if (!navigation || !enabled) { return; } const { location } = window; const path = location.pathname + location.search; const index = history.index; const previousIndex = previousIndexRef.current ?? 0; previousIndexRef.current = index; const rollbackHistory = () => { const delta = previousIndex - index; if (delta === 0) { return; } history .go(delta) // eslint-disable-next-line promise/always-return ?.then(() => { previousIndexRef.current = history.index; }) .catch(() => { // The navigation was interrupted }); }; const rollbackHistoryIfPrevented = ( callback: () => void, pendingDelta: PopStateDelta ) => { let removePrevented = false; let actionChangedState = false; const unsubscribeEvent = navigation.addListener( '__unsafe_event__', (e) => { if (e.data.type === 'beforeRemove' && e.data.defaultPrevented) { removePrevented = true; } } ); // After preventing remove, user may synchronously continue navigation // This is common when showing a confirmation dialog // If this action produces an update, we don't want to rollback the history const unsubscribeAction = navigation.addListener( '__unsafe_action__', (e) => { if (!e.data.noop) { actionChangedState = true; } } ); try { callback(); } finally { unsubscribeEvent(); unsubscribeAction(); } if (actionChangedState) { // The change may be committed later, e.g. with transitions // Remember the delta so it can be subtracted when syncing the commit pendingPopStateDeltaRef.current = pendingDelta; } else if (removePrevented) { rollbackHistory(); } }; const dispatch = ( action: Parameters[0], pendingDelta: PopStateDelta ) => { rollbackHistoryIfPrevented( () => navigation.dispatch(action), pendingDelta ); }; const resetRoot = ( state: Parameters[0], pendingDelta: PopStateDelta ) => { rollbackHistoryIfPrevented( () => navigation.resetRoot(state), pendingDelta ); }; // When browser back/forward is clicked, we first need to check if state object for this index exists // If it does we'll reset to that state object // Otherwise, we'll handle it like a regular deep link const record = history.get(index); if (record?.path === path && record?.state) { const currentState = navigation.getRootState(); const [currentFocused, recordFocused] = findMatchingState( currentState, record.state ); if ( previousIndex - index === 1 && currentFocused && recordFocused && isPoppingLastEntry(currentFocused, recordFocused) ) { const pending = pendingPopStateDeltaRef.current; // If we detect that the state change is popping the last entry // Dispatch a back action instead of resetting to the state // This makes sure changes to history state since the entry was added don't get lost dispatch( CommonActions.goBack(), // Stack on any delta from a previous `popstate` that hasn't committed yet pending === 'replace' ? 'replace' : (pending ?? 0) - 1 ); } else { // The browser already moved from the current state to the record's state resetRoot( record.state, currentFocused && recordFocused ? getHistoryLength(recordFocused) - getHistoryLength(currentFocused) : 'replace' ); } return; } const rootState = navigation.getRootState(); let state: ResultState | undefined; try { state = getStateFromPathRef.current(path, configRef.current); } catch (e) { console.error(e); state = undefined; } // We should only dispatch an action when going forward // Otherwise the action will likely add items to history, which would mess things up if (state) { // Make sure that the routes in the state exist in the root navigator // Otherwise there's an error in the linking configuration if (validateRoutesNotExistInRootState(state, rootState)) { return; } if (index > previousIndex) { const action = getActionFromStateRef.current( state, configRef.current ); if (action !== undefined) { try { dispatch( { target: rootState?.key, ...action, }, 'replace' ); } catch (e) { // Ignore any errors from deep linking. // This could happen in case of malformed links, navigation object not being initialized etc. console.warn( `An error occurred when trying to handle the link '${path}': ${ typeof e === 'object' && e != null && 'message' in e ? e.message : e }` ); } } else { resetRoot(state, 'replace'); } } else { resetRoot(state, 'replace'); } } else { // if current path didn't return any state, we should revert to initial state resetRoot(state, 'replace'); } }); }, [enabled, history, ref, validateRoutesNotExistInRootState]); React.useEffect(() => { if (!enabled) { return; } const getPathForRoute = ( route: ReturnType, state: InitialState ): string => { let path; // If the `route` object contains a `path`, use that path as long as `route.name` and `params` still match // This makes sure that we preserve the original URL for wildcard routes if (route?.path) { let stateForPath: ResultState | undefined; try { stateForPath = getStateFromPathRef.current( route.path, configRef.current ); } catch (e) { console.error(e); stateForPath = undefined; } if (stateForPath) { const focusedRoute = findFocusedRoute(stateForPath); if ( focusedRoute && focusedRoute.name === route.name && isEqual(focusedRoute.params, route.params) ) { path = route.path; } } } if (path == null) { path = getPathFromStateRef.current(state, configRef.current); } const previousRoute = previousStateRef.current ? findFocusedRoute(previousStateRef.current) : undefined; // Preserve the hash if the route didn't change if ( previousRoute && route && 'key' in previousRoute && 'key' in route && previousRoute.key === route.key ) { path = path + location.hash; } return path; }; if (ref.current) { // We need to record the current metadata on the first render if they aren't set // This will allow the initial state to be in the history entry const state = ref.current.getRootState(); if (state) { const stateForPath = getStateForPath(state, ref.current.getState()); const route = findFocusedRoute(stateForPath); const path = getPathForRoute(route, stateForPath); if (previousStateRef.current === undefined) { previousStateRef.current = state; } history.replace({ path, state }); previousIndexRef.current = history.index; } } const onStateChange = async () => { const navigation = ref.current; if (!navigation || !enabled) { return; } const previousState = previousStateRef.current; const state = navigation.getRootState(); // root state may not available, for example when root navigators switch inside the container if (!state) { return; } // Skip if the state hasn't changed since we last synced it if (previousState === state) { return; } const stateForPath = getStateForPath(state, navigation.getState()); const route = findFocusedRoute(stateForPath); const path = getPathForRoute(route, stateForPath); const pendingPopStateDelta = pendingPopStateDeltaRef.current; previousStateRef.current = state; pendingPopStateDeltaRef.current = undefined; // To detect the kind of state change, we need to: // - Find the common focused navigation state in previous and current state // - If only the route keys changed, compare history/routes.length to check if we go back/forward/replace // - If no common focused navigation state found, it's a replace const [previousFocusedState, focusedState] = findMatchingState( previousState, state ); if ( previousFocusedState && focusedState && // If the delta from `popstate` is unknown, we can only replace pendingPopStateDelta !== 'replace' ) { const historyDelta = getHistoryLength(focusedState) - getHistoryLength(previousFocusedState) - // Subtract the delta already applied by the browser to sync only the remaining changes (pendingPopStateDelta ?? 0); if (historyDelta > 0) { // If history length is increased, we should pushState // Note that path might not actually change here, for example, drawer open should pushState history.push({ path, state }); } else if (historyDelta < 0) { // If history length is decreased, i.e. entries were removed, we want to go back const nextIndex = history.backIndex({ path }); const currentIndex = history.index; try { if ( nextIndex !== -1 && nextIndex < currentIndex && // We should only go back if the entry exists and it's less than current index history.get(nextIndex) ) { // An existing entry for this path exists and it's less than current index, go back to that await history.go(nextIndex - currentIndex); } else { // We couldn't find an existing entry to go back to, so we'll go back by the delta // This won't be correct if multiple routes were pushed in one go before // Usually this shouldn't happen and this is a fallback for that await history.go(historyDelta); } // Store the updated state as well as fix the path if incorrect history.replace({ path, state }); } catch (e) { // The navigation was interrupted } } else { // If history length is unchanged, we want to replaceState history.replace({ path, state }); } } else { // If no common navigation state was found, assume it's a replace // This would happen if the user did a reset/conditionally changed navigators history.replace({ path, state }); } previousIndexRef.current = history.index; }; // We debounce onStateChange coz we don't want multiple state changes to be handled at one time // This could happen since `history.go(n)` is asynchronous // If `pushState` or `replaceState` were called before `history.go(n)` completes, it'll mess stuff up return ref.current?.addListener('state', series(onStateChange)); }, [enabled, history, ref]); return { getInitialState, }; }