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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { headers } from 'next/headers';
import { Orbitron, Exo_2 } from 'next/font/google';
import { ErrorBoundary } from '../components/ErrorBoundary';
import { OfflineBanner } from '../components/OfflineBanner';
import { AxeAccessibility } from '../components/AxeAccessibility';
import { WalletProvider } from '../lib/wallet/WalletProvider';
import { darkModeInitScript } from '../lib/darkMode';
import '../styles/tokens.css';
Expand Down Expand Up @@ -44,6 +45,8 @@ export default async function RootLayout({ children }: { children: ReactNode })
<script nonce={nonce} dangerouslySetInnerHTML={{ __html: darkModeInitScript }} />
</head>
<body>
{/* Dev-only @axe-core/react checker; tree-shaken out of prod bundles. */}
<AxeAccessibility />
<OfflineBanner />
<ErrorBoundary section="main">
<WalletProvider>{children}</WalletProvider>
Expand Down
26 changes: 26 additions & 0 deletions frontend/src/components/AxeAccessibility.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use client';

import * as React from 'react';
import { useEffect } from 'react';
import { reportAccessibility } from '../lib/reportAccessibility';

/**
* Renders nothing in the DOM. On mount — and only in a development client
* build — it wires up @axe-core/react so axe violations show up in the
* DevTools console as you develop.
*
* Rendered directly inside the root `<body>` (see src/app/layout.tsx) so it is
* part of the top-level React tree and shares the app's React/ReactDOM
* instances with @axe-core/react. It is fully eliminated from the production
* bundle: the NODE_ENV guard inside `reportAccessibility` becomes `false` at
* build time and the guarded imports are tree-shaken out.
*/
export function AxeAccessibility() {
useEffect(() => {
void reportAccessibility(React);
}, []);

return null;
}

export default AxeAccessibility;
52 changes: 52 additions & 0 deletions frontend/src/lib/__tests__/reportAccessibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// The React instance the harness passes through to @axe-core/react.
const fakeReact = { __marker: true } as unknown as typeof import('react');

jest.mock('@axe-core/react', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('react-dom', () => ({ __isMockDOM: true }));

describe('reportAccessibility', () => {
// Load fresh module state per test so the internal `initialized` singleton
// guard resets between cases.
const load = () => {
jest.resetModules();
const mod = require('../reportAccessibility') as typeof import('../reportAccessibility');
const axeDefault = (require('@axe-core/react') as { default: jest.Mock }).default;
axeDefault.mockClear();
return { reportAccessibility: mod.reportAccessibility, axeDefault };
};

it('is a no-op outside a development build (production env)', async () => {
const { reportAccessibility, axeDefault } = load();
jest.replaceProperty(process.env, 'NODE_ENV', 'production');

await reportAccessibility(fakeReact);

expect(axeDefault).not.toHaveBeenCalled();
});

it('initializes @axe-core/react in a development build', async () => {
const { reportAccessibility, axeDefault } = load();
jest.replaceProperty(process.env, 'NODE_ENV', 'development');

const config = { rules: { 'color-contrast': { enabled: true } } };
await reportAccessibility(fakeReact, config);

expect(axeDefault).toHaveBeenCalledTimes(1);
const [reactArg, domArg, timeout, cfg] = axeDefault.mock.calls[0];
expect(reactArg).toBe(fakeReact);
expect(domArg.__isMockDOM).toBe(true);
expect(timeout).toBe(1000);
expect(cfg).toEqual(config);
});

it('only initializes once even when called multiple times', async () => {
const { reportAccessibility, axeDefault } = load();
jest.replaceProperty(process.env, 'NODE_ENV', 'development');

await reportAccessibility(fakeReact);
await reportAccessibility(fakeReact);
await reportAccessibility(fakeReact);

expect(axeDefault).toHaveBeenCalledTimes(1);
});
});
43 changes: 43 additions & 0 deletions frontend/src/lib/reportAccessibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Dev-only accessibility harness for @axe-core/react.
*
* @axe-core/react monkey-patches ReactDOM so it re-scans the committed DOM
* after each render and logs any axe violations to the DevTools console. It
* catches issues locally during development before they ever reach CI's
* `accessibility.yml`, which runs the heavyweight axe/Lighthouse/pa11y audits.
*
* Everything here is a no-op under `next build`: Next.js statically replaces
* `process.env.NODE_ENV === 'development'` with `false` at build time, so the
* guarded branch — including the dynamic `@axe-core/react` and `react-dom`
* imports — is dead-code-eliminated and tree-shaken out of the production
* bundle. Only the unguarded harness (a handful of bytes) remains.
*/

// Ensure we only ever initialize axe once, so multiple client re-mounts
// (Fast Refresh, route transitions) don't patch ReactDOM repeatedly.
let initialized = false;

/**
* Initialize @axe-core/react in a development client build.
*
* Must be called from a client component that shares the app's React/ReactDOM
* instances. `config` is forwarded to axe and lets callers restrict rules; the
* default runs axe's standard WCAG rule set.
*/
export async function reportAccessibility(
ReactModule: typeof import('react'),
config?: Record<string, unknown>
): Promise<void> {
if (process.env.NODE_ENV !== 'development') return;
if (typeof window === 'undefined') return;
if (initialized) return;

initialized = true;

const axe = await import('@axe-core/react');
const ReactDOM = await import('react-dom');

// (ReactModule, ReactDOM, timeoutMs, config) — timeout is how long axe waits
// after a render before scanning, to avoid throttling during bursty updates.
axe.default(ReactModule, ReactDOM, 1000, config);
}