|
| 1 | +# API client: request de-dup, contract-error labels, 429 toast, landing a11y |
| 2 | + |
| 3 | +Four `frontend/` issues for the API-client / landing area. |
| 4 | + |
| 5 | +## What changed and why |
| 6 | + |
| 7 | +### #1334 - response caching layer: request de-dup + `invalidateTag` |
| 8 | +`cache.ts` already had TTL + tag-based invalidation. Added: |
| 9 | +- `apiCache.invalidateTag(tag)` - the mutation-flow-facing alias the issue asks for |
| 10 | + (thin wrapper over `invalidateByTags([tag])`). |
| 11 | +- `apiCache.dedupe(key, factory)` - returns the promise of any request already in flight |
| 12 | + for `key`, otherwise starts one and forgets it once it settles (resolve **or** reject). |
| 13 | +- Wired into the client: `request()` now routes every GET through |
| 14 | + `apiCache.dedupe(url, () => sendWithRetries(...))`, so two components mounting at once |
| 15 | + and reading `/statistics` share **one** network call. Mutations are never de-duped. |
| 16 | +- Tests (`cache-dedupe.test.ts`): 3 concurrent `dedupe` calls -> 1 factory call; forgotten |
| 17 | + after settle (and after rejection); different keys not merged; `invalidateTag` drops only |
| 18 | + the tagged entry. |
| 19 | + |
| 20 | +### #1338 - contract-error labels + docs drift test |
| 21 | +The `CONTRACT_ERROR_MESSAGES` map (admin-client.ts) already covers every code 100-160 with |
| 22 | +a fallback. Added: |
| 23 | +- `contractErrors.ts`: `CONTRACT_ERROR_LABELS` (61 short labels, generated from the |
| 24 | + `Variant` column of `docs/CONTRACT_ERRORS.md`) and `getContractError(code) -> |
| 25 | + { code, label, message }` - the long message from the existing map, the short label for |
| 26 | + compact surfaces. Unmapped codes get `label: 'Contract error'` + the generic message, |
| 27 | + never `undefined`, never a throw. |
| 28 | +- `contract-errors-drift.test.ts`: parses `docs/CONTRACT_ERRORS.md` and asserts both tables |
| 29 | + cover every documented code and carry no code the doc doesn't. Renaming a variant in |
| 30 | + `errors.rs` (and regenerating the doc) fails this test. |
| 31 | + |
| 32 | +### #1339 - surface 429s as one coalesced countdown toast |
| 33 | +- `rateLimit.ts` (new): a small signal bus. `reportRateLimited(retryAfterSec)` opens (or |
| 34 | + extends) a single cooldown window; parallel 429s coalesce into it and notify listeners |
| 35 | + once per window, not once per 429. `rateLimitRemainingSeconds()` / `isRateLimited()` for |
| 36 | + readers. |
| 37 | +- Both request helpers now call `reportRateLimited` on every 429 (before the retry sleep). |
| 38 | +- `components/ui/Toast.tsx` (new): `RateLimitToast` - a persistent `role="status"` toast |
| 39 | + with a 1s-ticking countdown that hides at zero; and `useRateLimited()` for action buttons |
| 40 | + to disable themselves until the window clears. |
| 41 | +- Tests (`rateLimit.test.ts`, `Toast.test.tsx`): 3 simultaneous 429s -> 1 notification / |
| 42 | + 1 toast; window extends when a later `Retry-After` reaches further; countdown from 30 -> |
| 43 | + hides at 0; invalid `Retry-After` -> 1s window. |
| 44 | + |
| 45 | +### #1350 - landing accessibility suite |
| 46 | +- `LandingPage.accessibility.test.tsx` was **6/40 red** on `main`: the newsletter form was |
| 47 | + extracted to `NewsletterSignup` and the page now also renders a Statistics error alert, |
| 48 | + so bare `getByRole('alert')` was ambiguous. Scoped those 6 assertions to the form's |
| 49 | + `#email-error` / `#api-error` alert via a `formErrorAlert()` helper - **40/40 pass**, no |
| 50 | + component change. |
| 51 | +- `e2e/accessibility.spec.ts`: added an axe scan (inject `axe-core`, run in-page after |
| 52 | + `networkidle` + `document.fonts.ready`) of the landing page and of its newsletter error |
| 53 | + state, asserting zero critical/serious violations. This is the "e2e-level axe scan" |
| 54 | + bullet the file was missing (it only had keyboard/ARIA checks). Not run here - needs the |
| 55 | + browser install - but it is the deliverable `accessibility.yml` expects. |
| 56 | + |
| 57 | +## How to test |
| 58 | + |
| 59 | +``` |
| 60 | +cd frontend |
| 61 | +PUPPETEER_SKIP_DOWNLOAD=true npm ci --legacy-peer-deps --ignore-scripts |
| 62 | +./node_modules/.bin/jest src/lib/api src/components/ui src/components/__tests__/LandingPage.accessibility.test.tsx |
| 63 | +``` |
| 64 | + |
| 65 | +- 176 tests pass across the touched suites (114 pre-existing api + new + the now-green |
| 66 | + landing a11y 40). |
| 67 | +- `tsc --noEmit`: no errors in the touched source files over the repo's pre-existing count. |
| 68 | +- `npm run build` / Playwright e2e not run here (full monorepo build / browser install). |
| 69 | + |
| 70 | +## Breaking changes |
| 71 | + |
| 72 | +None. New exports only; `request()` behaviour is unchanged apart from GET de-dup and the |
| 73 | +429 notification. |
| 74 | + |
| 75 | +## Related issues |
| 76 | + |
| 77 | +Closes #1334 |
| 78 | +Closes #1338 |
| 79 | +Closes #1339 |
| 80 | +Closes #1350 |
| 81 | + |
| 82 | +## PR Checklist |
| 83 | + |
| 84 | +- [x] Branch is up to date with `main` |
| 85 | +- [x] Commit messages follow Conventional Commits |
| 86 | +- [x] Tests added or updated for the change |
| 87 | +- [x] Documentation updated if behaviour changed (n/a) |
| 88 | +- [x] No secrets or credentials committed |
0 commit comments