Files
dwroller/docs/incident-2026-07-04-blank-site.md
Alex 2a6224ee6f feat: add Battlemap tab with grid-based tactical map
- src/components/BattlemapTab.jsx: full battlemap UI with hex grid,
  token placement, movement, and combat tracking
- src/shared/battlemapEngine.js: core engine (distance, movement,
  theme, coordinate utilities) used by backend routes and tests
- src/tests/battlemapEngine.test.js: unit tests for engine functions
- src/tests/battlemapTab.test.js: component rendering tests
- src/tests/battlemapTabLayout.test.js: layout/positioning tests
- src/tests/simulationBattlemap.test.js: integration tests for
  battlemap in simulation context
- docs/incident-2026-07-04-blank-site.md: post-mortem for the
  webpack CJS/ESM interop crash that caused blank pages;
  documents why BattlemapTab duplicates engine functions locally
2026-07-15 19:36:00 +02:00

5.4 KiB
Raw Permalink Blame History

Incident: dwroller.alw.dk blank page (2026-07-04)

Symptom

https://dwroller.alw.dk/ loaded a valid HTML shell (correct <title>, correct hashed JS/CSS asset links, all served 200) but the page body was blank — <div id="root"> never got populated by React.

Root causes

Two independent problems, both needed fixing.

1. /nas autofs idle-unmount (infrastructure)

/etc/fstab mounted /nas via autofs with x-systemd.idle-timeout=10min. The dwroller app's code, data files, and cwd all live under /nas/git/dwroller, so every ~10 minutes of inactivity autofs silently unmounted the share out from under the running Node process. PM2 detected the process as unhealthy ("Process 0 in a stopped status") and force stop/restarted it every 647 minutes (119+ restarts logged in pm2.log / deathwatch-server-out.log). Any request during one of these restart windows got nothing back.

Fix: changed the /nas line in /etc/fstab from an autofs mount with an idle timeout to a plain persistent NFS mount (kept _netdev,nofail so boot still won't hang if the NAS is down):

-192.168.1.113:/volume1/data   /nas        nfs  vers=4,noauto,x-systemd.automount,x-systemd.idle-timeout=10min,_netdev,nofail,retry=15,timeo=600,rsize=32768,wsize=32768  0  0
+192.168.1.113:/volume1/data   /nas        nfs  vers=4,_netdev,nofail,retry=15,timeo=600,rsize=32768,wsize=32768  0  0

Original backed up to /etc/fstab.bak.<timestamp>. Applied live with systemctl daemon-reload + umount/mount /nas (no reboot needed).

2. Frontend JS crash on every page load (application bug)

src/components/BattlemapTab.jsx (new component, not yet committed at the time) did:

const { ... } = require('../shared/battlemapEngine');

../shared/battlemapEngine.js is plain CommonJS (module.exports = {...}), required directly by the Express backend (database/routes/*.js) — that usage is fine, since backend code runs as real, unbundled Node.

But BattlemapTab.jsx is itself an ES module (import React from 'react', export default function BattlemapTab(...)), so it's part of the frontend's ESM dependency graph. Webpack 5's CommonJS↔ESM interop cannot safely reconcile a whole-object module.exports = {...} reassignment with a consumer inside an ES module graph. Depending on the exact require/import style used, this surfaced as one of:

  • Uncaught Error: ES Modules may not assign module.exports or exports.*, Use ESM export syntax, instead: <id>
  • Uncaught ReferenceError: exports is not defined
  • a build-time failure: Attempted import error: 'X' is not exported from '../shared/battlemapEngine'

All are the same underlying conflict, just different corners of webpack's static analysis / module concatenation / hmd (harmony module decorator) guard tripping over each other. This is not a transient glitch or a caching issue — every browser hitting the built bundle got a thrown error before ReactDOM.createRoot(...).render(...) ever ran, leaving #root permanently empty.

Confirmed by rendering the production bundle in headless Chromium (chromium --headless=old --dump-dom + --enable-logging=stderr --v=1 to capture console output) — <div id="root"></div> stayed empty and the uncaught error was visible in the captured console log.

Fix: src/shared/battlemapEngine.js was left untouched (still plain CommonJS, still required directly by the backend and by src/tests/battlemapEngine.test.js via Jest/Node). BattlemapTab.jsx no longer requires/imports it at all. The ~10 small pure functions it actually needed (battlemapVisibleForPlayer, normalizeMovement, normalizeTurnState, movementBudget, moveWithinRange, stepToward, distance, enemyMoveTowardNearest, sceneBattlemapTheme, and snapCoordinate, which the component already had a duplicate of) are defined locally inside the component, with a comment pointing at this incident/reasoning. This sidesteps the webpack interop bug entirely rather than fighting it further.

Known trade-off: those functions are now duplicated between BattlemapTab.jsx and src/shared/battlemapEngine.js (still used by database/routes/missionRoutes.js and simulationRoutes.js). If battlemap movement/theme logic changes, it needs updating in both places — nothing currently guards against drift between the two copies.

Verification

  • CI=true npx react-scripts build compiles cleanly.
  • Headless Chromium render of the live production bundle shows #root populated with the full app shell (nav, login form, tab list including "Battlemap") and zero uncaught console errors.
  • node -e "require('./src/shared/battlemapEngine')" still resolves all 41 exports for the backend/tests, unaffected by the frontend-side fix.
  • curl https://dwroller.alw.dk/ and curl http://localhost:5000/ both return 200 with the current build's asset hashes.
  • PM2 restart count stable (no new restarts) after the /nas mount fix.

Follow-ups (not done as part of this fix)

  • Consider whether src/shared/battlemapEngine.js should be split into a true dual CJS/ESM package (e.g. via a build step) if more frontend components need to consume it, instead of duplicating functions again.
  • Jest/RTL tests that spin up jsdom (battlemapTab.test.js, battlemapEngine.test.js when run together with DOM tests) hang/segfault on this ARM host — verify logic changes via plain node -e require checks or the built bundle instead of relying on npm test here.