From fb5be66329ca8c6b0ebb4f04264a3e10058f3427 Mon Sep 17 00:00:00 2001 From: Fran McDade Date: Tue, 3 Jun 2025 00:21:49 +1000 Subject: [PATCH 1/3] feat: add global 'beforepopstate' bus to intercept browser back/forward navigation (#509) --- src/providers/services/provider.tsx | 25 ++++++++ src/providers/services/wasPop/context.ts | 6 ++ src/providers/services/wasPop/hook.ts | 7 ++ src/providers/services/wasPop/provider.tsx | 58 +++++++++++++++++ src/providers/services/wasPop/types.ts | 3 + src/services/beforePopState/popStateBus.ts | 64 +++++++++++++++++++ src/services/beforePopState/types.ts | 15 +++++ src/services/beforePopState/useOnPopState.ts | 15 +++++ src/services/beforePopState/usePopStateBus.ts | 14 ++++ 9 files changed, 207 insertions(+) create mode 100644 src/providers/services/provider.tsx create mode 100644 src/providers/services/wasPop/context.ts create mode 100644 src/providers/services/wasPop/hook.ts create mode 100644 src/providers/services/wasPop/provider.tsx create mode 100644 src/providers/services/wasPop/types.ts create mode 100644 src/services/beforePopState/popStateBus.ts create mode 100644 src/services/beforePopState/types.ts create mode 100644 src/services/beforePopState/useOnPopState.ts create mode 100644 src/services/beforePopState/usePopStateBus.ts diff --git a/src/providers/services/provider.tsx b/src/providers/services/provider.tsx new file mode 100644 index 00000000..d259f112 --- /dev/null +++ b/src/providers/services/provider.tsx @@ -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 {children}; +} diff --git a/src/providers/services/wasPop/context.ts b/src/providers/services/wasPop/context.ts new file mode 100644 index 00000000..14e8d88a --- /dev/null +++ b/src/providers/services/wasPop/context.ts @@ -0,0 +1,6 @@ +import { createContext } from "react"; +import { WasPopContextProps } from "./types"; + +export const WasPopContext = createContext({ + wasPop: false, +}); diff --git a/src/providers/services/wasPop/hook.ts b/src/providers/services/wasPop/hook.ts new file mode 100644 index 00000000..d63284d5 --- /dev/null +++ b/src/providers/services/wasPop/hook.ts @@ -0,0 +1,7 @@ +import { useContext } from "react"; +import { WasPopContext } from "./context"; +import { WasPopContextProps } from "./types"; + +export const useWasPop = (): WasPopContextProps => { + return useContext(WasPopContext); +}; diff --git a/src/providers/services/wasPop/provider.tsx b/src/providers/services/wasPop/provider.tsx new file mode 100644 index 00000000..b9b73be4 --- /dev/null +++ b/src/providers/services/wasPop/provider.tsx @@ -0,0 +1,58 @@ +import { Router } from "next/router"; +import React, { ReactNode, useCallback, useEffect, useRef } from "react"; +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 wasPopRef = useRef(false); + + const onBeforePopState = useCallback(() => { + wasPopRef.current = true; + return true; + }, []); + + const onRouteChangeComplete = useCallback(() => { + wasPopRef.current = false; + }, []); + + const onRouteChangeStart = useCallback(() => { + if (wasPopRef.current) return; + wasPopRef.current = false; + }, []); + + // Register the callback to be invoked before pop. + useOnPopState(onBeforePopState); + + useEffect(() => { + Router.events.on("routeChangeStart", onRouteChangeStart); + Router.events.on("routeChangeComplete", onRouteChangeComplete); + + return (): void => { + Router.events.off("routeChangeStart", onRouteChangeStart); + Router.events.off("routeChangeComplete", onRouteChangeComplete); + }; + }, [onRouteChangeComplete, onRouteChangeStart]); + + return ( + + {children} + + ); +} diff --git a/src/providers/services/wasPop/types.ts b/src/providers/services/wasPop/types.ts new file mode 100644 index 00000000..1c771811 --- /dev/null +++ b/src/providers/services/wasPop/types.ts @@ -0,0 +1,3 @@ +export interface WasPopContextProps { + wasPop: boolean; +} diff --git a/src/services/beforePopState/popStateBus.ts b/src/services/beforePopState/popStateBus.ts new file mode 100644 index 00000000..0e58f0ba --- /dev/null +++ b/src/services/beforePopState/popStateBus.ts @@ -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(); + +/** + * 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); + } + }); + + return allAllow; + }); +} diff --git a/src/services/beforePopState/types.ts b/src/services/beforePopState/types.ts new file mode 100644 index 00000000..e02399de --- /dev/null +++ b/src/services/beforePopState/types.ts @@ -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[0]; diff --git a/src/services/beforePopState/useOnPopState.ts b/src/services/beforePopState/useOnPopState.ts new file mode 100644 index 00000000..3af41e47 --- /dev/null +++ b/src/services/beforePopState/useOnPopState.ts @@ -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]); +}; diff --git a/src/services/beforePopState/usePopStateBus.ts b/src/services/beforePopState/usePopStateBus.ts new file mode 100644 index 00000000..0715fbf6 --- /dev/null +++ b/src/services/beforePopState/usePopStateBus.ts @@ -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(); + }, []); +}; From 9aca39cc1363dc6525a76be531dbce452a4d2eb0 Mon Sep 17 00:00:00 2001 From: Fran McDade Date: Tue, 3 Jun 2025 00:33:58 +1000 Subject: [PATCH 2/3] fix: was pop hook (#509) --- src/providers/services/wasPop/provider.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/providers/services/wasPop/provider.tsx b/src/providers/services/wasPop/provider.tsx index b9b73be4..510be28e 100644 --- a/src/providers/services/wasPop/provider.tsx +++ b/src/providers/services/wasPop/provider.tsx @@ -32,23 +32,16 @@ export function WasPopProvider({ wasPopRef.current = false; }, []); - const onRouteChangeStart = useCallback(() => { - if (wasPopRef.current) return; - wasPopRef.current = false; - }, []); - // Register the callback to be invoked before pop. useOnPopState(onBeforePopState); useEffect(() => { - Router.events.on("routeChangeStart", onRouteChangeStart); Router.events.on("routeChangeComplete", onRouteChangeComplete); return (): void => { - Router.events.off("routeChangeStart", onRouteChangeStart); Router.events.off("routeChangeComplete", onRouteChangeComplete); }; - }, [onRouteChangeComplete, onRouteChangeStart]); + }, [onRouteChangeComplete]); return ( From 0c21d20952a79d3f3e30beb91f0bcb5ed2a7f3c5 Mon Sep 17 00:00:00 2001 From: Fran McDade Date: Tue, 3 Jun 2025 09:40:36 +1000 Subject: [PATCH 3/3] fix: was pop hook (#509) --- src/providers/services/wasPop/context.ts | 3 ++- src/providers/services/wasPop/provider.tsx | 26 +++++++++------------- src/providers/services/wasPop/types.ts | 6 ++++- 3 files changed, 17 insertions(+), 18 deletions(-) diff --git a/src/providers/services/wasPop/context.ts b/src/providers/services/wasPop/context.ts index 14e8d88a..d58ba455 100644 --- a/src/providers/services/wasPop/context.ts +++ b/src/providers/services/wasPop/context.ts @@ -2,5 +2,6 @@ import { createContext } from "react"; import { WasPopContextProps } from "./types"; export const WasPopContext = createContext({ - wasPop: false, + onClearPopRef: () => {}, + popRef: { current: undefined }, }); diff --git a/src/providers/services/wasPop/provider.tsx b/src/providers/services/wasPop/provider.tsx index 510be28e..507fb930 100644 --- a/src/providers/services/wasPop/provider.tsx +++ b/src/providers/services/wasPop/provider.tsx @@ -1,5 +1,5 @@ -import { Router } from "next/router"; -import React, { ReactNode, useCallback, useEffect, useRef } from "react"; +import React, { ReactNode, useCallback, useRef } from "react"; +import { NextHistoryState } from "../../../services/beforePopState/types"; import { useOnPopState } from "../../../services/beforePopState/useOnPopState"; import { WasPopContext } from "./context"; @@ -21,30 +21,24 @@ export function WasPopProvider({ }: { children: ReactNode; }): JSX.Element { - const wasPopRef = useRef(false); + const popRef = useRef(); - const onBeforePopState = useCallback(() => { - wasPopRef.current = true; + // Pop callback. + const onBeforePopState = useCallback((state: NextHistoryState) => { + popRef.current = state; return true; }, []); - const onRouteChangeComplete = useCallback(() => { - wasPopRef.current = false; + // Clear pop ref. + const onClearPopRef = useCallback(() => { + popRef.current = undefined; }, []); // Register the callback to be invoked before pop. useOnPopState(onBeforePopState); - useEffect(() => { - Router.events.on("routeChangeComplete", onRouteChangeComplete); - - return (): void => { - Router.events.off("routeChangeComplete", onRouteChangeComplete); - }; - }, [onRouteChangeComplete]); - return ( - + {children} ); diff --git a/src/providers/services/wasPop/types.ts b/src/providers/services/wasPop/types.ts index 1c771811..3cad603b 100644 --- a/src/providers/services/wasPop/types.ts +++ b/src/providers/services/wasPop/types.ts @@ -1,3 +1,7 @@ +import { MutableRefObject } from "react"; +import { NextHistoryState } from "../../../services/beforePopState/types"; + export interface WasPopContextProps { - wasPop: boolean; + onClearPopRef: () => void; + popRef: MutableRefObject; }