Skip to content

fix: share-link hydration dead on dev hard loads (useSearchParams dehydration)#159

Merged
Daren9m merged 3 commits into
mainfrom
claude/fix-dev-hydration
Jul 20, 2026
Merged

fix: share-link hydration dead on dev hard loads (useSearchParams dehydration)#159
Daren9m merged 3 commits into
mainfrom
claude/fix-dev-hydration

Conversation

@Daren9m

@Daren9m Daren9m commented Jul 20, 2026

Copy link
Copy Markdown
Owner

Root cause

Under next dev (Next 16 / React 19), a hard navigation to any URL with query params left the Suspense boundary around a useSearchParams() consumer permanently dehydrated: the prerendered HTML stayed visible but React never attached — no fibers on the DOM, no effects ever ran — so share-link hydration silently did nothing on /encounters, /maps, /noncombat, and /noncombat/player. Client-side transitions and the production static build were unaffected, which is why it went unnoticed.

Diagnosed by instrumentation, not guesswork: a module-level marker proved the right bundle executes; a fiber check proved the subtree never hydrates on hard loads; the same effect logs twice (StrictMode) and hydrates fine via client-side nav — the one-shot didInit guard (the original suspect) was actually correct.

Fix

In a fully static export, query params are a client-only concern:

  • The one-shot hydration effects read new URLSearchParams(window.location.search) directly — effects only run client-side and this never suspends.
  • /noncombat/player (which read params during render) now derives its projection from mount-time query state with a popstate listener; the prerendered "Preparing the handout…" state matches the first client render, so hydration is mismatch-free.
  • The now-purposeless Suspense wrappers are removed.
  • Regression test: a source scan over every page.tsx forbids reintroducing the useSearchParams import/call (red before the fix: 4 failures; green after).

Verification

  • Dev hard loads now hydrate on all four pages (verified in-browser: fiber attached, encounter/puzzle/map rendered from seed).
  • Replay contract intact: seed 1955118459 reproduces the identical "Skirmish — Ettercap in the Forest" in dev and the production static build; noncombat golden-pin seed 1337 renders "The Constellation Floor" in both the DM view and player handout.
  • npm run typecheck clean · npx vitest run 46 files, 691/691 · npm run lint 0 errors · npm run build static export succeeds.

Also includes a one-line autoPort addition to .claude/launch.json (dev convenience; port 3000 was occupied locally).

Daren9m added 3 commits July 20, 2026 01:39
Under next dev (Next 16 / React 19), a hard navigation to a URL with query
params left the Suspense boundary around every useSearchParams() consumer
permanently dehydrated: the prerendered HTML stayed visible but React never
attached (no fibers, no effects), so share-link hydration silently never
ran on /encounters, /maps, /noncombat, and /noncombat/player. Client-side
transitions and the production static build were unaffected.

In a fully static export, query params are a client-only concern: the
one-shot hydration effects now read window.location.search directly (which
never suspends), and the player page derives its projection from a
mount-time query state with a popstate listener. The now-unneeded Suspense
wrappers are removed. A source-scan regression test forbids reintroducing
the hook in page components.
@Daren9m
Daren9m merged commit 3be6e48 into main Jul 20, 2026
1 check passed
@Daren9m
Daren9m deleted the claude/fix-dev-hydration branch July 20, 2026 07:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant