1 | import type { NavigationState, PartialRoute } from '@react-navigation/native'
|
2 | import {
|
3 | StackActions,
|
4 | type NavigationContainerRefWithCurrent,
|
5 | type getPathFromState as originalGetPathFromState,
|
6 | } from '@react-navigation/native'
|
7 | import * as Linking from 'expo-linking'
|
8 | import { nanoid } from 'nanoid/non-secure'
|
9 | import { Fragment, startTransition, useSyncExternalStore, type ComponentType } from 'react'
|
10 | import { Platform } from 'react-native'
|
11 | import type { RouteNode } from '../Route'
|
12 | import { getLoaderPath, getPreloadPath } from '../cleanUrl'
|
13 | import type { State } from '../fork/getPathFromState'
|
14 | import { deepEqual, getPathDataFromState } from '../fork/getPathFromState'
|
15 | import { stripBaseUrl } from '../fork/getStateFromPath'
|
16 | import { getLinkingConfig, type ExpoLinkingOptions } from '../getLinkingConfig'
|
17 | import { getRoutes } from '../getRoutes'
|
18 | import type { OneRouter } from '../interfaces/router'
|
19 | import { resolveHref } from '../link/href'
|
20 | import { resolve } from '../link/path'
|
21 | import { matchDynamicName } from '../matchers'
|
22 | import { sortRoutes } from '../sortRoutes'
|
23 | import { getQualifiedRouteComponent } from '../useScreens'
|
24 | import { assertIsReady } from '../utils/assertIsReady'
|
25 | import { dynamicImport } from '../utils/dynamicImport'
|
26 | import { removeSearch } from '../utils/removeSearch'
|
27 | import { shouldLinkExternally } from '../utils/url'
|
28 | import type { One } from '../vite/types'
|
29 | import { getNormalizedStatePath, type UrlObject } from './getNormalizedStatePath'
|
30 | import { setLastAction } from './lastAction'
|
31 |
|
32 |
|
33 | export let routeNode: RouteNode | null = null
|
34 | export let rootComponent: ComponentType
|
35 | export let linking: ExpoLinkingOptions | undefined
|
36 |
|
37 | export let hasAttemptedToHideSplash = false
|
38 | export let initialState: OneRouter.ResultState | undefined
|
39 | export let rootState: OneRouter.ResultState | undefined
|
40 |
|
41 | let nextState: OneRouter.ResultState | undefined
|
42 | export let routeInfo: UrlObject | undefined
|
43 | let splashScreenAnimationFrame: number | undefined
|
44 |
|
45 |
|
46 | export let navigationRef: OneRouter.NavigationRef = null as any
|
47 | let navigationRefSubscription: () => void
|
48 |
|
49 | const rootStateSubscribers = new Set<OneRouter.RootStateListener>()
|
50 | const loadingStateSubscribers = new Set<OneRouter.LoadingStateListener>()
|
51 | const storeSubscribers = new Set<() => void>()
|
52 |
|
53 |
|
54 | export 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 |
|
75 | function 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 |
|
86 | function 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 |
|
113 | function 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 |
|
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 |
|
151 | export function navigate(url: OneRouter.Href, options?: OneRouter.LinkToOptions) {
|
152 | return linkTo(resolveHref(url), 'NAVIGATE', options)
|
153 | }
|
154 |
|
155 | export function push(url: OneRouter.Href, options?: OneRouter.LinkToOptions) {
|
156 | return linkTo(resolveHref(url), 'PUSH', options)
|
157 | }
|
158 |
|
159 | export function dismiss(count?: number) {
|
160 | navigationRef?.dispatch(StackActions.pop(count))
|
161 | }
|
162 |
|
163 | export function replace(url: OneRouter.Href, options?: OneRouter.LinkToOptions) {
|
164 | return linkTo(resolveHref(url), 'REPLACE', options)
|
165 | }
|
166 |
|
167 | export function setParams(params: Record<string, string | number> = {}) {
|
168 | assertIsReady(navigationRef)
|
169 | return navigationRef?.current?.setParams(
|
170 |
|
171 | params
|
172 | )
|
173 | }
|
174 |
|
175 | export function dismissAll() {
|
176 | navigationRef?.dispatch(StackActions.popToTop())
|
177 | }
|
178 |
|
179 | export function goBack() {
|
180 | assertIsReady(navigationRef)
|
181 | navigationRef?.current?.goBack()
|
182 | }
|
183 |
|
184 | export function canGoBack(): boolean {
|
185 | if (!navigationRef.isReady()) {
|
186 | return false
|
187 | }
|
188 | return navigationRef?.current?.canGoBack() ?? false
|
189 | }
|
190 |
|
191 | export 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 |
|
207 | export function getSortedRoutes() {
|
208 | if (!routeNode) {
|
209 | throw new Error('No routes')
|
210 | }
|
211 | return routeNode.children.filter((route) => !route.internal).sort(sortRoutes)
|
212 | }
|
213 |
|
214 | export 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 |
|
225 | export 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 |
|
239 | function 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 |
|
256 | export function subscribeToRootState(subscriber: OneRouter.RootStateListener) {
|
257 | rootStateSubscribers.add(subscriber)
|
258 | return () => {
|
259 | rootStateSubscribers.delete(subscriber)
|
260 | }
|
261 | }
|
262 |
|
263 | export function subscribeToStore(subscriber: () => void) {
|
264 | storeSubscribers.add(subscriber)
|
265 | return () => {
|
266 | storeSubscribers.delete(subscriber)
|
267 | }
|
268 | }
|
269 |
|
270 |
|
271 | export function subscribeToLoadingState(subscriber: OneRouter.LoadingStateListener) {
|
272 | loadingStateSubscribers.add(subscriber)
|
273 | return () => {
|
274 | loadingStateSubscribers.delete(subscriber)
|
275 | }
|
276 | }
|
277 |
|
278 | export function setLoadingState(state: OneRouter.LoadingState) {
|
279 | for (const listener of loadingStateSubscribers) {
|
280 | listener(state)
|
281 | }
|
282 | }
|
283 |
|
284 |
|
285 |
|
286 | let currentSnapshot: ReturnType<typeof getSnapshot> | null = null
|
287 |
|
288 | function updateSnapshot() {
|
289 | currentSnapshot = getSnapshot()
|
290 | }
|
291 |
|
292 | export function snapshot() {
|
293 | return currentSnapshot!
|
294 | }
|
295 |
|
296 | function 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 |
|
315 | export function rootStateSnapshot() {
|
316 | return rootState!
|
317 | }
|
318 |
|
319 | export function routeInfoSnapshot() {
|
320 | return routeInfo!
|
321 | }
|
322 |
|
323 |
|
324 | export function useOneRouter() {
|
325 | return useSyncExternalStore(subscribeToStore, snapshot, snapshot)
|
326 | }
|
327 |
|
328 | function 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 |
|
340 | export function useStoreRootState() {
|
341 | syncStoreRootState()
|
342 | return useSyncExternalStore(subscribeToRootState, rootStateSnapshot, rootStateSnapshot)
|
343 | }
|
344 |
|
345 | export function useStoreRouteInfo() {
|
346 | syncStoreRootState()
|
347 | return useSyncExternalStore(subscribeToRootState, routeInfoSnapshot, routeInfoSnapshot)
|
348 | }
|
349 |
|
350 |
|
351 | function 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 |
|
373 | type RouteLikeTree = { name: string; state?: { routes?: RouteLikeTree[] } }
|
374 |
|
375 | function 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 |
|
384 | export function cleanup() {
|
385 | if (splashScreenAnimationFrame) {
|
386 | cancelAnimationFrame(splashScreenAnimationFrame)
|
387 | }
|
388 | }
|
389 |
|
390 |
|
391 | export const preloadingLoader = {}
|
392 |
|
393 | function 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 |
|
411 | export function preloadRoute(href: string) {
|
412 | if (process.env.TAMAGUI_TARGET === 'native') {
|
413 |
|
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 |
|
426 | export async function linkTo(href: string, event?: string, options?: OneRouter.LinkToOptions) {
|
427 | if (href[0] === '#') {
|
428 |
|
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 |
|
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 |
|
497 | globalThis['__vxrntodopath'] = removeSearch(href)
|
498 | preloadRoute(href)
|
499 |
|
500 | const rootState = navigationRef.getRootState()
|
501 | const action = getNavigateAction(state, rootState, event)
|
502 |
|
503 |
|
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 |
|
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 |
|
532 | let nextOptions: OneRouter.LinkToOptions | null = null
|
533 |
|
534 | function getNavigateAction(
|
535 | actionState: OneRouter.ResultState,
|
536 | navigationState: NavigationState,
|
537 | type = 'NAVIGATE'
|
538 | ) {
|
539 | |
540 |
|
541 |
|
542 |
|
543 |
|
544 |
|
545 |
|
546 |
|
547 |
|
548 |
|
549 |
|
550 |
|
551 |
|
552 |
|
553 |
|
554 | let actionStateRoute: PartialRoute<any> | undefined
|
555 |
|
556 |
|
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 |
|
583 |
|
584 |
|
585 | const rootPayload: Record<string, any> = { params: {} }
|
586 | let payload = rootPayload
|
587 | let params = payload.params
|
588 |
|
589 |
|
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 |
|
603 | if (type === 'PUSH') {
|
604 | setLastAction()
|
605 |
|
606 |
|
607 | type = 'NAVIGATE'
|
608 |
|
609 | |
610 |
|
611 |
|
612 |
|
613 |
|
614 |
|
615 |
|
616 |
|
617 |
|
618 |
|
619 |
|
620 |
|
621 |
|
622 |
|
623 | if (navigationState.type === 'stack') {
|
624 | rootPayload.key = `${rootPayload.name}-${nanoid()}`
|
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 | }
|