diff --git a/.claude/skills/update-deps/denied.md b/.claude/skills/update-deps/denied.md index caf94f0..fd5e27d 100644 --- a/.claude/skills/update-deps/denied.md +++ b/.claude/skills/update-deps/denied.md @@ -2,4 +2,4 @@ | Package | Denied Version | Reason | Date | |---------|---------------|--------|------| -| metrics-graphics | 3.0.0-beta1 | Beta rewrite, incompatible with react-metrics-graphics | 2026-10-15 | +| metrics-graphics | 3.0.0-beta1 | Beta rewrite of the 2.x `MG.data_graphic` API that `views/MetricsGraphics.jsx` calls | 2026-10-15 | diff --git a/.env b/.env index e4e8be8..4a0ac17 100644 --- a/.env +++ b/.env @@ -1,3 +1,3 @@ -VITE_GA_TRACKING_ID='UA-00000000-0' +VITE_GA_MEASUREMENT_ID='G-XXXXXXXXXX' VITE_SITE_TITLE='Firefox Public Data Report' VITE_VALUE_DECIMAL_PLACES=3 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index cb8fcbf..6c51023 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -16,25 +16,18 @@ updates: - package-ecosystem: "npm" directory: "/" ignore: - # React majors need coordinated changes (react-refetch, the router, the test libraries below), so they're done by hand. + # React 19 makes charts appear about 600 ms later: its 300 ms Suspense reveal throttle stacks on the + # lazily loaded route and chart. Move only after restructuring that lazy loading. - dependency-name: "react" update-types: ["version-update:semver-major"] - dependency-name: "react-dom" update-types: ["version-update:semver-major"] - # @testing-library/react 13+ requires React 18, and @testing-library/dom is pinned to the 8.x that 12.x uses. - - dependency-name: "@testing-library/react" - update-types: ["version-update:semver-major"] - - dependency-name: "@testing-library/dom" - update-types: ["version-update:semver-major"] - # 3.x is a rewrite that's incompatible with react-metrics-graphics (see update-deps/denied.md). + # 3.x rewrites the MG.data_graphic API that views/MetricsGraphics.jsx calls (see update-deps/denied.md). - dependency-name: "metrics-graphics" update-types: ["version-update:semver-major"] - # v6+ is a rewrite, and v7 requires React 18. - - dependency-name: "react-router-dom" + # v8 requires React 19.2.7 or later. + - dependency-name: "react-router" update-types: ["version-update:semver-major"] - # 6.10.0+ requires @testing-library/dom 10, which is pinned to 8.x (see above). - - dependency-name: "@testing-library/jest-dom" - versions: [">=6.10.0"] # eslint-plugin-react and eslint-plugin-jsx-a11y don't support ESLint 10 yet; remove once they do. - dependency-name: "eslint" update-types: ["version-update:semver-major"] diff --git a/AGENTS.md b/AGENTS.md index 98bee33..005c779 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Project Structure & Module Organization -`ensemble` (v1.2.1, `private: true`, MPL-2.0, `git@github.com:mozilla/ensemble.git`) is the React app behind — the **Firefox Public Data Report**. It is a **pure client-side React 16 SPA, built with Vite, and it contains no data of its own.** Every metric is fetched at runtime from **[ensemble-transposer](https://github.com/mozilla/ensemble-transposer)** (separate repo), which reformats Mozilla's public telemetry and serves JSON from the same `data.firefox.com` domain. If a number on the site looks wrong, the bug is usually not here. +`ensemble` (v1.2.1, `private: true`, MPL-2.0, `git@github.com:mozilla/ensemble.git`) is the React app behind — the **Firefox Public Data Report**. It is a **pure client-side React 18 SPA, built with Vite, and it contains no data of its own.** Every metric is fetched at runtime from **[ensemble-transposer](https://github.com/mozilla/ensemble-transposer)** (separate repo), which reformats Mozilla's public telemetry and serves JSON from the same `data.firefox.com` domain. If a number on the site looks wrong, the bug is usually not here. **`src/config.json` is the spine.** Two arrays, and almost every structural change starts by editing one of them: @@ -13,7 +13,7 @@ **Adding a dashboard is adding one entry to `dashboards`.** The `source` values are **absolute production URLs baked into the repo** — there is no env var for the data source, so you cannot point the app at a local transposer without editing `config.json`. -**Data flow** (no state library, no hooks, no Redux — `this.state` in class containers, plus a `sessionStorage` key `preferredRegion` set in `DashboardContainer.jsx`): +**Data flow** (no state library, no state hooks, no Redux — `this.state` in class containers, plus a `sessionStorage` key `preferredRegion` set in `DashboardContainer.jsx`): | Container | Fetches | Notes | |---|---|---| @@ -21,20 +21,20 @@ | `containers/MetricOverviewContainer.jsx` | `${dashboardSource}/${activeRegion}/${slug}/index.json` | title, description, `type: 'line'|'table'`, axes, columns, data, annotations | | `containers/SummaryMetricContainer.jsx` | same per-metric endpoint | buckets populations under 5% into "Other" | -`react-refetch@3.0.1`'s `connect()` HOC **is the entire data layer** and is used in exactly those three files. Each one handles `dataFetch.pending` / `.rejected` / `.fulfilled` itself and renders `views/Spinner.jsx` or `views/Error.jsx`. All reshaping is client-side in each container's `formatData` (`ChartContainer` sorts populations by max y and drops nulls; `DataTableContainer` sorts rows by value descending). +`src/lib/withFetch.jsx` **is the entire data layer** and is used in exactly those three files: `withFetch(props => url)(Container)` passes a `dataFetch` prop (`pending`, `rejected`, `fulfilled`, `value`, `reason`), refetches when the URL changes (back to pending first), rejects on non-2xx, and drops a response that arrives after a newer request started. Each one handles `dataFetch.pending` / `.rejected` / `.fulfilled` itself and renders `views/Spinner.jsx` or `views/Error.jsx`. All reshaping is client-side in each container's `formatData` (`ChartContainer` sorts populations by max y and drops nulls; `DataTableContainer` sorts rows by value descending). Metric `description` strings are rendered as inline Markdown through `markdown-it('zero')` with only `link`/`entity`/`emphasis`/`sup` enabled, via `dangerouslySetInnerHTML`. `src/tests/jest/MetricOverview.test.jsx` exists **specifically to guard that sanitization** — never widen the enabled-rules list without extending that test. ``` src/ -├── index.jsx entry: ReactDOM.render + BrowserRouter +├── index.jsx entry: createRoot + HelmetProvider + BrowserRouter ├── config.json the route/dashboard table + next-button flow ├── registerServiceWorker.js create-react-app leftover; only unregister() is called (SW deliberately off) -├── setupTests.js Vitest setup: puts React on global, jest-dom matchers, fails on console.error +├── setupTests.js Vitest setup: jest-dom matchers, fails on console.error ├── components/ │ ├── containers/ 6 stateful/data-fetching .jsx -│ ├── decorators/ withTracker.jsx (react-ga), withNextButton.jsx -│ └── views/ 19 presentational .jsx +│ ├── decorators/ withTracker.jsx (GA4 via react-ga4), withNextButton.jsx +│ └── views/ 20 presentational .jsx │ ├── styl/ Stylus SOURCE (hand-edited); includes/lib.styl │ ├── css/ GENERATED, GITIGNORED — holds only a README stub │ ├── fonts/FiraSans/ 8 weights/styles of webfonts @@ -42,9 +42,10 @@ src/ ├── lib/ │ ├── lazyLoad.jsx React.lazy wrapper │ ├── LazyBoundary.jsx error boundary + spinner for lazy-loaded components +│ ├── withFetch.jsx the data layer: fetches JSON and passes `dataFetch` to containers │ └── utils.js bumpSort, isFloat, prettifyNumber, getPageTitle └── tests/ - └── jest/ 2 unit test files, run by Vitest (the directory name predates Vitest) + └── jest/ unit tests, run by Vitest (the directory name predates Vitest) tests/playwright/ ├── specs/ e2e specs (.spec.js, CommonJS), incl. dashboards/ and fixtures/; run on the recording and on live data ├── fixtures/ recorded transposer JSON, written by `npm run record:fixtures` @@ -92,15 +93,15 @@ npm run size # source-map-explorer on build/assets/index Run one Vitest file: `npx vitest run src/tests/jest/Dashboard.test.jsx`. Run one Playwright spec: `npx playwright test contact.spec.js`. -**Environment:** `.env` **is checked into git** and holds only public build-time config — `VITE_GA_TRACKING_ID='UA-00000000-0'` (placeholder), `VITE_SITE_TITLE='Firefox Public Data Report'`, `VITE_VALUE_DECIMAL_PLACES=3`. Consumed through `import.meta.env` by `decorators/withTracker.jsx` and `lib/utils.js`. Override inline: `VITE_SITE_TITLE='…' npm start`. +**Environment:** `.env` **is checked into git** and holds only public build-time config — `VITE_GA_MEASUREMENT_ID='G-XXXXXXXXXX'` (placeholder; the real GA4 measurement ID is a deploy decision outside this repo), `VITE_SITE_TITLE='Firefox Public Data Report'`, `VITE_VALUE_DECIMAL_PLACES=3`. Consumed through `import.meta.env` by `decorators/withTracker.jsx` and `lib/utils.js`. Override inline: `VITE_SITE_TITLE='…' npm start`. ## Coding Style & Naming Conventions - **`.jsx` for any file containing JSX** — including `src/index.jsx` and `*.test.jsx`. Plain `.js` only for non-JSX modules (`lib/utils.js`, `setupTests.js`, `registerServiceWorker.js`, the Playwright specs and helpers, and the config files at the repository root). -- **PascalCase filenames matching the default-exported component.** Decorators are camelCase with a `with` prefix; `lib/lazyLoad.jsx` is camelCase because it is a helper, not a component. +- **PascalCase filenames matching the default-exported component.** Decorators are camelCase with a `with` prefix; `lib/lazyLoad.jsx` and `lib/withFetch.jsx` are camelCase because they are helpers, not components. - **One component per file, always `export default`.** There are no named component exports anywhere in the repo; the only named exports at all are the four helpers in `lib/utils.js`. - **The container/view split is the organizing principle.** `containers/X.jsx` holds state and fetching and renders `views/X.jsx` with formatted props (`ChartContainer`→`Chart`, `DataTableContainer`→`DataTable`). Keep new state out of `views/`. -- Function components are the default (17 of 19 views), written as anonymous default-exported arrows — `export default props => (...)` — with no `displayName` (hence `react/display-name: off`). Use a class only where state, refs, or lifecycle are genuinely needed: all 6 containers plus `SummaryMetric.jsx` (d3 + `React.createRef`), `Spinner.jsx`, `MetricOverview.jsx`, `Footer.jsx`, and `lib/LazyBoundary.jsx` (error boundaries must be classes). +- Function components are the default (15 of 20 views), written as anonymous default-exported arrows — `export default props => (...)` — with no `displayName` (hence `react/display-name: off`). The router is react-router 7, imported from `react-router` (not `react-router-dom`), and read with its `useLocation()` hook, in `views/Main.jsx` and both decorators (`withTracker` sends its page view from a `useEffect` on the pathname). Use a class only where state, refs, or lifecycle are genuinely needed: all 6 containers plus `SummaryMetric.jsx` (d3 + `React.createRef`), `MetricsGraphics.jsx` (metrics-graphics + `React.createRef`), `Spinner.jsx`, `MetricOverview.jsx`, `Footer.jsx`, and `lib/LazyBoundary.jsx` (error boundaries must be classes). - Leading underscore for private class-property handlers (`_onRegionChange`, `_drawChart`, `_setChartWidth`) — applied inconsistently (`setChartSize` in `ChartContainer.jsx`); prefer the underscore in new code. - Locals named `maybeX` hold a JSX fragment **or `null`** (`maybeDescription`, `maybeSummaryMetrics`, `maybeRegion`, `maybeGraphURL`). Keep the idiom. - Styles attach via a global CSS side-effect import of the **generated** file at the top of the component: `import './css/Dashboard.css';`. Some components import several (`Chart.jsx` pulls `metrics-graphics/dist/metricsgraphics.css`, `./css/Chart.css`, `./css/Metric.css`). `Metric.css` and `LabelledSelector.css` are shared partials with no component of their own. @@ -110,7 +111,7 @@ Run one Vitest file: `npx vitest run src/tests/jest/Dashboard.test.jsx`. Run one **ESLint is one flat config, `eslint.config.js`, covering `.js` and `.jsx`.** `npm run lint:js` runs `eslint .`; the build does not lint. Warnings don't fail the run. -Errors: `eqeqeq`, `no-var`, `prefer-const`, `no-console`, `no-global-assign`, `no-redeclare` and `no-shadow` — the latter two with `builtinGlobals: true`, so **do not name a variable `name`, `status`, `history`, `event`, etc.** Warnings: `semi: always`, `prefer-arrow-callback`, `comma-dangle` (trailing commas **required** for multiline arrays/objects/imports/exports, **never** for function args/params). Off: `react/prop-types`, `react/display-name`, `jsx-a11y/no-onchange`. `react/no-unescaped-entities` forbids only `>` and `}`. `jsx-a11y/recommended` is on, so a11y violations are errors; `jsx-a11y/label-has-associated-control` requires `htmlFor`. `settings.react.version` is `'detect'`. `src/tests/jest/**/*.test.jsx` also gets `@vitest/eslint-plugin`'s recommended rules, the `React` global, and a `no-shadow` exception for React Testing Library's `screen` (which otherwise collides with `window.screen`). **Quote style is not enforced** (there is no `quotes` rule) — single quotes by convention. The config's own `ignores` excludes `build`. +Errors: `eqeqeq`, `no-var`, `prefer-const`, `no-console`, `no-global-assign`, `no-redeclare` and `no-shadow` — the latter two with `builtinGlobals: true`, so **do not name a variable `name`, `status`, `history`, `event`, etc.** Warnings: `semi: always`, `prefer-arrow-callback`, `comma-dangle` (trailing commas **required** for multiline arrays/objects/imports/exports, **never** for function args/params). Off: `react/prop-types`, `react/display-name`, `jsx-a11y/no-onchange`. `react/no-unescaped-entities` forbids only `>` and `}`. `jsx-a11y/recommended` is on, so a11y violations are errors; `jsx-a11y/label-has-associated-control` requires `htmlFor`. `settings.react.version` is `'detect'`. `src/tests/jest/**/*.test.jsx` also gets `@vitest/eslint-plugin`'s recommended rules and a `no-shadow` exception for React Testing Library's `screen` (which otherwise collides with `window.screen`). **Quote style is not enforced** (there is no `quotes` rule) — single quotes by convention. The config's own `ignores` excludes `build`. ## Styling: Stylus, outside Vite @@ -144,12 +145,13 @@ This applies to everything written for a person to read: ExecPlans, PR descripti ## Testing Guidelines -**Vitest + React Testing Library**, configured in the `test` block of `vite.config.mjs` (`jsdom`, `globals: true`, `include: ['src/tests/jest/**/*.test.jsx']`). Files live in `src/tests/jest/`, named `PascalCase.test.jsx` — **not colocated, no `__tests__` directories.** `@testing-library/react` is pinned to 12.x, the last line that supports React 16; `@testing-library/dom` is pinned to the 8.x it uses so `@testing-library/user-event` shares the same copy. `src/setupTests.js` puts `React` on `global` (the classic JSX runtime needs it in scope), so **test files don't import React**; they import `render` and `screen` from `@testing-library/react`. It also registers the jest-dom matchers (`toHaveTextContent`, `toBeInTheDocument`), and **fails any test that calls `console.error` unexpectedly**, so React warnings surface as failures — a test that expects an error replaces `console.error` with `vi.spyOn(console, 'error').mockImplementation(() => {})`. Tests render the real component tree into jsdom and assert on the DOM (`container.querySelector('#dashboard-description')`, or `innerHTML` where the markup itself is the point, as in the sanitization test). Existing style is top-level `it(...)` with long descriptive sentences. No snapshots. +**Vitest + React Testing Library**, configured in the `test` block of `vite.config.mjs` (`jsdom`, `globals: true`, `include: ['src/tests/jest/**/*.test.jsx']`). Files live in `src/tests/jest/`, named `PascalCase.test.jsx` — **not colocated, no `__tests__` directories.** JSX uses the automatic runtime, so neither components nor tests import React just for JSX; tests import `render`, `screen`, and `waitFor` from `@testing-library/react`. `src/setupTests.js` registers the jest-dom matchers (`toHaveTextContent`, `toBeInTheDocument`), and **fails any test that calls `console.error` unexpectedly**, so React warnings surface as failures — a test that expects an error replaces `console.error` with `vi.spyOn(console, 'error').mockImplementation(() => {})`. Tests render the real component tree into jsdom and assert on the DOM (`container.querySelector('#dashboard-description')`, or `innerHTML` where the markup itself is the point, as in the sanitization test). Existing style is top-level `it(...)` with long descriptive sentences. No snapshots. -- **Stubbing the network:** `react-refetch` captures `window.fetch` when `connect()` runs, which is at module import. Setting `window.fetch` inside a test or `beforeEach` is silently ignored and the container hits the real network. Install the stub with `vi.hoisted(() => { const mock = vi.fn(); window.fetch = mock; return mock; })` so it exists before the imports, then `mockReset()` it in `beforeEach`. The container tests (`SummaryMetricContainer.test.jsx` is the simplest) show the pattern. +- **Stubbing the network:** `withFetch` calls `window.fetch(new Request(url, …))` each time it fetches. Stub it with `fetchMock = vi.spyOn(window, 'fetch')` in `beforeEach` (`setupTests.js`'s `vi.restoreAllMocks()` undoes it) and read URLs from `fetchMock.mock.calls[n][0].url`. The container tests (`SummaryMetricContainer.test.jsx` is the simplest) show the pattern. +- **Waiting for lazy routes:** wait with React Testing Library's `waitFor` / `findBy*`, never `vi.waitFor`. RTL turns off React's act() check while it waits, so a `React.lazy` component can resolve. Under `vi.waitFor`, or after a test that returns while a lazy route is still loading, React 18 logs "A suspended resource finished loading inside a test, but the event was not wrapped in act(...)", and the console.error trap turns that into an unhandled error that fails the run. - **Testing data shaping:** containers that hand formatted props to a view that needs real layout (`views/Chart` → metrics-graphics, `views/SummaryMetric` → d3) are tested by replacing the view with `vi.mock('../../components/views/Chart', () => ({ default: vi.fn(() => null) }))` and asserting on the props it was last called with. Views that are plain markup (`views/DataTable`) render for real. -- **Testing chart views:** `Chart.test.jsx` renders the real `views/Chart` with `vi.mock('react-metrics-graphics', () => ({ default: vi.fn(() => null) }))` and asserts on the options it computes (bounds, units, UTC formatting, legend, markers, colors). `SummaryMetric.test.jsx` renders the real d3 summary bar inside `
`, with `vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(490)` — jsdom reports 0px, and at 490px the bar draws at its 450px maximum, so segment widths are predictable. -- **Routing:** `Routes.test.jsx` renders `views/Application` inside `MemoryRouter` through one `renderAt(path)` function — the only place a router upgrade has to touch — and mocks `react-ga` so no analytics load. Its cases are generated from `config.json`, so adding a dashboard adds a route test. +- **Testing chart views:** `Chart.test.jsx` renders the real `views/Chart` with `vi.mock('../../components/views/MetricsGraphics', () => ({ default: vi.fn(() => null) }))` and asserts on the options it computes (bounds, units, UTC formatting, legend, markers, colors). `SummaryMetric.test.jsx` renders the real d3 summary bar inside `
`, with `vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(490)` — jsdom reports 0px, and at 490px the bar draws at its 450px maximum, so segment widths are predictable. +- **Routing:** `Routes.test.jsx` renders `views/Application` inside `MemoryRouter` through one `renderAt(path)` function — the only place a router upgrade has to touch — wraps it in react-helmet-async's `HelmetProvider` (any test that renders a page with a `` needs one — `render(ui, { wrapper: HelmetProvider })`), and mocks `react-ga4` so no analytics load. Its cases are generated from `config.json`, so adding a dashboard adds a route test. **Playwright** specs in `tests/playwright/specs/` are **`camelCase.spec.js` or `kebab-case.spec.js`, CommonJS**. Every spec except `jsDisabled.spec.js` imports `test`, `expect` (and, where needed, `readFixture`) from **`tests/playwright/fixtureData.js`**, not `@playwright/test`. `tests/playwright/utils.js` exports `linkWorks`, `linksWork`, `metricTitleIsCorrect`, `changeRegionAndWaitForMetrics`, and `flagForUpdate` — which **deliberately fails when an element count changes**, to force a human to look. @@ -231,7 +233,7 @@ Replacement, as it should read: - A side-effect worth knowing about but not risky — a duplicate file deleted, a stray import removed — is a plain bullet like everything else. - Fold low-risk, mechanical fallout — a regenerated lockfile, a renamed CSS class, a fixture update — into its own short item, kept brief and after the substantive bullets, so it doesn't crowd out what the reviewer actually needs to think about. - Flag genuine uncertainty about your own work — "I'm not sure this handles the region-selector edge case" — distinct from rating risk. Risk-rating is banned above; naming a real unknown is not, and is often the most useful sentence in the description. -- Never explain mechanics the team already knows: why `react-refetch` isn't mocked in a test, how the container/view split works, what `flagForUpdate` does. +- Never explain mechanics the team already knows: why `withFetch` isn't mocked in a test, how the container/view split works, what `flagForUpdate` does. - Never describe your own process. Not which approach you tried first, not what a review caught, not that the branch is stacked on another. Two things that look similar but are worth keeping: deliberately deferred scope — "Ports the Nightwatch specs as-is; re-baselining the stale content assertions is a separate pass" — and a change's provenance if it matters — "Matches the Playwright config shape already used in mozmeao/springfield and mozmeao/bedrock". **Issue / Bugzilla link.** The full tracker URL, or `n/a`. List all of them where a change closes more than one, and give the context on a cross-reference: "Follow-up to #409, addressing the WebKit-specific slowness noted while porting the e2e suite." diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f2f05af..142495a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,7 +30,7 @@ Run one Vitest file directly: `npx vitest run src/tests/jest/Dashboard.test.jsx` working). Run one Playwright spec directly: `npx playwright test contact.spec.js`. **Environment:** `.env` is checked into git and holds only public build-time config — -`VITE_GA_TRACKING_ID='UA-00000000-0'` (a placeholder), +`VITE_GA_MEASUREMENT_ID='G-XXXXXXXXXX'` (a placeholder for a GA4 measurement ID), `VITE_SITE_TITLE='Firefox Public Data Report'`, `VITE_VALUE_DECIMAL_PLACES=3`. These are consumed by `src/components/decorators/withTracker.jsx` and `src/lib/utils.js` via `import.meta.env.VITE_*` — Vite's own convention, not Node's `process.env` (which is not populated in the browser bundle at @@ -143,32 +143,32 @@ configures the Vite build) — `environment: 'jsdom'`, `globals: true`, `setupFi Vitest's own default file-discovery glob doesn't also try to run Playwright's `.spec.js` files. Test files live in `src/tests/jest/`, named `PascalCase.test.jsx` (the directory is still named `jest` even though Vitest is what actually runs these — a naming leftover from before this -repository's toolchain migration, not worth a rename on its own). `@testing-library/react` is pinned -to 12.x, the last line that supports React 16, and `@testing-library/dom` is pinned to the 8.x it -uses so `@testing-library/user-event` shares the same copy. `src/setupTests.js` puts `React` on the -global object, because the classic JSX runtime needs it in scope, so test files don't import it. -They import `render` and `screen` from `@testing-library/react` and assert on the rendered DOM — +repository's toolchain migration, not worth a rename on its own). JSX uses the automatic runtime, so test +files don't import React. They import `render` and `screen` from `@testing-library/react` and assert on the rendered DOM — `container.querySelector('#dashboard-description')`, or `innerHTML` where the markup itself is the -point, as in the sanitization test. The setup file also registers the jest-dom matchers +point, as in the sanitization test. Wait with React Testing Library's +`waitFor` or `findBy*`, not `vi.waitFor`: React 18 warns when a lazily loaded route finishes outside +React's act(), and only RTL's helpers allow for that while they wait. The setup file also registers the jest-dom matchers (`toHaveTextContent`, `toBeInTheDocument`) and fails any test that calls `console.error` unexpectedly, so React warnings show up as failures. A test that expects an error replaces it first with `vi.spyOn(console, 'error').mockImplementation(() => {})`. The existing style is top-level `it(...)` with long descriptive sentences. No snapshots. -`react-refetch` captures `window.fetch` when `connect()` runs, which is at module import, so a stub -set inside a test is silently ignored and the container goes to the real network. Install it with -`vi.hoisted(() => { const mock = vi.fn(); window.fetch = mock; return mock; })` so it exists before -the imports, and `mockReset()` it in `beforeEach`; `SummaryMetricContainer.test.jsx` is the -simplest example. Containers whose view needs real layout (`views/Chart` uses metrics-graphics, +Containers fetch through `src/lib/withFetch.jsx`, which calls `window.fetch` with a `Request` each +time it fetches. Stub it with `fetchMock = vi.spyOn(window, 'fetch')` in `beforeEach` and read the +URL from `fetchMock.mock.calls[n][0].url`; `SummaryMetricContainer.test.jsx` is the simplest +example. The setup file restores the spy after each test. Containers whose view needs real layout (`views/Chart` uses metrics-graphics, `views/SummaryMetric` draws with d3) are tested by replacing that view with `vi.mock` and asserting on the props it received. Plain-markup views such as `views/DataTable` render for real. -`Chart.test.jsx` renders the real `views/Chart` with `react-metrics-graphics` replaced by -`vi.mock('react-metrics-graphics', () => ({ default: vi.fn(() => null) }))`, and asserts on the +`Chart.test.jsx` renders the real `views/Chart` with `views/MetricsGraphics` replaced by +`vi.mock('../../components/views/MetricsGraphics', () => ({ default: vi.fn(() => null) }))`, and asserts on the options it computes. `SummaryMetric.test.jsx` renders the real d3 summary bar inside `
`, with `HTMLElement.prototype.offsetWidth` spied to 490: jsdom reports 0px, and at 490px the bar draws at its 450px maximum, so segment widths are predictable. `Routes.test.jsx` renders `views/Application` inside `MemoryRouter` through a single `renderAt(path)` -function, so a router upgrade changes one place, and mocks `react-ga`. Its route cases come from +function, so a router upgrade changes one place, wraps it in react-helmet-async's `HelmetProvider`, +and mocks `react-ga4`. Any test that renders a page with a `` needs that provider: +`render(ui, { wrapper: HelmetProvider })`. Its route cases come from `config.json`. ESLint allows the name `screen` in test files despite `no-shadow`'s `builtinGlobals`, because importing React Testing Library's `screen` is that library's documented idiom. @@ -348,7 +348,7 @@ Replacement, as it should read: actually needs to think about. - Flag genuine uncertainty about your own work — distinct from rating risk. Naming a real unknown is often the most useful sentence in the description. -- Never explain mechanics the team already knows: why `react-refetch` isn't mocked in a test, how +- Never explain mechanics the team already knows: why `withFetch` isn't mocked in a test, how the container/view split works, what `flagForUpdate` does. - Never describe your own process — not which approach you tried first, not what a review caught, not that the branch is stacked on another. Deliberately deferred scope is worth stating diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index 263f12c..a625261 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -34,9 +34,12 @@ This application has no state library, no hooks-based state, and no Redux — st | `containers/MetricOverviewContainer.jsx` | `${dashboardSource}/${activeRegion}/${slug}/index.json` | title, description, `type: 'line'` or `'table'`, axes, columns, data, annotations | | `containers/SummaryMetricContainer.jsx` | the same per-metric endpoint | buckets any population under 5% into an "Other" bucket | -`react-refetch@3.0.1`'s `connect()` higher-order component is the entire data-fetching layer and is -used in exactly those three files. Each one handles its fetch's pending, rejected, and fulfilled -states itself and renders `views/Spinner.jsx` or `views/Error.jsx` accordingly. All reshaping of +`src/lib/withFetch.jsx` is the entire data-fetching layer, used in exactly those three files. It is +a higher-order component: `withFetch(props => url)(Container)` fetches the URL as JSON and passes +the container a `dataFetch` prop holding `pending`, `rejected`, `fulfilled`, `value`, and `reason`. +It refetches when the URL changes, going back to pending first, treats a non-2xx response as a +rejection, and ignores a response that arrives after a newer request has started. Each container +handles those states itself and renders `views/Spinner.jsx` or `views/Error.jsx` accordingly. All reshaping of fetched data happens client-side in each container's `formatData` method — `ChartContainer` sorts its populations by maximum y-value and drops nulls; `DataTableContainer` sorts rows by value, descending. @@ -105,14 +108,14 @@ permanent list in this file, for the same reason the wider issue backlog isn't s ## File layout src/ - ├── index.jsx entry: ReactDOM.render + BrowserRouter + ├── index.jsx entry: createRoot + HelmetProvider + BrowserRouter ├── config.json the route/dashboard table + next-button flow (above) ├── registerServiceWorker.js only unregister() is called - ├── setupTests.js puts React on the global object; jest-dom matchers; fails on console.error + ├── setupTests.js jest-dom matchers; fails on console.error ├── components/ │ ├── containers/ 6 stateful, data-fetching .jsx files (above) - │ ├── decorators/ withTracker.jsx (react-ga), withNextButton.jsx - │ └── views/ 19 presentational .jsx files + │ ├── decorators/ withTracker.jsx (GA4 via react-ga4), withNextButton.jsx + │ └── views/ 20 presentational .jsx files │ ├── styl/ Stylus source, hand-edited; includes/lib.styl holds shared variables │ ├── css/ generated from styl/, gitignored — see CONTRIBUTING.md │ ├── fonts/FiraSans/ 8 weights/styles of webfonts @@ -120,9 +123,10 @@ permanent list in this file, for the same reason the wider issue backlog isn't s ├── lib/ │ ├── lazyLoad.jsx wraps a dynamic import() in React.lazy │ ├── LazyBoundary.jsx error boundary + Suspense fallback, paired with lazyLoad.jsx + │ ├── withFetch.jsx the data layer: fetches JSON and passes `dataFetch` to containers │ └── utils.js bumpSort, isFloat, prettifyNumber, getPageTitle └── tests/ - └── jest/ 2 unit test files, run by Vitest (see CONTRIBUTING.md) + └── jest/ unit tests, run by Vitest (see CONTRIBUTING.md) tests/ └── playwright/ end-to-end specs, run by Playwright (see CONTRIBUTING.md); @@ -136,15 +140,17 @@ catch-all not-found page. ## The dependency ceiling on modernizing this application -Worth knowing before attempting any React upgrade: confirmed by reading their installed -`package.json` files directly, both `react-metrics-graphics` (this application's charting library) -and `react-refetch` (its entire data-fetching layer, described above) declare a `peerDependencies` -constraint that caps React at version 16 (`^15||^16` and an equivalent range respectively). Neither -has shipped a release since 2019, and the charting library's own upstream, `metrics-graphics`, has -been stuck on a never-promoted 3.0 beta since 2022. - -**This application cannot move past React 16 without first replacing its chart-rendering and -data-fetching layers.** That is a legitimate, large, separate initiative in its own right — not -something to bundle into routine maintenance or into any build-tooling change. Any plan touching -this application's build tooling should leave the React version, `react-router-dom`, and -`react-refetch` untouched, and say so explicitly, rather than assume they're in scope. +Two abandoned packages used to cap React at version 16: `react-refetch`, the data layer, and +`react-metrics-graphics`, the chart wrapper. Local replacements have taken their place: +`src/lib/withFetch.jsx` and `views/MetricsGraphics.jsx`, which calls `MG.data_graphic` directly. +`metrics-graphics` itself stays on 2.15.6, because its only newer release is a 3.0 beta, unpromoted +since 2022, that rewrites the API. + +What holds the application on React 18 now is its lazy loading, not a dependency. React 19 keeps a +`Suspense` fallback on screen for at least 300 ms before revealing what it was waiting for. A +dashboard goes through two of those: the lazily loaded route in `views/Main.jsx`, then each +metric's lazily loaded chart in `views/MetricOverview.jsx`. The dashboard's data fetch can't start +until the first one ends. Measured on the production build with recorded data, the first chart +appeared after 45–80 ms on React 18 and 640–700 ms on React 19. Restructure that lazy loading before +moving to React 19, for example by loading the chart chunk with the dashboard and starting route +chunks before the first render. diff --git a/docs/execplans/2026-10-26-react-18-upgrade.md b/docs/execplans/2026-10-26-react-18-upgrade.md new file mode 100644 index 0000000..4e067c6 --- /dev/null +++ b/docs/execplans/2026-10-26-react-18-upgrade.md @@ -0,0 +1,581 @@ +# Upgrade to React 18 and react-router 7, replace react-refetch, and clear the way to React 19 + +This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, +`Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. It is +maintained in accordance with `.claude/skills/execplans/references/PLANS.md`, including its +"Ensemble addendum", which overrides the upstream text where the two disagree. + +## Purpose / Big Picture + +The site runs on React 16.14, released in 2020. Its data layer (react-refetch) and its chart +wrapper (react-metrics-graphics) are abandoned, and both refuse any React newer than 16. Its +analytics library (react-ga) sends Universal Analytics hits, which Google stopped processing, so the +site has no working page-view tracking. Each month this lasts, Dependabot has more updates it can't +land and the next maintainer has an older React to learn. + +After this work the site runs on React 18 and react-router 7, and no dependency pins React to an +old major. Fetching and chart drawing run on small local modules the team owns. Page views go to +Google Analytics 4 once a real measurement ID is configured. To a visitor, nothing changes: every +dashboard, chart, region selector, title, and Not Found page behaves as it does today. + +The plan set out to reach React 19. Every dependency now allows it, but React 19 made charts appear +about 600 ms later (see Surprises), so the user chose to stay on React 18 until the lazy loading is +restructured. That restructure and the React 19 bump are follow-up work. + +To see it working, run `npm start`, open , and change the +region. The charts redraw. Then run `npm ls react` and see `react@18.3.1` with no `invalid` or +`overridden` markers. + +Out of scope: React 19 (see above); react-router 8, which needs React 19.2.7; converting class +components to hooks beyond what the router requires; React StrictMode; a real GA4 measurement ID; +and a consent banner. + +## Progress + +- [x] (2026-10-26) Drafted the plan on branch `react-18-upgrade` (first named `react-19-upgrade`), cut from `major-updates`. + Baseline from the improve-tests plan's outcome: Vitest 14 files, 80 tests. +- [x] (2026-10-26) Milestone 1: replaced react-refetch with `src/lib/withFetch.jsx`. Vitest + passes 15 files, 84 tests; recorded Playwright 70 passed; live Playwright 46 passed; lint + clean; build succeeds; `d3-transition` still nested under `d3@4`. Turning the stale-response + guard off fails exactly the new "Ignores a response that arrives after a newer request has + started" test. +- [x] (2026-10-26) Milestone 2: replaced react-metrics-graphics with + `src/components/views/MetricsGraphics.jsx`. Vitest 15 files, 84 tests; recorded Playwright + 70 passed, and the dev server no longer logs the `componentWillReceiveProps` warning; lint + clean; build succeeds; `d3-transition` still nested. Live Playwright had 1 failure (a + `toBeVisible` timeout) on the first run and 46 passed on the immediate re-run, so it was not + this change. +- [x] (2026-10-26) Milestone 3: react-router-dom 5.3.4 to 6.30.6. Vitest 15 files, 84 tests; + recorded Playwright 70 passed; lint clean; build succeeds; `d3-transition` still nested. Live + Playwright 45 passed, 1 flaky: the hardware "Charts render" flake described in Surprises, + which happens on `major-updates` too. +- [x] (2026-10-26) Milestone 4: React 18.3.1, `@testing-library/react` 16.3.3, + `@testing-library/dom` 10.4.2, `@testing-library/jest-dom` 7.0.1, `createRoot`, and the + automatic JSX runtime. Vitest 15 files, 84 tests, exit 0; recorded Playwright 70 passed, and + 68 of 68 chromium specs passed with retries off on a cold `vite --force` server with no 504s; + live Playwright 46 passed; lint clean; build succeeds; `d3-transition` still nested. +- [x] (2026-10-26) Milestone 5: react-router-dom 6.30.6 to react-router 7.18.4, imported from + `react-router`. Vitest 15 files, 84 tests, exit 0, no warnings; recorded Playwright 70 + passed; live Playwright 46 passed; lint clean; build succeeds; `d3-transition` still nested. + The future-flag pass on v6 was skipped: no routes use relative links or nested splats, and + the full suites on v7, which has both flags on by default, are the same check. +- [x] (2026-10-26) Milestone 6, revised: react-helmet-async 3.0.0 and GA4 through react-ga4 + 3.0.1, on React 18.3.1. Vitest 15 files, 84 tests, exit 0; recorded Playwright 70 passed with + retries off in 12.8 s; live Playwright 46 passed; lint clean; build succeeds; `d3-transition` + still nested. In a browser against the dev server: one `` in `<head>`, the noindex tag + on `/nope`, and one GA4 `page_view` per route (`/`, then `/dashboard/hardware` after a click). +- [x] (2026-10-26) Tried React 19.3.1 in Milestone 6. All suites passed, but charts appeared about + 600 ms later (see Surprises). Reverted to React 18.3.1 at the user's choice. +- [ ] Follow-up, not in this plan: restructure lazy loading so React 19's Suspense throttle doesn't + delay the charts, then move to React 19 and react-router 8. + +## Surprises & Discoveries + +- Observation: once `withFetch` reads `window.fetch` per request, the container tests need no + hoisting. `vi.spyOn(window, 'fetch')` in `beforeEach` works, and `setupTests.js`'s + `vi.restoreAllMocks()` removes it after each test. Do not call `mockReset()` on that spy: in + current Vitest, resetting a spy restores the real implementation, so the next request would go to + the network. + Evidence: all five converted files pass unchanged apart from the stub setup. +- Observation: on React 18, a test that waits with `vi.waitFor`, or returns while a lazy route is + still loading, fails the whole Vitest run. Every test still passes, but React logs "A suspended + resource finished loading inside a test, but the event was not wrapped in act(...)" when the + `React.lazy` chunk resolves. `setupTests.js` throws on that `console.error` outside any test, and + Vitest reports it as an unhandled rejection and exits 1. React Testing Library's `waitFor` and + `findBy*` turn off React's act environment while waiting, so they don't trigger it. + Evidence: `Routes.test.jsx` produced 12 unhandled rejections with "Tests 84 passed" and exit 1. + Switching its three `vi.waitFor` calls to RTL's `waitFor`, and making the header-links test wait + for the page, brought it to exit 0. +- Observation: the dev server pre-bundles dependencies correctly once the classic-runtime + `optimizeDeps` workaround is gone. On React 18 the dependency scan finds `react/jsx-dev-runtime`. + Evidence: `npx vite --force` then the chromium Playwright project, retries off: 68 passed, and no + "Outdated Optimize Dep" or re-optimization in the server log. +- Observation: React 19 makes charts appear about 600 ms later, and the cause is in this app's + lazy loading, not a bug. Once a `Suspense` boundary has shown its fallback, React 19 waits until + at least 300 ms after that fallback before revealing the content. A dashboard goes through two of + those: the lazily loaded route in `views/Main.jsx` and each metric's lazily loaded chart in + `views/MetricOverview.jsx`. The dashboard's `index.json` fetch only starts once the first one + ends, because `withFetch` fetches when the container mounts. The tests showed it first: + `regionSelector.spec.js` took 28 s instead of a few, because each `toBeVisible` that missed the + first poll waited for Playwright's next one, around 850 ms later. + Evidence: time from navigation start to the first `path.mg-main-line`, 5 loads each, recorded + data, production build (`vite preview`): React 18.3.1, hardware 80–113 ms and user-activity + 42–55 ms; React 19.3.1, hardware 680–735 ms and user-activity 637–645 ms. A DOM trace on React 19 + showed the spinner at 72 ms, every route module loaded by 117 ms, and the `index.json` request at + 389 ms. Making the route components lazy only once, at module scope, didn't help, because + `page.goto` is a full page load that starts with nothing cached. +- Observation: on React 19, react-helmet-async 3 doesn't manage `<head>` at all. It renders plain + `<title>` and `<meta>` elements and leaves the hoisting to React, and `HelmetProvider` does + nothing. So it can't deduplicate tags on React 19, which was the reason given for choosing it. + On React 18 it manages tags itself, as react-helmet did, and needs a `HelmetProvider` above every + `<Helmet>`, in tests too. + Evidence: `node_modules/react-helmet-async/README.md`, section "React 19". In a browser on React + 18, `head title` counts 1 and the title changes on each navigation. +- Observation: the live hardware "Charts render" spec sometimes times out waiting for + `#graphics-metric-overview-1 svg`. It happens on this branch's base as well, and never on + recorded data, so it comes from live-data latency, not this work. Retries absorb it in CI. + Evidence: with `--retries=0`, live: 1 of 8 failed on Milestone 3 and 1 of 16 failed on + `major-updates`, both on that locator. Recorded: 20 of 20 passed. + +## Decision Log + +- Decision: one plan covering all three upgrades, done in dependency order on one branch, + `react-18-upgrade` (first named `react-19-upgrade`), opened as a single pull request + (#465) stacked on `deps` (#463), which is the same commit as `major-updates`. + Rationale: each upgrade blocks the next. React 18 needs react-refetch, + react-metrics-graphics, and React Testing Library 12 gone. React 19 needs react-ga and + react-helmet gone. The user chose the single stacked branch. There is no issue yet, so the branch + has no issue-number prefix, at the user's request. + Date/Author: 2026-10-26, Stephanie Hobson. +- Decision: remove both React-16-only dependencies while still on React 16 (Milestones 1 and 2), + then move the router to v6 (Milestone 3), before touching React itself. + Rationale: every one of those changes can be proved against today's test suite on a React + version known to work. That way, if Milestone 4 fails, the cause is React 18 and nothing else. + Date/Author: 2026-10-26, Stephanie Hobson. +- Decision: replace react-refetch with a local higher-order component that keeps react-refetch's + `dataFetch` prop shape, rather than adopting a fetching library or rewriting the containers as + hooks. + Rationale: the three containers and their tests depend on exact behaviors (listed in Context + and Orientation). Keeping the prop shape means the containers change only in their import and + their `export default` line, and the existing container tests prove the replacement. + Date/Author: 2026-10-26, Stephanie Hobson. +- Decision: replace react-metrics-graphics with a local component rather than raising its peer + range with an npm `overrides` entry. + Rationale: the package is about 30 lines. It uses `componentWillReceiveProps`, which React 18 + and 19 warn about, and it hasn't been published since 2018. An override would hide a warning in + code nobody maintains. + Date/Author: 2026-10-26, Stephanie Hobson. +- Decision: take the router to 6, then to 7, and stop there. + Rationale: v6 runs on React 16.8 and later, so the API rewrite can land before React changes. + v7 needs React 18 and is a small step from v6. v8 needs React 19.2.7 and came out in October + 2026, which is too new under the repository's 7-day release-age habit. The user chose this. + Date/Author: 2026-10-26, Stephanie Hobson. +- Decision: replace react-ga with react-ga4, and rename `VITE_GA_TRACKING_ID` to + `VITE_GA_MEASUREMENT_ID`, with a placeholder value in `.env`. + Rationale: react-ga caps React at 18 and sends Universal Analytics hits, which Google no longer + processes. The user chose GA4 over removing analytics. A real measurement ID and any consent + banner belong to the deploy, which happens outside this repository, so this plan leaves the ID as + a placeholder. + Date/Author: 2026-10-26, Stephanie Hobson. +- Decision: replace react-helmet with react-helmet-async rather than React 19's built-in + `<title>` and `<meta>` hoisting. + Rationale: React 19 hoists those tags into `<head>` but does not deduplicate them. + `index.html` already holds a static `<title>`, and pages nest, so the built-in hoisting would + leave two titles and repeated meta tags. react-helmet-async supports React 16 through 19 and + replaces tags the way react-helmet does now. + Date/Author: 2026-10-26, Stephanie Hobson. +- Decision: in `views/Main.jsx`, keep creating the lazy route components inside render rather than + hoisting them to module scope, as Milestone 3 first planned. + Rationale: `React.lazy` caches a rejected import for good. Recreating it on each navigation + (`Main` re-renders only when the location changes) is what lets a route whose chunk failed to + load retry when visited again, which `LazyBoundary`'s pathname key exists to support. Hoisting + would buy stable component types that the pathname key throws away anyway. + Date/Author: 2026-10-26, Stephanie Hobson. +- Decision: write `withNextButton` and `withTracker` as function components that call + `useLocation()`, with `withTracker` sending its page view from a `useEffect` on the pathname, + rather than class components behind a hook wrapper. + Rationale: neither needs state of its own, and the effect states "track when the path changes" + directly. It also moves the page view out of `getDerivedStateFromProps`, which runs during + render and can run more than once under React 18. + Date/Author: 2026-10-26, Stephanie Hobson. + +- Decision: stay on React 18.3.1. Keep react-helmet-async and GA4, which are what React 19 needed + out of the way, and leave the React 19 bump to a follow-up. + Rationale: React 19 passed every suite, but it delayed the charts by about 600 ms on fast + connections (see Surprises). That is a visible regression. The fix means restructuring lazy + loading, which is a design change in its own right. The user chose this over accepting the delay + or attempting the restructure here. Dependabot keeps ignoring React majors, with a comment giving + the reason. + Date/Author: 2026-10-26, Stephanie Hobson. +- Decision: on React 18, use react-helmet-async rather than keeping react-helmet. + Rationale: react-helmet's react-side-effect caps React at 18, so it would block the React 19 + follow-up. react-helmet-async works on 16 through 19 with the same `<Helmet>` API. On React 19 it + becomes a thin pass-through to React's own tag hoisting (see Surprises). At that point the + follow-up can drop it, after checking the static `<title>` in `index.html`. This replaces the + rationale in the earlier react-helmet-async entry, which assumed it would deduplicate tags on + React 19. + Date/Author: 2026-10-26, Stephanie Hobson. +- Decision: configure GA4 with `send_page_view: false` and send every page view from + `withTracker`. + Rationale: gtag sends a page view by itself when it is configured. `withTracker` also sends one + on mount, so without this the first page would count twice. In a browser, the data layer shows + one `page_view` per route. + Date/Author: 2026-10-26, Stephanie Hobson. + +## Outcomes & Retrospective + +(2026-10-26, after Milestone 6.) + +**Achieved.** +- The site runs on React 18.3.1 and react-router 7.18.4. React Testing Library is 16 and the JSX + runtime is automatic. +- No dependency caps React below 19: react-refetch, react-metrics-graphics, react-helmet, and + react-ga are gone. +- Fetching and chart drawing run through two small local modules, `src/lib/withFetch.jsx` and + `src/components/views/MetricsGraphics.jsx`. +- Page views go to GA4 through react-ga4, one per route, once a real measurement ID replaces the + placeholder in `.env`. +- Every suite passes: Vitest 15 files and 84 tests, recorded Playwright 70, and live Playwright + 46, apart from the existing live hardware flake. + +**Not achieved.** React 19, the plan's original goal. It works, but it draws charts about 600 ms +later, because its Suspense throttle stacks on this app's two levels of lazy loading. The user +chose to stay on React 18. The next piece of work is to restructure that lazy loading. One option +is to load the chart chunk with the dashboard rather than per metric, and to start route chunks +before the first render. Then measure with the method in Surprises, move to React 19, and consider +react-router 8. + +**Lessons.** +- The suites caught every functional break, but nothing in them watches speed. The React 19 + regression only showed up because one spec got slower. For the React 19 follow-up, add a + timing check: first chart within a budget, on recorded data, against the production build. +- Read a package's notes for the target React version before picking it. react-helmet-async's + behaviour on React 19 undid the reason the plan gave for choosing it. +- Removing the React-16-only packages while still on React 16 worked as intended. Each React + bump then failed only for React's own reasons: act warnings on 18, and the throttle on 19. + +## Context and Orientation + +This builds on `docs/execplans/2026-10-21-improve-tests.md`, which added the tests this plan relies +on to catch regressions. Read its Context section for the test layout. This section restates what +this plan needs. + +**The app.** `ensemble` is the client-side React app behind <https://data.firefox.com/>. It holds +no data. Every number comes from JSON that a separate service, ensemble-transposer, serves from +`https://data.firefox.com/datasets/desktop/...`. `src/config.json` lists the dashboards, and each +one has a `key` (the URL slug) and a `source` (the base URL of its JSON). + +**Entry and routing.** `src/index.jsx` mounts `src/components/views/Application.jsx` inside +react-router 5's `BrowserRouter`, using `ReactDOM.render`. That is the only removed React API +anywhere in `src/`. Application renders Header, Main, and Footer. `src/components/views/Main.jsx` +holds the route table, exported through react-router 5's `withRouter`: + +- `/` renders Home, wrapped in `withTracker(withNextButton(...))`. +- `/contact` renders Contact, wrapped in `withTracker(...)`. +- One `/dashboard/<key>` route per dashboard, built from `config.json` with `render={...}`. Each + creates a `DashboardContainer` with `source={dashboard.source}`. +- A catch-all renders NotFound. + +Every route uses `exact`. Main wraps the routes in `LazyBoundary` (`src/lib/LazyBoundary.jsx`, an +error boundary around `React.Suspense`) keyed on `location.pathname`. Main also calls `lazyLoad`, +`withTracker`, and `withNextButton` inside its render. That creates new component types on every +render, which remounts the page each time. + +**Decorators.** `src/components/decorators/` holds two higher-order components (functions that +take a component and return a wrapped one). + +- `withTracker.jsx` calls `reactGA.initialize(import.meta.env.VITE_GA_TRACKING_ID)` at import. It + sends a page view on mount, and again from `getDerivedStateFromProps` when `props.location.pathname` + changes. +- `withNextButton.jsx` reads `this.props.location.pathname`, finds the matching `nextButtons` entry + in `config.json`, and renders a "Proceed to ..." link below the page. + +Both get `location` only because react-router 5's `Route` injects it as a prop. react-router 6 +does not. + +**The container/view split.** `src/components/containers/` holds the stateful components, which +fetch and reshape data. `src/components/views/` holds presentational components that receive +formatted props. + +**react-refetch's `connect()`.** react-refetch 3.0.1 is the whole data layer, used only in +`containers/DashboardContainer.jsx`, `containers/MetricOverviewContainer.jsx`, and +`containers/SummaryMetricContainer.jsx`. Each does +`export default connect(props => ({ dataFetch: { url: ... } }))(Component)`. The wrapped component +receives `props.dataFetch`, a "PromiseState" object with `pending`, `rejected`, `fulfilled`, +`value` (the parsed JSON), and `reason` (an `Error`). The containers rely on these behaviors, read +from `node_modules/react-refetch/lib/components/connect.js` and `lib/utils/buildRequest.js`: + +1. It calls `fetch(new Request(url, { method: 'GET', credentials: 'same-origin', mode: 'cors' }))`. + The tests read `.url` off the `Request` they receive. +2. A non-2xx response rejects. The reason's message comes from the parsed JSON body (the `error` + or `message` field, falling back to the status text). A 204 or empty body fulfills with + `undefined`. +3. It refetches in `componentDidUpdate` only when the URL string changes. +4. On a refetch it first resets `dataFetch` to pending. `MetricOverviewContainer`'s + `shouldComponentUpdate` returns false while pending. That is how the previous region's chart + stays on screen while the new region loads. `MetricOverviewContainer.test.jsx` checks this. +5. It ignores a response that arrives after a newer request has started. +6. It reads `window.fetch` once, when `connect()` runs, which is at module import. The five tests + that stub `fetch` therefore install the stub inside `vi.hoisted(...)`, with a comment saying why. + +Region choice is saved in `sessionStorage` under `preferredRegion` by +`DashboardContainer._onRegionChange`. Changing it changes `activeRegion`, which changes the metric +containers' URLs, which triggers behavior 3. + +`MetricOverviewContainer.jsx` carries a comment citing issue #313: the host answers a missing +metric with a 200 HTML page rather than a 404, so its rejected branch never runs in production. +Behavior 2 makes that 200 a JSON parse failure, which also rejects. Keep that comment accurate. + +**react-metrics-graphics.** `views/Chart.jsx` renders `<MetricsGraphics {...options} />` from +react-metrics-graphics 1.1.2. That package is a 30-line class, minified in +`node_modules/react-metrics-graphics/build/MetricsGraphics.js`. It: + +- renders `<div className="metricsGraphicsCon">` with a callback ref; +- filters its props to metrics-graphics' known option names; +- calls `MG.data_graphic({ target: div, ...options })` in `componentDidMount`, and again in + `componentDidUpdate` after copying the new props in `componentWillReceiveProps`; +- imports `metrics-graphics/dist/metricsgraphics.css`. + +metrics-graphics itself (2.15.6) stays: 3.0.0-beta1 is an incompatible rewrite, recorded in +`.claude/skills/update-deps/denied.md`. `src/tests/jest/Chart.test.jsx` mocks the +`react-metrics-graphics` module and asserts on the options Chart passes it. + +**Head tags.** `views/Application.jsx`, `Home.jsx`, `Contact.jsx`, `Dashboard.jsx`, and +`NotFound.jsx` each render a react-helmet `<Helmet>` with `<title>`, `og:*` and `description` +meta, or (NotFound) `<meta name="robots" content="noindex">`. `index.html` has a static +`<title>Firefox Public Data Report`. The Playwright specs `noindex.spec.js`, +`home.spec.js`, `contact.spec.js`, and the three `dashboards/*.spec.js` check these tags. +react-helmet depends on react-side-effect, which caps React at 18. + +**The console.error trap.** `src/setupTests.js` fails any Vitest test that calls `console.error` +unexpectedly. React reports deprecations through `console.error`, so a React upgrade warning +becomes a test failure. That is deliberate. Fix the cause; do not loosen the trap. + +**The classic JSX runtime.** React 16 has no `react/jsx-runtime`. So `vite.config.mjs` sets +`jsxRuntime: 'classic'` on the React plugin and repeats it under +`optimizeDeps.rolldownOptions.transform.jsx`. `src/setupTests.js` sets `global.React`, and every +file with JSX does `import React from 'react'`. All of this can go once React is 17 or later. + +**Test libraries.** `@testing-library/react` is pinned to 12.1.5, the last version that supports +React 16, and `@testing-library/dom` to 8.20.1. `@testing-library/jest-dom` is 6.9.1. +`.github/dependabot.yml` ignores majors of all three, and of react, react-dom, +react-router-dom, and metrics-graphics, with a comment on each. + +**The d3-transition trap.** `npm update` once hoisted the `d3-transition` nested under `d3@4` to +the top level. There it patched a different `d3-selection` copy, and charts stopped redrawing on +region change (commit `0bcc86c`). After any change to `package-lock.json`, run +`npm ls d3-transition` and confirm it is still nested under `d3`, and run the Playwright +`region-data` spec. + +**Commands.** These run in this repository today, from its root: + + npm install + npm run build:css # compiled CSS is gitignored; Vitest fails without it + npm run lint # ESLint plus stylint; must be clean + npm run test:jest # Vitest + npm run build # Vite production build plus build/version.json + npm run test:playwright:recorded # Playwright on tests/playwright/fixtures/ (CI-required) + npm run test:playwright:live # Playwright on live data (not required; may fail on upstream data) + +Every dependency in `package.json` is pinned exactly, with no `^` or `~`. Keep it that way. Use +`npm install --save-exact pkg@version`. + +## Plan of Work + +Each milestone leaves lint, Vitest, the build, and recorded Playwright green, and is its own commit. +Each one updates the docs and comments it makes stale (AGENTS.md, CONTRIBUTING.md, +`docs/architecture/frontend.md`, `.github/dependabot.yml`, `public/contribute.json`, and code +comments), plus this plan's living sections, in the same commit. + +### Milestone 1: replace react-refetch with a local `withFetch` + +At the end, react-refetch is gone. The three containers fetch through +`src/lib/withFetch.jsx`, and their existing tests pass with only stub-setup changes. + +1. Create `src/lib/withFetch.jsx`. It default-exports + `mapPropsToUrl => WrappedComponent => class`. The class: + - fetches in `componentDidMount`, and in `componentDidUpdate` when `mapPropsToUrl(props)` differs + from the last URL; + - sets `dataFetch` to `{ pending: true }` before each request; + - calls `window.fetch(new Request(url, { credentials: 'same-origin', mode: 'cors' }))`, reading + `window.fetch` at call time; + - rejects on a non-2xx response with an `Error` whose message comes from the JSON body's `error` + or `message`, falling back to `response.statusText`; + - parses JSON otherwise, treating a 204 or empty body as `undefined`; + - ignores results from any request older than the latest by comparing a request counter, and + ignores everything after unmount; + - renders ``, where + `dataFetch` is `{ pending, rejected, fulfilled, value, reason }`. +2. In each container, replace `import { connect } from 'react-refetch'` with + `import withFetch from '../../lib/withFetch'`. Replace the export with + `export default withFetch(props => )(Container)`. +3. In the five test files that stub `fetch` (`Routes`, `DashboardContainer`, + `MetricOverviewContainer`, `SummaryMetricContainer`, `SummaryMetric`), move the stub from + `vi.hoisted` to `vi.spyOn(window, 'fetch')` in `beforeEach`, and delete the import-time comments. +4. Add `src/tests/jest/withFetch.test.jsx`. It covers: + - one request per URL, with a refetch only on a URL change; + - pending before fulfilled; + - a 500 with a JSON body rejecting with that body's message; + - a slow first response that arrives after a fast second one being ignored. +5. Run `npm uninstall react-refetch`. +6. Update the docs: AGENTS.md's data-flow paragraph and its "Stubbing the network" bullet, + CONTRIBUTING.md, `docs/architecture/frontend.md`, the React comment in `.github/dependabot.yml`, + and `public/contribute.json`'s keyword list. + +### Milestone 2: replace react-metrics-graphics with a local component + +At the end, `views/Chart.jsx` draws through `src/components/views/MetricsGraphics.jsx`, and +react-metrics-graphics is gone. + +1. Create `src/components/views/MetricsGraphics.jsx`, a class component with + `this.chartElement = React.createRef()`. It renders + `
`. In `componentDidMount` and + `componentDidUpdate` it calls `MG.data_graphic({ ...this.props, target: this.chartElement.current })`, + with `MG` from `metrics-graphics`. It imports `metrics-graphics/dist/metricsgraphics.css`, which + `Chart.jsx` already imports too; keep one import. + + Do not copy the option-name filter. Chart passes only metrics-graphics options. Confirm this by + reading `views/Chart.jsx`, and if it passes anything else, strip it there and say so in Surprises. +2. Point `Chart.jsx` at it. In `Chart.test.jsx`, mock `'../../components/views/MetricsGraphics'` + instead of `react-metrics-graphics`. +3. Run `npm uninstall react-metrics-graphics`. Update `denied.md`'s metrics-graphics reason ("an + incompatible rewrite of the 2.x API that `views/MetricsGraphics.jsx` calls"), the Dependabot + comment, AGENTS.md, CONTRIBUTING.md, and `docs/architecture/frontend.md`. +4. The `fixtures/chart-series`, `region-data`, `dates`, and `boundaries` specs are the proof that + charts still draw and redraw. + +### Milestone 3: react-router-dom 5 to 6 + +At the end, the app runs on react-router-dom 6.30.x, still on React 16, with no `location` prop +threading. + +1. Run `npm install --save-exact react-router-dom@6.30.x`, using the newest 6.30 patch at least 7 + days old. +2. `Main.jsx`: + - Move the `lazyLoad(...)` calls, and the `withTracker`/`withNextButton` wrapping of Home, + Contact, NotFound, and one dashboard component per `config.json` entry, to module scope, so the + component types stay stable. + - Render `` with `} />` and so on. v6 matches + exactly by default, so drop `exact`. The catch-all becomes `path="*"`. + - Replace `withRouter` with `const location = useLocation()` for the `LazyBoundary` key. + `Main` is a function component, so the hook is allowed. +3. `withTracker.jsx`: track in `componentDidMount`, and in `componentDidUpdate` when the pathname + changes, instead of in `getDerivedStateFromProps`, which runs during render. Export a function + component that calls `useLocation()` and renders the class with `location`. +4. `withNextButton.jsx`: same wrapper pattern, or make it a function component that calls + `useLocation()`. Choose whichever reads more simply and record why. +5. `withNextButton.test.jsx`: render + `} />`. + `Routes.test.jsx` should pass unchanged. If it doesn't, record why in Surprises. +6. Remove the Dependabot ignore for react-router-dom majors and add one for majors above 6, with the + comment "v7 requires React 18". Update AGENTS.md's routing notes. + +### Milestone 4: React 18 and the automatic JSX runtime + +At the end, the app and tests run on React 18.3.1 with the automatic JSX runtime. + +1. Run + `npm install --save-exact react@18.3.1 react-dom@18.3.1 @testing-library/react@16.x @testing-library/dom@10.x @testing-library/jest-dom@7.x`. + Use the newest patches at least 7 days old. jest-dom 7 needs `@testing-library/dom` 10, and RTL + 16 supports React 18 and 19. +2. `src/index.jsx`: `createRoot(document.getElementById('root')).render(...)`, with `createRoot` + from `react-dom/client`. +3. `vite.config.mjs`: delete `jsxRuntime: 'classic'`, the whole `optimizeDeps` block, and their + comments. `src/setupTests.js`: delete `global.React` and its comment. +4. Delete `import React from 'react'` wherever only JSX used it. Keep it, or switch to named + imports, where `React.Component`, `React.Fragment`, `React.lazy`, `React.createRef`, or + `React.cloneElement` appear. +5. `eslint.config.js`: add `react.configs.flat['jsx-runtime']` after `recommended`. Remove the + `React` global from the test-file override. +6. Run Vitest and fix whatever the console.error trap surfaces. Expect act warnings around user + events and Suspense. Fix them with `await`/`findBy`, not by silencing them. +7. Update the Dependabot ignores: drop the RTL, `@testing-library/dom`, and jest-dom entries, and + keep React majors ignored, with the comment "React 19 needs react-ga and react-helmet replaced". + Update AGENTS.md and CONTRIBUTING.md wherever they say React 16, RTL 12, the classic runtime, or + "test files don't import React". Also update the "pure client-side React 16 SPA" line. + +### Milestone 5: react-router 7 + +At the end, the app imports from `react-router` 7.x. + +1. On v6 first, opt into the v7 future flags on `BrowserRouter` and `MemoryRouter` + (`v7_startTransition`, `v7_relativeSplatPath`), and confirm no warnings remain. +2. `npm uninstall react-router-dom && npm install --save-exact react-router@7.x`. Change every + import from `react-router-dom` to `react-router` (`src/index.jsx`, `Main.jsx`, `Header.jsx`, + `NextButton.jsx`, `Home.jsx`, the decorators, and two tests). Remove the future flags, which are + the default in v7. +3. Change the Dependabot ignore to react-router majors, with the comment "v8 requires React 19.2.7". + +### Milestone 6: react-helmet-async and GA4 (React 19 deferred) + +At the end, no installed dependency caps React below 19, and the app still runs on React 18. Step +3 was carried out and then reverted. See the Decision Log. + +1. Replace react-helmet with react-helmet-async 3.x. Swap the five imports, wrap the app in + `` in `src/index.jsx`, and do the same in any test that renders a Helmet + (`Routes.test.jsx` and others; grep for it). Proof is that the `noindex`, `home`, `contact`, and + `dashboards/*` title and meta specs pass. +2. Replace react-ga with react-ga4: + - In `withTracker.jsx`, use `ReactGA.initialize(import.meta.env.VITE_GA_MEASUREMENT_ID)` and + `ReactGA.send({ hitType: 'pageview', page })`. Drop `set`. + - In `.env`, rename the variable to `VITE_GA_MEASUREMENT_ID='G-XXXXXXXXXX'`. + - Update the mock and the page-view assertion in `Routes.test.jsx`. + - Check that recorded-mode Playwright still answers the GA4 hosts (`googletagmanager.com`, + `google-analytics.com`) with its empty 200. +3. Run `npm install --save-exact react@19.x react-dom@19.x`, using the newest patch at least 7 days + old. Fix whatever Vitest surfaces. +4. Keep the Dependabot React-major ignore, with a comment explaining the chart delay. Update + AGENTS.md, CONTRIBUTING.md, and `docs/architecture/frontend.md` for react-helmet-async, + react-ga4, and the env var, and add the React 19 constraint to the architecture doc. +5. Run every command in Concrete Steps, including live Playwright. Write Outcomes & + Retrospective. + +## Concrete Steps + +Run from the repository root after each milestone: + + npm install + npm ls d3-transition # expect it nested under d3@4.x, not at the top level + npm ls react # expect no "invalid" or "overridden" + npm run build:css + npm run lint # expect no errors + npm run test:jest # expect all pass; record files/tests in Progress + npm run build # expect "built in" and build/version.json + npm run test:playwright:recorded # expect all pass with retries + npm run test:playwright:live # record the result; re-run once before blaming the commit + +If port 3000 is taken, start `npx vite --port 3001 --strictPort` and set +`PLAYWRIGHT_BASE_URL=http://localhost:3001`. + +## Validation and Acceptance + +After each milestone, start `npm start` and check: + +1. : the charts draw. Changing the region keeps the old + chart visible until the new data arrives, then redraws it. +2. : the summary bars draw, and changing the date + changes them. +3. : shows Not Found, and the page's `` has + ``. +4. Moving between Home, Contact, and a dashboard changes the tab title each time, and the + "Proceed to ..." button points at the next page. +5. After Milestone 6, the network panel shows a request to `googletagmanager.com` for the placeholder + ID on each navigation. + +Milestone 1's new `withFetch.test.jsx` stale-response test must fail if the request-counter check +is removed. Try it once and record the result. + +## Idempotence and Recovery + +Every step is a source edit or an exact-pinned `npm install`. To back out a milestone, run +`git revert` on its commit, then `npm install`. After switching branches, always run +`npm install` before trusting a result, because `node_modules` may hold another branch's +packages. If `npm ls d3-transition` shows it hoisted, restore `package-lock.json` from the previous +commit and redo the install with only the intended package names, rather than running `npm update`. + +## Artifacts and Notes + +None yet. + +## Interfaces and Dependencies + +At the end of the plan: + +- `src/lib/withFetch.jsx` default-exports `mapPropsToUrl => WrappedComponent => Component`. + `mapPropsToUrl(props)` returns a URL string. The wrapped component receives + `dataFetch: { pending: boolean, rejected: boolean, fulfilled: boolean, value?: any, reason?: Error }`. +- `src/components/views/MetricsGraphics.jsx` default-exports a class component that takes + metrics-graphics options as props and draws into its own `
`. +- The dependencies that change: + - removed: `react-refetch`, `react-metrics-graphics`, `react-router-dom`, `react-helmet`, + `react-ga`; + - added: `react-router` 7.x, `react-helmet-async` 3.x, `react-ga4` 3.x; + - upgraded: `react` and `react-dom` to 18.3.1, `@testing-library/react` to 16.x, + `@testing-library/dom` to 10.x, `@testing-library/jest-dom` to 7.x. + +--- + +Revision note (2026-10-26): Milestone 6 moved to React 19, measured a chart delay of about 600 ms, +and reverted to React 18 at the user's choice. The title, Purpose, Milestone 6, Interfaces, and the +living sections now describe a plan that ends on React 18, with React 19 cleared of dependency +blockers but deferred. diff --git a/eslint.config.js b/eslint.config.js index 4c78f62..0697cce 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -24,6 +24,11 @@ module.exports = [ }, }, }, + { + // The automatic JSX runtime doesn't need React in scope. + files: ['**/*.{js,jsx}'], + ...react.configs.flat['jsx-runtime'], + }, { files: ['**/*.{js,jsx}'], ...jsxA11y.flatConfigs.recommended, @@ -81,15 +86,10 @@ module.exports = [ globals: { ...globals.browser, ...globals.vitest, - // src/setupTests.js puts React on the global object for every - // Vitest test. - React: 'readonly', }, }, }, { - // Defines the React global the block above declares, so it gets the - // Vitest globals without that declaration. files: ['src/setupTests.js'], languageOptions: { globals: { diff --git a/package-lock.json b/package-lock.json index fb5e235..0c11a5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,22 +18,20 @@ "memoize-one": "6.0.0", "metrics-graphics": "2.15.6", "npm-run-all2": "9.0.3", - "react": "16.14.0", - "react-dom": "16.14.0", - "react-ga": "3.3.1", - "react-helmet": "6.1.0", - "react-metrics-graphics": "1.1.2", - "react-refetch": "3.0.1", - "react-router-dom": "5.3.4", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-ga4": "3.0.1", + "react-helmet-async": "3.0.0", + "react-router": "7.18.4", "react-spinners": "0.17.1", "stylus": "0.64.0" }, "devDependencies": { "@eslint/js": "9.39.5", "@playwright/test": "1.64.1", - "@testing-library/dom": "8.20.1", - "@testing-library/jest-dom": "6.9.1", - "@testing-library/react": "12.1.5", + "@testing-library/dom": "10.4.2", + "@testing-library/jest-dom": "7.0.1", + "@testing-library/react": "16.3.3", "@testing-library/user-event": "14.6.8", "@vitejs/plugin-react": "6.1.2", "@vitest/eslint-plugin": "1.6.27", @@ -120,6 +118,7 @@ "version": "7.29.10", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -1003,29 +1002,29 @@ "license": "MIT" }, "node_modules/@testing-library/dom": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", - "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", - "aria-query": "5.1.3", - "chalk": "^4.1.0", + "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", + "picocolors": "1.1.1", "pretty-format": "^27.0.2" }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", - "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", "dev": true, "license": "MIT", "dependencies": { @@ -1037,9 +1036,18 @@ "redent": "^3.0.0" }, "engines": { - "node": ">=14", + "node": ">=22", "npm": ">=6", "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } } }, "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { @@ -1050,22 +1058,31 @@ "license": "MIT" }, "node_modules/@testing-library/react": { - "version": "12.1.5", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.5.tgz", - "integrity": "sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==", + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/runtime": "^7.12.5", - "@testing-library/dom": "^8.0.0", - "@types/react-dom": "<18.0.0" + "@babel/runtime": "^7.12.5" }, "engines": { - "node": ">=12" + "node": ">=18" }, "peerDependencies": { - "react": "<18.0.0", - "react-dom": "<18.0.0" + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, "node_modules/@testing-library/user-event": { @@ -1127,45 +1144,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@types/react": { - "version": "17.0.93", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.93.tgz", - "integrity": "sha512-KM4Ty/ZTLZupiYxZVAlP+InNJS3De6uBMdq0ePa6/04+eG9Y7ftnWfst1xTLQ5rwAhgHwQ4momt/O4KepdGBTw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/prop-types": "*", - "@types/scheduler": "^0.16", - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "17.0.26", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.26.tgz", - "integrity": "sha512-Z+2VcYXJwOqQ79HreLU/1fyQ88eXSSFh6I3JdrEHQIfYSI0kCQpTGvOrbE6jFGGYXKsHuwY9tBa/w5Uo6KzrEg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^17.0.0" - } - }, - "node_modules/@types/scheduler": { - "version": "0.16.8", - "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", - "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/@typescript-eslint/project-service": { "version": "8.72.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.0.tgz", @@ -1518,13 +1496,13 @@ "license": "Python-2.0" }, "node_modules/aria-query": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", - "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "deep-equal": "^2.0.5" + "dequal": "^2.0.3" } }, "node_modules/array-buffer-byte-length": { @@ -2039,6 +2017,19 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2074,14 +2065,6 @@ "dev": true, "license": "MIT" }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/d3": { "version": "4.13.0", "resolved": "https://registry.npmjs.org/d3/-/d3-4.13.0.tgz", @@ -2708,39 +2691,6 @@ "dev": true, "license": "MIT" }, - "node_modules/deep-equal": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", - "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.5", - "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.2", - "is-arguments": "^1.1.1", - "is-array-buffer": "^3.0.2", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "isarray": "^2.0.5", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "side-channel": "^1.0.4", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2797,6 +2747,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -3018,27 +2978,6 @@ "node": ">= 0.4" } }, - "node_modules/es-get-iterator": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", - "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "is-arguments": "^1.1.1", - "is-map": "^2.0.2", - "is-set": "^2.0.2", - "is-string": "^1.0.7", - "isarray": "^2.0.5", - "stop-iteration-iterator": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/es-iterator-helpers": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.4.0.tgz", @@ -4111,35 +4050,6 @@ "node": ">= 0.4" } }, - "node_modules/history": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/history/-/history-4.10.1.tgz", - "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.1.2", - "loose-envify": "^1.2.0", - "resolve-pathname": "^3.0.0", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0", - "value-equal": "^1.0.1" - } - }, - "node_modules/hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "license": "BSD-3-Clause", - "dependencies": { - "react-is": "^16.7.0" - } - }, - "node_modules/hoist-non-react-statics/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, "node_modules/hosted-git-info": { "version": "2.8.9", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", @@ -4281,23 +4191,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-arguments": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -5782,6 +5675,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5800,23 +5694,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object-is": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", - "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -6138,21 +6015,6 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, - "node_modules/path-to-regexp": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz", - "integrity": "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==", - "license": "MIT", - "dependencies": { - "isarray": "0.0.1" - } - }, - "node_modules/path-to-regexp/node_modules/isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", - "license": "MIT" - }, "node_modules/path-type": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz", @@ -6385,6 +6247,7 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -6396,6 +6259,7 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, "license": "MIT" }, "node_modules/punycode": { @@ -6418,32 +6282,28 @@ } }, "node_modules/react": { - "version": "16.14.0", - "resolved": "https://registry.npmjs.org/react/-/react-16.14.0.tgz", - "integrity": "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "prop-types": "^15.6.2" + "loose-envify": "^1.1.0" }, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "16.14.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz", - "integrity": "sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw==", + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", - "object-assign": "^4.1.1", - "prop-types": "^15.6.2", - "scheduler": "^0.19.1" + "scheduler": "^0.23.2" }, "peerDependencies": { - "react": "^16.14.0" + "react": "^18.3.1" } }, "node_modules/react-fast-compare": { @@ -6452,29 +6312,24 @@ "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", "license": "MIT" }, - "node_modules/react-ga": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/react-ga/-/react-ga-3.3.1.tgz", - "integrity": "sha512-4Vc0W5EvXAXUN/wWyxvsAKDLLgtJ3oLmhYYssx+YzphJpejtOst6cbIHCIyF50Fdxuf5DDKqRYny24yJ2y7GFQ==", - "license": "Apache-2.0", - "peerDependencies": { - "prop-types": "^15.6.0", - "react": "^15.6.2 || ^16.0 || ^17 || ^18" - } + "node_modules/react-ga4": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/react-ga4/-/react-ga4-3.0.1.tgz", + "integrity": "sha512-GyCc01bSheWXjzGDyHsXMOqk/SP5Cf/JrcJTg4hcpKx4eeSwaJKpJUc+ipF4ffLTZkmabmf3ZGBv4OKHTXNXyA==", + "license": "MIT" }, - "node_modules/react-helmet": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-6.1.0.tgz", - "integrity": "sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==", - "license": "MIT", + "node_modules/react-helmet-async": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-3.0.0.tgz", + "integrity": "sha512-nA3IEZfXiclgrz4KLxAhqJqIfFDuvzQwlKwpdmzZIuC1KNSghDEIXmyU0TKtbM+NafnkICcwx8CECFrZ/sL/1w==", + "license": "Apache-2.0", "dependencies": { - "object-assign": "^4.1.1", - "prop-types": "^15.7.2", - "react-fast-compare": "^3.1.1", - "react-side-effect": "^2.1.0" + "invariant": "^2.2.4", + "react-fast-compare": "^3.2.2", + "shallowequal": "^1.1.0" }, "peerDependencies": { - "react": ">=16.3.0" + "react": "^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/react-is": { @@ -6484,83 +6339,26 @@ "dev": true, "license": "MIT" }, - "node_modules/react-metrics-graphics": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/react-metrics-graphics/-/react-metrics-graphics-1.1.2.tgz", - "integrity": "sha512-Y2j7V2YIi+F3ZHzzCO9cEWjqOSYrDiM+7rHAZ6c/LfWNueNTh6H0G845Qd7g/2fRL7RUYV9SsZEjIqH8ozvmAw==", - "license": "MIT", - "peerDependencies": { - "metrics-graphics": "^2.11.0", - "prop-types": "^15||^16", - "react": "^15||^16", - "react-dom": "^15||^16" - } - }, - "node_modules/react-refetch": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/react-refetch/-/react-refetch-3.0.1.tgz", - "integrity": "sha512-jpsSjhHr9nVyTxJgDEmHdKVbv8DUyiDzr3vTSId0l4GSu8t2IeBY3fPsG7yOd7MSYifrSxPiUY5NjGEtOpUNIw==", - "license": "MIT", - "dependencies": { - "hoist-non-react-statics": "^3.3.0", - "invariant": "^2.2.4", - "warning": "^4.0.3" - }, - "peerDependencies": { - "react": "^0.14.0 || ^15.0.0-0 || ^16.0.0-0" - } - }, "node_modules/react-router": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", - "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.12.13", - "history": "^4.9.0", - "hoist-non-react-statics": "^3.1.0", - "loose-envify": "^1.3.1", - "path-to-regexp": "^1.7.0", - "prop-types": "^15.6.2", - "react-is": "^16.6.0", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0" + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" }, - "peerDependencies": { - "react": ">=15" - } - }, - "node_modules/react-router-dom": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz", - "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.13", - "history": "^4.9.0", - "loose-envify": "^1.3.1", - "prop-types": "^15.6.2", - "react-router": "5.3.4", - "tiny-invariant": "^1.0.2", - "tiny-warning": "^1.0.0" + "engines": { + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=15" - } - }, - "node_modules/react-router/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, - "node_modules/react-side-effect": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.2.tgz", - "integrity": "sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==", - "license": "MIT", - "peerDependencies": { - "react": "^16.3.0 || ^17.0.0 || ^18.0.0" + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } } }, "node_modules/react-spinners": { @@ -6787,12 +6585,6 @@ "node": ">=4" } }, - "node_modules/resolve-pathname": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-3.0.0.tgz", - "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==", - "license": "MIT" - }, "node_modules/rimraf": { "version": "2.6.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", @@ -6931,13 +6723,12 @@ } }, "node_modules/scheduler": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.19.1.tgz", - "integrity": "sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==", + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "object-assign": "^4.1.1" + "loose-envify": "^1.1.0" } }, "node_modules/semver": { @@ -6960,6 +6751,12 @@ "dev": true, "license": "ISC" }, + "node_modules/set-cookie-parser": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.3.tgz", + "integrity": "sha512-irZHtTqurLna57KQG/s+fzOwv/J6XSH4cs8M/vsrY3uCaQ3S6+QJguAH0WdN47SXlk82voMZ4naWMQf8fdEoIg==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -7009,6 +6806,12 @@ "node": ">= 0.4" } }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7860,18 +7663,6 @@ "node": ">=6.0.0" } }, - "node_modules/tiny-invariant": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", - "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", - "license": "MIT" - }, - "node_modules/tiny-warning": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", - "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", - "license": "MIT" - }, "node_modules/tinybench": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", @@ -8155,12 +7946,6 @@ "spdx-expression-parse": "^3.0.0" } }, - "node_modules/value-equal": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", - "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==", - "license": "MIT" - }, "node_modules/vite": { "version": "8.3.4", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", @@ -8335,15 +8120,6 @@ "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, - "node_modules/warning": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", - "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", diff --git a/package.json b/package.json index 738df84..0bdb64f 100644 --- a/package.json +++ b/package.json @@ -36,22 +36,20 @@ "memoize-one": "6.0.0", "metrics-graphics": "2.15.6", "npm-run-all2": "9.0.3", - "react": "16.14.0", - "react-dom": "16.14.0", - "react-ga": "3.3.1", - "react-helmet": "6.1.0", - "react-metrics-graphics": "1.1.2", - "react-refetch": "3.0.1", - "react-router-dom": "5.3.4", + "react": "18.3.1", + "react-dom": "18.3.1", + "react-ga4": "3.0.1", + "react-helmet-async": "3.0.0", + "react-router": "7.18.4", "react-spinners": "0.17.1", "stylus": "0.64.0" }, "devDependencies": { "@eslint/js": "9.39.5", "@playwright/test": "1.64.1", - "@testing-library/dom": "8.20.1", - "@testing-library/jest-dom": "6.9.1", - "@testing-library/react": "12.1.5", + "@testing-library/dom": "10.4.2", + "@testing-library/jest-dom": "7.0.1", + "@testing-library/react": "16.3.3", "@testing-library/user-event": "14.6.8", "@vitejs/plugin-react": "6.1.2", "@vitest/eslint-plugin": "1.6.27", diff --git a/public/contribute.json b/public/contribute.json index 2dbac66..4097a95 100644 --- a/public/contribute.json +++ b/public/contribute.json @@ -22,8 +22,7 @@ "metrics-graphics", "nightwatch", "node", - "react", - "react-refetch" + "react" ], "bugs": { "list": "https://github.com/mozilla/ensemble/issues", diff --git a/src/components/containers/DashboardContainer.jsx b/src/components/containers/DashboardContainer.jsx index 299bc6d..01a0940 100644 --- a/src/components/containers/DashboardContainer.jsx +++ b/src/components/containers/DashboardContainer.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { connect } from 'react-refetch'; +import withFetch from '../../lib/withFetch'; import Spinner from '../views/Spinner'; import ErrorComponent from '../views/Error'; @@ -70,6 +70,4 @@ class DashboardContainer extends React.Component { } } -export default connect(props => ({ - dataFetch: { url: `${props.source}/index.json` }, -}))(DashboardContainer); +export default withFetch(props => `${props.source}/index.json`)(DashboardContainer); diff --git a/src/components/containers/MetricOverviewContainer.jsx b/src/components/containers/MetricOverviewContainer.jsx index cba43c6..20e73a1 100644 --- a/src/components/containers/MetricOverviewContainer.jsx +++ b/src/components/containers/MetricOverviewContainer.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { connect } from 'react-refetch'; +import withFetch from '../../lib/withFetch'; import ErrorComponent from '../views/Error'; import MetricOverview from '../views/MetricOverview'; @@ -74,6 +74,4 @@ class MetricOverviewContainer extends React.Component { } } -export default connect(props => ({ - dataFetch: { url: `${props.dashboardSource}/${props.activeRegion}/${props.slug}/index.json` }, -}))(MetricOverviewContainer); +export default withFetch(props => `${props.dashboardSource}/${props.activeRegion}/${props.slug}/index.json`)(MetricOverviewContainer); diff --git a/src/components/containers/SummaryMetricContainer.jsx b/src/components/containers/SummaryMetricContainer.jsx index 89c9129..8e98880 100644 --- a/src/components/containers/SummaryMetricContainer.jsx +++ b/src/components/containers/SummaryMetricContainer.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { connect } from 'react-refetch'; +import withFetch from '../../lib/withFetch'; import SummaryMetric from '../views/SummaryMetric'; import ErrorComponent from '../views/Error'; @@ -112,6 +112,4 @@ class SummaryMetricContainer extends React.Component { } } -export default connect(props => ({ - dataFetch: { url: `${props.dashboardSource}/${props.activeRegion}/${props.slug}/index.json` }, -}))(SummaryMetricContainer); +export default withFetch(props => `${props.dashboardSource}/${props.activeRegion}/${props.slug}/index.json`)(SummaryMetricContainer); diff --git a/src/components/decorators/withNextButton.jsx b/src/components/decorators/withNextButton.jsx index c23c7e9..2e86ec0 100644 --- a/src/components/decorators/withNextButton.jsx +++ b/src/components/decorators/withNextButton.jsx @@ -1,31 +1,25 @@ import React from 'react'; +import { useLocation } from 'react-router'; import NextButton from '../views/NextButton'; import { nextButtons as nextButtonsConfig } from '../../config.json'; -export default WrappedComponent => { - const HOC = class extends React.Component { - render() { - const nextButtonMeta = nextButtonsConfig.find(nbm => { - return nbm.from === this.props.location.pathname; - }); +export default WrappedComponent => props => { + const { pathname } = useLocation(); + const nextButtonMeta = nextButtonsConfig.find(nbm => nbm.from === pathname); - if (nextButtonMeta) { - return ( - - - - - ); - } else { - return ; - } - } - }; + if (nextButtonMeta) { + return ( + + + + + ); + } - return HOC; + return ; }; diff --git a/src/components/decorators/withTracker.jsx b/src/components/decorators/withTracker.jsx index 568f76a..ed23d03 100644 --- a/src/components/decorators/withTracker.jsx +++ b/src/components/decorators/withTracker.jsx @@ -1,50 +1,21 @@ -import React from 'react'; -import reactGA from 'react-ga'; +import { useEffect } from 'react'; +import { useLocation } from 'react-router'; +import ReactGA from 'react-ga4'; -// This approach is recommended by react-ga -// https://github.com/react-ga/react-ga/wiki/React-Router-v4-withTracker +// gtag sends a page view on its own when it is configured. Turn that off so +// the first page isn't counted twice: every page view, including the first, +// is sent below when the path changes. +ReactGA.initialize(import.meta.env.VITE_GA_MEASUREMENT_ID, { + gtagOptions: { send_page_view: false }, +}); -reactGA.initialize(import.meta.env.VITE_GA_TRACKING_ID); +export default WrappedComponent => props => { + const page = useLocation().pathname; -export default (WrappedComponent, options = {}) => { - const trackPage = page => { - reactGA.set({ - page, - ...options, - }); - reactGA.pageview(page); - }; + useEffect(() => { + ReactGA.send({ hitType: 'pageview', page }); + }, [page]); - const HOC = class extends React.Component { - constructor(props) { - super(props); - this.state = { - page: props.location.pathname, - }; - } - - componentDidMount() { - trackPage(this.state.page); - } - - static getDerivedStateFromProps(nextProps, prevState) { - const nextPage = nextProps.location.pathname; - - if (nextPage !== prevState.page) { - trackPage(nextPage); - return { - page: nextPage, - }; - } - - return null; - } - - render() { - return ; - } - }; - - return HOC; + return ; }; diff --git a/src/components/views/Application.jsx b/src/components/views/Application.jsx index 8641760..5a80e66 100644 --- a/src/components/views/Application.jsx +++ b/src/components/views/Application.jsx @@ -1,5 +1,4 @@ -import React from 'react'; -import { Helmet } from 'react-helmet'; +import { Helmet } from 'react-helmet-async'; import Header from './Header'; import Main from './Main'; diff --git a/src/components/views/Chart.jsx b/src/components/views/Chart.jsx index b0f96e4..d290eb2 100644 --- a/src/components/views/Chart.jsx +++ b/src/components/views/Chart.jsx @@ -1,10 +1,9 @@ -import React from 'react'; -import MetricsGraphics from 'react-metrics-graphics'; import dateformat from 'dateformat'; import { curveCatmullRom } from 'd3-shape'; import distinctColors from 'distinct-colors'; import { prettifyNumber } from '../../lib/utils'; +import MetricsGraphics from './MetricsGraphics'; import 'metrics-graphics/dist/metricsgraphics.css'; import './css/Chart.css'; diff --git a/src/components/views/Contact.jsx b/src/components/views/Contact.jsx index 6753cc3..1a648fc 100644 --- a/src/components/views/Contact.jsx +++ b/src/components/views/Contact.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Helmet } from 'react-helmet'; +import { Helmet } from 'react-helmet-async'; import { getPageTitle } from '../../lib/utils'; diff --git a/src/components/views/Dashboard.jsx b/src/components/views/Dashboard.jsx index 68f48d5..57e191b 100644 --- a/src/components/views/Dashboard.jsx +++ b/src/components/views/Dashboard.jsx @@ -1,6 +1,6 @@ import React from 'react'; import markdownIt from 'markdown-it'; -import { Helmet } from 'react-helmet'; +import { Helmet } from 'react-helmet-async'; import DashboardSection from './DashboardSection'; import MetricOverviewCollection from './MetricOverviewCollection'; diff --git a/src/components/views/DashboardSection.jsx b/src/components/views/DashboardSection.jsx index 0fe6494..0cbb66c 100644 --- a/src/components/views/DashboardSection.jsx +++ b/src/components/views/DashboardSection.jsx @@ -1,5 +1,3 @@ -import React from 'react'; - import MetricOverviewCollection from './MetricOverviewCollection'; import './css/DashboardSection.css'; diff --git a/src/components/views/DataTable.jsx b/src/components/views/DataTable.jsx index 48c3cb9..63c4be1 100644 --- a/src/components/views/DataTable.jsx +++ b/src/components/views/DataTable.jsx @@ -1,5 +1,3 @@ -import React from 'react'; - import { prettifyNumber } from '../../lib/utils'; import './css/DataTable.css'; diff --git a/src/components/views/Error.jsx b/src/components/views/Error.jsx index b7a8db5..a549b7d 100644 --- a/src/components/views/Error.jsx +++ b/src/components/views/Error.jsx @@ -1,6 +1,3 @@ -import React from 'react'; - - export default props => { let maybeMessage = null; diff --git a/src/components/views/Header.jsx b/src/components/views/Header.jsx index 592109e..3535c19 100644 --- a/src/components/views/Header.jsx +++ b/src/components/views/Header.jsx @@ -1,5 +1,4 @@ -import React from 'react'; -import { Link } from 'react-router-dom'; +import { Link } from 'react-router'; import './css/Header.css'; diff --git a/src/components/views/Home.jsx b/src/components/views/Home.jsx index 0b8b62b..1f8761b 100644 --- a/src/components/views/Home.jsx +++ b/src/components/views/Home.jsx @@ -1,6 +1,6 @@ import React from 'react'; -import { Helmet } from 'react-helmet'; -import { Link } from 'react-router-dom'; +import { Helmet } from 'react-helmet-async'; +import { Link } from 'react-router'; import StripedHeader from './StripedHeader'; import { getPageTitle } from '../../lib/utils'; diff --git a/src/components/views/Main.jsx b/src/components/views/Main.jsx index 587213b..88ba6ad 100644 --- a/src/components/views/Main.jsx +++ b/src/components/views/Main.jsx @@ -1,5 +1,4 @@ -import React from 'react'; -import { Switch, Route, withRouter } from 'react-router-dom'; +import { Routes, Route, useLocation } from 'react-router'; import withTracker from '../decorators/withTracker'; import withNextButton from '../decorators/withNextButton'; @@ -9,11 +8,16 @@ import LazyBoundary from '../../lib/LazyBoundary'; import { dashboards } from '../../config.json'; -const Main = mainProps => { - const Home = lazyLoad(import('./Home')); - const Contact = lazyLoad(import('./Contact')); - const DashboardContainer = lazyLoad(import('../containers/DashboardContainer')); - const NotFound = lazyLoad(import('./NotFound')); +export default () => { + const { pathname } = useLocation(); + + // Created on each render, which only happens on navigation, so a route + // whose chunk failed to load retries the import when it is visited again. + // React.lazy remembers a failed load for good. + const Home = withTracker(withNextButton(lazyLoad(import('./Home')))); + const Contact = withTracker(lazyLoad(import('./Contact'))); + const DashboardContainer = withTracker(withNextButton(lazyLoad(import('../containers/DashboardContainer')))); + const NotFound = withTracker(withNextButton(lazyLoad(import('./NotFound')))); return (
@@ -21,32 +25,20 @@ const Main = mainProps => { later navigation - the key change fully remounts the boundary, resetting its error state, instead of leaving "Load error" in place until a full page reload. */} - - - - + + + } /> + } /> {dashboards.map(dashboard => ( { - const ThisDashboardContainer = () => ( - - ); - const Component = withTracker(withNextButton(ThisDashboardContainer)); - - return ; - }} + path={`/dashboard/${dashboard.key}`} + element={} /> ))} - - + } /> +
); }; - -export default withRouter(Main); diff --git a/src/components/views/MetricOverviewCollection.jsx b/src/components/views/MetricOverviewCollection.jsx index cec294f..c6a8ad3 100644 --- a/src/components/views/MetricOverviewCollection.jsx +++ b/src/components/views/MetricOverviewCollection.jsx @@ -1,5 +1,3 @@ -import React from 'react'; - import MetricOverviewContainer from '../containers/MetricOverviewContainer'; diff --git a/src/components/views/MetricsGraphics.jsx b/src/components/views/MetricsGraphics.jsx new file mode 100644 index 0000000..7c70999 --- /dev/null +++ b/src/components/views/MetricsGraphics.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import MG from 'metrics-graphics'; + + +// Draws a metrics-graphics chart into its own div. Every prop is passed to +// MG.data_graphic as an option, and the chart is redrawn on every update. +export default class extends React.Component { + chartElement = React.createRef(); + + componentDidMount() { + this._draw(); + } + + componentDidUpdate() { + this._draw(); + } + + _draw() { + MG.data_graphic({ ...this.props, target: this.chartElement.current }); + } + + render() { + return
; + } +} diff --git a/src/components/views/NextButton.jsx b/src/components/views/NextButton.jsx index cd6c344..1dc4695 100644 --- a/src/components/views/NextButton.jsx +++ b/src/components/views/NextButton.jsx @@ -1,5 +1,4 @@ -import React from 'react'; -import { Link } from 'react-router-dom'; +import { Link } from 'react-router'; import './css/NextButton.css'; diff --git a/src/components/views/NotFound.jsx b/src/components/views/NotFound.jsx index b988d37..ea0595c 100644 --- a/src/components/views/NotFound.jsx +++ b/src/components/views/NotFound.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Helmet } from 'react-helmet'; +import { Helmet } from 'react-helmet-async'; import ErrorComponent from './Error'; diff --git a/src/index.jsx b/src/index.jsx index a54fa67..c046ace 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -1,16 +1,18 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import { BrowserRouter } from 'react-router-dom'; +import { createRoot } from 'react-dom/client'; +import { BrowserRouter } from 'react-router'; +import { HelmetProvider } from 'react-helmet-async'; import { unregister } from './registerServiceWorker'; import Application from './components/views/Application'; -ReactDOM.render(( - - - -), document.getElementById('root')); +createRoot(document.getElementById('root')).render( + + + + + +); // Service workers were causing issues with DataOps hosting. We struggled to // understand what exactly was causing the problem. It's possible that #5 in the diff --git a/src/lib/withFetch.jsx b/src/lib/withFetch.jsx new file mode 100644 index 0000000..d38c88e --- /dev/null +++ b/src/lib/withFetch.jsx @@ -0,0 +1,80 @@ +import React from 'react'; + + +const pendingState = { pending: true, rejected: false, fulfilled: false }; + +async function fetchJSON(url) { + const response = await window.fetch(new Request(url, { + credentials: 'same-origin', + mode: 'cors', + })); + + if (response.status === 204 || response.headers.get('content-length') === '0') { + return undefined; + } + + const body = await response.json(); + + if (!response.ok) { + throw new Error(body.error || body.message || response.statusText); + } + + return body; +} + +/** + * Fetch JSON from the URL that mapPropsToUrl returns and pass the request's + * state to the wrapped component as `dataFetch`: { pending, rejected, + * fulfilled, value, reason }. Refetches whenever the URL changes, going back to + * pending first, and ignores any response that arrives after a newer request + * has started. + */ +export default mapPropsToUrl => WrappedComponent => class extends React.Component { + constructor(props) { + super(props); + this.state = { dataFetch: pendingState }; + this._latestRequest = 0; + } + + componentDidMount() { + this._fetch(mapPropsToUrl(this.props)); + } + + componentDidUpdate() { + const url = mapPropsToUrl(this.props); + if (url !== this._url) { + this._fetch(url); + } + } + + componentWillUnmount() { + this._latestRequest++; + } + + _fetch(url) { + this._url = url; + const request = ++this._latestRequest; + const isCurrent = () => request === this._latestRequest; + + if (this.state.dataFetch !== pendingState) { + this.setState({ dataFetch: pendingState }); + } + + fetchJSON(url).then( + value => { + if (isCurrent()) { + this.setState({ dataFetch: { pending: false, rejected: false, fulfilled: true, value } }); + } + }, + reason => { + if (isCurrent()) { + this.setState({ dataFetch: { pending: false, rejected: true, fulfilled: false, reason } }); + } + } + ); + } + + render() { + return ; + } +}; diff --git a/src/setupTests.js b/src/setupTests.js index 2bfcccd..650b28a 100644 --- a/src/setupTests.js +++ b/src/setupTests.js @@ -1,11 +1,7 @@ -import React from 'react'; import { cleanup } from '@testing-library/react'; import '@testing-library/jest-dom/vitest'; -// The classic JSX runtime needs React in scope wherever JSX appears. -global.React = React; - // React reports deprecations and misuse through console.error, so treat any // unexpected call as a failure. A test that expects an error replaces // console.error with its own spy. diff --git a/src/tests/jest/Chart.test.jsx b/src/tests/jest/Chart.test.jsx index 4e53c92..0c9f235 100644 --- a/src/tests/jest/Chart.test.jsx +++ b/src/tests/jest/Chart.test.jsx @@ -1,12 +1,11 @@ import { render } from '@testing-library/react'; -import MetricsGraphics from 'react-metrics-graphics'; - import Chart from '../../components/views/Chart'; +import MetricsGraphics from '../../components/views/MetricsGraphics'; // metrics-graphics needs real layout; these tests check the options the view // computes and hands to it. -vi.mock('react-metrics-graphics', () => ({ default: vi.fn(() => null) })); +vi.mock('../../components/views/MetricsGraphics', () => ({ default: vi.fn(() => null) })); const twoPopulations = [ [{ x: new Date('2026-01-04'), y: 5 }, { x: new Date('2026-01-11'), y: 4 }, { x: new Date('2026-01-18'), y: 9 }], diff --git a/src/tests/jest/Dashboard.test.jsx b/src/tests/jest/Dashboard.test.jsx index b1719cb..5f203d1 100644 --- a/src/tests/jest/Dashboard.test.jsx +++ b/src/tests/jest/Dashboard.test.jsx @@ -1,4 +1,5 @@ import { render } from '@testing-library/react'; +import { HelmetProvider } from 'react-helmet-async'; import Dashboard from '../../components/views/Dashboard'; @@ -17,7 +18,8 @@ it('When a description prop is present, p#dashboard-description is rendered', () + />, + { wrapper: HelmetProvider } ); expect(container.querySelector('#dashboard-description')).toHaveTextContent('Example description'); @@ -29,7 +31,8 @@ it('When a description prop is not present, an empty string, undefined, or null, + />, + { wrapper: HelmetProvider } ); expect(container.querySelector('#dashboard-description')).toBeNull(); diff --git a/src/tests/jest/DashboardContainer.test.jsx b/src/tests/jest/DashboardContainer.test.jsx index ae91856..95f3f6b 100644 --- a/src/tests/jest/DashboardContainer.test.jsx +++ b/src/tests/jest/DashboardContainer.test.jsx @@ -1,16 +1,11 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { HelmetProvider } from 'react-helmet-async'; import DashboardContainer from '../../components/containers/DashboardContainer'; -// react-refetch reads window.fetch when connect() runs at import time, so the -// stub has to exist before the container module loads. -const fetchMock = vi.hoisted(() => { - const mock = vi.fn(); - window.fetch = mock; - return mock; -}); +let fetchMock; const source = 'https://data.example/user-activity'; @@ -43,13 +38,13 @@ const multiRegionDashboard = { }; beforeEach(() => { - fetchMock.mockReset(); + fetchMock = vi.spyOn(window, 'fetch'); }); it('Requests the dashboard index from its source and shows a spinner while waiting', async () => { fetchMock.mockImplementation(() => new Promise(() => {})); - const { container } = render(); + const { container } = render(, { wrapper: HelmetProvider }); expect(container.querySelector('.spinner')).toBeInTheDocument(); await waitFor(() => expect(requestedUrls()).toEqual([`${source}/index.json`])); @@ -59,7 +54,7 @@ it('When the request fails, #dashboard-fetch-error is rendered', async () => { vi.spyOn(console, 'error').mockImplementation(() => {}); fetchMock.mockImplementation(() => jsonResponse({ error: 'Internal Server Error' }, 500)); - const { container } = render(); + const { container } = render(, { wrapper: HelmetProvider }); await waitFor(() => expect(container.querySelector('#dashboard-fetch-error')).toBeInTheDocument()); }); @@ -67,7 +62,7 @@ it('When the request fails, #dashboard-fetch-error is rendered', async () => { it('When the request succeeds, the dashboard renders and metrics load for the default region', async () => { serveDashboard(multiRegionDashboard); - const { container } = render(); + const { container } = render(, { wrapper: HelmetProvider }); await waitFor(() => expect(container.querySelector('#dashboard-title')).toHaveTextContent('User Activity')); expect(screen.getByLabelText('Region')).toHaveValue('Worldwide'); @@ -78,7 +73,7 @@ it('A preferred region saved in sessionStorage is used instead of the default', sessionStorage.setItem('preferredRegion', 'Brazil'); serveDashboard(multiRegionDashboard); - render(); + render(, { wrapper: HelmetProvider }); await waitFor(() => expect(screen.getByLabelText('Region')).toHaveValue('Brazil')); await waitFor(() => expect(requestedUrls()).toContain(`${source}/Brazil/daily-usage/index.json`)); @@ -87,7 +82,7 @@ it('A preferred region saved in sessionStorage is used instead of the default', it('Choosing a region saves it to sessionStorage and loads metrics for it', async () => { const user = userEvent.setup(); serveDashboard(multiRegionDashboard); - render(); + render(, { wrapper: HelmetProvider }); await screen.findByLabelText('Region'); await user.selectOptions(screen.getByLabelText('Region'), 'Germany'); @@ -106,7 +101,7 @@ it('A dashboard with one region shows no region selector and ignores the saved p categories: ['default'], }); - const { container } = render(); + const { container } = render(, { wrapper: HelmetProvider }); await waitFor(() => expect(container.querySelector('#dashboard-title')).toHaveTextContent('Hardware')); expect(container.querySelector('#region-selector')).toBeNull(); diff --git a/src/tests/jest/MetricOverviewContainer.test.jsx b/src/tests/jest/MetricOverviewContainer.test.jsx index 23acc6a..26d1536 100644 --- a/src/tests/jest/MetricOverviewContainer.test.jsx +++ b/src/tests/jest/MetricOverviewContainer.test.jsx @@ -3,13 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react'; import MetricOverviewContainer from '../../components/containers/MetricOverviewContainer'; -// react-refetch reads window.fetch when connect() runs at import time, so the -// stub has to exist before the container module loads. -const fetchMock = vi.hoisted(() => { - const mock = vi.fn(); - window.fetch = mock; - return mock; -}); +let fetchMock; const pendingRequests = {}; @@ -36,7 +30,7 @@ function renderForRegion(activeRegion, rerender) { } beforeEach(() => { - fetchMock.mockReset(); + fetchMock = vi.spyOn(window, 'fetch'); fetchMock.mockImplementation(request => new Promise(resolve => { pendingRequests[request.url] = resolve; })); diff --git a/src/tests/jest/Routes.test.jsx b/src/tests/jest/Routes.test.jsx index 3d34936..a9ff321 100644 --- a/src/tests/jest/Routes.test.jsx +++ b/src/tests/jest/Routes.test.jsx @@ -1,35 +1,31 @@ -import { render, screen } from '@testing-library/react'; -import { MemoryRouter } from 'react-router-dom'; -import reactGA from 'react-ga'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { HelmetProvider } from 'react-helmet-async'; +import ReactGA from 'react-ga4'; import Application from '../../components/views/Application'; import { dashboards } from '../../config.json'; -// react-refetch reads window.fetch when connect() runs at import time, so the -// stub has to exist before any container module loads. -const fetchMock = vi.hoisted(() => { - const mock = vi.fn(); - window.fetch = mock; - return mock; -}); +let fetchMock; -vi.mock('react-ga', () => ({ - default: { initialize: vi.fn(), set: vi.fn(), pageview: vi.fn() }, +vi.mock('react-ga4', () => ({ + default: { initialize: vi.fn(), send: vi.fn() }, })); function renderAt(path) { return render( - + , + { wrapper: HelmetProvider } ); } beforeEach(() => { - fetchMock.mockReset(); + fetchMock = vi.spyOn(window, 'fetch'); fetchMock.mockImplementation(() => new Promise(() => {})); - reactGA.pageview.mockClear(); + ReactGA.send.mockClear(); }); it('/ shows the introduction and a button to the first dashboard', async () => { @@ -49,7 +45,7 @@ it('/contact shows the contact page', async () => { it.each(dashboards)('/dashboard/$key requests its index from $source', async ({ key, source }) => { const { container } = renderAt(`/dashboard/${key}`); - await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled()); + await waitFor(() => expect(fetchMock).toHaveBeenCalled()); expect(fetchMock.mock.calls[0][0].url).toBe(`${source}/index.json`); expect(container.querySelector('#not-found')).toBeNull(); }); @@ -62,7 +58,7 @@ it.each([ ])('%s shows Not Found', async path => { const { container } = renderAt(path); - await vi.waitFor(() => expect(container.querySelector('#not-found')).toBeInTheDocument()); + await waitFor(() => expect(container.querySelector('#not-found')).toBeInTheDocument()); }); it.each([ @@ -73,11 +69,12 @@ it.each([ ])('%s sends a pageview for its path', async path => { renderAt(path); - await vi.waitFor(() => expect(reactGA.pageview).toHaveBeenCalledWith(path)); + await waitFor(() => expect(ReactGA.send).toHaveBeenCalledWith({ hitType: 'pageview', page: path })); }); -it('The header links to every dashboard in config.json and the contact page', () => { +it('The header links to every dashboard in config.json and the contact page', async () => { const { container } = renderAt('/'); + await screen.findByRole('link', { name: 'Proceed to User Activity' }); const links = [...container.querySelectorAll('#main-navigation a')].map(link => [link.textContent, link.getAttribute('href')]); expect(links).toEqual([ diff --git a/src/tests/jest/SummaryMetric.test.jsx b/src/tests/jest/SummaryMetric.test.jsx index b25ec08..5a676b4 100644 --- a/src/tests/jest/SummaryMetric.test.jsx +++ b/src/tests/jest/SummaryMetric.test.jsx @@ -3,13 +3,7 @@ import { render, waitFor } from '@testing-library/react'; import SummaryMetricContainer from '../../components/containers/SummaryMetricContainer'; -// react-refetch reads window.fetch when connect() runs at import time, so the -// stub has to exist before the container module loads. -const fetchMock = vi.hoisted(() => { - const mock = vi.fn(); - window.fetch = mock; - return mock; -}); +let fetchMock; const activeDate = '2026-01-04'; @@ -43,7 +37,7 @@ async function renderSummaryBar(populationValues) { } beforeEach(() => { - fetchMock.mockReset(); + fetchMock = vi.spyOn(window, 'fetch'); }); it('Each population gets a segment sized by its share, largest first, with small ones combined into a final "Other"', async () => { diff --git a/src/tests/jest/SummaryMetricContainer.test.jsx b/src/tests/jest/SummaryMetricContainer.test.jsx index 8a04b69..8f4f065 100644 --- a/src/tests/jest/SummaryMetricContainer.test.jsx +++ b/src/tests/jest/SummaryMetricContainer.test.jsx @@ -4,13 +4,7 @@ import SummaryMetric from '../../components/views/SummaryMetric'; import SummaryMetricContainer from '../../components/containers/SummaryMetricContainer'; -// react-refetch reads window.fetch when connect() runs at import time, so the -// stub has to exist before the container module loads. -const fetchMock = vi.hoisted(() => { - const mock = vi.fn(); - window.fetch = mock; - return mock; -}); +let fetchMock; // The real view draws with d3 into a measured parent node. vi.mock('../../components/views/SummaryMetric', () => ({ default: vi.fn(() => null) })); @@ -39,7 +33,7 @@ async function dataPassedToView() { } beforeEach(() => { - fetchMock.mockReset(); + fetchMock = vi.spyOn(window, 'fetch'); SummaryMetric.mockClear(); }); diff --git a/src/tests/jest/withFetch.test.jsx b/src/tests/jest/withFetch.test.jsx new file mode 100644 index 0000000..ac92c05 --- /dev/null +++ b/src/tests/jest/withFetch.test.jsx @@ -0,0 +1,73 @@ +import { render, screen } from '@testing-library/react'; + +import withFetch from '../../lib/withFetch'; + + +let fetchMock; +const pendingRequests = {}; + +function settle(url, body, httpStatus = 200) { + pendingRequests[url](new Response(JSON.stringify(body), { + status: httpStatus, + headers: { 'Content-Type': 'application/json' }, + })); +} + +const ShowFetchState = withFetch(props => `https://data.example/${props.slug}.json`)(({ dataFetch }) => { + if (dataFetch.pending) return

pending

; + if (dataFetch.rejected) return

rejected: {dataFetch.reason.message}

; + return

fulfilled: {dataFetch.value.title}

; +}); + +beforeEach(() => { + fetchMock = vi.spyOn(window, 'fetch'); + fetchMock.mockImplementation(request => new Promise(resolve => { + pendingRequests[request.url] = resolve; + })); +}); + +it('Is pending until the response arrives, then passes the parsed JSON', async () => { + render(); + expect(screen.getByText('pending')).toBeInTheDocument(); + + settle('https://data.example/one.json', { title: 'One' }); + + expect(await screen.findByText('fulfilled: One')).toBeInTheDocument(); +}); + +it('Requests once per URL and refetches, pending again, only when the URL changes', async () => { + const { rerender } = render(); + settle('https://data.example/one.json', { title: 'One' }); + await screen.findByText('fulfilled: One'); + + rerender(); + expect(fetchMock).toHaveBeenCalledTimes(1); + + rerender(); + expect(await screen.findByText('pending')).toBeInTheDocument(); + expect(fetchMock.mock.calls.map(([request]) => request.url)).toEqual([ + 'https://data.example/one.json', + 'https://data.example/two.json', + ]); +}); + +it('Rejects a non-2xx response with the message from its JSON body', async () => { + render(); + + settle('https://data.example/one.json', { error: 'Metric not found' }, 500); + + expect(await screen.findByText('rejected: Metric not found')).toBeInTheDocument(); +}); + +it('Ignores a response that arrives after a newer request has started', async () => { + const { rerender } = render(); + rerender(); + + settle('https://data.example/fast.json', { title: 'Fast' }); + await screen.findByText('fulfilled: Fast'); + settle('https://data.example/slow.json', { title: 'Slow' }); + + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(screen.getByText('fulfilled: Fast')).toBeInTheDocument(); +}); diff --git a/src/tests/jest/withNextButton.test.jsx b/src/tests/jest/withNextButton.test.jsx index 11a59fd..f16ab17 100644 --- a/src/tests/jest/withNextButton.test.jsx +++ b/src/tests/jest/withNextButton.test.jsx @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react'; -import { MemoryRouter, Route } from 'react-router-dom'; +import { MemoryRouter, Routes, Route } from 'react-router'; import withNextButton from '../../components/decorators/withNextButton'; import { nextButtons } from '../../config.json'; @@ -11,7 +11,9 @@ const PageWithNextButton = withNextButton(Page); function renderAt(path) { return render( - + + } /> + ); } diff --git a/tests/playwright/specs/fixtures/boundaries.spec.js b/tests/playwright/specs/fixtures/boundaries.spec.js index b4689b2..586f996 100644 --- a/tests/playwright/specs/fixtures/boundaries.spec.js +++ b/tests/playwright/specs/fixtures/boundaries.spec.js @@ -31,7 +31,7 @@ test('A population with a single data point is drawn as a dot', async ({ page }) }); test.describe('A metric whose populations are all null', () => { - // Pinned as current behavior, not as a design: react-metrics-graphics + // Pinned as current behavior, not as a design: metrics-graphics // throws on a chart with no points, and the error boundary around the chart // shows its generic "Load error". React logs the caught error too. test.use({ allowedErrors: /Cannot read properties of undefined \(reading 'x'\)|The above error occurred/ }); diff --git a/vite.config.mjs b/vite.config.mjs index 6db49ba..35f802e 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -3,30 +3,10 @@ import react from '@vitejs/plugin-react'; export default defineConfig({ - plugins: [ - react({ - // React 16 doesn't support the automatic JSX runtime (added in React - // 17); every file in this codebase already assumes the classic - // runtime, where React must be in scope wherever JSX is used. - jsxRuntime: 'classic', - }), - ], + plugins: [react()], server: { port: 3000, }, - optimizeDeps: { - // The React plugin sets the classic runtime for transforms but not for - // Vite's dependency scan, which then assumes the automatic runtime, - // fails to resolve react/jsx-dev-runtime (React 16 has none), and - // skips pre-bundling. The dev server then discovers dependencies one - // page at a time, answering "504 (Outdated Optimize Dep)" and - // reloading on first visits. - rolldownOptions: { - transform: { - jsx: { runtime: 'classic' }, - }, - }, - }, build: { outDir: 'build', // Needed for `npm run size` (source-map-explorer) to inspect the