UNPKG

one

Version:

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

913 lines (773 loc) 25 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 { type ComponentType, Fragment, startTransition, useDeferredValue, useSyncExternalStore, } from 'react' import { Platform } from 'react-native' import type { OneRouter } from '../interfaces/router' import { resolveHref } from '../link/href' import { openExternalURL } from '../link/openExternalURL' import { resolve } from '../link/path' import { assertIsReady } from '../utils/assertIsReady' import { getLoaderPath, getPreloadCSSPath, 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 { preloadRouteModules } from './useViteRoutes' import { getNavigateAction } from './utils/getNavigateAction' import { findRouteNodeFromState, extractParamsFromState, extractSearchFromHref, extractPathnameFromHref, } from './findRouteNode' import { validateParams as runValidateParams, RouteValidationError, ParamValidationError, } from '../validateParams' import { checkBlocker } from '../useBlocker' import { devtoolsRegistry } from '../devtools/registry' // Module-scoped variables export let routeNode: RouteNode | null = null export let rootComponent: ComponentType // Global registry for protected routes // Key: contextKey (e.g., '/protected-test'), Value: Set of protected route names const protectedRouteRegistry = new Map<string, Set<string>>() /** * Register protected routes for a navigator context. * Called by navigators when their protectedScreens changes. */ export function registerProtectedRoutes( contextKey: string, protectedScreens: Set<string> ) { if (protectedScreens.size === 0) { protectedRouteRegistry.delete(contextKey) } else { protectedRouteRegistry.set(contextKey, protectedScreens) } } /** * Unregister protected routes for a navigator context. * Called when a navigator unmounts. */ export function unregisterProtectedRoutes(contextKey: string) { protectedRouteRegistry.delete(contextKey) } /** * Check if a route path is protected and should be blocked. * Returns true if the route is protected. */ export function isRouteProtected(href: string): boolean { // Normalize the href (remove leading/trailing slashes) const normalizedHref = href.replace(/^\/+|\/+$/g, '') // Check each navigator context to see if this route is protected for (const [contextKey, protectedScreens] of protectedRouteRegistry) { const normalizedContextKey = contextKey.replace(/^\/+|\/+$/g, '') // Check if this href is under this context if (normalizedHref.startsWith(normalizedContextKey)) { // Get the route name relative to this context const relativePath = normalizedHref .slice(normalizedContextKey.length) .replace(/^\//, '') const routeName = relativePath.split('/')[0] || 'index' if (protectedScreens.has(routeName)) { return true } } } return false } 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>() // Validation state tracking export type ValidationState = { status: 'idle' | 'validating' | 'error' | 'valid' error?: Error lastValidatedHref?: string } let validationState: ValidationState = { status: 'idle' } const validationStateSubscribers = new Set<(state: ValidationState) => void>() export function subscribeToValidationState(subscriber: (state: ValidationState) => void) { validationStateSubscribers.add(subscriber) return () => validationStateSubscribers.delete(subscriber) } export function setValidationState(state: ValidationState) { validationState = state for (const subscriber of validationStateSubscribers) { subscriber(state) } // Dispatch event for devtools if ( process.env.TAMAGUI_TARGET !== 'native' && state.status === 'error' && state.error ) { window.dispatchEvent( new CustomEvent('one-validation-error', { detail: { error: { message: state.error.message, name: state.error.name, stack: state.error.stack, }, href: state.lastValidatedHref, timestamp: Date.now(), }, }) ) } } export function getValidationState(): ValidationState { return validationState } export function useValidationState() { return useSyncExternalStore( subscribeToValidationState, getValidationState, getValidationState ) } // 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') } if (process.env.ONE_DEBUG_ROUTER && routeNode) { const formatRouteTree = (node: RouteNode, indent = '', isLast = true): string => { const prefix = indent + (isLast ? '└─ ' : '├─ ') const childIndent = indent + (isLast ? ' ' : '│ ') const dynamicBadge = node.dynamic ? ` [${node.dynamic.map((d) => d.name).join(', ')}]` : '' const typeBadge = node.type !== 'layout' ? ` (${node.type})` : '' const routeName = node.route || '/' let line = `${prefix}${routeName}${dynamicBadge}${typeBadge}` const visibleChildren = node.children.filter((child) => !child.internal) for (let i = 0; i < visibleChildren.length; i++) { const child = visibleChildren[i] const childIsLast = i === visibleChildren.length - 1 line += '\n' + formatRouteTree(child, childIndent, childIsLast) } return line } console.info(`[one] 📍 Route structure:\n${formatRouteTree(routeNode)}`) } 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) { if (process.env.ONE_DEBUG_ROUTER) { console.info(`[one] 🔙 dismiss${count ? ` (${count})` : ''}`) } 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() { if (process.env.ONE_DEBUG_ROUTER) { console.info(`[one] 🔙 dismissAll`) } navigationRef?.dispatch(StackActions.popToTop()) } export function goBack() { if (process.env.ONE_DEBUG_ROUTER) { console.info(`[one] 🔙 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)) { if (process.env.ONE_DEBUG_ROUTER) { const from = routeInfo?.pathname || '(initial)' const to = nextRouteInfo.pathname const params = Object.keys(nextRouteInfo.params || {}).length ? nextRouteInfo.params : undefined console.info(`[one] 🧭 ${from}${to}`, params ? { params } : '') } routeInfo = nextRouteInfo } // Expose devtools API in development if (process.env.NODE_ENV === 'development' && typeof window !== 'undefined') { // Use registry to avoid circular deps - useLoader registers its function there ;(window as any).__oneDevtools = { routeInfo: nextRouteInfo, rootState: state, routeNode, getRoutes: () => routeNode?.children || [], getLoaderTimingHistory: () => devtoolsRegistry.getLoaderTimingHistory?.() ?? [], getPreloadHistory, } // Dispatch event for devtools panels to listen if (process.env.TAMAGUI_TARGET !== 'native') { window.dispatchEvent(new CustomEvent('one-route-change', { detail: 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() { const state = useSyncExternalStore(subscribeToStore, snapshot, snapshot) // useDeferredValue makes the transition concurrent, preventing main thread blocking return useDeferredValue(state) } 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() const state = useSyncExternalStore( subscribeToRootState, rootStateSnapshot, rootStateSnapshot ) return useDeferredValue(state) } export function useStoreRouteInfo() { syncStoreRootState() const state = useSyncExternalStore( subscribeToRootState, routeInfoSnapshot, routeInfoSnapshot ) return useDeferredValue(state) } // Cleanup function export function cleanup() { if (splashScreenAnimationFrame) { cancelAnimationFrame(splashScreenAnimationFrame) } } // TODO export const preloadingLoader: Record<string, Promise<any> | undefined> = {} async function doPreload(href: string) { const preloadPath = getPreloadPath(href) const loaderPath = getLoaderPath(href) const cssPreloadPath = getPreloadCSSPath(href) recordPreloadStart(href) try { const [_preload, cssPreloadModule, loader] = await Promise.all([ dynamicImport(preloadPath), dynamicImport(cssPreloadPath)?.catch(() => null) ?? Promise.resolve(null), // graceful fail if no CSS preload dynamicImport(loaderPath)?.catch(() => null) ?? Promise.resolve(null), // graceful fail if no loader file preloadRouteModules(href), ]) // Store the CSS inject function for later use on navigation const hasCss = !!cssPreloadModule?.injectCSS if (hasCss) { cssInjectFunctions[href] = cssPreloadModule.injectCSS } const hasLoader = !!loader?.loader if (!hasLoader) { recordPreloadComplete(href, false, hasCss) return null } const result = await loader.loader() recordPreloadComplete(href, true, hasCss) return result ?? null } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err) console.error(`[one] preload error for ${href}:`, err) recordPreloadError(href, errorMessage) return null } } // Store resolved preload data separately from promises export const preloadedLoaderData: Record<string, any> = {} // Store CSS inject functions for calling on navigation const cssInjectFunctions: Record<string, (() => Promise<void[]>) | undefined> = {} // Preload status tracking for devtools export type PreloadStatus = 'pending' | 'loading' | 'loaded' | 'error' export type PreloadEntry = { href: string status: PreloadStatus startTime: number endTime?: number error?: string hasLoader: boolean hasCss: boolean } const preloadHistory: PreloadEntry[] = [] const MAX_PRELOAD_HISTORY = 30 // Preload tracking functions - only do work in development for devtools function recordPreloadStart(href: string) { if (process.env.NODE_ENV !== 'development') return const existing = preloadHistory.find((p) => p.href === href) if (existing) { existing.status = 'loading' existing.startTime = performance.now() return } preloadHistory.unshift({ href, status: 'loading', startTime: performance.now(), hasLoader: false, hasCss: false, }) if (preloadHistory.length > MAX_PRELOAD_HISTORY) { preloadHistory.pop() } dispatchPreloadEvent() } function recordPreloadComplete(href: string, hasLoader: boolean, hasCss: boolean) { if (process.env.NODE_ENV !== 'development') return const entry = preloadHistory.find((p) => p.href === href) if (entry) { entry.status = 'loaded' entry.endTime = performance.now() entry.hasLoader = hasLoader entry.hasCss = hasCss } dispatchPreloadEvent() } function recordPreloadError(href: string, error: string) { if (process.env.NODE_ENV !== 'development') return const entry = preloadHistory.find((p) => p.href === href) if (entry) { entry.status = 'error' entry.endTime = performance.now() entry.error = error } dispatchPreloadEvent() } function dispatchPreloadEvent() { if (process.env.TAMAGUI_TARGET !== 'native') { window.dispatchEvent(new CustomEvent('one-preload-update')) } } export function getPreloadHistory(): PreloadEntry[] { return preloadHistory } export function preloadRoute(href: string, injectCSS = false): Promise<any> | undefined { if (process.env.TAMAGUI_TARGET === 'native') { return } if (process.env.NODE_ENV === 'development') { return } if (!preloadingLoader[href]) { preloadingLoader[href] = doPreload(href).then((data) => { // Store the resolved data for synchronous access preloadedLoaderData[href] = data return data }) } if (injectCSS) { // Wait for preload to populate cssInjectFunctions, then inject CSS (max 500ms) return preloadingLoader[href]?.then(async (data) => { const inject = cssInjectFunctions[href] if (inject) { await Promise.race([inject(), new Promise((r) => setTimeout(r, 500))]) } return data }) } return preloadingLoader[href] } export async function linkTo( href: string, event?: string, options?: OneRouter.LinkToOptions ) { if (process.env.ONE_DEBUG_ROUTER) { console.info(`[one] 🔗 ${event || 'NAVIGATE'} ${href}`) } if (href[0] === '#') { // this is just linking to a section of the current page on web return } if (shouldLinkExternally(href)) { openExternalURL(href) return } // Check if any blocker wants to block this navigation (web only) if (checkBlocker(href, event === 'REPLACE' ? 'replace' : 'push')) { return } // Check if the route is protected and should be blocked if (isRouteProtected(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') // Preload route modules first so loadRoute() won't throw Suspense promises await preloadRoute(href, true) // Run async route validation before navigation const matchingRouteNode = findRouteNodeFromState(state, routeNode) if (matchingRouteNode?.loadRoute) { setValidationState({ status: 'validating', lastValidatedHref: href }) try { const loadedRoute = matchingRouteNode.loadRoute() const params = extractParamsFromState(state) const search = extractSearchFromHref(href) const pathname = extractPathnameFromHref(href) // Run validateParams if exported if (loadedRoute.validateParams) { runValidateParams(loadedRoute.validateParams, params) } // Run validateRoute if exported if (loadedRoute.validateRoute) { const validationResult = await loadedRoute.validateRoute({ params, search, pathname, href, }) // Check for explicit invalid result if (validationResult && !validationResult.valid) { const error = new RouteValidationError( validationResult.error || 'Route validation failed', validationResult.details ) setValidationState({ status: 'error', error, lastValidatedHref: href }) throw error } } setValidationState({ status: 'valid', lastValidatedHref: href }) } catch (error) { // Handle Suspense promises thrown by loadRoute in dev mode if (error && typeof (error as any).then === 'function') { // Wait for the route to load and skip validation for this navigation await (error as Promise<any>).catch(() => {}) setValidationState({ status: 'valid', lastValidatedHref: href }) } else if ( error instanceof ParamValidationError || error instanceof RouteValidationError ) { setValidationState({ status: 'error', error, lastValidatedHref: href }) throw error } else { // Re-throw other errors throw error } } } 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 }