# Frontend production-readiness audit

This document records verified risks, the production controls that address them, and the next evidence to collect. It is intentionally operational: a passing build alone is not proof that the browser application is production-ready.

## 2026-07-16: pre-React boot recovery

Route Suspense and error boundaries can recover failures only after the React entry has loaded and committed. The production HTML previously contained an empty `#root`, so an entry-script fetch failure, synchronous initialization exception, unhandled startup rejection or permanently stalled module graph could leave a completely blank page with no operator action. React documents error boundaries as protection for errors in their descendant component tree; the browser `error` event separately reports resources that fail to load or scripts that cannot execute: <https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary>, <https://developer.mozilla.org/en-US/docs/Web/API/Window/error_event>.

The HTML now paints a credential-free static loading shell before any application JavaScript runs. A ten-second watchdog listens for startup resource errors and unhandled rejections; the first committed React tree emits a ready event that removes all listeners and the timer. If readiness never arrives, the existing shell becomes an explicit startup failure card with a manual “重新加载最新版本” action that adds a cache-busting query parameter. It never auto-reloads, so a persistent outage cannot create a reload loop, and it never replaces an already-rendered application tree. After React commits, a separate root boundary covers authentication, the router and the application shell, while the narrower route boundaries continue isolating individual modules.

Source tests protect the complete handshake and prove a shell-level render exception remains actionable, while the production build gate inspects the generated `index.html` and entry bundle so bundler changes cannot silently remove the pre-React boundary or ready signal. Release QA covers both the normal immediate replacement and a deliberately blocked module request that reaches the static recovery action without a blank viewport.

## 2026-07-16: throttled selected-vehicle address lookup

The selected vehicle position refreshes every ten seconds so its map marker, ripple and tracking camera remain current. The detail card previously used every six-decimal coordinate as part of the reverse-geocode query key, so a moving vehicle could create a new remote AMap request on every realtime response: up to 360 calls per hour for one selected vehicle in one operator tab. AMap documents reverse geocoding as an HTTP/HTTPS remote service that converts an input coordinate into a structured address, while TanStack Query caches independently by query key: <https://lbs.amap.com/api/webservice/guide/api/georegeo>, <https://tanstack.com/query/latest/docs/framework/react/guides/query-keys>.

Map position remains on the ten-second realtime cadence, but the textual address now has a separate one-minute publication window. Coordinates are rounded to four decimals before becoming query keys so stationary GPS jitter shares one address result. Continuous movement publishes the latest pending point at the fixed minute boundary rather than postponing forever; selecting another VIN publishes immediately, and unmounting the detail card cancels its pending timer. This bounds a continuously moving selection to at most 60 reverse-geocode calls per hour per tab, an 83.3% reduction before jitter coalescing.

Lifecycle coverage proves the first lookup is immediate, same-cell jitter causes no request, movement cannot request before the boundary, the latest point wins at the boundary, switching VIN is immediate, and unmount prevents a delayed request.

## 2026-07-16: interaction-scoped QR dependency

The monitor route statically imported the QR encoder even though it is used only after an operator opens the optional mobile-entry dialog. Every monitoring session therefore downloaded, parsed and retained the encoder. The production `MonitorPage` chunk was 50.26 kB raw / 17.97 kB gzip, and QR generation failures also left the dialog on an endless spinner because the Promise had no rejection state.

The dialog now dynamically imports the encoder only after it mounts, cancels state publication when it closes, and renders a retryable error state when module loading or canvas generation fails. The monitor critical-path chunk fell to 25.50 kB raw / 8.30 kB gzip: 49.3% fewer raw bytes and 53.8% fewer compressed bytes. The isolated QR chunk is 25.78 kB raw / 10.13 kB gzip and is requested only by the interaction. This follows the platform dynamic-import model for conditionally loading low-probability or memory-heavy functionality: <https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import>, <https://vite.dev/guide/features.html>.

The production build gate now requires exactly one QR payload reachable through a dynamic import from the monitor chunk and caps that critical-path chunk at 35 kB, preventing an accidental static reintroduction. Component tests prove the encoder is untouched before the dialog opens, prove a successful QR contains only the monitor URL, and prove a failed generation can recover through the visible retry action.

## 2026-07-16: visible-scope selected-vehicle polling

Selecting a vehicle starts a ten-second realtime query so the map can keep its ripple, plate and camera target synchronized. Switching to the full fleet list unmounted the map and detail card but retained the selected VIN, so that invisible query continued polling up to 360 times per hour per open operator tab without any rendered consumer.

The monitor now retains the operator's selected VIN as UI state but passes it to the realtime query only while map mode is active. Entering list mode changes the query key to the disabled empty selection, which stops the interval and lets the prior high-volume result reach its zero-retention cleanup; returning to the map restores the same selection and resumes live tracking. TanStack Query documents that an `enabled: false` query does not automatically fetch or refetch in the background, while `refetchInterval` otherwise operates independently of `staleTime`: <https://tanstack.com/query/latest/docs/framework/react/guides/disabling-queries>, <https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults>.

A component wiring test selects a vehicle, proves live tracking receives its VIN, switches to list mode and proves the polling input becomes empty, then returns to the map and proves the original selection resumes. Existing cache lifecycle coverage continues proving replaced selected-vehicle payloads are removed immediately.

## 2026-07-16: bounded route-module recovery

The route shell already displayed a meaningful Suspense skeleton and recovered rejected lazy chunks, but a dynamic import that remained pending forever never reached the error boundary. Operators could therefore stay on a loading workspace indefinitely during a stalled resource request. React documents that `lazy` caches its Promise and that Suspense continues showing the nearest fallback until that Promise settles; a rejected Promise is then delivered to the nearest error boundary: <https://react.dev/reference/react/lazy>, <https://react.dev/reference/react/Suspense>.

Every route import now has a ten-second settlement boundary. An ordinary transient rejection is retried once after 180 ms before it reaches React, while a true timeout is not repeated and immediately enters the existing controlled reload path. This preserves fast recovery from a single network hiccup without turning one hung request into two consecutive waits. If the reload cooldown prevents another automatic reload, the visible recovery page now identifies a network/resource timeout instead of incorrectly describing every chunk failure as a version update.

Regression tests prove that a first failed import resolves on its single retry, a permanently pending import rejects once with `RouteChunkLoadTimeoutError`, the error classifier accepts that failure, and the route boundary renders an explicit timeout explanation plus refresh action. Authenticated production navigation across history, mileage and access was also checked from a retained older tab to exercise compatibility assets; all three routes remained non-blank and no route recovery error appeared.

## 2026-07-16: render-scoped fleet refresh

The incremental map update already avoided AMap mutations when a 15-second response changed only its `asOf` timestamp, but the effects still depended on the complete response object. Each metadata-only refresh therefore traversed all visible points to rebuild signatures, cluster counts and label diffs before discovering that there was nothing to draw. The cost was hidden from mutation-count tests and could still create a periodic main-thread spike in a dense viewport.

FleetMap now binds rendering work to the response fields that can change pixels: `mode`, `points` and `clusters`. TanStack Query's JSON structural sharing preserves those nested references when their content is unchanged, and React compares effect dependencies with `Object.is`; an `asOf`-only root-object replacement therefore no longer enters either the MassMarks or LabelsLayer pipeline. Fallback vehicle points are also detached while a monitor-map payload is active, so unrelated list-prop identity changes cannot wake the map renderer. Real point-array changes still run the existing incremental marker replacement path.

This follows React's guidance to remove unnecessary object dependencies and TanStack Query's structural-sharing contract:

- <https://react.dev/reference/react/useEffect>
- <https://react.dev/learn/removing-effect-dependencies>
- <https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults>

A proxy-backed regression test counts numeric array-index reads and proves that a metadata-only refresh performs zero point traversal. The existing movement regression still proves that replacing one vehicle point refreshes MassMarks once and replaces exactly one plate label.

## 2026-07-16: search-scope route error recovery

The route boundary correctly isolated render failures, but its error state outlived search-parameter navigation inside the same pathname. If one alert, history or mileage scope triggered a render exception, correcting the filter or following a same-module link changed the URL while leaving the previous fallback permanently mounted. The operator had to reload the entire application or leave the module even when the next scope was valid.

An errored boundary now resets only when the complete route key changes. It retries the child tree for the new pathname/search scope, while a healthy boundary continues preserving local component state across ordinary filter changes. This avoids the coarse solution of keying the whole page by every search parameter, which would remount healthy pages and discard drafts or interaction state.

React documents Error Boundaries as the mechanism that replaces a crashed subtree with fallback UI and describes keys as the identity boundary for intentional state reset during navigation. The implementation applies the same identity principle conditionally to error state instead of resetting every healthy navigation:

- <https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary>
- <https://react.dev/learn/preserving-and-resetting-state>
- <https://react.dev/reference/react/Suspense#resetting-suspense-boundaries-on-navigation>

Regression coverage first renders an invalid `/alerts` search scope into the recovery page, changes only the search parameter, then proves that the error disappears and the valid scope mounts exactly once. The existing test still proves healthy search changes retain local state and do not remount.

## 2026-07-16: route-scoped mutation memory

The global QueryClient previously left TanStack Query's mutation garbage-collection policy at its default. Completed writes therefore remained in the Mutation Cache after their page unmounted. Most variables are small, but the vehicle-profile synchronization workflow closes over as many as 500 parsed CSV records and performs a dry run followed by an apply mutation. Repeated administration sessions could retain several completed result graphs and their closures for minutes after leaving the page.

Completed mutations now use zero inactive retention globally. Pending state, success/error feedback and follow-up invalidation remain available for as long as the owning component is mounted; when the route or panel unmounts, the unused mutation record and any referenced batch payload become immediately eligible for garbage collection. Queries keep their separate reuse policy because read caching improves navigation and is already bounded by data cardinality.

TanStack Query exposes separate Query and Mutation caches and documents `gcTime` as the lifecycle control for unused cache entries. Chrome's memory guidance likewise identifies lingering JavaScript references as the reason removed application objects remain reachable:

- <https://tanstack.com/query/v5/docs/reference/QueryClient>
- <https://tanstack.com/query/latest/docs/framework/react/guides/mutations>
- <https://developer.chrome.com/docs/devtools/memory-problems>

A component lifecycle test returns a 500-row mutation result, proves the mounted UI can still read it, then unmounts the route owner and proves the Mutation Cache falls from one entry to zero.

## 2026-07-16: scope-safe mileage queries

Mileage Statistics previously reused every successful summary and daily-mileage response as placeholder data for the next query key. After applying another vehicle, date range or source strategy, the new controls and column dates appeared immediately while the summary cards and cells could still contain values from the preceding scope. That visual mismatch could make an operator attribute mileage to the wrong period.

The page now treats vehicle selection, date range and enabled source priority as one semantic scope. A new scope removes the preceding summary and matrix and renders an explicit in-panel loading state until the replacement result arrives. Daily rows may be retained only while paging the bound fleet inside the same scope; that placeholder is hidden behind the loading state so old page values cannot be read as the new page. Same-key manual refresh continues displaying the current result because it is not a scope transition.

This follows TanStack Query's definition of placeholder data as observer-level temporary data and applies its previous-data pattern only to safe pagination. It also follows Elastic's dashboard model where query, filters and global time range define the visible dataset:

- <https://tanstack.com/query/v5/docs/framework/react/reference/useQuery>
- <https://tanstack.com/query/v5/docs/framework/react/guides/paginated-queries>
- <https://www.elastic.co/docs/explore-analyze/dashboards/using>

A delayed-response component test holds both replacement requests open after a date-range change and proves that the old period total and daily cells disappear while the new date range and loading boundary are visible.

## 2026-07-16: scope-safe monitor batch search

The monitor already accepts copied plate rows from Excel or text, normalizes separators and duplicates, and submits one bounded batch parameter. Its workspace placeholder, however, previously ignored the semantic filter boundary: changing plates, protocol or status could momentarily render the preceding vehicles under the new controls. Conversely, treating every workspace key change as a new scope would blank the map during ordinary pan and zoom.

Monitor queries now separate the stable filter scope from viewport and pagination. Plate/protocol/status changes immediately clear the old rail, map payload and list rows while the replacement query loads. Pan and zoom inside the same filter scope retain the preceding map payload until the new viewport data arrives, and list pagination retains its previous page without carrying rows into another filter. Batch pending state follows the actual filter request rather than TanStack Query's same-scope placeholder flag.

This applies TanStack Query's previous-data pagination pattern only within one semantic result scope and matches dashboard systems where query and filter controls define the visible dataset. Low refresh intervals remain bounded because mature dashboard guidance warns that aggressive polling increases backend load:

- <https://tanstack.com/query/v5/docs/framework/react/guides/paginated-queries>
- <https://www.elastic.co/docs/explore-analyze/dashboards/using>
- <https://grafana.com/docs/grafana/latest/dashboards/troubleshoot-dashboards/>

Regression tests cover copied multi-plate parsing, scope normalization, nested query-key scope matching, stale-row suppression during a batch transition and same-filter viewport continuity.

## 2026-07-16: scope-safe operational tables

Access Management and Alert Center repeated the history-page placeholder mistake. A new vehicle/protocol/status filter continued rendering the preceding page until the replacement request completed. Alert Center also kept its previous event selection alive, so the inspector could show an old event ID, vehicle and action evidence beside a newly submitted filter.

Both workspaces now derive a stable, order-independent scope key from their applied filters. Previous rows are retained only when limit or offset changes inside that same scope. A different scope enters the existing loading state with no old rows; the alert selection is bound to the scope as well, so its detail query and inspector disengage immediately. Manual alert refresh also skips the detail request when no event is selected instead of requesting an empty event ID.

This preserves the smooth pagination behavior described by TanStack Query without extending it across a semantic filter boundary. It also aligns the visible result with the active query, time range and filter controls used by mature Elastic and Grafana dashboards:

- <https://tanstack.com/query/v5/docs/framework/react/guides/paginated-queries>
- <https://www.elastic.co/docs/explore-analyze/dashboards/using>
- <https://grafana.com/docs/grafana/latest/visualizations/dashboards/variables/add-template-variables/>

Delayed-response component tests prove that the old access vehicle, alert row and alert inspector evidence disappear before the new responses resolve, then prove the replacement data renders. Query-policy tests protect stable scope serialization and same-scope-only placeholder reuse.

## 2026-07-16: scope-safe history transitions

The history page previously used every successful detail and trend response as placeholder data for the next query key. Changing the vehicle, time window, protocol or data category could therefore leave the old table, chart legend and selected-row evidence visible under the new filters until both replacement requests completed. Besides retaining the old high-cardinality payload during the transition, this could make an operator attribute evidence to the wrong search scope.

Placeholder reuse is now limited to pagination inside an identical history scope. A scope change immediately removes the old detail and trend payloads, resets the evidence panel and displays an explicit loading state; same-scope page changes retain the last page to avoid layout jumps. Selected evidence stores only the scope and row ID instead of a complete row object. TanStack Query documents previous-data placeholders specifically as a pagination technique, while Elastic Discover treats the query, filters and time range as the search context that defines the displayed result:

- <https://tanstack.com/query/v5/docs/framework/react/guides/paginated-queries>
- <https://tanstack.com/query/v5/docs/framework/react/guides/placeholder-query-data>
- <https://www.elastic.co/docs/explore-analyze/discover>

The regression suite holds the replacement requests open after a scope change and proves that the old plate, trend and evidence ID are absent while the new loading state is visible. A query-policy test independently proves that page placeholders survive only when the scope key is unchanged.

## 2026-07-16: session-scoped client cache

Logout previously removed only the bearer token. TanStack Query's inactive query results and mutation records could therefore remain in browser memory for their normal lifecycle and be reused after another operator logged in on the same workstation. An expired token also left the current route and its cached vehicle data visible while protected requests repeatedly returned 401.

Authentication is now a hard browser-data boundary. Login, explicit logout and any authenticated API 401 cancel in-flight queries and clear both query and mutation caches before the next session renders. The session-validation endpoint is excluded from the global 401 event so an invalid token remains a local login error, and 403 remains an authorization result rather than terminating a valid session. The API already sends `Cache-Control: no-store`, so protected responses are also excluded from the HTTP cache.

Component tests prove that login and logout remove prior query and mutation entries, logout aborts an in-flight protected read, and a protected 401 returns the application to the login screen without retaining the active result. API tests separately prove that `/api/v2/session` 401 does not recursively trigger global logout. This follows TanStack Query's documented `cancelQueries` and `clear` lifecycle controls and OWASP's requirement to clear client-side state after logout; Auth0 likewise treats local application-session termination as an explicit part of logout:

- <https://tanstack.com/query/v4/docs/reference/QueryClient>
- <https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html>
- <https://cornucopia.owasp.org/cards/FRE3>
- <https://auth0.com/docs/manage-users/sessions/manage-multi-site-sessions>

## 2026-07-16: incremental fleet-map refresh

The 15-second monitor refresh previously treated every new response object as a complete visual change. Even when only `asOf` changed, it regenerated MassMarks styles/data, cleared both label layers and constructed every plate marker again. With hundreds of visible vehicles this caused avoidable canvas work and periodic main-thread pressure.

FleetMap now fingerprints only fields that affect map rendering. An unchanged refresh performs no AMap data/style/label mutation. A moving vehicle still updates MassMarks once, but the LabelsLayer removes and replaces only that vehicle's changed marker; full label-layer replacement is reserved for crossing the normal/dense zoom boundary. The selected ripple marker also skips redundant position writes. This uses the official AMap JS API 2.0 [`LabelsLayer.remove`](https://lbs.amap.com/api/javascript-api-v2/guide/amap-massmarker/label-marker) and `LabelMarker` update model instead of reconstructing all overlays. Unit tests assert zero map mutations for an `asOf`-only refresh and exactly one label replacement for one moved vehicle.

## 2026-07-16: bounded track-playback rendering

The production replay for a 1,600-point day contained 84 interactive activity segments, nine stop rows and 64 event rows. Advancing `activeIndex` previously rendered the complete query/result rail and all 84 segment buttons on every playback step. It also rebuilt `Intl.NumberFormat` and `Intl.DateTimeFormat` instances during render, counted passed map points with a full-array reduction, and at 4× speed started a 180 ms map pan every 75 ms. The overlapping camera animations and repeated static work competed with the marker update on the main thread.

Playback now precomputes sparse stop/event highlight indexes once per track. The memoized result rail receives a shared empty selection for ordinary points, while the memoized segment rail receives stable callbacks, so both static subtrees skip the vast majority of playback renders. Date and number formatters are module-level reusable instances, and the passed-path count is a precomputed O(1) lookup instead of an O(points) scan per step. Follow animation is cadence-bound: 4× playback uses 65 ms movement inside its 75 ms update interval, while direct operator selection retains the smoother 180 ms transition.

This follows React's requirement that memoized children receive stable props and callbacks, and the browser animation model that schedules work against paint frames and calculates progress from timestamps: <https://react.dev/reference/react/memo>, <https://react.dev/reference/react/useMemo>, <https://developer.mozilla.org/en-US/docs/Web/API/Window/requestAnimationFrame>. The regression suite asserts sparse highlight mapping, the 55 ms minimum playback interval, 65 ms 4× follow duration, 180 ms paused duration, passed-path advancement and complete map/listener cleanup.

## 2026-07-16: single responsive mileage matrix

The mileage query rendered its desktop table and mobile card matrix at the same time, then hid one tree with CSS. A 90-day, 20-vehicle production page therefore retained 1,860 desktop cells and another 1,800 hidden mobile day cells. Chrome identifies excessive DOM size as a source of longer style calculation, layout and interaction work, and recommends creating nodes only when they are needed: <https://developer.chrome.com/docs/performance/insights/dom-size>, <https://web.dev/articles/dom-size-and-interactivity>.

The page now listens to the responsive breakpoint and mounts exactly one representation. The desktop production DOM fell from 7,752 elements to 2,191 elements (71.7% fewer); the hidden mobile day-cell count fell from 1,800 to zero. The 90-day table remained complete with all 1,860 desktop data cells, the page had no blocking overlay, and the browser console emitted no warning or error. Component tests independently prove the mobile breakpoint mounts the card matrix, removes the desktop table and unregisters its media-query listener on cleanup.

Daily mileage lookup now builds one VIN-indexed map and one ranking map before rendering. Rows no longer filter the complete daily result and scan the ranking list for every vehicle, reducing matrix preparation from O(vehicles × result rows) to O(vehicles + result rows). If a future supported date range makes even the single active matrix too large, row or column windowing is the next control; web.dev and TanStack document this virtualization pattern for large tables and lists: <https://web.dev/articles/virtualize-long-lists-react-window>, <https://tanstack.com/table/beta/docs/framework/react/guide/virtualization>.

## 2026-07-16: bounded predictive route warming

The route shell split every primary page into a lazy chunk, but its idle queue then imported every inactive page during the first session. Successful ES-module imports cannot be unloaded, so this gradually made all route code resident even when an operator stayed on the monitor. It also let speculative parsing overlap live map work and defeated much of the memory benefit of route splitting.

Background warming is now predictive and bounded. Each active page has two likely next destinations; a fast visible session warms at most those two, one idle task at a time. A 3G or 4 GB device warms one, while `saveData`, 2G, hidden tabs and devices reporting 2 GB or less warm none. Pointer, focus and pointer-down intent still warm the exact destination immediately on capable connections, so deliberate navigation keeps the short transition while unused pages stay lazy. This follows web.dev's guidance to prefetch only high-confidence future resources, avoid slow or data-saving connections and prefer likely links over every link: <https://web.dev/articles/link-prefetch>, <https://web.dev/learn/performance/prefetching-prerendering-precaching>.

Unit tests protect route predictions, memory/network budgets, sequential scheduling, background-tab cancellation and cleanup. The production build remains guarded by a visible Suspense skeleton and the route chunk recovery boundary, so a page transition cannot become an empty content area while a non-warmed chunk loads.

## 2026-07-16: high-cardinality address cache lifecycle

The monitor list previously retained every manually resolved coordinate for 24 hours, and track replay inherited the global five-minute cache for every paused point. Long-running dispatch sessions could therefore accumulate address payloads across pages and arbitrary playback coordinates even after their components disappeared.

Both query families now use zero inactive-cache retention: the visible list row or current paused track point keeps its address while mounted, but switching monitor mode, selecting another point, or leaving the route immediately releases the previous browser entry. This does not cause repeated AMap quota consumption because the authenticated server adapter independently owns the one-hour, 4,096-entry LRU cache and collapses concurrent misses. Component tests prove list addresses reach zero after returning to map mode, track address churn retains only one active key, and route unmount leaves no track address entry.

## 2026-07-16: complete release-asset compatibility gate

Release `web-release-smoke-20260716015150` installs `deploy/verify-web-release.sh` as a production release gate. It verifies the exact root document, runtime configuration object, every original asset in the current `.release-assets` manifest and every original asset in the immediately previous manifest. Asset responses are compared byte-for-byte with the active release files; HTTP 200 with an SPA HTML fallback therefore fails instead of masking a missing lazy route chunk.

Behavioral tests cover a valid release, a missing compatibility file, a wrong HTTP 200 response body and an unsafe manifest path. The deployed gate passed all 20 current assets and all 20 compatibility assets from `mileage-export-worker-20260716014414`; the platform service remained active and the authenticated health endpoint reported the new release. The ECS host still runs Python 3.6, so the test fixture uses a working-directory subshell instead of the newer `http.server --directory` option and now proves the same cases on both the development host and ECS. The final release is `web-release-smoke-portable-20260716015528`.

## 2026-07-16: bounded immutable release history

The initial immutable-release workflow copied every asset from the active release into the next release. Because the active release already contained its own compatibility assets, this formed a transitive chain: every deployment retained all earlier assets. The ECS host had accumulated 747 release directories occupying 12 GB, even though only the current and recent rollback revisions could serve traffic.

The production build now emits `.release-assets` itself and verifies that it lists every generated file exactly once. Release preparation keeps three explicit compatibility generations, copies only assets named by those manifests and rejects unsafe names, missing files or same-name/different-content collisions. Older tabs remain protected by the route boundary's one-shot current-version reload, while recent tabs continue loading their original hashed chunks without interruption.

`install-web-release.sh` stages an immutable directory, validates archive paths, atomically replaces the `current` symlink, restarts the service, verifies every current and compatibility asset byte-for-byte, checks the authenticated runtime release and only then prunes history to 20 releases. Any readiness, asset or health failure restores the previous symlink, release environment and service before returning an error. Tests exercise successful installation, bounded compatibility generations, unsafe input, missing assets, release pruning and a forced post-switch rollback.

Production release `bounded-release-history-20260716041827` passed 19 current and 19 immediate-previous asset checks before pruning. Release history fell from 748 directories / approximately 12 GB to 20 directories / 2.1 GB, releasing approximately 9.9 GB without touching business data. The authenticated health endpoint reported the new release, the service remained active with no critical log entry, and a signed-in `tracks -> history -> monitor` navigation smoke rendered meaningful content on every route with no framework overlay, warning or error.

This applies the same lifecycle principles as blue/green deployment: stage a separate revision, validate it, switch traffic and retain a bounded rollback history. AWS documents fast rollback by switching back to the blue environment, while Kubernetes exposes `revisionHistoryLimit` because unbounded old revisions consume control-plane resources: <https://docs.aws.amazon.com/whitepapers/latest/blue-green-deployments/introduction.html>, <https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#revision-history-limit>.

## 2026-07-16: cancellable, off-main-thread mileage export

The full-fleet mileage export previously performed three long-running phases as one opaque foreground action: paginating vehicle identities, paginating daily mileage rows and building the ExcelJS workbook on the browser main thread. Navigating away did not cancel the export requests, and a large `writeBuffer()` could make controls and route transitions unresponsive.

The export now owns one `AbortController` for its complete lifecycle. Every vehicle and mileage request consumes that signal, leaving the route aborts the controller, and the action can be cancelled from the same button. The UI reports determinate progress while the response total is known and an indeterminate stage while Excel is generated. Workbook creation and serialization run in an ES-module Web Worker; the worker transfers the resulting `ArrayBuffer` back without copying and is terminated on completion, error or cancellation. ExcelJS remains action-only and does not enter the initial route bundle; a main-thread fallback is retained only for browsers without Worker support.

This matches the long-task feedback and isolation guidance used by mature design systems: Carbon recommends progress feedback for operations that last more than a moment, while Material UI explicitly recommends Web Workers or batching for processor-intensive work:

- <https://carbondesignsystem.com/patterns/loading-pattern/>
- <https://carbondesignsystem.com/components/progress-bar/usage/>
- <https://mui.com/material-ui/react-progress/>

The evidence gate covers request-signal propagation, user cancellation before workbook generation, immediate abort before download, worker bundling, production build output and an authenticated production interaction smoke.

Production release `mileage-export-worker-20260716014414` exercised the 90-day full-fleet path for 1,024 bound vehicles. The page exposed the cancel action immediately, returned to the normal export state with `已取消导出`, removed the progress indicator, kept the query controls responsive and emitted no console warning or error. A separate full 30-day export completed for all 1,024 vehicles, proving the Worker asset and ExcelJS action chunks load successfully in the deployed browser build.

## 2026-07-16: 50-cycle authenticated route soak

The production ECS application completed 50 authenticated `monitor -> history -> statistics -> tracks -> monitor` cycles. Chrome performance and heap metrics were sampled every five cycles. Because the Browser channel does not permit `HeapProfiler.collectGarbage`, the gate uses post-warmup and 15-second-idle low-water marks instead of comparing transient allocation peaks.

| Metric | After 10 warm cycles and natural collection | After 50 cycles and 15 seconds idle | Result |
| --- | ---: | ---: | --- |
| JS heap used | 26.52 MB | 28.53 MB | Stable; +2.01 MB |
| Event listeners | 880 | 686 | No monotonic growth |
| DOM nodes | 7,763 | 4,811 | No monotonic growth |
| Detached script states | 0 | 0 | Fully released |
| Live page DOM | — | 1 AMap iframe / 1 map container | Only the active monitor map remains |

Transient peaks reached 165.83 MB while route effects and AMap instances overlapped, then returned to the low-water mark after natural collection. The final page remained `/monitor`, rendered live fleet statistics, and had zero console warnings or errors. This proves that the explicit query, map and listener cleanup prevents sustained growth under this route pattern; it does not replace a future allocation profile for data-heavy operator interactions.

The audit found one remaining lifecycle asymmetry: track start, end and stop markers registered anonymous click handlers and relied on overlay removal to release them. Track overlays now retain each handler identity and call `off('click', handler)` before redraw and unmount. `TrackMap.test.tsx` asserts every marker handler, the map drag handler, overlays and the map instance are released.

## 2026-07-16: monitor lifecycle and responsive rendering

The monitor's high-volume fleet, map and selected-vehicle queries now use immediate inactive-cache collection instead of inheriting the global five-minute retention. The detail card owns its detail, alert and address queries, so collapsing or clearing the card unmounts its observers while the separate selected-vehicle stream continues to move the map. Repeated viewport and vehicle-selection tests assert that only the active payload remains and that detail-card queries reach zero after unmount.

The realtime list no longer renders the desktop table and mobile cards simultaneously. A viewport listener mounts exactly one representation, cutting a 50-row page from 100 row trees and 100 address query observers to 50. The listener is removed with the component. Fleet and track maps also keep stable event-handler identities and pair every AMap `on` with `off` before overlay removal and `destroy()`.

This follows TanStack Query's documented behavior that inactive queries remain cached for five minutes unless `gcTime` changes, and AMap JS API 2.0's requirement to unbind the same handler object with `off` that was registered with `on`:

- <https://tanstack.com/query/latest/docs/framework/react/guides/important-defaults>
- <https://lbs.amap.com/api/javascript-api-v2/guide/events/map_overlay>

## 2026-07-16: production stylesheet boundary

The production entry renders `AppV2` exclusively. V2 pages do not import Semi UI components, so loading the legacy `global.css` from `main.tsx` made every route download and parse the complete legacy Semi theme and thousands of unreachable V1 rules. The entry now imports only `v2/styles/v2.css`; the legacy stylesheet remains available to the explicit `test:legacy` compatibility suite but cannot enter the V2 production bundle. `productionEntry.test.ts` protects this boundary. The verified production CSS fell from 435.20 kB / 53.50 kB gzip to 168.91 kB / 27.72 kB gzip, and the Semi theme Sass deprecation warnings disappeared from the build.

The default `test` command is now the production V2 suite. The old `App.tsx` integration test remains runnable as `test:legacy`, and `test:all` is retained for combined audits. This separation makes deprecation and jsdom navigation warnings from the retired Semi UI shell visible without allowing them to hide regressions in the production application. The 2026-07-16 baseline is 139/139 passing production tests and 154/168 passing legacy assertions; the 14 legacy failures describe retired V1 mileage and hash-route expectations and do not gate V2 deployment.

## 2026-07-15: route continuity and monitor memory

### Resolved in this round

| Risk | User-visible failure | Control | Evidence gate |
| --- | --- | --- | --- |
| A stale lazy route asset disappears during release | Navigation leaves the content area blank | Preserve the previous build's original hashed assets for one generation; isolate every route with a recoverable error boundary and one-shot reload | Request an old route asset after the symlink switch; navigate through every primary route without a blank shell |
| A pasted batch search temporarily shows the previous fleet | Operators may mistake stale vehicles for batch matches | Hide placeholder rows while `keywords` is changing; report matched and missing plates after the request resolves | Paste newline/comma-separated plates with a duplicate and an unknown plate; verify exact results and missing-count feedback |
| URL filters remount the whole route | Pagination or tab changes lose local state and may flash a loading screen | Key the route recovery boundary by pathname only; keep the full URL only for chunk-reload diagnostics | Change search parameters inside a stateful route and assert one mount with preserved local state |
| Route-scoped reads continue after navigation | Obsolete responses consume bandwidth and remain in memory | Consume TanStack Query's `AbortSignal` in every V2 read path; immediately collect high-volume access and alert pages after they become inactive | Start a read, navigate away or change its query key, and assert the fetch receives the query signal |
| A route is first fetched only after click | Slow transitions and a longer loading flash | Deduplicated route import cache plus pointer, focus and pointer-down preload | Route module unit tests and rendered navigation loop |
| Rapid map pans retain large query payloads | Heap growth and eventual interaction jank | Consume React Query `AbortSignal` and immediately garbage-collect inactive map viewport queries | Query-cache churn test keeps exactly one map payload after repeated viewport changes |
| Superseded monitor requests continue in the background | Wasted server traffic and stale responses | Pass request cancellation signals through the API client | API client signal test and browser network/console smoke |
| QR generation resolves after the dialog unmounts | State update after lifecycle end | Cancel the asynchronous state write on cleanup | Monitor component test |

React documents that `lazy` caches the import promise and propagates a rejected module load to the nearest Error Boundary; this is why a route-level boundary is required rather than a loading fallback alone: <https://react.dev/reference/react/lazy>. React's Suspense documentation also distinguishes a loading fallback from error handling: <https://react.dev/reference/react/Suspense>.

The first continuity pass only warmed Monitor, Tracks, History and Statistics in the background. A later pass expanded that queue to every route to reduce a 450 ms simulated cold transition, but doing so made all successful ES-module imports resident for every session. The current policy keeps pointer/focus/down intent warming for the exact destination and limits idle warming to one or two likely routes according to visibility, memory and connection constraints. This preserves the no-white-screen loading boundary without paying the memory cost of importing every page speculatively.

A production route-memory loop did not prove a retained application leak: after natural collection, JS heap returned to roughly its initial 31 MB range and old map documents/frames were released. It did expose an ineffective rendering hint: `content-visibility: auto` was attached to the always-visible vehicle scroll viewport instead of the 200 independently off-screen rows. The containment boundary now lives on every desktop rail row and mobile list card, paired with `contain-intrinsic-size` so skipped content keeps stable scroll geometry. This follows the CSS Containment specification's long-scroll-list use case while avoiding the documented scrollbar jump caused by missing intrinsic size: <https://www.w3.org/TR/css-contain-2/#content-visibility>.

Excel export previously produced two independent 940 KB ExcelJS assets because the worker imported the browser download module while that module also retained a main-thread workbook fallback. Workbook construction now lives in a worker-only runtime module; unsupported legacy browsers receive an upgrade message instead of freezing the page with a main-thread build. The production output contains one ExcelJS asset, the Statistics route chunk is smaller, and `scripts/verify-build.mjs` makes exactly one ExcelJS asset plus one mileage worker a mandatory `pnpm build` gate.

The Alert Center previously mounted every tab's data dependencies together: the default event view fetched the metric catalog even though only the rule editor consumes it, while a direct notifications entry fetched both the unread-only collection and the full notification collection. Queries are now gated by the active workspace. Events load events, summary, rule names and the unread badge; rules add the metric catalog only when opened; notifications load one full collection and derive the unread badge from it. This follows TanStack Query's lazy-query `enabled` model and preserves Grafana's separation between alert triage, rule configuration and notification handling: <https://tanstack.com/query/latest/docs/framework/react/guides/disabling-queries>, <https://grafana.com/docs/grafana/latest/alerting/>. Request-count tests protect both the default event route and a direct notifications entry.

Vite previously copied the ignored developer-local `public/app-config.js` into `dist` unchanged. The API correctly intercepted production `/app-config.js` requests, but a local AMap security code still entered release archives and would become exposed if that interception ever regressed. The production build now overwrites the copied file with the credential-free example and verifies an exact byte match. The ECS release smoke gate independently requires the server-side `/_AMapService` host and rejects the security-code property, so both the artifact and the served runtime surface fail closed. This matches AMap's production guidance, which strongly recommends server proxy forwarding and explicitly classifies browser plaintext configuration as unsafe: <https://lbs.amap.com/api/javascript-api-v2/guide/abc/jscode>.

The monitor map screen previously fanned one refresh into three independent requests: summary, a 200-row rail and a map snapshot of up to 10,000 vehicles. Each server path repeated the realtime snapshot count and row query, and the browser created three overlapping payload lifecycles. `/api/v2/monitor/workspace` now reads the bounded snapshot once and derives all three coherent views from it; the client owns one abortable, zero-retention high-volume query. This applies the gateway aggregation/BFF pattern to a single data-heavy screen and follows Grafana's recommendation to share results instead of repeating data-source queries: <https://learn.microsoft.com/en-us/azure/architecture/patterns/gateway-aggregation>, <https://grafana.com/docs/grafana/latest/visualizations/panels-visualizations/query-transform-data/share-query/>.

The navigation and progressive-loading direction follows mature observability systems: persistent global controls, collapsible sections and loading only the content needed for the current task. Elastic documents these dashboard interaction and panel-organization patterns here: <https://www.elastic.co/docs/explore-analyze/dashboards/using> and <https://www.elastic.co/docs/explore-analyze/dashboards/arrange-panels>.

### Remaining audit queue

1. Capture an allocation profile for data-heavy operator interactions: loaded history series, long track playback and repeated Excel exports.
2. Bound or explicitly evict other high-cardinality query families, especially arbitrary history windows and track playback ranges.
3. Retire or migrate the remaining legacy `App.tsx` integration suite after its compatibility coverage is replaced in V2.
4. Profile repeated large exports after the Web Worker migration and define an explicit maximum supported row/column envelope for browser-side workbooks.
5. Run the automated byte-for-byte current/compatibility asset smoke in every future ECS release and promote it into CI/CD when the deployment pipeline is formalized.

## 2026-07-15: query memory and vehicle-count semantics

Production reconciliation proved that the three visible vehicle counts represent different populations rather than a data loss: 1,035 service identities equal 1,024 bound master vehicles plus 11 realtime identities awaiting binding; 738 vehicles currently have a realtime location row and can enter the global-monitor map. The UI must name these populations explicitly instead of presenting them as interchangeable totals.

High-volume history rows, aggregated series, track playback and daily-mileage matrices consume the request `AbortSignal` and use zero inactive-cache retention. Small vehicle-option and summary results retain only a bounded 30–60 second cache. This preserves responsive repeated lookups without retaining multiple large arbitrary date-window payloads after their observer is gone.

## 2026-07-16: authenticated owner-bound history exports

Release `export-owner-scope-20260716165737` removes the last direct download link from the history page. Completed files are now fetched through the API client with the current session's Bearer token, converted to a short-lived object URL and downloaded with the server-provided business filename. The server encodes the Chinese filename in `X-Export-Name` and RFC 5987 `Content-Disposition`, so HTTP headers remain valid across Go, proxies and browsers.

The export workspace is owner-aware: each row shows the creating account, authorized vehicle count and query window. A customer sees only their own tasks; an administrator can audit all tasks. Frontend tests cover customer visibility and authenticated download headers, while backend tests cover customer A/B isolation, guessed-ID denial, live grant revocation and persisted owner/Scope metadata.

The production gate exported 1,274 location rows for one real VIN. The 215,212-byte CSV contained export audit, vehicle Scope and quality-reason metadata. A guessed ID returned 404, the API restarted successfully, and the file SHA-256 remained `00a4b1a877364f048a06e8bf6d80828e0dacaddd2f946b39b6cd00cfb2e9013e` with no partial file left behind. The release passed all 241 production frontend tests and the TypeScript/Vite build gate.

## 2026-07-16: monitor investigation context continuity

Release `monitor-context-continuity-20260716170911` makes the global monitor URL the canonical owner of its operator workspace: map/list mode, search and source/status filters, selected VIN, detail visibility, list page and limit, and map zoom/bounds. Child links carry only a validated same-application `/monitor` return path; malformed bounds, unsupported routes and external origins are discarded instead of becoming redirects.

Vehicle detail, track replay, history and mileage render one shared return bar only when monitor context exists. Track/history/statistics query changes preserve the return parameter, and vehicle-detail investigation links forward it. Direct menu entry remains clean. FleetMap restores the saved WGS-84 bounds midpoint and zoom at construction time and suppresses the selected-vehicle recenter that would otherwise overwrite a restored viewport; fractional zoom levels remain valid and are serialized to two decimal places.

Route and component tests cover exact context round trips, direct-entry behavior, same-origin validation, malformed viewport rejection, browser-history navigation, four child workflows and saved-map restoration. The complete frontend gate passed 49 files and 251 tests, followed by TypeScript/Vite production build verification. ECS smoke verified 23 current assets, 58 compatibility assets across three generations, all three active services, the authenticated production health release, six SPA routes and both context bundles.

## 2026-07-16: live-first single-vehicle detail

Release `vehicle-live-priority-20260716171441` moves current operational state ahead of the static vehicle archive in both DOM and visual order. The first-screen live overview exposes speed, SOC, total/today mileage, recommended source, source availability, report time, coordinates and address state. Availability flags take precedence over compatibility numeric fields so an unsupported metric cannot appear as a fabricated zero.

Single-vehicle reverse geocoding is explicitly user-triggered and keyed by a rounded coordinate. The ten-second realtime poll therefore does not create an address request per position update; after the vehicle moves, the existing address stays visible until the user elects to refresh it. Invalid or unavailable coordinates are excluded from the one-vehicle map and displayed as unavailable.

Vehicle-scoped track, history, mileage and alert actions are grouped together and filtered by the authenticated menu grants. Track/history/mileage preserve monitor return context. Tests assert information hierarchy, nested context, zero suppression and the absence of a geocoding request before user action. The full frontend gate passed 49 files and 252 tests plus the production build. ECS smoke verified the real vehicle `LNXNEGRR7SR318212`, the live-first bundle markers, production health and all three services; the release gate served 23 current and 60 compatibility assets.

## 2026-07-16: operator source diagnosis workspace

Release `source-diagnosis-stable-20260716173740` makes the operations page a single-vehicle evidence workspace before the existing global health panels. Vehicle candidates use bounded fuzzy search with 20-result pagination. Source evidence is fetched only after selection and is not attached to the recurring health/readiness polls.

Every real location candidate exposes a provider label, masked terminal, protocol, first/latest report evidence, protocol interval, position/mileage, online and quality state, policy priority and a human-readable election explanation. The page distinguishes an independently configurable source from a read-only canonical fusion snapshot. Only administrators can save enablement, priority and a reason; operators are read-only and viewers cannot call the diagnostic endpoint.

The browser never receives a gateway `source_key`. The API emits an opaque SHA-256 `sourceRef`, resolves it back to the current candidate server-side and writes the existing gateway policy table through optimistic platform-owned versions and immutable audits. The gateway election algorithm is unchanged and a saved policy is documented as taking effect on the next valid report.

The V2 gate passed 49 files and 253 tests, the TypeScript/Vite production build and release-installer tests. Production migration `015` was applied before traffic switched. A real multi-source vehicle returned two location candidates without leaking source keys. A no-op admin save kept version `1`, audit count `0` and the GB32960 recommendation unchanged. Viewer/operator/admin authorization returned 403/200/200, 20 diagnostic calls measured about 70 ms median and 79 ms P95, and all three services remained active.

## Release evidence template

- Commit and release identifier
- All non-legacy frontend tests
- Production V2 route tests
- TypeScript/Vite production build sizes
- Authenticated health response and `data.runtime.platformRelease`
- New main asset HTTP status
- Previous route asset HTTP status
- Browser route loop, blank-page check and console warnings/errors
- Desktop and mobile viewport screenshots
