UNPKG

one

Version:

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

1,178 lines (1,004 loc) 34.7 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 { devtoolsRegistry } from '../devtools/registry' import type { OneRouter } from '../interfaces/router' import { resolveHref } from '../link/href' import { openExternalURL } from '../link/openExternalURL' import { resolve } from '../link/path' import { checkBlocker } from '../useBlocker' import { assertIsReady } from '../utils/assertIsReady' import { getLoaderPath, getPreloadCSSPath, getPreloadPath } from '../utils/cleanUrl' import { dynamicImport } from '../utils/dynamicImport' import { shouldLinkExternally } from '../utils/url' import { ParamValidationError, RouteValidationError, validateParams as runValidateParams, } from '../validateParams' import type { One } from '../vite/types' import { extractParamsFromState, extractPathnameFromHref, extractSearchFromHref, findRouteNodeFromState, findAllRouteNodesFromState, } from './findRouteNode' 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 { setClientMatches } from '../useMatches' import type { RouteMatch } from '../useMatches' import { findInterceptRoute, setNavigationType, updateURLWithoutNavigation, storeInterceptState, } from './interceptRoutes' import { setSlotState } from '../views/Navigator' // 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>() // current matches for useMatches hook (cached on client) let currentMatches: RouteMatch[] = [] // 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 slotsBadge = node.slots?.size ? ` {@${Array.from(node.slots.keys()).join(', @')}}` : '' const routeName = node.route || '/' let line = `${prefix}${routeName}${dynamicBadge}${typeBadge}${slotsBadge}` 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)}`) // Log slot details if (routeNode.slots?.size) { console.info(`[one] 📦 Slots on root layout:`) for (const [slotName, slotConfig] of routeNode.slots) { console.info(` @${slotName}:`, { defaultRoute: slotConfig.defaultRoute?.route, interceptRoutes: slotConfig.interceptRoutes.map((r) => ({ route: r.route, intercept: r.intercept, })), }) } } } navigationRef = ref as unknown as OneRouter.NavigationRef 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 // On native, update client matches when route changes // This enables useMatches to work for initial route and navigation // Loader data will be undefined initially (fetched by useLoader) if (process.env.TAMAGUI_TARGET === 'native') { const params = extractParamsFromState(state) const newMatches = buildNativeMatches(state, nextRouteInfo.pathname, params) currentMatches = newMatches setClientMatches(newMatches) } } // 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 ) // note: we intentionally don't use useDeferredValue here because it can cause // layout flash when conditional rendering depends on pathname. the deferred value // delays the parent layout's update while nested layouts have already unmounted. return state } // Cleanup function export function cleanup() { if (splashScreenAnimationFrame) { cancelAnimationFrame(splashScreenAnimationFrame) } } export const preloadingLoader: Record<string, Promise<any> | undefined> = {} // inlined to ensure tree shakes away in prod // dev mode preload - fetches just the loader directly without production preload bundles async function doPreloadDev(href: string): Promise<any> { if (process.env.NODE_ENV === 'development') { const startTime = performance.now() const normalizedPath = normalizeLoaderPath(href) try { const loaderJSUrl = getLoaderPath(href, true) const moduleLoadStart = performance.now() const modulePromise = dynamicImport(loaderJSUrl) if (!modulePromise) { return null } const module = await modulePromise.catch(() => null) const moduleLoadTime = performance.now() - moduleLoadStart if (!module?.loader) { return null } const executionStart = performance.now() const result = await module.loader() const executionTime = performance.now() - executionStart const totalTime = performance.now() - startTime // detect server redirect signal from loader response if (result?.__oneRedirect) { return result } // record timing for devtools devtoolsRegistry.recordLoaderTiming?.({ path: normalizedPath, startTime, moduleLoadTime, executionTime, totalTime, source: 'preload', }) return result ?? null } catch (err) { const totalTime = performance.now() - startTime // record error timing for devtools devtoolsRegistry.recordLoaderTiming?.({ path: normalizedPath, startTime, totalTime, error: err instanceof Error ? err.message : String(err), source: 'preload', }) // graceful fail - loader will be fetched when component mounts if (process.env.ONE_DEBUG_ROUTER) { console.warn(`[one] dev preload failed for ${href}:`, err) } return null } } } 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() // detect server redirect signal from loader response if (result?.__oneRedirect) { return result } 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') { // in dev mode, use a simpler preload that just fetches the loader directly // this avoids issues with production-only preload paths while still ensuring // loader data is available before navigation completes if (process.env.NODE_ENV === 'development') { // normalize the path to match what useLoader uses for cache keys const normalizedHref = normalizeLoaderPath(href) if (!preloadingLoader[normalizedHref]) { preloadingLoader[normalizedHref] = doPreloadDev(href).then((data) => { preloadedLoaderData[normalizedHref] = data return data }) } return preloadingLoader[normalizedHref] } 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 800ms) return preloadingLoader[href]?.then(async (data) => { const inject = cssInjectFunctions[href] if (inject) { await Promise.race([inject(), new Promise((r) => setTimeout(r, 800))]) } return data }) } return preloadingLoader[href] } } // normalize path to match what useLoader uses for currentPath function normalizeLoaderPath(href: string): string { // remove search params and hash, normalize trailing slashes and /index const url = new URL(href, 'http://example.com') return url.pathname.replace(/\/index$/, '').replace(/\/$/, '') || '/' } /** * Build matches array for client-side navigation. * Preserves layout matches (cached from SSR) and updates page match with fresh data. * * Strategy: Since layouts don't re-run on client navigation, we keep all layout matches * from the current cached matches and only update the page match. */ function buildClientMatches( href: string, matchingNode: RouteNode | null, params: Record<string, string | string[]>, loaderData: unknown ): RouteMatch[] { const pathname = extractPathnameFromHref(href) const routeId = matchingNode?.contextKey || pathname // preserve all layout matches (those with _layout in routeId) from current state // since layout loaders don't re-run on client navigation const layoutMatches = currentMatches.filter((m) => m.routeId.includes('_layout')) // create the new page match with fresh loader data const pageMatch: RouteMatch = { routeId, pathname, params, loaderData, } // return layouts + new page match return [...layoutMatches, pageMatch] } /** * Build all matches for native, including layouts and page. * Unlike web which preserves SSR-hydrated layouts, native builds fresh * since there's no SSR context to hydrate from. */ function buildNativeMatches( state: OneRouter.ResultState, pathname: string, params: Record<string, string | string[]> ): RouteMatch[] { const allNodes = findAllRouteNodesFromState(state, routeNode) return allNodes.map((node) => ({ routeId: node.contextKey || pathname, pathname, params, loaderData: undefined, // loader data is fetched async by useLoader on native })) } /** * Initialize client matches from server context during hydration. * Called from createApp when hydrating. */ export function initClientMatches(matches: RouteMatch[]) { currentMatches = matches setClientMatches(matches) } export async function linkTo( href: string, event?: string, options?: OneRouter.LinkToOptions ) { if (process.env.ONE_DEBUG_ROUTER) { console.info(`[one] 🔗 ${event || 'NAVIGATE'} ${href}`) } // Mark this as a soft navigation (client-side Link click) // This enables intercepting routes to activate setNavigationType('soft') 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 } // Check for intercepting routes (parallel routes with @slot) // This enables modal patterns where soft nav shows modal, hard nav shows full page // Pass root node - findInterceptRoute will traverse to find all layouts with slots along the current path const currentLayoutNode = routeNode const currentPath = routeInfo?.pathname || '/' const interceptResult = findInterceptRoute(href, currentLayoutNode, currentPath) if (interceptResult) { // Found an intercept route! Render in slot instead of full navigation const { interceptRoute, slotName, layoutContextKey, params } = interceptResult // Create scoped slot key to prevent duplicate modals across layouts const scopedSlotKey = `${layoutContextKey}:${slotName}` // Store intercept state for forward navigation restoration storeInterceptState(scopedSlotKey, interceptRoute, params) // Update URL to show the target path (not the intercept route path) updateURLWithoutNavigation(href) // Activate the slot to render the intercept route (using scoped key) setSlotState(scopedSlotKey, { activeRouteKey: interceptRoute.contextKey, activeRouteNode: interceptRoute, params, isIntercepted: true, }) 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) // detect loader redirect signal before proceeding with navigation // this handles the case where a server loader returned redirect() during // a client-side navigation — we navigate to the redirect target instead const normalizedPreloadPath = normalizeLoaderPath(href) const preloadResult = preloadedLoaderData[normalizedPreloadPath] if (preloadResult?.__oneRedirect) { const redirectTarget = preloadResult.__oneRedirect // clean up so subsequent navigations don't see stale redirect delete preloadedLoaderData[normalizedPreloadPath] delete preloadingLoader[normalizedPreloadPath] // also clean the non-normalized key (used in prod) delete preloadedLoaderData[href] delete preloadingLoader[href] setLoadingState('loaded') linkTo(redirectTarget, 'REPLACE') return } // detect loader 404 signal before proceeding with navigation // (e.g., SSG route where the slug wasn't in generateStaticParams) if (preloadResult?.__oneError === 404) { delete preloadedLoaderData[normalizedPreloadPath] delete preloadingLoader[normalizedPreloadPath] delete preloadedLoaderData[href] delete preloadingLoader[href] setLoadingState('loaded') linkTo(preloadResult.__oneNotFoundPath || '/+not-found', 'REPLACE') return } // 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 } } } // Update client matches for useMatches hook // On web: runs after preload so loaderData is available // On native: runs without preloaded data (loaders are fetched by useLoader) const normalizedPath = normalizeLoaderPath(href) const loaderData = preloadedLoaderData[normalizedPath] const params = extractParamsFromState(state) const newMatches = buildClientMatches(href, matchingRouteNode, params, loaderData) currentMatches = newMatches setClientMatches(newMatches) 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 }