- 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
5.4 KiB
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 6–47 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 buildcompiles cleanly.- Headless Chromium render of the live production bundle shows
#rootpopulated 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/andcurl http://localhost:5000/both return 200 with the current build's asset hashes.- PM2 restart count stable (no new restarts) after the
/nasmount fix.
Follow-ups (not done as part of this fix)
- Consider whether
src/shared/battlemapEngine.jsshould 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.jswhen run together with DOM tests) hang/segfault on this ARM host — verify logic changes via plainnode -erequire checks or the built bundle instead of relying onnpm testhere.