Single-Root Islands
This feature is experimental and off by default. Its behaviour and configuration may change in a future release.
What it does
Islands (withHydration) normally hydrate as separate React roots: the server renders the page as one tree, but each island calls its own hydrateRoot on the client. The island therefore sits at a different position, in a different tree, than the server rendered it in — with three consequences:
useIdnever matches. React derives ids from a component's position relative to its render root, so every id the server rendered inside an island differs from the one the client computes. Anything that emitsid/aria-controlspairs into SSR'd HTML — Radix Popover, Dialog, Tooltip, … — produces a hydration mismatch, which projects work around by hardcoding ids per call site.- Context has to be bridged. The hydrator captures the surrounding context values and re-provides them inside the island root, re-rendering it whenever they change.
- Every island root has to be torn down again when the SPA takes over.
With Single-Root Islands enabled, the page keeps one React root. Each island becomes a Suspense boundary whose content is gated behind its hydration trigger: React leaves the boundary dehydrated — server DOM untouched, island chunk not requested — until the trigger fires, then hydrates it in place, inside the page tree. Positions match, so useId matches; context reaches the island natively; there is no island root to unmount.
{
"experimental": {
"singleRootIslands": true,
"eventReplay": true
}
}
What you get
- No hydration mismatches from
useId. After enabling the flag you can remove hardcoded id workarounds around Radix-style components inside islands. - The same lazy-loading behaviour. An island the user never reaches never downloads its chunk.
Loadable({ preloaded: true })still emits itsmodulepreloadhint andprefetched: truestill prefetches — the fetch is warmed, only the execution waits for the trigger. - Unchanged authoring API.
withHydrationand its options (hydrationTrigger,onlyClient,onlyServer,autoHeight,height…) behave exactly as before; no call site changes. - SSR assets unchanged. Islands still render server-side, so each one's script and CSS are still injected.
Requirements and constraints
eventReplay is required for click / hover islands
React does not deliver an interaction that targets a not-yet-hydrated island to any handler, and it does not replay it once the island hydrates. The interaction survives only because the Event Replay recorder buffers it and re-applies it afterwards. Enable experimental.eventReplay together with this flag unless every island uses the default visible trigger.
Under this flag the recorder attaches its listeners once, above the page root, instead of per island — the flag switches that automatically.
A page-wide state update can blank dormant islands
If a user interaction updates state that a provider above the islands owns (a cart context, a locale switch, …), React cannot keep the not-yet-hydrated boundaries around: it discards their server HTML and shows their placeholder until each island's trigger has fired and its chunk has loaded. This affects every dormant island under that provider, not only the ones consuming the changed value.
Two things soften it:
- The placeholder reserves the height the island had (
data-arc-island-fallback), so the page does not jump — measured once when the island mounts, so a viewport resize while an island is still dormant can leave it stale. - Wrapping such global updates in
startTransitionavoids the effect entirely; React then keeps serving the server HTML until the island is ready.
Consider this before enabling the flag on a page whose above-the-fold providers change on interaction.
Other notes
withComponentHydration(the class-component variant) still uses the multi-root hydrator. Avoid it foruseId-sensitive content while the flag is on.- Each island is its own Suspense boundary on the server too, so the SSR renderer waits for all boundaries before it flushes (
onAllReady) and lifts React's progressive chunk budget (progressiveChunkSize). Without those two, React writes a boundary as a collapsed placeholder plus a$RC()reveal script — either because it was still pending when the surrounding markup was written, or because the completed boundary no longer fit the 12.8 kB chunk budget — and the browser paints an empty frame before the swap. Nothing is streamed to the browser (the response is buffered into a string), so both are pure cost: the markup is the same complete document the multi-root hydrator produced, with the boundary markers the client gate needs. - The implementation is selected at build time from this flag, so only the one you use is shipped: with the flag off, projects ship none of the gating machinery. The same value is injected into the client and server bundles, the Storybook builder and
archibald test, so all of them exercise the hydrator the app actually ships.
How it works
- Server — the island renders inside the page tree, wrapped in a Suspense boundary, and reports its chunk so the SSR assets are injected as usual.
- Client — during hydration React reaches the boundary, finds its content behind an unresolved gate, and leaves the boundary dehydrated with the server DOM intact. Nothing is downloaded.
- Trigger fires —
visible(viewport),click/hover(delegated from the document, because React itself never sees those events on a dormant island) or an SPA navigation resolves the gate, the chunk loads, and React hydrates that boundary inside the page root. - Post-commit — the island's marker flips to hydrated, buffered interactions are replayed, and
hydration:readyis dispatched on the island container.