From e834283ded825de74f43d659bf13e94923b1296b Mon Sep 17 00:00:00 2001 From: xmaxcooking Date: Mon, 24 Jun 2024 17:24:10 +0200 Subject: [PATCH] feat(router): add useblocker --- packages/router/src/history.tsx | 96 ++++++++++++++++++++++++------- packages/router/src/index.ts | 1 + packages/router/src/link.tsx | 9 +-- packages/router/src/useBlocker.ts | 50 ++++++++++++++++ 4 files changed, 131 insertions(+), 25 deletions(-) create mode 100644 packages/router/src/useBlocker.ts diff --git a/packages/router/src/history.tsx b/packages/router/src/history.tsx index 63a157ac18f3..78a388c2df38 100644 --- a/packages/router/src/history.tsx +++ b/packages/router/src/history.tsx @@ -2,12 +2,19 @@ export interface NavigateOptions { replace?: boolean } +export type BlockerCallback = (tx: { retry: () => void }) => void +type Blocker = { + id: string; + callback: BlockerCallback; +} + const createHistory = () => { type Listener = (ev?: PopStateEvent) => any const listeners: Record = {} + const blockers: Blocker[] = [] - return { + const history = { listen: (listener: Listener) => { const listenerId = 'RW_HISTORY_LISTENER_ID_' + Date.now() listeners[listenerId] = listener @@ -15,31 +22,46 @@ const createHistory = () => { return listenerId }, navigate: (to: string, options?: NavigateOptions) => { - const { pathname, search, hash } = new URL( - globalThis?.location?.origin + to, - ) - - if ( - globalThis?.location?.pathname !== pathname || - globalThis?.location?.search !== search || - globalThis?.location?.hash !== hash - ) { - if (options?.replace) { - globalThis.history.replaceState({}, '', to) - } else { - globalThis.history.pushState({}, '', to) + const performNavigation = () => { + const { pathname, search, hash } = new URL( + globalThis?.location?.origin + to, + ) + + if ( + globalThis?.location?.pathname !== pathname || + globalThis?.location?.search !== search || + globalThis?.location?.hash !== hash + ) { + if (options?.replace) { + globalThis.history.replaceState({}, '', to) + } else { + globalThis.history.pushState({}, '', to) + } + } + + for (const listener of Object.values(listeners)) { + listener() } } - for (const listener of Object.values(listeners)) { - listener() + if (blockers.length > 0) { + processBlockers(0, performNavigation) + } else { + performNavigation() } }, back: () => { - globalThis.history.back() + const performBack = () => { + globalThis.history.back() + for (const listener of Object.values(listeners)) { + listener() + } + } - for (const listener of Object.values(listeners)) { - listener() + if (blockers.length > 0) { + processBlockers(0, performBack) + } else { + performBack() } }, remove: (listenerId: string) => { @@ -53,11 +75,43 @@ const createHistory = () => { ) } }, + block: (id: string, callback: BlockerCallback) => { + const existingBlockerIndex = blockers.findIndex(blocker => blocker.id === id) + if (existingBlockerIndex !== -1) { + blockers[existingBlockerIndex] = { id, callback } + } else { + blockers.push({ id, callback }) + } + }, + unblock: (id: string) => { + const index = blockers.findIndex(blocker => blocker.id === id) + if (index !== -1) { + blockers.splice(index, 1) + } + } } + + const processBlockers = (index: number, navigate: () => void) => { + if (index < blockers.length) { + blockers[index].callback({ + retry: () => processBlockers(index + 1, navigate) + }) + } else { + navigate() + } + } + + globalThis.addEventListener('beforeunload', (event: BeforeUnloadEvent) => { + if (blockers.length > 0) { + event.preventDefault() + } + }) + + return history } const gHistory = createHistory() -const { navigate, back } = gHistory +const { navigate, back, block, unblock } = gHistory -export { gHistory, navigate, back } +export { gHistory, navigate, back, block, unblock } diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index 224f1e66d696..539b7bcf247d 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -25,6 +25,7 @@ export * from './route-focus' export * from './useRouteName' export * from './useRoutePaths' export * from './useMatch' +export * from './useBlocker' export { parseSearch, getRouteRegexAndParams, matchPath } from './util' diff --git a/packages/router/src/link.tsx b/packages/router/src/link.tsx index f3a5737c6a1f..c442358e36c5 100644 --- a/packages/router/src/link.tsx +++ b/packages/router/src/link.tsx @@ -5,17 +5,18 @@ import React, { forwardRef } from 'react' -import { navigate } from './history' +import { NavigateOptions, navigate } from './history' export interface LinkProps { to: string onClick?: React.MouseEventHandler + options?: NavigateOptions } export const Link = forwardRef< HTMLAnchorElement, LinkProps & React.AnchorHTMLAttributes ->(({ to, onClick, ...rest }, ref) => ( +>(({ to, onClick, options, ...rest }, ref) => ( diff --git a/packages/router/src/useBlocker.ts b/packages/router/src/useBlocker.ts new file mode 100644 index 000000000000..9d54c92a1594 --- /dev/null +++ b/packages/router/src/useBlocker.ts @@ -0,0 +1,50 @@ +import { useEffect, useCallback, useState, useRef } from 'react' +import { block, unblock, BlockerCallback } from './history' + +type BlockerState = 'IDLE' | 'BLOCKED' + +interface UseBlockerOptions { + when: boolean +} + +export function useBlocker({ when }: UseBlockerOptions) { + const [blockerState, setBlockerState] = useState('IDLE') + const [pendingNavigation, setPendingNavigation] = useState<(() => void) | null>(null) + const blockerId = useRef(`BLOCKER_${Date.now()}${Math.random()}`) + + const blocker: BlockerCallback = useCallback( + ({ retry }) => { + if (when) { + setBlockerState('BLOCKED') + setPendingNavigation(() => retry) + } else { + retry() + } + }, + [when] + ) + + useEffect(() => { + if (when) { + block(blockerId.current, blocker) + } else { + unblock(blockerId.current) + } + return () => unblock(blockerId.current) + }, [when, blocker]) + + const confirm = useCallback(() => { + setBlockerState('IDLE') + if (pendingNavigation) { + pendingNavigation() + setPendingNavigation(null) + } + }, [pendingNavigation]) + + const abort = useCallback(() => { + setBlockerState('IDLE') + setPendingNavigation(null) + }, []) + + return { state: blockerState, confirm, abort } +}