Skip to content

fix(hydration): stop the mobile media query running during hydration (chat#1912 row 5) - #1916

Merged
sweetmantech merged 1 commit into
mainfrom
fix/hydration-mismatch-header
Jul 30, 2026
Merged

fix(hydration): stop the mobile media query running during hydration (chat#1912 row 5)#1916
sweetmantech merged 1 commit into
mainfrom
fix/hydration-mismatch-header

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Closes row 5 of chat#1912.

Why

Uncaught Error: Minified React error #418 fires on every cold load, signed out and signed in, on / and /catalogs/{id}. React regenerates the entire tree on the client each time, so the whole app pays a full re-render on first paint.

The error's args are ["HTML", ""], which in React 19's throwOnHydrationMismatch is fromText ? "text" : "HTML" — an element mismatch, not a text one. That ruled out the usual suspects (Date formatting, locale, Math.random()), and SSR output was confirmed byte-identical across fetches, so the server was not the variable side.

Reproducing against a dev build made React name the node:

<Header>
  <div className="z-[50] fix...">
    <button>
+   <button
+     type="button"
+     onClick={function handleAddArtist}
+     aria-label="Add a new artist"
+   >

The + is client-only output. Header.tsx renders that button behind isMobile && !isArtistSelected, and useIsMobile wraps usehooks-ts useMediaQuery, whose initializeWithValue defaults to true:

const [matches, setMatches] = useState(() => {
  if (initializeWithValue) return getMatches(query); // window.matchMedia, during the first render
  return defaultValue;
});

On the server getMatches returns defaultValue (false); on the client the hydration render already evaluates matchMedia, so at any viewport under 768px the two renders disagree and an extra <button> appears. Nothing about this is mobile-only in consequence: the mismatch regenerates the whole tree for that visitor.

What changed

One option on hooks/useIsMobile.tsx: initializeWithValue: false. The first render now matches the server, and the real viewport value arrives via the hook's existing layout effect — so the mobile UI still appears immediately, it just no longer contradicts the server's HTML.

components/Sidebar/RecentChats/useRecentChats.ts uses a different hook (useMobileDetection) which is already effect-based and correct, so it needed no change.

Verification

Reproduced and fixed against a local dev build at a 500px viewport:

Before After
/ cold load Hydration failed because the server rendered HTML didn't match the client + the <Header> diff above no hydration error
/catalogs cold load same no hydration error

The only console error remaining on localhost is Privy's frame-ancestors CSP rejection, which is expected off-domain and unrelated.

Tests

hooks/__tests__/useIsMobile.test.tsx — 3 cases, TDD red→green. The first two were RED against the real defect (the hook called useMediaQuery with no options object). They assert the hook does not evaluate the query during the hydration render, keeps the 768px breakpoint, and returns what the query reports.

Full suite 285 passed / 76 files, tsc --noEmit clean.

Verification on preview

The issue's Works when script (cold, signed-out loads of /, /catalogs, /catalogs/{id}, console clean of #418 / #423 / #425) will be re-run against the preview and posted as a PR comment.

🤖 Generated with Claude Code


Summary by cubic

Prevent the mobile media query from running during hydration to stop React hydration mismatches (#418). Fixes chat#1912 row 5 and removes the full re-render on cold loads of / and /catalogs.

  • Bug Fixes
    • Use useMediaQuery("(max-width: 768px)", { initializeWithValue: false }) from usehooks-ts in useIsMobile so the first client render matches SSR and no extra Header button appears on narrow viewports.
    • Add tests for useIsMobile to assert no query during hydration, the 768px breakpoint, and that the return mirrors the query.

Written for commit c702c10. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes
    • Improved mobile and desktop layout detection during initial page loading.
    • Prevented hydration mismatches and visual differences between server-rendered and client-rendered content.

@cursor

cursor Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
chat Ready Ready Preview Jul 30, 2026 7:25pm

Request Review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0808f15d-cda2-4f06-9810-4d57f4030d98

📥 Commits

Reviewing files that changed from the base of the PR and between 74513e7 and c702c10.

⛔ Files ignored due to path filters (1)
  • hooks/__tests__/useIsMobile.test.tsx is excluded by !**/*.test.* and included by hooks/**
📒 Files selected for processing (1)
  • hooks/useIsMobile.tsx

📝 Walkthrough

Walkthrough

useIsMobile now defers matchMedia evaluation during the initial render by configuring useMediaQuery with initializeWithValue: false. A module-level comment documents how this prevents server/client hydration mismatches.

Changes

Mobile query initialization

Layer / File(s) Summary
Defer mobile query evaluation
hooks/useIsMobile.tsx
useIsMobile configures useMediaQuery to avoid evaluating the media query during the initial render and documents the hydration behavior.

Estimated code review effort: 1 (Trivial) | ~5 minutes

Poem

The query waits, the render stays bright,
No hydration mismatch in sight.
Match media wakes when effects begin,
Mobile truth then settles in.
A calmer first paint takes flight.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Solid & Clean Code ✅ Passed PASS: The hook stays tiny and single-purpose, with a straightforward option change and focused tests; no over-engineering or duplication introduced.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/hydration-mismatch-header

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 2 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.

Auto-approved: Fixes hydration mismatch by deferring media query evaluation to after hydration. Adds tests verifying the fix.

Re-trigger cubic

…(chat#1912 row 5)

usehooks-ts defaults useMediaQuery's initializeWithValue to true, which
evaluates window.matchMedia during the first render — including the hydration
render. The server has no matchMedia and renders the desktop branch, so on any
viewport under 768px the client's first render disagreed: Header emitted an
"Add Your Artist" button the server never sent. React threw a hydration
mismatch (#418) and regenerated the entire tree on every page load, for signed
-out and signed-in visitors alike.

Confirmed by reproducing against a dev build, where React names the node: the
diff points at Header's extra <button aria-label="Add a new artist">.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sweetmantech

Copy link
Copy Markdown
Collaborator Author

Preview verification — 2026-07-30

Preview https://chat-k92m5496q-recoup.vercel.app, built from head c702c103 (deployment resolved by sha).

Rebased onto main first, so the preview includes the three rows already merged today (#1915, #1914, #1913). Verifying the pre-rebase branch would have tested a tree that no longer matches what merges. The rebase is confirmed live in the results below: the menu shows both View catalogs (#1913) and the Sign in slot (#1914).

The viewport was set before each load, never after. This bug only exists below 768px — above it the server and client agree and nothing reproduces — and resizing after hydration proves nothing, because hydration has already happened.

Results

# Check Before Actual
0 Control: prod / cold, signed out, 500px wide Uncaught Error: Minified React error #418 🔴 reproduces
1 Preview / cold, signed out, same viewport #418 on every load no console output at all
2 Preview /catalogs cold #418 none
3 Preview /catalogs/{id} cold #418 none
4 Preview / cold at 1440px none
5 Regression control: the mobile UI the mismatch came from header renders Add Your Artist under 768px still renders (Add a new artist present at 500px)
6 Rebase actually applied menu shows View catalogs and the Sign in slot

Row 0 is the point of the run: the same browser, same viewport, same signed-out state throws #418 against production and is silent against this preview. Row 5 is the one that could have gone wrong — the fix defers the media-query result to an effect, so the risk was that the mobile UI never appears at all. It appears.

What was actually wrong

usehooks-ts defaults useMediaQuery's initializeWithValue to true, which evaluates window.matchMedia during the first render — including the hydration render:

const [matches, setMatches] = useState(() => {
  if (initializeWithValue) return getMatches(query); // runs during hydration
  return defaultValue;
});

The server has no matchMedia and takes the desktop branch, so below 768px the client's first render disagreed and Header emitted an extra <button aria-label="Add a new artist"> the server never sent. React treated that as a mismatch and regenerated the entire tree on every page load, for signed-out and signed-in visitors alike.

Pinning it took three narrowing steps, recorded because the first two are reusable:

  1. The error's args are ["HTML", ""]. In React 19's throwOnHydrationMismatch the first arg is fromText ? "text" : "HTML", so this was an element mismatch — which ruled out the usual suspects (date formatting, locale, Math.random() in text).
  2. SSR output was byte-identical across two fetches (65,425 bytes), so the server was not the variable side.
  3. A local dev build then made React name the offending node via describeDiff.

components/Sidebar/RecentChats/useRecentChats.ts uses a different hook (useMobileDetection) which is already effect-based and correct, so it needed no change.

Suite 332 passed / 82 files after the rebase, tsc --noEmit clean.

No review findings

No bot or human comments were left on this PR, so there was nothing to triage.

@sweetmantech
sweetmantech merged commit 6751445 into main Jul 30, 2026
4 checks passed
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