AI & Agent Dev Bug Sandbox logo
AI & Agent Dev Bug Sandbox
Back to Radar

Pages Router Ssr:False Dynamic Imports Hydration Mismatch When Hydration Retries After Import Resolves

When using dynamic(..., { ssr: false }) in the Pages Router, if a sibling Suspense boundary throws during hydration and React retries after the dynamic import has resolved, the loader's useSyncExternalStore uses the current loaded state as the server snapshot. This causes React to see a mismatch between the server-rendered loading state and the now-loaded client state, discarding the server-rendered DOM and falling back to client rendering.

mediumConfidence 90%Next.jsAffected V14.2.35Affected V15.5.25Affected V16.3.4Affected V16.4.0-Canary.22

Origin Analysis

The Pages Router loadable component passes subscription.getCurrentValue as both the client snapshot and the server snapshot in useSyncExternalStore. For ssr: false dynamic imports, the module load can resolve between the initial suspended hydration attempt and the retry. When that happens, getCurrentValue returns the loaded component state instead of the loading state that was originally server-rendered, so the hydration snapshot no longer matches the server HTML. This is a design flaw in the shared runtime: getServerSnapshot should always return the server-rendered loading state for ssr: false modules, independent of the client-side load state.
1. Clone https://github.com/zjsng-trav/next-dynamic-hydration-repro.git and checkout c79e709.\n2. Install dependencies and next@16.4.0-canary.22, then build and start with webpack.\n3. Open http://localhost:3000/?mode=retry in a fresh document; the page server-renders a room section and excludes a header via dynamic(..., { ssr: false }).\n4. During hydration a sibling throws a stable promise, released 50ms after the header import resolves, causing React to retry hydration with the module already loaded.\n5. Observe the console and on-page evidence panel: React reports a hydration mismatch; originalCaptured is true but originalRetained and originalConnected become false.\n6. Compare ?mode=plain, ?mode=plain-no-boundary, and ?mode=ssr-retry to confirm the mismatch is specific to the retry-after-import-resolution case.

Fixing Code Block

// packages/next/src/shared/lib/loadable.shared-runtime.ts // Inside the Loadable component, replace the existing useSyncExternalStore call. const noSSRServerSnapshot = useMemo( () => ({ loading: true, loaded: false, error: null }), [] ); const getServerSnapshot = useCallback( () => (noSSR ? noSSRServerSnapshot : subscription.getCurrentValue()), [noSSR, noSSRServerSnapshot, subscription] ); const snapshot = useSyncExternalStore(subscription.getCurrentValue, getServerSnapshot);
The fix keeps the client snapshot as subscription.getCurrentValue so the component can still update when the dynamic import resolves after hydration. For the server/hydration snapshot, when noSSR is true we use a stable memoized object representing the loading state that was rendered on the server. Because this object is always the same during hydration, React always sees the server snapshot matching the original SSR output, regardless of whether the module resolved before the hydration retry. Once hydration completes, the normal subscription to getCurrentValue takes over and the loaded component renders in a standard client update.

Edge Case Audit

Rollback: revert to the original useSyncExternalStore(subscription.getCurrentValue, subscription.getCurrentValue) call. Potential secondary issues: (1) The useMemo-stable object must remain referentially identical across all getServerSnapshot calls during hydration; if it is recreated, React will log a 'getServerSnapshot should be memoized' warning and may still mismatch. (2) If noSSR is true but the server actually rendered non-loading content due to a future SSR streaming change, this fix would force a hydration mismatch; however, current ssr:false semantics always render the loading state on the server. (3) After hydration, the component may briefly flash the loading state before the client update applies; this is expected but could be noticeable. (4) The fix has not been tested with Turbopack or deployed Vercel build pipelines; additional verification is needed in those environments. For concurrency, ensure getServerSnapshot does not throw or depend on mutable client state. If a future version changes the noSSR behavior, re-evaluate the server snapshot logic.

Ecosystem Topology