Skip to content

perf(build): emit ESM per source module so consumers can tree-shake - #3269

Merged
oliverlaz merged 1 commit into
release-v15from
perf/esm-preserve-modules
Aug 18, 2026
Merged

perf(build): emit ESM per source module so consumers can tree-shake#3269
oliverlaz merged 1 commit into
release-v15from
perf/esm-preserve-modules

Conversation

@oliverlaz

@oliverlaz oliverlaz commented Aug 18, 2026

Copy link
Copy Markdown
Member

Goal

The ESM build collapsed the whole source tree into a single chunk, which defeats tree-shaking in consumer apps: importing only Chat pulled 405kB.

Implementation details

  • preserveModules on the ESM output only, so dist/es mirrors src one file per module (11 files -> 516). Consumer bundlers drop unused modules first, guided by our sideEffects field, and only then attempt statement-level elimination -- a merged chunk leaves nothing to drop. CJS stays chunked, since consumers do not tree-shake CJS.
  • Entry filenames and the package.json exports map are unchanged.
  • Measured consumer bundles: import { Chat } 405.1 -> 33.1 kB, Chat + Channel + MessageList 451.7 -> 332.6 kB, stream-chat-react/slot-layout 426.1 -> 160.4 kB. Whole-SDK import unchanged. Published package grows ~199kB raw.
  • Dropped the browserslist field: nothing in the repo reads it and its query contradicted the ES2022 output floor. Fixed the stale ES2020 target note in CLAUDE.md and recorded why emptyOutDir must stay false.

Verified: yarn build green, 231 test files / 2825 tests pass, ESM and CJS entries both load with 611 exports and a single shared ChatContext.

UI Changes

None, build output only.

The ESM build collapsed the whole source tree into a single chunk, which
defeats tree-shaking downstream. Consumer bundlers drop unused modules
first, guided by our package.json `sideEffects`, and only then attempt
statement-level elimination -- a merged chunk leaves nothing to drop.
Importing just `Chat` pulled 405kB; per-module output brings it to 33kB.

CJS stays chunked, since consumers do not tree-shake CJS either way.

Also drop the `browserslist` field: nothing in the repo reads it and its
query contradicted the ES2022 output floor. Fix the stale ES2020 target
note in CLAUDE.md, and record why `emptyOutDir` must stay false.
@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

βš™οΈ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: eb1681ed-2523-4b0d-b9e3-c4873ab0ab41

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • πŸ” Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❀️ Share

Comment @coderabbitai help to get the list of available commands.

@oliverlaz
oliverlaz had a problem deploying to Vite Example Development (Preview) August 18, 2026 07:33 — with GitHub Actions Error
@oliverlaz
oliverlaz had a problem deploying to Vite Example Public (Preview) August 18, 2026 07:33 — with GitHub Actions Failure
@codecov

codecov Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

βœ… All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (release-v15@9194215). Learn more about missing BASE report.

Additional details and impacted files
@@              Coverage Diff               @@
##             release-v15    #3269   +/-   ##
==============================================
  Coverage               ?   84.46%           
==============================================
  Files                  ?      526           
  Lines                  ?    15939           
  Branches               ?     5108           
==============================================
  Hits                   ?    13463           
  Misses                 ?     2476           
  Partials               ?        0           

β˜” View full report in Codecov by Harness.
πŸ“’ Have feedback on the report? Share it here.

πŸš€ New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • πŸ“¦ JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@oliverlaz
oliverlaz merged commit 55e76dc into release-v15 Aug 18, 2026
8 of 10 checks passed
@oliverlaz
oliverlaz deleted the perf/esm-preserve-modules branch August 18, 2026 07:54
oliverlaz added a commit that referenced this pull request Aug 24, 2026
…3275)

### Goal

Backport of #3269 (merged to `release-v15`) to `master`, for the ongoing
v14 release.

The ESM build collapsed the source tree into 9 chunks, which left
consumer bundlers nothing to drop. Tree-shaking works at module
granularity first β€” a bundler discards whole unused modules, guided by
our `sideEffects` field, before it attempts statement-level elimination.
When the entire SDK arrives as one merged chunk, that first pass has no
boundaries to cut on, so importing a single utility from
`stream-chat-react` pulled 172.8 kB gzip.

### Implementation details

- `preserveModules` on the **ESM output only**, so `dist/es` mirrors
`src` one file per module (9 files -> 514). CJS stays chunked, since
consumers do not tree-shake CJS.
- Entry filenames and the `package.json` `exports` map are unchanged.
- Corrected `sideEffects`. It pointed at `./dist/i18n/Streami18n.js`, a
path this build has not emitted since the output layout changed, so the
guard added in b91fd9a (#2483) was inert. It now names the two modules
that genuinely run code at import time β€” `dist/es/i18n/Streami18n.mjs`
and `dist/es/context/TranslationContext.mjs`, both of which call
`Dayjs.extend` / `Dayjs.updateLocale` at module scope. This matters far
more with 514 individually-droppable modules than it did with 9. It
moves no bytes today (both are always reachable); it is the guard for
later.
- Dropped the unused `browserslist` field β€” nothing in the repo reads
it.
- Recorded all three decisions in `AGENTS.md`.

#### Measured consumer impact

A throwaway consumer app resolving `stream-chat-react` through the real
`exports` map, built with Vite 8 / Rolldown, minified, one scenario per
entry, `react` / `react-dom` / `stream-chat` and the other peer deps
external. Same lockfile and same minifier on both sides.

| consumer imports | before | after |
| --- | --- | --- |
| one utility (`{ escapeRegExp }`) | 172.8 kB | **0.1 kB** |
| `{ Avatar }` | 86.1 kB | **19.8 kB** |
| `{ Chat }` | 422.5 kB | **205.5 kB** |
| `{ Chat, Channel, MessageList }` | 432.7 kB | **419.0 kB** |
| `stream-chat-react/channel-detail` | 150.9 kB | **149.6 kB** |
| `stream-chat-react/emojis` | 173.4 kB | **168.9 kB** |
| whole SDK (`import *`) | 465.7 kB | 465.8 kB |

(gzip, all chunks summed.)

Large win for narrow imports, no regression anywhere. Note the honest
part: a full-featured chat (`Chat` + `Channel` + `MessageList`) only
moves 3%, because 380 of the 381 SDK modules in that bundle are
genuinely reachable from those three entry points. That is architecture,
not build config, and two follow-ups are what move it:

- **Translation catalogs.** `Chat` -> `useChat` -> `Streami18n`
statically imports all 12 locale JSONs and assembles them into a runtime
`resources` map, so no bundler can drop the 11 an app does not use.
Measured cost: 125.5 kB gzip, 67% of `Chat` and 30% of the
three-component bundle.
- **Icons.** `useComponentContextIcons` does `import * as
DEFAULT_ICONS`, and `Icons/icons.tsx` is a single module of 87
unprovable `createIcon(...)` calls. Importing one icon costs 17.8 kB
gzip; importing three costs the same. Fixing this needs
`preserveModules` in place first β€” without module boundaries in the
output, splitting the icon set buys nothing.

#### Cost

The published ESM output grows: `dist/es` goes 9 files -> 514, 1784.8 ->
2037.8 kB raw and 421.7 -> 613.7 kB gzip, so roughly +192 kB on the
tarball. That is install-time only; nothing a browser downloads.
`dist/cjs` is unchanged.

### UI Changes

None, build output only.
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.

2 participants