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:
|
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.