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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/update-deps/denied.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
2 changes: 1 addition & 1 deletion .env
Original file line number Diff line number Diff line change
@@ -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
17 changes: 5 additions & 12 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
36 changes: 19 additions & 17 deletions AGENTS.md

Large diffs are not rendered by default.

32 changes: 16 additions & 16 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
`<div id="application"><main>`, 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 `<Helmet>` 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.
Expand Down Expand Up @@ -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
Expand Down
46 changes: 26 additions & 20 deletions docs/architecture/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -105,24 +108,25 @@ 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
│ └── img/ 3 assets
├── 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);
Expand All @@ -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.
Loading
Loading