Skip to content

fix(build): preserve ESM modules so bundlers can tree-shake unused Firebase SDKs - #838

Open
jhuleatt wants to merge 2 commits into
mainfrom
fix/esm-tree-shaking
Open

jhuleatt wants to merge 2 commits into
mainfrom
fix/esm-tree-shaking

Conversation

@jhuleatt

@jhuleatt jhuleatt commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Previously, vite.config.ts bundled all of src/* into a single monolithic dist/index.js that imported every Firebase product (firebase/firestore, firebase/auth, firebase/database, firebase/storage, firebase/functions, firebase/remote-config, firebase/performance) at the top of one file. Because all product imports lived inside a single pre-bundled chunk, consumer bundlers could not tree-shake unused Firebase SDKs or split them behind React.lazy.

This PR:

  1. Emits one ES module per source file (preserveModules: true, preserveModulesRoot: 'src', entryFileNames: '[name].js') alongside dist/index.umd.cjs.
  2. Externalizes rxjs, rxjs/operators, and rxfire/* (declared in dependencies) so consumer bundlers resolve and tree-shake them directly instead of inlining a duplicate copy.
  3. Updates "rxjs" in dependencies from "^6.6.3 || ^7.0.1" to "^7.1.0" so external resolution always provides RxJS ≥ 7.1 (required by share({ resetOnRefCountZero: () => timer(...) })).
  4. Adds "sideEffects": false to package.json.
  5. Sets "target": "es2024" in tsconfig.json (Baseline-certified) so TypeScript does not inject virtual _tslib.js helper chunks under preserveModules: true.

Bundle Size Impact

Measured with Vite 8.3.5 production builds against firebase@13.1.0 (gzip -9 of the emitted application bundle):

Consumer App Raw Firebase Baseline (No ReactFire) reactfire@4.2.6 (main) With This PR (preserveModules: true) Savings vs. 4.2.6
Auth-only (useUser) 96.0 KB (95,988 B) 269.1 KB (269,074 B) — bundles Firestore, RTDB, Storage, Perf, Functions, RC 103.8 KB (103,791 B) — only firebase/auth + rxjs + rxfire/auth -165.3 KB gzip (-61.4%)
Firestore-only (useFirestoreCollection) 206.8 KB (206,792 B) 279.0 KB (279,049 B) — bundles Auth, RTDB, Storage, Perf, Functions, RC 214.5 KB (214,541 B) — only firebase/firestore + rxjs + rxfire/firestore -64.5 KB gzip (-23.1%)

Backwards Compatibility & Release Notes

  • Identical Public Exports (74 / 74): Both import * as RF from 'reactfire' (dist/index.js) and require('reactfire') (dist/index.umd.cjs) expose the exact same 74 named exports as v4.2.6.
  • Unchanged Entrypoints: package.json "main" (dist/index.umd.cjs), "exports" ("." -> { "import": "./dist/index.js", "require": "./dist/index.umd.cjs" }), and "typings" (dist/index.d.ts) are unchanged.
  • Safe "sideEffects": false: All globalThis caches (_reactFirePreloadedObservables, _reactFireFirestoreQueryCache, _reactFireDatabaseCachedQueries) are initialized inside the modules that read them (useObservable.ts, firestore.tsx, database.tsx), so dropping an unused product module (e.g., dist/database.js or dist/performance.js) never drops state needed by used modules.
  • Release Note (rxjs ^7.1.0): Because rxjs is a regular dependency (and v4.2.6 already bundled rxjs@7.8.1), runtime behavior and npm install are unchanged. However, TypeScript applications still on rxjs@6 that pass their own custom Observable instances directly into useObservable or preloadObservable will need to upgrade to rxjs@^7.1.0 so TypeScript's Observable types match.

Closes #489
Closes #488
Refs #480

@jhuleatt
jhuleatt requested a review from tyler-reitz October 27, 2026 18:57

@tyler-reitz tyler-reitz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes for one thing, which only shows up together with #839.

Tree-shaking works as described: on Vite 8.3.5 with firebase 12, an auth-only app goes from 240 KB to 82 KB gzipped with no Firestore, Database, Storage or Functions code. The 74 ESM and CJS exports match main, and #835's import-time registration survives "sideEffects": false.

Blocking: rxjs 6 apps. Externalizing rxjs and rxfire splits them: in an rxjs 6.6.7 app, reactfire's code loads a nested rxjs 7.8.2 while rxfire loads the app's 6.6.7, and teardown doesn't cross that boundary. On its own that's invisible, since shareReplay never tears down, but with #839 merged too, useUser and useFirestoreDocData each leak one listener per remount (1,2,3,4,5 after five cycles; 0 on rxjs 7). Making rxjs a ^7.1.0 peer dependency turns this into an install error. Whichever of this and #839 lands first should carry it, plus a !, and the body's "runtime behavior and npm install are unchanged" needs updating for these apps.

Non-blocking: target: es2021 also avoids the tslib chunk and keeps dist at ES2020; es2024 raises it to ES2022 (native class fields), one level above main.

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.

@firebase/database included in the final build while I only use firestore Reactfire preventing lazy loading of firebase modules

2 participants