UNPKG

18.4 kBPlain TextView Raw
1import type { NavigationState, PartialRoute } from '@react-navigation/native'
2import {
3 StackActions,
4 type NavigationContainerRefWithCurrent,
5 type getPathFromState as originalGetPathFromState,
6} from '@react-navigation/native'
7import * as Linking from 'expo-linking'
8import { nanoid } from 'nanoid/non-secure'
9import { Fragment, startTransition, useSyncExternalStore, type ComponentType } from 'react'
10import { Platform } from 'react-native'
11import type { RouteNode } from '../Route'
12import { getLoaderPath, getPreloadPath } from '../cleanUrl'
13import type { State } from '../fork/getPathFromState'
14import { deepEqual, getPathDataFromState } from '../fork/getPathFromState'
15import { stripBaseUrl } from '../fork/getStateFromPath'
16import { getLinkingConfig, type ExpoLinkingOptions } from '../getLinkingConfig'
17import { getRoutes } from '../getRoutes'
18import type { OneRouter } from '../interfaces/router'
19import { resolveHref } from '../link/href'
20import { resolve } from '../link/path'
21import { matchDynamicName } from '../matchers'
22import { sortRoutes } from '../sortRoutes'
23import { getQualifiedRouteComponent } from '../useScreens'
24import { assertIsReady } from '../utils/assertIsReady'
25import { dynamicImport } from '../utils/dynamicImport'
26import { removeSearch } from '../utils/removeSearch'
27import { shouldLinkExternally } from '../utils/url'
28import type { One } from '../vite/types'
29import { getNormalizedStatePath, type UrlObject } from './getNormalizedStatePath'
30import { setLastAction } from './lastAction'
31
32// Module-scoped variables
33export let routeNode: RouteNode | null = null
34export let rootComponent: ComponentType
35export let linking: ExpoLinkingOptions | undefined
36
37export let hasAttemptedToHideSplash = false
38export let initialState: OneRouter.ResultState | undefined
39export let rootState: OneRouter.ResultState | undefined
40
41let nextState: OneRouter.ResultState | undefined
42export let routeInfo: UrlObject | undefined
43let splashScreenAnimationFrame: number | undefined
44
45// we always set it
46export let navigationRef: OneRouter.NavigationRef = null as any
47let navigationRefSubscription: () => void
48
49const rootStateSubscribers = new Set<OneRouter.RootStateListener>()
50const loadingStateSubscribers = new Set<OneRouter.LoadingStateListener>()
51const storeSubscribers = new Set<() => void>()
52
53// Initialize function
54export function initialize(
55 context: One.RouteContext,
56 ref: NavigationContainerRefWithCurrent<ReactNavigation.RootParamList>,
57 initialLocation?: URL
58) {
59 cleanUpState()
60 routeNode = getRoutes(context, {
61 ignoreEntryPoints: true,
62 platform: Platform.OS,
63 })
64 rootComponent = routeNode ? getQualifiedRouteComponent(routeNode) : Fragment
65
66 if (!routeNode && process.env.NODE_ENV === 'production') {
67 throw new Error('No routes found')
68 }
69
70 navigationRef = ref
71 setupLinking(initialLocation)
72 subscribeToNavigationChanges()
73}
74
75function cleanUpState() {
76 initialState = undefined
77 rootState = undefined
78 nextState = undefined
79 routeInfo = undefined
80 linking = undefined
81 navigationRefSubscription?.()
82 rootStateSubscribers.clear()
83 storeSubscribers.clear()
84}
85
86function setupLinking(initialLocation?: URL) {
87 if (routeNode) {
88 linking = getLinkingConfig(routeNode)
89
90 if (initialLocation) {
91 linking.getInitialURL = () => initialLocation.toString()
92 initialState = linking.getStateFromPath?.(
93 initialLocation.pathname + (initialLocation.search || ''),
94 linking.config
95 )
96 }
97 }
98
99 if (initialState) {
100 rootState = initialState
101 routeInfo = getRouteInfo(initialState)
102 } else {
103 routeInfo = {
104 unstable_globalHref: '',
105 pathname: '',
106 isIndex: false,
107 params: {},
108 segments: [],
109 }
110 }
111}
112
113function subscribeToNavigationChanges() {
114 navigationRefSubscription = navigationRef.addListener('state', (data) => {
115 const state = data.data.state as OneRouter.ResultState
116
117 if (!hasAttemptedToHideSplash) {
118 hasAttemptedToHideSplash = true
119 splashScreenAnimationFrame = requestAnimationFrame(() => {
120 // SplashScreen._internal_maybeHideAsync?.();
121 })
122 }
123
124 if (nextOptions) {
125 state.linkOptions = nextOptions
126 nextOptions = null
127 }
128
129 let shouldUpdateSubscribers = nextState === state
130 nextState = undefined
131
132 if (state && state !== rootState) {
133 updateState(state, undefined)
134 shouldUpdateSubscribers = true
135 }
136
137 if (shouldUpdateSubscribers) {
138 for (const subscriber of rootStateSubscribers) {
139 subscriber(state)
140 }
141 }
142 })
143
144 updateSnapshot()
145 for (const subscriber of storeSubscribers) {
146 subscriber()
147 }
148}
149
150// Navigation functions
151export function navigate(url: OneRouter.Href, options?: OneRouter.LinkToOptions) {
152 return linkTo(resolveHref(url), 'NAVIGATE', options)
153}
154
155export function push(url: OneRouter.Href, options?: OneRouter.LinkToOptions) {
156 return linkTo(resolveHref(url), 'PUSH', options)
157}
158
159export function dismiss(count?: number) {
160 navigationRef?.dispatch(StackActions.pop(count))
161}
162
163export function replace(url: OneRouter.Href, options?: OneRouter.LinkToOptions) {
164 return linkTo(resolveHref(url), 'REPLACE', options)
165}
166
167export function setParams(params: Record<string, string | number> = {}) {
168 assertIsReady(navigationRef)
169 return navigationRef?.current?.setParams(
170 // @ts-expect-error
171 params
172 )
173}
174
175export function dismissAll() {
176 navigationRef?.dispatch(StackActions.popToTop())
177}
178
179export function goBack() {
180 assertIsReady(navigationRef)
181 navigationRef?.current?.goBack()
182}
183
184export function canGoBack(): boolean {
185 if (!navigationRef.isReady()) {
186 return false
187 }
188 return navigationRef?.current?.canGoBack() ?? false
189}
190
191export function canDismiss(): boolean {
192 let state = rootState
193
194 while (state) {
195 if (state.type === 'stack' && state.routes.length > 1) {
196 return true
197 }
198 if (state.index === undefined) {
199 return false
200 }
201 state = state.routes?.[state.index]?.state as any
202 }
203
204 return false
205}
206
207export function getSortedRoutes() {
208 if (!routeNode) {
209 throw new Error('No routes')
210 }
211 return routeNode.children.filter((route) => !route.internal).sort(sortRoutes)
212}
213
214export function updateState(state: OneRouter.ResultState, nextStateParam = state) {
215 rootState = state
216 nextState = nextStateParam
217
218 const nextRouteInfo = getRouteInfo(state)
219
220 if (!deepEqual(routeInfo, nextRouteInfo)) {
221 routeInfo = nextRouteInfo
222 }
223}
224
225export function getRouteInfo(state: OneRouter.ResultState) {
226 return getRouteInfoFromState(
227 (state: Parameters<typeof originalGetPathFromState>[0], asPath: boolean) => {
228 return getPathDataFromState(state, {
229 screens: [],
230 ...linking?.config,
231 preserveDynamicRoutes: asPath,
232 preserveGroups: asPath,
233 })
234 },
235 state
236 )
237}
238
239function getRouteInfoFromState(
240 getPathFromState: (state: State, asPath: boolean) => { path: string; params: any },
241 state: State,
242 baseUrl?: string
243): UrlObject {
244 const { path } = getPathFromState(state, false)
245 const qualified = getPathFromState(state, true)
246
247 return {
248 unstable_globalHref: path,
249 pathname: stripBaseUrl(path, baseUrl).split('?')[0],
250 isIndex: isIndexPath(state),
251 ...getNormalizedStatePath(qualified, baseUrl),
252 }
253}
254
255// Subscription functions
256export function subscribeToRootState(subscriber: OneRouter.RootStateListener) {
257 rootStateSubscribers.add(subscriber)
258 return () => {
259 rootStateSubscribers.delete(subscriber)
260 }
261}
262
263export function subscribeToStore(subscriber: () => void) {
264 storeSubscribers.add(subscriber)
265 return () => {
266 storeSubscribers.delete(subscriber)
267 }
268}
269
270// Subscription functions
271export function subscribeToLoadingState(subscriber: OneRouter.LoadingStateListener) {
272 loadingStateSubscribers.add(subscriber)
273 return () => {
274 loadingStateSubscribers.delete(subscriber)
275 }
276}
277
278export function setLoadingState(state: OneRouter.LoadingState) {
279 for (const listener of loadingStateSubscribers) {
280 listener(state)
281 }
282}
283
284// Snapshot function
285
286let currentSnapshot: ReturnType<typeof getSnapshot> | null = null
287
288function updateSnapshot() {
289 currentSnapshot = getSnapshot()
290}
291
292export function snapshot() {
293 return currentSnapshot!
294}
295
296function getSnapshot() {
297 return {
298 linkTo,
299 routeNode,
300 rootComponent,
301 linking,
302 hasAttemptedToHideSplash,
303 initialState,
304 rootState,
305 nextState,
306 routeInfo,
307 splashScreenAnimationFrame,
308 navigationRef,
309 navigationRefSubscription,
310 rootStateSubscribers,
311 storeSubscribers,
312 }
313}
314
315export function rootStateSnapshot() {
316 return rootState!
317}
318
319export function routeInfoSnapshot() {
320 return routeInfo!
321}
322
323// Hook functions
324export function useOneRouter() {
325 return useSyncExternalStore(subscribeToStore, snapshot, snapshot)
326}
327
328function syncStoreRootState() {
329 if (!navigationRef) {
330 throw new Error(`No navigationRef, possible duplicate One dep`)
331 }
332 if (navigationRef.isReady()) {
333 const currentState = navigationRef.getRootState() as unknown as OneRouter.ResultState
334 if (rootState !== currentState) {
335 updateState(currentState)
336 }
337 }
338}
339
340export function useStoreRootState() {
341 syncStoreRootState()
342 return useSyncExternalStore(subscribeToRootState, rootStateSnapshot, rootStateSnapshot)
343}
344
345export function useStoreRouteInfo() {
346 syncStoreRootState()
347 return useSyncExternalStore(subscribeToRootState, routeInfoSnapshot, routeInfoSnapshot)
348}
349
350// Utility functions
351function isIndexPath(state: State) {
352 const route = getActualLastRoute(state.routes[state.index ?? state.routes.length - 1])
353
354 if (route.state) {
355 return isIndexPath(route.state)
356 }
357
358 if (route.name === 'index') {
359 return true
360 }
361
362 if (route.params && 'screen' in route.params) {
363 return route.params.screen === 'index'
364 }
365
366 if (route.name.match(/.+\/index$/)) {
367 return true
368 }
369
370 return false
371}
372
373type RouteLikeTree = { name: string; state?: { routes?: RouteLikeTree[] } }
374
375function getActualLastRoute<A extends RouteLikeTree>(routeLike: A): A {
376 if (routeLike.name[0] === '(' && routeLike.state?.routes) {
377 const routes = routeLike.state.routes
378 return getActualLastRoute(routes[routes.length - 1]) as any
379 }
380 return routeLike
381}
382
383// Cleanup function
384export function cleanup() {
385 if (splashScreenAnimationFrame) {
386 cancelAnimationFrame(splashScreenAnimationFrame)
387 }
388}
389
390// TODO
391export const preloadingLoader = {}
392
393function setupPreload(href: string) {
394 if (preloadingLoader[href]) return
395 preloadingLoader[href] = async () => {
396 const [_preload, loader] = await Promise.all([
397 dynamicImport(getPreloadPath(href)),
398 dynamicImport(getLoaderPath(href)),
399 ])
400
401 try {
402 const response = await loader
403 return await response.loader?.()
404 } catch (err) {
405 console.error(`Error preloading loader: ${err}`)
406 return null
407 }
408 }
409}
410
411export function preloadRoute(href: string) {
412 if (process.env.TAMAGUI_TARGET === 'native') {
413 // not enabled for now
414 return
415 }
416 if (process.env.NODE_ENV === 'development') {
417 return
418 }
419
420 setupPreload(href)
421 if (typeof preloadingLoader[href] === 'function') {
422 void preloadingLoader[href]()
423 }
424}
425
426export async function linkTo(href: string, event?: string, options?: OneRouter.LinkToOptions) {
427 if (href[0] === '#') {
428 // this is just linking to a section of the current page on web
429 return
430 }
431
432 if (shouldLinkExternally(href)) {
433 Linking.openURL(href)
434 return
435 }
436
437 assertIsReady(navigationRef)
438 const current = navigationRef.current
439
440 if (current == null) {
441 throw new Error(
442 "Couldn't find a navigation object. Is your component inside NavigationContainer?"
443 )
444 }
445
446 if (!linking) {
447 throw new Error('Attempted to link to route when no routes are present')
448 }
449
450 setLastAction()
451
452 if (href === '..' || href === '../') {
453 current.goBack()
454 return
455 }
456
457 if (href.startsWith('.')) {
458 // Resolve base path by merging the current segments with the params
459 let base =
460 routeInfo?.segments
461 ?.map((segment) => {
462 if (!segment.startsWith('[')) return segment
463
464 if (segment.startsWith('[...')) {
465 segment = segment.slice(4, -1)
466 const params = routeInfo?.params?.[segment]
467 if (Array.isArray(params)) {
468 return params.join('/')
469 }
470 return params?.split(',')?.join('/') ?? ''
471 }
472 segment = segment.slice(1, -1)
473 return routeInfo?.params?.[segment]
474 })
475 .filter(Boolean)
476 .join('/') ?? '/'
477
478 if (!routeInfo?.isIndex) {
479 base += '/..'
480 }
481
482 href = resolve(base, href)
483 }
484
485 const state = linking.getStateFromPath!(href, linking.config)
486
487 if (!state || state.routes.length === 0) {
488 console.error('Could not generate a valid navigation state for the given path: ' + href)
489 console.error(`linking.config`, linking.config)
490 console.error(`routes`, getSortedRoutes())
491 return
492 }
493
494 setLoadingState('loading')
495
496 // todo
497 globalThis['__vxrntodopath'] = removeSearch(href)
498 preloadRoute(href)
499
500 const rootState = navigationRef.getRootState()
501 const action = getNavigateAction(state, rootState, event)
502
503 // a bit hacky until can figure out a reliable way to tie it to the state
504 nextOptions = options ?? null
505
506 startTransition(() => {
507 const current = navigationRef.getCurrentRoute()
508
509 navigationRef.dispatch(action)
510 let warningTm
511 const interval = setInterval(() => {
512 const next = navigationRef.getCurrentRoute()
513 if (current !== next) {
514 // let the main thread clear at least before running
515 setTimeout(() => {
516 setLoadingState('loaded')
517 })
518 }
519 clearTimeout(warningTm)
520 clearTimeout(interval)
521 }, 16)
522 if (process.env.NODE_ENV === 'development') {
523 warningTm = setTimeout(() => {
524 console.warn(`Routing took more than 8 seconds`)
525 }, 1000)
526 }
527 })
528
529 return
530}
531
532let nextOptions: OneRouter.LinkToOptions | null = null
533
534function getNavigateAction(
535 actionState: OneRouter.ResultState,
536 navigationState: NavigationState,
537 type = 'NAVIGATE'
538) {
539 /**
540 * We need to find the deepest navigator where the action and current state diverge, If they do not diverge, the
541 * lowest navigator is the target.
542 *
543 * By default React Navigation will target the current navigator, but this doesn't work for all actions
544 * For example:
545 * - /deeply/nested/route -> /top-level-route the target needs to be the top-level navigator
546 * - /stack/nestedStack/page -> /stack1/nestedStack/other-page needs to target the nestedStack navigator
547 *
548 * This matching needs to done by comparing the route names and the dynamic path, for example
549 * - /1/page -> /2/anotherPage needs to target the /[id] navigator
550 *
551 * Other parameters such as search params and hash are not evaluated.
552 *
553 */
554 let actionStateRoute: PartialRoute<any> | undefined
555
556 // Traverse the state tree comparing the current state and the action state until we find where they diverge
557 while (actionState && navigationState) {
558 const stateRoute = navigationState.routes[navigationState.index]
559
560 actionStateRoute = actionState.routes[actionState.routes.length - 1]
561
562 const childState = actionStateRoute.state
563 const nextNavigationState = stateRoute.state
564
565 const dynamicName = matchDynamicName(actionStateRoute.name)
566
567 const didActionAndCurrentStateDiverge =
568 actionStateRoute.name !== stateRoute.name ||
569 !childState ||
570 !nextNavigationState ||
571 (dynamicName && actionStateRoute.params?.[dynamicName] !== stateRoute.params?.[dynamicName])
572
573 if (didActionAndCurrentStateDiverge) {
574 break
575 }
576
577 actionState = childState
578 navigationState = nextNavigationState as NavigationState
579 }
580
581 /*
582 * We found the target navigator, but the payload is in the incorrect format
583 * We need to convert the action state to a payload that can be dispatched
584 */
585 const rootPayload: Record<string, any> = { params: {} }
586 let payload = rootPayload
587 let params = payload.params
588
589 // The root level of payload is a bit weird, its params are in the child object
590 while (actionStateRoute) {
591 Object.assign(params, { ...actionStateRoute.params })
592 payload.screen = actionStateRoute.name
593 payload.params = { ...actionStateRoute.params }
594
595 actionStateRoute = actionStateRoute.state?.routes[actionStateRoute.state?.routes.length - 1]
596
597 payload.params ??= {}
598 payload = payload.params
599 params = payload
600 }
601
602 // One uses only three actions, but these don't directly translate to all navigator actions
603 if (type === 'PUSH') {
604 setLastAction()
605
606 // Only stack navigators have a push action, and even then we want to use NAVIGATE (see below)
607 type = 'NAVIGATE'
608
609 /*
610 * The StackAction.PUSH does not work correctly with One.
611 *
612 * One provides a getId() function for every route, altering how React Navigation handles stack routing.
613 * Ordinarily, PUSH always adds a new screen to the stack. However, with getId() present, it navigates to the screen with the matching ID instead (by moving the screen to the top of the stack)
614 * When you try and push to a screen with the same ID, no navigation will occur
615 * Refer to: https://github.com/react-navigation/react-navigation/blob/13d4aa270b301faf07960b4cd861ffc91e9b2c46/packages/routers/src/StackRouter.tsx#L279-L290
616 *
617 * One needs to retain the default behavior of PUSH, consistently adding new screens to the stack, even if their IDs are identical.
618 *
619 * To resolve this issue, we switch to using a NAVIGATE action with a new key. In the navigate action, screens are matched by either key or getId() function.
620 * By generating a unique new key, we ensure that the screen is always pushed onto the stack.
621 *
622 */
623 if (navigationState.type === 'stack') {
624 rootPayload.key = `${rootPayload.name}-${nanoid()}` // @see https://github.com/react-navigation/react-navigation/blob/13d4aa270b301faf07960b4cd861ffc91e9b2c46/packages/routers/src/StackRouter.tsx#L406-L407
625 }
626 }
627
628 if (type === 'REPLACE' && navigationState.type === 'tab') {
629 type = 'JUMP_TO'
630 }
631
632 return {
633 type,
634 target: navigationState.key,
635 payload: {
636 key: rootPayload.key,
637 name: rootPayload.screen,
638 params: rootPayload.params,
639 },
640 }
641}