Skip to content

Audit hydration-unsafe browser reads, and lint against them #88

Description

@TortoiseWolfe

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions