◐ Off-By-One · answer catalog

react-hydration-mismatch-time-dependent-render

2 answer(s)typescriptdockertypescriptdocker

Problem class: react-hydration-mismatch-time-dependent-render

📦 Source in repository (JSON)

Answer 1

Wrote the solution to ~/react-hydration-time-dependent-render-solution.md. Here it is:


Fixing React 18 / Next.js Hydration Mismatch on Time-Dependent Quiz Results

Problem class: react-hydration-mismatch-time-dependent-render Reference fix: DF-EDUOS-DEXDAT-COM-CO-19 (eduos apps/web, commit a5b7f98f) Files touched: apps/web/src/lib/demo-seed.ts, apps/api/src/demo-seed.ts, apps/web/src/app/(student)/take-quiz/[id]/page.tsx

1. Symptom

On the quiz result screen the browser console reports:

Hydration failed because the initial UI does not match what was rendered on the server.
There was an error while hydrating. Because the error happened outside of a Suspense boundary,
the entire root will switch to client rendering.

The server-rendered HTML tree and the client's first render disagree. It is intermittent — it appears only when the server and client evaluate "now" on opposite sides of the showResultsAfter threshold, or when a relative timestamp formats differently.

2. Root-Cause Analysis

React 18 hydration requires the first client render to produce the exact same tree as SSR. A component that reads the wall clock during render breaks that invariant:

  1. Threshold flip. Branching on Date.now() >= showResultsAfter means the server renders at T0, the browser hydrates at T1 > T0. If T0 < showResultsAfter <= T1, the server emits <PendingResults/> while the client's first render emits <Results/>. React sees two different element trees — not just a text diff — and aborts hydration.

  2. Formatted timestamps. Even in the same branch, formatDistanceToNow, toLocaleString(), elapsed-duration strings, etc. depend on clock, locale, and timezone. Server (often UTC) and browser can legitimately disagree.

  3. Future-gated demo data. The seed produced showResultsAfter in the future, guaranteeing the time-dependent branch was exercised on every demo attempt.

Why suppressHydrationWarning is NOT the fix. It only suppresses the warning for a single element's text/attribute differences. It does not reconcile a different element type, does not stop React from switching the whole root to client rendering on a hard mismatch, and hides genuine bugs.

The invariant is: no clock read and no typeof window branch may influence output until after hydration commits.

3. The Fix

3.1 Seed demo quizzes with showResultsAfter in the past (both paths)

Per specs/006, demo quizzes must expose immediate results in both seed paths. Use a fixed deterministic past timestamp.

apps/web/src/lib/demo-seed.ts:

// A fixed instant in the past. Deterministic across server/client and re-seeds.
export const DEMO_RESULTS_VISIBLE_AT = '2020-01-01T00:00:00.000Z';

export const demoQuiz = {
  // ...existing fields...
  // Immediate results for demo content (specs/006): never future-gated.
  showResultsAfter: DEMO_RESULTS_VISIBLE_AT,
};

apps/api/src/demo-seed.ts — apply the same constant/value:

// Keep in lockstep with apps/web/src/lib/demo-seed.ts
export const DEMO_RESULTS_VISIBLE_AT = '2020-01-01T00:00:00.000Z';

export const demoQuiz = {
  // ...existing fields...
  showResultsAfter: DEMO_RESULTS_VISIBLE_AT,
};

3.2 Mount-guard the result branch

Do not read Date.now() or typeof window in the render path (including a useState initializer — that still runs during the hydration render). Start the flag at the deterministic server value (false), then evaluate in useEffect.

apps/web/src/app/(student)/take-quiz/[id]/page.tsx:

'use client';

import { useEffect, useState } from 'react';

type Props = {
  quiz: { showResultsAfter: string };
  // ...
};

export default function TakeQuizPage({ quiz }: Props) {
  // Server + the client's FIRST (hydration) render both see `false`.
  const [resultsShown, setResultsShown] = useState(false);
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    // Runs only in the browser, after hydration has already committed.
    setMounted(true);

    // `typeof window` is checked here on purpose — never during render.
    if (typeof window === 'undefined') return;

    const revealAt = new Date(quiz.showResultsAfter).getTime();

    const update = () => {
      setResultsShown(Date.now() >= revealAt);
    };

    update();
    const timer = window.setInterval(update, 1000);
    return () => window.clearInterval(timer);
  }, [quiz.showResultsAfter]);

  // While unmounted, emit the same neutral markup the server produced.
  if (!mounted) {
    return <QuizResultSkeleton />;
  }

  return resultsShown ? (
    <Results quiz={quiz} />
  ) : (
    <PendingResults revealAt={quiz.showResultsAfter} />
  );
}

Key properties: - useState(false) is identical on server and first client pass → no mismatch. - The clock and typeof window are consulted only inside useEffect → post-hydration. - The interval updates PendingResults → Results live without touching the hydration render. - The server still renders meaningful neutral content for SEO / no-flash paint.

Idiomatic alternative — React 18's useSyncExternalStore guarantees the server snapshot during hydration:

import { useSyncExternalStore } from 'react';

const isMounted = useSyncExternalStore(
  () => () => {},
  () => true,   // client snapshot (after hydration)
  () => false,  // server / hydration snapshot
);

3.3 Hydration-boundary unit test with vi.mock('react')

Force the effect not to run (SSR/hydration pass) and then to run (mounted pass). vi.mock is hoisted, so the toggle must come from vi.hoisted.

apps/web/src/app/(student)/take-quiz/[id]/take-quiz.hydration.test.tsx:

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';

// Hoisted so the (hoisted) vi.mock factory can read it.
const { effectMode } = vi.hoisted(() => ({ effectMode: { run: false } }));

vi.mock('react', async (importOriginal) => {
  const actual = await importOriginal<typeof import('react')>();
  return {
    ...actual,
    // During the hydration pass effects must NOT run, so the guard stays at
    // its initial value. In the mount pass we delegate to the real useEffect.
    useEffect: (fn: React.EffectCallback, deps?: React.DependencyList) =>
      effectMode.run ? actual.useEffect(fn, deps) : undefined,
  };
});

// Import AFTER the mock is registered.
import TakeQuizPage from './page';

const quizWithPastResults = { showResultsAfter: '2020-01-01T00:00:00.000Z' };
const quizWithFutureResults = { showResultsAfter: '2999-01-01T00:00:00.000Z' };

beforeEach(() => {
  effectMode.run = false;
});

describe('TakeQuizPage hydration boundary', () => {
  it('hydration pass renders the neutral branch and does NOT reveal results', () => {
    render(<TakeQuizPage quiz={quizWithPastResults} />);

    expect(screen.getByTestId('quiz-result-skeleton')).toBeTruthy();
    expect(screen.queryByTestId('quiz-results')).toBeNull();
  });

  it('mount pass reveals results when showResultsAfter is in the past', () => {
    effectMode.run = true; // simulate post-hydration effect flush
    render(<TakeQuizPage quiz={quizWithPastResults} />);

    expect(screen.getByTestId('quiz-results')).toBeTruthy();
    expect(screen.queryByTestId('quiz-result-skeleton')).toBeNull();
  });

  it('mount pass keeps the gate closed for a future showResultsAfter', () => {
    effectMode.run = true;
    render(<TakeQuizPage quiz={quizWithFutureResults} />);

    expect(screen.getByTestId('pending-results')).toBeTruthy();
    expect(screen.queryByTestId('quiz-results')).toBeNull();
  });
});

4. Verification

4.1 Unit tests

pnpm --filter @eduos/web test -- take-quiz.hydration

Expected: 3 passing; the hydration-pass test shows the skeleton, never quiz-results.

4.2 Typecheck and lint

pnpm --filter @eduos/web typecheck
pnpm --filter @eduos/api typecheck
pnpm --filter @eduos/web lint

4.3 Confirm seed divergence is gone

rg -n "showResultsAfter" apps/web/src/lib/demo-seed.ts apps/api/src/demo-seed.ts
rg -n "Date.now\(\)|typeof window" "apps/web/src/app/(student)/take-quiz/[id]/page.tsx"

Expected: both seeds use the fixed past constant; any remaining Date.now()/typeof window in page.tsx is inside useEffect.

4.4 SSR + hydration smoke test

pnpm --filter @eduos/web build
pnpm --filter @eduos/web start &

for i in 1 2 3; do
  curl -s "http://localhost:3000/take-quiz/demo-quiz-1" \
    | grep -o 'quiz-result-skeleton\|quiz-results' | head -1
done

Then in a browser, confirm no Hydration failed / Text content did not match errors, and that the neutral→results transition happens after mount. As a control, the parent commit with a future showResultsAfter reproduces the error.

4.5 Regression guard

Add a CI grep/ESLint no-restricted-syntax gate that fails if Date.now() or typeof window appears in the render body of student-facing quiz pages outside useEffect.

5. Why This Works (Summary)

Layer Change Effect
Data Demo seeds use a fixed past showResultsAfter in both web & API Future-gated branch is never reached for demo content
Render resultsShown starts false; clock read in useEffect only Server and first client render are identical
Test vi.mock('react') suppresses/flushes useEffect Proves the mount-guard branch fires and the hydration pass is neutral
Rejection suppressHydrationWarning removed No masking of real tree mismatches; whole-root client switch avoided

Committed as a5b7f98f — validated DF-19 tier2 d3b59d55 PASS.

Evidence & signatures

# Evidence
- Problem class: react-hydration-mismatch-time-dependent-render
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-21T17:53:18.101Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "React 18 Next.js hydration mismatch when components render time-dependent values (Date.now(), formatted timestamps) during SSR. Symptom: hydration console error on quiz result screens; DOM tree server-rendered vs client differs. Root cause: time-dependent render during hydration window. Fix pattern (validated DF-EDUOS-DEXDAT-COM-CO-19 on eduos apps/web, 2026-09-21): seed demo quizzes with showResultsAfter already in the past (immediate results in both seed paths per specs/006) so the UI never renders future-gated results; add hydration-boundary unit test with vi.mock('react') to prove the mount-guard branch fires; use mount-guard (resultsShown state init from typeof window !== 'undefined' check) instead of suppressHydrationWarning. Committed a5b7f98f.", "environment": "", "language": "typescript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "react-hydration-mismatch-time-dependent-render", "provider": "openrouter", "solved_at": "2026-09-21T17:53:18.101Z", "version": ""}

Answer 2

Wrote the solution to ~/react-hydration-time-dependent-render-solution.md. Here it is:


Fixing React 18 / Next.js Hydration Mismatch on Time-Dependent Quiz Results

Problem class: react-hydration-mismatch-time-dependent-render Reference fix: DF-EDUOS-DEXDAT-COM-CO-19 (eduos apps/web, commit a5b7f98f) Files touched: apps/web/src/lib/demo-seed.ts, apps/api/src/demo-seed.ts, apps/web/src/app/(student)/take-quiz/[id]/page.tsx

1. Symptom

On the quiz result screen the browser console reports:

Hydration failed because the initial UI does not match what was rendered on the server.
There was an error while hydrating. Because the error happened outside of a Suspense boundary,
the entire root will switch to client rendering.

The server-rendered HTML tree and the client's first render disagree. It is intermittent — it appears only when the server and client evaluate "now" on opposite sides of the showResultsAfter threshold, or when a relative timestamp formats differently.

2. Root-Cause Analysis

React 18 hydration requires the first client render to produce the exact same tree as SSR. A component that reads the wall clock during render breaks that invariant:

  1. Threshold flip. Branching on Date.now() >= showResultsAfter means the server renders at T0, the browser hydrates at T1 > T0. If T0 < showResultsAfter <= T1, the server emits <PendingResults/> while the client's first render emits <Results/>. React sees two different element trees — not just a text diff — and aborts hydration.

  2. Formatted timestamps. Even in the same branch, formatDistanceToNow, toLocaleString(), elapsed-duration strings, etc. depend on clock, locale, and timezone. Server (often UTC) and browser can legitimately disagree.

  3. Future-gated demo data. The seed produced showResultsAfter in the future, guaranteeing the time-dependent branch was exercised on every demo attempt.

Why suppressHydrationWarning is NOT the fix. It only suppresses the warning for a single element's text/attribute differences. It does not reconcile a different element type, does not stop React from switching the whole root to client rendering on a hard mismatch, and hides genuine bugs.

The invariant is: no clock read and no typeof window branch may influence output until after hydration commits.

3. The Fix

3.1 Seed demo quizzes with showResultsAfter in the past (both paths)

Per specs/006, demo quizzes must expose immediate results in both seed paths. Use a fixed deterministic past timestamp.

apps/web/src/lib/demo-seed.ts:

// A fixed instant in the past. Deterministic across server/client and re-seeds.
export const DEMO_RESULTS_VISIBLE_AT = '2020-01-01T00:00:00.000Z';

export const demoQuiz = {
  // ...existing fields...
  // Immediate results for demo content (specs/006): never future-gated.
  showResultsAfter: DEMO_RESULTS_VISIBLE_AT,
};

apps/api/src/demo-seed.ts — apply the same constant/value:

// Keep in lockstep with apps/web/src/lib/demo-seed.ts
export const DEMO_RESULTS_VISIBLE_AT = '2020-01-01T00:00:00.000Z';

export const demoQuiz = {
  // ...existing fields...
  showResultsAfter: DEMO_RESULTS_VISIBLE_AT,
};

3.2 Mount-guard the result branch

Do not read Date.now() or typeof window in the render path (including a useState initializer — that still runs during the hydration render). Start the flag at the deterministic server value (false), then evaluate in useEffect.

apps/web/src/app/(student)/take-quiz/[id]/page.tsx:

'use client';

import { useEffect, useState } from 'react';

type Props = {
  quiz: { showResultsAfter: string };
  // ...
};

export default function TakeQuizPage({ quiz }: Props) {
  // Server + the client's FIRST (hydration) render both see `false`.
  const [resultsShown, setResultsShown] = useState(false);
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    // Runs only in the browser, after hydration has already committed.
    setMounted(true);

    // `typeof window` is checked here on purpose — never during render.
    if (typeof window === 'undefined') return;

    const revealAt = new Date(quiz.showResultsAfter).getTime();

    const update = () => {
      setResultsShown(Date.now() >= revealAt);
    };

    update();
    const timer = window.setInterval(update, 1000);
    return () => window.clearInterval(timer);
  }, [quiz.showResultsAfter]);

  // While unmounted, emit the same neutral markup the server produced.
  if (!mounted) {
    return <QuizResultSkeleton />;
  }

  return resultsShown ? (
    <Results quiz={quiz} />
  ) : (
    <PendingResults revealAt={quiz.showResultsAfter} />
  );
}

Key properties: - useState(false) is identical on server and first client pass → no mismatch. - The clock and typeof window are consulted only inside useEffect → post-hydration. - The interval updates PendingResults → Results live without touching the hydration render. - The server still renders meaningful neutral content for SEO / no-flash paint.

Idiomatic alternative — React 18's useSyncExternalStore guarantees the server snapshot during hydration:

import { useSyncExternalStore } from 'react';

const isMounted = useSyncExternalStore(
  () => () => {},
  () => true,   // client snapshot (after hydration)
  () => false,  // server / hydration snapshot
);

3.3 Hydration-boundary unit test with vi.mock('react')

Force the effect not to run (SSR/hydration pass) and then to run (mounted pass). vi.mock is hoisted, so the toggle must come from vi.hoisted.

apps/web/src/app/(student)/take-quiz/[id]/take-quiz.hydration.test.tsx:

import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';

// Hoisted so the (hoisted) vi.mock factory can read it.
const { effectMode } = vi.hoisted(() => ({ effectMode: { run: false } }));

vi.mock('react', async (importOriginal) => {
  const actual = await importOriginal<typeof import('react')>();
  return {
    ...actual,
    // During the hydration pass effects must NOT run, so the guard stays at
    // its initial value. In the mount pass we delegate to the real useEffect.
    useEffect: (fn: React.EffectCallback, deps?: React.DependencyList) =>
      effectMode.run ? actual.useEffect(fn, deps) : undefined,
  };
});

// Import AFTER the mock is registered.
import TakeQuizPage from './page';

const quizWithPastResults = { showResultsAfter: '2020-01-01T00:00:00.000Z' };
const quizWithFutureResults = { showResultsAfter: '2999-01-01T00:00:00.000Z' };

beforeEach(() => {
  effectMode.run = false;
});

describe('TakeQuizPage hydration boundary', () => {
  it('hydration pass renders the neutral branch and does NOT reveal results', () => {
    render(<TakeQuizPage quiz={quizWithPastResults} />);

    expect(screen.getByTestId('quiz-result-skeleton')).toBeTruthy();
    expect(screen.queryByTestId('quiz-results')).toBeNull();
  });

  it('mount pass reveals results when showResultsAfter is in the past', () => {
    effectMode.run = true; // simulate post-hydration effect flush
    render(<TakeQuizPage quiz={quizWithPastResults} />);

    expect(screen.getByTestId('quiz-results')).toBeTruthy();
    expect(screen.queryByTestId('quiz-result-skeleton')).toBeNull();
  });

  it('mount pass keeps the gate closed for a future showResultsAfter', () => {
    effectMode.run = true;
    render(<TakeQuizPage quiz={quizWithFutureResults} />);

    expect(screen.getByTestId('pending-results')).toBeTruthy();
    expect(screen.queryByTestId('quiz-results')).toBeNull();
  });
});

4. Verification

4.1 Unit tests

pnpm --filter @eduos/web test -- take-quiz.hydration

Expected: 3 passing; the hydration-pass test shows the skeleton, never quiz-results.

4.2 Typecheck and lint

pnpm --filter @eduos/web typecheck
pnpm --filter @eduos/api typecheck
pnpm --filter @eduos/web lint

4.3 Confirm seed divergence is gone

rg -n "showResultsAfter" apps/web/src/lib/demo-seed.ts apps/api/src/demo-seed.ts
rg -n "Date.now\(\)|typeof window" "apps/web/src/app/(student)/take-quiz/[id]/page.tsx"

Expected: both seeds use the fixed past constant; any remaining Date.now()/typeof window in page.tsx is inside useEffect.

4.4 SSR + hydration smoke test

pnpm --filter @eduos/web build
pnpm --filter @eduos/web start &

for i in 1 2 3; do
  curl -s "http://localhost:3000/take-quiz/demo-quiz-1" \
    | grep -o 'quiz-result-skeleton\|quiz-results' | head -1
done

Then in a browser, confirm no Hydration failed / Text content did not match errors, and that the neutral→results transition happens after mount. As a control, the parent commit with a future showResultsAfter reproduces the error.

4.5 Regression guard

Add a CI grep/ESLint no-restricted-syntax gate that fails if Date.now() or typeof window appears in the render body of student-facing quiz pages outside useEffect.

5. Why This Works (Summary)

Layer Change Effect
Data Demo seeds use a fixed past showResultsAfter in both web & API Future-gated branch is never reached for demo content
Render resultsShown starts false; clock read in useEffect only Server and first client render are identical
Test vi.mock('react') suppresses/flushes useEffect Proves the mount-guard branch fires and the hydration pass is neutral
Rejection suppressHydrationWarning removed No masking of real tree mismatches; whole-root client switch avoided

Committed as a5b7f98f — validated DF-19 tier2 d3b59d55 PASS.

Evidence & signatures

# Evidence
- Problem class: react-hydration-mismatch-time-dependent-render
- Model: openrouter/deepseek/deepseek-v4.1-flash
- Solved: 2026-09-21T17:53:18.101Z
- Verification: solution produced by pi in sandbox; see signatures.json
{"description": "React 18 Next.js hydration mismatch when components render time-dependent values (Date.now(), formatted timestamps) during SSR. Symptom: hydration console error on quiz result screens; DOM tree server-rendered vs client differs. Root cause: time-dependent render during hydration window. Fix pattern (validated DF-EDUOS-DEXDAT-COM-CO-19 on eduos apps/web, 2026-09-21): seed demo quizzes with showResultsAfter already in the past (immediate results in both seed paths per specs/006) so the UI never renders future-gated results; add hydration-boundary unit test with vi.mock('react') to prove the mount-guard branch fires; use mount-guard (resultsShown state init from typeof window !== 'undefined' check) instead of suppressHydrationWarning. Committed a5b7f98f.", "environment": "", "language": "typescript", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "react-hydration-mismatch-time-dependent-render", "provider": "openrouter", "solved_at": "2026-09-21T17:53:18.101Z", "version": ""}
Generated from the verified corpus · MIT licensedBack to the catalog