Repository: kyeotic/raviger
Pull request #74 (2020-10-22 20:44:53)

Add a hook for navigating with the basePath
This adds a `useNavigate` hook that provides a navigate function that includes the `basePath` (making it more consistent with the `Link` href).

I understand this addition to the API might not be wanted, but I found the existing behavior of `navigate` surprising and thought I'd propose an addition (see https://github.com/kyeotic/raviger/issues/73#issuecomment-714084441).

kyeotic: I think this is a great idea!
tschaub: Any suggestions on the `size-limit` [failures](https://github.com/kyeotic/raviger/pull/74/checks?check_run_id=1294860497)?
kyeotic: This is a fantastic PR, well done. You got the tests, docs, API is perfect. You are only missing the declaration in `/types` for typescript.

```
  Package size limit has exceeded by 19 B
  Size limit: 4 KB
  Size:       4.02 KB with all dependencies, minified and gzipped
```

The build failure is due to missing the size-limit check by a literally a handful of bytes.. Feel free to bump the size limit to 4.1kb.
tschaub: I failed to come up with a more specific type than this:
```
export function useNavigate(): Function
```

Is there a trick for returning a type that represents that variadic/overloaded navigate signature?

kyeotic: That's a tough one. I think it should look something like this
```typscript
export function useNavigate(): (
  (url: string, replace?: boolean) => void)
  | ((url: string,
    query?: URLSearchParams,
    replace?: boolean
    ) => void)
```
tschaub: I was trying to union the function types, but [still got issues](https://www.typescriptlang.org/play?#code/C4TwDgpgBAcghgNwJYHM7AgdScAFgJQjABs4BjaAXigAoBXAJ2IC4oBnYBpAOxQBooDIqQoB+VgCMA9lOIQ43AJRRKAPigIpSACYBuAFChIsRKnRYcuAIp0IDECtqMW7Tj35QAjrfvioAVXwAGQBleQYyXAAFOAY4AFs2ASEScgg-aVl5JRV1TR0DfW0IMlIhKAAzOm4yYCQpbig6Ngh4ZDQMGkVWNrMMbDxCVIooAB8TdvMB6x8QQrIGjihuUw6qJpbeta7ClcnOgHIAegqZA8UDPb6IGmPTqQOBTlsL-Svtu7OBbggAdwDgmFYpEYnFErcJAlKJCAF7nJ4MF4GIA).
```ts
type NavigateWithReplace = (url: string, replace?: boolean) => void;
type NavigateWithQuery = (url: string, query?: URLSearchParams, replace?: boolean) => void;

declare function useNavigate(): NavigateWithReplace | NavigateWithQuery;

const navigate = useNavigate();

navigate('/foo');
navigate('/foo', true);
navigate('/foo', new URLSearchParams('bam=baz'), true);
```
kyeotic: Good catch. [This seems to work](https://www.typescriptlang.org/play?#code/KYDwDg9gTgLgBASwHY2FAZgQwMbDgRQFc0BPABUykwFs4BvAKDjgG0BrYEgLjgGcYoyAOYBdHpiQkGAXwYwSYPADlMANwRDMqAOoIYACwBKwMABsceALxwAFISime-QUiEAaOFBPncAfh4ARhAQpsASAJRwlgB8cKoQCAAmANxyCspqGlrAugZEpFG29o58AsIeAI7EUCT+BNXklDRwAD5wAKqGADIAymFQ2PoUVNS8Hl5mFnVBIWFIkTFxCSkMDInA2OZecOiESNgwCBBIcIS8wCrqmqg24TyXWTp6Rt4WcABkcA-XOc-5NakGNhjvw4EhMj9CmcLhDsrdAeCrnCAOQAenQwWR4VSiMewBsaIxEGRHgExGxDFxPwJ6MxHiQwAA7h1un1KINhjReASAjRLLyAF5Y0lQck42E3Ql0uBk4D0pks3r9DlNUY8vmCrGpIA)

```typescript
export interface QueryParam {
  [key: string]: any
}
type NavigateWithReplace = (url: string, replace?: boolean) => void;
type NavigateWithQuery = (url: string, query?: QueryParam | URLSearchParams, replace?: boolean) => void;

declare function useNavigate(): NavigateWithReplace & NavigateWithQuery;

const navigate = useNavigate();

navigate('/foo');
navigate('/foo', true);
navigate('/foo', new URLSearchParams('bam=baz'), true);
navigate('/foo', true, new URLSearchParams('bam=baz'); <-- correctly errors

```

Patch:
diff --git a/docs/content/api/navigate.md b/docs/content/api/navigate.md
index 82181b9..fa484e9 100644
--- a/docs/content/api/navigate.md
+++ b/docs/content/api/navigate.md
@@ -17,6 +17,8 @@ export function navigate(
 ): void
 {{< /highlight >}}
 
+**Note**: `navigate` does not consider any `basePath` that may be set.  The `useNavigate` hook should be used if you want to prepend the `basePath` to the URL when navigating.
+
 ## Basic
 
 the `navigate` function is intended to be used outside of components to perform page navigation programmatically. 
diff --git a/docs/content/api/useNavigate.md b/docs/content/api/useNavigate.md
new file mode 100644
index 0000000..1cdc3fc
--- /dev/null
+++ b/docs/content/api/useNavigate.md
@@ -0,0 +1,55 @@
+---
+title: "useNavigate"
+date: 2020-10-22T14:27:55-06:00
+weight: 5
+---
+
+A hook for imperative route changes that include any configured `basePath`
+
+## API
+
+{{< highlight typescript >}}
+type NavigateWithReplace = (url: string, replace?: boolean) => void;
+type NavigateWithQuery = (url: string, query?: URLSearchParams, replace?: boolean) => void;
+
+export function useNavigate(optBasePath?: string): NavigateWithReplace & NavigateWithQuery;
+{{< /highlight >}}
+
+The function returned by `useNavigate` has the same signature as the non-hook [navigate function](/api/navigate).  The only difference is that this function considers the `basePath` when navigating.
+
+## Basic
+
+{{< highlight jsx >}}
+import { useRoutes, useNavigate } from 'raviger'
+
+function Home () {
+  const navigate = useNavigate()
+
+  // pathname will be /app/about after navigation
+  return <button onClick={() => navigate('/about')}>about</button>
+}
+
+function About () {
+  const navigate = useNavigate()
+
+  // pathname will be /app/ after navigation
+  return <button onClick={() => navigate('/')}>home</button>
+}
+
+const routes = {
+  '/': () => <Home />
+  '/about': () => <About />
+}
+
+export default function App() {
+  return useRoutes(routes, { basePath: '/app' })
+}
+{{< /highlight >}}
+
+## Outside a Router
+
+If the `useNavigate` hook is used inside a router context (there is a `useRoutes` in its parent heirarchy), the `basePath` will be inherited.  If the hook is used outside a router context, or if you want to override the `basePath`, you can call the `useNavigate` hook with an `optBasePath` argument.
+
+## Query Params and Replacing State
+
+As with the non-hook `navigate` function, the function returned by `useNavigate` can be called with query params or with a boolean indicating whether to replace the current history entry rather than adding to the history.
diff --git a/package.json b/package.json
index e4b8705..3c47d66 100644
--- a/package.json
+++ b/package.json
@@ -92,7 +92,7 @@
     {
       "webpack": false,
       "path": "dist/module.js",
-      "limit": "4kb"
+      "limit": "4.1kb"
     }
   ]
 }
diff --git a/src/main.js b/src/main.js
index cc7a114..6482c80 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,6 +1,6 @@
 export { useRoutes } from './router.js'
 export { useRedirect, Redirect } from './redirect.js'
 export { default as Link, ActiveLink } from './Link.js'
-export { navigate, useNavigationPrompt } from './navigate.js'
+export { navigate, useNavigate, useNavigationPrompt } from './navigate.js'
 export { usePath, useHash, useBasePath, useLocationChange } from './path.js'
 export { useQueryParams } from './querystring.js'
diff --git a/src/navigate.js b/src/navigate.js
index 03a9f17..01310ba 100644
--- a/src/navigate.js
+++ b/src/navigate.js
@@ -1,5 +1,6 @@
-import { useEffect } from 'react'
+import { useCallback, useEffect } from 'react'
 import { isNode } from './node.js'
+import { useBasePath } from './path.js'
 
 const defaultPrompt = 'Are you sure you want to leave this page?'
 const interceptors = new Set()
@@ -66,3 +67,16 @@ function cancelNavigation(event, prompt) {
   // Return value for prompt per spec
   return prompt
 }
+
+export function useNavigate(optBasePath = '') {
+  const basePath = useBasePath()
+  const navigateWithBasePath = useCallback(
+    (url, replaceOrQuery, replace) => {
+      const base = optBasePath || basePath
+      const href = url.startsWith('/') ? base + url : url
+      navigate(href, replaceOrQuery, replace)
+    },
+    [basePath, optBasePath]
+  )
+  return navigateWithBasePath
+}
diff --git a/types/raviger.d.ts b/types/raviger.d.ts
index eaabef2..cb31de5 100644
--- a/types/raviger.d.ts
+++ b/types/raviger.d.ts
@@ -45,6 +45,11 @@ export function navigate(
   replace?: boolean
 ): void
 
+type NavigateWithReplace = (url: string, replace?: boolean) => void;
+type NavigateWithQuery = (url: string, query?: URLSearchParams | QueryParam, replace?: boolean) => void;
+
+export function useNavigate(optBasePath?: string): NavigateWithReplace & NavigateWithQuery;
+
 export function usePath(basePath?: string): string
 export function useHash(options?: { stripHash?: boolean }): string
 


Test patch:
diff --git a/test/navigate.spec.js b/test/navigate.spec.js
index 01a0881..eec4068 100644
--- a/test/navigate.spec.js
+++ b/test/navigate.spec.js
@@ -1,6 +1,11 @@
 import React from 'react'
 import { render, act } from '@testing-library/react'
-import { useNavigationPrompt, navigate } from '../src/main.js'
+import {
+  useNavigationPrompt,
+  useRoutes,
+  useNavigate,
+  navigate
+} from '../src/main.js'
 
 const originalConfirm = window.confirm
 const originalReplaceState = window.history.replaceState
@@ -19,6 +24,121 @@ afterEach(() => {
   window.history.pushState = originalPushState
 })
 
+describe('useNavigate', () => {
+  function Route({ newPath, query, replace }) {
+    const navigateWithBasePath = useNavigate()
+    return (
+      <button
+        onClick={() => {
+          navigateWithBasePath(newPath, query, replace)
+        }}
+      >
+        click to navigate
+      </button>
+    )
+  }
+
+  const routes = {
+    '/*': ({ newPath, query, replace }) => (
+      <Route newPath={newPath} query={query} replace={replace} />
+    )
+  }
+
+  function App({ basePath, newPath, query, replace }) {
+    const route = useRoutes(routes, {
+      basePath,
+      routeProps: { newPath, query, replace }
+    })
+    return route
+  }
+
+  test('navigate with a basePath', async () => {
+    const basePath = '/base'
+    const newPath = '/path'
+
+    const { container } = render(<App basePath={basePath} newPath={newPath} />)
+    act(() => navigate(`${basePath}/`))
+
+    const button = container.querySelector('button')
+    act(() => {
+      button.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+    })
+
+    expect(document.location.pathname).toEqual(basePath + newPath)
+  })
+
+  test('navigate without a basePath', async () => {
+    const newPath = '/path'
+
+    const { container } = render(<App newPath={newPath} />)
+    act(() => navigate('/'))
+
+    const button = container.querySelector('button')
+    act(() => {
+      button.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+    })
+
+    expect(document.location.pathname).toEqual(newPath)
+  })
+
+  test('navigate to a relative path', async () => {
+    const basePath = '/base'
+    const newPath = 'relative'
+
+    const { container } = render(<App basePath={basePath} newPath={newPath} />)
+    act(() => navigate(`${basePath}/page`))
+
+    const button = container.querySelector('button')
+    act(() => {
+      button.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+    })
+
+    expect(document.location.pathname).toEqual(`${basePath}/${newPath}`)
+  })
+
+  test('navigate with a query', async () => {
+    const basePath = '/base'
+    const newPath = '/path'
+    const query = { foo: 'bar' }
+
+    const { container } = render(
+      <App basePath={basePath} newPath={newPath} query={query} />
+    )
+    act(() => navigate(`${basePath}/`))
+
+    const button = container.querySelector('button')
+    act(() => {
+      button.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+    })
+
+    expect(document.location.pathname).toEqual(basePath + newPath)
+    expect(document.location.search).toEqual('?foo=bar')
+  })
+
+  test('navigate without history', async () => {
+    const basePath = '/base'
+    const newPath = '/path'
+
+    const { container } = render(
+      <App basePath={basePath} newPath={newPath} replace />
+    )
+    act(() => navigate(`${basePath}/`))
+
+    window.history.replaceState = jest.fn()
+    const button = container.querySelector('button')
+    act(() => {
+      button.dispatchEvent(new MouseEvent('click', { bubbles: true }))
+    })
+
+    expect(window.history.replaceState.mock.calls).toHaveLength(1)
+    expect(window.history.replaceState.mock.calls[0]).toHaveLength(3)
+    expect(window.history.replaceState.mock.calls[0][2]).toEqual(
+      basePath + newPath
+    )
+    jest.clearAllMocks()
+  })
+})
+
 describe('useNavigationPrompt', () => {
   function Route({ block = true, prompt }) {
     useNavigationPrompt(block, prompt)