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
}