diff --git a/src/react/components/Router.tsx b/src/react/components/Router.tsx index 0cd3f3b..a178638 100644 --- a/src/react/components/Router.tsx +++ b/src/react/components/Router.tsx @@ -22,6 +22,7 @@ import { useNavigationEvents } from 'router/react:hooks/useNavigationEvents' import { MatcherContext } from 'router/react:context/MatcherContext' import { TransitionContext } from 'router/react:context/TransitionContext' import { PathnameContext } from 'router/react:context/PathnameContext' +import { UrlContext } from 'router/react:context/UrlContext' import { extractPathname } from 'router/react:extractPathname' /** @@ -57,6 +58,14 @@ interface CurrentState { * active links and read the current location. */ pathname: string + + /** + * The full destination URL string for this navigation. + * Used by `useSearchParams` to derive search parameters + * from React state rather than reading the mutable + * `navigation.currentEntry` during render. + */ + url: string | null } /** @@ -171,6 +180,7 @@ export function Router(options: RouterProps) { signal: null, navigationType: null, pathname: extractPathname(url), + url, } }) @@ -223,6 +233,7 @@ export function Router(options: RouterProps) { signal: event.signal, navigationType: event.navigationType, pathname: extractPathname(event.destination.url), + url: event.destination.url, }) }) @@ -250,13 +261,15 @@ export function Router(options: RouterProps) { - - - - - - - + + + + + + + + + diff --git a/src/react/context/NavigationContext.ts b/src/react/context/NavigationContext.ts index 6824c0b..37c917b 100644 --- a/src/react/context/NavigationContext.ts +++ b/src/react/context/NavigationContext.ts @@ -10,4 +10,4 @@ import { createContext } from 'react' * The `useNavigation` hook throws a descriptive error when consumed * without a provider. */ -export const NavigationContext = createContext(null as unknown as Navigation) +export const NavigationContext = createContext(null) diff --git a/src/react/context/UrlContext.ts b/src/react/context/UrlContext.ts new file mode 100644 index 0000000..a6520db --- /dev/null +++ b/src/react/context/UrlContext.ts @@ -0,0 +1,14 @@ +import { createContext } from 'react' + +/** + * Provides the full committed URL string to descendant + * components. Updated by the Router on every navigation + * with the destination URL. Defaults to `null` when no + * Router is present in the tree. + * + * Consumed by `useSearchParams` to derive search parameters + * from React state rather than reading the mutable + * `navigation.currentEntry` during render — preventing + * subscription tearing in concurrent mode. + */ +export const UrlContext = createContext(null) diff --git a/src/react/createRouter.test.ts b/src/react/createRouter.test.ts index 4d75eeb..b1d985a 100644 --- a/src/react/createRouter.test.ts +++ b/src/react/createRouter.test.ts @@ -838,4 +838,205 @@ describe('createRouter', { concurrent: true }, function () { expect(router.match('/dashboard/settings')?.handler.component).toBe(DashSettings) }) }) + + describe('redirect cycle detection', { concurrent: true }, function () { + it('throws on a self-redirect', function ({ expect }) { + expect(function () { + createRouter(function (route) { + route('/loop').redirect('/loop') + }) + }).toThrow('redirect cycle detected') + }) + + it('throws on a two-hop redirect cycle', function ({ expect }) { + expect(function () { + createRouter(function (route) { + route('/a').redirect('/b') + route('/b').redirect('/a') + }) + }).toThrow('redirect cycle detected') + }) + + it('throws on a multi-hop redirect cycle', function ({ expect }) { + expect(function () { + createRouter(function (route) { + route('/a').redirect('/b') + route('/b').redirect('/c') + route('/c').redirect('/a') + }) + }).toThrow('redirect cycle detected') + }) + + it('allows non-cyclic redirect chains', function ({ expect }) { + expect(function () { + createRouter(function (route) { + route('/a').redirect('/b') + route('/b').redirect('/c') + route('/c').render(Stub) + }) + }).not.toThrow() + }) + + it('skips cycle detection for callback redirects', function ({ expect }) { + expect(function () { + createRouter(function (route) { + route('/a').redirect(function () { + return '/a' + }) + }) + }).not.toThrow() + }) + }) + + describe('builder consumed guard', { concurrent: true }, function () { + it('throws when calling middleware after render', function ({ expect }) { + expect(function () { + createRouter(function (route) { + const builder = route('/page') + + builder.render(Stub) + builder.middleware([createMiddleware()]) + }) + }).toThrow('cannot call .middleware() on a route builder') + }) + + it('throws when calling prefetch after render', function ({ expect }) { + expect(function () { + createRouter(function (route) { + const builder = route('/page') + + builder.render(Stub) + builder.prefetch(vi.fn()) + }) + }).toThrow('cannot call .prefetch() on a route builder') + }) + + it('throws when calling scroll after render', function ({ expect }) { + expect(function () { + createRouter(function (route) { + const builder = route('/page') + + builder.render(Stub) + builder.scroll('manual') + }) + }).toThrow('cannot call .scroll() on a route builder') + }) + + it('throws when calling focusReset after render', function ({ expect }) { + expect(function () { + createRouter(function (route) { + const builder = route('/page') + + builder.render(Stub) + builder.focusReset('manual') + }) + }).toThrow('cannot call .focusReset() on a route builder') + }) + + it('throws when calling formHandler after render', function ({ expect }) { + expect(function () { + createRouter(function (route) { + const builder = route('/page') + + builder.render(Stub) + builder.formHandler(vi.fn()) + }) + }).toThrow('cannot call .formHandler() on a route builder') + }) + + it('throws when calling render twice', function ({ expect }) { + expect(function () { + createRouter(function (route) { + const builder = route('/page') + + builder.render(Stub) + builder.render(createStub()) + }) + }).toThrow('cannot call .render() on a route builder') + }) + + it('throws when calling redirect after render', function ({ expect }) { + expect(function () { + createRouter(function (route) { + const builder = route('/page') + + builder.render(Stub) + builder.redirect('/other') + }) + }).toThrow('cannot call .redirect() on a route builder') + }) + + it('throws when calling group after render', function ({ expect }) { + expect(function () { + createRouter(function (route) { + const builder = route('/page') + + builder.render(Stub) + builder.group() + }) + }).toThrow('cannot call .group() on a route builder') + }) + + it('throws when calling render after redirect', function ({ expect }) { + expect(function () { + createRouter(function (route) { + const builder = route('/page') + + builder.redirect('/other') + builder.render(Stub) + }) + }).toThrow('cannot call .render() on a route builder') + }) + + it('throws when calling render after group', function ({ expect }) { + expect(function () { + createRouter(function (route) { + const builder = route('/page') + + builder.group() + builder.render(Stub) + }) + }).toThrow('cannot call .render() on a route builder') + }) + }) + + describe('duplicate route registration', { concurrent: true }, function () { + it('throws when registering the same path twice with render', function ({ expect }) { + expect(function () { + createRouter(function (route) { + route('/page').render(Stub) + route('/page').render(createStub()) + }) + }).toThrow('duplicate route registration') + }) + + it('throws when registering the same path with render and redirect', function ({ expect }) { + expect(function () { + createRouter(function (route) { + route('/page').render(Stub) + route('/page').redirect('/other') + }) + }).toThrow('duplicate route registration') + }) + + it('throws when registering the same nested path twice', function ({ expect }) { + expect(function () { + createRouter(function (route) { + const app = route('/app').group() + + app('/page').render(Stub) + app('/page').render(createStub()) + }) + }).toThrow('duplicate route registration') + }) + + it('allows registering different paths', function ({ expect }) { + expect(function () { + createRouter(function (route) { + route('/a').render(Stub) + route('/b').render(createStub()) + }) + }).not.toThrow() + }) + }) }) diff --git a/src/react/createRouter.ts b/src/react/createRouter.ts index 8cfb1be..bebaae1 100644 --- a/src/react/createRouter.ts +++ b/src/react/createRouter.ts @@ -304,9 +304,17 @@ function RedirectFallback() { * * @param matcher - The matcher to register routes on. * @param inherited - Configuration from parent groups. + * @param redirects - Shared map collecting static redirect + * targets for post-registration cycle detection. Keys are + * source paths, values are target paths. Only populated + * for string (non-callback) redirect targets. * @returns A `RouteFactory` function. */ -function createRouteFactory(matcher: Matcher, inherited: InheritedConfig): RouteFactory { +function createRouteFactory( + matcher: Matcher, + inherited: InheritedConfig, + redirects: Map +): RouteFactory { return function route(path?: string): RouteBuilder { const state: BuilderState = { path, @@ -317,6 +325,31 @@ function createRouteFactory(matcher: Matcher, inherited: InheritedConfi formHandler: undefined, } + /** + * Whether a terminal method (`.render()`, `.redirect()`, + * or `.group()`) has been called on this builder. Once + * consumed, further method calls throw to prevent + * accidental reuse. + */ + let isConsumed = false + + /** + * Guards against calling builder methods after a terminal + * method has been invoked. Throws a descriptive error + * indicating which method was called on a consumed builder. + * + * @param method - The method name being called. + * @throws When the builder has already been consumed. + */ + function assertNotConsumed(method: string) { + if (isConsumed) { + throw new Error( + `cannot call .${method}() on a route builder that has ` + + `already been consumed by .render(), .redirect(), or .group()` + ) + } + } + /** * Computes the full registration path by joining the * inherited prefix with this builder's path. Throws @@ -363,36 +396,44 @@ function createRouteFactory(matcher: Matcher, inherited: InheritedConfi const builder: RouteBuilder = { middleware(list) { + assertNotConsumed('middleware') state.middlewares.push(...list) return builder }, prefetch(fn) { + assertNotConsumed('prefetch') state.prefetches.push(fn) return builder }, scroll(behavior) { + assertNotConsumed('scroll') state.scroll = behavior return builder }, focusReset(behavior) { + assertNotConsumed('focusReset') state.focusReset = behavior return builder }, formHandler(fn) { + assertNotConsumed('formHandler') state.formHandler = fn return builder }, render(component) { + assertNotConsumed('render') + isConsumed = true + const fullPath = resolveFullPath() const handler: Handler = { @@ -408,8 +449,15 @@ function createRouteFactory(matcher: Matcher, inherited: InheritedConfi }, redirect(target) { + assertNotConsumed('redirect') + isConsumed = true + const fullPath = resolveFullPath() + if (typeof target === 'string') { + redirects.set(fullPath, target) + } + const handler: Handler = { component: RedirectFallback, prefetch: function (context) { @@ -423,6 +471,9 @@ function createRouteFactory(matcher: Matcher, inherited: InheritedConfi }, group() { + assertNotConsumed('group') + isConsumed = true + const childPrefix = joinPaths(inherited.prefix, state.path ?? '') const childInherited: InheritedConfig = { @@ -431,7 +482,7 @@ function createRouteFactory(matcher: Matcher, inherited: InheritedConfi prefetches: [...inherited.prefetches, ...state.prefetches], } - return createRouteFactory(matcher, childInherited) + return createRouteFactory(matcher, childInherited, redirects) }, } @@ -439,6 +490,35 @@ function createRouteFactory(matcher: Matcher, inherited: InheritedConfi } } +/** + * Detects cycles in static redirect routes. Walks each + * redirect chain and throws if a cycle is found. Only + * checks string (non-callback) redirect targets since + * callback targets are resolved at runtime. + * + * @param redirects - Map of source path to target path + * for all static redirects. + * @throws When a redirect cycle is detected, with the + * full cycle path in the error message. + */ +function detectRedirectCycles(redirects: Map) { + for (const [source] of redirects) { + const visited = new Set() + let current = source + + while (redirects.has(current)) { + if (visited.has(current)) { + const cycle = [...visited, current].join(' -> ') + + throw new Error(`redirect cycle detected: ${cycle}`) + } + + visited.add(current) + current = redirects.get(current)! + } + } +} + /** * Creates a route matcher using a declarative builder API. * Routes are defined inside a callback that receives a @@ -448,12 +528,17 @@ function createRouteFactory(matcher: Matcher, inherited: InheritedConfi * chaining, nested groups with path prefixing, redirects, * and all handler options (scroll, focusReset, formHandler). * + * After all routes are registered, static redirect targets + * are checked for cycles. Self-redirects and multi-hop loops + * throw an error at registration time. + * * Returns a `Matcher` that plugs directly into the * `` component. * * @param callback - A function that defines routes using the * provided `route` factory. * @returns A populated matcher ready for the Router. + * @throws When a static redirect cycle is detected. * * @example * ```tsx @@ -481,6 +566,7 @@ function createRouteFactory(matcher: Matcher, inherited: InheritedConfi */ export function createRouter(callback: (route: RouteFactory) => void): Matcher { const matcher = createMatcher() + const redirects = new Map() const rootInherited: InheritedConfig = { prefix: '', @@ -488,9 +574,11 @@ export function createRouter(callback: (route: RouteFactory) => void): Matcher` component tree. * * @returns An object with `back` and `canGoBack`. @@ -50,6 +57,27 @@ export interface UseBackResult { */ export function useBack(): UseBackResult { const navigation = useNavigation() + const [canGoBack, setCanGoBack] = useState(navigation.canGoBack) + + useEffect( + function () { + /** + * Syncs the React state with the Navigation API's + * `canGoBack` property whenever the current entry + * changes. + */ + function onEntryChange() { + setCanGoBack(navigation.canGoBack) + } + + navigation.addEventListener('currententrychange', onEntryChange) + + return function () { + navigation.removeEventListener('currententrychange', onEntryChange) + } + }, + [navigation] + ) /** * Traverses backward in the session history by @@ -61,6 +89,6 @@ export function useBack(): UseBackResult { return { back, - canGoBack: navigation.canGoBack, + canGoBack, } } diff --git a/src/react/hooks/useForward.ts b/src/react/hooks/useForward.ts index 96d47ec..5a235f0 100644 --- a/src/react/hooks/useForward.ts +++ b/src/react/hooks/useForward.ts @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react' import { useNavigation } from 'router/react:hooks/useNavigation' /** @@ -20,7 +21,9 @@ export interface UseForwardResult { /** * Whether forward navigation is possible. Mirrors * `navigation.canGoForward` from the Navigation API. - * When false, calling `forward()` will throw. + * Reactively updates when navigations change the + * history stack. When false, calling `forward()` will + * throw. */ readonly canGoForward: boolean } @@ -31,6 +34,11 @@ export interface UseForwardResult { * `canGoForward` boolean that reflects whether the history * stack has a next entry to traverse to. * + * The `canGoForward` value is kept in React state and + * updated via the `currententrychange` event, ensuring + * it stays reactive across navigations — including those + * triggered outside of React (e.g. browser forward button). + * * Must be used inside a `` component tree. * * @returns An object with `forward` and `canGoForward`. @@ -50,6 +58,27 @@ export interface UseForwardResult { */ export function useForward(): UseForwardResult { const navigation = useNavigation() + const [canGoForward, setCanGoForward] = useState(navigation.canGoForward) + + useEffect( + function () { + /** + * Syncs the React state with the Navigation API's + * `canGoForward` property whenever the current entry + * changes. + */ + function onEntryChange() { + setCanGoForward(navigation.canGoForward) + } + + navigation.addEventListener('currententrychange', onEntryChange) + + return function () { + navigation.removeEventListener('currententrychange', onEntryChange) + } + }, + [navigation] + ) /** * Traverses forward in the session history by @@ -61,6 +90,6 @@ export function useForward(): UseForwardResult { return { forward, - canGoForward: navigation.canGoForward, + canGoForward, } } diff --git a/src/react/hooks/usePrefetch.ts b/src/react/hooks/usePrefetch.ts index 40af9a1..41cf2e7 100644 --- a/src/react/hooks/usePrefetch.ts +++ b/src/react/hooks/usePrefetch.ts @@ -14,12 +14,28 @@ export interface PrefetchOptions { matcher?: Matcher } +/** + * Tracks URL pathnames that have already been prefetched, + * keyed by matcher instance. Using a WeakMap ensures that + * each matcher (and therefore each Router) gets its own + * dedup set, and the set is garbage-collected when the + * matcher is no longer referenced. This prevents cross- + * contamination between independent Router instances and + * between isolated test environments. + */ +const prefetchedByMatcher = new WeakMap, Set>() + /** * Returns a function that triggers the prefetch logic for a * given URL by resolving it against the matcher and calling * the route's prefetch function. Used by the Link component * for hover and viewport prefetch strategies. * + * Each pathname is prefetched at most once per matcher + * instance per page session. Subsequent calls with the same + * pathname are no-ops, preventing thundering-herd problems + * when many Links point to the same destination. + * * Since prefetch triggered from Link happens outside of a * navigation event, a stub NavigationPrecommitController is * passed (the redirect capability is not meaningful here). @@ -38,9 +54,14 @@ export function usePrefetch(options?: PrefetchOptions) { * URL, and a stub controller. Extracts the pathname from the * URL before matching to handle both absolute and relative URLs. * + * Returns early without calling the handler when the pathname + * has already been prefetched or is currently in-flight for + * this matcher instance. + * * @param url - The URL or path to prefetch data for. * @returns The prefetch promise, or undefined if no prefetch - * handler is registered for the matched route. + * handler is registered for the matched route or if the + * pathname has already been prefetched. */ return function (url: string) { const parsed = new URL(url, 'http://localhost') @@ -50,6 +71,19 @@ export function usePrefetch(options?: PrefetchOptions) { return } + let prefetched = prefetchedByMatcher.get(matcher) + + if (prefetched === undefined) { + prefetched = new Set() + prefetchedByMatcher.set(matcher, prefetched) + } + + if (prefetched.has(parsed.pathname)) { + return + } + + prefetched.add(parsed.pathname) + /** * Stub controller for prefetch outside of navigation events. * Redirect and addHandler are no-ops in this context since diff --git a/src/react/hooks/useSearchParams.test.ts b/src/react/hooks/useSearchParams.test.ts index 916fc80..e894d60 100644 --- a/src/react/hooks/useSearchParams.test.ts +++ b/src/react/hooks/useSearchParams.test.ts @@ -3,11 +3,14 @@ import { createElement, type ReactNode } from 'react' import { renderHook } from 'router/react:test-helpers' import { useSearchParams } from './useSearchParams' import { NavigationContext } from 'router/react:context/NavigationContext' +import { UrlContext } from 'router/react:context/UrlContext' import { createMemoryNavigation } from 'router/react:navigation/createMemoryNavigation' /** - * Creates a wrapper providing NavigationContext with a memory - * navigation instance at the given URL. + * Creates a wrapper providing NavigationContext and UrlContext + * with a memory navigation instance at the given URL. The + * UrlContext receives the raw URL string so that useSearchParams + * can derive search params from React state. */ function createNavigationWrapper(url: string) { const navigation = createMemoryNavigation({ url }) @@ -16,10 +19,15 @@ function createNavigationWrapper(url: string) { /** * React wrapper component that provides the memory - * navigation via NavigationContext. + * navigation via NavigationContext and the URL string + * via UrlContext. */ function Wrapper({ children }: { children: ReactNode }) { - return createElement(NavigationContext, { value: navigation }, children) + return createElement( + NavigationContext, + { value: navigation }, + createElement(UrlContext, { value: url }, children) + ) } } @@ -152,4 +160,59 @@ describe('useSearchParams', { concurrent: true }, function () { expect(navigateSpy).toHaveBeenCalledWith('/page?q=push', { history: 'push' }) }) + + it('setter preserves the hash fragment when updating search params', function ({ + expect, + onTestFinished, + }) { + const { navigation, Wrapper } = createNavigationWrapper( + 'https://example.com/page?q=old#section' + ) + + const navigateSpy = vi.spyOn(navigation, 'navigate') + + const { current, unmount } = renderHook( + function () { + return useSearchParams() + }, + { wrapper: Wrapper } + ) + + onTestFinished(unmount) + + const [, setSearchParams] = current + + setSearchParams({ q: 'new' }) + + expect(navigateSpy).toHaveBeenCalledWith('/page?q=new#section', { history: 'replace' }) + }) + + it('returns empty search params when UrlContext is null', function ({ expect, onTestFinished }) { + const navigation = createMemoryNavigation({ url: 'https://example.com/' }) + + /** + * Wrapper providing NavigationContext but UrlContext with + * null value, simulating a component outside the Router. + */ + function Wrapper({ children }: { children: ReactNode }) { + return createElement( + NavigationContext, + { value: navigation }, + createElement(UrlContext, { value: null }, children) + ) + } + + const { current, unmount } = renderHook( + function () { + return useSearchParams() + }, + { wrapper: Wrapper } + ) + + onTestFinished(unmount) + + const [searchParams] = current + + expect(searchParams.toString()).toBe('') + }) }) diff --git a/src/react/hooks/useSearchParams.ts b/src/react/hooks/useSearchParams.ts index 18a065f..a6c21f7 100644 --- a/src/react/hooks/useSearchParams.ts +++ b/src/react/hooks/useSearchParams.ts @@ -1,3 +1,5 @@ +import { use } from 'react' +import { UrlContext } from 'router/react:context/UrlContext' import { useNavigation } from 'router/react:hooks/useNavigation' /** @@ -29,11 +31,15 @@ export interface SetSearchParamsOptions { * `URLSearchParams` instance and a setter function to * update them via navigation. * - * The getter reads from `navigation.currentEntry.url` - * on each render, so it always reflects the committed - * URL. The setter performs a navigation with the updated - * search string, defaulting to `history: 'replace'` to - * avoid polluting the history stack with parameter changes. + * The getter derives search params from `UrlContext` — + * React state managed by the Router — rather than reading + * the mutable `navigation.currentEntry` during render. + * This prevents subscription tearing in concurrent mode + * where two components could otherwise see different + * search params if a navigation fires mid-render. + * + * The setter preserves the existing hash fragment when + * updating search parameters. * * The React Compiler handles memoization of the setter, * so no manual `useCallback` is needed. @@ -64,27 +70,28 @@ export function useSearchParams(): [ (updater: SearchParamsUpdater, options?: SetSearchParamsOptions) => NavigationResult, ] { const navigation = useNavigation() + const currentUrl = use(UrlContext) - const currentUrl = navigation.currentEntry?.url - - const searchParams = currentUrl ? new URL(currentUrl).searchParams : new URLSearchParams() + const searchParams = currentUrl + ? new URL(currentUrl, 'http://localhost').searchParams + : new URLSearchParams() /** * Navigates to the current pathname with updated search * parameters. Accepts a URLSearchParams instance, a plain * record, or a function that receives the current params - * and returns new ones. + * and returns new ones. Preserves the existing hash + * fragment. */ function setSearchParams(updater: SearchParamsUpdater, options?: SetSearchParamsOptions) { - const currentEntry = navigation.currentEntry - const url = new URL(currentEntry?.url ?? '/', 'http://localhost') + const url = new URL(currentUrl ?? '/', 'http://localhost') const next = typeof updater === 'function' ? updater(url.searchParams) : updater const nextParams = next instanceof URLSearchParams ? next : new URLSearchParams(next) const search = nextParams.toString() - const destination = url.pathname + (search ? '?' + search : '') + const destination = url.pathname + (search ? '?' + search : '') + url.hash return navigation.navigate(destination, { history: options?.history ?? 'replace', diff --git a/src/react/index.ts b/src/react/index.ts index f4eda3c..59b476c 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -10,6 +10,7 @@ export * from 'router/react:context/NavigationContext' export * from 'router/react:context/NavigationSignalContext' export * from 'router/react:context/NavigationTypeContext' export * from 'router/react:context/PathnameContext' +export * from 'router/react:context/UrlContext' export * from 'router/react:hooks/useNavigation' export * from 'router/react:hooks/useNavigate' diff --git a/src/router/matcher.test.ts b/src/router/matcher.test.ts index 050ad09..9b9e019 100644 --- a/src/router/matcher.test.ts +++ b/src/router/matcher.test.ts @@ -274,4 +274,56 @@ describe('router', function () { }).not.toThrow() }) }) + + describe('duplicate registration', function () { + it('throws when registering the same static path twice', function ({ expect }) { + const router = createMatcher() + + router.register('/foo/bar', 1) + + expect(function () { + router.register('/foo/bar', 2) + }).toThrow('duplicate route registration for pattern "/foo/bar"') + }) + + it('throws when registering the same dynamic path twice', function ({ expect }) { + const router = createMatcher() + + router.register('/user/:id', 1) + + expect(function () { + router.register('/user/:id', 2) + }).toThrow('duplicate route registration') + }) + + it('throws when registering the same wildcard path twice', function ({ expect }) { + const router = createMatcher() + + router.register('/files/*path', 1) + + expect(function () { + router.register('/files/*path', 2) + }).toThrow('duplicate route registration') + }) + + it('throws when registering root twice', function ({ expect }) { + const router = createMatcher() + + router.register('/', 1) + + expect(function () { + router.register('/', 2) + }).toThrow('duplicate route registration') + }) + + it('allows different paths that share a prefix', function ({ expect }) { + const router = createMatcher() + + router.register('/foo', 1) + + expect(function () { + router.register('/foo/bar', 2) + }).not.toThrow() + }) + }) }) diff --git a/src/router/matcher.ts b/src/router/matcher.ts index ee14f3f..c04152a 100644 --- a/src/router/matcher.ts +++ b/src/router/matcher.ts @@ -193,6 +193,10 @@ export function createMatcher(options?: Options): Matcher { node = next } + if (node.handler !== undefined) { + throw new Error(`duplicate route registration for pattern "${pattern}"`) + } + node.handler = handler }