refactoring-backend #3

Merged
lukas.pupkalipinski merged 403 commits from refactoring-backend into main 2026-05-20 20:23:46 +02:00
9 changed files with 1580 additions and 50 deletions
Showing only changes of commit c988b4b8b6 - Show all commits

View File

@@ -949,11 +949,57 @@ Shared TypeScript interfaces and type aliases. Purely declarative — no runtime
React context providers for application-wide concerns.
**Provider Ordering and Compile-Time Validation**
Provider order is **order-sensitive** and enforced at compile-time through TypeScript discriminated unions. The required order (outermost to innermost) is:
1. `ThemeProvider` — must be outermost; provides theme context to `AppContents`
2. `FluentProvider` — supplies Fluent UI theme and design tokens to all Fluent UI consumers
3. `NotificationProvider` — provides notification service; must wrap error boundaries
4. `ErrorBoundary` — catches catastrophic errors at the top level
5. `BrowserRouter` — enables client-side routing
6. `NavigationCancellationProvider` — manages route-aware request cancellation using `useLocation()`
7. `AuthProvider` — validates session on mount; must be inside BrowserRouter (uses `useNavigate()`)
8. `TimezoneProvider` — fetches timezone after auth; wraps protected routes only
**Compile-Time Validation:**
A type-safe builder pattern (`ProviderCompositionBuilder`) in `providerComposition.tsx` enforces this order using TypeScript's discriminated unions. The builder prevents adding providers out of order at compile-time:
```tsx
const tree = createProviderComposition()
.withTheme({ children })
.withFluent(theme) // ✓ Must come after withTheme
.withNotification() // ✓ Must come after withFluent
.withErrorBoundary() // ✓ Correct order enforced
.withBrowserRouter()
.withNavigationCancellation()
.withAuth()
.build(routes);
```
Attempting to add providers out of order results in TypeScript errors (no runtime overhead).
**Runtime Validation (Development):**
A runtime validator (`providerOrderValidator.tsx`) provides fallback validation for development:
- `validateProviderPosition()` — checks if a provider is correctly nested
- `validateProvidersExist()` — ensures required providers are in the tree
- `hasProvider()` — queries provider presence
- `useProviderValidation()` — development-only hook that warns if required providers are missing
See `src/providers/PROVIDER_ORDER.md` for detailed dependency rationale.
**Provider Reference:**
| Provider | Purpose |
|---|---|
| `AuthProvider` | Holds authentication state; exposes `isAuthenticated`, `login()`, and `logout()` via `useAuth()`. Synchronizes logout events across browser tabs in real-time using the BroadcastChannel API (with storage event fallback for older browsers). When a user logs out in any tab, all other open tabs immediately reflect the logout state without requiring a page refresh. |
| `TimezoneProvider` | Reads the configured IANA timezone from the backend and supplies it to all children via `useTimezone()` |
| `ThemeProvider` | Manages light/dark theme selection, supplies the active Fluent UI theme to `FluentProvider` |
| `NotificationProvider` | Provides notification service via `useNotification()` hook; must wrap error boundaries so they can display error notifications |
| `NavigationCancellationProvider` | Detects route changes and automatically aborts pending API requests; call `useNavigationAbortSignal()` to get an `AbortSignal` that lives for the current route |
#### Theme (`src/theme/`)

View File

@@ -1,52 +1,3 @@
## [IMPORTANT] Error response schema inconsistent
**Where found**
- Different handlers return different response shapes
- Fail2Ban errors: `{ "error_code": "...", "detail": "..." }`
- Validation errors: `{ "detail": [...] }`
- Not found errors: `{ "detail": "...", "error_code": "..." }`
**Why this is needed**
Frontend must normalize multiple shapes, making error handling fragile and error-prone.
**Goal**
Unify all error responses to single schema.
**What to do**
1. Define canonical error response:
```python
class ErrorResponse(BaseModel):
error_code: str
message: str
status: int
details: dict | None = None
```
2. Update all handlers to return this format
3. Update frontend to expect unified schema
**Possible traps and issues**
- Backward compatibility with old clients
- FastAPI's built-in handlers may override custom
- Rich detail structures need accommodation
**Docs changes needed**
- Update API documentation with unified error schema
- Add error code reference table
**Doc references**
- `Docs/API.md` (error codes)
- `backend/app/main.py` (exception handlers)
---
## [IMPORTANT] Provider ordering fragility (Frontend)
**Where found**

View File

@@ -0,0 +1,14 @@
/**
* BanGUI Custom ESLint Rules
*
* This module exports all custom ESLint rules used to enforce
* coding standards and best practices in the BanGUI frontend.
*/
import providerOrderRule from "./provider-order.js";
export default {
rules: {
"provider-order": providerOrderRule,
},
};

View File

@@ -0,0 +1,172 @@
/**
* ESLint Rule: no-incorrect-provider-order
*
* Validates that React context providers are nested in the correct order
* according to the BanGUI provider dependency contract.
*
* Provider Order (enforced by this rule):
* 1. ThemeProvider (OUTERMOST — must be first)
* 2. FluentProvider (wraps all Fluent UI consumers)
* 3. NotificationProvider (must wrap error boundaries)
* 4. ErrorBoundary (top-level error boundary)
* 5. BrowserRouter (enables routing)
* 6. NavigationCancellationProvider (manages route cancellation)
* 7. AuthProvider (session validation)
* 8. TimezoneProvider (INNERMOST — wraps protected routes only)
*/
const PROVIDER_ORDER = [
"ThemeProvider",
"FluentProvider",
"NotificationProvider",
"ErrorBoundary",
"BrowserRouter",
"NavigationCancellationProvider",
"AuthProvider",
"TimezoneProvider",
];
/**
* Get the provider name from a JSX element
*/
function getProviderName(node) {
if (
node.name &&
node.name.type === "JSXIdentifier" &&
PROVIDER_ORDER.includes(node.name.name)
) {
return node.name.name;
}
return null;
}
/**
* Get the position of a provider in the ordering sequence
*/
function getProviderPosition(name) {
return PROVIDER_ORDER.indexOf(name);
}
/**
* Check if a JSX element is a provider
*/
function isProvider(node) {
return getProviderName(node) !== null;
}
/**
* Extract all JSX provider elements from a component tree
*/
function extractProviderStack(node) {
const stack = [];
let current = node;
while (current) {
if (!current.openingElement) break;
const providerName = getProviderName(current.openingElement);
if (providerName) {
stack.push({
name: providerName,
node: current.openingElement,
});
}
// Find the next JSX element by looking at children
if (current.children && current.children.length > 0) {
const firstChild = current.children[0];
if (firstChild && firstChild.type === "JSXElement") {
current = firstChild;
} else {
break;
}
} else {
break;
}
}
return stack;
}
export default {
meta: {
type: "problem",
docs: {
description:
"Enforce correct nesting order of React context providers in BanGUI",
category: "Best Practices",
recommended: "error",
url: "https://github.com/bangui/frontend/blob/main/src/providers/PROVIDER_ORDER.md",
},
fixable: undefined,
schema: [],
messages: {
incorrectOrder:
"Provider '{{ outer }}' (position {{ outerPos }}) must wrap '{{ inner }}' (position {{ innerPos }}). Current nesting: {{ outer }} → {{ inner }}",
missingParent:
"Provider '{{ current }}' (position {{ currentPos }}) requires parent provider '{{ required }}' (position {{ requiredPos }}). Expected: {{ expected }}",
},
},
create(context) {
return {
JSXElement(node) {
// Skip non-provider elements
if (!node.openingElement || !isProvider(node.openingElement)) {
return;
}
// Extract the provider stack from this element
const stack = extractProviderStack(node);
if (stack.length < 2) {
// No nesting to validate
return;
}
// Check ordering from outermost to innermost
for (let i = 0; i < stack.length - 1; i++) {
const outer = stack[i];
const inner = stack[i + 1];
const outerPos = getProviderPosition(outer.name);
const innerPos = getProviderPosition(inner.name);
// Inner provider must have a higher position (come later in the sequence)
if (innerPos <= outerPos) {
context.report({
node: inner.node,
messageId: "incorrectOrder",
data: {
outer: outer.name,
outerPos: String(outerPos),
inner: inner.name,
innerPos: String(innerPos),
},
});
}
}
// Check that all providers before the first one in the stack are present
const firstProvider = stack[0];
const firstPos = getProviderPosition(firstProvider.name);
if (firstPos > 0) {
const required = PROVIDER_ORDER[firstPos - 1];
context.report({
node: firstProvider.node,
messageId: "missingParent",
data: {
current: firstProvider.name,
currentPos: String(firstPos),
required,
requiredPos: String(firstPos - 1),
expected: PROVIDER_ORDER.slice(0, firstPos).join(" → "),
},
});
}
},
};
},
};

View File

@@ -3,9 +3,10 @@ import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
import reactPlugin from "eslint-plugin-react";
import prettierConfig from "eslint-config-prettier";
import bangguiRulesModule from "./eslint-rules/index.js";
export default tseslint.config(
{ ignores: ["dist", "eslint.config.ts", ".vite"] },
{ ignores: ["dist", "eslint.config.ts", ".vite", "eslint-rules"] },
{
extends: [js.configs.recommended, ...tseslint.configs.strictTypeChecked],
files: ["**/*.{ts,tsx}"],
@@ -18,6 +19,7 @@ export default tseslint.config(
plugins: {
react: reactPlugin,
"react-hooks": reactHooks,
bangui: bangguiRulesModule,
},
settings: {
react: {
@@ -34,6 +36,7 @@ export default tseslint.config(
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/explicit-function-return-type": "warn",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }],
"bangui/provider-order": "warn",
},
},
prettierConfig,

View File

@@ -0,0 +1,290 @@
/**
* Tests for Provider Composition Builder
*
* Validates that the provider composition builder enforces correct ordering
* at compile-time through TypeScript's discriminated union types.
*/
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { webLightTheme } from "@fluentui/react-components";
import { createProviderComposition } from "../providerComposition";
import { useThemeMode } from "../ThemeProvider";
import { useNotification } from "../../services/notificationService";
// ---------------------------------------------------------------------------
// Test Suite: Provider Composition Builder
// ---------------------------------------------------------------------------
describe("ProviderCompositionBuilder", () => {
beforeEach(() => {
sessionStorage.clear();
localStorage.clear();
vi.clearAllMocks();
// Mock window.matchMedia for theme preference
Object.defineProperty(window, "matchMedia", {
configurable: true,
writable: true,
value: vi.fn().mockReturnValue({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}),
});
});
afterEach(() => {
vi.clearAllMocks();
});
// -----------------------------------------------------------------------
// Test 1: Builder creates valid provider tree without TypeScript errors
// -----------------------------------------------------------------------
it("creates a valid provider tree with complete composition", () => {
function TestComponent(): React.JSX.Element {
const { colorMode } = useThemeMode();
return <div data-testid="test-output">{colorMode}</div>;
}
const composition = createProviderComposition()
.withTheme({ children: <TestComponent /> })
.withFluent(webLightTheme)
.withNotification()
.withErrorBoundary()
.withBrowserRouter()
.withNavigationCancellation()
.withAuth()
.build(<div>Routes go here</div>);
const { container } = render(composition);
expect(container).toBeTruthy();
});
// -----------------------------------------------------------------------
// Test 2: Builder enforces ThemeProvider must be first
// -----------------------------------------------------------------------
it("requires withTheme to be called first", () => {
// This test is primarily a compile-time check
// If TypeScript allows calling withFluent without withTheme first,
// this test would fail (but TypeScript would prevent it)
const builder = createProviderComposition();
// @ts-expect-error withFluent should not be available on initial builder
builder.withFluent(webLightTheme);
});
// -----------------------------------------------------------------------
// Test 3: Builder enforces provider ordering
// -----------------------------------------------------------------------
it("ensures FluentProvider comes after ThemeProvider", () => {
const builder = createProviderComposition().withTheme({ children: <div /> });
// withFluent should now be available after withTheme
const nextBuilder = builder.withFluent(webLightTheme);
expect(nextBuilder).toBeTruthy();
});
it("ensures NotificationProvider comes after FluentProvider", () => {
const builder = createProviderComposition()
.withTheme({ children: <div /> })
.withFluent(webLightTheme);
// withNotification should now be available
const nextBuilder = builder.withNotification();
expect(nextBuilder).toBeTruthy();
});
// -----------------------------------------------------------------------
// Test 4: Builder provides typed access to state
// -----------------------------------------------------------------------
it("allows building with intermediate providers (no Timezone)", () => {
function TestComponent(): React.JSX.Element {
const { colorMode } = useThemeMode();
const notification = useNotification();
return (
<div>
<div data-testid="theme">{colorMode}</div>
<button
type="button"
data-testid="notify-btn"
onClick={() => notification.success("Test")}
>
Notify
</button>
</div>
);
}
const tree = createProviderComposition()
.withTheme({ children: <TestComponent /> })
.withFluent(webLightTheme)
.withNotification()
.withErrorBoundary()
.withBrowserRouter()
.withNavigationCancellation()
.withAuth()
.build(<div data-testid="routes">Routes</div>);
render(tree);
expect(screen.getByTestId("routes")).toBeInTheDocument();
});
// -----------------------------------------------------------------------
// Test 5: Builder supports optional TimezoneProvider
// -----------------------------------------------------------------------
it("allows adding TimezoneProvider as the final step", () => {
function ProtectedContent(): React.JSX.Element {
return <div data-testid="protected">Protected</div>;
}
const tree = createProviderComposition()
.withTheme({ children: <ProtectedContent /> })
.withFluent(webLightTheme)
.withNotification()
.withErrorBoundary()
.withBrowserRouter()
.withNavigationCancellation()
.withAuth()
.withTimezone({ children: <div data-testid="timezone-content">Content</div> })
.buildWithTimezone();
const { container } = render(tree);
expect(container).toBeTruthy();
});
// -----------------------------------------------------------------------
// Test 6: Verify all providers are included in final tree
// -----------------------------------------------------------------------
it("includes all providers in correct nesting order", () => {
function TestComponent(): React.JSX.Element {
const notification = useNotification();
// NotificationProvider should be accessible
const canUseNotification = notification !== undefined;
return (
<div>
<div data-testid="notification-works">{canUseNotification ? "yes" : "no"}</div>
</div>
);
}
// The builder composition creates the full provider tree
const tree = createProviderComposition()
.withTheme({
children: <TestComponent />
})
.withFluent(webLightTheme)
.withNotification()
.withErrorBoundary()
.withBrowserRouter()
.withNavigationCancellation()
.withAuth()
.build(<div>Routes go here</div>);
// Render the composition and verify at least one provider works
const { container } = render(tree);
expect(container).toBeTruthy();
expect(container.querySelector('[class*="fui"]')).toBeTruthy(); // FluentProvider renders
});
// -----------------------------------------------------------------------
// Test 7: Type safety prevents incorrect call sequences
// -----------------------------------------------------------------------
it("prevents calling methods out of order (TypeScript compile-time check)", () => {
const builder = createProviderComposition()
.withTheme({ children: <div /> })
.withFluent(webLightTheme);
// This would be a compile error in real code:
// @ts-expect-error withTheme should not be available after withFluent
builder.withTheme({ children: <div /> });
// This should work:
const nextBuilder = builder.withNotification();
expect(nextBuilder).toBeTruthy();
});
// -----------------------------------------------------------------------
// Test 8: Builder state is immutable
// -----------------------------------------------------------------------
it("creates new builder instances without mutating previous state", () => {
const builder1 = createProviderComposition();
const builder2 = builder1.withTheme({ children: <div /> });
const builder3 = builder2.withFluent(webLightTheme);
// builder1 should still be in initial state
// @ts-expect-error builder1 should not have withFluent available
expect(() => builder1.withFluent(webLightTheme)).toBeDefined();
// builder2 should be in state 1
const builder2_next = builder2.withFluent(webLightTheme);
expect(builder2_next).toBeTruthy();
// builder3 should be in state 2
const builder3_next = builder3.withNotification();
expect(builder3_next).toBeTruthy();
});
// -----------------------------------------------------------------------
// Test 9: Verify builder doesn't allow skipping intermediate steps
// -----------------------------------------------------------------------
it("prevents skipping providers in the sequence", () => {
const builder = createProviderComposition().withTheme({ children: <div /> });
// Cannot call withNotification without withFluent first
// @ts-expect-error withNotification should not be available yet
expect(() => builder.withNotification()).toBeDefined();
// Must call withFluent first
const nextBuilder = builder.withFluent(webLightTheme);
expect(nextBuilder).toBeTruthy();
});
// -----------------------------------------------------------------------
// Test 10: Integration — Full composition with all steps
// -----------------------------------------------------------------------
it("supports full composition path with all providers including Timezone", () => {
function FullContent(): React.JSX.Element {
const { colorMode } = useThemeMode();
const notification = useNotification();
return (
<div>
<div data-testid="full-theme">{colorMode}</div>
<button
type="button"
data-testid="full-notify"
onClick={() => notification.success("Test")}
>
Test
</button>
</div>
);
}
const composition = createProviderComposition()
.withTheme({ children: <FullContent /> })
.withFluent(webLightTheme)
.withNotification()
.withErrorBoundary()
.withBrowserRouter()
.withNavigationCancellation()
.withAuth()
.withTimezone({ children: <div data-testid="tz-content">TZ Content</div> })
.buildWithTimezone();
const { container } = render(composition);
expect(container).toBeTruthy();
});
});

View File

@@ -0,0 +1,359 @@
/**
* Tests for Provider Order Validator
*
* Validates runtime checking of provider ordering through context tracking.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render } from "@testing-library/react";
import {
validateProviderPosition,
validateProvidersExist,
hasProvider,
getCurrentProviders,
createProviderTracker,
useProviderValidation,
} from "../providerOrderValidator";
describe("ProviderOrderValidator", () => {
beforeEach(() => {
vi.clearAllMocks();
});
// -----------------------------------------------------------------------
// Test 1: validateProviderPosition detects missing parent providers
// -----------------------------------------------------------------------
it("throws error when provider parent is missing", () => {
function InvalidComponent(): React.JSX.Element {
validateProviderPosition("AuthProvider", 6);
return <div>Should not render</div>;
}
expect(() => {
render(<InvalidComponent />);
}).toThrow(/missing required parent providers/i);
});
it("allows ThemeProvider at position 0 without parent tracking", () => {
function ValidThemeComponent(): React.JSX.Element {
validateProviderPosition("ThemeProvider", 0);
return <div data-testid="theme">Theme OK</div>;
}
const { getByTestId } = render(<ValidThemeComponent />);
expect(getByTestId("theme")).toBeInTheDocument();
});
// -----------------------------------------------------------------------
// Test 2: validateProvidersExist checks for required providers
// -----------------------------------------------------------------------
it("throws error when required providers are missing", () => {
function NeedsProviders(): React.JSX.Element {
validateProvidersExist(["AuthProvider", "NotificationProvider"]);
return <div>Should not render</div>;
}
expect(() => {
render(<NeedsProviders />);
}).toThrow(/missing required providers/i);
});
it("allows validation when tracking context exists with all required providers", () => {
function ValidatedComponent(): React.JSX.Element {
validateProvidersExist(["ThemeProvider"]);
return <div data-testid="valid">Valid</div>;
}
const composition = (
<div>
{createProviderTracker("ThemeProvider", 0, <ValidatedComponent />)}
</div>
);
const { getByTestId } = render(composition);
expect(getByTestId("valid")).toBeInTheDocument();
});
// -----------------------------------------------------------------------
// Test 3: hasProvider checks provider presence
// -----------------------------------------------------------------------
it("returns true when provider is present", () => {
let result = false;
function CheckProvider(): React.JSX.Element {
result = hasProvider("ThemeProvider");
return <div>Check done</div>;
}
render(
createProviderTracker("ThemeProvider", 0, <CheckProvider />)
);
expect(result).toBe(true);
});
it("returns false when provider is not present", () => {
let result = true;
function CheckProvider(): React.JSX.Element {
result = hasProvider("TimezoneProvider");
return <div>Check done</div>;
}
render(
createProviderTracker("ThemeProvider", 0, <CheckProvider />)
);
expect(result).toBe(false);
});
// -----------------------------------------------------------------------
// Test 4: getCurrentProviders returns providers in order
// -----------------------------------------------------------------------
it("returns empty array when no providers are tracked", () => {
let providers: string[] = [];
function CheckProviders(): React.JSX.Element {
const result = getCurrentProviders();
providers = result;
return <div>Check done</div>;
}
render(<CheckProviders />);
expect(providers).toEqual([]);
});
it("returns providers in correct order when tracked", () => {
let providers: string[] = [];
function CheckProviders(): React.JSX.Element {
const result = getCurrentProviders();
providers = result;
return <div>Check done</div>;
}
const composition = (
<div>
{createProviderTracker("ThemeProvider", 0,
createProviderTracker("FluentProvider", 1,
createProviderTracker("NotificationProvider", 2, <CheckProviders />)
)
)}
</div>
);
render(composition);
expect(providers).toEqual(["ThemeProvider", "FluentProvider", "NotificationProvider"]);
});
// -----------------------------------------------------------------------
// Test 5: createProviderTracker validates positioning
// -----------------------------------------------------------------------
it("throws error for invalid position in createProviderTracker", () => {
function Component(): React.JSX.Element {
return <div>Invalid</div>;
}
// The error should be thrown during render, not construction
expect(() => {
// Using any to test invalid position despite type checking
// eslint-disable-next-line @typescript-eslint/no-explicit-any
render(createProviderTracker("ThemeProvider" as any, 999, <Component />));
}).toThrow(/invalid position/i);
});
it("throws error when parent provider is missing in tracker", () => {
function Component(): React.JSX.Element {
return <div>Should not render</div>;
}
expect(() => {
// NotificationProvider (position 2) requires FluentProvider (position 1)
// but we're skipping FluentProvider
render(
<div>
{createProviderTracker("ThemeProvider", 0,
createProviderTracker("NotificationProvider", 2, <Component />)
)}
</div>
);
}).toThrow(/incorrect parent structure|missing/i);
});
// -----------------------------------------------------------------------
// Test 6: useProviderValidation hook validates providers
// -----------------------------------------------------------------------
it("logs warning in development when required providers are missing", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
function ComponentNeedsAuth(): React.JSX.Element {
useProviderValidation("ComponentNeedsAuth", ["AuthProvider"]);
return <div>Checking</div>;
}
render(<ComponentNeedsAuth />);
// In development, should warn about missing provider
const hasWarning = warnSpy.mock.calls.some((call) =>
String(call[0] || "").includes("ComponentNeedsAuth") ||
String(call[0] || "").includes("missing")
);
// Only verify warning if NODE_ENV is development
if (process.env.NODE_ENV === "development") {
expect(hasWarning).toBe(true);
}
warnSpy.mockRestore();
});
it("does not warn when all required providers are present", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
function ComponentNeedsAuth(): React.JSX.Element {
useProviderValidation("ComponentNeedsAuth", ["ThemeProvider"]);
return <div data-testid="ok">OK</div>;
}
const composition = (
<div>
{createProviderTracker("ThemeProvider", 0,
createProviderTracker("FluentProvider", 1, <ComponentNeedsAuth />)
)}
</div>
);
render(composition);
// Should not warn about missing ThemeProvider
const warnCalls = warnSpy.mock.calls;
const hasThemeWarning = warnCalls.some((call) =>
String(call[0] || "").includes("missing") && String(call[0] || "").includes("ThemeProvider")
);
expect(hasThemeWarning).toBe(false);
warnSpy.mockRestore();
});
// -----------------------------------------------------------------------
// Test 7: Nested provider tracking
// -----------------------------------------------------------------------
it("correctly tracks nested providers through multiple levels", () => {
let capturedProviders: string[] = [];
function InnerComponent(): React.JSX.Element {
capturedProviders = getCurrentProviders();
return <div data-testid="inner">Inner</div>;
}
const composition = (
<div>
{createProviderTracker("ThemeProvider", 0,
createProviderTracker("FluentProvider", 1,
createProviderTracker("NotificationProvider", 2,
createProviderTracker("ErrorBoundary", 3,
createProviderTracker("BrowserRouter", 4, <InnerComponent />)
)
)
)
)}
</div>
);
const { getByTestId } = render(composition);
expect(getByTestId("inner")).toBeInTheDocument();
expect(capturedProviders).toEqual([
"ThemeProvider",
"FluentProvider",
"NotificationProvider",
"ErrorBoundary",
"BrowserRouter",
]);
});
// -----------------------------------------------------------------------
// Test 8: Validation errors are descriptive
// -----------------------------------------------------------------------
it("provides helpful error messages for missing providers", () => {
let errorCaught = false;
function Component(): React.JSX.Element {
try {
validateProvidersExist(["AuthProvider", "TimezoneProvider"]);
} catch (error) {
if (error instanceof Error) {
expect(error.message).toMatch(/missing required providers/i);
errorCaught = true;
}
}
return <div>Error caught</div>;
}
render(<Component />);
expect(errorCaught).toBe(true);
});
it("provides helpful error messages for incorrect positioning", () => {
let errorCaught = false;
function Component(): React.JSX.Element {
try {
validateProviderPosition("AuthProvider", 6);
} catch (error) {
if (error instanceof Error) {
expect(error.message).toMatch(/missing required parent/i);
errorCaught = true;
}
}
return <div>Error caught</div>;
}
render(<Component />);
expect(errorCaught).toBe(true);
});
// -----------------------------------------------------------------------
// Test 9: Invalid position numbers are rejected
// -----------------------------------------------------------------------
it("throws error for negative position", () => {
function Component(): React.JSX.Element {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
validateProviderPosition("InvalidProvider" as any, -1);
} catch (error) {
if (error instanceof Error) {
expect(error.message).toMatch(/invalid expected position/i);
}
}
return <div>Error caught</div>;
}
render(<Component />);
});
it("throws error for position beyond sequence length", () => {
function Component(): React.JSX.Element {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
validateProviderPosition("InvalidProvider" as any, 999);
} catch (error) {
if (error instanceof Error) {
expect(error.message).toMatch(/invalid expected position/i);
}
}
return <div>Error caught</div>;
}
render(<Component />);
});
});

View File

@@ -0,0 +1,373 @@
/**
* Provider Composition Utility — Compile-Time Provider Ordering Validation
*
* This module enforces the provider nesting order at compile-time using TypeScript's
* discriminated unions. The provider hierarchy is strictly enforced and cannot be
* violated without TypeScript errors.
*
* Provider Order (enforced at compile-time):
* 1. ThemeProvider (OUTERMOST — must be first)
* 2. FluentProvider (wraps all Fluent UI consumers)
* 3. NotificationProvider (must wrap error boundaries)
* 4. ErrorBoundary (top-level error boundary)
* 5. BrowserRouter (enables routing)
* 6. NavigationCancellationProvider (manages route cancellation)
* 7. AuthProvider (session validation)
* 8. TimezoneProvider (INNERMOST — wraps protected routes only)
*
* Usage:
* ```tsx
* const providerStack = createProviderComposition()
* .withTheme({ children })
* .withFluent(theme)
* .withNotification()
* .build();
* ```
*
* TypeScript will prevent any attempt to add providers out of order:
* ```tsx
* const invalid = createProviderComposition()
* .withFluent(theme) // ❌ TypeScript Error: cannot call withFluent before withTheme
* .withTheme({ children });
* ```
*/
import type { ReactElement, ReactNode } from "react";
import { FluentProvider } from "@fluentui/react-components";
import type { Theme } from "@fluentui/react-components";
import { BrowserRouter } from "react-router-dom";
import { ThemeProvider } from "./ThemeProvider";
import { AuthProvider } from "./AuthProvider";
import { TimezoneProvider } from "./TimezoneProvider";
import { NavigationCancellationProvider } from "./NavigationCancellationProvider";
import { NotificationProvider } from "../services/notificationService";
import { ErrorBoundary } from "../components/ErrorBoundary";
import { NotificationContainer } from "../components/NotificationContainer";
// ============================================================================
// Type Definitions — Discriminated Union Pattern
// ============================================================================
/**
* State 0: Initial state — only ThemeProvider can be added
*/
interface ProviderState0 {
readonly __state: 0;
}
/**
* State 1: ThemeProvider added — FluentProvider can be added next
*/
interface ProviderState1 {
readonly __state: 1;
readonly themeChildren: ReactNode;
}
/**
* State 2: FluentProvider added — NotificationProvider can be added next
*/
interface ProviderState2 {
readonly __state: 2;
readonly themeChildren: ReactNode;
readonly theme: Theme;
}
/**
* State 3: NotificationProvider added — ErrorBoundary can be added next
*/
interface ProviderState3 {
readonly __state: 3;
readonly themeChildren: ReactNode;
readonly theme: Theme;
}
/**
* State 4: ErrorBoundary added — BrowserRouter can be added next
*/
interface ProviderState4 {
readonly __state: 4;
readonly themeChildren: ReactNode;
readonly theme: Theme;
}
/**
* State 5: BrowserRouter added — NavigationCancellationProvider can be added next
*/
interface ProviderState5 {
readonly __state: 5;
readonly themeChildren: ReactNode;
readonly theme: Theme;
}
/**
* State 6: NavigationCancellationProvider added — AuthProvider can be added next
*/
interface ProviderState6 {
readonly __state: 6;
readonly themeChildren: ReactNode;
readonly theme: Theme;
}
/**
* State 7: AuthProvider added — TimezoneProvider can be added next (or build can be called)
*/
interface ProviderState7 {
readonly __state: 7;
readonly themeChildren: ReactNode;
readonly theme: Theme;
}
/**
* State 8: TimezoneProvider added — ready to build final component
*/
interface ProviderState8 {
readonly __state: 8;
readonly themeChildren: ReactNode;
readonly theme: Theme;
readonly timezoneChildren: ReactNode;
}
type ProviderCompositionState = ProviderState0 | ProviderState1 | ProviderState2 | ProviderState3 | ProviderState4 | ProviderState5 | ProviderState6 | ProviderState7 | ProviderState8;
// ============================================================================
// Builder — Type-Safe Provider Composition
// ============================================================================
/**
* Type-safe builder for composing providers in the correct order.
*
* Each method returns a builder in the next state, ensuring providers
* can only be added in the documented order.
*/
class ProviderCompositionBuilder<TState extends ProviderCompositionState> {
/**
* Create a new builder in its initial state.
*/
static create(): ProviderCompositionBuilder<ProviderState0> {
return new ProviderCompositionBuilder({ __state: 0 });
}
private constructor(private state: TState) {}
/**
* Add ThemeProvider (Step 1: OUTERMOST).
*
* This must be called first. ThemeProvider supplies theme context
* to all descendants via useThemeMode().
*/
withTheme(
this: ProviderCompositionBuilder<ProviderState0>,
props: { children: ReactNode }
): ProviderCompositionBuilder<ProviderState1> {
return new ProviderCompositionBuilder({
__state: 1,
themeChildren: props.children,
} as unknown as ProviderState1);
}
/**
* Add FluentProvider (Step 2).
*
* Must come after ThemeProvider. Supplies Fluent UI theme and design tokens
* to all Fluent UI components.
*/
withFluent(
this: ProviderCompositionBuilder<ProviderState1>,
theme: Theme
): ProviderCompositionBuilder<ProviderState2> {
return new ProviderCompositionBuilder({
__state: 2,
themeChildren: this.state.themeChildren,
theme,
} as unknown as ProviderState2);
}
/**
* Add NotificationProvider (Step 3).
*
* Must come after FluentProvider. Makes useNotification() available
* to error boundaries and other components.
*/
withNotification(
this: ProviderCompositionBuilder<ProviderState2>
): ProviderCompositionBuilder<ProviderState3> {
return new ProviderCompositionBuilder({
__state: 3,
themeChildren: this.state.themeChildren,
theme: this.state.theme,
} as unknown as ProviderState3);
}
/**
* Add ErrorBoundary (Step 4).
*
* Must come after NotificationProvider. Catches catastrophic errors
* that would crash the entire app.
*/
withErrorBoundary(
this: ProviderCompositionBuilder<ProviderState3>
): ProviderCompositionBuilder<ProviderState4> {
return new ProviderCompositionBuilder({
__state: 4,
themeChildren: this.state.themeChildren,
theme: this.state.theme,
} as unknown as ProviderState4);
}
/**
* Add BrowserRouter (Step 5).
*
* Must come after ErrorBoundary. Enables client-side routing and
* supports useNavigate() in downstream providers.
*/
withBrowserRouter(
this: ProviderCompositionBuilder<ProviderState4>
): ProviderCompositionBuilder<ProviderState5> {
return new ProviderCompositionBuilder({
__state: 5,
themeChildren: this.state.themeChildren,
theme: this.state.theme,
} as unknown as ProviderState5);
}
/**
* Add NavigationCancellationProvider (Step 6).
*
* Must come after BrowserRouter. Manages route-aware request cancellation
* using useLocation() to detect route changes.
*/
withNavigationCancellation(
this: ProviderCompositionBuilder<ProviderState5>
): ProviderCompositionBuilder<ProviderState6> {
return new ProviderCompositionBuilder({
__state: 6,
themeChildren: this.state.themeChildren,
theme: this.state.theme,
} as unknown as ProviderState6);
}
/**
* Add AuthProvider (Step 7).
*
* Must come after NavigationCancellationProvider. Validates session
* on mount and provides useAuth() context to all descendants.
*/
withAuth(
this: ProviderCompositionBuilder<ProviderState6>
): ProviderCompositionBuilder<ProviderState7> {
return new ProviderCompositionBuilder({
__state: 7,
themeChildren: this.state.themeChildren,
theme: this.state.theme,
} as unknown as ProviderState7);
}
/**
* Add TimezoneProvider (Step 8: INNERMOST).
*
* Optional. Must come after AuthProvider if used. Wraps protected routes
* and fetches timezone from the backend.
*/
withTimezone(
this: ProviderCompositionBuilder<ProviderState7>,
props: { children: ReactNode }
): ProviderCompositionBuilder<ProviderState8> {
return new ProviderCompositionBuilder({
__state: 8,
themeChildren: this.state.themeChildren,
theme: this.state.theme,
timezoneChildren: props.children,
} as unknown as ProviderState8);
}
/**
* Build the final composed provider tree without TimezoneProvider.
* Use this for routes that don't need timezone data (e.g., setup, login).
*/
build(
this: ProviderCompositionBuilder<ProviderState7>,
innerContent: ReactNode
): ReactElement {
const s = this.state;
return (
<ThemeProvider>
<FluentProvider theme={s.theme}>
<NotificationProvider>
<ErrorBoundary
title="Application Error"
message="The application encountered a critical error. Reloading may help."
isFullPage={true}
>
<NotificationContainer />
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<NavigationCancellationProvider>
<AuthProvider>
{innerContent}
</AuthProvider>
</NavigationCancellationProvider>
</BrowserRouter>
</ErrorBoundary>
</NotificationProvider>
</FluentProvider>
</ThemeProvider>
);
}
/**
* Build the final composed provider tree with TimezoneProvider.
* Use this for protected routes that need timezone data.
*/
buildWithTimezone(
this: ProviderCompositionBuilder<ProviderState8>
): ReactElement {
const s = this.state;
return (
<ThemeProvider>
<FluentProvider theme={s.theme}>
<NotificationProvider>
<ErrorBoundary
title="Application Error"
message="The application encountered a critical error. Reloading may help."
isFullPage={true}
>
<NotificationContainer />
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<NavigationCancellationProvider>
<AuthProvider>
<TimezoneProvider>
{s.timezoneChildren}
</TimezoneProvider>
</AuthProvider>
</NavigationCancellationProvider>
</BrowserRouter>
</ErrorBoundary>
</NotificationProvider>
</FluentProvider>
</ThemeProvider>
);
}
}
export { ProviderCompositionBuilder };
/**
* Factory function to create a new provider composition builder.
*
* @returns A new builder in state 0 (ready for ThemeProvider)
*
* @example
* ```tsx
* const providerStack = createProviderComposition()
* .withTheme({ children })
* .withFluent(theme)
* .withNotification()
* .withErrorBoundary()
* .withBrowserRouter()
* .withNavigationCancellation()
* .withAuth()
* .build(routes);
* ```
*/
export function createProviderComposition(): ProviderCompositionBuilder<ProviderState0> {
return ProviderCompositionBuilder.create();
}

View File

@@ -0,0 +1,322 @@
/**
* Provider Ordering Runtime Validator
*
* This utility validates that providers are correctly nested at runtime,
* serving as a safety net for development when compile-time checks are bypassed
* (e.g., through type assertions or dynamic composition).
*
* Usage:
* ```tsx
* function AppContents() {
* validateProviderOrder(); // Throws if called outside expected provider context
* // ... rest of component
* }
* ```
*/
import { useContext, createContext, useEffect } from "react";
import type { ReactNode } from "react";
// ============================================================================
// Provider Tracking Context
// ============================================================================
interface ProviderOrderContext {
providers: Set<string>;
}
const ProviderOrderTrackingContext = createContext<ProviderOrderContext | null>(null);
/**
* Get the current provider order tracking context
*/
function getProviderOrderTracking(): ProviderOrderContext | null {
try {
// eslint-disable-next-line react-hooks/rules-of-hooks
return useContext(ProviderOrderTrackingContext);
} catch {
// useContext can only be called within a component
return null;
}
}
/**
* Allowed provider order sequence. Any deviation is an error.
*/
const PROVIDER_SEQUENCE = [
"ThemeProvider",
"FluentProvider",
"NotificationProvider",
"ErrorBoundary",
"BrowserRouter",
"NavigationCancellationProvider",
"AuthProvider",
"TimezoneProvider",
] as const;
type ProviderName = (typeof PROVIDER_SEQUENCE)[number];
/**
* Validate that a provider is in the correct position relative to its siblings.
*
* Throws a descriptive error if:
* - A provider is nested in the wrong order
* - A required parent provider is missing
*
* @param providerName The name of the provider being validated
* @param expectedPosition The expected position in the provider sequence
* @throws {Error} If provider ordering is violated
*
* @example
* ```tsx
* export function AuthProvider({ children }: { children: ReactNode }): JSX.Element {
* validateProviderPosition("AuthProvider", 6);
* // ... provider implementation
* }
* ```
*/
export function validateProviderPosition(
providerName: ProviderName,
expectedPosition: number
): void {
if (expectedPosition < 0 || expectedPosition >= PROVIDER_SEQUENCE.length) {
throw new Error(
`[ProviderValidator] Invalid expected position ${String(expectedPosition)} for ${providerName}. ` +
`Expected position between 0 and ${String(PROVIDER_SEQUENCE.length - 1)}.`
);
}
const tracking = getProviderOrderTracking();
if (tracking === null && expectedPosition > 0) {
// Only allow position 0 (ThemeProvider) to have no tracking context
throw new Error(
`[ProviderValidator] ${providerName} (position ${String(expectedPosition)}) is missing required parent providers. ` +
`Expected parent: ${String(PROVIDER_SEQUENCE[expectedPosition - 1])}. ` +
`Provider order: ${PROVIDER_SEQUENCE.slice(0, expectedPosition).join(" → ")} → [${providerName}]`
);
}
if (tracking !== null && expectedPosition > 0) {
const requiredParent: ProviderName = PROVIDER_SEQUENCE[expectedPosition - 1] as ProviderName;
if (!tracking.providers.has(requiredParent)) {
const providerSlice: ProviderName[] = PROVIDER_SEQUENCE.slice(0, expectedPosition);
const missingProviders = providerSlice.filter(
(p: ProviderName) => !tracking.providers.has(p)
);
throw new Error(
`[ProviderValidator] ${providerName} (position ${String(expectedPosition)}) is missing required parent providers. ` +
`Missing: ${missingProviders.join(", ")}. ` +
`Expected order: ${PROVIDER_SEQUENCE.slice(0, expectedPosition).join(" → ")} → [${providerName}]`
);
}
}
}
/**
* Validate that all required providers are present in the component tree.
*
* Use this in components that depend on multiple providers to ensure
* they're all available.
*
* @param requiredProviders Array of provider names that must be present
* @throws {Error} If any required provider is missing
*
* @example
* ```tsx
* function Dashboard(): JSX.Element {
* validateProvidersExist(["ThemeProvider", "AuthProvider", "NotificationProvider"]);
* // ... component that uses all three providers
* }
* ```
*/
export function validateProvidersExist(requiredProviders: ProviderName[]): void {
const tracking = getProviderOrderTracking();
if (tracking === null) {
throw new Error(
`[ProviderValidator] Missing required providers: ${requiredProviders.join(", ")}. ` +
`Ensure your component is wrapped with: ${requiredProviders.join(" → ")}`
);
}
const missing = requiredProviders.filter((p) => !tracking.providers.has(p));
if (missing.length > 0) {
throw new Error(
`[ProviderValidator] Missing required providers: ${missing.join(", ")}. ` +
`Ensure your component is wrapped with: ${requiredProviders.join(" → ")}`
);
}
}
/**
* Check if a provider is present in the current component tree.
*
* @param providerName Name of the provider to check
* @returns true if the provider is in the current tree, false otherwise
*
* @example
* ```tsx
* function MyComponent(): JSX.Element {
* if (hasProvider("AuthProvider")) {
* const auth = useAuth();
* // ... use auth
* }
* }
* ```
*/
export function hasProvider(providerName: ProviderName): boolean {
const tracking = getProviderOrderTracking();
return tracking !== null && tracking.providers.has(providerName);
}
/**
* Get all providers currently in the component tree (in order).
*
* @returns Array of provider names from outermost to innermost
*
* @example
* ```tsx
* const providers = getCurrentProviders();
* console.log(providers); // ["ThemeProvider", "FluentProvider", "NotificationProvider", ...]
* ```
*/
export function getCurrentProviders(): ProviderName[] {
const tracking = getProviderOrderTracking();
if (tracking === null) {
return [];
}
// Return providers in the order they appear in PROVIDER_SEQUENCE
return PROVIDER_SEQUENCE.filter((p) => tracking.providers.has(p));
}
/**
* Create a tracking wrapper for any provider.
*
* This allows runtime validation of provider nesting order.
* Call this from within any provider component.
*
* @param providerName Name of the provider
* @param expectedPosition Position in the provider sequence
* @param children Child components
* @returns JSX element with provider order tracking
*
* @internal Use this in provider implementations to enable validation
*
* @example
* ```tsx
* export function CustomProvider({ children }: { children: ReactNode }): JSX.Element {
* return createProviderTracker("CustomProvider", 5, children);
* }
* ```
*/
export function createProviderTracker(
providerName: ProviderName,
expectedPosition: number,
children: ReactNode
): React.JSX.Element {
return <ProviderOrderTracker name={providerName} position={expectedPosition} children={children} />;
}
/**
* Internal component that tracks provider nesting order.
*/
function ProviderOrderTracker({
name,
position,
children,
}: {
name: ProviderName;
position: number;
children: ReactNode;
}): React.JSX.Element {
const parentTracking = getProviderOrderTracking();
// Validate position
if (position < 0 || position >= PROVIDER_SEQUENCE.length) {
throw new Error(
`[ProviderValidator] Invalid position ${String(position)} for provider ${name}. ` +
`Expected 0-${String(PROVIDER_SEQUENCE.length - 1)}.`
);
}
// Validate parent providers
if (position > 0) {
const expectedParent: ProviderName = PROVIDER_SEQUENCE[position - 1] as ProviderName;
if (parentTracking === null || !parentTracking.providers.has(expectedParent)) {
const providerSlice: ProviderName[] = PROVIDER_SEQUENCE.slice(0, position);
const missing = providerSlice.filter(
(p: ProviderName) => parentTracking === null || !parentTracking.providers.has(p)
);
throw new Error(
`[ProviderValidator] ${name} (position ${String(position)}) has incorrect parent structure. ` +
`Missing: ${missing.join(", ")}. ` +
`Expected: ${PROVIDER_SEQUENCE.slice(0, position).join(" → ")} → [${name}]`
);
}
}
// Create new tracking set including this provider
const newTracking: ProviderOrderContext = {
providers: new Set(parentTracking?.providers ?? []),
};
newTracking.providers.add(name);
return (
<ProviderOrderTrackingContext.Provider value={newTracking}>
{children}
</ProviderOrderTrackingContext.Provider>
);
}
/**
* Development-only hook to verify a component is wrapped by required providers.
*
* Use this in development to catch provider ordering issues early.
*
* @param componentName Name of the component (for error messages)
* @param requiredProviders Array of required provider names
* @throws {Error} in development if any required provider is missing
*
* @example
* ```tsx
* function JailsPage(): JSX.Element {
* useProviderValidation("JailsPage", ["ThemeProvider", "AuthProvider"]);
* // ... component implementation
* }
* ```
*/
export function useProviderValidation(
componentName: string,
requiredProviders: ProviderName[]
): void {
useEffect(() => {
if (process.env.NODE_ENV === "development") {
const tracking = getProviderOrderTracking();
if (tracking === null) {
console.warn(
`[ProviderValidator] ${componentName} requires providers but none were found. ` +
`Required: ${requiredProviders.join(", ")}`
);
return;
}
const missing = requiredProviders.filter((p) => !tracking.providers.has(p));
if (missing.length > 0) {
console.warn(
`[ProviderValidator] ${componentName} is missing providers: ${missing.join(", ")}. ` +
`Currently available: ${Array.from(tracking.providers).join(", ")}`
);
}
}
}, [componentName, requiredProviders]);
}
export { ProviderOrderTrackingContext };