UNPKG

one

Version:

One is a new React Framework that makes Vite serve both native and web.

341 lines (294 loc) 9.04 kB
import * as React from 'react' import { Platform } from 'react-native' export type BlockerState = 'unblocked' | 'blocked' | 'proceeding' export type BlockerFunction = (args: { currentLocation: string nextLocation: string historyAction: 'push' | 'pop' | 'replace' }) => boolean export type Blocker = | { state: 'unblocked' reset?: undefined proceed?: undefined location?: undefined } | { state: 'blocked' reset: () => void proceed: () => void location: string } | { state: 'proceeding' reset?: undefined proceed?: undefined location: string } /** * Stored pending navigation that was blocked. * We need to restore it when the user confirms. */ type PendingNavigation = { previousLocation: string nextLocation: string historyAction: 'push' | 'pop' | 'replace' } // Global state for blocking let currentLocation = typeof window !== 'undefined' ? window.location.pathname + window.location.search : '' let isBlocking = false let isProceeding = false let pendingNavigation: PendingNavigation | null = null // Active blocker callbacks const blockerCallbacks = new Map< symbol, { shouldBlock: () => boolean onBlock: (pending: PendingNavigation) => void onProceed: () => void onReset: () => void } >() // Track if global listeners are set up let listenersSetup = false function setupListeners() { if (listenersSetup || typeof window === 'undefined') return listenersSetup = true // Track current location on page load currentLocation = window.location.pathname + window.location.search // Listen for popstate (browser back/forward) window.addEventListener('popstate', () => { if (isProceeding) return const nextLocation = window.location.pathname + window.location.search // Check if any blocker wants to block for (const [, callbacks] of blockerCallbacks) { if (callbacks.shouldBlock()) { // Block the navigation isBlocking = true pendingNavigation = { previousLocation: currentLocation, nextLocation, historyAction: 'pop', } // Restore the previous URL immediately using history.forward() or history.back() // We pushed a state when we got here, so we need to go back window.history.go(1) // Go forward to restore // Notify the blocker callbacks.onBlock(pendingNavigation) return } } // No blocking, update current location currentLocation = nextLocation }) // Intercept pushState and replaceState const originalPushState = window.history.pushState.bind(window.history) const originalReplaceState = window.history.replaceState.bind(window.history) window.history.pushState = function (state, title, url) { if (isProceeding || !url) { return originalPushState(state, title, url) } const nextLocation = typeof url === 'string' ? url : url.toString() for (const [, callbacks] of blockerCallbacks) { if (callbacks.shouldBlock()) { isBlocking = true pendingNavigation = { previousLocation: currentLocation, nextLocation, historyAction: 'push', } callbacks.onBlock(pendingNavigation) return // Don't call original } } currentLocation = nextLocation return originalPushState(state, title, url) } window.history.replaceState = function (state, title, url) { if (isProceeding || !url) { return originalReplaceState(state, title, url) } const nextLocation = typeof url === 'string' ? url : url.toString() for (const [, callbacks] of blockerCallbacks) { if (callbacks.shouldBlock()) { isBlocking = true pendingNavigation = { previousLocation: currentLocation, nextLocation, historyAction: 'replace', } callbacks.onBlock(pendingNavigation) return // Don't call original } } currentLocation = nextLocation return originalReplaceState(state, title, url) } // Handle beforeunload (page close/refresh) window.addEventListener('beforeunload', (event) => { for (const [, callbacks] of blockerCallbacks) { if (callbacks.shouldBlock()) { event.preventDefault() event.returnValue = '' return } } }) } /** * Block navigation when a condition is met. * * This is useful for preventing users from accidentally leaving a page with unsaved changes. * Works with both browser navigation (back/forward, URL changes) and programmatic navigation. * * @param shouldBlock - Either a boolean or a function that returns whether to block. * When using a function, you receive the current and next locations and can make dynamic decisions. * * @example * ```tsx * function EditForm() { * const [isDirty, setIsDirty] = useState(false) * const blocker = useBlocker(isDirty) * * return ( * <> * <form onChange={() => setIsDirty(true)}> * {// form fields} * </form> * * {blocker.state === 'blocked' && ( * <Dialog> * <p>You have unsaved changes. Leave anyway?</p> * <button onClick={blocker.reset}>Stay</button> * <button onClick={blocker.proceed}>Leave</button> * </Dialog> * )} * </> * ) * } * ``` * * @example * ```tsx * // Function-based blocking with location info * const blocker = useBlocker(({ currentLocation, nextLocation }) => { * // Only block when leaving this specific section * return currentLocation.startsWith('/edit') && !nextLocation.startsWith('/edit') * }) * ``` */ export function useBlocker(shouldBlock: BlockerFunction | boolean): Blocker { const [state, setState] = React.useState<BlockerState>('unblocked') const [blockedLocation, setBlockedLocation] = React.useState<string | null>(null) const idRef = React.useRef<symbol | null>(null) const shouldBlockRef = React.useRef(shouldBlock) shouldBlockRef.current = shouldBlock React.useEffect(() => { // Only run on web if (Platform.OS !== 'web' || typeof window === 'undefined') return setupListeners() const id = Symbol('blocker') idRef.current = id blockerCallbacks.set(id, { shouldBlock: () => { const block = shouldBlockRef.current if (typeof block === 'function') { return block({ currentLocation, nextLocation: pendingNavigation?.nextLocation || '', historyAction: pendingNavigation?.historyAction || 'push', }) } return block }, onBlock: (pending) => { setBlockedLocation(pending.nextLocation) setState('blocked') }, onProceed: () => { setState('proceeding') }, onReset: () => { setState('unblocked') setBlockedLocation(null) }, }) return () => { blockerCallbacks.delete(id) } }, []) const reset = React.useCallback(() => { isBlocking = false pendingNavigation = null setBlockedLocation(null) setState('unblocked') }, []) const proceed = React.useCallback(() => { if (!pendingNavigation) return setState('proceeding') isProceeding = true const pending = pendingNavigation pendingNavigation = null isBlocking = false // Execute the blocked navigation requestAnimationFrame(() => { if (pending.historyAction === 'pop') { // Go back to where the user wanted to go window.history.back() } else if (pending.historyAction === 'push') { window.history.pushState(null, '', pending.nextLocation) } else { window.history.replaceState(null, '', pending.nextLocation) } currentLocation = pending.nextLocation // Reset state after navigation requestAnimationFrame(() => { isProceeding = false setBlockedLocation(null) setState('unblocked') }) }) }, []) if (state === 'unblocked') { return { state: 'unblocked' } } if (state === 'proceeding') { return { state: 'proceeding', location: blockedLocation! } } return { state: 'blocked', reset, proceed, location: blockedLocation!, } } /** * Check if any active blocker wants to block navigation. * Called by the router before navigating via Link. * Returns true if navigation was blocked. */ export function checkBlocker( nextLocation: string, historyAction: 'push' | 'pop' | 'replace' = 'push' ): boolean { if (Platform.OS !== 'web' || typeof window === 'undefined') { return false } if (isProceeding) { return false } for (const [, callbacks] of blockerCallbacks) { if (callbacks.shouldBlock()) { isBlocking = true pendingNavigation = { previousLocation: currentLocation, nextLocation, historyAction, } callbacks.onBlock(pendingNavigation) return true } } return false }