Repository: kyeotic/raviger (ts)

Issue:
route change interceptors
We need a way to confirm route changes before they happen. We need _interceptors_. I believe this may be the last feature before going to 1.0.

I am currently working on an API, but my home development time has been severely restricted the last two weeks due to a neck injury. Any ideas or sub-feature requests for interceptors are welcome here.

## Confirmation

In the confirmation case it is expected that the the intercept-handler will gather user input and decide whether to cancel or allow the navigation to occur. 

This could be achieved either through immediate cancellation that is resumed if the intercept-handler indicates, or through synchronous/browser-managed confirmation. However, according to the MDN

> To combat unwanted pop-ups, some browsers don't display prompts created in beforeunload event handlers unless the page has been interacted with. Moreover, some don't display them at all.

I believe using the browser built-ins is the right way to go, even if some browsers don't support it (that is an intentional decision we would be fighting).

## Inspection

In the inspection case it is expected a component may want to conditionally alter navigation changes or block navigation. Blocking navigation without a prompt may not be possible in all situations, but should be possible if navigation was triggered from within raviger.

## API Proposals

### Simple

Provide a confirmation prompt that will be presented to the user if they try to navigate. Only active if `predicate` is true.

```typescript
function useInterceptor(prompt: string, predicate = true: boolean) : void
```

### Functional Sync

Provide a function that will receive the current route and a function that will cancel the transition. Must handle syncronously.

```typescript
function useInterceptor(interceptFn: (route: string, cancelFn: () => void) => void) : void
```

### Functional Async

Provide a function that will receive the current route and returns the intended route as a promise. can return the same route to allow, or `null` to cancel.

```typescript
function useInterceptor(interceptFn: (route: string) => Promise<string | null>) : void
```

Pull request:
useNavigationPrompt
closes #23 

add a navigation blocking hook

```typescript
function useNavigationPrompt(predicate = true, prompt?: string): void
```

when `predicate == true` Navigation and page unload is confirmed with a prompt. For page leaving the browser's built in is used, for `navigate` a `window.confirm` is used. When `predicate == false` navigation is not blocked.

# Try it out

this is published under the beta tag, install with

```
npm install raviger@beta

// or
npm install raviger@0.5.9-beta.2
```

# Changes

In `beta.2` the hook was renamed to `useNavigationPrompt` from `useInterceptor` to better describe its actual function. 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6b0241a..83ceb45 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,10 @@ 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).
 
+## [0.5.9] - Unpublished
+### Added
+- `useNavigationPrompt` for confirming navigation changes
+
 ## [0.5.8] - 2019-10-14
 ### Fixed
 - `<Link target="_blank" />` triggering local navigation
diff --git a/docs/content/api/useNavigationPrompt.md b/docs/content/api/useNavigationPrompt.md
new file mode 100644
index 0000000..eaab455
--- /dev/null
+++ b/docs/content/api/useNavigationPrompt.md
@@ -0,0 +1,34 @@
+---
+title: "useNavigationPrompt"
+weight: 7
+date: 2019-10-26T11:32:24-07:00
+---
+
+This hook causes a confirmation to block navigation.
+
+## API
+
+{{< highlight typescript >}}
+export function useNavigationPrompt(
+  predicate = true,
+  prompt?: string
+): void
+{{< /highlight >}}
+
+## Basic
+
+
+If `predicate` is truthy the user will be prompted if they try to navigate away from the page, either by leaving the site or through `<Link>` or `navigate` being invoked.
+
+A standard `prompt` will be used if none is provided. **Note**: due to browser restrictions custom prompts are ignored when the user is trying to leave the site. Custom prompts will always work for in-site navigation from `<Link>` or `navigate` being invoked
+
+{{< highlight jsx >}}
+import React, { useState } from 'react'
+import { useNavigationPrompt, navigate } from 'raviger'
+
+function Form({ isFormDirty }) {
+  // When isFormDirty navigation will cause a confirm dialog
+  useNavigationPrompt(isFormDirty)
+  return (/* */)
+{{< /highlight >}}
+
diff --git a/example/src/App.js b/example/src/App.js
index 747a572..8623ed9 100644
--- a/example/src/App.js
+++ b/example/src/App.js
@@ -1,11 +1,13 @@
 import React from 'react'
 import { useRoutes, Link, usePath, useQueryParams } from '../../src/main.js'
 import Nav from './Nav.js'
+import Form from './Form.js'
 
 const routes = {
   '/': () => <span>Home</span>,
   '/about': () => <span>about</span>,
   '/contact': () => <span>contact</span>,
+  '/form': () => <Form />,
   '/users/:userId': ({ userId }) => <span>User: {userId}</span>,
   '/filter': () => <Filter />,
   '/deep*': () => <Deep />,
@@ -21,6 +23,7 @@ export default function App() {
         <Link href="/">Home</Link>
         <Link href="/about">About</Link>
         <Link href="/contact">Contact</Link>
+        <Link href="/form">Form</Link>
         <Link href="/users/1">Tom</Link>
         <Link href="/users/2">Jane</Link>
         <Link href="/deep">Deep Root</Link>
diff --git a/example/src/Form.js b/example/src/Form.js
new file mode 100644
index 0000000..eb3adae
--- /dev/null
+++ b/example/src/Form.js
@@ -0,0 +1,41 @@
+import React, { useState } from 'react'
+import { useNavigationPrompt, navigate } from '../../src/main.js'
+
+export default function Form() {
+  const [names, setNames] = useState([])
+  const [newName, setName] = useState('')
+  useNavigationPrompt(
+    !!newName.length,
+    'Are you sure you want to leave the name form?'
+  )
+  const handleSubmit = e => {
+    e.preventDefault()
+    setNames(names.concat(newName))
+    setName('')
+  }
+  const saveAndNavigate = () => {
+    setNames(names.concat(newName))
+    navigate('/')
+  }
+  return (
+    <div>
+      <form onSubmit={handleSubmit}>
+        <label>
+          Name
+          <input
+            type="text"
+            value={newName}
+            onChange={e => setName(e.target.value)}
+          />
+        </label>
+        <button type="submit">Add</button>
+        <button onClick={saveAndNavigate}>Save and Leave</button>
+      </form>
+      <ul>
+        {names.map(name => (
+          <li key={name}>{name}</li>
+        ))}
+      </ul>
+    </div>
+  )
+}
diff --git a/package-lock.json b/package-lock.json
index 7c57ecf..d8569c2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
 {
   "name": "raviger",
-  "version": "0.5.8",
+  "version": "0.5.9-beta.1",
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
@@ -8904,9 +8904,9 @@
       }
     },
     "react": {
-      "version": "16.9.0",
-      "resolved": "https://registry.npmjs.org/react/-/react-16.9.0.tgz",
-      "integrity": "sha512-+7LQnFBwkiw+BobzOF6N//BdoNw0ouwmSJTEm9cglOOmsg/TMiFHZLe2sEoN5M7LgJTj9oHH0gxklfnQe66S1w==",
+      "version": "16.11.0",
+      "resolved": "https://registry.npmjs.org/react/-/react-16.11.0.tgz",
+      "integrity": "sha512-M5Y8yITaLmU0ynd0r1Yvfq98Rmll6q8AxaEe88c8e7LxO8fZ2cNgmFt0aGAS9wzf1Ao32NKXtCl+/tVVtkxq6g==",
       "dev": true,
       "requires": {
         "loose-envify": "^1.1.0",
@@ -8915,15 +8915,15 @@
       }
     },
     "react-dom": {
-      "version": "16.9.0",
-      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.9.0.tgz",
-      "integrity": "sha512-YFT2rxO9hM70ewk9jq0y6sQk8cL02xm4+IzYBz75CQGlClQQ1Bxq0nhHF6OtSbit+AIahujJgb/CPRibFkMNJQ==",
+      "version": "16.11.0",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.11.0.tgz",
+      "integrity": "sha512-nrRyIUE1e7j8PaXSPtyRKtz+2y9ubW/ghNgqKFHHAHaeP0fpF5uXR+sq8IMRHC+ZUxw7W9NyCDTBtwWxvkb0iA==",
       "dev": true,
       "requires": {
         "loose-envify": "^1.1.0",
         "object-assign": "^4.1.1",
         "prop-types": "^15.6.2",
-        "scheduler": "^0.15.0"
+        "scheduler": "^0.17.0"
       }
     },
     "react-is": {
@@ -9418,9 +9418,9 @@
       "dev": true
     },
     "scheduler": {
-      "version": "0.15.0",
-      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.15.0.tgz",
-      "integrity": "sha512-xAefmSfN6jqAa7Kuq7LIJY0bwAPG3xlCj0HMEBQk1lxYiDKZscY2xJ5U/61ZTrYbmNQbXa+gc7czPkVo11tnCg==",
+      "version": "0.17.0",
+      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.17.0.tgz",
+      "integrity": "sha512-7rro8Io3tnCPuY4la/NuI5F2yfESpnfZyT6TtkXnSWVkcu0BCDJ+8gk5ozUaFaxpIyNuWAPXrH0yFcSi28fnDA==",
       "dev": true,
       "requires": {
         "loose-envify": "^1.1.0",
diff --git a/package.json b/package.json
index 26f6fe1..ef5583e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "raviger",
-  "version": "0.5.8",
+  "version": "0.5.9-beta.1",
   "description": "React routing with hooks",
   "main": "dist/main.js",
   "module": "dist/module.js",
@@ -63,8 +63,8 @@
     "jest-junit": "^8.0.0",
     "npm-run-all": "^4.1.5",
     "prettier": "^1.18.2",
-    "react": "^16.8",
-    "react-dom": "^16.8",
+    "react": "^16.11.0",
+    "react-dom": "^16.11.0",
     "rollup": "^1.17.0",
     "rollup-plugin-babel": "^4.3.3",
     "rollup-plugin-replace": "^2.2.0",
diff --git a/src/main.js b/src/main.js
index e352f70..8f660e5 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,6 +1,6 @@
 export { useRoutes } from './router.js'
 export { useRedirect } from './redirect.js'
 export { default as Link, ActiveLink } from './Link.js'
-export { navigate } from './navigate.js'
+export { navigate, useNavigationPrompt } from './navigate.js'
 export { usePath, useBasePath, usePopState } from './path.js'
 export { useQueryParams } from './querystring.js'
diff --git a/src/navigate.js b/src/navigate.js
index 9330294..67b676f 100644
--- a/src/navigate.js
+++ b/src/navigate.js
@@ -1,3 +1,9 @@
+import { useEffect } from 'react'
+import { isNode } from './node.js'
+
+const defaultPrompt = 'Are you sure you want to leave this page?'
+const interceptors = new Set()
+
 export function navigate(url, replaceOrQuery = false, replace = false) {
   if (typeof url !== 'string') {
     throw new Error(`"url" must be a string, was provided a(n) ${typeof url}`)
@@ -7,6 +13,7 @@ export function navigate(url, replaceOrQuery = false, replace = false) {
       '"replaceOrQuery" must be boolean, object, or URLSearchParams'
     )
   }
+  if (shouldCancelNavigation()) return
   if (replaceOrQuery !== null && typeof replaceOrQuery === 'object') {
     url += '?' + new URLSearchParams(replaceOrQuery).toString()
   } else {
@@ -15,3 +22,45 @@ export function navigate(url, replaceOrQuery = false, replace = false) {
   window.history[`${replace ? 'replace' : 'push'}State`](null, null, url)
   dispatchEvent(new PopStateEvent('popstate', null))
 }
+
+export function useNavigationPrompt(predicate = true, prompt = defaultPrompt) {
+  if (isNode) return
+  useEffect(() => {
+    const handler = e => {
+      if (predicate) {
+        return e ? cancelNavigation(e, prompt) : prompt
+      }
+    }
+    addInterceptor(handler)
+    return () => removeInterceptor(handler)
+  }, [predicate, prompt])
+}
+
+export function shouldCancelNavigation() {
+  // confirm if any interceptors return true
+  return Array.from(interceptors).some(interceptor => {
+    let prompt = interceptor()
+    if (!prompt) return false
+    // cancel navigation if user declines
+    return !window.confirm(prompt) // eslint-disable-line no-alert
+  })
+}
+
+function addInterceptor(handler) {
+  window.addEventListener('beforeunload', handler)
+  interceptors.add(handler)
+}
+
+function removeInterceptor(handler) {
+  window.removeEventListener('beforeunload', handler)
+  interceptors.delete(handler)
+}
+
+function cancelNavigation(event, prompt) {
+  // Cancel the event as stated by the standard.
+  event.preventDefault()
+  // Chrome requires returnValue to be set.
+  event.returnValue = prompt
+  // Return value for prompt per spec
+  return prompt
+}
diff --git a/types/raviger.d.ts b/types/raviger.d.ts
index 34f732f..4089a86 100644
--- a/types/raviger.d.ts
+++ b/types/raviger.d.ts
@@ -63,3 +63,5 @@ export function useQueryParams<T, Q>(
   parseFn?: (query: string) => T,
   serializeFn?: (query: Q) => string
 ): [T, (query: Q, replace?: boolean) => void]
+
+export function useNavigationPrompt(predicate = true, prompt?: string): void


Test patch:
diff --git a/test/Link.spec.js b/test/Link.spec.js
index 1586317..cd27de5 100644
--- a/test/Link.spec.js
+++ b/test/Link.spec.js
@@ -2,21 +2,8 @@ import React from 'react'
 import { render, act, fireEvent } from '@testing-library/react'
 import { Link, ActiveLink, navigate } from '../src/main.js'
 
-// this is just a little hack to silence a warning that we'll get until we
-// upgrade to 16.9: https://github.com/facebook/react/pull/14853
-const originalError = console.error
-beforeAll(() => {
-  console.error = (...args) => {
-    if (/Warning.*not wrapped in act/.test(args[0])) {
-      return
-    }
-    originalError.call(console, ...args)
-  }
-})
-
 afterAll(() => {
   act(() => navigate('/'))
-  console.error = originalError
 })
 
 describe('Link', () => {
diff --git a/test/navigate.spec.js b/test/navigate.spec.js
new file mode 100644
index 0000000..f042399
--- /dev/null
+++ b/test/navigate.spec.js
@@ -0,0 +1,50 @@
+import React from 'react'
+import { render, act } from '@testing-library/react'
+import { useNavigationPrompt, navigate } from '../src/main.js'
+
+const originalConfirm = window.confirm
+
+afterAll(() => {
+  window.confirm = originalConfirm
+  act(() => navigate('/'))
+})
+
+describe('useNavigationPrompt', () => {
+  function Route({ block = true, prompt }) {
+    useNavigationPrompt(block, prompt)
+    return null
+  }
+  test('navigation does not block when prompt is false', async () => {
+    render(<Route block={false} />)
+    act(() => navigate('/foo'))
+
+    expect(document.location.pathname).toEqual('/foo')
+  })
+
+  test('navigation does not block when prompt is accepted', async () => {
+    window.confirm = jest.fn().mockImplementation(() => true)
+    act(() => navigate('/'))
+    render(<Route block />)
+    act(() => navigate('/foo'))
+
+    expect(document.location.pathname).toEqual('/foo')
+  })
+
+  test('navigation blocks when prompt is declined', async () => {
+    window.confirm = jest.fn().mockImplementation(() => false)
+    act(() => navigate('/'))
+    render(<Route block />)
+    act(() => navigate('/foo'))
+
+    expect(document.location.pathname).toEqual('/')
+  })
+
+  test('navigation is confirmed with custom prompt', async () => {
+    window.confirm = jest.fn().mockImplementation(() => false)
+    act(() => navigate('/'))
+    render(<Route block prompt="custom" />)
+    act(() => navigate('/foo'))
+
+    expect(window.confirm).toHaveBeenCalledWith('custom')
+  })
+})
diff --git a/test/redirect.spec.js b/test/redirect.spec.js
index 5410db1..fc4b56c 100644
--- a/test/redirect.spec.js
+++ b/test/redirect.spec.js
@@ -2,21 +2,8 @@ import React from 'react'
 import { render, act } from '@testing-library/react'
 import { useRedirect, navigate } from '../src/main.js'
 
-// this is just a little hack to silence a warning that we'll get until we
-// upgrade to 16.9: https://github.com/facebook/react/pull/14853
-const originalError = console.error
-beforeAll(() => {
-  console.error = (...args) => {
-    if (/Warning.*not wrapped in act/.test(args[0])) {
-      return
-    }
-    originalError.call(console, ...args)
-  }
-})
-
 afterAll(() => {
   act(() => navigate('/'))
-  console.error = originalError
 })
 
 describe('useRedirect', () => {
diff --git a/test/router.spec.js b/test/router.spec.js
index 1c5f5c7..ac9cddc 100644
--- a/test/router.spec.js
+++ b/test/router.spec.js
@@ -8,21 +8,8 @@ import {
   navigate
 } from '../src/main.js'
 
-// this is just a little hack to silence a warning that we'll get until we
-// upgrade to 16.9: https://github.com/facebook/react/pull/14853
-const originalError = console.error
-beforeAll(() => {
-  console.error = (...args) => {
-    if (/Warning.*not wrapped in act/.test(args[0])) {
-      return
-    }
-    originalError.call(console, ...args)
-  }
-})
-
 afterAll(() => {
   act(() => navigate('/'))
-  console.error = originalError
 })
 
 describe('useRoutes', () => {