Skip to content

Commit 0b851fd

Browse files
authored
feat: add global 'beforepopstate' bus to intercept browser back/forward navigation (#509) (#511)
* feat: add global 'beforepopstate' bus to intercept browser back/forward navigation (#509) * fix: was pop hook (#509) * fix: was pop hook (#509) ---------
1 parent 4b09aa2 commit 0b851fd

9 files changed

Lines changed: 199 additions & 0 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import React, { ReactNode } from "react";
2+
import { usePopStateBus } from "../../services/beforePopState/usePopStateBus";
3+
import { WasPopProvider } from "../services/wasPop/provider";
4+
5+
/**
6+
* ServicesProvider is a component that initializes and provides access to various service-related
7+
* functionality throughout the application.
8+
*
9+
* This provider:
10+
* 1. Registers the pop state bus to handle browser navigation events.
11+
* 2. Provides the WasPopProvider context to track browser back/forward navigation
12+
*
13+
* This provider should be placed at the _app root level to ensure all components have access to these services.
14+
*/
15+
16+
export function ServicesProvider({
17+
children,
18+
}: {
19+
children: ReactNode;
20+
}): JSX.Element {
21+
// Register the pop state bus.
22+
usePopStateBus();
23+
24+
return <WasPopProvider>{children}</WasPopProvider>;
25+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { createContext } from "react";
2+
import { WasPopContextProps } from "./types";
3+
4+
export const WasPopContext = createContext<WasPopContextProps>({
5+
onClearPopRef: () => {},
6+
popRef: { current: undefined },
7+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { useContext } from "react";
2+
import { WasPopContext } from "./context";
3+
import { WasPopContextProps } from "./types";
4+
5+
export const useWasPop = (): WasPopContextProps => {
6+
return useContext(WasPopContext);
7+
};
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import React, { ReactNode, useCallback, useRef } from "react";
2+
import { NextHistoryState } from "../../../services/beforePopState/types";
3+
import { useOnPopState } from "../../../services/beforePopState/useOnPopState";
4+
import { WasPopContext } from "./context";
5+
6+
/**
7+
* WasPopProvider tracks browser navigation events to determine if the current route change
8+
* was triggered by a popstate event (browser back/forward navigation).
9+
*
10+
* This provider:
11+
* 1. Registers callbacks for route change events.
12+
* 2. Tracks when navigation occurs via browser back/forward buttons
13+
* 3. Provides this state via context to child components
14+
*
15+
* This allows components to respond differently to user-initiated navigation versus
16+
* browser history navigation.
17+
*/
18+
19+
export function WasPopProvider({
20+
children,
21+
}: {
22+
children: ReactNode;
23+
}): JSX.Element {
24+
const popRef = useRef<NextHistoryState | undefined>();
25+
26+
// Pop callback.
27+
const onBeforePopState = useCallback((state: NextHistoryState) => {
28+
popRef.current = state;
29+
return true;
30+
}, []);
31+
32+
// Clear pop ref.
33+
const onClearPopRef = useCallback(() => {
34+
popRef.current = undefined;
35+
}, []);
36+
37+
// Register the callback to be invoked before pop.
38+
useOnPopState(onBeforePopState);
39+
40+
return (
41+
<WasPopContext.Provider value={{ onClearPopRef, popRef }}>
42+
{children}
43+
</WasPopContext.Provider>
44+
);
45+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { MutableRefObject } from "react";
2+
import { NextHistoryState } from "../../../services/beforePopState/types";
3+
4+
export interface WasPopContextProps {
5+
onClearPopRef: () => void;
6+
popRef: MutableRefObject<NextHistoryState | undefined>;
7+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import Router from "next/router";
2+
import { BeforePopStateCallback, NextHistoryState } from "./types";
3+
4+
/**
5+
* Pop‐State Event Bus
6+
*
7+
* Provides a centralized mechanism for components to intercept
8+
* and optionally prevent Back/Forward navigation (Next.js beforePopState).
9+
*/
10+
11+
/**
12+
* A set of callback functions that will run before Next.js performs a pop.
13+
* Each callback returns `true` to allow navigation or `false` to block.
14+
*/
15+
const beforePopCallbacks = new Set<BeforePopStateCallback>();
16+
17+
/**
18+
* Register a callback to be invoked immediately before any Next.js pop navigation.
19+
* Return `false` from your callback to prevent the pop; otherwise return `true`.
20+
* @param cb - The callback function to register.
21+
*/
22+
export function registerBeforePopCallback(cb: BeforePopStateCallback): void {
23+
beforePopCallbacks.add(cb);
24+
}
25+
26+
/**
27+
* Unregister a previously registered “before pop” callback.
28+
* @param cb - The callback function to unregister.
29+
*/
30+
export function unregisterBeforePopCallback(cb: BeforePopStateCallback): void {
31+
beforePopCallbacks.delete(cb);
32+
}
33+
34+
/**
35+
* Ensures that we only hook into Next.js once. After install, any pop event
36+
* will first invoke all registered callbacks and only proceed if all return true.
37+
*/
38+
let hasInstalledInterceptor = false;
39+
40+
/**
41+
* Install the global “before pop” interceptor into Next.js’s router.
42+
* Subsequent calls to this function will be no‐ops.
43+
*
44+
* This method must be called once (e.g. in your app’s top‐level code)
45+
* to enable the pop‐state bus.
46+
*/
47+
export function registerPopStateHandler(): void {
48+
if (hasInstalledInterceptor) return;
49+
hasInstalledInterceptor = true;
50+
51+
Router.beforePopState((state: NextHistoryState) => {
52+
// Iteratively call every callback. If any returns false, block navigation.
53+
let allAllow = true;
54+
beforePopCallbacks.forEach((cb) => {
55+
try {
56+
if (cb(state) === false) allAllow = false;
57+
} catch (e: unknown) {
58+
console.error("Pop listener failed:", e);
59+
}
60+
});
61+
62+
return allAllow;
63+
});
64+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import Router from "next/router";
2+
3+
/**
4+
* Type representing the callback function passed to beforePopState.
5+
* Extracted from the Next.js Router.beforePopState API.
6+
*/
7+
export type BeforePopStateCallback = Parameters<
8+
typeof Router.beforePopState
9+
>[0];
10+
11+
/**
12+
* Type representing the state passed to beforePopState.
13+
* Extracted from the Next.js Router.beforePopState API.
14+
*/
15+
export type NextHistoryState = Parameters<BeforePopStateCallback>[0];
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { useEffect } from "react";
2+
import {
3+
registerBeforePopCallback,
4+
unregisterBeforePopCallback,
5+
} from "./popStateBus";
6+
import { BeforePopStateCallback } from "./types";
7+
8+
export const useOnPopState = (cb: BeforePopStateCallback): void => {
9+
useEffect(() => {
10+
registerBeforePopCallback(cb);
11+
return (): void => {
12+
unregisterBeforePopCallback(cb);
13+
};
14+
}, [cb]);
15+
};
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { useEffect } from "react";
2+
import { registerPopStateHandler } from "./popStateBus";
3+
4+
/**
5+
* Registers the single global `router.beforePopState` handler that
6+
* fans out to all feature listeners.
7+
* Safe to call multiple times - only the first invocation does the real install.
8+
*/
9+
10+
export const usePopStateBus = (): void => {
11+
useEffect(() => {
12+
registerPopStateHandler();
13+
}, []);
14+
};

0 commit comments

Comments
 (0)