Skip to content

Commit

Permalink
Add useUnloadConfirmation hook to control preventing of unloads (#25)
Browse files Browse the repository at this point in the history
* Upgraded dependencies

* Added support for preventing navigation with `useUnloadConfirmation` hook

* Fixed router state changing if no event state exists

* init stateCount to current history state

* Fixed tests

* Fixed edge case where event state is null

* Swapped to set and used routercontext instead of cache
Added clearUnloadInterceptors

* Removed console log

* Removed hook in favour of custom builder

* Removed console log

* Removed hacky clear function

* Added simple test for beforeUnloader
  • Loading branch information
benjackwhite authored Jun 10, 2022
1 parent 55b655d commit fd6b683
Show file tree
Hide file tree
Showing 7 changed files with 290 additions and 27 deletions.
9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"build": "kea-typegen write && tsc -b",
"lint": "eslint src/**",
"prepublishOnly": "npm run test && npm run build",
"start": "npm run watch",
"start": "watch 'npm run build' ./src",
"test": "BABEL_ENV=test jest"
},
"files": [
Expand Down Expand Up @@ -85,16 +85,17 @@
"jest": "^26.0.1",
"jest-environment-node-debug": "^2.0.0",
"jsdom": "^16.2.2",
"kea": "^3.0.0-alpha.1",
"kea-typegen": "^3.0.0-alpha.0",
"kea": "^3.0.1",
"kea-typegen": "^3.1.0",
"prop-types": "^15.7.2",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-redux": "^7.2.0",
"react-test-renderer": "^16.13.1",
"redux": "^4.0.5",
"reselect": "^4.0.0",
"typescript": "^4.6.3"
"typescript": "^4.6.3",
"watch": "^1.0.2"
},
"dependencies": {
"url-pattern": "^1.0.3"
Expand Down
86 changes: 85 additions & 1 deletion src/__tests__/router.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
/* global test, expect */
import { kea, resetContext } from 'kea'
import { kea, actions, reducers, resetContext } from 'kea'

import '@babel/polyfill'

import { routerPlugin } from '../plugin'
import { parsePath } from '../utils'
import { router } from '../router'
import { urlToAction, actionToUrl, beforeUnload } from '../builders'

window.confirm = jest.fn()

test('urlToAction and actionToUrl work', async () => {
const location = {
Expand Down Expand Up @@ -524,3 +527,84 @@ test('urlPatternOptions', async () => {
expect(location.pathname).toBe('/pages')
expect(logic.values.activePage).toBe(null)
})

test('beforeUnload', async () => {
const location = {
pathname: '/pages/first',
search: '',
hash: '',
}

const onConirmFn = jest.fn()

const history = {
pushState(state, _, url) {
Object.assign(location, parsePath(url))
},
replaceState(state, _, url) {
Object.assign(location, parsePath(url))
},
}

resetContext({
plugins: [routerPlugin({ history, location })],
createStore: { middleware: [] },
})

const logic = kea([
actions(() => ({
page: (page) => ({ page }),
preventUnload: (preventUnload) => ({ preventUnload }),
})),

reducers(({ actions }) => ({
preventUnload: [
false,
{
[actions.preventUnload]: (_, { preventUnload }) => preventUnload,
},
],
})),

urlToAction(({ actions }) => ({
'/pages/:page': ({ page }, __, ___, payload, previousLocation) => {
actions.page(page)
},
})),

actionToUrl(({ actions }) => ({
[actions.page]: ({ page }) => `/pages/${page}`,
})),

beforeUnload(({ values }) => ({
enabled: () => values.preventUnload,
message: "You're not going anywhere!",
onConfirm: onConirmFn,
})),
])

const unmount = logic.mount()

expect(location.pathname).toBe('/pages/first')
expect(logic.values.preventUnload).toBe(false)

logic.actions.page('prevent')
logic.actions.preventUnload(true)

expect(location.pathname).toBe('/pages/prevent')
expect(logic.values.preventUnload).toBe(true)

// Doesn't navigate if denied
window.confirm.mockReturnValueOnce(false)
logic.actions.page('first')
expect(location.pathname).toBe('/pages/prevent')
expect(onConirmFn).not.toBeCalled()

// Does navigate if confirmed
window.confirm.mockReturnValueOnce(true)
logic.actions.page('first')
expect(location.pathname).toBe('/pages/first')
expect(onConirmFn).toBeCalled()

unmount()
})
49 changes: 47 additions & 2 deletions src/builders.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { getRouterContext, router } from './router'
import UrlPattern from 'url-pattern'
import { ActionToUrlPayload, LocationChangedPayload, UrlToActionPayload } from './types'
import {
ActionToUrlPayload,
BeforeUnloadPayload,
LocationChangedPayload,
RouterBeforeUnloadInterceptor,
UrlToActionPayload,
} from './types'
import { stringOrObjectToString } from './utils'
import { afterMount, BuiltLogic, connect, getContext, listeners, Logic, LogicBuilder } from 'kea'
import { afterMount, beforeUnmount, BuiltLogic, connect, getContext, listeners, Logic, LogicBuilder } from 'kea'

function assureConnectionToRouter<L extends Logic = Logic>(logic: BuiltLogic<L>) {
if (!logic.connections[router.pathString]) {
Expand Down Expand Up @@ -138,3 +144,42 @@ export function urlToAction<L extends Logic = Logic>(
})(logic)
}
}

/**
beforeUnload - when enabled prevent navigation with a confrimation popup
kea([
beforeUnload(({ actions, values }) => ({
enabled: values.formChanged,
message: "Your changes will be lost. Are you sure you want to leave?"
onConfirm: () => actions.resetForm()
})),
])
*/
export function beforeUnload<L extends Logic = Logic>(
input: BeforeUnloadPayload<L> | ((logic: BuiltLogic<L>) => BeforeUnloadPayload<L>),
): LogicBuilder<L> {
return (logic) => {
const config = typeof input === 'function' ? input(logic) : input

const beforeWindowUnloadHandler = (e: BeforeUnloadEvent): void => {
if (config.enabled()) {
e.preventDefault()
e.returnValue = config.message
}
}

afterMount(() => {
const { beforeUnloadInterceptors } = getRouterContext()

beforeUnloadInterceptors.add(config)
window.addEventListener('beforeunload', beforeWindowUnloadHandler)
})(logic)

beforeUnmount(() => {
const { beforeUnloadInterceptors } = getRouterContext()

beforeUnloadInterceptors.delete(config)
window.removeEventListener('beforeunload', beforeWindowUnloadHandler)
})(logic)
}
}
10 changes: 8 additions & 2 deletions src/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { KeaPlugin } from 'kea'
import { router, setRouterContext } from './router'
import { encodeParams as encode, decodeParams as decode, stringOrObjectToString } from './utils'
import { encodeParams as encode, decodeParams as decode } from './utils'
import { RouterPluginContext, RouterPluginOptions } from './types'
import { actionToUrl, urlToAction } from './builders'
import { actionToUrl, beforeUnload, urlToAction } from './builders'

const memoryHistroy = {
pushState(state, _, url) {},
Expand All @@ -24,6 +24,11 @@ export function routerPlugin(options: RouterPluginOptions = {}): KeaPlugin {
pathFromRoutesToWindow: (path) => path,
pathFromWindowToRoutes: (path) => path,
options,
beforeUnloadInterceptors: new Set(),
historyStateCount:
typeof window !== 'undefined' && typeof window.history.state?.count === 'number'
? window.history.state?.count
: null,
})
},

Expand All @@ -34,6 +39,7 @@ export function routerPlugin(options: RouterPluginOptions = {}): KeaPlugin {
legacyBuild(logic, input) {
'urlToAction' in input && input.urlToAction && urlToAction(input.urlToAction)(logic)
'actionToUrl' in input && input.actionToUrl && actionToUrl(input.actionToUrl)(logic)
'beforeUnload' in input && input.beforeUnload && beforeUnload(input.beforeUnload)(logic)
},
},
}
Expand Down
57 changes: 54 additions & 3 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,29 @@ import { combineUrl } from './utils'
import { routerType } from './routerType'
import { LocationChangedPayload, RouterPluginContext } from './types'

function preventUnload(): boolean {
// We only check the last reference for unloading. Generally there should only be one loaded anyway.
const { beforeUnloadInterceptors } = getRouterContext()

if (!beforeUnloadInterceptors) {
return
}

for (const beforeUnload of Array.from(beforeUnloadInterceptors)) {
if (!beforeUnload.enabled()) {
continue
}

if (confirm(beforeUnload.message)) {
beforeUnload.onConfirm?.()
return false
}
return true
}

return false
}

export const router = kea<routerType>([
path(['kea', 'router']),

Expand Down Expand Up @@ -86,10 +109,16 @@ export const router = kea<routerType>([
sharedListeners(({ actions }) => ({
updateLocation: ({ url, searchInput, hashInput }, breakpoint, action) => {
const method: 'push' | 'replace' = action.type === actions.push.toString() ? 'push' : 'replace'
const { history } = getRouterContext()
const routerContext = getRouterContext()
const { history } = routerContext
const response = combineUrl(url, searchInput, hashInput)

history[`${method}State` as 'pushState' | 'replaceState']({}, '', response.url)
if (preventUnload()) {
return
}

routerContext.historyStateCount = (routerContext.historyStateCount ?? 0) + 1
history[`${method}State`]({ count: routerContext.historyStateCount }, '', response.url)
actions.locationChanged({ method: method.toUpperCase() as 'PUSH' | 'REPLACE', ...response })
},
})),
Expand All @@ -105,7 +134,29 @@ export const router = kea<routerType>([
}

cache.popListener = (event: PopStateEvent) => {
const { location, decodeParams } = getRouterContext()
const routerContext = getRouterContext()
const { location, decodeParams } = routerContext

const eventStateCount = event.state?.count

if (eventStateCount !== routerContext.historyStateCount && preventUnload()) {
if (typeof eventStateCount !== 'number' || routerContext.historyStateCount === null) {
// If we can't determine the direction then we just live with the url being wrong
return
}
if (eventStateCount < routerContext.historyStateCount) {
routerContext.historyStateCount = eventStateCount + 1 // Account for page reloads
history.forward()
} else {
routerContext.historyStateCount = eventStateCount - 1 // Account for page reloads
history.back()
}
return
}

routerContext.historyStateCount =
typeof eventStateCount === 'number' ? eventStateCount : routerContext.historyStateCount

if (location) {
actions.locationChanged({
method: 'POP',
Expand Down
11 changes: 11 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Logic } from 'kea'
import { MutableRefObject } from 'react'

export interface RouterPluginOptions {
history?: undefined
Expand All @@ -20,7 +21,15 @@ export interface RouterPluginContext {
pathFromWindowToRoutes: (path: string) => string
encodeParams: (obj: Record<string, any>, symbol: string) => string
decodeParams: (input: string, symbol: string) => Record<string, any>
historyStateCount: number
options: RouterPluginOptions
beforeUnloadInterceptors: Set<RouterBeforeUnloadInterceptor>
}

export interface RouterBeforeUnloadInterceptor {
enabled: () => boolean
message: string
onConfirm?: () => void
}

// from node_modules/url-pattern/index.d.ts
Expand Down Expand Up @@ -97,3 +106,5 @@ export type ActionToUrlPayload<L extends Logic = Logic> = {
{ replace?: boolean },
]
}

export type BeforeUnloadPayload<L extends Logic = Logic> = RouterBeforeUnloadInterceptor
Loading

0 comments on commit fd6b683

Please sign in to comment.