Follow-up to #86, which was one instance of a class this repo has more of.
The class
Two related mistakes, both of which ship wrong HTML from a static export:
1. typeof navigator !== 'undefined' used as a server/browser check. It is not one. Node 21+ ships a global navigator. In the build container (Node 22.23.2):
typeof navigator: object
navigator.onLine: undefined
So the guard passes on the server and the browser branch runs with missing properties. #86 was exactly this: the guard passed, navigator.onLine was undefined, and the static export rendered /contact as if the visitor were offline — shipping "You are currently offline" to everyone.
All known instances were corrected in e23be43. typeof window !== 'undefined' is the check that actually discriminates, and the repo already uses it in 8+ places.
2. Reading browser-only state in a useState initialiser. Even with a correct guard this is hydration-unsafe: the client's first render has to match the server's, so the real value has to be read in an effect. Correct shape:
const [x, setX] = useState(SAFE_DEFAULT); // same on both sides
useEffect(() => { setX(realBrowserValue()); }, []);
Known remaining instances of (2)
src/hooks/useWindowResize.ts:39-40 — reads window.innerWidth/innerHeight in the initialiser, falling back to 0. Any route rendering something conditional on width will mismatch.
src/hooks/useVisibilityChange.ts:39 — same shape.
Neither is on /contact, so neither caused #86, but both are the same hazard on whatever routes use them. Worth auditing which routes those are before deciding urgency.
Prevention
A lint rule would stop the whole class returning. Two candidates:
- ban
typeof navigator !== 'undefined' outright (no-restricted-syntax), with a message pointing at window
- flag
useState( initialisers that reference window, navigator, localStorage, sessionStorage, document or Date.now/new Date
The repo already has custom ESLint config (eslint-plugin-no-secrets is wired in eslint.config.mjs), so adding a no-restricted-syntax entry is cheap.
Why this is worth doing
#86 shipped a false "you are offline" banner to every visitor of a public page and cost the page its theme, and nothing caught it — not type-checking (the value was typed boolean), not the unit suite, not E2E. It was found incidentally, by a contrast gate asserting that a theme had applied before measuring it.
Related: #86, #79.
Follow-up to #86, which was one instance of a class this repo has more of.
The class
Two related mistakes, both of which ship wrong HTML from a static export:
1.
typeof navigator !== 'undefined'used as a server/browser check. It is not one. Node 21+ ships a globalnavigator. In the build container (Node 22.23.2):So the guard passes on the server and the browser branch runs with missing properties. #86 was exactly this: the guard passed,
navigator.onLinewasundefined, and the static export rendered/contactas if the visitor were offline — shipping "You are currently offline" to everyone.All known instances were corrected in
e23be43.typeof window !== 'undefined'is the check that actually discriminates, and the repo already uses it in 8+ places.2. Reading browser-only state in a
useStateinitialiser. Even with a correct guard this is hydration-unsafe: the client's first render has to match the server's, so the real value has to be read in an effect. Correct shape:Known remaining instances of (2)
src/hooks/useWindowResize.ts:39-40— readswindow.innerWidth/innerHeightin the initialiser, falling back to0. Any route rendering something conditional on width will mismatch.src/hooks/useVisibilityChange.ts:39— same shape.Neither is on
/contact, so neither caused #86, but both are the same hazard on whatever routes use them. Worth auditing which routes those are before deciding urgency.Prevention
A lint rule would stop the whole class returning. Two candidates:
typeof navigator !== 'undefined'outright (no-restricted-syntax), with a message pointing atwindowuseState(initialisers that referencewindow,navigator,localStorage,sessionStorage,documentorDate.now/new DateThe repo already has custom ESLint config (
eslint-plugin-no-secretsis wired ineslint.config.mjs), so adding ano-restricted-syntaxentry is cheap.Why this is worth doing
#86 shipped a false "you are offline" banner to every visitor of a public page and cost the page its theme, and nothing caught it — not type-checking (the value was typed
boolean), not the unit suite, not E2E. It was found incidentally, by a contrast gate asserting that a theme had applied before measuring it.Related: #86, #79.