Skip to content

Link calculator error pages to the visitor's country's reports - #1374

Merged
MaxGhenis merged 2 commits into
mainfrom
fix/calculator-error-country-reports-link
Oct 20, 2026
Merged

MaxGhenis merged 2 commits into
mainfrom
fix/calculator-error-country-reports-link

Conversation

@MaxGhenis

@MaxGhenis MaxGhenis commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

What

The calculator's error pages linked every visitor to /us/reports, so a UK user who hit an error was sent to the US Reports page. Now:

  • Calculator and workspace routes ([countryId]/(calculator)/error.tsx, which (workspace)/error.tsx re-exports): the country comes from the route, useParams().countryId.
  • Root (app/error.tsx): this page also shows for errors the segment pages don't catch, such as in the [countryId] or (calculator) layouts, or on a route outside a country. The country comes from the first path segment, decoded once as Next.js decodes route params (next/dist/shared/lib/router/utils/route-matcher.js:20), because usePathname() keeps the encoding: a request to /%75k/reports renders the UK calculator. A malformed escape, which Next.js rejects as a param, is kept as it is and so falls back to the US.
  • Either way, a value that isn't one of countryIds (@/libs/countries), or no value, links to /us/reports. The shared helper is calculator-app/src/app/reportsHref.ts.

The link stays a plain <a>, so following it is a full page load that discards the client state that may have caused the error. #1366's eslint-disable-next-line @next/next/no-html-link-for-pages comment is removed. The rule skips any href that isn't a string literal (@next/eslint-plugin-next/dist/rules/no-html-link-for-pages.js:211), so once href is an expression the directive goes unused, and an unused directive fails bun run lint at --max-warnings 0. The comment explaining the full page load is kept, and a test now fails if the link becomes a next/link.

#1365 (still a draft) adds isCountryId to countries.ts. This PR doesn't depend on it: it uses countryIds, which is on main. It adds fast-check to app's devDependencies on the same line, with the same bun.lock line, as #1365, so the two PRs merge cleanly in either order.

Invariants

Tested with fast-check properties as well as examples, in app/src/tests/unit/calculator/ErrorPages.test.tsx:

  1. Bounded: for any value, reportsHref returns the Reports page of a supported country.
  2. Identity on supported countries: for each of countryIds, reportsHref(id) === /${id}/reports.
  3. Fallback: any string that isn't a supported country ID (including UK, uk, ../uk, constructor), any non-string, and a list of segments all return /us/reports.
  4. Path round trip: for a supported country and any further path segments, with or without a trailing slash, firstPathSegment returns that country, including when any of its letters are percent-encoded in either hex case.
  5. Total: firstPathSegment never throws, whatever mix of %, hex digits and slashes the path has.
  6. Route over path: the segment page takes the country from the route params, not from the path.

Verification

  • calculator-app: bun run lint, bun run format:check and bun run typecheck pass.

  • app: prettier check, eslint, tsc and the full vitest suite pass (437 files; 5,380 passed, 10 skipped).

  • The new tests catch the bug: against main's error pages, 14 of the 64 fail, exactly the non-US cases. Mutants fail too: swapping the <a> for next/link fails the plain-link test; looking the country up in an object literal fails on constructor and ['uk']; not decoding the segment fails 7 tests; decoding without the try fails 3.

  • End to end on the calculator dev server, with temporary pages (not committed) that throw:

    Path Page that caught it Link
    /uk/zz-throw-calculator calculator routes /uk/reports
    /uk/zz-throw-workspace workspace routes /uk/reports
    /uk/zz-throw-root root /uk/reports
    /ca/zz-throw-root root /ca/reports
    /%75k/zz-throw-root root /uk/reports (was /us/reports before 623f3c1)
    /zz-throw-outside root /us/reports

    Clicking the link on the UK page was a full page load: a DOM marker was gone afterwards, and performance.timeOrigin and the navigation entry both changed to /uk/reports.

Review

  • Round 1 at 954d165 (Subfleet review lane): REQUEST_CHANGES, one should-fix: the root page compared the encoded first segment, so /%75k/… linked to /us/reports. Reproduced on the dev server, fixed in 623f3c1 with regression tests. It confirmed the rest: the hook contexts in Next 15.5.12, which page catches which errors, the lint reasoning, and that Return 404 for calculator paths whose first segment is not a country #1365 merges cleanly in either order.
  • Round 2, a delta review at 623f3c1 (Subfleet review lane): APPROVE, no new findings. It confirmed the decoding matches Next.js 15.5.12, that no string path throws (it checked malformed UTF-8 and all 65,536 percent-encoded byte pairs), and the author's mutant counts (7 and 3).

axiom: n/a: calculator UI routing, no policy logic

🤖 Generated with Claude Code

Both calculator error pages linked everyone to /us/reports, so a UK user
who hit an error landed on the US reports page. The calculator and
workspace routes' page now takes the country from the route; the root
page, which also shows for errors outside a country route, takes it from
the first path segment. Anything that is not a supported country falls
back to /us/reports.

The link stays a plain <a>, so following it is a full page load. Its
eslint-disable comment for @next/next/no-html-link-for-pages is gone:
the rule only checks string-literal hrefs, so with an expression the
directive was unused and failed lint at --max-warnings 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
policyengine-calculator-next Ready Ready Preview Oct 20, 2026 8:21pm UTC
policyengine-website Ready Ready Preview Oct 20, 2026 8:21pm UTC

Request Review

Next.js decodes route params, so a request to /%75k/reports renders
the UK calculator with countryId "uk", while usePathname() keeps the
encoded path. The root error page compared the encoded segment and
linked such a visitor to /us/reports. firstPathSegment now decodes the
segment once, as Next.js does, and keeps a malformed one as it is.

Found by the independent review of #1374.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@MaxGhenis

Copy link
Copy Markdown
Contributor Author

Merge audit for head 623f3c1.

  • Checks: all 10 pass on this head (gh pr checks exits 0; PR checks run 39491069080, visual regression, Storybook and both Vercel deploys).
  • Reviews (independent Subfleet review lane):
    • Round 1 at 954d165: REQUEST_CHANGES, one should-fix: /%75k/… linked to /us/reports. Fixed in 623f3c1.
    • Round 2 at 623f3c1: APPROVE, no new findings.
    • Details are in the description's Review section.
  • Mergeable, not a draft, and no changes-requested review. Main has since moved by Run app Stylelint in CI; cover all apps in make typecheck and test #1373, which adds an app Stylelint step and Makefile targets and touches none of this PR's files. The merged tree has no conflicts, and bun run stylelint in app/ passes on it.
  • Why --admin: the protect-master ruleset requires one approving review, and the PR author can't approve their own PR. Nothing else blocks it.
  • Merge method: a merge commit, as the repo's other PRs use.

@MaxGhenis
MaxGhenis merged commit dc0031b into main Oct 20, 2026
10 checks passed
@MaxGhenis
MaxGhenis deleted the fix/calculator-error-country-reports-link branch October 20, 2026 20:43

This branch was successfully deployed

2 active deployments
Preview – policyengine-calculator-next — 623f3c1d Deployed Oct 20, 2026 by vercel[bot]
Preview – policyengine-website — 623f3c1d Deployed Oct 20, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant