Refactor provider composition and ESLint configuration
- Add new provider composition system with validation - Create providerComposition.tsx for centralized provider management - Implement providerOrderValidator.tsx to ensure correct provider order - Add comprehensive tests for provider composition - Create custom ESLint rules in frontend/eslint-rules/ - Update ESLint configuration - Update architecture and tasks documentation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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/`)
|
||||
|
||||
|
||||
@@ -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**
|
||||
|
||||
14
frontend/eslint-rules/index.js
Normal file
14
frontend/eslint-rules/index.js
Normal 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,
|
||||
},
|
||||
};
|
||||
172
frontend/eslint-rules/provider-order.js
Normal file
172
frontend/eslint-rules/provider-order.js
Normal 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(" → "),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
359
frontend/src/providers/__tests__/providerOrderValidator.test.tsx
Normal file
359
frontend/src/providers/__tests__/providerOrderValidator.test.tsx
Normal 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 />);
|
||||
});
|
||||
});
|
||||
373
frontend/src/providers/providerComposition.tsx
Normal file
373
frontend/src/providers/providerComposition.tsx
Normal 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();
|
||||
}
|
||||
322
frontend/src/providers/providerOrderValidator.tsx
Normal file
322
frontend/src/providers/providerOrderValidator.tsx
Normal 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 };
|
||||
Reference in New Issue
Block a user