perf(build): emit ESM per source module so consumers can tree-shake - #3269
Conversation
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.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the βοΈ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
Codecov Reportβ
All modified and coverable lines are covered by tests. 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. π New features to boost your workflow:
|
β¦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.
Goal
The ESM build collapsed the whole source tree into a single chunk, which defeats tree-shaking in consumer apps: importing only
Chatpulled 405kB.Implementation details
preserveModuleson the ESM output only, sodist/esmirrorssrcone file per module (11 files -> 516). Consumer bundlers drop unused modules first, guided by oursideEffectsfield, and only then attempt statement-level elimination -- a merged chunk leaves nothing to drop. CJS stays chunked, since consumers do not tree-shake CJS.package.jsonexports map are unchanged.import { Chat }405.1 -> 33.1 kB,Chat + Channel + MessageList451.7 -> 332.6 kB,stream-chat-react/slot-layout426.1 -> 160.4 kB. Whole-SDK import unchanged. Published package grows ~199kB raw.browserslistfield: 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 whyemptyOutDirmust stayfalse.Verified:
yarn buildgreen, 231 test files / 2825 tests pass, ESM and CJS entries both load with 611 exports and a single sharedChatContext.UI Changes
None, build output only.