Skip to content

fix(react): avoid bundling hydrate module into client graph#797

Merged
OS-jacobbell merged 2 commits into
stenciljs:mainfrom
Burzmalian:gfohl/react-next-bundling-fix
May 12, 2026
Merged

fix(react): avoid bundling hydrate module into client graph#797
OS-jacobbell merged 2 commits into
stenciljs:mainfrom
Burzmalian:gfohl/react-next-bundling-fix

Conversation

@Burzmalian
Copy link
Copy Markdown
Contributor

@Burzmalian Burzmalian commented Apr 28, 2026

Pull request checklist

  • Tests for the changes have been added (updated generator test snapshots)
  • Docs have been reviewed and added / updated if needed (no user-facing API docs affected)
  • Build (`npm run build`) was run locally for affected output targets
  • Tests (`npm test`) were run locally and passed
  • Prettier (`npm run prettier`) was run locally and passed

Pull request type

  • Bugfix
  • Feature
  • Code style update (formatting, renaming)
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • Documentation content changes
  • Other (please describe):

What is the current behavior?

In the runtime-based Next.js SSR setup, the Stencil hydrate module (output of `dist-hydrate-script`) ends up in the client bundle and is fetched by the browser on every page load, even though it is only ever meant to run on the server.

The generated `components.server.ts` file starts with `'use client'` and contains, once per component:

```ts
hydrateModule: import('component-library/hydrate') as Promise,
```

Because this sits at module top level inside a `'use client'` module, webpack compiles the dynamic import into an eager chunk request (`webpack_require.e("..._hydrate_index_mjs")`) that fires during module initialization on the client. The `typeof window !== 'undefined'` guard inside `createComponent` short-circuits execution of the hydrate path on the client, but runs too late to prevent the chunk fetch. The hydrate module is downloaded and evaluated in the browser even though none of its exports are ever called there.

Observable via `ANALYZE=true next build` in `example-project/next-15-runtime-based` (hydrate chunk appears in `.next/analyze/client.html`) and via DevTools Network tab on `next start` (a request for `_app-pages-browser_component-library_hydrate_index_mjs.js` fires on every page that imports from `component-library-react/next`).

Issue URL: n/a (reported via internal discussion)

What is the new behavior?

  • The React output-target generator now emits the hydrate `import()` behind a static `typeof window === 'undefined'` ternary, so webpack/Turbopack/Vite can dead-code-eliminate the dynamic import from the client build. On the server build the ternary folds to the import and runs exactly as before; on the client build the ternary folds to `undefined` and the `webpack_require.e(...)` call is removed entirely.
  • `createComponent` in `packages/react/src/runtime/ssr.tsx` now accepts `hydrateModule: Promise | undefined` (non-breaking widening). A defensive error is thrown on the server branch if the module is missing so misconfigured consumers get a clear message rather than `await undefined`.
  • Regenerated `example-project/component-library-react/src/components.server.ts` so every `createComponent` call reflects the new template.
  • Drive-by: split the `next-15-runtime-based` example's `start` script into `dev` (`next dev`) and `start` (`next start`), matching Next's conventions so the production server can actually be exercised.

Verification on `next build` in `example-project/next-15-runtime-based`:

Before After
Client has hydrate chunk Yes (`_app-pages-browser_component-library_hydrate_index_mjs.js`) Gone
Server has hydrate chunk Yes Still present (`.next/server/chunks/951.js`)
`hydrateModule` refs in any client page chunk Many `webpack_require.e(...)` calls Zero

SSR markup verified intact via `curl`: `<template shadowrootmode="open">` and serialized complex props (Map, Set, Symbol, Infinity) still render on the server side.

All 41 React output-target unit tests pass.

Does this introduce a breaking change?

  • Yes
  • No

`createComponent`'s `hydrateModule` option type is widened from `Promise` to `Promise | undefined`. This is a non-breaking widening (accepting `undefined` where it previously required a Promise is strictly more permissive) and direct callers outside the generator are extremely rare.

Other information

Follow-up worth considering (out of scope for this PR):

  • The Vue output target generator in `packages/vue/src/generate-vue-component.ts` emits `hydrateModule: import('${outputTarget.hydrateModule}')` in the argument list of `defineStencilSSRComponent`, behind a runtime `globalThis.window ?` ternary rather than a static `typeof` check. Whether Rollup/Vite DCE that reliably should be verified against the Nuxt example. If the chunk also leaks to the client there, the same guarded-ternary pattern would apply.
  • A longer-term architectural cleanup would split `components.server.ts` into true server/client files with `react-server` conditional exports; the `'use client'` directive on a module containing server-only references is the root cause this PR works around.

@Burzmalian Burzmalian requested a review from a team as a code owner April 28, 2026 17:26
@davidpett
Copy link
Copy Markdown
Contributor

closes #766

@Burzmalian Burzmalian force-pushed the gfohl/react-next-bundling-fix branch from 1e2eb0b to aa2b9df Compare May 5, 2026 13:52
@Armand-Lluka
Copy link
Copy Markdown
Contributor

For what it's worth, I've also tested this on my side and can confirm that this prevents the hydrate bundle being sent to the client @davidpett.

Would love to see this make it in as well 👍

Copy link
Copy Markdown
Contributor

@OS-jacobbell OS-jacobbell left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR, looks like the right solution!

@OS-jacobbell OS-jacobbell merged commit 865cd52 into stenciljs:main May 12, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants