perf(build): emit ESM per source module so consumers can tree-shake - #3275
Conversation
### 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. (cherry picked from commit 55e76dc)
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
Included review availability: Your plan provides up to 4 included reviews per hour; 3 remain after this review. 📝 WalkthroughWalkthroughThe build configuration now preserves ESM modules while retaining chunked CJS output. Package metadata marks ESM i18n modules as side-effectful and no longer defines a package-level browserslist configuration. AGENTS.md documents these build and metadata requirements. ChangesBuild and package metadata
Estimated code review effort: 2 (Simple) | ~10 minutes Merge Risk: ⚪ Minimal · up to This build-output change has no actionable merge-blocking risk remaining and is merge-ready after normal checks and review. Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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 |
|
Size Change: +186 kB (+20.93%) 🚨 Total Size: 1.08 MB 📦 View Changed
ℹ️ View Unchanged
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #3275 +/- ##
==========================================
+ Coverage 85.29% 85.30% +0.01%
==========================================
Files 509 509
Lines 15990 15990
Branches 5042 5042
==========================================
+ Hits 13638 13641 +3
+ Misses 2352 2349 -3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
## [14.12.0](v14.11.1...v14.12.0) (2026-08-28) ### Features * allow sending messages with pending uploads ([#3277](#3277)) ([cf66d0e](cf66d0e)) ### Performance Improvements * **build:** emit ESM per source module so consumers can tree-shake ([#3275](#3275)) ([d70b8f9](d70b8f9)), closes [#3269](#3269) [#2483](#2483)
|
🎉 This PR is included in version 14.12.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Goal
Backport of #3269 (merged to
release-v15) tomaster, 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
sideEffectsfield, 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 fromstream-chat-reactpulled 172.8 kB gzip.Implementation details
preserveModuleson the ESM output only, sodist/esmirrorssrcone file per module (9 files -> 514). CJS stays chunked, since consumers do not tree-shake CJS.package.jsonexportsmap are unchanged.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 (fix: address the circular dependencies among TranslationContext and Streami18n #2483) was inert. It now names the two modules that genuinely run code at import time —dist/es/i18n/Streami18n.mjsanddist/es/context/TranslationContext.mjs, both of which callDayjs.extend/Dayjs.updateLocaleat 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.browserslistfield — nothing in the repo reads it.AGENTS.md.Measured consumer impact
A throwaway consumer app resolving
stream-chat-reactthrough the realexportsmap, built with Vite 8 / Rolldown, minified, one scenario per entry,react/react-dom/stream-chatand the other peer deps external. Same lockfile and same minifier on both sides.{ escapeRegExp }){ Avatar }{ Chat }{ Chat, Channel, MessageList }stream-chat-react/channel-detailstream-chat-react/emojisimport *)(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:Chat->useChat->Streami18nstatically imports all 12 locale JSONs and assembles them into a runtimeresourcesmap, so no bundler can drop the 11 an app does not use. Measured cost: 125.5 kB gzip, 67% ofChatand 30% of the three-component bundle.useComponentContextIconsdoesimport * as DEFAULT_ICONS, andIcons/icons.tsxis a single module of 87 unprovablecreateIcon(...)calls. Importing one icon costs 17.8 kB gzip; importing three costs the same. Fixing this needspreserveModulesin place first — without module boundaries in the output, splitting the icon set buys nothing.Cost
The published ESM output grows:
dist/esgoes 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/cjsis unchanged.UI Changes
None, build output only.