Skip to content

React #418 on cold-cache loads: deferred sections can resolve before hydration (DecoPageRenderer seeds first client render from the deferred cache) #443

Description

@danitombini

Summary

On a TanStack Start storefront running on Cloudflare Workers (React 19.2.7, production build), the home page logs Uncaught Error: Minified React error #418 (Hydration failed because the server rendered HTML didn't match the client) during hydration, and React discards the whole server-rendered tree under main > div.

I verified the side effect by tagging the SSR nodes before hydration and checking node identity afterwards: every child of main > div (h1.sr-only, section#Theme-Theme, #Header-Header, #Images-Carousel, the shelves, #Footer-Footer, …) gets detached and re-created on the client, and the useId values change from _R_ikn6_ / _R_kikn6_ to _r_0_ / _r_1_. In practice the whole home is rendered twice and the SSR benefit is lost (extra main-thread work, visual flash, LCP/INP impact).

Reproduction

  • 3/3 reproductions with a cold browser cache (first loads of the origin).
  • 0/4 reproductions with a warm cache (JS assets already cached).

The error is not visible with a naive console read because it happens during hydration; I captured it by hooking console.error and the error event before any page script runs.

Evidence that it is a transient (race) mismatch, not a static markup difference

  • Comparing the pre-hydration DOM with the client-regenerated tree, the structure is identical — the only differences are useId values and style attribute serialization (border-bottom-color:#FFFFFF vs border-bottom-color: rgb(255, 255, 255)).
  • The server HTML for the home contains 16 skeleton placeholders (animate-pulse), i.e. the page relies heavily on deferred sections.
  • main's server HTML is ~68.6 KB and matches the regenerated client tree, so nothing structural is missing/extra in a stable way.

Suspected root cause

In packages/tanstack/src/hooks/DecoPageRenderer.tsx:

  • Lines 258-259 seed the very first client render from the module-level deferred cache:
const [section, setSection] = useState<ResolvedSection | null>(() =>
  typeof document === "undefined" ? null : getCachedDeferredSection(stableKey),
);

On the server this is always null (skeleton is emitted), while on the client the first render — the hydration render — can already return resolved content. Any divergence here is a guaranteed hydration mismatch.

  • The streamed path at lines 539-557 (<Suspense fallback={skeleton}><Await promise={promise}>…) can have its data resolved before hydration starts on slow/cold loads, which is consistent with the cold-cache-only reproduction: when the JS bundles are already cached, hydration wins the race and no error is reported.

Suggested fix

Keep the first client render byte-identical to the SSR output and only adopt resolved/cached content in a later commit:

  • read getCachedDeferredSection() inside useEffect (after mount) instead of in the useState initializer, or
  • use useSyncExternalStore with getServerSnapshot() returning null so server and hydration snapshots always agree, or
  • gate the cache read behind an explicit hydrated flag set after the first commit.

Related

I could not determine the exact @decocms/start version of this deployment (no version endpoint exposed), so it would be useful to know whether this is residual behaviour from something already fixed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions