1import { type NavigationAction, type NavigationState } from '@react-navigation/native'; 2import * as Linking from 'expo-linking'; 3 4import type { RouterStore } from './router-store'; 5import { ResultState } from '../fork/getStateFromPath'; 6import { Href, resolveHref } from '../link/href'; 7import { resolve } from '../link/path'; 8import { hasUrlProtocolPrefix } from '../utils/url'; 9 10function assertIsReady(store: RouterStore) { 11 if (!store.navigationRef.isReady()) { 12 throw new Error( 13 'Attempted to navigate before mounting the Root Layout component. Ensure the Root Layout component is rendering a Slot, or other navigator on the first render.' 14 ); 15 } 16} 17 18export function push(this: RouterStore, url: Href) { 19 return this.linkTo(resolveHref(url)); 20} 21 22export function replace(this: RouterStore, url: Href) { 23 return this.linkTo(resolveHref(url), 'REPLACE'); 24} 25 26export function goBack(this: RouterStore) { 27 assertIsReady(this); 28 this.navigationRef?.current?.goBack(); 29} 30 31export function canGoBack(this: RouterStore): boolean { 32 // Return a default value here if the navigation hasn't mounted yet. 33 // This can happen if the user calls `canGoBack` from the Root Layout route 34 // before mounting a navigator. This behavior exists due to React Navigation being dynamically 35 // constructed at runtime. We can get rid of this in the future if we use 36 // the static configuration internally. 37 if (!this.navigationRef.isReady()) { 38 return false; 39 } 40 return this.navigationRef?.current?.canGoBack() ?? false; 41} 42 43export function setParams(this: RouterStore, params: Record<string, string | number> = {}) { 44 assertIsReady(this); 45 return (this.navigationRef?.current?.setParams as any)(params); 46} 47 48export function linkTo(this: RouterStore, href: string, event?: string) { 49 if (hasUrlProtocolPrefix(href)) { 50 Linking.openURL(href); 51 return; 52 } 53 54 assertIsReady(this); 55 const navigationRef = this.navigationRef.current; 56 57 if (navigationRef == null) { 58 throw new Error( 59 "Couldn't find a navigation object. Is your component inside NavigationContainer?" 60 ); 61 } 62 63 if (!this.linking) { 64 throw new Error('Attempted to link to route when no routes are present'); 65 } 66 67 if (href === '..' || href === '../') { 68 navigationRef.goBack(); 69 return; 70 } 71 72 const rootState = navigationRef.getRootState(); 73 74 if (href.startsWith('.')) { 75 let base = 76 this.linking.getPathFromState?.(rootState, { 77 screens: [], 78 preserveGroups: true, 79 }) ?? ''; 80 81 if (base && !base.endsWith('/')) { 82 base += '/..'; 83 } 84 href = resolve(base, href); 85 } 86 87 const state = this.linking.getStateFromPath!(href, this.linking.config); 88 89 if (!state || state.routes.length === 0) { 90 console.error('Could not generate a valid navigation state for the given path: ' + href); 91 return; 92 } 93 94 switch (event) { 95 case 'REPLACE': 96 return navigationRef.dispatch(getNavigateReplaceAction(state, rootState)); 97 default: 98 return navigationRef.dispatch(getNavigatePushAction(state, rootState)); 99 } 100} 101 102type NavigationParams = Partial<{ 103 screen: string; 104 params: NavigationParams; 105}>; 106 107function rewriteNavigationStateToParams( 108 state?: { routes: ResultState['routes'] }, 109 params: NavigationParams = {} 110) { 111 if (!state) return params; 112 // We Should always have at least one route in the state 113 const lastRoute = state.routes.at(-1)!; 114 params.screen = lastRoute.name; 115 // Weirdly, this always needs to be an object. If it's undefined, it won't work. 116 params.params = lastRoute.params ? JSON.parse(JSON.stringify(lastRoute.params)) : {}; 117 118 if (lastRoute.state) { 119 rewriteNavigationStateToParams(lastRoute.state, params.params); 120 } 121 122 return JSON.parse(JSON.stringify(params)); 123} 124 125function getNavigatePushAction(state: ResultState, rootState: NavigationState) { 126 const { screen, params } = rewriteNavigationStateToParams(state); 127 return { 128 type: 'NAVIGATE', 129 target: rootState.key, 130 payload: { 131 name: screen, 132 params, 133 }, 134 }; 135} 136 137function getNavigateReplaceAction( 138 state: ResultState, 139 parentState: NavigationState, 140 lastNavigatorSupportingReplace: NavigationState = parentState 141): NavigationAction { 142 // We should always have at least one route in the state 143 const route = state.routes.at(-1)!; 144 145 // Only these navigators support replace 146 if (parentState.type === 'stack' || parentState.type === 'tab') { 147 lastNavigatorSupportingReplace = parentState; 148 } 149 150 const currentRoute = parentState.routes.find((parentRoute) => parentRoute.name === route.name); 151 const routesAreEqual = parentState.routes[parentState.index] === currentRoute; 152 153 // If there is nested state and the routes are equal, we should keep going down the tree 154 if (route.state && routesAreEqual && currentRoute.state) { 155 return getNavigateReplaceAction( 156 route.state, 157 currentRoute.state as any, 158 lastNavigatorSupportingReplace 159 ); 160 } 161 162 // Either we reached the bottom of the state or the point where the routes diverged 163 const { screen, params } = rewriteNavigationStateToParams(state); 164 165 return { 166 type: lastNavigatorSupportingReplace.type === 'stack' ? 'REPLACE' : 'JUMP_TO', 167 target: lastNavigatorSupportingReplace?.key, 168 payload: { 169 name: screen, 170 params, 171 }, 172 }; 173} 174