From 12bcec88bee6bc2d521cef7d83846237081efb5d Mon Sep 17 00:00:00 2001 From: HamChowderr Date: Thu, 30 Jul 2026 11:11:31 -0700 Subject: [PATCH] fix(code-block): avoid SSR hydration mismatch from the token cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tokensCache` is module-level, so it lives for the whole server process. Once the server has highlighted a block, subsequent SSR responses render COLORED tokens from that warm cache. Every fresh client, meanwhile, starts with a cold cache, so its first render — the hydration render — produces the plain `rawTokens` fallback. Those two disagree, and React reports a hydration mismatch on any SSR page containing a CodeBlock. It is intermittent by nature: the first request after a server start matches, later ones do not, which makes it awkward to reproduce and easy to misattribute. Gate highlighting on mount so the first render is always `rawTokens` on both sides, then apply shiki once mounted. Server HTML and the client's first render are then identical regardless of cache state. Costs one paint of unhighlighted code on first load, which is already the behaviour whenever shiki has not resolved yet. --- packages/elements/src/code-block.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/elements/src/code-block.tsx b/packages/elements/src/code-block.tsx index 820142d2..2e1adeaf 100644 --- a/packages/elements/src/code-block.tsx +++ b/packages/elements/src/code-block.tsx @@ -393,6 +393,16 @@ export const CodeBlockContent = ({ const [asyncTokens, setAsyncTokens] = useState(null); const asyncKeyRef = useRef({ code, language }); + // Gate highlighting on mount so the first render is always `rawTokens`. + // `tokensCache` is module-level and lives for the whole server process, so once + // the server has highlighted a block, later SSR responses emit COLORED tokens — + // while every fresh client starts with a cold cache and its first (hydration) + // render emits plain `rawTokens`. Those two disagree, which React reports as a + // hydration mismatch. Forcing both to `rawTokens` initially makes them + // identical regardless of cache state; shiki is applied after mount. + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + // Invalidate stale async tokens synchronously during render if ( asyncKeyRef.current.code !== code || @@ -416,7 +426,7 @@ export const CodeBlockContent = ({ }; }, [code, language]); - const tokenized = asyncTokens ?? syncTokens; + const tokenized = mounted ? (asyncTokens ?? syncTokens) : rawTokens; return (