From 9c2d30d7de28e83a4d0afb117f46ed07a099f944 Mon Sep 17 00:00:00 2001 From: sbc1-code <207095575+sbc1-code@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:20:34 -0700 Subject: [PATCH 1/3] fix(deps): prepare patched Router 7.18.4 candidate Removes the two Router audit entries in isolation. Candidate is held locally: its 67.01 KiB entry exceeds the unchanged 62 KiB release budget. No publication authorized with a failing gate. --- package-lock.json | 64 +++++++++++++++++++++++++++++------------------ package.json | 2 +- 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9f5b4f0f..16c9fc52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,7 +53,7 @@ "react-dom": "^18.2.0", "react-hook-form": "^7.75.0", "react-resizable-panels": "^2.1.7", - "react-router-dom": "^6.26.0", + "react-router-dom": "^7.18.4", "recharts": "^2.15.4", "sonner": "^2.0.1", "tailwind-merge": "^3.6.0", @@ -2493,15 +2493,6 @@ "version": "1.1.1", "license": "MIT" }, - "node_modules/@remix-run/router": { - "version": "1.23.4", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz", - "integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/@resvg/resvg-js": { "version": "2.6.2", "dev": true, @@ -3847,6 +3838,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/css-line-break": { "version": "2.1.0", "license": "MIT", @@ -5993,35 +5997,41 @@ } }, "node_modules/react-router": { - "version": "6.30.6", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz", - "integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==", + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.4" + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=16.8" + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } } }, "node_modules/react-router-dom": { - "version": "6.30.6", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz", - "integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==", + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", "license": "MIT", "dependencies": { - "@remix-run/router": "1.23.4", - "react-router": "6.30.6" + "react-router": "7.18.4" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" }, "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" + "react": ">=18", + "react-dom": ">=18" } }, "node_modules/react-smooth": { @@ -6324,6 +6334,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/sonner": { "version": "2.0.7", "license": "MIT", diff --git a/package.json b/package.json index 0ea12c40..c43f573d 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ "react-dom": "^18.2.0", "react-hook-form": "^7.75.0", "react-resizable-panels": "^2.1.7", - "react-router-dom": "^6.26.0", + "react-router-dom": "^7.18.4", "recharts": "^2.15.4", "sonner": "^2.0.1", "tailwind-merge": "^3.6.0", From fe9ae120dc1e74c7979e314d46f03d7db85141c2 Mon Sep 17 00:00:00 2001 From: sbc1-code <207095575+sbc1-code@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:22:35 -0700 Subject: [PATCH 2/3] test: retain Router candidate regressions and release blocker --- CHANGELOG.md | 13 +++++++ DECISIONS.md | 8 +++++ ROADMAP.md | 7 ++-- docs/REFRESH-OPERATIONS.md | 46 ++++++++++++++++++++++++ package.json | 2 +- scripts/check-router-navigation.mjs | 54 +++++++++++++++++++++++++++++ scripts/smoke-routes.mjs | 2 ++ scripts/test-router-navigation.mjs | 43 +++++++++++++++++++++++ 8 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 scripts/check-router-navigation.mjs create mode 100644 scripts/test-router-navigation.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cc3d103..8af6e18c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # CHANGELOG +## 2026-10-22 — Router security candidate (local, not published) + +- Verify the two Router advisories are fixed in react-router 7.18.0+, not in + the unrelated postcss-selector-parser 6.1.5 backport. Prepare Router DOM and + Router 6.30.6 → 7.18.4 with only their required runtime lock entries changed. +- Candidate raw audit drops from eight to six moderate entries; the two Router + entries disappear. Build, 130 unit tests and 402 diagnostic route checks pass, + including mixed-separator rejection and EN/ES desktop/mobile navigation. +- Full npm test **fails** the unchanged 62 KiB entry gate: candidate 67.0146 KiB + versus baseline 60.6943 KiB. No app code, build configuration, audit exception + or budget changed. No candidate push, PR, CI or deployment was performed. + Keep it for review; production remains the verified historical-pattern release. + ## 2026-10-22 — Historical pattern confidence (published) - PR #139 merged at `9893047411d8545df8298eb196a8f585258b1598` after both diff --git a/DECISIONS.md b/DECISIONS.md index edb08b81..ae037abf 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,5 +1,13 @@ # DECISIONS +## 2026-10-22 · A patched dependency candidate must also pass the performance gate + +Router 7.18.4 clears both runtime audit entries and matches the app's tested +React/Node requirements, but increases initial JS above the existing budget. +Keep the minimal package/lock change and regression evidence local. Do not +raise the budget, move bytes to another eagerly required chunk to hide them, +or change unrelated build packages merely to reduce the advisory count. + ## 2026-10-22 · Historical weekday medians are not live observations The anomaly feed's lowest_today scans 30-day weekday buckets, and sunday_spike diff --git a/ROADMAP.md b/ROADMAP.md index 743dd0df..250033f2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -84,8 +84,11 @@ cross-border experience; it is a product hypothesis, not a claim of demand. - [ ] Verify fresh public history/FX after the repair and observe two subsequent scheduled publications. Keep first-week usage/cost review pending separately; no second monitoring automation or manual run counts as a scheduled success. -- [ ] Triage the eight remaining moderate audit package entries without absorbing - unrelated dependency majors or adding broad advisory exceptions. +- [~] Router runtime repair evaluated October 22: local 7.18.4 candidate removes + its two audit entries and passes functional QA, but exceeds the unchanged + initial-JS gate (67.01 vs 62 KiB). Not published. Reduce initial bytes without + weakening the gate before remote CI/publication. Six build-chain entries stay + separate; parser 6.1.5 is not a Router fix. See the existing refresh runbook. - [x] Current-waits views refresh while open and on tab return/reconnection, including comparison, crossing, best-time, walk-or-drive and embed routes. Slow optional feeds no longer delay waits, and the service worker does not diff --git a/docs/REFRESH-OPERATIONS.md b/docs/REFRESH-OPERATIONS.md index 0c4a9d0b..0b27b0bb 100644 --- a/docs/REFRESH-OPERATIONS.md +++ b/docs/REFRESH-OPERATIONS.md @@ -1,5 +1,51 @@ # Public data refresh +## Router candidate review — 2026-10-22 UTC (not published) + +- Exact targets: **react-router-dom 6.30.6 → 7.18.4**, resolving **react-router + 7.18.4**. Both advisories name react-router, not the CSS selector parser. + [GHSA-wrjc-x8rr-h8h6](https://github.com/remix-run/react-router/security/advisories/GHSA-wrjc-x8rr-h8h6) + affects >=6.0.0,<7.18.0; [GHSA-337j-9hxr-rhxg](https://github.com/remix-run/react-router/security/advisories/GHSA-337j-9hxr-rhxg) + affects >=6.4.0,<7.18.0. Both list fixes at 7.18.0+. Official npm's latest + stable 6.x is 6.30.6; parser 6.1.5 does not mitigate either Router advisory. +- [Versioned official upgrade guide](https://reactrouter.com/7.18.4/upgrading/v6) + requires Node20+/React18+/ReactDOM18+. Current React/DOM 18.3.1, local Node25, + verification Node22 and publisher Node24 satisfy those baselines. The guide's + no-breaking statement is conditional on future flags, not a blanket promise. + This app has only single-segment '*' fallbacks, absolute internal links, + module-level lazy declarations and declarative BrowserRouter; unused data + router/fetcher/hydration changes were not introduced. No app API/import, + React/Vite, security-setting or future-flag change was needed for the candidate. +- Minimal dependency diff: root Router DOM range, the two Router entries, + removal of their unused @remix-run/router, and required cookie/set-cookie-parser. + Six unrelated optional WASI lock additions from npm were excluded. Other + resolved packages are identical. No override or new audit exception. +- Raw candidate audit: **six moderate, zero high/critical**, with both Router + entries absent. Four new library regressions check normalized separators, + external-navigation rejection, internal query/hash/replace/Back/Forward and + encoded separators inside internal parameters. Before/after library behavior + was captured; these tests do not imply the old public app exposed raw URLs. +- Build and 130 unit tests pass. Full npm test **fails at test:bundle**: + 68,623 bytes / **67.01465 KiB** initial JS against 62 KiB. Baseline is 62,151 + bytes / 60.69434 KiB: **+6,472 bytes**. CSS remains 19.08 KiB under 20 KiB. + The budget and audit scripts are unchanged. No successful full-suite claim. +- Standalone route diagnostics subsequently passed **402 navigations**, existing + freshness/history/share/feedback/language regressions, and added EN/ES + desktop/mobile nested navigation, unknown/encoded-path fallback and checks + against external main-frame navigation. These diagnostic results do not + bypass the failed release gate or authorize publication. Visual headless QA + passed in both languages and widths. SSR/hydration exploit execution was not + tested because the app does not use that mode; package mitigation is supported + by maintainer ranges and the raw audit, not a complete security certification. +- Candidate stays local, with dependency-only and regression/documentation + commits separated. No remote candidate branch, CI, PR or deployment. Live + production remains 98930474 from PR #139. Rollback is local-only: revert the + candidate's support commit then dependency commit in its isolated branch; + preserve other worktrees and collector inputs. No production rollback needed. +- Next: reduce actual initial bytes without raising/evading the gate, rerun + complete checks, then obtain passing remote CI and verify production under + the existing authorization. Do not mix in the separate parser/build-chain work. + ## Historical pattern release — 2026-10-22 UTC - [PR #139](https://github.com/sbc1-code/borderpulse/pull/139) merged reviewed diff --git a/package.json b/package.json index c43f573d..77d53dab 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "news:classify": "node scripts/build-curation-queue.mjs", "test:bundle": "node scripts/check-bundle-size.mjs", "test:audit": "node scripts/check-audit.mjs", - "test:trust": "node --test scripts/test-trust-state.mjs scripts/test-local-comparison.mjs scripts/test-browser-state.mjs scripts/test-historical-pattern.mjs", + "test:trust": "node --test scripts/test-trust-state.mjs scripts/test-local-comparison.mjs scripts/test-browser-state.mjs scripts/test-historical-pattern.mjs scripts/test-router-navigation.mjs", "test:mdx": "node --test scripts/test-mdx-frontmatter.mjs", "test:writer": "node --test scripts/test-generated-writer.mjs", "test:refresh": "node --test scripts/test-data-refresh.mjs" diff --git a/scripts/check-router-navigation.mjs b/scripts/check-router-navigation.mjs new file mode 100644 index 00000000..dee85564 --- /dev/null +++ b/scripts/check-router-navigation.mjs @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; + +export async function checkRouterNavigation(owner, context, baseUrl) { + const errors = [], external = []; + for (const language of ['en', 'es']) { + const qa = await context.browser().newContext({ viewport: owner.viewportSize(), serviceWorkers: 'block' }); + await qa.route('**/*', route => { + const request = route.request(); + const url = new URL(request.url()); + if (url.origin === baseUrl.origin) return route.continue(); + if (request.isNavigationRequest() && request.frame().parentFrame() === null) external.push(url.origin); + return route.abort('blockedbyclient'); + }); + await qa.addInitScript(value => localStorage.setItem('borderPulse_language', value), language); + const page = await qa.newPage(); + page.setDefaultTimeout(12_000); + page.on('pageerror', error => errors.push(error.message)); + page.on('console', message => { if (message.type() === 'error' && /ErrorBoundary|Uncaught|TypeError|ReferenceError/.test(message.text())) errors.push(message.text()); }); + try { + await page.goto(new URL('/crossing/san-ysidro/', baseUrl).href); + await page.locator('#root').getByRole('link', { name: language === 'en' ? 'All crossings' : 'Todos los cruces', exact: true }).waitFor(); + await page.locator('#root a[href="/best-time/san-ysidro/"]').first().click(); + await page.waitForURL(url => url.pathname === '/best-time/san-ysidro/'); + await page.getByRole('heading', { level: 1 }).waitFor(); + await page.goBack(); + await page.waitForURL(url => url.pathname === '/crossing/san-ysidro/'); + await page.locator('#root a[href="/compare/san-ysidro-vs-otay-mesa/"]').first().click(); + await page.getByTestId('compare-summary').waitFor(); + await page.goBack(); + await page.waitForURL(url => url.pathname === '/crossing/san-ysidro/'); + await page.goForward(); + await page.getByTestId('compare-summary').waitFor(); + assert.equal(await page.locator('html').getAttribute('lang'), language); + assert.equal(new URL(page.url()).origin, baseUrl.origin); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + // Unknown targets are either rejected by the host (404) or handled by + // the app's existing Navigate-to-home fallback, never a foreign origin. + for (const input of ['/crossing/not-a-port-for-router-test/', '/crossing/%5C%5Cbp-router.invalid/', '/crossing/%2F%5Cbp-router.invalid/', '/%5C', '/not-a-route?returnTo=https%3A%2F%2Fbp-router.invalid']) { + const url = new URL(input, baseUrl); + assert.equal(url.origin, baseUrl.origin); + const response = await page.goto(url.href); + assert.ok([200, 404].includes(response.status()), `${input}: unexpected server status`); + if (response.status() === 200) { + await page.waitForURL(url => url.pathname === '/'); + await page.getByRole('searchbox', { name: language === 'en' ? 'Search crossings by name or city' : 'Buscar cruces por nombre o ciudad', exact: true }).waitFor(); + } + assert.equal(new URL(page.url()).origin, baseUrl.origin); + } + } finally { await qa.close(); } + } + assert.deepEqual(external, [], 'no fixture can send the main frame to an external origin'); + assert.deepEqual(errors, [], 'routing fixtures must not crash the app'); + console.log('[router-navigation] PASS: EN/ES internal links, nested routes, Back/Forward, unknown/encoded-separator fallback and no external navigation'); +} diff --git a/scripts/smoke-routes.mjs b/scripts/smoke-routes.mjs index ef7cb483..eeb14782 100644 --- a/scripts/smoke-routes.mjs +++ b/scripts/smoke-routes.mjs @@ -6,6 +6,7 @@ import { chromium } from 'playwright-core'; import { checkGrowthInteractions } from './check-growth-interactions.mjs'; import { checkBrowserResilience } from './check-browser-resilience.mjs'; import { checkHistoricalPatterns } from './check-historical-pattern.mjs'; +import { checkRouterNavigation } from './check-router-navigation.mjs'; import { checkLocalCrossings } from './check-local-crossings.mjs'; import { checkNoJsReports } from './check-nojs-reports.mjs'; import { checkWalkDriveFreshness } from './check-walk-drive-freshness.mjs'; @@ -325,6 +326,7 @@ async function runProfile(browser, profile, manifest, expectedCrossingCount) { await checkGrowthInteractions(page, context, baseUrl); await checkBrowserResilience(page, context, baseUrl); await checkHistoricalPatterns(page, context, baseUrl); + await checkRouterNavigation(page, context, baseUrl); await checkLocalCrossings(page, context, baseUrl); await checkWalkDriveAvailability(page, context, baseUrl); await checkWalkDriveFreshness(context, baseUrl); diff --git a/scripts/test-router-navigation.mjs b/scripts/test-router-navigation.mjs new file mode 100644 index 00000000..0008ef48 --- /dev/null +++ b/scripts/test-router-navigation.mjs @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createMemoryRouter, resolvePath } from 'react-router-dom'; + +const backslash = String.fromCharCode(92); +const mixed = ['//foo', backslash + backslash + 'foo', '/' + backslash + 'foo', backslash + '/foo']; +const router = () => createMemoryRouter([ + { path: '/home', element: null }, { path: '/foo', element: null }, + { path: '/crossing/:slug', element: null }, +], { initialEntries: ['/home'] }); + +test('Router normalizes the mixed-separator paths covered by the maintainer fix', () => { + for (const value of mixed) assert.deepEqual(resolvePath(value), { pathname: '/foo', search: '', hash: '' }); +}); +test('Router rejects external/protocol-relative navigation and leaves the current location intact', async () => { + const r = router(); + try { + for (const value of [...mixed, 'https://bp-router.invalid/path']) { + await assert.rejects(r.navigate(value), /External navigation is not allowed/); + assert.equal(r.state.location.pathname, '/home'); + } + } finally { r.dispose(); } +}); +test('internal query/hash, replace and Back/Forward navigation retain their intended history', async () => { + const r = router(); + try { + await r.navigate('/foo?area=california#lanes'); + assert.deepEqual([r.state.location.pathname, r.state.location.search, r.state.location.hash], ['/foo', '?area=california', '#lanes']); + await r.navigate('/home?lang=es', { replace: true }); + await r.navigate(-1); + assert.deepEqual([r.state.location.pathname, r.state.location.search], ['/home', '']); + await r.navigate(1); + assert.deepEqual([r.state.location.pathname, r.state.location.search], ['/home', '?lang=es']); + } finally { r.dispose(); } +}); +test('encoded separators inside an internal route parameter do not become an external destination', async () => { + const r = router(); + try { + await r.navigate('/crossing/%5C%5Cbp-router.invalid/'); + assert.equal(r.state.location.pathname, '/crossing/%5C%5Cbp-router.invalid/'); + assert.equal(r.state.errors, null); + } finally { r.dispose(); } +}); From e0bac72fd4d978dfbbec7f3c68d804025d3ac45c Mon Sep 17 00:00:00 2001 From: sbc1-code <207095575+sbc1-code@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:52:40 -0700 Subject: [PATCH 3/3] perf: load closed dashboard summary on request within Router budget --- CHANGELOG.md | 21 ++++ DECISIONS.md | 14 ++- docs/REFRESH-OPERATIONS.md | 45 ++++++++ scripts/check-dashboard-summary.mjs | 107 ++++++++++++++++++ scripts/smoke-routes.mjs | 2 + src/components/dashboard/DashboardSummary.jsx | 12 ++ .../dashboard/DashboardSummaryDisclosure.jsx | 37 ++++++ src/components/ui/dropdown-menu.jsx | 41 ++++--- src/pages/Dashboard.jsx | 18 +-- 9 files changed, 256 insertions(+), 41 deletions(-) create mode 100644 scripts/check-dashboard-summary.mjs create mode 100644 src/components/dashboard/DashboardSummary.jsx create mode 100644 src/components/dashboard/DashboardSummaryDisclosure.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 8af6e18c..004a898d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # CHANGELOG +## 2026-10-22 — Router performance gate follow-up (validated locally) + +- Keep the reviewed Router 7.18.4 security candidate and the unchanged 62 KiB + budget. Load the already-collapsed dashboard summary/chart on first opening, + retain its state on reopening, and isolate asset failures with an EN/ES notice + while crossing reports, filters and navigation stay usable. +- Make unused local dropdown wrappers removable through a pure ref/name factory; + preserve every export and the rendered menu's refs, styles and actions. +- Entry is 63,169 bytes / 61.6885 KiB gzip (319 bytes below budget), versus + 68,623 bytes before. All initially required JS totals 280,504 versus 285,958 + gzip bytes: a real 5,454-byte reduction, with no build/config/budget changes. + First summary opening requests one 5,861-byte gzip chunk. In cold headless + samples at 150ms latency/200KiB/s, first opening took 0.30–1.34s versus + 0.02–0.05s before. Loading is announced; current reports stay interactive. + This is an explicit on-request cost for formerly hidden work, not a speed claim. +- Fresh build, 130 unit tests and full npm test pass, including 402 route + navigations and new EN/ES desktop/mobile delayed/failed-asset recovery, + summary keyboard/filter/state and menu keyboard/focus/action checks. + Audit remains six moderate build-chain entries, zero high/critical. +- Publication remains conditional on remote CI; no deployment is claimed here. + ## 2026-10-22 — Router security candidate (local, not published) - Verify the two Router advisories are fixed in react-router 7.18.0+, not in diff --git a/DECISIONS.md b/DECISIONS.md index ae037abf..d0daeed8 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -2,11 +2,15 @@ ## 2026-10-22 · A patched dependency candidate must also pass the performance gate -Router 7.18.4 clears both runtime audit entries and matches the app's tested -React/Node requirements, but increases initial JS above the existing budget. -Keep the minimal package/lock change and regression evidence local. Do not -raise the budget, move bytes to another eagerly required chunk to hide them, -or change unrelated build packages merely to reduce the advisory count. +The first Router 7.18.4 candidate cleared both runtime audit entries but +exceeded the existing entry budget. A bounded follow-up loads the existing +closed summary/chart on request and removes unused local wrapper construction, +with explicit loading/failure handling and EN/ES keyboard/recovery regressions. +The full local release suite now passes under the unchanged budget. Release +still requires green remote CI and served-app verification. Do not raise the +budget, move bytes to another eagerly required chunk to hide them, or change +unrelated build packages merely to reduce the advisory count. First summary +opening has a measured cold-network cost; reports remain usable throughout. ## 2026-10-22 · Historical weekday medians are not live observations diff --git a/docs/REFRESH-OPERATIONS.md b/docs/REFRESH-OPERATIONS.md index 0b27b0bb..95cd15d6 100644 --- a/docs/REFRESH-OPERATIONS.md +++ b/docs/REFRESH-OPERATIONS.md @@ -1,5 +1,50 @@ # Public data refresh +## Router follow-up — 2026-10-22 UTC (local release checks passed) + +The earlier candidate checkpoint below records the original failed gate. The +same isolated branch now passes the complete local suite without changing +Vite configuration, the bundle/audit scripts, exceptions or any security setting. +Dependency-only commit `9c2d30d7` remains separate from navigation regressions +and this performance follow-up. Version 6.1.5 is the CSS parser, not Router. + +- Profile the exact same installed graph before/after. Direct Router imports, + production deep-entry aliases and pure call annotations alone did not solve + the gate; none of those experiments changed the app/config. Do not patch + upstream Router code or strip its navigation validation. +- The native dashboard disclosure remains closed by default. Its existing + summary/map now loads once on first opening, retains mounted state across + close/reopen, and follows current filters/data. An asset failure produces a + localized status and leaves the report list and route navigation operational; + reloading after network recovery restores the section. No data/feed/threshold + or permissions change. Unused dropdown wrappers retain their API and names, + but their construction can be tree-shaken as a pure factory. +- Exact entry gzip: **63,169 bytes / 61.68848 KiB**, below the unchanged 62 KiB + cap by 319 bytes. The original candidate was 68,623 bytes. Counting **all four + initially required JS chunks**, before is 285,958 and after 280,504 bytes: + **5,454 bytes actually removed from initial loading**. First opening adds one + 5,861-byte gzip summary chunk; no new vendor waterfall. Across all routes JS + totals 427,793 versus 427,367 gzip bytes (+426 bytes of splitting/handling). + Neither total nor navigation cost is concealed by the entry-only gate. +- Cold headless EN/ES desktop/mobile samples with simulated 150ms latency, + 200KiB/s download and 80KiB/s upload: usable reports 2.00–2.05s after versus + 2.02–2.11s before; first chart opening 0.30–1.34s versus 0.02–0.05s. Small + samples are diagnostic, not a production latency/usage/conversion claim. + Loading is visible/announced and list interaction works even while the asset + is deliberately held. This request cost is confined to formerly hidden work. +- Fresh build and **full npm test PASS**, with 130 standalone unit tests and + 402 existing navigations. New tests cover both languages and both viewports: + no chart work/request before opening, cold held asset, list filtering while + loading, map keyboard selection, close/reopen identity and cache reuse, + changed filters, menu lane toggle/route actions/Escape focus, asset failure + without ErrorBoundary and successful reload recovery. Existing snapshot aging, + history, sharing/feedback, browser resilience, SEO and alias checks pass. +- Publication: await remote CI on the exact reviewed head, check concurrent + changes again, then use the existing single production workflow after merge. + No second deployment dispatch or new tool/monitor. Preserve newer collector + inputs. Rollback through a normal inverse/revert PR of this scoped code and + dependency diff, keeping collector data; do not roll the whole data bundle back. + ## Router candidate review — 2026-10-22 UTC (not published) - Exact targets: **react-router-dom 6.30.6 → 7.18.4**, resolving **react-router diff --git a/scripts/check-dashboard-summary.mjs b/scripts/check-dashboard-summary.mjs new file mode 100644 index 00000000..f5d3648c --- /dev/null +++ b/scripts/check-dashboard-summary.mjs @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +// A cold module request is held until the test releases it. This proves the +// report list works during loading, instead of hiding a first-open regression. +export async function checkDashboardSummary(owner, context, baseUrl) { + const snapshot = JSON.parse(fs.readFileSync(new URL('../public/data/crossings.json', import.meta.url))); + snapshot.fetched_at = new Date().toISOString(); + const port = snapshot.crossings.find(row => row.port_number === '250401'); + port.current_wait_time = 60; + port.lanes.passenger_standard = { status: 'Delay', delay_minutes: 60, lanes_open: 1 }; + const errors = []; + for (const language of ['en', 'es']) { + const qa = await context.browser().newContext({ viewport: owner.viewportSize(), serviceWorkers: 'block', reducedMotion: 'reduce' }); + await qa.addInitScript(value => { + localStorage.setItem('borderPulse_language', value); + window.summaryEvents = []; + window.umami = { track: (event, data) => window.summaryEvents.push({ event, data }) }; + }, language); + await qa.route('**/*', route => new URL(route.request().url()).origin === baseUrl.origin ? route.continue() : route.abort('blockedbyclient')); + await qa.route(url => ['/data/crossings.json', '/api/public/crossings'].includes(url.pathname), route => route.fulfill({ contentType: 'application/json', body: JSON.stringify(snapshot) })); + let requests = 0, fail = false, release; + const pending = new Promise(resolve => { release = resolve; }); + const asset = /\/assets\/DashboardSummary-[^/]+\.js$/; + await qa.route(asset, async route => { + requests++; + if (fail) return route.abort('failed'); + const response = await route.fetch(); + await pending; + await route.fulfill({ response }); + }); + const page = await qa.newPage(); + page.setDefaultTimeout(10_000); + page.on('pageerror', error => errors.push(error.message)); + page.on('console', message => { if (message.type() === 'error' && /ErrorBoundary|Uncaught|TypeError|ReferenceError/.test(message.text())) errors.push(message.text()); }); + const search = page.getByRole('searchbox', { name: language === 'en' ? 'Search crossings by name or city' : 'Buscar cruces por nombre o ciudad', exact: true }); + const summary = page.locator('summary').filter({ hasText: language === 'en' ? 'Show wait summary and border chart' : 'Ver resumen y mapa de esperas' }); + const chart = page.getByRole('region', { name: language === 'en' ? 'The border, west to east' : 'La frontera, de oeste a este', exact: true }); + try { + await page.goto(baseUrl.href); + await search.waitFor(); + assert.equal(requests, 0, 'closed disclosure must not request the summary chunk'); + assert.equal(await chart.count(), 0, 'closed summary must not render hidden chart work'); + await summary.focus(); + await page.keyboard.press('Enter'); + await page.getByRole('status').getByText(language === 'en' ? 'Loading…' : 'Cargando…', { exact: true }).waitFor(); + await search.fill('San Ysidro'); + await page.locator('#root a[href="/crossing/san-ysidro/"]').first().waitFor(); + release(); + await chart.waitFor(); + assert.equal(requests, 1); + assert.match(await chart.getByRole('list').innerText(), /San Ysidro: 60 min/); + const tick = chart.getByRole('button', { name: /^San Ysidro:/ }); + await tick.focus(); + await page.keyboard.press('Enter'); + assert.equal(await page.evaluate(() => window.summaryEvents.filter(event => event.event === 'borderline-select' && event.data.port === '250401').length), 1); + await page.evaluate(() => { window.retainedSummary = document.querySelector('section.bp-line'); }); + await summary.click(); + await summary.click(); + assert.ok(await page.evaluate(() => window.retainedSummary === document.querySelector('section.bp-line')), 'reopening keeps chart state and DOM'); + assert.equal(requests, 1, 'reopening must reuse the loaded module'); + await search.fill('Otay Mesa'); + await chart.getByRole('button', { name: /^Otay Mesa:/ }).waitFor(); + assert.equal(await chart.getByRole('button', { name: /^San Ysidro:/ }).count(), 0, 'loaded summary follows current filters'); + assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + + // The menu wrappers that are used must retain keyboard/focus and actions. + const menu = page.getByRole('button', { name: language === 'en' ? 'More options' : 'Más opciones', exact: true }).first(); + await menu.focus(); + await page.keyboard.press('ArrowDown'); + const lane = page.getByRole('menuitem', { name: /^(Show lanes|Hide lanes|Ver carriles|Ocultar carriles)$/ }); + const label = await lane.innerText(); + await lane.click(); + await menu.click(); + assert.notEqual(await lane.innerText(), label, 'lane menu action still toggles'); + await page.keyboard.press('Escape'); + assert.ok(await menu.evaluate(node => node === document.activeElement), 'Escape restores trigger focus'); + await menu.click(); + await page.getByRole('menuitem', { name: language === 'en' ? 'Best time to cross' : 'Mejor hora para cruzar', exact: true }).click(); + await page.waitForURL(url => url.pathname === '/best-time/otay-mesa/'); + await page.goBack(); + await search.waitFor(); + + // A failed chunk must not replace the crossing list with ErrorBoundary. + fail = true; + await page.reload(); + await search.waitFor(); + await summary.click(); + await page.getByRole('status').getByText(language === 'en' ? 'Summary unavailable. Reload the page to retry.' : 'Resumen no disponible. Recarga la página para reintentar.', { exact: true }).waitFor(); + await search.fill('San Ysidro'); + await page.locator('#root a[href="/crossing/san-ysidro/"]').first().click(); + await page.waitForURL(url => url.pathname === '/crossing/san-ysidro/'); + await page.getByRole('heading', { level: 1 }).waitFor(); + fail = false; + await page.goto(baseUrl.href); + await search.waitFor(); + await summary.click(); + await chart.waitFor(); + assert.ok(!/Something went wrong/.test(await page.locator('#root').innerText())); + } finally { + release(); + await qa.close(); + } + } + assert.deepEqual(errors, [], 'summary loading/failure/recovery must not crash the app'); + console.log('[dashboard-summary] PASS: EN/ES closed-load budget, delayed first open, keyboard selection, retained state, current filters, menu actions/focus, failed asset isolation and recovery'); +} diff --git a/scripts/smoke-routes.mjs b/scripts/smoke-routes.mjs index eeb14782..803ccad6 100644 --- a/scripts/smoke-routes.mjs +++ b/scripts/smoke-routes.mjs @@ -7,6 +7,7 @@ import { checkGrowthInteractions } from './check-growth-interactions.mjs'; import { checkBrowserResilience } from './check-browser-resilience.mjs'; import { checkHistoricalPatterns } from './check-historical-pattern.mjs'; import { checkRouterNavigation } from './check-router-navigation.mjs'; +import { checkDashboardSummary } from './check-dashboard-summary.mjs'; import { checkLocalCrossings } from './check-local-crossings.mjs'; import { checkNoJsReports } from './check-nojs-reports.mjs'; import { checkWalkDriveFreshness } from './check-walk-drive-freshness.mjs'; @@ -327,6 +328,7 @@ async function runProfile(browser, profile, manifest, expectedCrossingCount) { await checkBrowserResilience(page, context, baseUrl); await checkHistoricalPatterns(page, context, baseUrl); await checkRouterNavigation(page, context, baseUrl); + await checkDashboardSummary(page, context, baseUrl); await checkLocalCrossings(page, context, baseUrl); await checkWalkDriveAvailability(page, context, baseUrl); await checkWalkDriveFreshness(context, baseUrl); diff --git a/src/components/dashboard/DashboardSummary.jsx b/src/components/dashboard/DashboardSummary.jsx new file mode 100644 index 00000000..f4c9dd5f --- /dev/null +++ b/src/components/dashboard/DashboardSummary.jsx @@ -0,0 +1,12 @@ +import React from 'react'; +import BorderLine from './BorderLine'; +import CommuterSnapshot from './CommuterSnapshot'; + +export default function DashboardSummary({ direction, regionLabel, ...props }) { + return ( + <> + + + + ); +} diff --git a/src/components/dashboard/DashboardSummaryDisclosure.jsx b/src/components/dashboard/DashboardSummaryDisclosure.jsx new file mode 100644 index 00000000..47de2909 --- /dev/null +++ b/src/components/dashboard/DashboardSummaryDisclosure.jsx @@ -0,0 +1,37 @@ +import React, { useEffect, useState } from 'react'; + +// The chart is hidden behind the existing native disclosure. Load it only +// on request, then keep it mounted so closing/reopening preserves its state. +// An asset failure stays local: the current crossing reports remain usable. +export default function DashboardSummaryDisclosure(props) { + const [state, setState] = useState('closed'); + const Summary = typeof state === 'function' ? state : null; + const es = props.language === 'es'; + + useEffect(() => { + if (state !== 'loading') return; + let active = true; + import('./DashboardSummary').then( + module => { if (active) setState(() => module.default); }, + () => { if (active) setState('failed'); }, + ); + return () => { active = false; }; + }, [state]); + + return ( +
{ + if (event.currentTarget.open && state === 'closed') setState('loading'); + }} className="mb-4 rounded-lg border border-slate-200 bg-white/70 p-3 dark:border-gray-700 dark:bg-gray-900/70"> + + {es ? 'Ver resumen y mapa de esperas' : 'Show wait summary and border chart'} + + {Summary ? : state !== 'closed' && ( +

+ {state === 'failed' + ? (es ? 'Resumen no disponible. Recarga la página para reintentar.' : 'Summary unavailable. Reload the page to retry.') + : (es ? 'Cargando…' : 'Loading…')} +

+ )} +
+ ); +} diff --git a/src/components/ui/dropdown-menu.jsx b/src/components/ui/dropdown-menu.jsx index 248dec05..ef851e92 100644 --- a/src/components/ui/dropdown-menu.jsx +++ b/src/components/ui/dropdown-menu.jsx @@ -4,6 +4,14 @@ import { Check, ChevronRight, Circle } from "lucide-react" import { cn } from "@/lib/utils" +// Name a newly created ref component in one pure factory call so unused +// wrappers can be removed without losing any of the public exports. +function namedRef(render, displayName) { + const component = React.forwardRef(render) + component.displayName = displayName + return component +} + const DropdownMenu = DropdownMenuPrimitive.Root const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger @@ -16,7 +24,7 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup -const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, ...props }, ref) => ( +const DropdownMenuSubTrigger = /*#__PURE__*/ namedRef(({ className, inset, children, ...props }, ref) => ( -)) -DropdownMenuSubTrigger.displayName = - DropdownMenuPrimitive.SubTrigger.displayName +), DropdownMenuPrimitive.SubTrigger.displayName) -const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => ( +const DropdownMenuSubContent = /*#__PURE__*/ namedRef(({ className, ...props }, ref) => ( -)) -DropdownMenuSubContent.displayName = - DropdownMenuPrimitive.SubContent.displayName +), DropdownMenuPrimitive.SubContent.displayName) const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => ( @@ -71,7 +75,7 @@ const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) )) DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName -const DropdownMenuCheckboxItem = React.forwardRef(({ className, children, checked, ...props }, ref) => ( +const DropdownMenuCheckboxItem = /*#__PURE__*/ namedRef(({ className, children, checked, ...props }, ref) => ( {children} -)) -DropdownMenuCheckboxItem.displayName = - DropdownMenuPrimitive.CheckboxItem.displayName +), DropdownMenuPrimitive.CheckboxItem.displayName) -const DropdownMenuRadioItem = React.forwardRef(({ className, children, ...props }, ref) => ( +const DropdownMenuRadioItem = /*#__PURE__*/ namedRef(({ className, children, ...props }, ref) => ( {children} -)) -DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName +), DropdownMenuPrimitive.RadioItem.displayName) -const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => ( +const DropdownMenuLabel = /*#__PURE__*/ namedRef(({ className, inset, ...props }, ref) => ( -)) -DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName +), DropdownMenuPrimitive.Label.displayName) -const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => ( +const DropdownMenuSeparator = /*#__PURE__*/ namedRef(({ className, ...props }, ref) => ( -)) -DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName +), DropdownMenuPrimitive.Separator.displayName) const DropdownMenuShortcut = ({ className, diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 4d039463..87c5e392 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -15,8 +15,7 @@ import InstallPrompt from '@/components/dashboard/InstallPrompt'; import SkeletonCard from '@/components/dashboard/SkeletonCard'; import StaleDataBanner from '@/components/dashboard/StaleDataBanner'; import PopularCrossings from '@/components/dashboard/PopularCrossings'; -import BorderLine from '@/components/dashboard/BorderLine'; -import CommuterSnapshot from '@/components/dashboard/CommuterSnapshot'; +import DashboardSummaryDisclosure from '@/components/dashboard/DashboardSummaryDisclosure'; import LocalCrossingComparison from '@/components/dashboard/LocalCrossingComparison'; import DashboardCameras from '@/components/dashboard/DashboardCameras'; import { CORRIDORS, corridorFor } from '@/lib/corridors'; @@ -540,27 +539,16 @@ export default function Dashboard() { -
- {language === 'es' ? 'Ver resumen y mapa de esperas' : 'Show wait summary and border chart'} - - - {/* The border drawn to scale. Primary orientation layer: on small - screens the card list below collapses behind it. */} - -
{/* Wait list (left) + sidebar (right): stats + USD/MXN + alert banner. */}