Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions packages/one/src/createRouteConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { One } from './interfaces/router'

/**
* Helper to create a typed route configuration object.
*
* Use this to configure route-specific behavior like loading mode and sitemap settings.
*
* @example
* ```tsx
* // blocking mode - wait for loader before navigation
* export const config = createRouteConfig({
* loading: 'blocking',
* })
*
* // instant mode - navigate immediately, show Loading component
* export const config = createRouteConfig({
* loading: 'instant',
* })
*
* // timed mode - wait 200ms, then show Loading if still loading
* export const config = createRouteConfig({
* loading: 200,
* })
*
* // with sitemap config
* export const config = createRouteConfig({
* loading: 'blocking',
* sitemap: {
* priority: 0.8,
* changefreq: 'weekly',
* },
* })
* ```
*/
export function createRouteConfig(config: One.RouteConfig): One.RouteConfig {
return config
}
2 changes: 2 additions & 0 deletions packages/one/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,5 @@ export { SourceInspector, type SourceInspectorProps } from './views/SourceInspec
export { useScrollGroup } from './useScrollGroup'
// server
export { getServerData, setResponseHeaders, setServerData } from './vite/one-server-only'
// route config
export { createRouteConfig } from './createRouteConfig'
25 changes: 25 additions & 0 deletions packages/one/src/interfaces/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,4 +525,29 @@ export namespace One {
*/
exclude?: boolean
}

/**
* Loading mode for route navigation.
* - `'blocking'` - Wait for loader to complete before navigation (default for SSG/SSR)
* - `'instant'` - Navigate immediately, show Loading component while loader runs
* - `number` - Wait up to N milliseconds, then navigate (showing Loading if still loading)
*/
export type RouteLoadingMode = 'blocking' | 'instant' | number

/**
* Route configuration object for customizing route behavior.
*/
export type RouteConfig = {
/**
* Loading mode for this route.
* - `'blocking'` - Wait for loader before navigation (default for SSG/SSR)
* - `'instant'` - Navigate immediately, show Loading component (default for SPA)
* - `number` - Wait up to N ms, then show Loading if still loading
*/
loading?: RouteLoadingMode
/**
* Sitemap configuration for this route.
*/
sitemap?: RouteSitemap
}
}
7 changes: 7 additions & 0 deletions packages/one/src/router/Route.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { createContext, type ReactNode, useContext } from 'react'
import type { ErrorBoundaryProps } from '../views/Try'
import type { One as OneInterfaces } from '../interfaces/router'
import type { LoaderProps } from '../types'
import type { One } from '../vite/types'
import type { ParamValidator, RouteValidationFn } from '../validateParams'
Expand All @@ -21,6 +22,10 @@ export type LoadedRoute = {
params?: Record<string, string | string[]>
}) => Record<string, string | string[]>[]
loader?: (props: LoaderProps) => Record<string, string | string[]>[]
/** Route configuration for loading behavior, sitemap, etc. */
config?: OneInterfaces.RouteConfig
/** Loading component shown while loader is running (for instant/timed modes). */
Loading?: React.ComponentType
/**
* Validate route params before navigation.
* Use with Zod, Valibot, or a custom function.
Expand Down Expand Up @@ -77,6 +82,8 @@ export type RouteNode = {
layouts?: RouteNode[]
/** Parent middlewares */
middlewares?: RouteNode[]
/** Cached loading mode from route config. */
loadingMode?: OneInterfaces.RouteLoadingMode
}

export const RouteParamsContext = createContext<
Expand Down
129 changes: 110 additions & 19 deletions packages/one/src/router/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,28 @@ import {
useSyncExternalStore,
} from 'react'
import { Platform } from 'react-native'
import type { OneRouter } from '../interfaces/router'
import { devtoolsRegistry } from '../devtools/registry'
import type { OneRouter, One as OneInterfaces } 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,
} from './findRouteNode'
import type { UrlObject } from './getNormalizedStatePath'
import { getRouteInfo } from './getRouteInfo'
import { getRoutes } from './getRoutes'
Expand All @@ -35,19 +48,6 @@ 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
Expand Down Expand Up @@ -530,9 +530,36 @@ export function cleanup() {
}
}

// TODO
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') {
try {
const loaderJSUrl = getLoaderPath(href, true)
const modulePromise = dynamicImport(loaderJSUrl)
if (!modulePromise) {
return null
}
const module = await modulePromise.catch(() => null)

if (!module?.loader) {
return null
}

const result = await module.loader()
return result ?? null
} catch (err) {
// 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)
Expand Down Expand Up @@ -654,8 +681,20 @@ export function preloadRoute(href: string, injectCSS = false): Promise<any> | un
if (process.env.TAMAGUI_TARGET === 'native') {
return
}

// 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') {
return
// 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]) {
Expand All @@ -680,6 +719,47 @@ export function preloadRoute(href: string, injectCSS = false): Promise<any> | un
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(/\/$/, '') || '/'
}

// get the loading mode for a route
function getRouteLoadingMode(
routeNode: RouteNode | null | undefined,
loadedRoute?: { config?: OneInterfaces.RouteConfig }
): OneInterfaces.RouteLoadingMode {
if (loadedRoute?.config?.loading !== undefined) {
return loadedRoute.config.loading
}
if (routeNode?.loadingMode !== undefined) {
return routeNode.loadingMode
}
if (routeNode?.type === 'spa') {
return 'instant'
}
return 'blocking'
}

// handle preload based on loading mode
async function handlePreloadWithMode(
href: string,
loadingMode: OneInterfaces.RouteLoadingMode
): Promise<void> {
const preloadPromise = preloadRoute(href, true)

if (loadingMode === 'blocking') {
await preloadPromise
} else if (loadingMode === 'instant') {
// don't wait - navigate immediately
} else if (typeof loadingMode === 'number') {
const timeoutPromise = new Promise<void>((resolve) => setTimeout(resolve, loadingMode))
await Promise.race([preloadPromise, timeoutPromise])
}
}

export async function linkTo(
href: string,
event?: string,
Expand Down Expand Up @@ -772,16 +852,27 @@ export async function linkTo(

setLoadingState('loading')

// Preload route modules first so loadRoute() won't throw Suspense promises
await preloadRoute(href, true)
// Find the matching route node to determine loading mode
const matchingRouteNode = findRouteNodeFromState(state, routeNode)

// Get the loading mode - uses cached config or defaults based on route type
const loadingMode = getRouteLoadingMode(matchingRouteNode)

// Preload route modules based on loading mode
await handlePreloadWithMode(href, loadingMode)

// Run async route validation before navigation
const matchingRouteNode = findRouteNodeFromState(state, routeNode)
if (matchingRouteNode?.loadRoute) {
setValidationState({ status: 'validating', lastValidatedHref: href })

try {
const loadedRoute = matchingRouteNode.loadRoute()

// Cache the loading mode from config for subsequent navigations
if (loadedRoute.config?.loading !== undefined && !matchingRouteNode.loadingMode) {
matchingRouteNode.loadingMode = loadedRoute.config.loading
}

const params = extractParamsFromState(state)
const search = extractSearchFromHref(href)
const pathname = extractPathnameFromHref(href)
Expand Down
35 changes: 35 additions & 0 deletions packages/one/types/createRouteConfig.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { One } from './interfaces/router';
/**
* Helper to create a typed route configuration object.
*
* Use this to configure route-specific behavior like loading mode and sitemap settings.
*
* @example
* ```tsx
* // blocking mode - wait for loader before navigation
* export const config = createRouteConfig({
* loading: 'blocking',
* })
*
* // instant mode - navigate immediately, show Loading component
* export const config = createRouteConfig({
* loading: 'instant',
* })
*
* // timed mode - wait 200ms, then show Loading if still loading
* export const config = createRouteConfig({
* loading: 200,
* })
*
* // with sitemap config
* export const config = createRouteConfig({
* loading: 'blocking',
* sitemap: {
* priority: 0.8,
* changefreq: 'weekly',
* },
* })
* ```
*/
export declare function createRouteConfig(config: One.RouteConfig): One.RouteConfig;
//# sourceMappingURL=createRouteConfig.d.ts.map
1 change: 1 addition & 0 deletions packages/one/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,5 @@ export { ScrollBehavior } from './views/ScrollBehavior';
export { SourceInspector, type SourceInspectorProps } from './views/SourceInspector';
export { useScrollGroup } from './useScrollGroup';
export { getServerData, setResponseHeaders, setServerData } from './vite/one-server-only';
export { createRouteConfig } from './createRouteConfig';
//# sourceMappingURL=index.d.ts.map
23 changes: 23 additions & 0 deletions packages/one/types/interfaces/router.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,5 +372,28 @@ export declare namespace One {
*/
exclude?: boolean;
};
/**
* Loading mode for route navigation.
* - `'blocking'` - Wait for loader to complete before navigation (default for SSG/SSR)
* - `'instant'` - Navigate immediately, show Loading component while loader runs
* - `number` - Wait up to N milliseconds, then navigate (showing Loading if still loading)
*/
type RouteLoadingMode = 'blocking' | 'instant' | number;
/**
* Route configuration object for customizing route behavior.
*/
type RouteConfig = {
/**
* Loading mode for this route.
* - `'blocking'` - Wait for loader before navigation (default for SSG/SSR)
* - `'instant'` - Navigate immediately, show Loading component (default for SPA)
* - `number` - Wait up to N ms, then show Loading if still loading
*/
loading?: RouteLoadingMode;
/**
* Sitemap configuration for this route.
*/
sitemap?: RouteSitemap;
};
}
//# sourceMappingURL=router.d.ts.map
7 changes: 7 additions & 0 deletions packages/one/types/router/Route.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { type ReactNode } from 'react';
import type { ErrorBoundaryProps } from '../views/Try';
import type { One as OneInterfaces } from '../interfaces/router';
import type { LoaderProps } from '../types';
import type { One } from '../vite/types';
import type { ParamValidator, RouteValidationFn } from '../validateParams';
Expand All @@ -17,6 +18,10 @@ export type LoadedRoute = {
params?: Record<string, string | string[]>;
}) => Record<string, string | string[]>[];
loader?: (props: LoaderProps) => Record<string, string | string[]>[];
/** Route configuration for loading behavior, sitemap, etc. */
config?: OneInterfaces.RouteConfig;
/** Loading component shown while loader is running (for instant/timed modes). */
Loading?: React.ComponentType;
/**
* Validate route params before navigation.
* Use with Zod, Valibot, or a custom function.
Expand Down Expand Up @@ -72,6 +77,8 @@ export type RouteNode = {
layouts?: RouteNode[];
/** Parent middlewares */
middlewares?: RouteNode[];
/** Cached loading mode from route config. */
loadingMode?: OneInterfaces.RouteLoadingMode;
};
export declare const RouteParamsContext: React.Context<Record<string, string | undefined> | undefined>;
/** Return the RouteNode at the current contextual boundary. */
Expand Down
Loading
Loading