UNPKG

one

Version:

One is a new React Framework that makes Vite serve both native and web.

516 lines (425 loc) 13 kB
/** * Note: this entire module is exported as an interface router.* * We need to treat exports as an API and not change them, maybe not * the best decision. */ import { type NavigationContainerRefWithCurrent, StackActions } from '@react-navigation/native' import * as Linking from 'expo-linking' import { type ComponentType, Fragment, startTransition, useSyncExternalStore } from 'react' import { Platform } from 'react-native' import type { OneRouter } from '../interfaces/router' import { resolveHref } from '../link/href' import { resolve } from '../link/path' import { assertIsReady } from '../utils/assertIsReady' import { getLoaderPath, getPreloadPath } from '../utils/cleanUrl' import { dynamicImport } from '../utils/dynamicImport' import { shouldLinkExternally } from '../utils/url' import type { One } from '../vite/types' import type { UrlObject } from './getNormalizedStatePath' import { getRouteInfo } from './getRouteInfo' import { getRoutes } from './getRoutes' import { setLastAction } from './lastAction' import { getLinking, resetLinking, setupLinking } from './linkingConfig' import type { RouteNode } from './Route' import { sortRoutes } from './sortRoutes' import { getQualifiedRouteComponent } from './useScreens' import { getNavigateAction } from './utils/getNavigateAction' // Module-scoped variables export let routeNode: RouteNode | null = null export let rootComponent: ComponentType export let hasAttemptedToHideSplash = false export let initialState: OneRouter.ResultState | undefined export let rootState: OneRouter.ResultState | undefined let nextState: OneRouter.ResultState | undefined export let routeInfo: UrlObject | undefined let splashScreenAnimationFrame: number | undefined // we always set it export let navigationRef: OneRouter.NavigationRef = null as any let navigationRefSubscription: () => void const rootStateSubscribers = new Set<OneRouter.RootStateListener>() const loadingStateSubscribers = new Set<OneRouter.LoadingStateListener>() const storeSubscribers = new Set<() => void>() // Initialize function export function initialize( context: One.RouteContext, ref: NavigationContainerRefWithCurrent<ReactNavigation.RootParamList>, initialLocation?: URL ) { cleanUpState() routeNode = getRoutes(context, { ignoreEntryPoints: true, platform: Platform.OS, }) rootComponent = routeNode ? getQualifiedRouteComponent(routeNode) : Fragment if (!routeNode && process.env.NODE_ENV === 'production') { throw new Error('No routes found') } navigationRef = ref setupLinkingAndRouteInfo(initialLocation) subscribeToNavigationChanges() } function cleanUpState() { initialState = undefined rootState = undefined nextState = undefined routeInfo = undefined resetLinking() navigationRefSubscription?.() rootStateSubscribers.clear() storeSubscribers.clear() } function setupLinkingAndRouteInfo(initialLocation?: URL) { initialState = setupLinking(routeNode, initialLocation) if (initialState) { rootState = initialState routeInfo = getRouteInfo(initialState) } else { routeInfo = { unstable_globalHref: '', pathname: '', isIndex: false, params: {}, segments: [], } } } function subscribeToNavigationChanges() { navigationRefSubscription = navigationRef.addListener('state', (data) => { let state = { ...data.data.state } as OneRouter.ResultState if (state.key) { if (hashes[state.key]) { state.hash = hashes[state.key] delete hashes[state.key] } } if (!hasAttemptedToHideSplash) { hasAttemptedToHideSplash = true splashScreenAnimationFrame = requestAnimationFrame(() => { // SplashScreen._internal_maybeHideAsync?.(); }) } if (nextOptions) { state = { ...state, linkOptions: nextOptions } nextOptions = null } let shouldUpdateSubscribers = nextState === state nextState = undefined if (state && state !== rootState) { updateState(state, undefined) shouldUpdateSubscribers = true } if (shouldUpdateSubscribers) { startTransition(() => { for (const subscriber of rootStateSubscribers) { subscriber(state) } }) } }) startTransition(() => { updateSnapshot() for (const subscriber of storeSubscribers) { subscriber() } }) } // Navigation functions export function navigate(url: OneRouter.Href, options?: OneRouter.LinkToOptions) { return linkTo(resolveHref(url), 'NAVIGATE', options) } export function push(url: OneRouter.Href, options?: OneRouter.LinkToOptions) { return linkTo(resolveHref(url), 'PUSH', options) } export function dismiss(count?: number) { navigationRef?.dispatch(StackActions.pop(count)) } export function replace(url: OneRouter.Href, options?: OneRouter.LinkToOptions) { return linkTo(resolveHref(url), 'REPLACE', options) } export function setParams(params: OneRouter.InpurRouteParamsGeneric = {}) { assertIsReady(navigationRef) return navigationRef?.current?.setParams( // @ts-expect-error params ) } export function dismissAll() { navigationRef?.dispatch(StackActions.popToTop()) } export function goBack() { assertIsReady(navigationRef) navigationRef?.current?.goBack() } export function canGoBack(): boolean { if (!navigationRef.isReady()) { return false } return navigationRef?.current?.canGoBack() ?? false } export function canDismiss(): boolean { let state = rootState while (state) { if (state.type === 'stack' && state.routes.length > 1) { return true } if (state.index === undefined) { return false } state = state.routes?.[state.index]?.state as any } return false } export function getSortedRoutes() { if (!routeNode) { throw new Error('No routes') } return routeNode.children.filter((route) => !route.internal).sort(sortRoutes) } export function updateState(state: OneRouter.ResultState, nextStateParam = state) { rootState = state nextState = nextStateParam const nextRouteInfo = getRouteInfo(state) if (!deepEqual(routeInfo, nextRouteInfo)) { routeInfo = nextRouteInfo } } // Subscription functions export function subscribeToRootState(subscriber: OneRouter.RootStateListener) { rootStateSubscribers.add(subscriber) return () => { rootStateSubscribers.delete(subscriber) } } export function subscribeToStore(subscriber: () => void) { storeSubscribers.add(subscriber) return () => { storeSubscribers.delete(subscriber) } } // Subscription functions export function subscribeToLoadingState(subscriber: OneRouter.LoadingStateListener) { loadingStateSubscribers.add(subscriber) return () => { loadingStateSubscribers.delete(subscriber) } } export function setLoadingState(state: OneRouter.LoadingState) { startTransition(() => { for (const listener of loadingStateSubscribers) { listener(state) } }) } // Snapshot function let currentSnapshot: ReturnType<typeof getSnapshot> | null = null function updateSnapshot() { currentSnapshot = getSnapshot() } export function snapshot() { return currentSnapshot! } function getSnapshot() { return { linkTo, routeNode, rootComponent, linking: getLinking(), hasAttemptedToHideSplash, initialState, rootState, nextState, routeInfo, splashScreenAnimationFrame, navigationRef, navigationRefSubscription, rootStateSubscribers, storeSubscribers, } } export function rootStateSnapshot() { return rootState! } export function routeInfoSnapshot() { return routeInfo! } // Hook functions export function useOneRouter() { return useSyncExternalStore(subscribeToStore, snapshot, snapshot) } function syncStoreRootState() { if (!navigationRef) { throw new Error(`No navigationRef, possible duplicate One dep`) } if (navigationRef.isReady()) { const currentState = navigationRef.getRootState() as unknown as OneRouter.ResultState if (rootState !== currentState) { updateState(currentState) } } } export function useStoreRootState() { syncStoreRootState() return useSyncExternalStore(subscribeToRootState, rootStateSnapshot, rootStateSnapshot) } export function useStoreRouteInfo() { syncStoreRootState() return useSyncExternalStore(subscribeToRootState, routeInfoSnapshot, routeInfoSnapshot) } // Cleanup function export function cleanup() { if (splashScreenAnimationFrame) { cancelAnimationFrame(splashScreenAnimationFrame) } } // TODO export const preloadingLoader = {} function setupPreload(href: string) { if (preloadingLoader[href]) return preloadingLoader[href] = async () => { try { const [_preload, loader] = await Promise.all([ dynamicImport(getPreloadPath(href)), dynamicImport(getLoaderPath(href)), ]) const response = await loader return await response.loader?.() } catch (err) { console.error(`Error preloading loader: ${err}`) return null } } } export function preloadRoute(href: string) { if (process.env.TAMAGUI_TARGET === 'native') { // not enabled for now return } if (process.env.NODE_ENV === 'development') { return } setupPreload(href) if (typeof preloadingLoader[href] === 'function') { void preloadingLoader[href]() } } export async function linkTo(href: string, event?: string, options?: OneRouter.LinkToOptions) { if (href[0] === '#') { // this is just linking to a section of the current page on web return } if (shouldLinkExternally(href)) { Linking.openURL(href) return } assertIsReady(navigationRef) const current = navigationRef.current if (current == null) { throw new Error( "Couldn't find a navigation object. Is your component inside NavigationContainer?" ) } const linking = getLinking() if (!linking) { throw new Error('Attempted to link to route when no routes are present') } setLastAction() if (href === '..' || href === '../') { current.goBack() return } if (href.startsWith('.')) { // Resolve base path by merging the current segments with the params let base = routeInfo?.segments ?.map((segment) => { if (!segment.startsWith('[')) return segment if (segment.startsWith('[...')) { segment = segment.slice(4, -1) const params = routeInfo?.params?.[segment] if (Array.isArray(params)) { return params.join('/') } return params?.split(',')?.join('/') ?? '' } segment = segment.slice(1, -1) return routeInfo?.params?.[segment] }) .filter(Boolean) .join('/') ?? '/' if (!routeInfo?.isIndex) { base += '/..' } href = resolve(base, href) } const state = linking.getStateFromPath!(href, linking.config) if (!state || state.routes.length === 0) { console.error('Could not generate a valid navigation state for the given path: ' + href) console.error(`linking.config`, linking.config) console.error(`routes`, getSortedRoutes()) return } setLoadingState('loading') preloadRoute(href) const rootState = navigationRef.getRootState() const hash = href.indexOf('#') if (rootState.key && hash > 0) { hashes[rootState.key] = href.slice(hash) } // a bit hacky until can figure out a reliable way to tie it to the state nextOptions = options ?? null startTransition(() => { const action = getNavigateAction(state, rootState, event) const current = navigationRef.getCurrentRoute() navigationRef.dispatch(action) let warningTm const interval = setInterval(() => { const next = navigationRef.getCurrentRoute() if (current !== next) { // let the main thread clear at least before running setTimeout(() => { setLoadingState('loaded') }) } clearTimeout(warningTm) clearTimeout(interval) }, 16) if (process.env.NODE_ENV === 'development') { warningTm = setTimeout(() => { console.warn(`Routing took more than 8 seconds`) }, 1000) } }) return } const hashes: Record<string, string> = {} let nextOptions: OneRouter.LinkToOptions | null = null function deepEqual(a: any, b: any) { if (a === b) { return true } if (Array.isArray(a) && Array.isArray(b)) { if (a.length !== b.length) { return false } for (let i = 0; i < a.length; i++) { if (!deepEqual(a[i], b[i])) { return false } } return true } if (typeof a === 'object' && typeof b === 'object') { const keysA = Object.keys(a) const keysB = Object.keys(b) if (keysA.length !== keysB.length) { return false } for (const key of keysA) { if (!deepEqual(a[key], b[key])) { return false } } return true } return false }