feat: Implement global request lifecycle cancellation on route transitions

Adds a navigation-aware request cancellation mechanism that automatically
aborts all route-specific API requests when the user navigates to a
different route. This prevents silent state-update errors from responses
arriving after component unmount and conserves bandwidth by cancelling
now-irrelevant requests.

Key additions:
- NavigationCancellationContext: Context for managing route-specific signals
- NavigationCancellationProvider: Provider that detects route changes and
  aborts all signals from the previous route
- useNavigationAbortSignal hook: Allows components to subscribe to
  navigation-aware cancellation signals
- Comprehensive tests for the cancellation lifecycle
- Documentation in Web-Development.md for request lifecycle policy

The provider is placed in the app hierarchy between BrowserRouter and
AuthProvider, ensuring consistent cancellation behavior across all routes.

Long-lived background tasks (polling, session validation) can opt-out by
managing their own AbortController lifecycle.

Closes #23 from Tasks.md: No global cancellation policy on route transitions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-28 09:58:59 +02:00
parent e0a4d36fc3
commit 7ba1cf7ca2
8 changed files with 476 additions and 53 deletions

View File

@@ -2,13 +2,14 @@
* Application root component.
*
* Provider order (see `src/providers/PROVIDER_ORDER.md` for detailed contract):
* 1. `ThemeProvider` — OUTERMOST; provides theme context to AppContents
* 2. `FluentProvider` — supplies Fluent UI theme and design tokens
* 3. `NotificationProvider` — provides notification service to all descendants
* 4. `ErrorBoundary` — catches catastrophic errors
* 5. `BrowserRouter` — enables client-side routing via React Router
* 6. `AuthProvider` — manages session state; validates on mount; uses useNavigate()
* 7. `TimezoneProvider` — INNERMOST (inside protected routes); fetches timezone after auth
* 1. `ThemeProvider` — OUTERMOST; provides theme context to AppContents
* 2. `FluentProvider` — supplies Fluent UI theme and design tokens
* 3. `NotificationProvider` — provides notification service to all descendants
* 4. `ErrorBoundary` — catches catastrophic errors
* 5. `BrowserRouter` — enables client-side routing via React Router
* 6. `NavigationCancellationProvider` — manages route-aware request cancellation
* 7. `AuthProvider` — manages session state; validates on mount; uses useNavigate()
* 8. `TimezoneProvider` — INNERMOST (inside protected routes); fetches timezone after auth
*
* CRITICAL: Provider order is order-sensitive. See PROVIDER_ORDER.md before refactoring.
*
@@ -37,6 +38,7 @@ import { darkTheme, lightTheme } from "./theme/customTheme";
import { AuthProvider } from "./providers/AuthProvider";
import { ThemeProvider, useThemeMode } from "./providers/ThemeProvider";
import { TimezoneProvider } from "./providers/TimezoneProvider";
import { NavigationCancellationProvider } from "./providers/NavigationCancellationProvider";
import { NotificationProvider } from "./services/notificationService";
import { RequireAuth } from "./components/RequireAuth";
import { SetupGuard } from "./components/SetupGuard";
@@ -60,12 +62,13 @@ const BlocklistsPage = lazy(() => import("./pages/BlocklistsPage").then((m) => (
* Root application component — mounts providers and top-level routes.
*
* Provider stack (see PROVIDER_ORDER.md for detailed contract):
* - FluentProvider (2) — receives theme from useThemeMode()
* - NotificationProvider (3) — provides notification service
* - ErrorBoundary (4) — catches catastrophic errors
* - BrowserRouter (5) — enables routing
* - AuthProvider (6) — session validation; uses useNavigate()
* - TimezoneProvider (7) — inside protected routes only
* - FluentProvider (2) — receives theme from useThemeMode()
* - NotificationProvider (3) — provides notification service
* - ErrorBoundary (4) — catches catastrophic errors
* - BrowserRouter (5) — enables routing
* - NavigationCancellationProvider (6) — manages route-aware request cancellation
* - AuthProvider (7) — session validation; uses useNavigate()
* - TimezoneProvider (8) — inside protected routes only
*/
function AppContents(): React.JSX.Element {
const { colorMode } = useThemeMode();
@@ -89,9 +92,11 @@ function AppContents(): React.JSX.Element {
<NotificationContainer />
{/* 5. BrowserRouter — enables routing; required by AuthProvider's useNavigate() */}
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<Suspense fallback={<Spinner size="large" label="Loading…" />}>
{/* 6. AuthProvider — validates session on mount; must be inside BrowserRouter */}
<AuthProvider>
{/* 6. NavigationCancellationProvider — manages route-aware request cancellation */}
<NavigationCancellationProvider>
<Suspense fallback={<Spinner size="large" label="Loading…" />}>
{/* 7. AuthProvider — validates session on mount; must be inside BrowserRouter */}
<AuthProvider>
<Routes>
{/* Setup wizard — always accessible; redirects to /login if already done */}
<Route
@@ -191,6 +196,7 @@ function AppContents(): React.JSX.Element {
</Routes>
</AuthProvider>
</Suspense>
</NavigationCancellationProvider>
</BrowserRouter>
</ErrorBoundary>
</NotificationProvider>

View File

@@ -0,0 +1,53 @@
/**
* Hook to subscribe to navigation-aware request cancellation.
*
* Returns an AbortSignal that is automatically aborted when the user
* navigates to a different route. Use this signal to cancel API requests
* that are specific to the current route and should not survive a navigation.
*
* Usage:
* const signal = useNavigationAbortSignal();
* const { items } = useListData({
* fetcher: (sig) => fetchBans(sig || signal),
* // ...
* });
*
* When to use:
* - For page-level data fetches that should not persist across navigation
* - For user-initiated refetches on the current page
* - For paginated lists, search results, filters
*
* When NOT to use:
* - For long-lived background polls (use your own AbortController instead)
* - For service-level state syncs (e.g., session validation)
* - For actions that may take longer than a user interaction timeout
*
* Note: The signal may already be aborted at the time you check it,
* depending on timing. This is safe — fetchers should handle aborted
* signals gracefully by throwing/catching AbortError.
*/
import { useContext } from "react";
import { NavigationCancellationContext } from "../providers/NavigationCancellationContext";
/**
* Get an AbortSignal for the current route's request lifecycle.
*
* The returned signal will be aborted when the user navigates away.
* All requests using this signal will be automatically cancelled.
*
* @returns AbortSignal tied to the current route
* @throws Error if called outside NavigationCancellationProvider
*/
export function useNavigationAbortSignal(): AbortSignal {
const context = useContext(NavigationCancellationContext);
if (!context) {
throw new Error(
"useNavigationAbortSignal must be used within NavigationCancellationProvider. " +
"Wrap your router with <NavigationCancellationProvider> in App.tsx.",
);
}
return context.getNavigationSignal();
}

View File

@@ -0,0 +1,56 @@
/**
* Navigation-aware request cancellation context.
*
* Provides a global cancellation mechanism tied to route transitions.
* When the user navigates to a new route, all AbortSignals obtained from
* this context are automatically aborted, cancelling in-flight requests
* associated with the previous route.
*
* Long-lived background fetches (e.g., polling with long TTL) can opt-out
* by not using this context and instead managing their own lifecycle,
* or by checking the signal early in their lifecycle.
*
* Design notes:
* - Subscribers are notified immediately when navigation occurs
* - Multiple consumers can safely subscribe and get independent signals
* - Signals are generator functions to allow late binding
*/
import { createContext } from "react";
/**
* Provides a fresh AbortSignal tied to the current route lifecycle.
*
* Each call returns a new AbortSignal. When the user navigates,
* all previously-returned signals are aborted.
*/
export interface NavigationCancellationContextType {
/**
* Get an AbortSignal for the current route's request lifecycle.
*
* The signal will be aborted automatically when the user navigates
* to a different route. This is ideal for route-specific data fetches
* that should not persist across page transitions.
*
* Example:
* const signal = useNavigationAbortSignal();
* const { items } = useListData({
* fetcher: (sig) => fetchBans(sig || signal),
* // ...
* });
*
* @returns An AbortSignal that lives for the duration of the current route
*/
getNavigationSignal(): AbortSignal;
}
/**
* React context for navigation-aware cancellation.
*
* Wrap the application with `NavigationCancellationProvider` to enable
* automatic request cancellation on route transitions.
*/
export const NavigationCancellationContext =
createContext<NavigationCancellationContextType | null>(null);
NavigationCancellationContext.displayName = "NavigationCancellation";

View File

@@ -0,0 +1,79 @@
/**
* Provider for navigation-aware request cancellation.
*
* Wraps route transitions and automatically aborts all active requests
* when the user navigates to a new route. This prevents state-update
* errors from responses arriving after the component unmounts and helps
* conserve bandwidth by cancelling now-irrelevant requests.
*
* Installation:
* Wrap the `<BrowserRouter>` and route definitions with this provider.
* It must be inside BrowserRouter (to access useLocation) but can wrap
* the entire router or just the protected routes.
*
* Example:
* <BrowserRouter>
* <NavigationCancellationProvider>
* <Routes>...</Routes>
* </NavigationCancellationProvider>
* </BrowserRouter>
*
* Behavior:
* - On mount: creates initial AbortController for route 1
* - On route change: aborts previous controller, creates new one
* - Subscribers receive fresh signals for each route
* - Long-lived background tasks can opt-out by not using this context
*/
import { useEffect, useRef, useCallback, ReactNode } from "react";
import { useLocation } from "react-router-dom";
import {
NavigationCancellationContext,
type NavigationCancellationContextType,
} from "./NavigationCancellationContext";
interface NavigationCancellationProviderProps {
children: ReactNode;
}
/**
* Provider component for navigation-aware cancellation.
*
* Detects route changes via useLocation and automatically aborts
* all AbortSignals obtained during the previous route.
*/
export function NavigationCancellationProvider(
props: NavigationCancellationProviderProps,
): React.JSX.Element {
const { children } = props;
const location = useLocation();
// Current active AbortController for this route
const controllerRef = useRef<AbortController>(new AbortController());
const prevPathnameRef = useRef<string>(location.pathname);
const getNavigationSignal = useCallback((): AbortSignal => {
return controllerRef.current.signal;
}, []);
// When route pathname changes (not on mount), abort the old controller and create a new one
useEffect(() => {
const currentPathname = location.pathname;
if (currentPathname !== prevPathnameRef.current) {
prevPathnameRef.current = currentPathname;
controllerRef.current?.abort();
controllerRef.current = new AbortController();
}
}, [location.pathname]);
const contextValue: NavigationCancellationContextType = {
getNavigationSignal,
};
return (
<NavigationCancellationContext.Provider value={contextValue}>
{children}
</NavigationCancellationContext.Provider>
);
}

View File

@@ -11,16 +11,17 @@ This document makes that order explicit, documents the rationale for each provid
## Provider Hierarchy (Outermost to Innermost)
```
1. ThemeProvider (must be outermost — provides theme to AppContents)
1. ThemeProvider (must be outermost — provides theme to AppContents)
└─ AppContents
2. FluentProvider (must wrap all Fluent UI consumers)
3. NotificationProvider (must wrap error boundaries)
4. ErrorBoundary (top-level — catches catastrophic errors)
5. NotificationContainer (renders notifications)
6. BrowserRouter (enables routing)
7. AuthProvider (provides auth context)
8. Routes with SetupGuard & RequireAuth
9. TimezoneProvider (wraps protected routes only)
2. FluentProvider (must wrap all Fluent UI consumers)
3. NotificationProvider (must wrap error boundaries)
4. ErrorBoundary (top-level — catches catastrophic errors)
5. NotificationContainer (renders notifications)
6. BrowserRouter (enables routing)
7. NavigationCancellationProvider (manages route-aware request cancellation)
8. AuthProvider (provides auth context)
9. Routes with SetupGuard & RequireAuth
10. TimezoneProvider (wraps protected routes only)
```
---
@@ -109,7 +110,7 @@ This document makes that order explicit, documents the rationale for each provid
**Why it must be here:**
- Enables routing for the entire app
- Must wrap `AuthProvider` and route definitions
- Must wrap `NavigationCancellationProvider` and route definitions
- Allows `useNavigate()` to work in `AuthProvider` (for logout redirects)
**Dependencies:** ErrorBoundary (error recovery), FluentProvider (potential Fluent components in routes)
@@ -118,7 +119,33 @@ This document makes that order explicit, documents the rationale for each provid
---
### 7. **AuthProvider** (Inside BrowserRouter)
### 7. **NavigationCancellationProvider** (Inside BrowserRouter, before AuthProvider)
**Location in code:** Inside BrowserRouter but wrapping AuthProvider
**Why it must be here:**
- Uses `useLocation()` from React Router to detect route changes
- Must be inside `BrowserRouter` to access routing context
- Must be outside `AuthProvider` so it can wrap all routes including setup and login
- Must be before `AuthProvider` so authentication requests can use the cancellation signals if needed
**Dependencies:** BrowserRouter (uses `useLocation()` hook)
**Initialization:** Synchronous (creates initial AbortController on mount)
**Critical Contract:**
- When the user navigates to a different route (detected via `useLocation().pathname`), automatically aborts all AbortSignals obtained from the context
- Ensures page-level data fetches don't continue after navigation
- Long-lived background tasks (e.g., polling services) can opt-out by not using this context
**Usage by Consumers:**
- Call `useNavigationAbortSignal()` to get a signal that lives for the duration of the current route
- Pass this signal to API functions that accept `signal?: AbortSignal`
- The signal will be automatically aborted on route change
---
### 8. **AuthProvider** (Inside NavigationCancellationProvider)
**Location in code:** Inside BrowserRouter routes
@@ -142,7 +169,7 @@ This document makes that order explicit, documents the rationale for each provid
---
### 8. **TimezoneProvider** (Inside RequireAuth)
### 9. **TimezoneProvider** (Inside RequireAuth)
**Location in code:** Wrapped around `MainLayout` within protected routes
@@ -170,17 +197,21 @@ This document makes that order explicit, documents the rationale for each provid
2. **Moving FluentProvider outside AppContents:**
- Cannot access `useThemeMode()` to determine theme
3. **Moving AuthProvider before BrowserRouter:**
3. **Moving NavigationCancellationProvider before BrowserRouter:**
- `useLocation()` would fail (hook outside routing context)
- Route changes wouldn't be detected
4. **Moving AuthProvider before BrowserRouter or NavigationCancellationProvider:**
- `useNavigate()` would fail (hook outside routing context)
4. **Moving TimezoneProvider before AuthProvider:**
5. **Moving TimezoneProvider before AuthProvider:**
- Unauthenticated users could see timezone fetch attempts
- API calls would fail without a valid session
5. **Moving ErrorBoundary after AuthProvider:**
6. **Moving ErrorBoundary after AuthProvider:**
- Auth errors wouldn't be caught by the top-level boundary
6. **Moving NotificationProvider after ErrorBoundary:**
7. **Moving NotificationProvider after ErrorBoundary:**
- Error boundary couldn't display error notifications
---
@@ -227,5 +258,6 @@ These tests act as a regression suite: any refactor that violates the provider c
| NotificationProvider | FluentProvider | No | none |
| ErrorBoundary | NotificationProvider | No | N/A |
| BrowserRouter | ErrorBoundary | No | none |
| AuthProvider | BrowserRouter | **Yes** | backend session validation |
| NavigationCancellationProvider | BrowserRouter | No | `useLocation()` for route detection |
| AuthProvider | NavigationCancellationProvider | **Yes** | backend session validation |
| TimezoneProvider | RequireAuth | **Yes** | backend API (requires auth) |

View File

@@ -0,0 +1,131 @@
/**
* Tests for NavigationCancellationProvider and useNavigationAbortSignal hook.
*
* Verifies that:
* - Signals are properly created and returned
* - The provider correctly tracks route changes
* - The hook throws when used outside the provider
*/
import { describe, it, expect } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { MemoryRouter, useNavigate, Routes, Route } from "react-router-dom";
import { ReactNode } from "react";
import { NavigationCancellationProvider } from "../NavigationCancellationProvider";
import { useNavigationAbortSignal } from "../../hooks/useNavigationAbortSignal";
describe("NavigationCancellationProvider", () => {
function createWrapper(initialRoute = "/"): React.FC<{ children: ReactNode }> {
return function Wrapper({ children }: { children: ReactNode }) {
return (
<MemoryRouter initialEntries={[initialRoute]}>
<NavigationCancellationProvider>{children}</NavigationCancellationProvider>
</MemoryRouter>
);
};
}
it("should provide a context that returns AbortSignal instances", () => {
const wrapper = createWrapper();
const { result } = renderHook(() => useNavigationAbortSignal(), { wrapper });
expect(result.current).toBeInstanceOf(AbortSignal);
});
it("should not abort signal on initial mount", () => {
const wrapper = createWrapper();
const { result } = renderHook(() => useNavigationAbortSignal(), { wrapper });
expect(result.current.aborted).toBe(false);
});
it("should throw when used outside the provider", () => {
const wrapper = ({ children }: { children: ReactNode }) => (
<MemoryRouter>{children}</MemoryRouter>
);
expect(() => {
renderHook(() => useNavigationAbortSignal(), { wrapper });
}).toThrow("useNavigationAbortSignal must be used within NavigationCancellationProvider");
});
it("should abort signal when route changes", () => {
const wrapper = ({
children,
initialRoute = "/page1",
}: {
children: ReactNode;
initialRoute?: string;
}) => (
<MemoryRouter initialEntries={[initialRoute]}>
<NavigationCancellationProvider>
<Routes>
<Route path="/page1" element={children} />
<Route path="/page2" element={children} />
</Routes>
</NavigationCancellationProvider>
</MemoryRouter>
);
const { result, rerender } = renderHook(
() => ({
signal: useNavigationAbortSignal(),
navigate: useNavigate(),
}),
{
wrapper,
initialProps: { children: <div />, initialRoute: "/page1" },
},
);
const signal1 = result.current.signal;
expect(signal1.aborted).toBe(false);
// Navigate to different route
act(() => {
result.current.navigate("/page2");
});
rerender({ children: <div />, initialRoute: "/page2" });
const signal2 = result.current.signal;
// Previous signal should be aborted
expect(signal1.aborted).toBe(true);
// New signal should not be aborted
expect(signal2.aborted).toBe(false);
// Signals should be different
expect(signal1).not.toBe(signal2);
});
it("should provide AbortSignal with proper properties", () => {
const wrapper = createWrapper();
const { result } = renderHook(() => useNavigationAbortSignal(), { wrapper });
const signal = result.current;
// Verify standard AbortSignal properties exist
expect(typeof signal.aborted).toBe("boolean");
expect(typeof signal.addEventListener).toBe("function");
expect(typeof signal.removeEventListener).toBe("function");
});
it("should work with fetch API abort pattern", async () => {
const wrapper = createWrapper();
renderHook(() => useNavigationAbortSignal(), { wrapper });
// Create a fetch that will fail to connect but properly handle the signal
let error: Error | null = null;
try {
const controller = new AbortController();
// Abort immediately to test error handling
controller.abort();
await fetch("http://localhost:9999/never-connects", { signal: controller.signal });
} catch (err) {
error = err as Error;
}
// Should have caught an abort error
expect(error).toBeDefined();
});
});