Skip to content

Fix hydration mismatch from the lazy emphasis enhancer - #7

Draft
dav-is wants to merge 4 commits into
mainfrom
fix/inline-enhancer-hydration
Draft

dav-is wants to merge 4 commits into
mainfrom
fix/inline-enhancer-hydration

Conversation

@dav-is

@dav-is dav-is commented Sep 26, 2026 •

Copy link
Copy Markdown
Member

Summary

Under CodeProviderLazy, inline code with the default highlightAfter could log a hydration mismatch on the first request after next dev started: a frame rendered without data-frame-type on the server and with "focus" on the client, or the reverse. React doesn't patch mismatched attributes, so the wrong frames also stayed on screen.

Cause

useCode ran the provider's enhanceCodeEmphasisLazy during the server render and the hydration. That enhancer returns a result synchronously only once its chunk has loaded in the current process, and a promise before. The server and the browser load it independently, so the hydrated tree depended on which side had loaded it. The same could happen with any enhancer that returns sync-or-promise, and in the ContentLoading fallback that buildStringFallback prepares.

Fix

  • Hydration gate: during the server render and hydration, useSourceEnhancing and buildStringFallback run only enhancers marked with the new optional enhancerSync flag. The built-in createEnhanceCodeEmphasis enhancers set it, so their frames stay in the server HTML. Every other enhancer the tree hasn't recorded runs right after hydration, so no enhancer can cause a mismatch, flagged or not.
  • Server enhancement stays explicit: as in canary.18, pass sourceEnhancers to CodeHighlighter. The server records the enhancer, and the provider's enhancer of the same name skips the tree without loading.
  • createLazySourceEnhancer(enhancerName, load) (new, at @fairgarden/docs/pipeline/createLazySourceEnhancer): builds an enhancer that loads on demand. It loads once and caches the result, returns synchronously once loaded, has preload(), and requires the loaded enhancer's name so upstream-enhanced trees are never enhanced twice. enhanceCodeEmphasisLazy is now built with it.
  • Performance:
    • The tree is cloned only right before an enhancer runs; a held-back block's server render drops from about 1.1 ms to 0.01 ms.
    • A component that holds nothing back no longer renders again after hydration.
    • CodeHighlighterClient tracks hydration only for the 'hydration' modes.

Behavior change: when custom enhancers run

The new enhancerSync flag declares that an enhancer never returns a promise and produces the same output on the server and in the browser for the same input. That means no browser-only state, timing or lazily loaded code; options or state that are the same on both sides are fine. What changes depends on where the enhancer is passed.

  • Passed to useCode, a CodeProvider / CodeProviderLazy or a CodeControllerContext:
    • Previously, a synchronous enhancer also ran during the server render and the hydration, so its output was in the server HTML. If it could return a promise on one side only, the hydrated markup could differ from the server HTML.
    • Now, an enhancer without enhancerSync: true runs right after hydration. Its output isn't in the server HTML, and appears on the first render after hydration.
    • With enhancerSync: true, it runs during the server render and hydration as before.
  • Passed to CodeHighlighter's sourceEnhancers:
    • The server loader runs it on the loaded code, marked or not, as before.
    • Previously, every synchronous enhancer also shaped the ContentLoading fallback window. Now only enhancerSync enhancers are applied to the fallback, so an unmarked one doesn't window it.
  • To keep the previous behaviour, set enhancerSync: true on an enhancer that meets the criteria above:
    const annotate: SourceEnhancer = (root, comments, fileName) => addAnnotations(root, comments);
    annotate.enhancerSync = true;
  • Not affected: the built-in emphasis enhancers (enhanceCodeEmphasis, createEnhanceCodeEmphasis(...)) already set it.

Compatibility

  • Public API: additive only. There is a new optional SourceEnhancer.enhancerSync and a new subpath; no exports were removed or changed (checked against the .d.ts of 0.13.4-canary.18).
  • Rendering: identical to canary.18 on all 176 docs pages, server HTML and hydrated DOM, except the fixed mismatch.
  • Lazy provider apps: with the default CodeProviderLazy and no server enhancers, emphasis applies right after hydration, deterministically. To server-render the frames, and avoid a layout shift when the window applies, pass sourceEnhancers={[enhanceCodeEmphasis]} to CodeHighlighter or use the eager CodeProvider.

Tests and verification

  • New tests: hydration tests (renderToString then hydrateRoot, lazy chunk loaded on one side only) for the loader path, useSourceEnhancing, custom and unmarked enhancers, and the client-rendered loading fallback; fallback-window, render-count and helper tests. They fail before the fix.
  • New fixture: the unlinked e2e fixture code-highlighter/demos/code-inline-hydration fails against a fresh canary.18 next dev and passes after.
  • Suites: unit (5086), typescript, eslint, docs:build, docs:validate (0 changes on the second run) and the code-highlighter production e2e pass; demo-variants is a known pre-existing flake.
  • Dev cold starts: 3 fresh next dev starts gave 0 hydration warnings.

@dav-is dav-is added the bug Something isn't working label Sep 26, 2026
@vercel

vercel Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
fairgarden-docs Ready Ready Preview Sep 27, 2026 3:09am UTC

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

The provider documentation incorrectly claims every server-loaded block skips the lazy enhancer despite supported override and opt-out paths.

Review effort: Balanced
Findings: 1 Low severity

Open (1)
What changed in this PR

Prevents lazy emphasis enhancement from causing server/client hydration mismatches.

Changes:

  • Applies default emphasis during server-side code loading.
  • Defers lazy client enhancers until hydration completes.
  • Adds unit, hydration, E2E, and documentation coverage.
File Description
AGENTS.md Documents the hydration issue and reproduction path.
packages/​docs/​src/​withFairGardenDocs/​withFairGardenDocs.ts Exposes emphasis options to server loaders.
packages/​docs/​src/​withFairGardenDocs/​withFairGardenDocs.test.ts Tests environment configuration.
packages/​docs/​src/​useCode/​useSourceEnhancing.ts Defers lazy enhancers through hydration.
packages/​docs/​src/​useCode/​useSourceEnhancing.test.ts Tests applied-enhancer skipping.
packages/​docs/​src/​useCode/​useSourceEnhancing.hydration.test.tsx Covers lazy-enhancer hydration behavior.
packages/​docs/​src/​pipeline/​enhanceCodeEmphasis/​enhanceCodeEmphasisLazy.ts Marks the enhancer as lazy.
packages/​docs/​src/​pipeline/​enhanceCodeEmphasis/​enhanceCodeEmphasisLazy.test.ts Tests the lazy marker.
packages/​docs/​src/​CodeHighlighter/​types.ts Documents new enhancer and server defaults.
packages/​docs/​src/​CodeHighlighter/​createDefaultSourceEnhancers.ts Creates configured default server enhancers.
packages/​docs/​src/​CodeHighlighter/​createDefaultSourceEnhancers.test.ts Tests default enhancer creation.
packages/​docs/​src/​CodeHighlighter/​CodeSourceLoader.tsx Enhances all server-loaded files.
packages/​docs/​src/​CodeHighlighter/​CodeSourceLoader.test.tsx Tests server enhancement and overrides.
packages/​docs/​src/​CodeHighlighter/​CodeInitialSourceLoader.tsx Enhances initial server-loaded sources.
packages/​docs/​src/​CodeHighlighter/​CodeInitialSourceLoader.test.tsx Tests initial-source enhancement.
packages/​docs/​src/​CodeHighlighter/​CodeHighlighter.hydration.test.tsx Tests the complete hydration path.
docs/​app/​lib/​pipeline/​with-fairgarden-docs/​types.md Updates generated option documentation.
docs/​app/​lib/​pipeline/​with-fairgarden-docs/​page.mdx Explains shared emphasis configuration.
docs/​app/​lib/​hooks/​use-code/​page.mdx Documents server, client, and lazy enhancers.
docs/​app/​lib/​hooks/​page.mdx Adds lazy enhancers to navigation.
docs/​app/​lib/​components/​code-provider/​page.mdx Updates lazy-provider behavior guidance.
docs/​app/​lib/​components/​code-highlighter/​types.md Updates generated prop documentation.
docs/​app/​lib/​components/​code-highlighter/​demos/​code-inline-hydration/​test.ts Adds hydration and SSR E2E coverage.
docs/​app/​lib/​components/​code-highlighter/​demos/​code-inline-hydration/​page.tsx Adds the regression fixture route.
docs/​app/​lib/​components/​code-highlighter/​demos/​code-inline-hydration/​InlineCodeContent.tsx Simulates delayed hydration content.
docs/​app/​lib/​components/​code-highlighter/​demos/​code-inline-hydration/​InlineCode.tsx Defines inline-code regression cases.
docs/​app/​lib/​components/​code-highlighter/​demos/​code-inline-hydration/​CodeNote.tsx Provides lazily loaded fixture content.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +253 to +257
`CodeProviderLazy` it is a lazy wrapper that a precomputed or server-loaded block
skips without loading (the build or the server already ran it), and that an
editing/highlighting block warms before it is needed. It never runs while the
page hydrates, so whether it has loaded yet can't make the hydrated markup differ
from the server HTML; a block that still needs it gets it right after hydration.

This branch was successfully deployed

1 active deployment
Preview — 018f9e85 Deployed Sep 27, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants