From 65bb65d8df7e1ffc7a559320684972ebe98e4781 Mon Sep 17 00:00:00 2001 From: Oliver Lazoroski Date: Tue, 18 Aug 2026 09:54:04 +0200 Subject: [PATCH] perf(build): emit ESM per source module so consumers can tree-shake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 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 b91fd9aa6 (#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 55e76dca1f9e8a11c3f2ccdc88cc0686d9cf3ccc) --- AGENTS.md | 3 +++ package.json | 8 ++------ vite.config.ts | 6 ++++++ 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5dada4c5a..93f5d0fa5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -331,6 +331,9 @@ Vite 8 / Rolldown specifics baked into `vite.config.ts` (do not "simplify" these - Externals are regexes (`^dep(\/.+)?$`) so **subpath** imports (`dayjs/locale/de`) stay external; otherwise CJS `require()` glue leaks into the ESM output - No minification, sourcemaps on, target from `tsconfig.lib.json` (`es2020`), all deps/peerDeps externalized - Rolldown's strict CJS interop means default-imported CJS deps may need `.default` unwrapping at the call site +- `preserveModules` is on for the **ESM output only**, so `dist/es` mirrors `src` one file per module. Consumer bundlers drop whole unused modules first (guided by our `sideEffects` field) and only then attempt statement-level elimination — a single merged chunk leaves them nothing to drop. CJS stays chunked, since consumers do not tree-shake CJS +- `emptyOutDir` stays `false` because the four parallel build steps share `dist/`; `yarn build` already wipes it up front via `yarn clean` +- **`sideEffects` in `package.json` is load-bearing.** With one ESM file per module, consumer bundlers drop modules individually, so any module that runs code at import time must be listed there or it can be deleted out from under a consumer. Today that is `i18n/Streami18n` and `context/TranslationContext` (both call `Dayjs.extend` / `Dayjs.updateLocale` at module scope). Add an entry whenever you introduce module-level side effects ## Styling architecture diff --git a/package.json b/package.json index 858af3924..3bf9afb7b 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,8 @@ }, "sideEffects": [ "*.css", - "./dist/i18n/Streami18n.js" + "./dist/es/i18n/Streami18n.mjs", + "./dist/es/context/TranslationContext.mjs" ], "keywords": [ "chat", @@ -233,11 +234,6 @@ "built": true } }, - "browserslist": [ - ">0.2%", - "not ie <= 11", - "not op_mini all" - ], "packageManager": "yarn@4.15.0", "workspaces": [ "examples/*" diff --git a/vite.config.ts b/vite.config.ts index 633485c58..361aa9ebe 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -24,6 +24,7 @@ export default defineConfig({ 'mp3-encoder': resolve(__dirname, './src/plugins/encoders/mp3.ts'), }, }, + // `yarn build` already wipes dist up front via `yarn clean` emptyOutDir: false, outDir: 'dist', minify: false, @@ -43,6 +44,11 @@ export default defineConfig({ chunkFileNames: `${dir}/[name].[hash].${extension}`, entryFileNames: `${dir}/[name].${extension}`, hashCharacters: 'hex', + // Emit the ESM build as one file per source module. Consumer bundlers + // drop whole modules (guided by our package.json `sideEffects`) before + // they attempt statement-level elimination. The CJS build stays chunked, + // as CJS is not tree-shaken by consumers either way. + preserveModules: format === 'es', }; }), },