Skip to content

Commit 2e1ef3f

Browse files
author
Codex
committed
fix(apps/landing): prevent hydration mismatch in live-signal-preview
The previous implementation called getSemanticBridgePreview() inside useState's lazy initializer, which executes both during SSR and during client hydration. Each call invoked new Date(), producing a different observedAt timestamp on server vs client. The rendered chip then formatted that timestamp with toLocaleTimeString(), which uses the runtime's timezone and locale, so the server-rendered text never matched the client-rendered text. Fix: - Initial state is now null (same on server and client first render). - useEffect fetches the real preview from /api/live-signal; on non-OK or network error it falls back to the local getSemanticBridgePreview() on the client side, so the fallback timestamp is also client-only. - A structurally identical placeholder section renders while preview is null, with aria-busy on the section and aria-hidden on the empty right-grid slot. The 'Observed ...' chip is replaced by the real time string only after hydration completes, so toLocaleTimeString() is never called during the SSR vs client diff. Scope: this commit contains the hydration hotfix only. The Week 2 practice-surface additions (consult page, session-types, cohort-cta, ARIA fixes to the practice components, content namespace additions and the new tests) remain uncommitted in the working tree and will be committed separately once the copy review lands. Validation: pnpm typecheck, pnpm lint, pnpm test (12/12), pnpm build (10 static routes + 2 dynamic API routes) all pass.
1 parent 12d85de commit 2e1ef3f

1 file changed

Lines changed: 49 additions & 5 deletions

File tree

apps/landing/src/components/live-signal-preview.tsx

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ import { getSemanticBridgePreview, type SemanticBridgePreview } from "@/lib/sema
77
import { useEffect, useState } from "react";
88

99
export function LiveSignalPreview() {
10-
const [preview, setPreview] = useState<SemanticBridgePreview>(() => getSemanticBridgePreview());
10+
// SSR-safe initial state: `null` means "not loaded yet". The server's
11+
// first render and the client's first render both see this same value,
12+
// so hydration is deterministic. The real preview is fetched in
13+
// useEffect; until it arrives we render a structurally identical
14+
// placeholder so the section never shows a hydration mismatch.
15+
const [preview, setPreview] = useState<SemanticBridgePreview | null>(null);
1116

1217
useEffect(() => {
1318
const controller = new AbortController();
@@ -18,13 +23,15 @@ export function LiveSignalPreview() {
1823
cache: "no-store",
1924
signal: controller.signal,
2025
});
21-
if (!response.ok) {
26+
if (response.ok) {
27+
const nextPreview = (await response.json()) as SemanticBridgePreview;
28+
setPreview(nextPreview);
2229
return;
2330
}
24-
const nextPreview = (await response.json()) as SemanticBridgePreview;
25-
setPreview(nextPreview);
31+
// Non-OK: use local fallback (still client-side, so no SSR mismatch).
32+
setPreview(getSemanticBridgePreview());
2633
} catch {
27-
// Read-only preview; local fallback stays in place on request failure.
34+
setPreview(getSemanticBridgePreview());
2835
}
2936
}
3037

@@ -33,6 +40,43 @@ export function LiveSignalPreview() {
3340
return () => controller.abort();
3441
}, []);
3542

43+
// Loading state: same section shape, deterministic placeholder copy.
44+
if (!preview) {
45+
return (
46+
<section
47+
id="live-signal"
48+
className="scroll-mt-24 border-t border-white/10 pt-10 sm:pt-12"
49+
aria-busy="true"
50+
>
51+
<div className="grid gap-6 lg:grid-cols-[0.84fr_1.16fr] lg:items-start">
52+
<Reveal>
53+
<div className="space-y-4">
54+
<SectionHeading
55+
eyebrow="System trace"
56+
title="Read-only residue"
57+
description="A low-volume echo from the live seam. Alive enough to matter. Detached enough to stay safe."
58+
/>
59+
60+
<div className="flex flex-wrap gap-2">
61+
<span className="chip">Observed …</span>
62+
<span className="chip">Dominant …</span>
63+
<span className="chip">Mode …</span>
64+
</div>
65+
66+
<Reveal delay={120}>
67+
<p className="max-w-xl text-sm leading-7 text-zinc-400">
68+
Loading live signal…
69+
</p>
70+
</Reveal>
71+
</div>
72+
</Reveal>
73+
74+
<div className="space-y-4" aria-hidden="true" />
75+
</div>
76+
</section>
77+
);
78+
}
79+
3680
return (
3781
<section id="live-signal" className="scroll-mt-24 border-t border-white/10 pt-10 sm:pt-12">
3882
<div className="grid gap-6 lg:grid-cols-[0.84fr_1.16fr] lg:items-start">

0 commit comments

Comments
 (0)