Skip to content

Commit

Permalink
add support for back/forward interception (#92)
Browse files Browse the repository at this point in the history
  • Loading branch information
kyeotic authored Jun 18, 2021
1 parent 337c175 commit c36bff7
Show file tree
Hide file tree
Showing 5 changed files with 65 additions and 27 deletions.
12 changes: 8 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,23 @@ 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.3.0] - 2021-06-18
### Changed
- `useNavigationPrompt` now intercepts browser back/forward button navigation

## [2.2.0] - 2021-06-07
### Changed
- Added support for React@17 in `peerDependencies`
-
- Added support for React@17 in `peerDependencies`

## [2.1.0] - 2021-05-02
### Added
- `options.onInitial` parameter for `useLocationChange` that controls the first render behavior. `default: false`.
- `options.onInitial` parameter for `useLocationChange` that controls the first render behavior. `default: false`.
### Fixed
- `useLocationChange` invoking the setter on initial render. This was not intended and was an unannounced change from the v1 behavior, so reverting it is not considered an API change but a bugfix.

## [2.0.2] - 2021-03-22
### Added
- `state` parameter for `navigate`
- `state` parameter for `navigate`

## [2.0.1] - 2021-01-07
### Removed
Expand Down
35 changes: 35 additions & 0 deletions src/intercept.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
const interceptors = new Set()

export const defaultPrompt = 'Are you sure you want to leave this page?'

let hasIntercepted = false
let hasUserCancelled = false

export function shouldCancelNavigation() {
if (hasIntercepted) return hasUserCancelled
// confirm if any interceptors return true
return Array.from(interceptors).some(interceptor => {
const prompt = interceptor()
if (!prompt) return false
// cancel navigation if user declines
hasUserCancelled = !window.confirm(prompt) // eslint-disable-line no-alert
// track user response so that multiple interceptors don't prompt
hasIntercepted = true
// reset so that future navigation attempts are prompted
setTimeout(() => {
hasIntercepted = false
hasUserCancelled = false
}, 5)
return hasUserCancelled
})
}

export function addInterceptor(handler) {
window.addEventListener('beforeunload', handler)
interceptors.add(handler)
}

export function removeInterceptor(handler) {
window.removeEventListener('beforeunload', handler)
interceptors.delete(handler)
}
39 changes: 17 additions & 22 deletions src/navigate.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { useCallback, useLayoutEffect } from 'react'
import { isNode } from './node.js'
import { useBasePath } from './path.js'
import {
shouldCancelNavigation,
addInterceptor,
removeInterceptor,
defaultPrompt
} from './intercept'

const defaultPrompt = 'Are you sure you want to leave this page?'
const interceptors = new Set()
let lastPath = ''

export function navigate(url, replaceOrQuery, replace, state = null) {
if (typeof url !== 'string') {
Expand All @@ -22,12 +27,22 @@ export function navigate(url, replaceOrQuery, replace, state = null) {
} else if (replace === undefined && replaceOrQuery === undefined) {
replace = false
}
lastPath = url
window.history[`${replace ? 'replace' : 'push'}State`](state, null, url)
dispatchEvent(new PopStateEvent('popstate', null))
}

export function useNavigationPrompt(predicate = true, prompt = defaultPrompt) {
if (isNode) return
useLayoutEffect(() => {
const onPopStateNavigation = () => {
if (shouldCancelNavigation()) {
window.history.pushState(null, null, lastPath)
}
}
window.addEventListener('popstate', onPopStateNavigation)
return () => window.removeEventListener('popstate', onPopStateNavigation)
}, [])
useLayoutEffect(() => {
const handler = e => {
if (predicate) {
Expand All @@ -39,26 +54,6 @@ export function useNavigationPrompt(predicate = true, prompt = defaultPrompt) {
}, [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()
Expand Down
2 changes: 2 additions & 0 deletions src/path.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { BasePathContext, PathContext } from './context.js'
import { useMountedLayout } from './hooks.js'
import { isNode, getSsrPath } from './node.js'
import { isFunction } from './typeChecks.js'
import { shouldCancelNavigation } from './intercept'

export function usePath(basePath) {
const contextPath = useContext(PathContext)
Expand Down Expand Up @@ -82,6 +83,7 @@ export function useLocationChange(
const onPopState = useCallback(() => {
// No predicate defaults true
if (isActive !== undefined && !isPredicateActive(isActive)) return
if (shouldCancelNavigation()) return
// console.log('loc', basePath || 'none', getFormattedPath(basePath))
setRef.current(getFormattedPath(basePath))
}, [isActive, basePath])
Expand Down
4 changes: 3 additions & 1 deletion test/navigate.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ beforeEach(() => {
act(() => navigate('/'))
})

afterEach(() => {
afterEach(async () => {
window.confirm = originalConfirm
window.history.replaceState = originalReplaceState
window.history.pushState = originalPushState
// We must wait for the intercept reset op
return new Promise(resolve => setTimeout(() => resolve(), 7))
})

describe('useNavigate', () => {
Expand Down

0 comments on commit c36bff7

Please sign in to comment.