diff --git a/.oxlintrc.json b/.oxlintrc.json
index 1d08ce8..bf74d79 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -8,5 +8,5 @@
"rules": {
"react/react-in-jsx-scope": "off"
},
- "ignorePatterns": ["dist/**", "coverage/**", "node_modules/**", "src/react-old/**", "src/old/**"]
+ "ignorePatterns": ["dist/**", "coverage/**", "node_modules/**"]
}
diff --git a/src/react/components/NotFound.tsx b/src/react/components/NotFound.tsx
index 68db889..4ea8c81 100644
--- a/src/react/components/NotFound.tsx
+++ b/src/react/components/NotFound.tsx
@@ -1,8 +1,11 @@
/**
* Default fallback component rendered when no registered
- * route matches the current URL. Can be overridden via the
- * `notFound` prop on the Router component.
+ * route matches the current URL. Uses an `
` heading
+ * for semantic document structure and accessibility.
+ *
+ * Can be overridden via the `notFound` prop on the Router
+ * component for custom 404 pages.
*/
export function NotFound() {
- return
Not Found
+ return
Not Found
}
diff --git a/src/react/components/Router.test.tsx b/src/react/components/Router.test.tsx
index bedccf6..feb8019 100644
--- a/src/react/components/Router.test.tsx
+++ b/src/react/components/Router.test.tsx
@@ -6,7 +6,7 @@ import { Router } from './Router'
import { createMatcher } from 'router:matcher'
import { type Handler } from 'router/react:router'
import { PathnameContext } from 'router/react:context/PathnameContext'
-import { ParamsContext } from 'router/react:context/PropsContext'
+import { ParamsContext } from 'router/react:context/ParamsContext'
import { NavigationContext } from 'router/react:context/NavigationContext'
import { TransitionContext } from 'router/react:context/TransitionContext'
diff --git a/src/react/components/Router.tsx b/src/react/components/Router.tsx
index a178638..cb0b447 100644
--- a/src/react/components/Router.tsx
+++ b/src/react/components/Router.tsx
@@ -9,7 +9,7 @@ import {
} from 'react'
import { type Handler } from 'router/react:router'
import { type Matcher } from 'router:matcher'
-import { ParamsContext } from 'router/react:context/PropsContext'
+import { ParamsContext } from 'router/react:context/ParamsContext'
import { NavigationContext } from 'router/react:context/NavigationContext'
import { NavigationSignalContext } from 'router/react:context/NavigationSignalContext'
import { NavigationTypeContext } from 'router/react:context/NavigationTypeContext'
@@ -152,10 +152,10 @@ export interface RouterProps {
* - `PathnameContext` — the current URL pathname
* - `ParamsContext` — the extracted route parameters
*/
-export function Router(options: RouterProps) {
+export function Router(props: RouterProps) {
const contextNavigation = use(NavigationContext)
const navigation: Navigation =
- options.navigation ??
+ props.navigation ??
contextNavigation ??
(typeof window !== 'undefined' ? window.navigation : undefined)!
@@ -166,11 +166,11 @@ export function Router(options: RouterProps) {
'Use createMemoryNavigation() for SSR or non-browser environments.'
)
}
- const matcher: Matcher = options.matcher ?? use(MatcherContext)
+ const matcher: Matcher = props.matcher ?? use(MatcherContext)
const internalTransition = useTransition()
- const transition = options.transition ?? internalTransition
+ const transition = props.transition ?? internalTransition
const next = useNextMatch({ matcher })
- const notFound = options.notFound ?? NotFound
+ const notFound = props.notFound ?? NotFound
const [current, setCurrent] = useState(function () {
const url = navigation.currentEntry?.url ?? null
@@ -247,8 +247,8 @@ export function Router(options: RouterProps) {
useNavigationEvents(navigation, {
onNavigate,
- onNavigateSuccess: options.onNavigateSuccess,
- onNavigateError: options.onNavigateError,
+ onNavigateSuccess: props.onNavigateSuccess,
+ onNavigateError: props.onNavigateError,
})
const CurrentComponent = current.match.handler.component
@@ -263,7 +263,7 @@ export function Router(options: RouterProps) {
-
+
diff --git a/src/react/context/NavigationSignalContext.ts b/src/react/context/NavigationSignalContext.ts
index 7b0b39f..b19c533 100644
--- a/src/react/context/NavigationSignalContext.ts
+++ b/src/react/context/NavigationSignalContext.ts
@@ -5,5 +5,10 @@ import { createContext } from 'react'
* Consumers can use this to cancel in-flight async operations
* (fetches, transitions, etc.) when a navigation is superseded
* by another one.
+ *
+ * Defaults to `undefined` when no Router is present — the
+ * `useNavigationSignal` hook throws in this case. The Router
+ * provides `null` on initial render (before any navigation
+ * event), which is distinct from the `undefined` sentinel.
*/
-export const NavigationSignalContext = createContext(null)
+export const NavigationSignalContext = createContext(undefined)
diff --git a/src/react/context/NavigationTypeContext.ts b/src/react/context/NavigationTypeContext.ts
index bc83691..d3affee 100644
--- a/src/react/context/NavigationTypeContext.ts
+++ b/src/react/context/NavigationTypeContext.ts
@@ -4,5 +4,10 @@ import { createContext } from 'react'
* Provides the navigation type of the most recent NavigateEvent
* (`push`, `replace`, `reload`, or `traverse`). Allows route
* components to vary behavior based on how they were reached.
+ *
+ * Defaults to `undefined` when no Router is present — the
+ * `useNavigationType` hook throws in this case. The Router
+ * provides `null` on initial render (before any navigation
+ * event), which is distinct from the `undefined` sentinel.
*/
-export const NavigationTypeContext = createContext(null)
+export const NavigationTypeContext = createContext(undefined)
diff --git a/src/react/context/ParamsContext.ts b/src/react/context/ParamsContext.ts
new file mode 100644
index 0000000..56f5e7a
--- /dev/null
+++ b/src/react/context/ParamsContext.ts
@@ -0,0 +1,12 @@
+import { createContext } from 'react'
+
+/**
+ * Provides the route parameters extracted from the matched URL
+ * pattern as a string-keyed record. Defaults to `null` when no
+ * Router is present in the tree — the `useParams` hook throws
+ * a descriptive error in this case.
+ *
+ * The Router component updates this context on every successful
+ * navigation with the newly extracted parameters.
+ */
+export const ParamsContext = createContext | null>(null)
diff --git a/src/react/context/PathnameContext.ts b/src/react/context/PathnameContext.ts
index 54202c4..a166df4 100644
--- a/src/react/context/PathnameContext.ts
+++ b/src/react/context/PathnameContext.ts
@@ -5,8 +5,9 @@ import { createContext } from 'react'
* Updated by the Router on every navigation with the pathname
* extracted from the destination URL.
*
- * Consumed by the `usePathname` hook and the `Link` component
- * for active link detection. Defaults to `'/'` when no Router
- * is present in the tree.
+ * Defaults to `null` when no Router is present in the tree —
+ * the `usePathname` hook throws a descriptive error in this
+ * case. Consumed by the `usePathname` hook and the `Link`
+ * component for active link detection.
*/
-export const PathnameContext = createContext('/')
+export const PathnameContext = createContext(null)
diff --git a/src/react/context/PropsContext.ts b/src/react/context/PropsContext.ts
deleted file mode 100644
index 7b4a647..0000000
--- a/src/react/context/PropsContext.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { createContext } from 'react'
-
-/**
- * Provides the route parameters extracted from the matched URL
- * pattern as a string-keyed record. Defaults to an empty object
- * when no route has been matched yet.
- *
- * Consumed via the `useParams` hook. The Router component
- * updates this context on every successful navigation with
- * the newly extracted parameters.
- */
-export const ParamsContext = createContext>({})
diff --git a/src/react/context/TransitionContext.ts b/src/react/context/TransitionContext.ts
index 3588786..5de52f4 100644
--- a/src/react/context/TransitionContext.ts
+++ b/src/react/context/TransitionContext.ts
@@ -1,4 +1,4 @@
-import { type TransitionFunction, createContext, useTransition } from 'react'
+import { createContext, useTransition } from 'react'
/**
* Provides the `[isPending, startTransition]` tuple from
@@ -13,9 +13,3 @@ import { type TransitionFunction, createContext, useTransition } from 'react'
* explicit provider instead.
*/
export const TransitionContext = createContext | null>(null)
-
-/**
- * Type alias for the startTransition function extracted from
- * the useTransition tuple.
- */
-export type StartTransitionFn = (callback: TransitionFunction) => void
diff --git a/src/react/extractPathname.ts b/src/react/extractPathname.ts
index 42e034b..f110b6d 100644
--- a/src/react/extractPathname.ts
+++ b/src/react/extractPathname.ts
@@ -1,8 +1,7 @@
/**
* Extracts the pathname portion from a URL string. Uses a
* dummy base URL to handle both absolute and relative paths
- * correctly. Returns `'/'` when the input is null, undefined,
- * or an empty string.
+ * correctly. Returns `'/'` when the input is null or undefined.
*
* Used by the Router (to extract pathname from navigation
* destination URLs), Link (for active link comparison), and
@@ -15,7 +14,7 @@
* provided.
*/
export function extractPathname(url: string | null | undefined): string {
- if (!url) {
+ if (url === null || url === undefined) {
return '/'
}
diff --git a/src/react/hooks/useActiveLinkProps.ts b/src/react/hooks/useActiveLinkProps.ts
index 7ce2718..53757d7 100644
--- a/src/react/hooks/useActiveLinkProps.ts
+++ b/src/react/hooks/useActiveLinkProps.ts
@@ -79,6 +79,17 @@ export function useActiveLinkProps(
options?: ActiveLinkOptions
): { isActive: boolean; props: ActiveLinkProps } {
const currentPathname = use(PathnameContext)
+
+ if (currentPathname === null) {
+ return {
+ isActive: false,
+ props: {
+ 'data-active': undefined,
+ 'aria-current': undefined,
+ },
+ }
+ }
+
const isExact = options?.exact ?? true
const isActive = isActiveHref(href, currentPathname, isExact)
diff --git a/src/react/hooks/useNavigate.ts b/src/react/hooks/useNavigate.ts
index 89a8664..dabe510 100644
--- a/src/react/hooks/useNavigate.ts
+++ b/src/react/hooks/useNavigate.ts
@@ -8,6 +8,21 @@ import { useNavigation } from 'router/react:hooks/useNavigation'
*
* @returns A navigate function that accepts a URL string and
* optional `NavigationNavigateOptions`.
+ *
+ * @example
+ * ```tsx
+ * function LogoutButton() {
+ * const navigate = useNavigate()
+ *
+ * return (
+ *
+ * )
+ * }
+ * ```
*/
export function useNavigate() {
const navigation = useNavigation()
diff --git a/src/react/hooks/useNavigation.ts b/src/react/hooks/useNavigation.ts
index 95a4937..e01868f 100644
--- a/src/react/hooks/useNavigation.ts
+++ b/src/react/hooks/useNavigation.ts
@@ -12,6 +12,16 @@ import { NavigationContext } from 'router/react:context/NavigationContext'
*
* @returns The Navigation object from the nearest provider.
* @throws When used outside a NavigationContext provider.
+ *
+ * @example
+ * ```tsx
+ * function HistoryDebug() {
+ * const navigation = useNavigation()
+ * const entries = navigation.entries()
+ *
+ * return
{JSON.stringify(entries.map(e => e.url))}
+ * }
+ * ```
*/
export function useNavigation(): Navigation {
const navigation = use(NavigationContext)
diff --git a/src/react/hooks/useNavigationEvents.ts b/src/react/hooks/useNavigationEvents.ts
index 1fb7747..e72d672 100644
--- a/src/react/hooks/useNavigationEvents.ts
+++ b/src/react/hooks/useNavigationEvents.ts
@@ -44,6 +44,24 @@ export interface NavigationEventHandlers {
* @param navigation - The Navigation object to subscribe to.
* @param handlers - Callbacks for each navigation lifecycle
* event. All are optional.
+ *
+ * @example
+ * ```tsx
+ * function NavigationLogger() {
+ * const navigation = useNavigation()
+ *
+ * useNavigationEvents(navigation, {
+ * onNavigateSuccess() {
+ * console.log('navigation completed')
+ * },
+ * onNavigateError(error) {
+ * console.error('navigation failed', error)
+ * },
+ * })
+ *
+ * return null
+ * }
+ * ```
*/
export function useNavigationEvents(navigation: Navigation, handlers: NavigationEventHandlers) {
/**
diff --git a/src/react/hooks/useNavigationHandlers.ts b/src/react/hooks/useNavigationHandlers.ts
index 56626c5..c0108e1 100644
--- a/src/react/hooks/useNavigationHandlers.ts
+++ b/src/react/hooks/useNavigationHandlers.ts
@@ -45,6 +45,15 @@ export interface PrecommitHandlerOptions {
* provides TransitionContext.
* @throws When no transition tuple is provided and the
* hook is used outside a TransitionContext provider.
+ *
+ * @example
+ * ```tsx
+ * function CustomRouter() {
+ * const transition = useTransition()
+ * const { createHandler } = useNavigationHandlers(transition)
+ * // use createHandler to build intercept handlers
+ * }
+ * ```
*/
export function useNavigationHandlers(transition?: ReturnType) {
const contextTransition = transition ?? use(TransitionContext)
diff --git a/src/react/hooks/useNavigationSignal.test.ts b/src/react/hooks/useNavigationSignal.test.ts
index 4812495..3e99657 100644
--- a/src/react/hooks/useNavigationSignal.test.ts
+++ b/src/react/hooks/useNavigationSignal.test.ts
@@ -5,10 +5,32 @@ import { useNavigationSignal } from './useNavigationSignal'
import { NavigationSignalContext } from 'router/react:context/NavigationSignalContext'
describe('useNavigationSignal', { concurrent: true }, function () {
- it('returns null by default', function ({ expect, onTestFinished }) {
- const { current, unmount } = renderHook(function () {
- return useNavigationSignal()
- })
+ it('throws when used outside a provider', function ({ expect }) {
+ expect(function () {
+ renderHook(function () {
+ return useNavigationSignal()
+ })
+ }).toThrow('useNavigationSignal requires a or provider')
+ })
+
+ it('returns null when provider gives null (initial render)', function ({
+ expect,
+ onTestFinished,
+ }) {
+ /**
+ * Wrapper providing null signal, simulating the initial
+ * render before any navigation event has fired.
+ */
+ function Wrapper({ children }: { children: ReactNode }) {
+ return createElement(NavigationSignalContext, { value: null }, children)
+ }
+
+ const { current, unmount } = renderHook(
+ function () {
+ return useNavigationSignal()
+ },
+ { wrapper: Wrapper }
+ )
onTestFinished(unmount)
diff --git a/src/react/hooks/useNavigationSignal.ts b/src/react/hooks/useNavigationSignal.ts
index 38601fd..619af64 100644
--- a/src/react/hooks/useNavigationSignal.ts
+++ b/src/react/hooks/useNavigationSignal.ts
@@ -11,8 +11,29 @@ import { NavigationSignalContext } from 'router/react:context/NavigationSignalCo
* Returns `null` before any navigation event has occurred
* (i.e. on the initial render).
*
+ * Must be used inside a `` component tree.
+ *
* @returns The current AbortSignal or null.
+ * @throws When used outside a Router or NavigationSignalContext
+ * provider.
+ *
+ * @example
+ * ```tsx
+ * function UserProfile({ id }: { id: string }) {
+ * const signal = useNavigationSignal()
+ *
+ * useEffect(function () {
+ * fetch(`/api/user/${id}`, { signal })
+ * }, [id, signal])
+ * }
+ * ```
*/
export function useNavigationSignal(): AbortSignal | null {
- return use(NavigationSignalContext)
+ const signal = use(NavigationSignalContext)
+
+ if (signal === undefined) {
+ throw new Error('useNavigationSignal requires a or provider')
+ }
+
+ return signal
}
diff --git a/src/react/hooks/useNavigationType.test.ts b/src/react/hooks/useNavigationType.test.ts
index 169afa7..d6b25c6 100644
--- a/src/react/hooks/useNavigationType.test.ts
+++ b/src/react/hooks/useNavigationType.test.ts
@@ -5,10 +5,32 @@ import { useNavigationType } from './useNavigationType'
import { NavigationTypeContext } from 'router/react:context/NavigationTypeContext'
describe('useNavigationType', { concurrent: true }, function () {
- it('returns null by default', function ({ expect, onTestFinished }) {
- const { current, unmount } = renderHook(function () {
- return useNavigationType()
- })
+ it('throws when used outside a provider', function ({ expect }) {
+ expect(function () {
+ renderHook(function () {
+ return useNavigationType()
+ })
+ }).toThrow('useNavigationType requires a or provider')
+ })
+
+ it('returns null when provider gives null (initial render)', function ({
+ expect,
+ onTestFinished,
+ }) {
+ /**
+ * Wrapper providing null navigation type, simulating the
+ * initial render before any navigation event has fired.
+ */
+ function Wrapper({ children }: { children: ReactNode }) {
+ return createElement(NavigationTypeContext, { value: null }, children)
+ }
+
+ const { current, unmount } = renderHook(
+ function () {
+ return useNavigationType()
+ },
+ { wrapper: Wrapper }
+ )
onTestFinished(unmount)
diff --git a/src/react/hooks/useNavigationType.ts b/src/react/hooks/useNavigationType.ts
index ca739a5..fefdd1e 100644
--- a/src/react/hooks/useNavigationType.ts
+++ b/src/react/hooks/useNavigationType.ts
@@ -10,8 +10,32 @@ import { NavigationTypeContext } from 'router/react:context/NavigationTypeContex
* Returns `null` before any navigation event has occurred
* (i.e. on the initial render).
*
+ * Must be used inside a `` component tree.
+ *
* @returns The current NavigationType or null.
+ * @throws When used outside a Router or NavigationTypeContext
+ * provider.
+ *
+ * @example
+ * ```tsx
+ * function PageTransition({ children }: { children: ReactNode }) {
+ * const type = useNavigationType()
+ * const isTraversal = type === 'traverse'
+ *
+ * return (
+ *
+ * {children}
+ *
+ * )
+ * }
+ * ```
*/
export function useNavigationType(): NavigationType | null {
- return use(NavigationTypeContext)
+ const navigationType = use(NavigationTypeContext)
+
+ if (navigationType === undefined) {
+ throw new Error('useNavigationType requires a or provider')
+ }
+
+ return navigationType
}
diff --git a/src/react/hooks/useNextMatch.ts b/src/react/hooks/useNextMatch.ts
index 351cbf4..539a754 100644
--- a/src/react/hooks/useNextMatch.ts
+++ b/src/react/hooks/useNextMatch.ts
@@ -25,6 +25,17 @@ export interface NextMatchOptions {
* @param options - Optional matcher override.
* @returns A resolver function that takes a destination URL
* and a not-found component, returning the resolved match.
+ *
+ * @example
+ * ```tsx
+ * function CustomRouter() {
+ * const resolve = useNextMatch()
+ * const match = resolve(window.location.href, NotFound)
+ * const Component = match.handler.component
+ *
+ * return
+ * }
+ * ```
*/
export function useNextMatch(options?: NextMatchOptions) {
const matcher = options?.matcher ?? use(MatcherContext)
diff --git a/src/react/hooks/useParams.test.ts b/src/react/hooks/useParams.test.ts
index db3b896..2b00b02 100644
--- a/src/react/hooks/useParams.test.ts
+++ b/src/react/hooks/useParams.test.ts
@@ -2,17 +2,15 @@ import { describe, it } from 'vitest'
import { createElement, type ReactNode } from 'react'
import { renderHook } from 'router/react:test-helpers'
import { useParams } from './useParams'
-import { ParamsContext } from 'router/react:context/PropsContext'
+import { ParamsContext } from 'router/react:context/ParamsContext'
describe('useParams', { concurrent: true }, function () {
- it('returns empty object by default', function ({ expect, onTestFinished }) {
- const { current, unmount } = renderHook(function () {
- return useParams()
- })
-
- onTestFinished(unmount)
-
- expect(current).toEqual({})
+ it('throws when used outside a provider', function ({ expect }) {
+ expect(function () {
+ renderHook(function () {
+ return useParams()
+ })
+ }).toThrow('useParams requires a or provider')
})
it('returns params from context', function ({ expect, onTestFinished }) {
@@ -36,4 +34,28 @@ describe('useParams', { concurrent: true }, function () {
expect(current).toEqual({ id: '42', slug: 'hello' })
})
+
+ it('returns empty params when provider gives empty object', function ({
+ expect,
+ onTestFinished,
+ }) {
+ /**
+ * Wrapper providing ParamsContext with an empty object,
+ * simulating a route with no dynamic segments.
+ */
+ function Wrapper({ children }: { children: ReactNode }) {
+ return createElement(ParamsContext, { value: {} }, children)
+ }
+
+ const { current, unmount } = renderHook(
+ function () {
+ return useParams()
+ },
+ { wrapper: Wrapper }
+ )
+
+ onTestFinished(unmount)
+
+ expect(current).toEqual({})
+ })
})
diff --git a/src/react/hooks/useParams.ts b/src/react/hooks/useParams.ts
index 7082aeb..8678d74 100644
--- a/src/react/hooks/useParams.ts
+++ b/src/react/hooks/useParams.ts
@@ -1,5 +1,5 @@
import { use } from 'react'
-import { ParamsContext } from 'router/react:context/PropsContext'
+import { ParamsContext } from 'router/react:context/ParamsContext'
/**
* Returns the dynamic route parameters extracted from the
@@ -11,6 +11,7 @@ import { ParamsContext } from 'router/react:context/PropsContext'
* `ParamsContext` is provided.
*
* @returns A record of parameter names to their string values.
+ * @throws When used outside a Router or ParamsContext provider.
*
* @example
* ```tsx
@@ -19,6 +20,12 @@ import { ParamsContext } from 'router/react:context/PropsContext'
* const { id } = useParams() // id === "42"
* ```
*/
-export function useParams() {
- return use(ParamsContext)
+export function useParams(): Record {
+ const params = use(ParamsContext)
+
+ if (params === null) {
+ throw new Error('useParams requires a or provider')
+ }
+
+ return params
}
diff --git a/src/react/hooks/usePathname.test.ts b/src/react/hooks/usePathname.test.ts
index 1a9658c..f396380 100644
--- a/src/react/hooks/usePathname.test.ts
+++ b/src/react/hooks/usePathname.test.ts
@@ -5,14 +5,12 @@ import { usePathname } from './usePathname'
import { PathnameContext } from 'router/react:context/PathnameContext'
describe('usePathname', { concurrent: true }, function () {
- it('returns "/" by default', function ({ expect, onTestFinished }) {
- const { current, unmount } = renderHook(function () {
- return usePathname()
- })
-
- onTestFinished(unmount)
-
- expect(current).toBe('/')
+ it('throws when used outside a provider', function ({ expect }) {
+ expect(function () {
+ renderHook(function () {
+ return usePathname()
+ })
+ }).toThrow('usePathname requires a or provider')
})
it('returns pathname from context', function ({ expect, onTestFinished }) {
diff --git a/src/react/hooks/usePathname.ts b/src/react/hooks/usePathname.ts
index 7a541e0..f186450 100644
--- a/src/react/hooks/usePathname.ts
+++ b/src/react/hooks/usePathname.ts
@@ -13,6 +13,7 @@ import { PathnameContext } from 'router/react:context/PathnameContext'
* `PathnameContext` is provided.
*
* @returns The current pathname string (e.g. `"/user/42"`).
+ * @throws When used outside a Router or PathnameContext provider.
*
* @example
* ```tsx
@@ -24,5 +25,11 @@ import { PathnameContext } from 'router/react:context/PathnameContext'
* ```
*/
export function usePathname(): string {
- return use(PathnameContext)
+ const pathname = use(PathnameContext)
+
+ if (pathname === null) {
+ throw new Error('usePathname requires a or provider')
+ }
+
+ return pathname
}
diff --git a/src/react/hooks/usePrefetch.test.ts b/src/react/hooks/usePrefetch.test.ts
index cadca61..30422ce 100644
--- a/src/react/hooks/usePrefetch.test.ts
+++ b/src/react/hooks/usePrefetch.test.ts
@@ -1,7 +1,7 @@
import { describe, it, vi } from 'vitest'
import { type ComponentType, createElement, type ReactNode } from 'react'
import { renderHook } from 'router/react:test-helpers'
-import { usePrefetch } from './usePrefetch'
+import { clearPrefetchCache, usePrefetch } from './usePrefetch'
import { MatcherContext } from 'router/react:context/MatcherContext'
import { createMatcher } from 'router:matcher'
import { type Handler } from 'router/react:router'
@@ -144,4 +144,95 @@ describe('usePrefetch', { concurrent: true }, function () {
expect(prefetchSpy).toHaveBeenCalledTimes(1)
})
+
+ it('deduplicates prefetch calls for the same pathname', function ({ expect, onTestFinished }) {
+ const prefetchSpy = vi.fn()
+ const matcher = createMatcher()
+
+ matcher.register('/dedup', {
+ component: createStub(),
+ prefetch: prefetchSpy,
+ })
+
+ const { current, unmount } = renderHook(function () {
+ return usePrefetch({ matcher })
+ })
+
+ onTestFinished(unmount)
+
+ current('/dedup')
+ current('/dedup')
+ current('/dedup')
+
+ expect(prefetchSpy).toHaveBeenCalledTimes(1)
+ })
+
+ it('allows re-prefetch after clearPrefetchCache', function ({ expect, onTestFinished }) {
+ const prefetchSpy = vi.fn()
+ const matcher = createMatcher()
+
+ matcher.register('/clearable', {
+ component: createStub(),
+ prefetch: prefetchSpy,
+ })
+
+ const { current, unmount } = renderHook(function () {
+ return usePrefetch({ matcher })
+ })
+
+ onTestFinished(unmount)
+
+ current('/clearable')
+
+ expect(prefetchSpy).toHaveBeenCalledTimes(1)
+
+ clearPrefetchCache(matcher)
+
+ current('/clearable')
+
+ expect(prefetchSpy).toHaveBeenCalledTimes(2)
+ })
+
+ it('clearPrefetchCache does not affect other matchers', function ({ expect, onTestFinished }) {
+ const prefetchSpy1 = vi.fn()
+ const prefetchSpy2 = vi.fn()
+
+ const matcher1 = createMatcher()
+ const matcher2 = createMatcher()
+
+ matcher1.register('/shared', {
+ component: createStub(),
+ prefetch: prefetchSpy1,
+ })
+
+ matcher2.register('/shared', {
+ component: createStub(),
+ prefetch: prefetchSpy2,
+ })
+
+ const { current: prefetch1, unmount: unmount1 } = renderHook(function () {
+ return usePrefetch({ matcher: matcher1 })
+ })
+
+ const { current: prefetch2, unmount: unmount2 } = renderHook(function () {
+ return usePrefetch({ matcher: matcher2 })
+ })
+
+ onTestFinished(unmount1)
+ onTestFinished(unmount2)
+
+ prefetch1('/shared')
+ prefetch2('/shared')
+
+ expect(prefetchSpy1).toHaveBeenCalledTimes(1)
+ expect(prefetchSpy2).toHaveBeenCalledTimes(1)
+
+ clearPrefetchCache(matcher1)
+
+ prefetch1('/shared')
+ prefetch2('/shared')
+
+ expect(prefetchSpy1).toHaveBeenCalledTimes(2)
+ expect(prefetchSpy2).toHaveBeenCalledTimes(1)
+ })
})
diff --git a/src/react/hooks/usePrefetch.ts b/src/react/hooks/usePrefetch.ts
index 41cf2e7..64e0463 100644
--- a/src/react/hooks/usePrefetch.ts
+++ b/src/react/hooks/usePrefetch.ts
@@ -25,6 +25,45 @@ export interface PrefetchOptions {
*/
const prefetchedByMatcher = new WeakMap, Set>()
+/**
+ * Clears the prefetch deduplication cache for a specific
+ * matcher instance. After clearing, subsequent calls to the
+ * prefetch function will re-execute handlers for pathnames
+ * that were previously skipped.
+ *
+ * Useful when cached data becomes stale — for example after
+ * a user logs out, a form submission invalidates server
+ * state, or a known data expiry occurs.
+ *
+ * When called without a matcher argument, has no effect.
+ * The cache is automatically garbage-collected when the
+ * matcher instance is no longer referenced, so explicit
+ * clearing is only needed for long-lived matchers.
+ *
+ * @param matcher - The matcher whose prefetch cache should
+ * be cleared.
+ *
+ * @example
+ * ```tsx
+ * function LogoutButton() {
+ * const navigate = useNavigate()
+ * const matcher = use(MatcherContext)
+ *
+ * return (
+ *
+ * )
+ * }
+ * ```
+ */
+export function clearPrefetchCache(matcher: Matcher) {
+ prefetchedByMatcher.delete(matcher)
+}
+
/**
* Returns a function that triggers the prefetch logic for a
* given URL by resolving it against the matcher and calling
@@ -43,6 +82,22 @@ const prefetchedByMatcher = new WeakMap, Set>()
* @param options - Optional matcher override.
* @returns A function that accepts a URL string and invokes
* the matched route's prefetch handler, if any.
+ *
+ * @example
+ * ```tsx
+ * function PrefetchOnHover({ href, children }: Props) {
+ * const prefetch = usePrefetch()
+ *
+ * return (
+ *
+ * {children}
+ *
+ * )
+ * }
+ * ```
*/
export function usePrefetch(options?: PrefetchOptions) {
const matcher = options?.matcher ?? use(MatcherContext)
diff --git a/src/react/index.ts b/src/react/index.ts
index 59b476c..39e61c7 100644
--- a/src/react/index.ts
+++ b/src/react/index.ts
@@ -5,7 +5,7 @@ export * from 'router/react:components/Link'
export * from 'router/react:context/MatcherContext'
export * from 'router/react:context/TransitionContext'
-export * from 'router/react:context/PropsContext'
+export * from 'router/react:context/ParamsContext'
export * from 'router/react:context/NavigationContext'
export * from 'router/react:context/NavigationSignalContext'
export * from 'router/react:context/NavigationTypeContext'
diff --git a/src/react/navigation/createMemoryNavigation.test.ts b/src/react/navigation/createMemoryNavigation.test.ts
index 872916b..7ec1956 100644
--- a/src/react/navigation/createMemoryNavigation.test.ts
+++ b/src/react/navigation/createMemoryNavigation.test.ts
@@ -65,4 +65,36 @@ describe('createMemoryNavigation', { concurrent: true }, function () {
expect(finished?.url).toBe('https://example.com/')
})
+
+ it('back returns a result with pre-resolved promises', async function ({ expect }) {
+ const nav = createMemoryNavigation({ url: 'https://example.com/' })
+ const result = nav.back()
+ const committed = await result.committed
+
+ expect(committed?.url).toBe('https://example.com/')
+ })
+
+ it('forward returns a result with pre-resolved promises', async function ({ expect }) {
+ const nav = createMemoryNavigation({ url: 'https://example.com/' })
+ const result = nav.forward()
+ const finished = await result.finished
+
+ expect(finished?.url).toBe('https://example.com/')
+ })
+
+ it('traverseTo returns a result with pre-resolved promises', async function ({ expect }) {
+ const nav = createMemoryNavigation({ url: 'https://example.com/' })
+ const result = nav.traverseTo('some-key')
+ const committed = await result.committed
+
+ expect(committed?.url).toBe('https://example.com/')
+ })
+
+ it('updateCurrentEntry does not throw', function ({ expect }) {
+ const nav = createMemoryNavigation({ url: 'https://example.com/' })
+
+ expect(function () {
+ nav.updateCurrentEntry({ state: { foo: 'bar' } })
+ }).not.toThrow()
+ })
})
diff --git a/src/react/navigation/createMemoryNavigation.ts b/src/react/navigation/createMemoryNavigation.ts
index 62d62a2..798c27a 100644
--- a/src/react/navigation/createMemoryNavigation.ts
+++ b/src/react/navigation/createMemoryNavigation.ts
@@ -1,10 +1,10 @@
/**
* Minimal subset of the NavigationHistoryEntry interface
- * needed by the Router component. Only the `url` property
- * is read during rendering. The full NavigationHistoryEntry
- * interface is far larger, but the Router never accesses
- * properties like `key`, `id`, `sameDocument`, `getState`,
- * or the event handlers.
+ * needed by the Router component and associated hooks. Only
+ * the `url` property is read during rendering. The full
+ * NavigationHistoryEntry interface is far larger, but the
+ * Router never accesses properties like `key`, `id`,
+ * `sameDocument`, `getState`, or the event handlers.
*/
interface MemoryNavigationEntry {
/**
@@ -33,13 +33,17 @@ export interface MemoryNavigationOptions {
* browser Navigation API is unavailable.
*
* The returned object satisfies the subset of the `Navigation`
- * interface consumed by the Router component:
+ * interface consumed by the Router component and hooks:
*
* - `currentEntry.url` — returns the initial URL
* - `addEventListener` / `removeEventListener` — no-ops
* (no events fire in a memory environment)
* - `navigate()` — no-op that returns a NavigationResult
* with immediately-resolved promises
+ * - `back()` / `forward()` — no-ops that return a
+ * NavigationResult with immediately-resolved promises
+ * - `traverseTo()` — no-op that returns a NavigationResult
+ * - `updateCurrentEntry()` — no-op
* - `canGoBack` / `canGoForward` — always false
* - `entries()` — returns a single-entry array
*
@@ -70,6 +74,19 @@ export function createMemoryNavigation(options: MemoryNavigationOptions): Naviga
url: options.url,
}
+ const entryAsHistoryEntry = entry as unknown as NavigationHistoryEntry
+
+ /**
+ * Pre-built NavigationResult returned by all navigation
+ * methods. Uses the same entry cast as a history entry
+ * with immediately-resolved promises. Allocated once to
+ * avoid per-call object creation.
+ */
+ const result: NavigationResult = {
+ committed: Promise.resolve(entryAsHistoryEntry),
+ finished: Promise.resolve(entryAsHistoryEntry),
+ }
+
/**
* No-op event listener registration. In SSR and testing
* environments, no navigation events are dispatched, so
@@ -91,18 +108,48 @@ export function createMemoryNavigation(options: MemoryNavigationOptions): Naviga
* on the result.
*/
function navigate(): NavigationResult {
- return {
- committed: Promise.resolve(entry as unknown as NavigationHistoryEntry),
- finished: Promise.resolve(entry as unknown as NavigationHistoryEntry),
- }
+ return result
+ }
+
+ /**
+ * No-op backward navigation. Returns pre-resolved promises
+ * matching the NavigationResult interface. In a memory
+ * environment there is no history stack to traverse.
+ */
+ function back(): NavigationResult {
+ return result
+ }
+
+ /**
+ * No-op forward navigation. Returns pre-resolved promises
+ * matching the NavigationResult interface. In a memory
+ * environment there is no history stack to traverse.
+ */
+ function forward(): NavigationResult {
+ return result
+ }
+
+ /**
+ * No-op history traversal to a specific entry key. Returns
+ * pre-resolved promises. In a memory environment there is
+ * only a single entry, so traversal is meaningless.
+ */
+ function traverseTo(): NavigationResult {
+ return result
}
+ /**
+ * No-op current entry state update. In a memory environment
+ * entry state is not tracked, so this is silently ignored.
+ */
+ function updateCurrentEntry() {}
+
/**
* Returns the single-entry history list. The memory
* adapter only ever has one entry — the initial URL.
*/
function entries(): NavigationHistoryEntry[] {
- return [entry as unknown as NavigationHistoryEntry]
+ return [entryAsHistoryEntry]
}
return {
@@ -113,6 +160,10 @@ export function createMemoryNavigation(options: MemoryNavigationOptions): Naviga
addEventListener,
removeEventListener,
navigate,
+ back,
+ forward,
+ traverseTo,
+ updateCurrentEntry,
entries,
} as unknown as Navigation
}