From 2f6399dcff2031abfbe80d118e812e5d24ce8242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Erik=20C=2E=20For=C3=A9s?= Date: Sun, 12 Apr 2026 14:42:04 +0200 Subject: [PATCH] fix: resolve low-severity audit findings across hooks, contexts, and DX Rename PropsContext to ParamsContext for clarity, add throw guards to hooks used outside their provider, rename Router 'options' parameter to 'props', add memory navigation stubs (back/forward/traverseTo/ updateCurrentEntry), add @example JSDoc to all public hooks, fix extractPathname falsy check, remove unused StartTransitionFn export, clean stale oxlintrc ignore patterns, change NotFound from div to h1, and add clearPrefetchCache for cache invalidation. BREAKING CHANGE: PropsContext renamed to ParamsContext in barrel export. StartTransitionFn type removed from TransitionContext. Hooks (useParams, usePathname, useNavigationType, useNavigationSignal) now throw when used outside their provider instead of returning a default value. --- .oxlintrc.json | 2 +- src/react/components/NotFound.tsx | 9 +- src/react/components/Router.test.tsx | 2 +- src/react/components/Router.tsx | 18 ++-- src/react/context/NavigationSignalContext.ts | 7 +- src/react/context/NavigationTypeContext.ts | 7 +- src/react/context/ParamsContext.ts | 12 +++ src/react/context/PathnameContext.ts | 9 +- src/react/context/PropsContext.ts | 12 --- src/react/context/TransitionContext.ts | 8 +- src/react/extractPathname.ts | 5 +- src/react/hooks/useActiveLinkProps.ts | 11 +++ src/react/hooks/useNavigate.ts | 15 +++ src/react/hooks/useNavigation.ts | 10 ++ src/react/hooks/useNavigationEvents.ts | 18 ++++ src/react/hooks/useNavigationHandlers.ts | 9 ++ src/react/hooks/useNavigationSignal.test.ts | 30 +++++- src/react/hooks/useNavigationSignal.ts | 23 ++++- src/react/hooks/useNavigationType.test.ts | 30 +++++- src/react/hooks/useNavigationType.ts | 26 +++++- src/react/hooks/useNextMatch.ts | 11 +++ src/react/hooks/useParams.test.ts | 40 ++++++-- src/react/hooks/useParams.ts | 13 ++- src/react/hooks/usePathname.test.ts | 14 ++- src/react/hooks/usePathname.ts | 9 +- src/react/hooks/usePrefetch.test.ts | 93 ++++++++++++++++++- src/react/hooks/usePrefetch.ts | 55 +++++++++++ src/react/index.ts | 2 +- .../navigation/createMemoryNavigation.test.ts | 32 +++++++ .../navigation/createMemoryNavigation.ts | 73 ++++++++++++--- 30 files changed, 519 insertions(+), 86 deletions(-) create mode 100644 src/react/context/ParamsContext.ts delete mode 100644 src/react/context/PropsContext.ts 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 }