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 }