Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

wouter

Package Overview
Dependencies
Maintainers
1
Versions
97
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

wouter - npm Package Compare versions

Comparing version
3.7.1
to
3.8.0-beta.1
+1
src/index.d.ts
export * from "../types/index.js";
import { parse as parsePattern } from "regexparam";
import {
useBrowserLocation,
useSearch as useBrowserSearch,
} from "./use-browser-location.js";
import {
useRef,
useContext,
createContext,
isValidElement,
cloneElement,
createElement as h,
Fragment,
forwardRef,
useIsomorphicLayoutEffect,
useEvent,
useMemo,
} from "./react-deps.js";
import { absolutePath, relativePath, sanitizeSearch } from "./paths.js";
/*
* Router and router context. Router is a lightweight object that represents the current
* routing options: how location is managed, base path etc.
*
* There is a default router present for most of the use cases, however it can be overridden
* via the <Router /> component.
*/
const defaultRouter = {
hook: useBrowserLocation,
searchHook: useBrowserSearch,
parser: parsePattern,
base: "",
// this option is used to override the current location during SSR
ssrPath: undefined,
ssrSearch: undefined,
// optional context to track render state during SSR
ssrContext: undefined,
// customizes how `href` props are transformed for <Link />
hrefs: (x) => x,
};
const RouterCtx = createContext(defaultRouter);
// gets the closest parent router from the context
export const useRouter = () => useContext(RouterCtx);
/**
* Parameters context. Used by `useParams()` to get the
* matched params from the innermost `Route` component.
*/
const Params0 = {},
ParamsCtx = createContext(Params0);
export const useParams = () => useContext(ParamsCtx);
/*
* Part 1, Hooks API: useRoute and useLocation
*/
// Internal version of useLocation to avoid redundant useRouter calls
const useLocationFromRouter = (router) => {
const [location, navigate] = router.hook(router);
// the function reference should stay the same between re-renders, so that
// it can be passed down as an element prop without any performance concerns.
// (This is achieved via `useEvent`.)
return [
relativePath(router.base, location),
useEvent((to, navOpts) => navigate(absolutePath(to, router.base), navOpts)),
];
};
export const useLocation = () => useLocationFromRouter(useRouter());
export const useSearch = () => {
const router = useRouter();
return sanitizeSearch(router.searchHook(router));
};
export const matchRoute = (parser, route, path, loose) => {
// if the input is a regexp, skip parsing
const { pattern, keys } =
route instanceof RegExp
? { keys: false, pattern: route }
: parser(route || "*", loose);
// array destructuring loses keys, so this is done in two steps
const result = pattern.exec(path) || [];
// when parser is in "loose" mode, `$base` is equal to the
// first part of the route that matches the pattern
// (e.g. for pattern `/a/:b` and path `/a/1/2/3` the `$base` is `a/1`)
// we use this for route nesting
const [$base, ...matches] = result;
return $base !== undefined
? [
true,
(() => {
// for regex paths, `keys` will always be false
// an object with parameters matched, e.g. { foo: "bar" } for "/:foo"
// we "zip" two arrays here to construct the object
// ["foo"], ["bar"] → { foo: "bar" }
const groups =
keys !== false
? Object.fromEntries(keys.map((key, i) => [key, matches[i]]))
: result.groups;
// convert the array to an instance of object
// this makes it easier to integrate with the existing param implementation
let obj = { ...matches };
// merge named capture groups with matches array
groups && Object.assign(obj, groups);
return obj;
})(),
// the third value if only present when parser is in "loose" mode,
// so that we can extract the base path for nested routes
...(loose ? [$base] : []),
]
: [false, null];
};
export const useRoute = (pattern) =>
matchRoute(useRouter().parser, pattern, useLocation()[0]);
/*
* Part 2, Low Carb Router API: Router, Route, Link, Switch
*/
export const Router = ({ children, ...props }) => {
// the router we will inherit from - it is the closest router in the tree,
// unless the custom `hook` is provided (in that case it's the default one)
const parent_ = useRouter();
const parent = props.hook ? defaultRouter : parent_;
// holds to the context value: the router object
let value = parent;
// when `ssrPath` contains a `?` character, we can extract the search from it
const [path, search] = props.ssrPath?.split("?") ?? [];
if (search) (props.ssrSearch = search), (props.ssrPath = path);
// hooks can define their own `href` formatter (e.g. for hash location)
props.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;
// what is happening below: to avoid unnecessary rerenders in child components,
// we ensure that the router object reference is stable, unless there are any
// changes that require reload (e.g. `base` prop changes -> all components that
// get the router from the context should rerender, even if the component is memoized).
// the expected behaviour is:
//
// 1) when the resulted router is no different from the parent, use parent
// 2) if the custom `hook` prop is provided, we always inherit from the
// default router instead. this resets all previously overridden options.
// 3) when the router is customized here, it should stay stable between renders
let ref = useRef({}),
prev = ref.current,
next = prev;
for (let k in parent) {
const option =
k === "base"
? /* base is special case, it is appended to the parent's base */
parent[k] + (props[k] || "")
: props[k] || parent[k];
if (prev === next && option !== next[k]) {
ref.current = next = { ...next };
}
next[k] = option;
// the new router is no different from the parent or from the memoized value, use parent
if (option !== parent[k] || option !== value[k]) value = next;
}
return h(RouterCtx.Provider, { value, children });
};
const h_route = ({ children, component }, params) => {
// React-Router style `component` prop
if (component) return h(component, { params });
// support render prop or plain children
return typeof children === "function" ? children(params) : children;
};
// Cache params object between renders if values are shallow equal
const useCachedParams = (value) => {
let prev = useRef(Params0);
const curr = prev.current;
return (prev.current =
// Update cache if number of params changed or any value changed
Object.keys(value).length !== Object.keys(curr).length ||
Object.entries(value).some(([k, v]) => v !== curr[k])
? value // Return new value if there are changes
: curr); // Return cached value if nothing changed
};
export function useSearchParams() {
const [location, navigate] = useLocation();
const search = useSearch();
const searchParams = useMemo(() => new URLSearchParams(search), [search]);
// cached value before next render, so you can call setSearchParams multiple times
let tempSearchParams = searchParams;
const setSearchParams = useEvent((nextInit, options) => {
tempSearchParams = new URLSearchParams(
typeof nextInit === "function" ? nextInit(tempSearchParams) : nextInit
);
navigate(location + "?" + tempSearchParams, options);
});
return [searchParams, setSearchParams];
}
export const Route = ({ path, nest, match, ...renderProps }) => {
const router = useRouter();
const [location] = useLocationFromRouter(router);
const [matches, routeParams, base] =
// `match` is a special prop to give up control to the parent,
// it is used by the `Switch` to avoid double matching
match ?? matchRoute(router.parser, path, location, nest);
// when `routeParams` is `null` (there was no match), the argument
// below becomes {...null} = {}, see the Object Spread specs
// https://tc39.es/proposal-object-rest-spread/#AbstractOperations-CopyDataProperties
const params = useCachedParams({ ...useParams(), ...routeParams });
if (!matches) return null;
const children = base
? h(Router, { base }, h_route(renderProps, params))
: h_route(renderProps, params);
return h(ParamsCtx.Provider, { value: params, children });
};
export const Link = forwardRef((props, ref) => {
const router = useRouter();
const [currentPath, navigate] = useLocationFromRouter(router);
const {
to = "",
href: targetPath = to,
onClick: _onClick,
asChild,
children,
className: cls,
/* eslint-disable no-unused-vars */
replace /* ignore nav props */,
state /* ignore nav props */,
/* eslint-enable no-unused-vars */
...restProps
} = props;
const onClick = useEvent((event) => {
// ignores the navigation when clicked using right mouse button or
// by holding a special modifier key: ctrl, command, win, alt, shift
if (
event.ctrlKey ||
event.metaKey ||
event.altKey ||
event.shiftKey ||
event.button !== 0
)
return;
_onClick?.(event);
if (!event.defaultPrevented) {
event.preventDefault();
navigate(targetPath, props);
}
});
// handle nested routers and absolute paths
const href = router.hrefs(
targetPath[0] === "~" ? targetPath.slice(1) : router.base + targetPath,
router // pass router as a second argument for convinience
);
return asChild && isValidElement(children)
? cloneElement(children, { onClick, href })
: h("a", {
...restProps,
onClick,
href,
// `className` can be a function to apply the class if this link is active
className: cls?.call ? cls(currentPath === targetPath) : cls,
children,
ref,
});
});
const flattenChildren = (children) =>
Array.isArray(children)
? children.flatMap((c) =>
flattenChildren(c && c.type === Fragment ? c.props.children : c)
)
: [children];
export const Switch = ({ children, location }) => {
const router = useRouter();
const [originalLocation] = useLocationFromRouter(router);
for (const element of flattenChildren(children)) {
let match = 0;
if (
isValidElement(element) &&
// we don't require an element to be of type Route,
// but we do require it to contain a truthy `path` prop.
// this allows to use different components that wrap Route
// inside of a switch, for example <AnimatedRoute />.
(match = matchRoute(
router.parser,
element.props.path,
location || originalLocation,
element.props.nest
))[0]
)
return cloneElement(element, { match });
}
return null;
};
export const Redirect = (props) => {
const { to, href = to } = props;
const router = useRouter();
const [, navigate] = useLocationFromRouter(router);
const redirect = useEvent(() => navigate(to || href, props));
const { ssrContext } = router;
// redirect is guaranteed to be stable since it is returned from useEvent
useIsomorphicLayoutEffect(() => {
redirect();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
if (ssrContext) {
ssrContext.redirectTo = to;
}
return null;
};
export * from "../types/memory-location.js";
import mitt from "mitt";
import { useSyncExternalStore } from "./react-deps.js";
/**
* In-memory location that supports navigation
*/
export const memoryLocation = ({
path = "/",
searchPath = "",
static: staticLocation,
record,
} = {}) => {
let initialPath = path;
if (searchPath) {
// join with & if path contains search query, and ? otherwise
initialPath += path.split("?")[1] ? "&" : "?";
initialPath += searchPath;
}
let [currentPath, currentSearch = ""] = initialPath.split("?");
const history = [initialPath];
const emitter = mitt();
const navigateImplementation = (path, { replace = false } = {}) => {
if (record) {
if (replace) {
history.splice(history.length - 1, 1, path);
} else {
history.push(path);
}
}
[currentPath, currentSearch = ""] = path.split("?");
emitter.emit("navigate", path);
};
const navigate = !staticLocation ? navigateImplementation : () => null;
const subscribe = (cb) => {
emitter.on("navigate", cb);
return () => emitter.off("navigate", cb);
};
const useMemoryLocation = () => [
useSyncExternalStore(subscribe, () => currentPath),
navigate,
];
const useMemoryQuery = () =>
useSyncExternalStore(subscribe, () => currentSearch);
// Attach searchHook to the location hook for auto-inheritance in Router
useMemoryLocation.searchHook = useMemoryQuery;
function reset() {
// clean history array with mutation to preserve link
history.splice(0, history.length);
navigateImplementation(initialPath);
}
return {
hook: useMemoryLocation,
searchHook: useMemoryQuery,
navigate,
history: record ? history : undefined,
reset: record ? reset : undefined,
};
};
/*
* Transforms `path` into its relative `base` version
* If base isn't part of the path provided returns absolute path e.g. `~/app`
*/
const _relativePath = (base, path) =>
!path.toLowerCase().indexOf(base.toLowerCase())
? path.slice(base.length) || "/"
: "~" + path;
/**
* When basepath is `undefined` or '/' it is ignored (we assume it's empty string)
*/
const baseDefaults = (base = "") => (base === "/" ? "" : base);
export const absolutePath = (to, base) =>
to[0] === "~" ? to.slice(1) : baseDefaults(base) + to;
export const relativePath = (base = "", path) =>
_relativePath(unescape(baseDefaults(base)), unescape(path));
/*
* Removes leading question mark
*/
const stripQm = (str) => (str[0] === "?" ? str.slice(1) : str);
/*
* decodes escape sequences such as %20
*/
const unescape = (str) => {
try {
return decodeURI(str);
} catch (_e) {
// fail-safe mode: if string can't be decoded do nothing
return str;
}
};
export const sanitizeSearch = (search) => unescape(stripQm(search));
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"];
export {
useMemo,
useRef,
useState,
useContext,
createContext,
isValidElement,
cloneElement,
createElement,
Fragment,
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";
// Copied from:
// https://github.com/facebook/react/blob/main/packages/shared/ExecutionEnvironment.js
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;
// 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.
export const useEvent = (fn) => {
const ref = React.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
useInsertionEffect(() => {
ref[0] = fn;
});
return ref[1];
};
export * from "../types/use-browser-location.js";
import { useSyncExternalStore } from "./react-deps.js";
/**
* History API docs @see https://developer.mozilla.org/en-US/docs/Web/API/History
*/
const eventPopstate = "popstate";
const eventPushState = "pushState";
const eventReplaceState = "replaceState";
const eventHashchange = "hashchange";
const events = [
eventPopstate,
eventPushState,
eventReplaceState,
eventHashchange,
];
const subscribeToLocationUpdates = (callback) => {
for (const event of events) {
addEventListener(event, callback);
}
return () => {
for (const event of events) {
removeEventListener(event, callback);
}
};
};
export const useLocationProperty = (fn, ssrFn) =>
useSyncExternalStore(subscribeToLocationUpdates, fn, ssrFn);
const currentSearch = () => location.search;
export const useSearch = ({ ssrSearch } = {}) =>
useLocationProperty(
currentSearch,
ssrSearch != null ? () => ssrSearch : currentSearch
);
const currentPathname = () => location.pathname;
export const usePathname = ({ ssrPath } = {}) =>
useLocationProperty(
currentPathname,
ssrPath != null ? () => ssrPath : currentPathname
);
const currentHistoryState = () => history.state;
export const useHistoryState = () =>
useLocationProperty(currentHistoryState, () => null);
export const navigate = (to, { replace = false, state = null } = {}) =>
history[replace ? eventReplaceState : eventPushState](state, "", to);
// the 2nd argument of the `useBrowserLocation` return value is a function
// that allows to perform a navigation.
export const useBrowserLocation = (opts = {}) => [usePathname(opts), navigate];
const patchKey = Symbol.for("wouter_v3");
// While History API does have `popstate` event, the only
// proper way to listen to changes via `push/replaceState`
// is to monkey-patch these methods.
//
// See https://stackoverflow.com/a/4585031
if (typeof history !== "undefined" && typeof window[patchKey] === "undefined") {
for (const type of [eventPushState, eventReplaceState]) {
const original = history[type];
// TODO: we should be using unstable_batchedUpdates to avoid multiple re-renders,
// however that will require an additional peer dependency on react-dom.
// See: https://github.com/reactwg/react-18/discussions/86#discussioncomment-1567149
history[type] = function () {
const result = original.apply(this, arguments);
const event = new Event(type);
event.arguments = arguments;
dispatchEvent(event);
return result;
};
}
// patch history object only once
// See: https://github.com/molefrog/wouter/issues/167
Object.defineProperty(window, patchKey, { value: true });
}
export * from "../types/use-hash-location.js";
import { useSyncExternalStore } from "./react-deps.js";
// array of callback subscribed to hash updates
const listeners = {
v: [],
};
const onHashChange = () => listeners.v.forEach((cb) => cb());
// we subscribe to `hashchange` only once when needed to guarantee that
// all listeners are called synchronously
const subscribeToHashUpdates = (callback) => {
if (listeners.v.push(callback) === 1)
addEventListener("hashchange", onHashChange);
return () => {
listeners.v = listeners.v.filter((i) => i !== callback);
if (!listeners.v.length) removeEventListener("hashchange", onHashChange);
};
};
// leading '#' is ignored, leading '/' is optional
const currentHashLocation = () => "/" + location.hash.replace(/^#?\/?/, "");
export const navigate = (to, { state = null, replace = false } = {}) => {
const oldURL = location.href;
const [hash, search] = to.replace(/^#?\/?/, "").split("?");
// Works for ALL protocols including data:
const url = new URL(location.href);
url.hash = `/${hash}`;
if (search) url.search = search;
const newURL = url.href;
if (replace) {
history.replaceState(state, "", newURL);
} else {
history.pushState(state, "", newURL);
}
const event =
typeof HashChangeEvent !== "undefined"
? new HashChangeEvent("hashchange", { oldURL, newURL })
: new Event("hashchange", { detail: { oldURL, newURL } });
dispatchEvent(event);
};
export const useHashLocation = ({ ssrPath = "/" } = {}) => [
useSyncExternalStore(
subscribeToHashUpdates,
currentHashLocation,
() => ssrPath
),
navigate,
];
useHashLocation.hrefs = (href) => "#" + href;
export { useSyncExternalStore } from "use-sync-external-store/shim/index.js";
export { useSyncExternalStore } from "use-sync-external-store/shim/index.native.js";
+8
-10
{
"name": "wouter",
"version": "3.7.1",
"version": "3.8.0-beta.1",
"description": "Minimalist-friendly ~1.5KB router for React",

@@ -16,23 +16,23 @@ "type": "module",

"files": [
"esm",
"src",
"types/**/*.d.ts",
"types/*.d.ts"
],
"main": "esm/index.js",
"main": "src/index.js",
"exports": {
".": {
"types": "./types/index.d.ts",
"default": "./esm/index.js"
"default": "./src/index.js"
},
"./use-browser-location": {
"types": "./types/use-browser-location.d.ts",
"default": "./esm/use-browser-location.js"
"default": "./src/use-browser-location.js"
},
"./use-hash-location": {
"types": "./types/use-hash-location.d.ts",
"default": "./esm/use-hash-location.js"
"default": "./src/use-hash-location.js"
},
"./memory-location": {
"types": "./types/memory-location.d.ts",
"default": "./esm/memory-location.js"
"default": "./src/memory-location.js"
}

@@ -58,5 +58,3 @@ },

"scripts": {
"build": "rollup -c",
"watch": "rollup -c -w",
"prepublishOnly": "npm run build && cp ../../README.md ."
"prepublishOnly": "cp ../../README.md ."
},

@@ -63,0 +61,0 @@ "author": "Alexey Taktarov <molefrog@gmail.com>",

@@ -10,3 +10,3 @@ <div align="center">

<a href="https://travis-ci.org/molefrog/wouter"><img alt="CI" src="https://img.shields.io/github/actions/workflow/status/molefrog/wouter/size.yml?color=black&labelColor=888&label=2.5KB+limit" /></a>
<a href="https://codecov.io/gh/molefrog/wouter"><img alt="Coverage" src="https://img.shields.io/codecov/c/github/molefrog/wouter.svg?color=black&labelColor=888" /></a>
<a href="https://coveralls.io/github/molefrog/wouter?branch=v3"><img alt="Coverage" src="https://img.shields.io/coveralls/github/molefrog/wouter/v3.svg?color=black&labelColor=888" /></a>
<a href="https://www.npmjs.com/package/wouter"><img alt="Coverage" src="https://img.shields.io/npm/dm/wouter.svg?color=black&labelColor=888" /></a>

@@ -149,3 +149,3 @@ <a href="https://pr.new/molefrog/wouter"><img alt="Edit in StackBlitz IDE" src="https://img.shields.io/badge/StackBlitz-New%20PR-black?labelColor=888" /></a>

These can be used separately from the main module and have an interface similar to `useState`. These hooks don't support nesting, base path, route matching.
These can be used separately from the main module and have an interface similar to `useState`. These hooks are standalone and don't include built-in support for nesting, base path, or route matching. However, when passed to `<Router>`, they work seamlessly with all Router features including nesting and base paths.

@@ -366,3 +366,3 @@ - **[`import { useBrowserLocation } from "wouter/use-browser-location"`](https://github.com/molefrog/wouter/blob/v3/packages/wouter/src/use-browser-location.js)** —

Allow you to get and set any search parameters. The first returned value is a `URLSearchParams` object and the second returned value is a setter that accepts a `URLSearchParams` object with options.
Returns a `URLSearchParams` object and a setter function to update search parameters. The setter accepts either a value (object, URLSearchParams, string[][], etc.) or a **callback function** that receives the current params and must return the new params.

@@ -380,2 +380,3 @@ ```jsx

prev.set('tab', 'settings');
return prev;
});

@@ -394,2 +395,3 @@

prev.set('order', 'desc');
return prev;
},

@@ -405,2 +407,3 @@ {

prev.set('foo', 'bar');
return prev;
},

@@ -757,3 +760,3 @@ {

**[▶ Demo Sandbox](https://codesandbox.io/s/wouter-v3-strict-routes-w3xdtz)**
**[▶ Demo Sandbox](https://codesandbox.io/p/sandbox/wouter-v3-strict-routes-w3xdtz)**

@@ -779,3 +782,3 @@ ### Are relative routes and links supported?

**[▶ Demo Sandbox](https://codesandbox.io/s/wouter-v3-nested-routes-l8p23s)**
**[▶ Demo Sandbox](https://codesandbox.io/p/sandbox/wouter-v3-nested-routes-l8p23s)**

@@ -929,6 +932,6 @@ ### Can I initiate navigation from outside a component?

// even if you call `navigate` somewhere in the app location won't change
const { hook } = memoryLocation({ path: "/user/2", static: true });
const { hook, searchHook } = memoryLocation({ path: "/user/2", static: true });
const { container } = render(
<Router hook={hook}>
<Router hook={hook} searchHook={searchHook}>
<Route path="/user/:id">{(params) => <>User ID: {params.id}</>}</Route>

@@ -942,2 +945,16 @@ </Router>

**Note:** When you pass a `hook` prop to `Router`, it will automatically inherit the `searchHook` from the hook if available (via `hook.searchHook`). This means you don't need to explicitly pass both `hook` and `searchHook` when using `memoryLocation` - just passing `hook` is enough for `useSearch()` to work correctly with query parameters.
```jsx
it("works with query parameters", () => {
const { hook } = memoryLocation({ path: "/products?sort=price&order=asc" });
const { result } = renderHook(() => useSearch(), {
wrapper: ({ children }) => <Router hook={hook}>{children}</Router>,
});
expect(result.current).toBe("sort=price&order=asc");
});
```
The hook can be configured to record navigation history. Additionally, it comes with a `navigate` function for external navigation.

@@ -995,2 +1012,13 @@

## Contributing
**Architecture principles:**
- All code is written in JavaScript for full control over size optimization
- TypeScript definitions are maintained separately in `types/` directories
- `wouter-preact` reuses the same source except for `react-deps.js` (Preact-specific hooks)
- Type definitions are duplicated between packages (not ideal, but works for now)
**Development:** Tests run directly from source files (no build required). Run `npm run test` for interactive mode or `npm run test -- --run` for a single run. Use `npm run build` to build the distributable package before publishing.
## Acknowledgements

@@ -997,0 +1025,0 @@

@@ -13,2 +13,3 @@ // Minimum TypeScript Version: 4.1

MouseEventHandler,
JSXElementConstructor,
} from "react";

@@ -83,3 +84,3 @@

path?: RoutePath;
component?: ComponentType<
component?: JSXElementConstructor<
RouteComponentProps<

@@ -86,0 +87,0 @@ T extends DefaultParams

@@ -11,7 +11,11 @@ /*

export type HrefsFormatter = (href: string, router?: any) => string;
// the base useLocation hook type. Any custom hook (including the
// default one) should inherit from it.
export type BaseLocationHook = (
...args: any[]
) => [Path, (path: Path, ...args: any[]) => any];
export type BaseLocationHook = {
(...args: any[]): [Path, (path: Path, ...args: any[]) => any];
searchHook?: BaseSearchHook;
hrefs?: HrefsFormatter;
};

@@ -18,0 +22,0 @@ export type BaseSearchHook = (...args: any[]) => SearchString;

@@ -6,2 +6,3 @@ import {

BaseSearchHook,
HrefsFormatter,
} from "./location-hook.js";

@@ -14,4 +15,2 @@

export type HrefsFormatter = (href: string, router: RouterObject) => string;
// the object returned from `useRouter`

@@ -18,0 +17,0 @@ export interface RouterObject {

import { parse } from 'regexparam';
import { useBrowserLocation, useSearch as useSearch$1 } from './use-browser-location.js';
import { createContext, forwardRef, useEvent, isValidElement, cloneElement, createElement, useContext, useRef, useMemo, useIsomorphicLayoutEffect, Fragment } from './react-deps.js';
/*
* Transforms `path` into its relative `base` version
* If base isn't part of the path provided returns absolute path e.g. `~/app`
*/
const _relativePath = (base, path) =>
!path.toLowerCase().indexOf(base.toLowerCase())
? path.slice(base.length) || "/"
: "~" + path;
/**
* When basepath is `undefined` or '/' it is ignored (we assume it's empty string)
*/
const baseDefaults = (base = "") => (base === "/" ? "" : base);
const absolutePath = (to, base) =>
to[0] === "~" ? to.slice(1) : baseDefaults(base) + to;
const relativePath = (base = "", path) =>
_relativePath(unescape(baseDefaults(base)), unescape(path));
/*
* Removes leading question mark
*/
const stripQm = (str) => (str[0] === "?" ? str.slice(1) : str);
/*
* decodes escape sequences such as %20
*/
const unescape = (str) => {
try {
return decodeURI(str);
} catch (_e) {
// fail-safe mode: if string can't be decoded do nothing
return str;
}
};
const sanitizeSearch = (search) => unescape(stripQm(search));
/*
* Router and router context. Router is a lightweight object that represents the current
* routing options: how location is managed, base path etc.
*
* There is a default router present for most of the use cases, however it can be overridden
* via the <Router /> component.
*/
const defaultRouter = {
hook: useBrowserLocation,
searchHook: useSearch$1,
parser: parse,
base: "",
// this option is used to override the current location during SSR
ssrPath: undefined,
ssrSearch: undefined,
// optional context to track render state during SSR
ssrContext: undefined,
// customizes how `href` props are transformed for <Link />
hrefs: (x) => x,
};
const RouterCtx = createContext(defaultRouter);
// gets the closest parent router from the context
const useRouter = () => useContext(RouterCtx);
/**
* Parameters context. Used by `useParams()` to get the
* matched params from the innermost `Route` component.
*/
const Params0 = {},
ParamsCtx = createContext(Params0);
const useParams = () => useContext(ParamsCtx);
/*
* Part 1, Hooks API: useRoute and useLocation
*/
// Internal version of useLocation to avoid redundant useRouter calls
const useLocationFromRouter = (router) => {
const [location, navigate] = router.hook(router);
// the function reference should stay the same between re-renders, so that
// it can be passed down as an element prop without any performance concerns.
// (This is achieved via `useEvent`.)
return [
relativePath(router.base, location),
useEvent((to, navOpts) => navigate(absolutePath(to, router.base), navOpts)),
];
};
const useLocation = () => useLocationFromRouter(useRouter());
const useSearch = () => {
const router = useRouter();
return sanitizeSearch(router.searchHook(router));
};
const matchRoute = (parser, route, path, loose) => {
// if the input is a regexp, skip parsing
const { pattern, keys } =
route instanceof RegExp
? { keys: false, pattern: route }
: parser(route || "*", loose);
// array destructuring loses keys, so this is done in two steps
const result = pattern.exec(path) || [];
// when parser is in "loose" mode, `$base` is equal to the
// first part of the route that matches the pattern
// (e.g. for pattern `/a/:b` and path `/a/1/2/3` the `$base` is `a/1`)
// we use this for route nesting
const [$base, ...matches] = result;
return $base !== undefined
? [
true,
(() => {
// for regex paths, `keys` will always be false
// an object with parameters matched, e.g. { foo: "bar" } for "/:foo"
// we "zip" two arrays here to construct the object
// ["foo"], ["bar"] → { foo: "bar" }
const groups =
keys !== false
? Object.fromEntries(keys.map((key, i) => [key, matches[i]]))
: result.groups;
// convert the array to an instance of object
// this makes it easier to integrate with the existing param implementation
let obj = { ...matches };
// merge named capture groups with matches array
groups && Object.assign(obj, groups);
return obj;
})(),
// the third value if only present when parser is in "loose" mode,
// so that we can extract the base path for nested routes
...(loose ? [$base] : []),
]
: [false, null];
};
const useRoute = (pattern) =>
matchRoute(useRouter().parser, pattern, useLocation()[0]);
/*
* Part 2, Low Carb Router API: Router, Route, Link, Switch
*/
const Router = ({ children, ...props }) => {
// the router we will inherit from - it is the closest router in the tree,
// unless the custom `hook` is provided (in that case it's the default one)
const parent_ = useRouter();
const parent = props.hook ? defaultRouter : parent_;
// holds to the context value: the router object
let value = parent;
// when `ssrPath` contains a `?` character, we can extract the search from it
const [path, search] = props.ssrPath?.split("?") ?? [];
if (search) (props.ssrSearch = search), (props.ssrPath = path);
// hooks can define their own `href` formatter (e.g. for hash location)
props.hrefs = props.hrefs ?? props.hook?.hrefs;
// what is happening below: to avoid unnecessary rerenders in child components,
// we ensure that the router object reference is stable, unless there are any
// changes that require reload (e.g. `base` prop changes -> all components that
// get the router from the context should rerender, even if the component is memoized).
// the expected behaviour is:
//
// 1) when the resulted router is no different from the parent, use parent
// 2) if the custom `hook` prop is provided, we always inherit from the
// default router instead. this resets all previously overridden options.
// 3) when the router is customized here, it should stay stable between renders
let ref = useRef({}),
prev = ref.current,
next = prev;
for (let k in parent) {
const option =
k === "base"
? /* base is special case, it is appended to the parent's base */
parent[k] + (props[k] || "")
: props[k] || parent[k];
if (prev === next && option !== next[k]) {
ref.current = next = { ...next };
}
next[k] = option;
// the new router is no different from the parent or from the memoized value, use parent
if (option !== parent[k] || option !== value[k]) value = next;
}
return createElement(RouterCtx.Provider, { value, children });
};
const h_route = ({ children, component }, params) => {
// React-Router style `component` prop
if (component) return createElement(component, { params });
// support render prop or plain children
return typeof children === "function" ? children(params) : children;
};
// Cache params object between renders if values are shallow equal
const useCachedParams = (value) => {
let prev = useRef(Params0);
const curr = prev.current;
return (prev.current =
// Update cache if number of params changed or any value changed
Object.keys(value).length !== Object.keys(curr).length ||
Object.entries(value).some(([k, v]) => v !== curr[k])
? value // Return new value if there are changes
: curr); // Return cached value if nothing changed
};
function useSearchParams() {
const [location, navigate] = useLocation();
const search = useSearch();
const searchParams = useMemo(() => new URLSearchParams(search), [search]);
// cached value before next render, so you can call setSearchParams multiple times
let tempSearchParams = searchParams;
const setSearchParams = useEvent((nextInit, options) => {
tempSearchParams = new URLSearchParams(
typeof nextInit === "function" ? nextInit(tempSearchParams) : nextInit
);
navigate(location + "?" + tempSearchParams, options);
});
return [searchParams, setSearchParams];
}
const Route = ({ path, nest, match, ...renderProps }) => {
const router = useRouter();
const [location] = useLocationFromRouter(router);
const [matches, routeParams, base] =
// `match` is a special prop to give up control to the parent,
// it is used by the `Switch` to avoid double matching
match ?? matchRoute(router.parser, path, location, nest);
// when `routeParams` is `null` (there was no match), the argument
// below becomes {...null} = {}, see the Object Spread specs
// https://tc39.es/proposal-object-rest-spread/#AbstractOperations-CopyDataProperties
const params = useCachedParams({ ...useParams(), ...routeParams });
if (!matches) return null;
const children = base
? createElement(Router, { base }, h_route(renderProps, params))
: h_route(renderProps, params);
return createElement(ParamsCtx.Provider, { value: params, children });
};
const Link = forwardRef((props, ref) => {
const router = useRouter();
const [currentPath, navigate] = useLocationFromRouter(router);
const {
to = "",
href: targetPath = to,
onClick: _onClick,
asChild,
children,
className: cls,
/* eslint-disable no-unused-vars */
replace /* ignore nav props */,
state /* ignore nav props */,
/* eslint-enable no-unused-vars */
...restProps
} = props;
const onClick = useEvent((event) => {
// ignores the navigation when clicked using right mouse button or
// by holding a special modifier key: ctrl, command, win, alt, shift
if (
event.ctrlKey ||
event.metaKey ||
event.altKey ||
event.shiftKey ||
event.button !== 0
)
return;
_onClick?.(event);
if (!event.defaultPrevented) {
event.preventDefault();
navigate(targetPath, props);
}
});
// handle nested routers and absolute paths
const href = router.hrefs(
targetPath[0] === "~" ? targetPath.slice(1) : router.base + targetPath,
router // pass router as a second argument for convinience
);
return asChild && isValidElement(children)
? cloneElement(children, { onClick, href })
: createElement("a", {
...restProps,
onClick,
href,
// `className` can be a function to apply the class if this link is active
className: cls?.call ? cls(currentPath === targetPath) : cls,
children,
ref,
});
});
const flattenChildren = (children) =>
Array.isArray(children)
? children.flatMap((c) =>
flattenChildren(c && c.type === Fragment ? c.props.children : c)
)
: [children];
const Switch = ({ children, location }) => {
const router = useRouter();
const [originalLocation] = useLocationFromRouter(router);
for (const element of flattenChildren(children)) {
let match = 0;
if (
isValidElement(element) &&
// we don't require an element to be of type Route,
// but we do require it to contain a truthy `path` prop.
// this allows to use different components that wrap Route
// inside of a switch, for example <AnimatedRoute />.
(match = matchRoute(
router.parser,
element.props.path,
location || originalLocation,
element.props.nest
))[0]
)
return cloneElement(element, { match });
}
return null;
};
const Redirect = (props) => {
const { to, href = to } = props;
const router = useRouter();
const [, navigate] = useLocationFromRouter(router);
const redirect = useEvent(() => navigate(to || href, props));
const { ssrContext } = router;
// redirect is guaranteed to be stable since it is returned from useEvent
useIsomorphicLayoutEffect(() => {
redirect();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
if (ssrContext) {
ssrContext.redirectTo = to;
}
return null;
};
export { Link, Redirect, Route, Router, Switch, matchRoute, useLocation, useParams, useRoute, useRouter, useSearch, useSearchParams };
import mitt from 'mitt';
import { useSyncExternalStore } from './react-deps.js';
/**
* In-memory location that supports navigation
*/
const memoryLocation = ({
path = "/",
searchPath = "",
static: staticLocation,
record,
} = {}) => {
let initialPath = path;
if (searchPath) {
// join with & if path contains search query, and ? otherwise
initialPath += path.split("?")[1] ? "&" : "?";
initialPath += searchPath;
}
let [currentPath, currentSearch = ""] = initialPath.split("?");
const history = [initialPath];
const emitter = mitt();
const navigateImplementation = (path, { replace = false } = {}) => {
if (record) {
if (replace) {
history.splice(history.length - 1, 1, path);
} else {
history.push(path);
}
}
[currentPath, currentSearch = ""] = path.split("?");
emitter.emit("navigate", path);
};
const navigate = !staticLocation ? navigateImplementation : () => null;
const subscribe = (cb) => {
emitter.on("navigate", cb);
return () => emitter.off("navigate", cb);
};
const useMemoryLocation = () => [
useSyncExternalStore(subscribe, () => currentPath),
navigate,
];
const useMemoryQuery = () =>
useSyncExternalStore(subscribe, () => currentSearch);
function reset() {
// clean history array with mutation to preserve link
history.splice(0, history.length);
navigateImplementation(initialPath);
}
return {
hook: useMemoryLocation,
searchHook: useMemoryQuery,
navigate,
history: record ? history : undefined,
reset: record ? reset : undefined,
};
};
export { memoryLocation };
import * as React from 'react';
export { Fragment, cloneElement, createContext, createElement, forwardRef, isValidElement, useContext, useMemo, useRef, useState } from 'react';
export { useSyncExternalStore } from 'use-sync-external-store/shim/index.js';
// React.useInsertionEffect is not available in React <18
// This hack fixes a transpilation issue on some apps
const useBuiltinInsertionEffect = React["useInsertion" + "Effect"];
// Copied from:
// https://github.com/facebook/react/blob/main/packages/shared/ExecutionEnvironment.js
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."
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
const useInsertionEffect =
useBuiltinInsertionEffect || useIsomorphicLayoutEffect;
// 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.
const useEvent = (fn) => {
const ref = React.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
useInsertionEffect(() => {
ref[0] = fn;
});
return ref[1];
};
export { useEvent, useInsertionEffect, useIsomorphicLayoutEffect };
import { useSyncExternalStore } from './react-deps.js';
/**
* History API docs @see https://developer.mozilla.org/en-US/docs/Web/API/History
*/
const eventPopstate = "popstate";
const eventPushState = "pushState";
const eventReplaceState = "replaceState";
const eventHashchange = "hashchange";
const events = [
eventPopstate,
eventPushState,
eventReplaceState,
eventHashchange,
];
const subscribeToLocationUpdates = (callback) => {
for (const event of events) {
addEventListener(event, callback);
}
return () => {
for (const event of events) {
removeEventListener(event, callback);
}
};
};
const useLocationProperty = (fn, ssrFn) =>
useSyncExternalStore(subscribeToLocationUpdates, fn, ssrFn);
const currentSearch = () => location.search;
const useSearch = ({ ssrSearch = "" } = {}) =>
useLocationProperty(currentSearch, () => ssrSearch);
const currentPathname = () => location.pathname;
const usePathname = ({ ssrPath } = {}) =>
useLocationProperty(
currentPathname,
ssrPath ? () => ssrPath : currentPathname
);
const currentHistoryState = () => history.state;
const useHistoryState = () =>
useLocationProperty(currentHistoryState, () => null);
const navigate = (to, { replace = false, state = null } = {}) =>
history[replace ? eventReplaceState : eventPushState](state, "", to);
// the 2nd argument of the `useBrowserLocation` return value is a function
// that allows to perform a navigation.
const useBrowserLocation = (opts = {}) => [usePathname(opts), navigate];
const patchKey = Symbol.for("wouter_v3");
// While History API does have `popstate` event, the only
// proper way to listen to changes via `push/replaceState`
// is to monkey-patch these methods.
//
// See https://stackoverflow.com/a/4585031
if (typeof history !== "undefined" && typeof window[patchKey] === "undefined") {
for (const type of [eventPushState, eventReplaceState]) {
const original = history[type];
// TODO: we should be using unstable_batchedUpdates to avoid multiple re-renders,
// however that will require an additional peer dependency on react-dom.
// See: https://github.com/reactwg/react-18/discussions/86#discussioncomment-1567149
history[type] = function () {
const result = original.apply(this, arguments);
const event = new Event(type);
event.arguments = arguments;
dispatchEvent(event);
return result;
};
}
// patch history object only once
// See: https://github.com/molefrog/wouter/issues/167
Object.defineProperty(window, patchKey, { value: true });
}
export { navigate, useBrowserLocation, useHistoryState, useLocationProperty, usePathname, useSearch };
import { useSyncExternalStore } from './react-deps.js';
// array of callback subscribed to hash updates
const listeners = {
v: [],
};
const onHashChange = () => listeners.v.forEach((cb) => cb());
// we subscribe to `hashchange` only once when needed to guarantee that
// all listeners are called synchronously
const subscribeToHashUpdates = (callback) => {
if (listeners.v.push(callback) === 1)
addEventListener("hashchange", onHashChange);
return () => {
listeners.v = listeners.v.filter((i) => i !== callback);
if (!listeners.v.length) removeEventListener("hashchange", onHashChange);
};
};
// leading '#' is ignored, leading '/' is optional
const currentHashLocation = () => "/" + location.hash.replace(/^#?\/?/, "");
const navigate = (to, { state = null, replace = false } = {}) => {
const [hash, search] = to.replace(/^#?\/?/, "").split("?");
const newRelativePath =
location.pathname + (search ? `?${search}` : location.search) + `#/${hash}`;
const oldURL = location.href;
const newURL = new URL(newRelativePath, location.origin).href;
if (replace) {
history.replaceState(state, "", newRelativePath);
} else {
history.pushState(state, "", newRelativePath);
}
const event =
typeof HashChangeEvent !== "undefined"
? new HashChangeEvent("hashchange", { oldURL, newURL })
: new Event("hashchange", { detail: { oldURL, newURL } });
dispatchEvent(event);
};
const useHashLocation = ({ ssrPath = "/" } = {}) => [
useSyncExternalStore(
subscribeToHashUpdates,
currentHashLocation,
() => ssrPath
),
navigate,
];
useHashLocation.hrefs = (href) => "#" + href;
export { navigate, useHashLocation };