-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add global 'beforepopstate' bus to intercept browser back/forward navigation (#509) #511
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| import React, { ReactNode } from "react"; | ||
| import { usePopStateBus } from "../../services/beforePopState/usePopStateBus"; | ||
| import { WasPopProvider } from "../services/wasPop/provider"; | ||
|
|
||
| /** | ||
| * ServicesProvider is a component that initializes and provides access to various service-related | ||
| * functionality throughout the application. | ||
| * | ||
| * This provider: | ||
| * 1. Registers the pop state bus to handle browser navigation events. | ||
| * 2. Provides the WasPopProvider context to track browser back/forward navigation | ||
| * | ||
| * This provider should be placed at the _app root level to ensure all components have access to these services. | ||
| */ | ||
|
|
||
| export function ServicesProvider({ | ||
| children, | ||
| }: { | ||
| children: ReactNode; | ||
| }): JSX.Element { | ||
| // Register the pop state bus. | ||
| usePopStateBus(); | ||
|
|
||
| return <WasPopProvider>{children}</WasPopProvider>; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import { createContext } from "react"; | ||
| import { WasPopContextProps } from "./types"; | ||
|
|
||
| export const WasPopContext = createContext<WasPopContextProps>({ | ||
| onClearPopRef: () => {}, | ||
| popRef: { current: undefined }, | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import { useContext } from "react"; | ||
| import { WasPopContext } from "./context"; | ||
| import { WasPopContextProps } from "./types"; | ||
|
|
||
| export const useWasPop = (): WasPopContextProps => { | ||
| return useContext(WasPopContext); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| import React, { ReactNode, useCallback, useRef } from "react"; | ||
| import { NextHistoryState } from "../../../services/beforePopState/types"; | ||
| import { useOnPopState } from "../../../services/beforePopState/useOnPopState"; | ||
| import { WasPopContext } from "./context"; | ||
|
|
||
| /** | ||
| * WasPopProvider tracks browser navigation events to determine if the current route change | ||
| * was triggered by a popstate event (browser back/forward navigation). | ||
| * | ||
| * This provider: | ||
| * 1. Registers callbacks for route change events. | ||
| * 2. Tracks when navigation occurs via browser back/forward buttons | ||
| * 3. Provides this state via context to child components | ||
| * | ||
| * This allows components to respond differently to user-initiated navigation versus | ||
| * browser history navigation. | ||
| */ | ||
|
|
||
| export function WasPopProvider({ | ||
| children, | ||
| }: { | ||
| children: ReactNode; | ||
| }): JSX.Element { | ||
| const popRef = useRef<NextHistoryState | undefined>(); | ||
|
|
||
| // Pop callback. | ||
| const onBeforePopState = useCallback((state: NextHistoryState) => { | ||
| popRef.current = state; | ||
| return true; | ||
| }, []); | ||
|
|
||
| // Clear pop ref. | ||
| const onClearPopRef = useCallback(() => { | ||
| popRef.current = undefined; | ||
| }, []); | ||
|
|
||
| // Register the callback to be invoked before pop. | ||
| useOnPopState(onBeforePopState); | ||
|
|
||
| return ( | ||
| <WasPopContext.Provider value={{ onClearPopRef, popRef }}> | ||
| {children} | ||
| </WasPopContext.Provider> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import { MutableRefObject } from "react"; | ||
| import { NextHistoryState } from "../../../services/beforePopState/types"; | ||
|
|
||
| export interface WasPopContextProps { | ||
| onClearPopRef: () => void; | ||
| popRef: MutableRefObject<NextHistoryState | undefined>; | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,64 @@ | ||||||||||||||||||||||||||||||||||||
| import Router from "next/router"; | ||||||||||||||||||||||||||||||||||||
| import { BeforePopStateCallback, NextHistoryState } from "./types"; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||
| * Pop‐State Event Bus | ||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||
| * Provides a centralized mechanism for components to intercept | ||||||||||||||||||||||||||||||||||||
| * and optionally prevent Back/Forward navigation (Next.js beforePopState). | ||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||
| * A set of callback functions that will run before Next.js performs a pop. | ||||||||||||||||||||||||||||||||||||
| * Each callback returns `true` to allow navigation or `false` to block. | ||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||
| const beforePopCallbacks = new Set<BeforePopStateCallback>(); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||
| * Register a callback to be invoked immediately before any Next.js pop navigation. | ||||||||||||||||||||||||||||||||||||
| * Return `false` from your callback to prevent the pop; otherwise return `true`. | ||||||||||||||||||||||||||||||||||||
| * @param cb - The callback function to register. | ||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||
| export function registerBeforePopCallback(cb: BeforePopStateCallback): void { | ||||||||||||||||||||||||||||||||||||
| beforePopCallbacks.add(cb); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||
| * Unregister a previously registered “before pop” callback. | ||||||||||||||||||||||||||||||||||||
| * @param cb - The callback function to unregister. | ||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||
| export function unregisterBeforePopCallback(cb: BeforePopStateCallback): void { | ||||||||||||||||||||||||||||||||||||
| beforePopCallbacks.delete(cb); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||
| * Ensures that we only hook into Next.js once. After install, any pop event | ||||||||||||||||||||||||||||||||||||
| * will first invoke all registered callbacks and only proceed if all return true. | ||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||
| let hasInstalledInterceptor = false; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||
| * Install the global “before pop” interceptor into Next.js’s router. | ||||||||||||||||||||||||||||||||||||
| * Subsequent calls to this function will be no‐ops. | ||||||||||||||||||||||||||||||||||||
| * | ||||||||||||||||||||||||||||||||||||
| * This method must be called once (e.g. in your app’s top‐level code) | ||||||||||||||||||||||||||||||||||||
| * to enable the pop‐state bus. | ||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||
| export function registerPopStateHandler(): void { | ||||||||||||||||||||||||||||||||||||
| if (hasInstalledInterceptor) return; | ||||||||||||||||||||||||||||||||||||
| hasInstalledInterceptor = true; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| Router.beforePopState((state: NextHistoryState) => { | ||||||||||||||||||||||||||||||||||||
| // Iteratively call every callback. If any returns false, block navigation. | ||||||||||||||||||||||||||||||||||||
| let allAllow = true; | ||||||||||||||||||||||||||||||||||||
| beforePopCallbacks.forEach((cb) => { | ||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||
| if (cb(state) === false) allAllow = false; | ||||||||||||||||||||||||||||||||||||
| } catch (e: unknown) { | ||||||||||||||||||||||||||||||||||||
| console.error("Pop listener failed:", e); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+54
to
+60
|
||||||||||||||||||||||||||||||||||||
| beforePopCallbacks.forEach((cb) => { | |
| try { | |
| if (cb(state) === false) allAllow = false; | |
| } catch (e: unknown) { | |
| console.error("Pop listener failed:", e); | |
| } | |
| }); | |
| for (const cb of beforePopCallbacks) { | |
| try { | |
| if (cb(state) === false) { | |
| allAllow = false; | |
| break; | |
| } | |
| } catch (e: unknown) { | |
| console.error("Pop listener failed:", e); | |
| } | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We might have the case where a "downstream" listeners with an important side-effect never runs... leaving as is!
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import Router from "next/router"; | ||
|
|
||
| /** | ||
| * Type representing the callback function passed to beforePopState. | ||
| * Extracted from the Next.js Router.beforePopState API. | ||
| */ | ||
| export type BeforePopStateCallback = Parameters< | ||
| typeof Router.beforePopState | ||
| >[0]; | ||
|
|
||
| /** | ||
| * Type representing the state passed to beforePopState. | ||
| * Extracted from the Next.js Router.beforePopState API. | ||
| */ | ||
| export type NextHistoryState = Parameters<BeforePopStateCallback>[0]; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { useEffect } from "react"; | ||
| import { | ||
| registerBeforePopCallback, | ||
| unregisterBeforePopCallback, | ||
| } from "./popStateBus"; | ||
| import { BeforePopStateCallback } from "./types"; | ||
|
|
||
| export const useOnPopState = (cb: BeforePopStateCallback): void => { | ||
| useEffect(() => { | ||
| registerBeforePopCallback(cb); | ||
| return (): void => { | ||
| unregisterBeforePopCallback(cb); | ||
| }; | ||
| }, [cb]); | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| import { useEffect } from "react"; | ||
| import { registerPopStateHandler } from "./popStateBus"; | ||
|
|
||
| /** | ||
| * Registers the single global `router.beforePopState` handler that | ||
| * fans out to all feature listeners. | ||
| * Safe to call multiple times - only the first invocation does the real install. | ||
| */ | ||
|
|
||
| export const usePopStateBus = (): void => { | ||
| useEffect(() => { | ||
| registerPopStateHandler(); | ||
| }, []); | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The import path can be simplified to "./wasPop/provider" to avoid the extra directory traversal and improve readability.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good pick up thank you!