Repository: kyeotic/raviger
Pull request #75 (2020-11-01 21:20:50)

V2
This PR is for tracking work on the upcoming major release version 2.

It primarily focuses on changes around #73, handling cases where `basePath` is provided but is missing from the start of the actual `path`. In these cases `null` is returned.

Also handles several **TODO** notes that required a breaking change to fix.
Here is the CHANGELOG so far

### Changed
- **BREAKING**: `useRoutes` and `usePath` will return `null` if `basePath` is provided and missing from path
- **BREAKING**:`useLocationChange` will invoke callback with `null` if `basePath` is provided and missing from path
- **BREAKING**:`useLocationChange` option `inheritBasePath` now accepts any false value (previously required `false` with `===`)
- **BREAKING**:`useRoutes` option `matchTrailingSlash` default to `true` (was `false`)
- **BREAKING**: removed `linkRef` prop from `Link` and `ActiveLink`, replaced with standard React `forwardRef`
- **BREAKING**:`useQueryParams` setter second argument changed from `replace` to options param with `replace` property
- **BREAKING**:`useRedirect` parameters changed to match properties on `Redirect` component
### Added
- `useFullPath` for getting the full path, ignoring any context-provided `basePath`
- Rollup-plugin-terser for builds
### Removed
- Support for Node 8

Available for pre-release testing

```
npm i raviger@beta

# or

npm i raviger@2.0.0-2
```



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5951167..dd2f30d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [2.0.0] - Unreleased
+### Changed
+- **BREAKING**: `useRoutes` and `usePath` will return `null` if `basePath` is provided and missing from path
+- **BREAKING**: `useLocationChange` will invoke callback with `null` if `basePath` is provided and missing from path
+- **BREAKING**: `useLocationChange` option `inheritBasePath` now accepts any false value (previously required `false` with `===`)
+- **BREAKING**: `useRoutes` option `matchTrailingSlash` default to `true` (was `false`)
+- **BREAKING**: removed `linkRef` prop from `Link` and `ActiveLink`, replaced with standard React `forwardRef`
+- **BREAKING**: `useQueryParams` setter second argument changed from `replace` to options param with `replace` property
+- **BREAKING**: `useRedirect` parameters changed to match properties on `Redirect` component
+### Added
+- `useFullPath` for getting the full path, ignoring any context-provided `basePath`
+- Support for Node 14
+- Rollup-plugin-terser for builds
+### Removed
+- Support for Node 8
+
 ## [1.6.0] - 2020-10-22
 ### Added
 - `useNavigate` hook
@@ -127,4 +143,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [0.3.2] - 2019-08-08
 ### Added
-- rollup output for module and cjs
\ No newline at end of file
+- rollup output for module and cjs
diff --git a/docs/content/api/ActiveLink.md b/docs/content/api/ActiveLink.md
index 1339418..7fb15ce 100644
--- a/docs/content/api/ActiveLink.md
+++ b/docs/content/api/ActiveLink.md
@@ -13,7 +13,7 @@ export interface ActiveLinkProps extends LinkProps {
   activeClass?: string
   exactActiveClass?: string
 }
-export const ActiveLink: React.FC<ActiveLinkProps>
+export const ActiveLink: React.ForwardRefExoticComponent<ActiveLinkProps & React.RefAttributes<HTMLAnchorElement>>
 {{< /highlight >}}
 
 ## Basic
@@ -31,4 +31,8 @@ Just like `<Link>`, but with two additional properties for modifying the `classN
   >
   go to foo
 </ActiveLink>
-{{< /highlight >}}
\ No newline at end of file
+{{< /highlight >}}
+
+## Ref passing
+
+`ActiveLink` supports the standard [forwardRef](https://reactjs.org/docs/forwarding-refs.html#forwarding-refs-to-dom-components) API.
\ No newline at end of file
diff --git a/docs/content/api/Link.md b/docs/content/api/Link.md
index aecc053..1ba81bd 100644
--- a/docs/content/api/Link.md
+++ b/docs/content/api/Link.md
@@ -4,19 +4,17 @@ date: 2019-09-30T18:16:24-07:00
 weight: 3
 ---
 
-A React component for rendering a `<a>` that uses *history* navigation for local URLs.
+A React component for rendering a `<a>` that uses *history* navigation for local URLs. Supports `ref` forwarding.
 
 ## API
 
 {{< highlight typescript >}}
 export interface LinkProps
   extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
-  // Unlike normal <a>, this property is required
-  href: string,
-  basePath?: string,
-  linkRef?: React.RefObject<HTMLAnchorElement>
+  href: string
+  basePath?: string
 }
-export const Link: React.FC<LinkProps>
+export const Link: React.ForwardRefExoticComponent<LinkProps & React.RefAttributes<HTMLAnchorElement>>
 {{< /highlight >}}
 
 ## Basic
@@ -60,4 +58,4 @@ export default function App() {
 
 ## Ref Passing
 
-To pass a [React ref](https://reactjs.org/docs/refs-and-the-dom.html) to the `<Link>` DOM Node use the `linkRef` property. This will assign the ref to the internal `<a>` node. In future version this will become `ref` and use the standard [forwardRef](https://reactjs.org/docs/forwarding-refs.html#forwarding-refs-to-dom-components) API.
\ No newline at end of file
+`Link` supports the standard [forwardRef](https://reactjs.org/docs/forwarding-refs.html#forwarding-refs-to-dom-components) API.
\ No newline at end of file
diff --git a/docs/content/api/useFullPath.md b/docs/content/api/useFullPath.md
new file mode 100644
index 0000000..a35ae67
--- /dev/null
+++ b/docs/content/api/useFullPath.md
@@ -0,0 +1,13 @@
+---
+title: "useFullPath"
+date: 2019-09-30T18:43:29-07:00
+weight: 9
+---
+
+Get the current path of the page, ignoring any `basePath` provided by Raviger contexts.
+
+## API
+
+{{< highlight typescript >}}
+export function useFullPath(): string
+{{< /highlight >}}
diff --git a/docs/content/api/useLocationChange.md b/docs/content/api/useLocationChange.md
index d395266..bb040e4 100644
--- a/docs/content/api/useLocationChange.md
+++ b/docs/content/api/useLocationChange.md
@@ -20,7 +20,7 @@ export function useLocationChange(
 ): void
 {{< /highlight >}}
 
-**Note**: `options.inheritBasePath` is treated as `true` unless it is set to `false` (even if `options` is not provided), and takes precedence over `options.basePath` if `true`. If no BasePath is in the context to inherit `options.basePath` will be used as a fallback, if present.
+**Note**: `options.inheritBasePath` defaults to `true` (even if `options` is not provided), and takes precedence over `options.basePath` if `true`. If no BasePath is in the context to inherit `options.basePath` will be used as a fallback, if present. If `basePath` is provided, either by parameter or by context, and is missing from the current path `null` is sent to the `setFn` callback.
 
 ## Basic
 
diff --git a/docs/content/api/usePath.md b/docs/content/api/usePath.md
index 0b48821..7b27f46 100644
--- a/docs/content/api/usePath.md
+++ b/docs/content/api/usePath.md
@@ -4,7 +4,7 @@ date: 2019-09-30T18:43:29-07:00
 weight: 8
 ---
 
-Get the current path of the page.
+Get the current path of the page. If `basePath` is provided and missing from the current path `null` is returned.
 
 ## API
 
diff --git a/docs/content/api/useRedirect.md b/docs/content/api/useRedirect.md
index 8fd2cdf..75f9c8e 100644
--- a/docs/content/api/useRedirect.md
+++ b/docs/content/api/useRedirect.md
@@ -12,21 +12,24 @@ This hook causes a browser redirect to occur if its `predicateUrl` matches.
 export function useRedirect(
   predicateUrl: string,
   targetUrl: string,
-  queryParams?: QueryParam | URLSearchParams,
-  replace?: boolean
+  options?: {
+    query?: QueryParam | URLSearchParams
+    replace?: boolean
+    merge?: boolean
+  }
 ): void
 {{< /highlight >}}
 
 ## Basic
 
-If `predicateUrl` is the current path, redirect to the `targetUrl`. `queryObj` is optional, and uses the same serializer that `useQueryParams` uses by default. If `replace` (default: true) it will replace the current URL (back button will skip the `predicateUrl`).
+If `predicateUrl` is the current path, redirect to the `targetUrl`. `queryObj` is optional, and uses the same serializer that `useQueryParams` uses by default. If `replace` (default: true) it will replace the current URL (back button will skip the `predicateUrl`). If `merge` is true the `query` will be merged with the current url query.
 
 {{< highlight jsx >}}
 import { useRedirect } from 'raviger'
 
 function Route () {
   // Will redirect to '/new' if current path is '/old'
-  useRedirect('/old', '/new', { name: 'kyeotic' })
+  useRedirect('/old', '/new', { query: { name: 'kyeotic' } })
   return <span>Home</span>
 }
 {{< /highlight >}}
diff --git a/docs/content/api/useRoutes.md b/docs/content/api/useRoutes.md
index ef4c833..73d9245 100644
--- a/docs/content/api/useRoutes.md
+++ b/docs/content/api/useRoutes.md
@@ -59,7 +59,7 @@ export default function App() {
 
 ## Using a Base Path
 
-The `basePath` option sets a base path that causes all routes to match as if they had the base path prepended to them. It also sets the base path on the router's context, making it available to hooks and `<Link>` components lower in matching *route's* tree.
+The `basePath` option sets a base path that causes all routes to match as if they had the base path prepended to them. It also sets the base path on the router's context, making it available to hooks and `<Link>` components lower in matching *route's* tree. If `basePath` is provided and missing from the current path `null` is returned.
 
 {{< highlight jsx >}}
 import { useRoutes } from 'raviger'
diff --git a/example/src/App2.js b/example/src/App2.js
new file mode 100644
index 0000000..c682332
--- /dev/null
+++ b/example/src/App2.js
@@ -0,0 +1,73 @@
+import React from 'react'
+import {
+  useRoutes,
+  Link,
+  usePath,
+  useQueryParams,
+  Redirect,
+  navigate
+} from '../../src/main.js'
+
+const PROJECTS = [
+  'ac2fd99a-495a-498a-843c-b7ddd7f7bf3f',
+  'b986a8e3-3f92-4112-9cda-506a1ca28ef0'
+]
+const BrandingPage = ({ projectId }) => {
+  return React.createElement(
+    'h2',
+    null,
+    'BrandingPage content for project ',
+    projectId
+  )
+}
+function ProjectArea({ projectId }) {
+  const match = useRoutes(
+    React.useMemo(
+      () => ({
+        '/settings/branding': () =>
+          React.createElement(BrandingPage, { projectId: projectId })
+      }),
+      [projectId]
+    ),
+    {
+      basePath: `/projects/${projectId}`
+    }
+  )
+  return React.createElement(
+    'div',
+    { style: { display: 'flex', flexDirection: 'column' } },
+    React.createElement('h1', null, 'Raviger issue'),
+    React.createElement(
+      'select',
+      {
+        onChange: React.useCallback(e => {
+          navigate(`/projects/${e.target.value}/settings/branding`)
+        }, []),
+        value: projectId
+      },
+      React.createElement('option', { value: PROJECTS[0] }, PROJECTS[0]),
+      React.createElement('option', { value: PROJECTS[1] }, PROJECTS[1])
+    ),
+    match !== null && match !== void 0
+      ? match
+      : React.createElement('h2', null, '404')
+  )
+}
+export default function App() {
+  const match = useRoutes(
+    React.useMemo(
+      () => ({
+        '/projects/:projectId*': ({ projectId }) =>
+          React.createElement(ProjectArea, { projectId: projectId })
+      }),
+      []
+    )
+  )
+  React.useLayoutEffect(() => {
+    const index = Math.round(Math.random()) // the initial projectId doesn't matter
+    navigate(`/projects/${PROJECTS[index]}/settings/branding`)
+  }, [])
+  return match !== null && match !== void 0
+    ? match
+    : React.createElement('h2', null, '404')
+}
diff --git a/example/src/index.js b/example/src/index.js
index 798fbaa..0af7af3 100644
--- a/example/src/index.js
+++ b/example/src/index.js
@@ -1,10 +1,11 @@
 import React from 'react'
 import ReactDOM from 'react-dom'
 import App from './App'
+// import App from './App2'
 // import App from './NestedApp.js'
 // eslint-disable-next-line import/no-unresolved
-import whyDidYouRender from 'whyDidYouRender'
+// import whyDidYouRender from 'whyDidYouRender'
 
-whyDidYouRender(React)
+// whyDidYouRender(React)
 
 ReactDOM.render(<App />, document.getElementById('root'))
diff --git a/package-lock.json b/package-lock.json
index d7f5c46..2719883 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
 {
   "name": "raviger",
-  "version": "1.6.0",
+  "version": "2.0.0-3",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
@@ -1172,6 +1172,12 @@
         "@babel/types": "^7.4.4"
       }
     },
+    "@babel/helper-validator-identifier": {
+      "version": "7.10.4",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.10.4.tgz",
+      "integrity": "sha512-3U9y+43hz7ZM+rzG24Qe2mufW5KhvFg/NhnNph+i9mgCtdTCtMJuI1TMkrIUiK7Ix4PYlRF9I5dhqaLYA/ADXw==",
+      "dev": true
+    },
     "@babel/helper-wrap-function": {
       "version": "7.8.3",
       "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.8.3.tgz",
@@ -13314,6 +13320,110 @@
         "rollup-pluginutils": "^2.6.0"
       }
     },
+    "rollup-plugin-terser": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-7.0.2.tgz",
+      "integrity": "sha512-w3iIaU4OxcF52UUXiZNsNeuXIMDvFrr+ZXK6bFZ0Q60qyVfq4uLptoS4bbq3paG3x216eQllFZX7zt6TIImguQ==",
+      "dev": true,
+      "requires": {
+        "@babel/code-frame": "^7.10.4",
+        "jest-worker": "^26.2.1",
+        "serialize-javascript": "^4.0.0",
+        "terser": "^5.0.0"
+      },
+      "dependencies": {
+        "@babel/code-frame": {
+          "version": "7.10.4",
+          "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz",
+          "integrity": "sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==",
+          "dev": true,
+          "requires": {
+            "@babel/highlight": "^7.10.4"
+          }
+        },
+        "@babel/highlight": {
+          "version": "7.10.4",
+          "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.10.4.tgz",
+          "integrity": "sha512-i6rgnR/YgPEQzZZnbTHHuZdlE8qyoBNalD6F+q4vAFlcMEcqmkoG+mPqJYJCo63qPf74+Y1UZsl3l6f7/RIkmA==",
+          "dev": true,
+          "requires": {
+            "@babel/helper-validator-identifier": "^7.10.4",
+            "chalk": "^2.0.0",
+            "js-tokens": "^4.0.0"
+          }
+        },
+        "has-flag": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+          "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+          "dev": true
+        },
+        "jest-worker": {
+          "version": "26.6.2",
+          "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz",
+          "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==",
+          "dev": true,
+          "requires": {
+            "@types/node": "*",
+            "merge-stream": "^2.0.0",
+            "supports-color": "^7.0.0"
+          }
+        },
+        "serialize-javascript": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz",
+          "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==",
+          "dev": true,
+          "requires": {
+            "randombytes": "^2.1.0"
+          }
+        },
+        "source-map": {
+          "version": "0.7.3",
+          "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz",
+          "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==",
+          "dev": true
+        },
+        "source-map-support": {
+          "version": "0.5.19",
+          "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.19.tgz",
+          "integrity": "sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==",
+          "dev": true,
+          "requires": {
+            "buffer-from": "^1.0.0",
+            "source-map": "^0.6.0"
+          },
+          "dependencies": {
+            "source-map": {
+              "version": "0.6.1",
+              "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+              "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+              "dev": true
+            }
+          }
+        },
+        "supports-color": {
+          "version": "7.2.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+          "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+          "dev": true,
+          "requires": {
+            "has-flag": "^4.0.0"
+          }
+        },
+        "terser": {
+          "version": "5.3.8",
+          "resolved": "https://registry.npmjs.org/terser/-/terser-5.3.8.tgz",
+          "integrity": "sha512-zVotuHoIfnYjtlurOouTazciEfL7V38QMAOhGqpXDEg6yT13cF4+fEP9b0rrCEQTn+tT46uxgFsTZzhygk+CzQ==",
+          "dev": true,
+          "requires": {
+            "commander": "^2.20.0",
+            "source-map": "~0.7.2",
+            "source-map-support": "~0.5.19"
+          }
+        }
+      }
+    },
     "rollup-pluginutils": {
       "version": "2.8.1",
       "resolved": "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.1.tgz",
diff --git a/package.json b/package.json
index e2d7757..7117a42 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "raviger",
-  "version": "1.6.0",
+  "version": "2.0.0-3",
   "description": "React routing with hooks",
   "main": "dist/main.js",
   "module": "dist/module.js",
@@ -8,6 +8,9 @@
     "dist/",
     "types/"
   ],
+  "engines": {
+    "node": ">=10.0.0 <15.0.0"
+  },
   "types": "types/raviger.d.ts",
   "keywords": [
     "react",
@@ -77,6 +80,7 @@
     "rollup": "^1.31.0",
     "rollup-plugin-babel": "^4.3.3",
     "rollup-plugin-replace": "^2.2.0",
+    "rollup-plugin-terser": "^7.0.2",
     "size-limit": "^4.0.0"
   },
   "np": {
@@ -92,7 +96,7 @@
     {
       "webpack": false,
       "path": "dist/module.js",
-      "limit": "4.1kb"
+      "limit": "2.5kb"
     }
   ]
 }
diff --git a/rollup.config.js b/rollup.config.js
index 3dc2cd1..e28bcbf 100644
--- a/rollup.config.js
+++ b/rollup.config.js
@@ -1,6 +1,7 @@
 const replace = require('rollup-plugin-replace')
 const babel = require('rollup-plugin-babel')
 const packageJson = require('./package.json')
+const { terser } = require('rollup-plugin-terser')
 
 const deps = Object.keys(packageJson.dependencies || []).concat(
   Object.keys(packageJson.peerDependencies)
@@ -36,7 +37,8 @@ module.exports = [
         ],
         sourceMaps: true,
         inputSourceMap: true
-      })
+      }),
+      terser()
     ]
   },
   {
@@ -67,7 +69,8 @@ module.exports = [
         ],
         sourceMaps: true,
         inputSourceMap: true
-      })
+      }),
+      terser()
     ]
   }
 ]
diff --git a/src/Link.js b/src/Link.js
index eb3cf62..06a3004 100644
--- a/src/Link.js
+++ b/src/Link.js
@@ -1,8 +1,8 @@
-import React, { useCallback } from 'react'
+import React, { useCallback, forwardRef } from 'react'
 import { navigate } from './navigate'
 import { usePath, useBasePath } from './path.js'
 
-export default function Link({ href, basePath, linkRef, ...props }) {
+const Link = forwardRef(({ href, basePath, ...props }, ref) => {
   const contextBasePath = useBasePath()
   basePath = basePath || contextBasePath
   href = getLinkHref(href, basePath)
@@ -21,11 +21,12 @@ export default function Link({ href, basePath, linkRef, ...props }) {
     },
     [basePath, href, props.onClick]
   )
-  // TODO: 2.0 - replace linkRef with forwardRef API
-  return <a {...props} href={href} onClick={onClick} ref={linkRef} />
-}
+  return <a {...props} href={href} onClick={onClick} ref={ref} />
+})
+
+export default Link
 
-export function ActiveLink(props) {
+const ActiveLink = forwardRef((props, ref) => {
   const basePath = useBasePath()
   const path = usePath(basePath)
   const href = getLinkHref(props.href, basePath)
@@ -33,8 +34,10 @@ export function ActiveLink(props) {
   if (!className) className = ''
   if (exactActiveClass && path === href) className += ` ${exactActiveClass}`
   if (activeClass && path.startsWith(href)) className += ` ${activeClass}`
-  return <Link {...rest} className={className} />
-}
+  return <Link {...rest} className={className} ref={ref} />
+})
+
+export { ActiveLink }
 
 function getLinkHref(href, basePath = '') {
   return href.substring(0, 1) === '/' ? basePath + href : href
diff --git a/src/context.js b/src/context.js
index f64fe1e..f977001 100644
--- a/src/context.js
+++ b/src/context.js
@@ -7,7 +7,9 @@ export { BasePathContext }
 export { PathContext }
 
 export function useRouter() {
-  const basePath = useContext(BasePathContext)
-  const path = useContext(PathContext)
+  const [basePath, path] = [
+    useContext(BasePathContext),
+    useContext(PathContext)
+  ]
   return useMemo(() => ({ basePath, path }), [basePath, path])
 }
diff --git a/src/main.js b/src/main.js
index 6482c80..c06d8e4 100644
--- a/src/main.js
+++ b/src/main.js
@@ -2,5 +2,11 @@ export { useRoutes } from './router.js'
 export { useRedirect, Redirect } from './redirect.js'
 export { default as Link, ActiveLink } from './Link.js'
 export { navigate, useNavigate, useNavigationPrompt } from './navigate.js'
-export { usePath, useHash, useBasePath, useLocationChange } from './path.js'
+export {
+  usePath,
+  useHash,
+  useFullPath,
+  useBasePath,
+  useLocationChange
+} from './path.js'
 export { useQueryParams } from './querystring.js'
diff --git a/src/navigate.js b/src/navigate.js
index 01310ba..a634e63 100644
--- a/src/navigate.js
+++ b/src/navigate.js
@@ -1,4 +1,4 @@
-import { useCallback, useEffect } from 'react'
+import { useCallback, useLayoutEffect } from 'react'
 import { isNode } from './node.js'
 import { useBasePath } from './path.js'
 
@@ -28,7 +28,7 @@ export function navigate(url, replaceOrQuery, replace) {
 
 export function useNavigationPrompt(predicate = true, prompt = defaultPrompt) {
   if (isNode) return
-  useEffect(() => {
+  useLayoutEffect(() => {
     const handler = e => {
       if (predicate) {
         return e ? cancelNavigation(e, prompt) : prompt
diff --git a/src/path.js b/src/path.js
index e806219..51bce41 100644
--- a/src/path.js
+++ b/src/path.js
@@ -1,46 +1,54 @@
-import { useState, useEffect, useCallback, useRef, useContext } from 'react'
+import {
+  useState,
+  useCallback,
+  useRef,
+  useContext,
+  useLayoutEffect
+} from 'react'
 import { BasePathContext, PathContext } from './context.js'
 import { isNode, getSsrPath } from './node.js'
 import { isFunction } from './typeChecks.js'
 
 export function usePath(basePath) {
   const contextPath = useContext(PathContext)
-
   const contextBasePath = useBasePath() // hooks can't be called conditionally
   basePath = basePath || contextBasePath
-  const [path, setPath] = useState(getCurrentPath(basePath))
+
+  const [path, setPath] = useState(getFormattedPath(basePath))
   useLocationChange(setPath, { basePath, inheritBasePath: !basePath })
 
-  return (
-    (contextPath !== null
-      ? contextPath
-      : path.replace(basePathMatcher(basePath), '')) || '/'
-  )
-  // return path
+  return contextPath || path
 }
 
 export function useBasePath() {
   return useContext(BasePathContext)
 }
 
+export function useFullPath() {
+  const [path, setPath] = useState(getCurrentPath())
+  useLocationChange(setPath, { inheritBasePath: false })
+
+  return path || '/'
+}
+
 export function useHash({ stripHash = true } = {}) {
   const [hash, setHash] = useState(window.location.hash)
   const handleHash = useCallback(() => {
     if (window.location.hash === hash) return
     setHash(window.location.hash)
   }, [setHash])
-  useEffect(() => {
+
+  useLayoutEffect(() => {
     window.addEventListener('hashchange', handleHash, false)
     return () => window.removeEventListener('hashchange', handleHash)
   }, [handleHash])
+
   useLocationChange(handleHash)
   return stripHash ? hash.substring(1) : hash
 }
 
-export function getCurrentPath(basePath = '') {
-  return isNode
-    ? getSsrPath()
-    : window.location.pathname.replace(basePathMatcher(basePath), '') || '/'
+export function getCurrentPath() {
+  return isNode ? getSsrPath() : window.location.pathname || '/'
 }
 
 export function getCurrentHash() {
@@ -52,14 +60,16 @@ export function getCurrentHash() {
   return window.location.hash
 }
 
-export function useLocationChange(setFn, options = {}) {
+export function useLocationChange(
+  setFn,
+  { inheritBasePath = true, basePath = '', isActive } = {}
+) {
   if (isNode) return
-  let basePath = ''
   const routerBasePath = useBasePath()
-  if (options.inheritBasePath !== false) basePath = routerBasePath
-  if (!basePath && options.basePath) basePath = options.basePath
+  if (inheritBasePath && routerBasePath) basePath = routerBasePath
+
   const setRef = useRef(setFn)
-  useEffect(() => {
+  useLayoutEffect(() => {
     // setFn could be an in-render declared callback, making it unstable
     // This is a method of using an often-changing callback from React Hooks
     // https://reactjs.org/docs/hooks-faq.html#how-to-read-an-often-changing-value-from-usecallback
@@ -67,16 +77,38 @@ export function useLocationChange(setFn, options = {}) {
     // For reducing the useEffect cleanup from setFn changing every render
     setRef.current = setFn
   })
+
   const onPopState = useCallback(() => {
     // No predicate defaults true
-    if (options.isActive !== undefined && !isPredicateActive(options.isActive))
-      return
-    setRef.current(getCurrentPath(basePath))
-  }, [options.isActive, basePath])
-  useEffect(() => {
+    if (isActive !== undefined && !isPredicateActive(isActive)) return
+    // console.log('loc', basePath || 'none', getFormattedPath(basePath))
+    setRef.current(getFormattedPath(basePath))
+  }, [isActive, basePath])
+
+  useLayoutEffect(() => {
     window.addEventListener('popstate', onPopState)
     return () => window.removeEventListener('popstate', onPopState)
   }, [onPopState])
+
+  // When the basePath changes re-check the path after the render completes
+  // This allows nested contexts to get an up-to-date formatted path
+  useLayoutEffect(() => {
+    if (isActive !== undefined && !isPredicateActive(isActive)) return
+    setRef.current(getFormattedPath(basePath))
+  }, [basePath, isActive])
+}
+
+/**
+ * Returns the current path. If basePath is provided it will be removed from the front of the path.
+ * If basePath is provided and the path does not begin with it will return null
+ * @param {string} basePath basePath, if any
+ * @return {string | null} returns path with basePath prefix removed, or null if basePath is provided and missing
+ */
+export function getFormattedPath(basePath) {
+  const path = getCurrentPath()
+  const baseMissing = basePath && !isPathInBase(basePath, path)
+  if (path === null || baseMissing) return null
+  return !basePath ? path : path.replace(basePathMatcher(basePath), '') || '/'
 }
 
 function isPredicateActive(predicate) {
@@ -86,3 +118,9 @@ function isPredicateActive(predicate) {
 function basePathMatcher(basePath) {
   return new RegExp('^' + basePath, 'i')
 }
+
+function isPathInBase(basePath, path) {
+  return (
+    basePath && path && path.toLowerCase().startsWith(basePath.toLowerCase())
+  )
+}
diff --git a/src/querystring.js b/src/querystring.js
index 7eff551..7bc3f83 100644
--- a/src/querystring.js
+++ b/src/querystring.js
@@ -9,8 +9,7 @@ export function useQueryParams(
 ) {
   const [querystring, setQuerystring] = useState(getQueryString())
   const setQueryParams = useCallback(
-    // TODO: V2 using options param for replace
-    (params, replace = true) => {
+    (params, { replace = true } = {}) => {
       let path = getCurrentPath()
       params = replace ? params : { ...parseFn(querystring), ...params }
       const serialized = serializeFn(params).toString()
@@ -21,25 +20,21 @@ export function useQueryParams(
     [querystring]
   )
   // Update state when route changes
-  useLocationChange(() => setQuerystring(getQueryString()))
+  const updateQuery = useCallback(() => setQuerystring(getQueryString()), [
+    setQueryParams
+  ])
+  useLocationChange(updateQuery)
   return [parseFn(querystring), setQueryParams]
 }
 
 function parseQuery(querystring) {
-  return [...new URLSearchParams(querystring)].reduce(
-    (result, [key, value]) => {
-      result[key] = value
-      return result
-    },
-    {}
-  )
+  return Object.fromEntries(new URLSearchParams(querystring).entries())
 }
 
 function serializeQuery(queryParams) {
-  return Object.entries(queryParams).reduce((query, [key, value]) => {
-    if (value !== null) query.append(key, value)
-    return query
-  }, new URLSearchParams())
+  return new URLSearchParams(
+    Object.entries(queryParams).filter(([, v]) => v !== null)
+  )
 }
 
 export function getQueryString() {
@@ -50,16 +45,3 @@ export function getQueryString() {
   }
   return location.search
 }
-
-export function deriveQueryString(current, target, merge) {
-  current = queryAsObject(current)
-  target = queryAsObject(target)
-  return merge
-    ? new URLSearchParams({ ...current, ...target }).toString()
-    : new URLSearchParams(target).toString()
-}
-
-function queryAsObject(query) {
-  if (!query) return {}
-  return Object.fromEntries(new URLSearchParams(query).entries())
-}
diff --git a/src/redirect.js b/src/redirect.js
index 2cff5ad..3e1bf23 100644
--- a/src/redirect.js
+++ b/src/redirect.js
@@ -1,35 +1,37 @@
-import { useEffect } from 'react'
+import { useLayoutEffect } from 'react'
 import { usePath, getCurrentHash } from './path.js'
 import { navigate } from './navigate.js'
-import { useQueryParams, deriveQueryString } from './querystring.js'
+import { useQueryParams } from './querystring.js'
+
+export function Redirect({ to, query, replace = true, merge = true }) {
+  useRedirect(usePath(), to, { query, replace, merge })
+  return null
+}
 
-// TODO: V2 replace this signature with one more like the <Redirect /> Component
 export function useRedirect(
   predicateUrl,
   targetUrl,
-  queryParams = null,
-  replace = true
+  { query, replace = true, merge = true } = {}
 ) {
   const currentPath = usePath()
-  useEffect(() => {
-    if (currentPath === predicateUrl) {
-      navigate(targetUrl, queryParams, replace)
-    }
-  }, [predicateUrl, targetUrl, queryParams, replace, currentPath])
-}
-
-export function Redirect({ to, query, replace = true, merge = true }) {
   const [currentQuery] = useQueryParams()
   const hash = getCurrentHash()
-  const path = usePath()
-  let url = to
-  const targetQuery = deriveQueryString(currentQuery, query, merge)
+
+  let url = targetUrl
+  const targetQuery = new URLSearchParams({
+    ...(merge ? currentQuery : {}),
+    ...query
+  }).toString()
   if (targetQuery) {
     url += '?' + targetQuery
   }
   if (merge && hash && hash.length) {
     url += hash
   }
-  useRedirect(path, url, null, replace)
-  return null
+
+  useLayoutEffect(() => {
+    if (currentPath === predicateUrl) {
+      navigate(url, undefined, replace)
+    }
+  }, [predicateUrl, url, undefined, replace, currentPath])
 }
diff --git a/src/router.js b/src/router.js
index e914ccb..50c314f 100644
--- a/src/router.js
+++ b/src/router.js
@@ -1,7 +1,7 @@
-import React, { useMemo, useState } from 'react'
+import React, { useMemo } from 'react'
 import { BasePathContext, PathContext } from './context.js'
 import { isNode, setSsrPath, getSsrPath } from './node'
-import { useLocationChange, getCurrentPath } from './path.js'
+import { usePath, getFormattedPath } from './path.js'
 
 export function useRoutes(
   routes,
@@ -9,13 +9,21 @@ export function useRoutes(
     basePath = '',
     routeProps = {},
     overridePathParams = true,
-    matchTrailingSlash = false
+    matchTrailingSlash = true
   } = {}
 ) {
-  // path is the browser url location
-  const [path, setPath] = useState(getCurrentPath(basePath))
+  /*
+    This is a hack to setup a listener for the path while always using this latest path
+    The issue with usePath is that, in order to not re-render nested components when
+    their parent router changes the path, it uses the context's path
+    But since that path has to get _set_ here in useRoutes something has to give
+
+    If usePath returns latest it causes render thrashing
+    If useRoutes hacks itself into the latest path nothing bad happens (...afaik)
+  */
+
+  const path = usePath(basePath) && getFormattedPath(basePath)
 
-  useLocationChange(setPath, { basePath, inheritBasePath: !basePath })
   // Get the current route
   const route = matchRoute(routes, path, {
     routeProps,
@@ -23,7 +31,7 @@ export function useRoutes(
     matchTrailingSlash
   })
   // No match should not return an empty Provider, just null
-  if (!route) return null
+  if (!route || path === null) return null
   return (
     <BasePathContext.Provider value={basePath}>
       <PathContext.Provider value={path}>{route}</PathContext.Provider>
@@ -57,6 +65,7 @@ function matchRoute(
     () => Object.keys(routes).map(createRouteMatcher),
     [hashRoutes(routes)]
   )
+  if (path === null) return null
   // Hacky method for find + map
   let routeMatch
   routeMatchers.find(([routePath, regex, groups]) => {
diff --git a/types/raviger.d.ts b/types/raviger.d.ts
index 957716b..d39cfaf 100644
--- a/types/raviger.d.ts
+++ b/types/raviger.d.ts
@@ -21,14 +21,13 @@ export interface LinkProps
   extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
   href: string
   basePath?: string
-  linkRef?: React.RefObject<HTMLAnchorElement>
 }
-export const Link: React.FC<LinkProps>
+export const Link: React.ForwardRefExoticComponent<LinkProps & React.RefAttributes<HTMLAnchorElement>>
 export interface ActiveLinkProps extends LinkProps {
   activeClass?: string
   exactActiveClass?: string
 }
-export const ActiveLink: React.FC<ActiveLinkProps>
+export const ActiveLink: React.ForwardRefExoticComponent<ActiveLinkProps & React.RefAttributes<HTMLAnchorElement>>
 
 export interface RedirectProps {
   to: string
@@ -68,10 +67,14 @@ export interface QueryParam {
   [key: string]: any
 }
 
+export interface setQueryParamsOptions {
+  replace?: boolean
+}
+
 export function useQueryParams(
   parseFn?: (query: string) => QueryParam,
   serializeFn?: (query: QueryParam) => string
-): [QueryParam, (query: QueryParam, replace?: boolean) => void]
+): [QueryParam, (query: QueryParam, options?: setQueryParamsOptions) => void]
 
 export function useQueryParams<T>(
   parseFn?: (query: string) => T,


Test patch:
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index bb6138f..47fab7c 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -18,7 +18,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [8.x, 10.x, 12.x]
+        node-version: [10.x, 12.x, 14.x]
 
     steps:
       - uses: actions/checkout@v1
diff --git a/test/Link.spec.js b/test/Link.spec.js
index 6346991..bab778e 100644
--- a/test/Link.spec.js
+++ b/test/Link.spec.js
@@ -69,7 +69,7 @@ describe('Link', () => {
     act(() => void fireEvent.click(getByTestId('link')))
     expect(document.location.pathname).toEqual('/')
   })
-  test('passes linkRef to anchor element', async () => {
+  test('passes ref to anchor element', async () => {
     act(() => navigate('/'))
 
     let ref
@@ -77,12 +77,7 @@ describe('Link', () => {
       const linkRef = useRef()
       ref = linkRef
       return (
-        <Link
-          href="/foo"
-          target="_blank"
-          data-testid="linkref"
-          linkRef={linkRef}
-        >
+        <Link href="/foo" target="_blank" data-testid="linkref" ref={linkRef}>
           go to foo
         </Link>
       )
diff --git a/test/context.spec.js b/test/context.spec.js
index 6f6d62f..264a446 100644
--- a/test/context.spec.js
+++ b/test/context.spec.js
@@ -35,7 +35,7 @@ describe('useRouter', () => {
       <Harness routes={routes} options={{ basePath: '/home' }} />
     )
 
-    act(() => navigate('/'))
+    act(() => navigate('/home'))
     expect(getByTestId('basePath')).toHaveTextContent('home')
   })
   test('provides path', async () => {
@@ -44,4 +44,12 @@ describe('useRouter', () => {
     act(() => navigate('/about'))
     expect(getByTestId('path')).toHaveTextContent('about')
   })
+  test('provides null path when basePath is missing', async () => {
+    const { getByTestId } = render(
+      <Harness routes={routes} options={{ basePath: '/home' }} />
+    )
+
+    act(() => navigate('/about'))
+    expect(getByTestId('label')).toHaveTextContent('not found')
+  })
 })
diff --git a/test/path.spec.js b/test/path.spec.js
index f152572..456bec5 100644
--- a/test/path.spec.js
+++ b/test/path.spec.js
@@ -13,8 +13,8 @@ beforeEach(() => {
 })
 
 describe('useLocationChange', () => {
-  function Route({ onChange, isActive }) {
-    useLocationChange(onChange, { isActive })
+  function Route({ onChange, isActive, basePath }) {
+    useLocationChange(onChange, { isActive, basePath })
     return null
   }
   test('setter gets updated path', async () => {
@@ -33,6 +33,15 @@ describe('useLocationChange', () => {
 
     expect(watcher).not.toBeCalled()
   })
+  test('setter gets null when provided basePath is missing', async () => {
+    let watcher = jest.fn()
+    render(<Route onChange={watcher} basePath="/home" />)
+
+    act(() => navigate('/foo'))
+    expect(watcher).toBeCalledWith(null)
+    act(() => navigate('/home'))
+    expect(watcher).toBeCalledWith('/')
+  })
 })
 
 describe('usePath', () => {
@@ -120,6 +129,7 @@ describe('usePath', () => {
     expect(getByTestId('path')).toHaveTextContent('/')
   })
 
+  // tracks regression of https://github.com/kyeotic/raviger/issues/64
   test('usePath is not called when unmounting', async () => {
     const homeFn = jest.fn()
     function Home() {
@@ -163,15 +173,22 @@ describe('usePath', () => {
       )
     }
 
+    // console.log('start')
+    // start with about mounted
     act(() => navigate('/about'))
     const { getByTestId } = render(<Harness routes={routes} />)
+
     expect(getByTestId('path')).toHaveTextContent('/about')
     expect(aboutFn).toHaveBeenCalledTimes(1)
+    // console.log('preset')
 
+    // reset
     aboutFn.mockClear()
 
     // console.log('reset')
     // act(() => navigate('/'))
+
+    // go home with async
     act(() => void fireEvent.click(getByTestId('home-btn')))
     // console.log('acted')
     // Wait for the internal setTimeout
@@ -181,6 +198,18 @@ describe('usePath', () => {
     expect(homeFn).toHaveBeenCalledTimes(1)
     expect(aboutFn).toHaveBeenCalledTimes(0)
   })
+
+  test('returns null when provided basePath is missing', async () => {
+    function Route() {
+      let path = usePath('/home')
+      return <span data-testid="path">{path || 'not found'}</span>
+    }
+    act(() => navigate('/'))
+    const { getByTestId } = render(<Route />)
+    act(() => navigate('/about'))
+
+    expect(getByTestId('path')).toHaveTextContent('not found')
+  })
 })
 
 describe('useHash', () => {
diff --git a/test/querystring.spec.js b/test/querystring.spec.js
index 7dea110..fcdd46d 100644
--- a/test/querystring.spec.js
+++ b/test/querystring.spec.js
@@ -1,7 +1,6 @@
 import React from 'react'
 import { render, act, fireEvent } from '@testing-library/react'
 import { navigate, useQueryParams } from '../src/main.js'
-import { deriveQueryString } from '../src/querystring.js'
 
 beforeEach(() => {
   act(() => navigate('/'))
@@ -38,7 +37,10 @@ describe('setQueryParams', () => {
   function Route({ replace, foo = 'bar' }) {
     let [query, setQuery] = useQueryParams()
     return (
-      <button data-testid="update" onClick={() => setQuery({ foo }, replace)}>
+      <button
+        data-testid="update"
+        onClick={() => setQuery({ foo }, { replace })}
+      >
         Set Query: {query.foo}
       </button>
     )
@@ -89,32 +91,3 @@ describe('setQueryParams', () => {
     expect(document.location.hash).toEqual('#test')
   })
 })
-
-describe('deriveQueryString', () => {
-  test('merges current and target when merge is true', () => {
-    expect(deriveQueryString({ a: 'a' }, { b: 'b' }, true)).toBe('a=a&b=b')
-    expect(deriveQueryString('a=a', { b: 'b' }, true)).toBe('a=a&b=b')
-    expect(deriveQueryString({ a: 'a' }, 'b=b', true)).toBe('a=a&b=b')
-    expect(deriveQueryString('a=a', 'b=b', true)).toBe('a=a&b=b')
-    expect(
-      deriveQueryString(
-        new URLSearchParams({ a: 'a' }),
-        new URLSearchParams({ b: 'b' }),
-        true
-      )
-    ).toBe('a=a&b=b')
-  })
-  test('returns target when merge is false', () => {
-    expect(deriveQueryString({ a: 'a' }, { b: 'b' }, false)).toBe('b=b')
-    expect(deriveQueryString('a=a', { b: 'b' }, false)).toBe('b=b')
-    expect(deriveQueryString({ a: 'a' }, 'b=b', false)).toBe('b=b')
-    expect(deriveQueryString('a=a', 'b=b', false)).toBe('b=b')
-    expect(
-      deriveQueryString(
-        new URLSearchParams({ a: 'a' }),
-        new URLSearchParams({ b: 'b' }),
-        false
-      )
-    ).toBe('b=b')
-  })
-})
diff --git a/test/redirect.spec.js b/test/redirect.spec.js
index 21363b3..467031c 100644
--- a/test/redirect.spec.js
+++ b/test/redirect.spec.js
@@ -8,7 +8,7 @@ beforeEach(() => {
 
 describe('useRedirect', () => {
   function Mock() {
-    useRedirect('/fail', '/catch', { name: 'kyeotic' })
+    useRedirect('/fail', '/catch', { query: { name: 'kyeotic' } })
     useRedirect('/miss', '/catch')
     return <span>Mock</span>
   }
diff --git a/test/router.spec.js b/test/router.spec.js
index c91c75c..9f42f81 100644
--- a/test/router.spec.js
+++ b/test/router.spec.js
@@ -50,6 +50,7 @@ describe('useRoutes', () => {
     act(() => navigate('/missing'))
     expect(getByTestId('label')).toHaveTextContent('not found')
   })
+
   test('matches updated route', async () => {
     const { getByTestId } = render(<Harness routes={routes} />)
 
@@ -59,12 +60,23 @@ describe('useRoutes', () => {
     act(() => navigate('/about'))
     expect(getByTestId('label')).toHaveTextContent('about')
   })
-  test('does not match trailing slash by default', async () => {
+
+  test('matches trailing slash by default', async () => {
     const { getByTestId } = render(<Harness routes={routes} />)
     act(() => navigate('/'))
     act(() => navigate('/about/'))
+    expect(getByTestId('label')).toHaveTextContent('about')
+  })
+
+  test('does not match trailing slash with option', async () => {
+    const { getByTestId } = render(
+      <Harness routes={routes} options={{ matchTrailingSlash: false }} />
+    )
+    act(() => navigate('/'))
+    act(() => navigate('/about/'))
     expect(getByTestId('label')).toHaveTextContent('not found')
   })
+
   test('matches trailing slash with option', async () => {
     const { getByTestId } = render(
       <Harness routes={routes} options={{ matchTrailingSlash: true }} />
@@ -73,6 +85,7 @@ describe('useRoutes', () => {
     act(() => navigate('/about/'))
     expect(getByTestId('label')).toHaveTextContent('about')
   })
+
   test('matches trailing slash on "/"', async () => {
     const { getByTestId } = render(
       <Harness routes={routes} options={{ matchTrailingSlash: true }} />
@@ -80,18 +93,21 @@ describe('useRoutes', () => {
     act(() => navigate('/'))
     expect(getByTestId('label')).toHaveTextContent('home')
   })
+
   test('matches route with parameters', async () => {
     const { getByTestId } = render(<Harness routes={routes} />)
 
     act(() => navigate('/users/1'))
     expect(getByTestId('label')).toHaveTextContent('User 1')
   })
+
   test('matches case insensitive rout', async () => {
     const { getByTestId } = render(<Harness routes={routes} />)
 
     act(() => navigate('/About'))
     expect(getByTestId('label')).toHaveTextContent('about')
   })
+
   test('works with lazy routes', async () => {
     let loader = new Promise(resolve => {
       setTimeout(() => resolve({ default: Route }), 50)
@@ -113,6 +129,7 @@ describe('useRoutes', () => {
     act(() => navigate('/lazy'))
     expect(getByTestId('label')).toHaveTextContent('lazy')
   })
+
   test('passes extra route props to route', async () => {
     const { getByTestId } = render(
       <Harness
@@ -123,6 +140,7 @@ describe('useRoutes', () => {
     act(() => navigate('/about'))
     expect(getByTestId('extra')).toHaveTextContent('injected')
   })
+
   test('overrides route props', async () => {
     const { getByTestId } = render(
       <Harness routes={routes} options={{ routeProps: { userId: 4 } }} />
@@ -130,6 +148,7 @@ describe('useRoutes', () => {
     act(() => navigate('/users/1'))
     expect(getByTestId('label')).toHaveTextContent('User 4')
   })
+
   test('underrides route props', async () => {
     const { getByTestId } = render(
       <Harness
@@ -178,6 +197,73 @@ describe('useRoutes', () => {
     act(() => navigate('/new'))
     expect(getByTestId('label')).toHaveTextContent('new route')
   })
+
+  describe('with basePath', () => {
+    const routes = {
+      '/': () => <Route label="home" />,
+      '/about': () => <Route label="about" />
+    }
+
+    test('matches current route', async () => {
+      act(() => navigate('/foo'))
+      const options = { basePath: '/foo' }
+      const { getByTestId } = render(
+        <Harness routes={routes} options={options} />
+      )
+
+      // act(() => navigate('/foo'))
+      expect(getByTestId('label')).toHaveTextContent('home')
+      act(() => navigate('/foo/about'))
+      expect(getByTestId('label')).toHaveTextContent('about')
+    })
+
+    test('does not match current route', async () => {
+      const options = { basePath: '/foo' }
+      const { getByTestId } = render(
+        <Harness routes={routes} options={options} />
+      )
+
+      act(() => navigate('/'))
+      expect(getByTestId('label')).toHaveTextContent('not found')
+
+      act(() => navigate('/about'))
+      expect(getByTestId('label')).toHaveTextContent('not found')
+    })
+
+    test('nested router with basePath matches after navigation', async () => {
+      const appRoutes = {
+        '/app/:id*': ({ id }) => SubRouter({ id })
+      }
+      function App() {
+        const route = useRoutes(appRoutes)
+        return route || <NotFound />
+      }
+      function SubRouter({ id }) {
+        const subRoutes = React.useMemo(
+          () => ({
+            '/settings': () => <RouteItem id={id} />
+          }),
+          [id]
+        )
+        const route = useRoutes(subRoutes, { basePath: `/app/${id}` })
+        return route || <NotFound />
+      }
+      function RouteItem({ id }) {
+        return <span data-testid="label">{id}</span>
+      }
+
+      function NotFound() {
+        return <span data-testid="label">Not Found</span>
+      }
+      act(() => navigate('/app/1/settings'))
+      const { getByTestId } = render(<App />)
+
+      expect(getByTestId('label')).toHaveTextContent('1')
+
+      act(() => navigate('/app/2/settings'))
+      expect(getByTestId('label')).toHaveTextContent('2')
+    })
+  })
 })
 
 describe('useBasePath', () => {