ui/phase-21: harden applyOrderOverlay against HMR-stale localScience

Fixes a black-canvas regression on /map after creating a science in
DEV: when Vite hot-reloads the decoder bump that adds the
`localScience` field, the live in-memory `gameState.report` keeps
its older shape with no such field, so the overlay's
`[...report.localScience]` throws inside the reactive getter and
silently aborts the map view's `$effect`. The fix wraps the spread
and the final return in `?? []` defaults — and matches the
ship-class branches for symmetry — so the overlay stays well-defined
for any partial report shape upstream consumers may carry across an
HMR boundary.

Also adds order-overlay regression tests covering the createScience /
removeScience branches plus the explicit HMR-stale shape, and a
Playwright e2e (sciences-map-regress.spec.ts) replaying the
user-reported flow: /map → /designer/science → save → /map, asserting
no map-mount-error overlay and no console errors.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-10 22:00:03 +02:00
parent f674c86e4b
commit e55355a2cf
3 changed files with 357 additions and 6 deletions
+22 -6
View File
@@ -897,7 +897,7 @@ export function applyOrderOverlay(
}
if (cmd.kind === "createShipClass") {
if (mutatedShipClass === null) {
mutatedShipClass = [...report.localShipClass];
mutatedShipClass = [...(report.localShipClass ?? [])];
}
// Skip duplicates: the engine refuses them server-side and
// the designer's local validator prevents them client-side,
@@ -917,7 +917,7 @@ export function applyOrderOverlay(
}
if (cmd.kind === "removeShipClass") {
if (mutatedShipClass === null) {
mutatedShipClass = [...report.localShipClass];
mutatedShipClass = [...(report.localShipClass ?? [])];
}
const idx = mutatedShipClass.findIndex((cls) => cls.name === cmd.name);
if (idx < 0) continue;
@@ -926,7 +926,18 @@ export function applyOrderOverlay(
}
if (cmd.kind === "createScience") {
if (mutatedScience === null) {
mutatedScience = [...report.localScience];
// `?? []` guards a real failure mode: in DEV with hot
// module replacement the running `gameState.report`
// object can predate the decoder bump that introduced
// `localScience` — its field is then `undefined` on
// the live JS object even though the type declares it
// as a required array. A naked spread on `undefined`
// throws inside the reactive overlay getter and aborts
// the map's `$effect` silently, leaving the canvas
// blank until a full reload. The default keeps the
// overlay well-defined for any upstream that supplies
// a partial report shape.
mutatedScience = [...(report.localScience ?? [])];
}
// Skip duplicates by name: the engine refuses duplicates
// server-side and the designer's local validator pre-checks
@@ -946,7 +957,7 @@ export function applyOrderOverlay(
}
if (cmd.kind === "removeScience") {
if (mutatedScience === null) {
mutatedScience = [...report.localScience];
mutatedScience = [...(report.localScience ?? [])];
}
const idx = mutatedScience.findIndex((sci) => sci.name === cmd.name);
if (idx < 0) continue;
@@ -966,8 +977,13 @@ export function applyOrderOverlay(
...report,
planets: mutatedPlanets ?? report.planets,
routes: mutatedRoutes ?? report.routes,
localShipClass: mutatedShipClass ?? report.localShipClass,
localScience: mutatedScience ?? report.localScience,
// `?? []` mirrors the per-branch HMR guard above: an old
// in-memory `report` whose shape predates a field bump must
// still produce a well-defined array on the way out, otherwise
// downstream `$derived` blocks (`localShipClass.map`,
// `localScience.find`, …) fault and the active view blanks.
localShipClass: mutatedShipClass ?? report.localShipClass ?? [],
localScience: mutatedScience ?? report.localScience ?? [],
};
}