= T;
+
+type RouteParamsFor<
+ T extends object | undefined,
+ P extends PathPattern
+> = T extends object
+ ? T
+ : P extends string
+ ? StringRouteParams
+ : RegexRouteParams;
-export type MatchWithParams = [
+export type MatchWithParams = [
true,
Params
];
export type NoMatch = [false, null];
-export type Match =
+export type Match =
| MatchWithParams
| NoMatch;
@@ -57,38 +64,24 @@ export type Match =
* Components:
*/
-export interface RouteComponentProps {
+export interface RouteComponentProps {
params: T;
}
export interface RouteProps<
- T extends DefaultParams | undefined = undefined,
+ T extends object | undefined = undefined,
RoutePath extends PathPattern = PathPattern
> {
children?:
- | ((
- params: T extends DefaultParams
- ? T
- : RoutePath extends string
- ? StringRouteParams
- : RegexRouteParams
- ) => ComponentChildren)
+ | ((params: RouteParamsFor) => ComponentChildren)
| ComponentChildren;
path?: RoutePath;
- component?: ComponentType<
- RouteComponentProps<
- T extends DefaultParams
- ? T
- : RoutePath extends string
- ? StringRouteParams
- : RegexRouteParams
- >
- >;
+ component?: ComponentType>>;
nest?: boolean;
}
export function Route<
- T extends DefaultParams | undefined = undefined,
+ T extends object | undefined = undefined,
RoutePath extends PathPattern = PathPattern
>(props: RouteProps): ReturnType;
@@ -125,13 +118,11 @@ export type RedirectProps =
};
export function Redirect(
- props: RedirectProps,
- context?: any
+ props: RedirectProps
): null;
export function Link(
- props: LinkProps,
- context?: any
+ props: LinkProps
): ReturnType;
/*
@@ -161,17 +152,9 @@ export const Router: FunctionComponent;
export function useRouter(): RouterObject;
export function useRoute<
- T extends DefaultParams | undefined = undefined,
+ T extends object | undefined = undefined,
RoutePath extends PathPattern = PathPattern
->(
- pattern: RoutePath
-): Match<
- T extends DefaultParams
- ? T
- : RoutePath extends string
- ? StringRouteParams
- : RegexRouteParams
->;
+>(pattern: RoutePath): Match>;
export function useLocation<
H extends BaseLocationHook = BrowserLocationHook
@@ -181,20 +164,36 @@ export function useSearch<
H extends BaseSearchHook = BrowserSearchHook
>(): ReturnType;
-export type URLSearchParamsInit = ConstructorParameters<
- typeof URLSearchParams
->[0];
-
-export type SetSearchParams = (
- nextInit:
- | URLSearchParamsInit
- | ((prev: URLSearchParams) => URLSearchParamsInit),
- options?: { replace?: boolean; state?: any }
-) => void;
-
-export function useSearchParams(): [URLSearchParams, SetSearchParams];
+export type URLSearchParamsInit =
+ | ConstructorParameters[0]
+ | ReadonlyArray;
+
+// Preserve custom hooks' required options without accepting arguments that
+// useSearchParams does not forward to navigate.
+type SearchParamsNavigationArgs = Extract<
+ Parameters[1]>,
+ [unknown, unknown, ...unknown[]]
+> extends never
+ ? [options?: Parameters[1]>[1]]
+ : HookReturnValue[1] extends (to: Path, options: infer Options) => unknown
+ ? [options: Options]
+ : never;
+
+export type SetSearchParams =
+ (
+ nextInit:
+ | URLSearchParamsInit
+ | ((prev: URLSearchParams) => URLSearchParamsInit),
+ ...args: SearchParamsNavigationArgs
+ ) => void;
+
+export function useSearchParams<
+ H extends BaseLocationHook = BrowserLocationHook
+>(): [URLSearchParams, SetSearchParams];
-export function useParams(): T extends string
+export function useParams<
+ T extends string | object | undefined = undefined
+>(): T extends string
? StringRouteParams
: T extends undefined
? DefaultParams
@@ -204,20 +203,46 @@ export function useParams(): T extends string
* Helpers
*/
+export type MatchWithBase = [
+ true,
+ Params,
+ string
+];
+// The optional slot lets TS 5.2 consumers destructure the base even on a miss.
+type NoMatchWithBase = [false, null, undefined?];
+export type LooseMatch =
+ | MatchWithBase
+ | NoMatchWithBase;
+type OptionalBaseMatch =
+ | [true, Params, string?]
+ | NoMatchWithBase;
+
+export function matchRoute<
+ T extends object | undefined = undefined,
+ RoutePath extends PathPattern = PathPattern
+>(
+ parser: Parser,
+ pattern: RoutePath,
+ path: string,
+ loose: true
+): LooseMatch>;
+export function matchRoute<
+ T extends object | undefined = undefined,
+ RoutePath extends PathPattern = PathPattern
+>(
+ parser: Parser,
+ pattern: RoutePath,
+ path: string,
+ loose?: false
+): Match>;
export function matchRoute<
- T extends DefaultParams | undefined = undefined,
+ T extends object | undefined = undefined,
RoutePath extends PathPattern = PathPattern
>(
parser: Parser,
pattern: RoutePath,
path: string,
- loose?: boolean
-): Match<
- T extends DefaultParams
- ? T
- : RoutePath extends string
- ? StringRouteParams
- : RegexRouteParams
->;
+ loose: boolean | undefined
+): OptionalBaseMatch>;
// tslint:enable:no-unnecessary-generics
diff --git a/packages/wouter-preact/types/location-hook.d.ts b/packages/wouter-preact/types/location-hook.d.ts
index b3ed50f9..744bd0cd 100644
--- a/packages/wouter-preact/types/location-hook.d.ts
+++ b/packages/wouter-preact/types/location-hook.d.ts
@@ -1,3 +1,5 @@
+import type { RouterObject } from "./router.js";
+
/*
* Foundation: useLocation and paths
*/
@@ -8,7 +10,7 @@ export type PathPattern = string | RegExp;
export type SearchString = string;
-export type HrefsFormatter = (href: string, router?: any) => string;
+export type HrefsFormatter = (href: string, router: RouterObject) => string;
// the base useLocation hook type. Any custom hook (including the
// default one) should inherit from it.
@@ -27,14 +29,15 @@ export type BaseSearchHook = (...args: any[]) => SearchString;
// Returns the type of the location tuple of the given hook.
export type HookReturnValue = ReturnType;
+// Utility type that allows us to handle cases like `any` and `never`
+type EmptyInterfaceWhenAnyOrNever = 0 extends 1 & T
+ ? {}
+ : [T] extends [never]
+ ? {}
+ : T;
+
// Returns the type of the navigation options that hook's push function accepts.
export type HookNavigationOptions =
- HookReturnValue[1] extends (
- path: Path,
- options: infer R,
- ...rest: any[]
- ) => any
- ? R extends { [k: string]: any }
- ? R
- : {}
- : {};
+ EmptyInterfaceWhenAnyOrNever<
+ NonNullable[1]>[1]> // get's the second argument of a tuple returned by the hook
+ >;
diff --git a/packages/wouter-preact/types/memory-location.d.ts b/packages/wouter-preact/types/memory-location.d.ts
index 27e4bc43..e79f588d 100644
--- a/packages/wouter-preact/types/memory-location.d.ts
+++ b/packages/wouter-preact/types/memory-location.d.ts
@@ -1,26 +1,33 @@
-import { BaseLocationHook, Path } from "./location-hook.js";
+import type { Path, SearchString } from "./location-hook.js";
+import type { NavigateOptions } from "./router.js";
-type Navigate = (
- to: Path,
- options?: { replace?: boolean; state?: S; transition?: boolean }
-) => void;
+type Navigate = (to: Path, options?: NavigateOptions) => void;
+type SearchHook = () => SearchString;
type HookReturnValue = {
- hook: BaseLocationHook;
+ hook: {
+ (): [Path, Navigate];
+ searchHook: SearchHook;
+ };
+ searchHook: SearchHook;
navigate: Navigate;
readonly state: S | null;
};
type StubHistory = { history: Path[]; reset: () => void };
-export function memoryLocation(options?: {
+type MemoryLocationOptions = {
path?: Path;
+ searchPath?: SearchString;
state?: S;
static?: boolean;
- record?: false;
-}): HookReturnValue;
-export function memoryLocation(options?: {
- path?: Path;
- state?: S;
- static?: boolean;
- record: true;
-}): HookReturnValue & StubHistory;
+};
+
+export function memoryLocation(
+ options?: MemoryLocationOptions & { record?: false }
+): HookReturnValue;
+export function memoryLocation(
+ options: MemoryLocationOptions & { record: true }
+): HookReturnValue & StubHistory;
+export function memoryLocation(
+ options: MemoryLocationOptions & { record?: boolean }
+): HookReturnValue & Partial;
diff --git a/packages/wouter-preact/types/route-params.d.ts b/packages/wouter-preact/types/route-params.d.ts
new file mode 100644
index 00000000..c401d23e
--- /dev/null
+++ b/packages/wouter-preact/types/route-params.d.ts
@@ -0,0 +1,37 @@
+// Mirror regexparam's segment parser rather than interpreting colons in literals.
+type SegmentParams = Segment extends `*${infer Rest}`
+ ? Rest extends `?${string}`
+ ? { "*"?: string | undefined }
+ : { "*": string }
+ : Segment extends `:${infer Name}`
+ ? Name extends `${infer Key}?${string}`
+ ? { [Param in Key]?: string | undefined }
+ : Name extends `${infer Key}.${string}`
+ ? { [Param in Key]: string }
+ : { [Param in Name]: string }
+ : {};
+
+// Repeated capture names are assigned in order; the last value wins.
+type AddSegment = Omit<
+ Params,
+ keyof SegmentParams
+> &
+ SegmentParams;
+
+type ParseSegments = Path extends ""
+ ? Params
+ : Path extends `${infer Segment}/${infer Rest}`
+ ? Segment extends ""
+ ? Params
+ : ParseSegments>
+ : AddSegment;
+
+// An empty route is the catch-all; only one leading slash is removed by the
+// parser, and an empty interior segment ends parsing.
+export type ExtractRouteParams = Path extends unknown
+ ? ParseSegments<
+ Path extends "" ? "*" : Path extends `/${infer Rest}` ? Rest : Path
+ > extends infer Params
+ ? { [Param in keyof Params]: Params[Param] }
+ : never
+ : never;
diff --git a/packages/wouter-preact/types/router.d.ts b/packages/wouter-preact/types/router.d.ts
index 56a0872e..3bf16277 100644
--- a/packages/wouter-preact/types/router.d.ts
+++ b/packages/wouter-preact/types/router.d.ts
@@ -1,4 +1,4 @@
-import {
+import type {
Path,
SearchString,
BaseLocationHook,
@@ -9,7 +9,7 @@ import {
export type Parser = (
route: Path,
loose?: boolean
-) => { pattern: RegExp; keys: string[] };
+) => { pattern: RegExp; keys?: readonly string[] | false | undefined };
// Standard navigation options supported by all built-in location hooks
export type NavigateOptions = {
@@ -31,7 +31,6 @@ export interface RouterObject {
readonly hook: BaseLocationHook;
readonly searchHook: BaseSearchHook;
readonly base: Path;
- readonly ownBase: Path;
readonly parser: Parser;
readonly ssrPath?: Path;
readonly ssrSearch?: SearchString;
diff --git a/packages/wouter-preact/types/use-browser-location.d.ts b/packages/wouter-preact/types/use-browser-location.d.ts
index 5b23c2e2..8de053b0 100644
--- a/packages/wouter-preact/types/use-browser-location.d.ts
+++ b/packages/wouter-preact/types/use-browser-location.d.ts
@@ -1,10 +1,7 @@
-import { Path, SearchString } from "./location-hook.js";
+import type { Path, SearchString } from "./location-hook.js";
+import type { NavigateOptions } from "./router.js";
-type Primitive = string | number | bigint | boolean | null | undefined | symbol;
-export const useLocationProperty: (
- fn: () => S,
- ssrFn?: () => S
-) => S;
+export const useLocationProperty: (fn: () => S, ssrFn?: () => S) => S;
export type BrowserSearchHook = (options?: {
ssrSearch?: SearchString;
@@ -18,7 +15,7 @@ export const useHistoryState: () => T;
export const navigate: (
to: string | URL,
- options?: { replace?: boolean; state?: S; transition?: boolean }
+ options?: NavigateOptions
) => void;
/*
diff --git a/packages/wouter-preact/types/use-hash-location.d.ts b/packages/wouter-preact/types/use-hash-location.d.ts
index 0fb32d3f..ff094014 100644
--- a/packages/wouter-preact/types/use-hash-location.d.ts
+++ b/packages/wouter-preact/types/use-hash-location.d.ts
@@ -1,10 +1,11 @@
-import { Path } from "./location-hook.js";
+import type { Path } from "./location-hook.js";
+import type { NavigateOptions } from "./router.js";
-export function navigate(
- to: Path,
- options?: { state?: S; replace?: boolean; transition?: boolean }
-): void;
+export function navigate(to: Path, options?: NavigateOptions): void;
-export function useHashLocation(options?: {
- ssrPath?: Path;
-}): [Path, typeof navigate];
+export type HashLocationHook = {
+ (options?: { ssrPath?: Path }): [Path, typeof navigate];
+ hrefs: (href: Path) => Path;
+};
+
+export const useHashLocation: HashLocationHook;
diff --git a/packages/wouter/package.json b/packages/wouter/package.json
index a900c98a..b0db7034 100644
--- a/packages/wouter/package.json
+++ b/packages/wouter/package.json
@@ -1,7 +1,7 @@
{
"name": "wouter",
- "version": "3.11.0",
- "description": "Minimalist-friendly ~1.5KB router for React",
+ "version": "4.0.0-next.0",
+ "description": "Minimalist-friendly ~2.4KB router for React",
"type": "module",
"keywords": [
"react",
@@ -38,7 +38,7 @@
},
"types": "types/index.d.ts",
"typesVersions": {
- ">=4.1": {
+ ">=5.2": {
"types/index.d.ts": [
"types/index.d.ts"
],
@@ -63,10 +63,9 @@
},
"license": "Unlicense",
"peerDependencies": {
- "react": ">=16.8.0"
+ "react": ">=18.2.0"
},
"dependencies": {
- "regexparam": "^3.0.0",
- "use-sync-external-store": "^1.0.0"
+ "regexparam": "^3.0.0"
}
}
diff --git a/packages/wouter/src/index.js b/packages/wouter/src/index.js
index 7c8f549e..a07e0a52 100644
--- a/packages/wouter/src/index.js
+++ b/packages/wouter/src/index.js
@@ -146,10 +146,10 @@ export const Router = ({ children, ...props }) => {
}
// hooks can define their own `href` formatter (e.g. for hash location)
- props.hrefs = props.hrefs ?? props.hook?.hrefs;
+ props.hrefs ??= props.hook?.hrefs;
// hooks can define their own search hook (e.g. for memory location)
- props.searchHook = props.searchHook ?? props.hook?.searchHook;
+ props.searchHook ??= props.hook?.searchHook;
// what is happening below: to avoid unnecessary rerenders in child components,
// we ensure that the router object reference is stable, unless there are any
@@ -198,9 +198,9 @@ const useCachedParams = (value) => {
const curr = prev.current,
keys = Object.keys(value);
return (prev.current =
- // Update cache if number of params changed or any value changed
+ // Update cache if parameter names or values changed
keys.length !== Object.keys(curr).length ||
- keys.some((k) => value[k] !== curr[k])
+ keys.some((k) => !Object.hasOwn(curr, k) || value[k] !== curr[k])
? value // Return new value if there are changes
: curr); // Return cached value if nothing changed
};
diff --git a/packages/wouter/src/react-deps.js b/packages/wouter/src/react-deps.js
index 573fbd1d..77ceb005 100644
--- a/packages/wouter/src/react-deps.js
+++ b/packages/wouter/src/react-deps.js
@@ -1,13 +1,14 @@
-import * as React from "react";
-
-// React.useInsertionEffect is not available in React <18
-// This hack fixes a transpilation issue on some apps
-const useBuiltinInsertionEffect = React["useInsertion" + "Effect"];
+import {
+ useRef,
+ useInsertionEffect,
+ useLayoutEffect,
+ useEffect,
+ useSyncExternalStore as useReactSyncExternalStore,
+} from "react";
export {
useMemo,
useRef,
- useState,
useContext,
createContext,
isValidElement,
@@ -17,43 +18,32 @@ export {
forwardRef,
} from "react";
-// To resolve webpack 5 errors, while not presenting problems for native,
-// we copy the approaches from https://github.com/TanStack/query/pull/3561
-// and https://github.com/TanStack/query/pull/3601
-// ~ Show this aging PR some love to remove the need for this hack:
-// https://github.com/facebook/react/pull/25231 ~
-export { useSyncExternalStore } from "./use-sync-external-store.js";
+// A fresh getter makes React 18 refresh its store instance after render-phase
+// state updates. Without it, returning to the initial location can be missed.
+// https://github.com/facebook/react/pull/25578
+export const useSyncExternalStore = (
+ subscribe,
+ getSnapshot,
+ getServerSnapshot
+) =>
+ useReactSyncExternalStore(subscribe, () => getSnapshot(), getServerSnapshot);
-// Copied from:
-// https://github.com/facebook/react/blob/main/packages/shared/ExecutionEnvironment.js
+// React 18 warns when useLayoutEffect runs during server rendering.
+// Redirects still need a layout effect in the browser, before the next paint.
const canUseDOM = !!(
typeof window !== "undefined" &&
typeof window.document !== "undefined" &&
typeof window.document.createElement !== "undefined"
);
-
-// Copied from:
-// https://github.com/reduxjs/react-redux/blob/master/src/utils/useIsomorphicLayoutEffect.ts
-// "React currently throws a warning when using useLayoutEffect on the server.
-// To get around it, we can conditionally useEffect on the server (no-op) and
-// useLayoutEffect in the browser."
export const useIsomorphicLayoutEffect = canUseDOM
- ? React.useLayoutEffect
- : React.useEffect;
-
-// useInsertionEffect is already a noop on the server.
-// See: https://github.com/facebook/react/blob/main/packages/react-server/src/ReactFizzHooks.js
-export const useInsertionEffect =
- useBuiltinInsertionEffect || useIsomorphicLayoutEffect;
+ ? useLayoutEffect
+ : useEffect;
-// Userland polyfill while we wait for the forthcoming
-// https://github.com/reactjs/rfcs/blob/useevent/text/0000-useevent.md
-// Note: "A high-fidelity polyfill for useEvent is not possible because
-// there is no lifecycle or Hook in React that we can use to switch
-// .current at the right timing."
-// So we will have to make do with this "close enough" approach for now.
+// Keep callbacks stable while using the latest committed handler.
+// React's useEffectEvent is restricted to Effects, so it cannot replace the
+// callbacks passed to components and called by click/navigation handlers here.
export const useEvent = (fn) => {
- const ref = React.useRef([fn, (...args) => ref[0](...args)]).current;
+ const ref = useRef([fn, (...args) => ref[0](...args)]).current;
// Per Dan Abramov: useInsertionEffect executes marginally closer to the
// correct timing for ref synchronization than useLayoutEffect on React 18.
// See: https://github.com/facebook/react/pull/25881#issuecomment-1356244360
diff --git a/packages/wouter/src/use-hash-location.js b/packages/wouter/src/use-hash-location.js
index 130b886b..c3288192 100644
--- a/packages/wouter/src/use-hash-location.js
+++ b/packages/wouter/src/use-hash-location.js
@@ -34,12 +34,7 @@ export const navigate = (to, { state = null, replace = false } = {}) => {
history[replace ? "replaceState" : "pushState"](state, "", newURL);
- const event =
- typeof HashChangeEvent !== "undefined"
- ? new HashChangeEvent("hashchange", { oldURL, newURL })
- : new Event("hashchange", { detail: { oldURL, newURL } });
-
- dispatchEvent(event);
+ dispatchEvent(new HashChangeEvent("hashchange", { oldURL, newURL }));
};
export const useHashLocation = ({ ssrPath = "/" } = {}) => [
diff --git a/packages/wouter/src/use-sync-external-store.js b/packages/wouter/src/use-sync-external-store.js
deleted file mode 100644
index 885d7624..00000000
--- a/packages/wouter/src/use-sync-external-store.js
+++ /dev/null
@@ -1 +0,0 @@
-export { useSyncExternalStore } from "use-sync-external-store/shim/index.js";
diff --git a/packages/wouter/src/use-sync-external-store.native.js b/packages/wouter/src/use-sync-external-store.native.js
deleted file mode 100644
index 53206504..00000000
--- a/packages/wouter/src/use-sync-external-store.native.js
+++ /dev/null
@@ -1 +0,0 @@
-export { useSyncExternalStore } from "use-sync-external-store/shim/index.native.js";
diff --git a/packages/wouter/test/fixtures/ssr.tsx b/packages/wouter/test/fixtures/ssr.tsx
new file mode 100644
index 00000000..16c9cb7e
--- /dev/null
+++ b/packages/wouter/test/fixtures/ssr.tsx
@@ -0,0 +1,58 @@
+import { createElement as h } from "react";
+import { renderToString } from "react-dom/server";
+import {
+ Link,
+ Redirect,
+ Router,
+ useLocation,
+ useSearch,
+ type SsrContext,
+} from "wouter";
+import { useHashLocation } from "wouter/use-hash-location";
+
+function Location() {
+ const [path] = useLocation();
+ const search = useSearch();
+ return h("p", null, `${path}?${search}`);
+}
+
+const context: SsrContext = {};
+
+console.log(
+ JSON.stringify({
+ browserGlobals: [
+ typeof window,
+ typeof document,
+ typeof location,
+ typeof history,
+ ],
+ browser: renderToString(
+ h(Router, {
+ ssrPath: "/ssr/react?from=path",
+ children: h(Location),
+ })
+ ),
+ hash: renderToString(
+ h(Router, {
+ hook: useHashLocation,
+ ssrPath: "/ssr/hash",
+ ssrSearch: "?from=hash",
+ children: h(Location),
+ })
+ ),
+ link: renderToString(
+ h(Router, {
+ ssrPath: "/",
+ children: h(Link, { href: "/about" }, "About"),
+ })
+ ),
+ redirect: renderToString(
+ h(Router, {
+ ssrPath: "/",
+ ssrContext: context,
+ children: h(Redirect, { to: "/about" }),
+ })
+ ),
+ context,
+ })
+);
diff --git a/packages/wouter/test/link.test.tsx b/packages/wouter/test/link.test.tsx
index a8acfcb0..879a36b2 100644
--- a/packages/wouter/test/link.test.tsx
+++ b/packages/wouter/test/link.test.tsx
@@ -37,6 +37,30 @@ describe("", () => {
expect(refCallback).toHaveBeenCalledWith(element);
});
+ test.each([false, true])(
+ "clears the callback ref on unmount (asChild: %s)",
+ (asChild) => {
+ const ref = mock<(element: HTMLAnchorElement | null) => void>();
+ const { getByRole, unmount } = render(
+ asChild ? (
+
+ Home
+
+ ) : (
+
+ Home
+
+ )
+ );
+
+ expect(ref).toHaveBeenCalledWith(getByRole("link", { name: "Home" }));
+ expect(ref).toHaveBeenCalledTimes(1);
+ unmount();
+ expect(ref.mock.calls.at(-1)?.[0]).toBe(null);
+ expect(ref).toHaveBeenCalledTimes(2);
+ }
+ );
+
test("still creates a plain link when nothing is passed", () => {
const { getByTestId } = render();
diff --git a/packages/wouter/test/match-route.test-d.ts b/packages/wouter/test/match-route.test-d.ts
index 7b8e0279..295c7463 100644
--- a/packages/wouter/test/match-route.test-d.ts
+++ b/packages/wouter/test/match-route.test-d.ts
@@ -4,12 +4,13 @@ import { matchRoute, useRouter } from "../src/index.js";
const assertType = (_value: T): void => {};
const { parser } = useRouter();
-test("should only accept strings", () => {
+test("accepts string and regular expression patterns", () => {
// @ts-expect-error
assertType(matchRoute(parser, Symbol(), ""));
// @ts-expect-error
assertType(matchRoute(parser, undefined, ""));
assertType(matchRoute(parser, "/", ""));
+ assertType(matchRoute(parser, /\/users\/(\d+)/, ""));
});
test('has a boolean "match" result as a first returned value', () => {
@@ -50,7 +51,117 @@ test("infers parameters from the route path", () => {
2?: string;
name?: string;
id: string;
- wildcard?: string;
+ "*"?: string;
}>();
}
});
+
+test("returns the matched base only for a successful loose match", () => {
+ const result = matchRoute(parser, "/users/:id", "/users/123/edit", true);
+
+ if (result[0]) {
+ expectTypeOf(result[1].id).toEqualTypeOf();
+ expectTypeOf(result[2]).toEqualTypeOf();
+ expectTypeOf(result.length).toEqualTypeOf<3>();
+ } else {
+ expectTypeOf(result).toEqualTypeOf<[false, null, undefined?]>();
+ expectTypeOf(result[2]).toEqualTypeOf();
+ }
+});
+
+test("narrows a destructured loose match and its base together", () => {
+ const [matched, params, base] = matchRoute(
+ parser,
+ "/users/:id",
+ "/users/123/edit",
+ true
+ );
+
+ if (matched) {
+ expectTypeOf(params.id).toEqualTypeOf();
+ expectTypeOf(base).toEqualTypeOf();
+ } else {
+ expectTypeOf(params).toEqualTypeOf();
+ expectTypeOf(base).toEqualTypeOf();
+ }
+});
+
+test("strict matches have exactly two tuple elements", () => {
+ const omitted = matchRoute(parser, "/users/:id", "/users/123");
+ const explicit = matchRoute(parser, "/users/:id", "/users/123", false);
+
+ expectTypeOf(omitted.length).toEqualTypeOf<2>();
+ expectTypeOf(explicit.length).toEqualTypeOf<2>();
+ // @ts-expect-error strict matches have no base tuple element
+ omitted[2];
+ // @ts-expect-error strict matches have no base tuple element
+ explicit[2];
+});
+
+test("handles a dynamic loose option", () => {
+ const loose: boolean = Math.random() > 0.5;
+ const result = matchRoute(parser, "/users/:id", "/users/123", loose);
+
+ if (result[0]) {
+ expectTypeOf(result[1].id).toEqualTypeOf();
+ expectTypeOf(result[2]).toEqualTypeOf();
+ } else {
+ expectTypeOf(result).toEqualTypeOf<[false, null, undefined?]>();
+ expectTypeOf(result[2]).toEqualTypeOf();
+ }
+
+ const optional: boolean | undefined = Math.random() > 0.5 ? loose : undefined;
+ const optionalResult = matchRoute(
+ parser,
+ "/users/:id",
+ "/users/123",
+ optional
+ );
+ if (optionalResult[0]) {
+ expectTypeOf(optionalResult[2]).toEqualTypeOf();
+ }
+
+ const [matched, params, base] = matchRoute(
+ parser,
+ "/users/:id",
+ "/users/123",
+ loose
+ );
+ if (matched) {
+ expectTypeOf(params.id).toEqualTypeOf();
+ expectTypeOf(base).toEqualTypeOf();
+ } else {
+ expectTypeOf(params).toEqualTypeOf();
+ expectTypeOf(base).toEqualTypeOf();
+ }
+});
+
+test("treats an empty pattern as a catch-all", () => {
+ const [, params] = matchRoute(parser, "", "/anything");
+
+ if (params) {
+ expectTypeOf(params["*"]).toEqualTypeOf();
+ }
+});
+
+test("accepts interface parameters for strict and loose matches", () => {
+ interface UserParams {
+ id: string;
+ }
+
+ const strict = matchRoute(parser, "/users/:id", "/users/123");
+ const loose = matchRoute(
+ parser,
+ "/users/:id",
+ "/users/123",
+ true
+ );
+
+ if (strict[0]) {
+ expectTypeOf(strict[1]).toEqualTypeOf();
+ }
+ if (loose[0]) {
+ expectTypeOf(loose[1]).toEqualTypeOf();
+ expectTypeOf(loose[2]).toEqualTypeOf();
+ }
+});
diff --git a/packages/wouter/test/match-route.test.ts b/packages/wouter/test/match-route.test.ts
index e4603dc4..35ed143b 100644
--- a/packages/wouter/test/match-route.test.ts
+++ b/packages/wouter/test/match-route.test.ts
@@ -1,20 +1,17 @@
import { test, expect } from "bun:test";
import { parse } from "regexparam";
import { matchRoute } from "../src/index.js";
+import type { RegexRouteParams } from "../src/index.js";
test("keeps empty matches and only includes the base in loose mode", () => {
expect(matchRoute(parse, /^/, "/users")).toEqual([true, {}]);
- expect(matchRoute(parse, /^/, "/users", true)).toEqual([
- true,
- {},
- "",
- ]);
+ expect(matchRoute(parse, /^/, "/users", true)).toEqual([true, {}, ""]);
expect(matchRoute(parse, "/users", "/other", true)).toEqual([false, null]);
});
test("named parser keys take precedence over positional captures", () => {
const parser = () => ({ pattern: /^\/(\w+)\/(\w+)$/, keys: ["1", "0"] });
- expect(matchRoute(parser, "", "/first/second")).toEqual([
+ expect(matchRoute(parser, "", "/first/second")).toEqual([
true,
{ 0: "second", 1: "first" },
]);
@@ -25,7 +22,7 @@ test("duplicate names keep the last capture, including missing captures", () =>
true,
{ 0: "first", 1: "second", id: "second" },
]);
- expect(matchRoute(parse, "/:id/:id?", "/first")).toStrictEqual([
+ expect(matchRoute(parse, "/:id/:id?", "/first")).toStrictEqual([
true,
{ 0: "first", 1: undefined, id: undefined },
]);
@@ -35,7 +32,7 @@ test("regex routes bypass the parser and retain optional named captures", () =>
const parser = () => {
throw new Error("Regex routes should not be parsed");
};
- expect(
+ expect(
matchRoute(parser, /^\/(?\w+)(?:\/(?\w+))?/, "/first", true)
).toStrictEqual([
true,
diff --git a/packages/wouter/test/memory-location.test-d.ts b/packages/wouter/test/memory-location.test-d.ts
index 347cba3a..5a13caff 100644
--- a/packages/wouter/test/memory-location.test-d.ts
+++ b/packages/wouter/test/memory-location.test-d.ts
@@ -1,6 +1,6 @@
import { test, expectTypeOf } from "bun:test";
import { memoryLocation } from "../src/memory-location.js";
-import { BaseLocationHook } from "../src/index.js";
+import { BaseLocationHook, useLocation } from "../src/index.js";
const assertType = (_value: T): void => {};
@@ -28,7 +28,7 @@ test("should support `record` option for saving the navigation history", () => {
assertType(reset);
});
-test("should have history only wheen record is true", () => {
+test("should have history only when record is true", () => {
// @ts-expect-error
const { history, reset } = memoryLocation({ record: false });
assertType(history);
@@ -50,6 +50,73 @@ test("should support state", () => {
expectTypeOf(memory.state).toEqualTypeOf<{ from: string } | null>();
});
+test("should preserve inferred state in both navigation functions", () => {
+ const memory = memoryLocation({ state: { from: "initial" } });
+ const [, navigate] = memory.hook();
+
+ expectTypeOf(navigate).toEqualTypeOf(memory.navigate);
+ navigate("/next", { state: { from: "previous" }, transition: true });
+ memory.navigate("/next", { state: { from: "previous" }, replace: true });
+
+ // @ts-expect-error - the hook must preserve the inferred state shape
+ navigate("/next", { state: { missing: "from" } });
+ // @ts-expect-error - external navigation uses the same state shape
+ memory.navigate("/next", { state: 42 });
+ // @ts-expect-error - state is read-only
+ memory.state = { from: "next" };
+});
+
+test("should preserve explicit state through useLocation", () => {
+ const memory = memoryLocation<{ count: number }>({ record: true });
+ const [, navigate] = useLocation();
+
+ expectTypeOf(memory.state).toEqualTypeOf<{ count: number } | null>();
+ navigate("/next", { state: { count: 1 } });
+ memory.navigate("/next", { state: { count: 2 } });
+ memory.reset();
+
+ // @ts-expect-error - explicitly typed state survives the public hook
+ navigate("/next", { state: { count: "wrong" } });
+});
+
+test("should expose the search hook attached to the location hook", () => {
+ const memory = memoryLocation({ searchPath: "tab=1" });
+
+ expectTypeOf(memory.hook.searchHook).toEqualTypeOf(memory.searchHook);
+ expectTypeOf(memory.hook.searchHook()).toEqualTypeOf();
+
+ // @ts-expect-error - attached searchHook is a function
+ const search: string = memory.hook.searchHook;
+});
+
+test("should allow dynamic recording without guaranteeing history", () => {
+ const record: boolean = Math.random() > 0.5;
+ const memory = memoryLocation({ record, state: { count: 0 } });
+
+ expectTypeOf(memory.history).toEqualTypeOf();
+ expectTypeOf(memory.reset).toEqualTypeOf<(() => void) | undefined>();
+ memory.reset?.();
+ memory.hook()[1]("/next", { state: { count: 1 } });
+
+ // @ts-expect-error - recording may be disabled
+ memory.reset();
+ // @ts-expect-error - recording may be disabled
+ const history: string[] = memory.history;
+ // @ts-expect-error - dynamic recording must not erase the state type
+ memory.hook()[1]("/next", { state: "wrong" });
+});
+
+test("should accept optional recording configuration", () => {
+ const options: { record?: boolean } = {};
+ const memory = memoryLocation<{ count: number }>(options);
+
+ expectTypeOf(memory.history).toEqualTypeOf();
+ memory.navigate("/next", { state: { count: 1 } });
+
+ // @ts-expect-error - record only accepts a boolean
+ memoryLocation({ record: "yes" });
+});
+
test("should support `static` option", () => {
const { hook } = memoryLocation({ static: true });
diff --git a/packages/wouter/test/public-api.test-d.tsx b/packages/wouter/test/public-api.test-d.tsx
new file mode 100644
index 00000000..f09e3d14
--- /dev/null
+++ b/packages/wouter/test/public-api.test-d.tsx
@@ -0,0 +1,294 @@
+import { createRef } from "react";
+import {
+ Link,
+ Redirect,
+ Route,
+ Router,
+ Switch,
+ matchRoute,
+ useLocation,
+ useParams,
+ useRoute,
+ useSearch,
+ useSearchParams,
+} from "wouter";
+import type {
+ Parser,
+ RouteComponentProps,
+ RouterObject,
+ RouterOptions,
+ StringRouteParams,
+} from "wouter";
+import { memoryLocation } from "wouter/memory-location";
+
+const location = memoryLocation({ path: "/users/42", record: true });
+const routerOptions = {
+ hook: location.hook,
+ searchHook: location.searchHook,
+ ssrPath: "/users/42",
+} satisfies RouterOptions;
+
+function User({ params }: RouteComponentProps<{ id: string }>) {
+ return {params.id.toUpperCase()}
;
+}
+
+function Hooks() {
+ const [matched, params] = useRoute("/users/:id/:tab?");
+ if (matched) {
+ params.id.toUpperCase();
+ params.tab?.toUpperCase();
+ } else {
+ const absent: null = params;
+ void absent;
+ }
+
+ const paramsFromContext = useParams<"/users/:id">();
+ paramsFromContext.id.toUpperCase();
+
+ const [path, navigate] = useLocation();
+ navigate(path, { replace: true, state: { from: "/" } });
+ useSearch().toUpperCase();
+ const [search, setSearch] = useSearchParams();
+ setSearch(
+ (previous) => {
+ previous.set("q", search.get("q") ?? "");
+ return previous;
+ },
+ { replace: true }
+ );
+
+ return null;
+}
+
+export const application = (
+
+ ()}>
+ User
+
+ (active ? "active" : undefined)}>
+ Home
+
+
+
+
+ {(params) => {params.slug.toUpperCase()}
}
+
+
+
+
+
+
+
+);
+
+// Both destination props at once would be ambiguous.
+// @ts-expect-error Choose either href or to.
+export const ambiguousLink = ;
+
+// @ts-expect-error A destination is required.
+export const missingRedirect = ;
+
+export const callbackRefLink = (
+ {
+ const anchor: HTMLAnchorElement | null = element;
+ anchor?.href.toUpperCase();
+ // @ts-expect-error Anchor refs are not button elements.
+ element?.disabled;
+ // React 18 calls the same callback with null when detaching the ref.
+ const attached: boolean = anchor !== null;
+ void attached;
+ }}
+ />
+);
+
+export const callbackRefChildLink = (
+ {
+ const child: HTMLElement | null = element;
+ child?.focus();
+ // @ts-expect-error An arbitrary child need not be an anchor.
+ element?.href;
+ }}
+ >
+
+
+);
+
+export const invalidRefElement = (
+ // @ts-expect-error A regular Link forwards its ref to an anchor, not a button.
+ ()} />
+);
+
+interface UserParams {
+ id: string;
+ tab?: string;
+}
+
+export const interfaceRoute = (
+ path="/users/:id/:tab?">
+ {(params) => (
+
+ {params.id.toUpperCase()}
+ {params.tab?.toUpperCase()}
+
+ )}
+
+);
+
+export const optionalDuplicate: StringRouteParams<"/:id/:id?"> = {
+ id: undefined,
+};
+export const requiredDuplicate: StringRouteParams<"/:id?/:id"> = {
+ id: "42",
+};
+
+// @ts-expect-error The final required capture cannot be omitted.
+export const missingDuplicate: StringRouteParams<"/:id?/:id"> = {};
+
+const missingKeys: Parser = () => ({ pattern: /^\/users\/(\w+)/ });
+const falseKeys: Parser = () => ({
+ pattern: /^\/users\/(\w+)/,
+ keys: false,
+});
+const readonlyKeys: Parser = () => ({
+ pattern: /^\/users\/(\w+)/,
+ keys: ["id"] as const,
+});
+
+export const customParsers: RouterOptions[] = [
+ { parser: missingKeys },
+ { parser: falseKeys },
+ { parser: readonlyKeys },
+ {
+ hrefs: (href, router) => {
+ const currentRouter: RouterObject = router;
+ // @ts-expect-error RouterObject has no arbitrary properties.
+ router.missingProperty;
+ return currentRouter.base + href;
+ },
+ },
+];
+
+function ImprovedRouteInference(dynamicPattern: string) {
+ const [, explicit] = useRoute("/users/:id/:tab?");
+ if (explicit) {
+ const id: string = explicit.id;
+ const optional: string | undefined = explicit.tab;
+ void [id, optional];
+ }
+
+ const [, wildcard] = useRoute("/files/*/edit");
+ if (wildcard) {
+ const capture: string = wildcard["*"];
+ // @ts-expect-error Runtime names wildcard captures "*", not "wild".
+ wildcard.wild;
+ void capture;
+ }
+
+ const [, filename] = useRoute("/files/:name.json/:version?.txt");
+ if (filename) {
+ const name: string = filename.name;
+ const version: string | undefined = filename.version;
+ // @ts-expect-error The extension is not part of the parameter name.
+ filename["name.json"];
+ void [name, version];
+ }
+
+ const [, optional] = useRoute("/:id/:id?");
+ if (optional) {
+ const lastCapture: string | undefined = optional.id;
+ // @ts-expect-error The final optional capture can be absent.
+ const required: string = optional.id;
+ void [lastCapture, required];
+ }
+
+ const [, dynamic] = useRoute(dynamicPattern);
+ if (dynamic) {
+ const named: string | undefined = dynamic.id;
+ const numbered: string | undefined = dynamic[0];
+ // @ts-expect-error A dynamic pattern does not guarantee named parameters.
+ const required: string = dynamic.id;
+ void [named, numbered, required];
+ }
+
+ const [matched, params, base] = matchRoute(
+ readonlyKeys,
+ "/users/:id",
+ "/users/42/details",
+ true
+ );
+ if (matched) {
+ const id: string = params.id;
+ const matchedBase: string = base;
+ void [id, matchedBase];
+ } else {
+ const missingParams: null = params;
+ const missingBase: undefined = base;
+ void [missingParams, missingBase];
+ }
+}
+
+const statefulMemory = memoryLocation({ state: { page: 1 } });
+
+function StatefulHooks() {
+ const [, directlyNavigate] = statefulMemory.hook();
+ directlyNavigate("/users", { state: { page: 2 } });
+ // @ts-expect-error Memory state retains the initial object's shape.
+ directlyNavigate("/users", { state: { page: "two" } });
+
+ const [, navigate] = useLocation();
+ navigate("/users", { state: { page: 2 } });
+ // @ts-expect-error useLocation preserves custom navigation options.
+ navigate("/users", { state: { page: "two" } });
+
+ const [, setSearch] = useSearchParams();
+ const entries = [["page", "2"]] as const;
+ setSearch(entries, { state: { page: 2 } });
+ setSearch(() => entries);
+ // @ts-expect-error Search updates use the location hook's state type.
+ setSearch(entries, { state: { page: "two" } });
+
+ const [, setBrowserSearch] = useSearchParams();
+ setBrowserSearch(entries, { transition: true });
+ return null;
+}
+
+void [ImprovedRouteInference, StatefulHooks];
+
+type NullableOptionsHook = () => [
+ string,
+ (to: string, options?: { token: string } | null) => void
+];
+type RequiredOptionsHook = () => [
+ string,
+ (to: string, options: { token: string }) => void
+];
+type OptionalOptionsHook = () => [
+ string,
+ (to: string, options?: { token: string }) => void
+];
+
+function CustomSearchOptions() {
+ const [, setNullableSearch] = useSearchParams();
+ setNullableSearch("q=test", null);
+ setNullableSearch("q=test", undefined);
+ setNullableSearch("q=test");
+ setNullableSearch("q=test", { token: "session" });
+ // @ts-expect-error Only the hook's declared options are allowed.
+ setNullableSearch("q=test", 123);
+
+ const [, setUnionSearch] = useSearchParams<
+ RequiredOptionsHook | OptionalOptionsHook
+ >();
+ setUnionSearch("q=test", { token: "session" });
+ // @ts-expect-error The options must be safe for either possible hook.
+ setUnionSearch("q=test");
+ // @ts-expect-error The required branch does not accept undefined.
+ setUnionSearch("q=test", undefined);
+}
+
+void CustomSearchOptions;
diff --git a/packages/wouter/test/public-subpaths.test-d.ts b/packages/wouter/test/public-subpaths.test-d.ts
new file mode 100644
index 00000000..c8e1dde0
--- /dev/null
+++ b/packages/wouter/test/public-subpaths.test-d.ts
@@ -0,0 +1,34 @@
+import {
+ navigate,
+ useBrowserLocation,
+ useHistoryState,
+ useLocationProperty,
+ usePathname,
+ useSearch,
+} from "wouter/use-browser-location";
+import {
+ navigate as navigateHash,
+ useHashLocation,
+} from "wouter/use-hash-location";
+import { memoryLocation } from "wouter/memory-location";
+
+// Compile through the package's public exports, rather than source-relative
+// imports or path aliases. These functions are never executed.
+function browserHooks() {
+ navigate(new URL("https://example.com"), { state: { page: 1 } });
+ navigateHash("/page", { replace: true });
+ useBrowserLocation({ ssrPath: "/" })[0].toUpperCase();
+ useHashLocation({ ssrPath: "/" })[0].toUpperCase();
+ usePathname({ ssrPath: "/" }).toUpperCase();
+ useSearch({ ssrSearch: "q=test" }).toUpperCase();
+ useHistoryState<{ page: number }>()?.page.toFixed();
+ const literal: "online" = useLocationProperty(() => "online" as const);
+ return literal;
+}
+
+const memory = memoryLocation({ path: "/", record: true });
+memory.navigate("/next");
+memory.history[0]?.toUpperCase();
+memory.reset();
+
+void browserHooks;
diff --git a/packages/wouter/test/route.test-d.tsx b/packages/wouter/test/route.test-d.tsx
index f820da4b..db1b1a89 100644
--- a/packages/wouter/test/route.test-d.tsx
+++ b/packages/wouter/test/route.test-d.tsx
@@ -109,11 +109,26 @@ describe("parameter inference", () => {
;
});
- test("extract wildcard params into `wild` property", () => {
+ test("extracts wildcard params into the `*` property", () => {
- {({ wild }) => {
- expectTypeOf(wild).toEqualTypeOf();
- return The path is {wild}
;
+ {(params) => {
+ expectTypeOf(params["*"]).toEqualTypeOf();
+ // @ts-expect-error wildcard captures are named `*`, not `wild`
+ params.wild;
+ return The path is {params["*"]}
;
+ }}
+ ;
+ });
+
+ test("infers optional parameters and file extensions", () => {
+
+ {(params) => {
+ expectTypeOf(params.name).toEqualTypeOf();
+ expectTypeOf(params.revision).toEqualTypeOf();
+ expectTypeOf(params["*"]).toEqualTypeOf();
+ // @ts-expect-error regexparam treats the extension as literal text
+ params.format;
+ return null;
}}
;
});
@@ -127,11 +142,33 @@ describe("parameter inference", () => {
;
});
- test("can't infer the type when the path isn't known at compile time", () => {
+ test("supports interface parameters without an index signature", () => {
+ interface UserParams {
+ id: string;
+ section?: string;
+ }
+
+ path="/users/:id/:section?">
+ {(params) => {
+ expectTypeOf(params).toEqualTypeOf();
+ return params.id;
+ }}
+ ;
+ });
+
+ test("uses optional string parameters for dynamic and any paths", () => {
+ const path: string = "/home/:section";
+
+
+ {(params) => {
+ expectTypeOf(params.section).toEqualTypeOf();
+ return ;
+ }}
+ ;
+
{(params) => {
- // @ts-expect-error
- params.section;
+ expectTypeOf(params.section).toEqualTypeOf();
return ;
}}
;
diff --git a/packages/wouter/test/router.test-d.tsx b/packages/wouter/test/router.test-d.tsx
index 1908862d..f56c9d83 100644
--- a/packages/wouter/test/router.test-d.tsx
+++ b/packages/wouter/test/router.test-d.tsx
@@ -7,6 +7,7 @@ import {
useRouter,
Parser,
Path,
+ RouterObject,
} from "../src/index.js";
test("should have at least one child", () => {
@@ -63,7 +64,9 @@ test("accepts `hrefs` function for transforming href strings", () => {
{
- expectTypeOf(router).toEqualTypeOf();
+ expectTypeOf(router).toEqualTypeOf();
+ // @ts-expect-error This field does not exist on the router at runtime.
+ router.ownBase;
return href + router.base;
}}
>
@@ -80,6 +83,14 @@ test("accepts `parser` function for generating regular expressions", () => {
};
this is a valid router;
+
+ ({ pattern: /(?\w+)/ })}>Named groups;
+ ({ pattern: /(?\w+)/, keys: false })}>
+ Named groups without keys
+ ;
+ ({ pattern: /(\w+)/, keys: ["id"] as const })}>
+ Readonly parser keys
+ ;
});
test("does not accept other props", () => {
diff --git a/packages/wouter/test/ssr-environment.test.ts b/packages/wouter/test/ssr-environment.test.ts
new file mode 100644
index 00000000..6435e6ab
--- /dev/null
+++ b/packages/wouter/test/ssr-environment.test.ts
@@ -0,0 +1,26 @@
+import { expect, test } from "bun:test";
+import { join } from "path";
+
+test("renders links, redirects, and locations without browser globals or SSR warnings", async () => {
+ // A plain Bun process does not load the test suite's happy-dom preload.
+ const child = Bun.spawn(
+ [process.execPath, join(import.meta.dir, "fixtures/ssr.tsx")],
+ { stdout: "pipe", stderr: "pipe" }
+ );
+ const [output, errors, exitCode] = await Promise.all([
+ new Response(child.stdout).text(),
+ new Response(child.stderr).text(),
+ child.exited,
+ ]);
+
+ expect(errors).toBe("");
+ expect(exitCode).toBe(0);
+ expect(JSON.parse(output)).toEqual({
+ browserGlobals: ["undefined", "undefined", "undefined", "undefined"],
+ browser: "/ssr/react?from=path
",
+ hash: "/ssr/hash?from=hash
",
+ link: 'About',
+ redirect: "",
+ context: { redirectTo: "/about" },
+ });
+});
diff --git a/packages/wouter/test/use-browser-location.test-d.ts b/packages/wouter/test/use-browser-location.test-d.ts
index 3e990870..fe0aa54c 100644
--- a/packages/wouter/test/use-browser-location.test-d.ts
+++ b/packages/wouter/test/use-browser-location.test-d.ts
@@ -3,6 +3,7 @@ import {
useBrowserLocation,
useSearch,
useHistoryState,
+ useLocationProperty,
} from "../src/use-browser-location.js";
const assertType = (_value: T): void => {};
@@ -27,6 +28,13 @@ describe("useBrowserLocation", () => {
assertType(navigate(null));
assertType(navigate("/path", { replace: true }));
+ assertType(navigate(new URL("https://example.com/next")));
+ navigate<{ count: number }>("/path", {
+ state: { count: 1 },
+ transition: true,
+ });
+ // @ts-expect-error - explicit state shapes are checked
+ navigate<{ count: number }>("/path", { state: { count: "wrong" } });
// @ts-expect-error
assertType(navigate("/path", { unknownOption: true }));
});
@@ -38,6 +46,27 @@ describe("useBrowserLocation", () => {
});
});
+describe("useLocationProperty", () => {
+ test("should preserve object and nullable snapshot types", () => {
+ const snapshot = { from: "/previous", scroll: [0, 100] as const };
+ const state = useLocationProperty(() => snapshot);
+
+ expectTypeOf(state).toEqualTypeOf();
+
+ const nullable = useLocationProperty(
+ () => snapshot,
+ () => null
+ );
+ expectTypeOf(nullable).toEqualTypeOf();
+
+ useLocationProperty(
+ () => snapshot,
+ // @ts-expect-error - the server snapshot must match the chosen type
+ () => "wrong"
+ );
+ });
+});
+
describe("useSearch", () => {
test("should return string", () => {
type Search = ReturnType;
diff --git a/packages/wouter/test/use-hash-location.test-d.ts b/packages/wouter/test/use-hash-location.test-d.ts
index 8144be85..e4464091 100644
--- a/packages/wouter/test/use-hash-location.test-d.ts
+++ b/packages/wouter/test/use-hash-location.test-d.ts
@@ -19,6 +19,13 @@ test("accepts a `ssrPath` path option", () => {
useHashLocation({ unknown: "/base" });
});
+test("exposes its href formatter", () => {
+ expectTypeOf(useHashLocation.hrefs("/users")).toEqualTypeOf();
+
+ // @ts-expect-error - hrefs formats string paths
+ useHashLocation.hrefs(new URL("https://example.com"));
+});
+
describe("`navigate` function", () => {
test("accepts an arbitrary `state` option", () => {
navigate("/object", { state: { foo: "bar" } });
@@ -30,4 +37,18 @@ describe("`navigate` function", () => {
test("returns nothing", () => {
assertType(navigate("/foo"));
});
+
+ test("preserves explicit state types through the hook", () => {
+ const [, navigateFromHook] = useHashLocation();
+ navigateFromHook<{ count: number }>("/next", {
+ state: { count: 1 },
+ replace: true,
+ transition: true,
+ });
+
+ // @ts-expect-error - explicit state shapes are checked
+ navigateFromHook<{ count: number }>("/next", { state: "wrong" });
+ // @ts-expect-error - hash navigation only accepts string paths
+ navigate(new URL("https://example.com"));
+ });
});
diff --git a/packages/wouter/test/use-params.test-d.ts b/packages/wouter/test/use-params.test-d.ts
index 82d3f4c8..ae90c749 100644
--- a/packages/wouter/test/use-params.test-d.ts
+++ b/packages/wouter/test/use-params.test-d.ts
@@ -1,5 +1,5 @@
import { test, expectTypeOf } from "bun:test";
-import { useParams } from "../src/index.js";
+import { useParams, StringRouteParams } from "../src/index.js";
test("does not accept any arguments", () => {
expectTypeOf().parameters.toEqualTypeOf<[]>();
@@ -35,3 +35,49 @@ test("can accept the custom type of parameters as a generic argument", () => {
//@ts-expect-error
return params.notFound;
});
+
+test("accepts interface parameters without an index signature", () => {
+ interface UserParams {
+ id: string;
+ page?: number;
+ }
+
+ expectTypeOf(useParams()).toEqualTypeOf();
+});
+
+test("rejects primitives that are neither patterns nor parameter objects", () => {
+ // @ts-expect-error a number cannot describe route parameters
+ useParams();
+ // @ts-expect-error a boolean cannot describe route parameters
+ useParams();
+ // @ts-expect-error null cannot describe route parameters
+ useParams();
+ // @ts-expect-error void cannot describe route parameters
+ useParams();
+});
+
+test("accepts explicitly undefined optional capture values", () => {
+ const params: StringRouteParams<"/:id?/*?"> = {
+ id: undefined,
+ "*": undefined,
+ };
+
+ expectTypeOf(params.id).toEqualTypeOf();
+ expectTypeOf(params["*"]).toEqualTypeOf();
+});
+
+test("provides optional string values for an unspecified route string", () => {
+ const params = useParams();
+
+ expectTypeOf(params.id).toEqualTypeOf();
+ expectTypeOf(params[0]).toEqualTypeOf();
+});
+
+test("preserves regexparam's names for optional extension captures", () => {
+ const params = useParams<"/files/:name.json?/:revision?.txt">();
+
+ expectTypeOf(params["name.json"]).toEqualTypeOf();
+ expectTypeOf(params.revision).toEqualTypeOf();
+ // @ts-expect-error regexparam uses all text before `?` as the parameter name
+ params.name;
+});
diff --git a/packages/wouter/test/use-params.test.tsx b/packages/wouter/test/use-params.test.tsx
index eb433d21..573fa1e3 100644
--- a/packages/wouter/test/use-params.test.tsx
+++ b/packages/wouter/test/use-params.test.tsx
@@ -150,6 +150,25 @@ test("keeps the object ref the same if params haven't changed", () => {
expect(result.current).toBe(firstRenderedParams);
});
+test("updates parameter names when optional values remain undefined", () => {
+ const { hook } = memoryLocation({ path: "/" });
+ let path = "/:first?";
+
+ const { result, rerender } = renderHook(() => useParams(), {
+ wrapper: (props) => (
+
+ {props.children}
+
+ ),
+ });
+
+ expect(result.current).toStrictEqual({ 0: undefined, first: undefined });
+
+ path = "/:second?";
+ rerender();
+ expect(result.current).toStrictEqual({ 0: undefined, second: undefined });
+});
+
test("works when the route becomes matching", () => {
const { hook, navigate } = memoryLocation({ path: "/" });
diff --git a/packages/wouter/test/use-route.test-d.ts b/packages/wouter/test/use-route.test-d.ts
index 57261bca..e7346043 100644
--- a/packages/wouter/test/use-route.test-d.ts
+++ b/packages/wouter/test/use-route.test-d.ts
@@ -3,12 +3,13 @@ import { useRoute } from "../src/index.js";
const assertType = (_value: T): void => {};
-test("should only accept strings", () => {
+test("accepts string and regular expression patterns", () => {
// @ts-expect-error
assertType(useRoute(Symbol()));
// @ts-expect-error
assertType(useRoute());
assertType(useRoute("/"));
+ assertType(useRoute(/\/users\/(\d+)/));
});
test('has a boolean "match" result as a first returned value', () => {
@@ -47,11 +48,97 @@ test("infers parameters from the route path", () => {
2?: string;
name?: string;
id: string;
- wildcard?: string;
+ "*"?: string;
}>();
}
});
+test("uses the runtime wildcard key in every position", () => {
+ const [match, params] = useRoute("/files/*/edit");
+
+ if (match) {
+ expectTypeOf(params["*"]).toEqualTypeOf();
+ // @ts-expect-error middle wildcard captures are still named `*`
+ params.wild;
+ }
+
+ const [, optional] = useRoute("/files/*?");
+ if (optional) {
+ expectTypeOf(optional["*"]).toEqualTypeOf();
+ }
+});
+
+test("extracts parameter names before file extensions", () => {
+ const [, params] = useRoute("/files/:name.json/:version?.txt");
+
+ if (params) {
+ expectTypeOf(params.name).toEqualTypeOf();
+ expectTypeOf(params.version).toEqualTypeOf();
+ // @ts-expect-error the file extension is not part of the key
+ params["name.json"];
+ }
+});
+
+test("lets the last duplicate capture determine optionality", () => {
+ const [, required] = useRoute("/:id?/:id");
+ const [, optional] = useRoute("/:id/:id?");
+
+ if (required) {
+ expectTypeOf(required.id).toEqualTypeOf();
+ }
+ if (optional) {
+ expectTypeOf(optional.id).toEqualTypeOf();
+ }
+});
+
+test("ignores embedded colons and segments after an empty segment", () => {
+ const [, params] = useRoute("/literal:name/:id//:ignored");
+
+ if (params) {
+ expectTypeOf(params.id).toEqualTypeOf();
+ // @ts-expect-error a colon must begin a segment to introduce a parameter
+ params.name;
+ // @ts-expect-error the parser stops at the empty segment
+ params.ignored;
+ }
+});
+
+test("returns optional string parameters for dynamic paths", () => {
+ const path: string = "/users/:id";
+ const [, params] = useRoute(path);
+
+ if (params) {
+ expectTypeOf(params.id).toEqualTypeOf();
+ expectTypeOf(params[0]).toEqualTypeOf();
+ }
+});
+
+test("distributes inferred parameters over route unions", () => {
+ const pattern = Math.random() ? "/users/:id" : "/posts/:slug";
+ const [, params] = useRoute(pattern);
+
+ if (params && "id" in params) {
+ expectTypeOf(params.id).toEqualTypeOf();
+ // @ts-expect-error the other route's parameter is not present
+ params.slug;
+ }
+ if (params && "slug" in params) {
+ expectTypeOf(params.slug).toEqualTypeOf();
+ }
+});
+
+test("accepts interface parameters without an index signature", () => {
+ interface UserParams {
+ id: string;
+ name?: string;
+ }
+
+ const [match, params] = useRoute("/users/:id/:name?");
+ if (match) {
+ expectTypeOf(params).toEqualTypeOf();
+ }
+});
+
test("infers parameters from absolute route patterns", () => {
const [match, params] = useRoute("~/app/users/:name?/:id");
diff --git a/packages/wouter/test/use-search-params.test-d.ts b/packages/wouter/test/use-search-params.test-d.ts
new file mode 100644
index 00000000..9e41e8ff
--- /dev/null
+++ b/packages/wouter/test/use-search-params.test-d.ts
@@ -0,0 +1,67 @@
+import { test, expectTypeOf } from "bun:test";
+import { useSearchParams, type SetSearchParams } from "../src/index.js";
+import { memoryLocation } from "../src/memory-location.js";
+
+test("accepts readonly search entries and all standard navigation options", () => {
+ const [params, setParams] = useSearchParams();
+ expectTypeOf(params).toEqualTypeOf();
+ expectTypeOf(setParams).toEqualTypeOf();
+ setParams([["q", "hello"]] as const, { transition: true });
+ setParams(() => [["page", "2"]] as const, { replace: true });
+ setParams(undefined);
+});
+
+test("preserves the navigation state type of a memory hook", () => {
+ const memory = memoryLocation<{ from: string }>();
+ const [, setParams] = useSearchParams();
+ setParams("q=hello", { state: { from: "/" }, transition: true });
+ setParams({ q: "hello" });
+ // @ts-expect-error State must conform to the custom hook.
+ setParams("q=hello", { state: { from: 123 } });
+});
+
+test("preserves required options for a custom navigation hook", () => {
+ type CustomHook = () => [
+ string,
+ (to: string, options: { token: string }) => void
+ ];
+ const [, setParams] = useSearchParams();
+ setParams("q=hello", { token: "session" });
+ // @ts-expect-error This hook requires navigation options.
+ setParams("q=hello");
+ // @ts-expect-error This hook requires a token.
+ setParams("q=hello", { replace: true });
+ // @ts-expect-error The setter only forwards the options argument.
+ setParams("q=hello", { token: "session" }, "extra");
+});
+
+test("forwards nullable navigation options unchanged", () => {
+ type CustomHook = () => [
+ string,
+ (to: string, options?: { token: string } | null) => void
+ ];
+ const [, setParams] = useSearchParams();
+ setParams("q=hello", null);
+ setParams("q=hello", undefined);
+ setParams("q=hello");
+ setParams("q=hello", { token: "session" });
+ // @ts-expect-error The hook does not accept numbers.
+ setParams("q=hello", 123);
+});
+
+test("keeps options required when a hook union contains a required branch", () => {
+ type RequiredHook = () => [
+ string,
+ (to: string, options: { token: string }) => void
+ ];
+ type OptionalHook = () => [
+ string,
+ (to: string, options?: { token: string }) => void
+ ];
+ const [, setParams] = useSearchParams();
+ setParams("q=hello", { token: "session" });
+ // @ts-expect-error Options must be safe for either possible hook.
+ setParams("q=hello");
+ // @ts-expect-error The required hook does not accept undefined.
+ setParams("q=hello", undefined);
+});
diff --git a/packages/wouter/test/use-search.test.tsx b/packages/wouter/test/use-search.test.tsx
index 9b5bb02d..d238e3e9 100644
--- a/packages/wouter/test/use-search.test.tsx
+++ b/packages/wouter/test/use-search.test.tsx
@@ -11,7 +11,7 @@ test("revisits the initial search after a render-phase state update (#393)", ()
const { result } = renderHook(() => {
const search = useSearch();
// Minimal equivalent of urql synchronizing query state during render.
- // React 18 drops this update; fixed in React 19:
+ // React 18 drops this update without our fresh-snapshot-getter adapter:
// https://github.com/facebook/react/pull/25578
const [previousSearch, setPreviousSearch] = useState(search);
if (previousSearch !== search) setPreviousSearch(search);
diff --git a/packages/wouter/types/index.d.ts b/packages/wouter/types/index.d.ts
index cada6813..18abb5b7 100644
--- a/packages/wouter/types/index.d.ts
+++ b/packages/wouter/types/index.d.ts
@@ -1,19 +1,18 @@
-// Minimum TypeScript Version: 4.1
+// Minimum TypeScript Version: 5.2
// tslint:disable:no-unnecessary-generics
-import {
+import type {
AnchorHTMLAttributes,
FunctionComponent,
RefAttributes,
- ComponentType,
ReactNode,
ReactElement,
MouseEventHandler,
JSXElementConstructor,
} from "react";
-import {
+import type {
Path,
PathPattern,
BaseLocationHook,
@@ -21,23 +20,21 @@ import {
HookNavigationOptions,
BaseSearchHook,
} from "./location-hook.js";
-import {
+import type {
BrowserLocationHook,
BrowserSearchHook,
} from "./use-browser-location.js";
-import { Parser, RouterObject, RouterOptions } from "./router.js";
+import type { Parser, RouterObject, RouterOptions } from "./router.js";
-// these files only export types, so we can re-export them as-is
-// in TS 5.0 we'll be able to use `export type * from ...`
-export * from "./location-hook.js";
-export * from "./router.js";
+export type * from "./location-hook.js";
+export type * from "./router.js";
-import { RouteParams } from "regexparam";
+import type { ExtractRouteParams } from "./route-params.js";
-export type StringRouteParams = RouteParams & {
- [param: number]: string | undefined;
-};
+export type StringRouteParams = string extends T
+ ? DefaultParams
+ : ExtractRouteParams & { [param: number]: string | undefined };
export type RegexRouteParams = { [key: string | number]: string | undefined };
/**
@@ -47,14 +44,23 @@ export interface DefaultParams {
readonly [paramName: string | number]: string | undefined;
}
-export type Params = T;
+export type Params = T;
+
+type RouteParamsFor<
+ T extends object | undefined,
+ P extends PathPattern
+> = T extends object
+ ? T
+ : P extends string
+ ? StringRouteParams
+ : RegexRouteParams;
-export type MatchWithParams = [
+export type MatchWithParams = [
true,
Params
];
export type NoMatch = [false, null];
-export type Match =
+export type Match =
| MatchWithParams
| NoMatch;
@@ -62,38 +68,24 @@ export type Match =
* Components:
*/
-export interface RouteComponentProps {
+export interface RouteComponentProps {
params: T;
}
export interface RouteProps<
- T extends DefaultParams | undefined = undefined,
+ T extends object | undefined = undefined,
RoutePath extends PathPattern = PathPattern
> {
- children?:
- | ((
- params: T extends DefaultParams
- ? T
- : RoutePath extends string
- ? StringRouteParams
- : RegexRouteParams
- ) => ReactNode)
- | ReactNode;
+ children?: ((params: RouteParamsFor) => ReactNode) | ReactNode;
path?: RoutePath;
component?: JSXElementConstructor<
- RouteComponentProps<
- T extends DefaultParams
- ? T
- : RoutePath extends string
- ? StringRouteParams
- : RegexRouteParams
- >
+ RouteComponentProps>
>;
nest?: boolean;
}
export function Route<
- T extends DefaultParams | undefined = undefined,
+ T extends object | undefined = undefined,
RoutePath extends PathPattern = PathPattern
>(props: RouteProps): ReturnType;
@@ -112,8 +104,7 @@ export type RedirectProps =
};
export function Redirect(
- props: RedirectProps,
- context?: any
+ props: RedirectProps
): null;
type AsChildProps =
@@ -139,8 +130,7 @@ export type LinkProps =
>;
export function Link(
- props: LinkProps,
- context?: any
+ props: LinkProps
): ReturnType;
/*
@@ -170,17 +160,9 @@ export const Router: FunctionComponent;
export function useRouter(): RouterObject;
export function useRoute<
- T extends DefaultParams | undefined = undefined,
+ T extends object | undefined = undefined,
RoutePath extends PathPattern = PathPattern
->(
- pattern: RoutePath
-): Match<
- T extends DefaultParams
- ? T
- : RoutePath extends string
- ? StringRouteParams
- : RegexRouteParams
->;
+>(pattern: RoutePath): Match>;
export function useLocation<
H extends BaseLocationHook = BrowserLocationHook
@@ -190,19 +172,36 @@ export function useSearch<
H extends BaseSearchHook = BrowserSearchHook
>(): ReturnType;
-export type URLSearchParamsInit = ConstructorParameters<
- typeof URLSearchParams
->[0];
-export type SetSearchParams = (
- nextInit:
- | URLSearchParamsInit
- | ((prev: URLSearchParams) => URLSearchParamsInit),
- options?: { replace?: boolean; state?: any }
-) => void;
-
-export function useSearchParams(): [URLSearchParams, SetSearchParams];
+export type URLSearchParamsInit =
+ | ConstructorParameters[0]
+ | ReadonlyArray;
+
+// Preserve custom hooks' required options without accepting arguments that
+// useSearchParams does not forward to navigate.
+type SearchParamsNavigationArgs = Extract<
+ Parameters[1]>,
+ [unknown, unknown, ...unknown[]]
+> extends never
+ ? [options?: Parameters[1]>[1]]
+ : HookReturnValue[1] extends (to: Path, options: infer Options) => unknown
+ ? [options: Options]
+ : never;
+
+export type SetSearchParams =
+ (
+ nextInit:
+ | URLSearchParamsInit
+ | ((prev: URLSearchParams) => URLSearchParamsInit),
+ ...args: SearchParamsNavigationArgs
+ ) => void;
+
+export function useSearchParams<
+ H extends BaseLocationHook = BrowserLocationHook
+>(): [URLSearchParams, SetSearchParams];
-export function useParams(): T extends string
+export function useParams<
+ T extends string | object | undefined = undefined
+>(): T extends string
? StringRouteParams
: T extends undefined
? DefaultParams
@@ -212,20 +211,46 @@ export function useParams(): T extends string
* Helpers
*/
+export type MatchWithBase = [
+ true,
+ Params,
+ string
+];
+// The optional slot lets TS 5.2 consumers destructure the base even on a miss.
+type NoMatchWithBase = [false, null, undefined?];
+export type LooseMatch =
+ | MatchWithBase
+ | NoMatchWithBase;
+type OptionalBaseMatch =
+ | [true, Params, string?]
+ | NoMatchWithBase;
+
+export function matchRoute<
+ T extends object | undefined = undefined,
+ RoutePath extends PathPattern = PathPattern
+>(
+ parser: Parser,
+ pattern: RoutePath,
+ path: string,
+ loose: true
+): LooseMatch>;
+export function matchRoute<
+ T extends object | undefined = undefined,
+ RoutePath extends PathPattern = PathPattern
+>(
+ parser: Parser,
+ pattern: RoutePath,
+ path: string,
+ loose?: false
+): Match>;
export function matchRoute<
- T extends DefaultParams | undefined = undefined,
+ T extends object | undefined = undefined,
RoutePath extends PathPattern = PathPattern
>(
parser: Parser,
pattern: RoutePath,
path: string,
- loose?: boolean
-): Match<
- T extends DefaultParams
- ? T
- : RoutePath extends string
- ? StringRouteParams
- : RegexRouteParams
->;
+ loose: boolean | undefined
+): OptionalBaseMatch>;
// tslint:enable:no-unnecessary-generics
diff --git a/packages/wouter/types/location-hook.d.ts b/packages/wouter/types/location-hook.d.ts
index e7e0db03..744bd0cd 100644
--- a/packages/wouter/types/location-hook.d.ts
+++ b/packages/wouter/types/location-hook.d.ts
@@ -1,3 +1,5 @@
+import type { RouterObject } from "./router.js";
+
/*
* Foundation: useLocation and paths
*/
@@ -8,7 +10,7 @@ export type PathPattern = string | RegExp;
export type SearchString = string;
-export type HrefsFormatter = (href: string, router?: any) => string;
+export type HrefsFormatter = (href: string, router: RouterObject) => string;
// the base useLocation hook type. Any custom hook (including the
// default one) should inherit from it.
diff --git a/packages/wouter/types/memory-location.d.ts b/packages/wouter/types/memory-location.d.ts
index 18446b90..e79f588d 100644
--- a/packages/wouter/types/memory-location.d.ts
+++ b/packages/wouter/types/memory-location.d.ts
@@ -1,34 +1,33 @@
-import {
- BaseLocationHook,
- BaseSearchHook,
- Path,
- SearchString,
-} from "./location-hook.js";
+import type { Path, SearchString } from "./location-hook.js";
+import type { NavigateOptions } from "./router.js";
-type Navigate = (
- to: Path,
- options?: { replace?: boolean; state?: S; transition?: boolean }
-) => void;
+type Navigate = (to: Path, options?: NavigateOptions) => void;
+type SearchHook = () => SearchString;
type HookReturnValue = {
- hook: BaseLocationHook;
- searchHook: BaseSearchHook;
+ hook: {
+ (): [Path, Navigate