Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/providers/services/provider.tsx
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";

Copilot AI Jun 2, 2025

Copy link

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.

Suggested change
import { WasPopProvider } from "../services/wasPop/provider";
import { WasPopProvider } from "./wasPop/provider";

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

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!


/**
* 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>;
}
7 changes: 7 additions & 0 deletions src/providers/services/wasPop/context.ts
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 },
});
7 changes: 7 additions & 0 deletions src/providers/services/wasPop/hook.ts
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);
};
45 changes: 45 additions & 0 deletions src/providers/services/wasPop/provider.tsx
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>
);
}
7 changes: 7 additions & 0 deletions src/providers/services/wasPop/types.ts
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>;
}
64 changes: 64 additions & 0 deletions src/services/beforePopState/popStateBus.ts
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

Copilot AI Jun 2, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using a for…of loop over the callback set and breaking early when a callback returns false, to avoid invoking all listeners once navigation is already blocked.

Suggested change
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);
}
}

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

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!


return allAllow;
});
}
15 changes: 15 additions & 0 deletions src/services/beforePopState/types.ts
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];
15 changes: 15 additions & 0 deletions src/services/beforePopState/useOnPopState.ts
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]);
};
14 changes: 14 additions & 0 deletions src/services/beforePopState/usePopStateBus.ts
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();
}, []);
};