From 5249a7e4e85b0aa6ddbb1fc5d7c9d4328f224208 Mon Sep 17 00:00:00 2001 From: naorpeled Date: Sun, 27 Sep 2026 22:26:39 +0300 Subject: [PATCH] perf: trim bundled.js to the 2.14.1 polyfill set, fix stale docs - Exclude the 10 core-js 3.0 modules with no counterpart in the @babel/polyfill set every released bundled.js shipped (concat/splice species fixes, Array#flat, Object.fromEntries, the __defineGetter__ family, NodeList#forEach): 156.1 -> 153.2 kB (49.3 -> 48.4 kB gzip). babel rejects unknown exclude names, so a core-js rename fails the build. - terser `passes: 5`, where output stops shrinking: es5.js 7.52 -> 7.49 kB gzip. - New test: bundled.js must not install polyfills no release ever shipped (fails on the previous master build, passes on 2.14.1 and this one). - README no longer claims named imports shrink the bundle; AGENTS.md describes the tsdown build and four-artifact acceptance tests; CONTRIBUTING points at the existing lint:check script. --- .github/CONTRIBUTING.md | 2 +- AGENTS.md | 4 ++-- README.md | 5 +++-- build/entries/bundled.js | 2 ++ test/acceptance/test-es5-runtime.js | 23 +++++++++++++++++++++++ tsdown.config.ts | 27 ++++++++++++++++++++++++++- 6 files changed, 57 insertions(+), 6 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 79b896a..fdd7de3 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -38,5 +38,5 @@ If you'd like to contribute a change to `bowser`, modify the files in `src/`, an $ npm install $ npm run build #build $ npm test #run tests -$ npm run lint #check lint rules +$ npm run lint:check #check lint rules ``` diff --git a/AGENTS.md b/AGENTS.md index caad724..e43eaf5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ Also supports User-Agent Client Hints via an optional `clientHints` parameter on ## Commands -- **Build**: `npm run build` (Webpack + Babel → `es5.js`, `bundled.js`) +- **Build**: `npm run build` (tsdown + Babel → UMD `es5.js` / `bundled.js` and ESM `bowser.mjs`, all ES5; see `tsdown.config.ts`) - **Test**: `npm test` (AVA — runs both `test/unit/` and `test/acceptance/`) - **Lint**: `npm run lint:check` / `npm run lint:fix` @@ -72,7 +72,7 @@ Returns `true`/`false` if the browser matches, or `undefined` if the browser isn ## Testing Details -- **Acceptance tests** (`test/acceptance/test-list-of-ua.js`): Loads `useragentstrings.yml`, tests every UA against both `src/bowser.js` (ES6) and `es5.js` (built) — so **build before running tests** if you changed source. +- **Acceptance tests** (`test/acceptance/test-list-of-ua.js`): Loads `useragentstrings.yml`, tests every UA against `src/bowser.js` and all three built artifacts (`es5.js`, `bundled.js`, `bowser.mjs`) — so **build before running tests** if you changed source. - **Unit tests** (`test/unit/`): Test individual Parser/Bowser/Utils methods. Use `new Parser(UA, true)` (skipParsing=true) for isolated method testing. - Test framework: AVA with Babel transpilation via `@babel/register`. diff --git a/README.md b/README.md index 3289a3b..e42f7ad 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,9 @@ import * as Bowser from "bowser"; // TypeScript import Bowser from "bowser"; // ES6 (and TypeScript with --esModuleInterop enabled) ``` -The ES module build also exposes `parse` and `getParser` as named exports, so -you can import just the part you use and let your bundler drop the rest: +The ES module build also exposes `parse` and `getParser` as named exports. +Detection needs every parser either way, so named imports are a matter of +style, not bundle size — but an import that ends up unused is dropped entirely: ```javascript import { getParser, parse } from "bowser"; diff --git a/build/entries/bundled.js b/build/entries/bundled.js index 240facd..391f046 100644 --- a/build/entries/bundled.js +++ b/build/entries/bundled.js @@ -11,6 +11,8 @@ * `core-js/es` rather than `core-js/stable`: `stable` is `es` plus every web * polyfill, and the extra ones — `URL`, `URLSearchParams`, `queueMicrotask` — * were never in any released `bundled.js`, yet cost ~20 kB (~7 kB gzipped). + * The few `es` modules `@babel/polyfill` did not have are excluded in + * `tsdown.config.ts` (`NOT_IN_BABEL_POLYFILL`). * `test/acceptance/test-es5-runtime.js` pins the `@babel/polyfill` surface. * * See `./es5.js` for why this only re-exports the default. diff --git a/test/acceptance/test-es5-runtime.js b/test/acceptance/test-es5-runtime.js index 8723df8..aaa8499 100644 --- a/test/acceptance/test-es5-runtime.js +++ b/test/acceptance/test-es5-runtime.js @@ -179,3 +179,26 @@ test('bundled.js still installs everything @babel/polyfill did', (t) => { vm.runInContext(fs.readFileSync(path.join(root, 'bundled.js'), 'utf8'), context); t.deepEqual(BABEL_POLYFILL_SURFACE.filter((ref) => !present(ref)), [], 'bundled.js no longer installs these'); }); + +/** + * The other side of the contract: polyfills no released `bundled.js` ever + * shipped stay out, because each one is pure weight for existing consumers. + * Adding one back should be a deliberate decision that updates this list, not + * a side effect of switching a core-js entry point. + */ +const NEVER_SHIPPED = [ + 'this.URL', 'this.URLSearchParams', 'this.queueMicrotask', + 'Array.prototype.flat', 'Object.fromEntries', 'NodeList.prototype.forEach', +]; + +test('bundled.js does not install polyfills no release ever shipped', (t) => { + const context = createEs5Context(); + vm.runInContext(NEVER_SHIPPED + .map((ref) => `try { delete ${ref}; } catch (e) {}`) + .join('\n'), context); + vm.runInContext('this.NodeList = function NodeList() {};', context); + const present = (ref) => vm.runInContext(`typeof (${ref})`, context) !== 'undefined'; + + vm.runInContext(fs.readFileSync(path.join(root, 'bundled.js'), 'utf8'), context); + t.deepEqual(NEVER_SHIPPED.filter(present), []); +}); diff --git a/tsdown.config.ts b/tsdown.config.ts index 90b8674..89c6f82 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -38,11 +38,34 @@ const legacyBabel = (useBuiltIns: false | 'entry') => babel({ modules: false, loose: true, useBuiltIns, - ...(useBuiltIns ? { corejs: '3' } : {}), + ...(useBuiltIns ? { corejs: '3', exclude: NOT_IN_BABEL_POLYFILL } : {}), targets: legacyTargets, }]], }); +/** + * The only core-js 3.0 `es` / web modules with no counterpart in the + * `@babel/polyfill` (core-js 2) set that every released `bundled.js` shipped: + * the `concat`/`splice` species fixes, `Array#flat`, `Object.fromEntries`, the + * Annex B `__defineGetter__` family and `NodeList#forEach`. Excluding them makes + * the payload exactly that set again, 2.8 kB (0.9 kB gzipped) smaller. + * + * babel rejects unknown names here, so a rename in core-js fails the build + * rather than silently shipping the module again. + */ +const NOT_IN_BABEL_POLYFILL = [ + 'es.array.concat', + 'es.array.flat', + 'es.array.splice', + 'es.array.unscopables.flat', + 'es.object.define-getter', + 'es.object.define-setter', + 'es.object.from-entries', + 'es.object.lookup-getter', + 'es.object.lookup-setter', + 'web.dom-collections.for-each', +]; + /** * Lowers the *emitted chunk* to ES5, after bundling and before terser. * @@ -108,6 +131,8 @@ const terser = () => ({ // IE 8: reserved words as property names must stay quoted. ie8: true, safari10: true, + // Output stops shrinking at 5 passes (measured; 6+ is byte-identical). + compress: { passes: 5 }, format: { // Keep the `/*!` banner. comments: /^!/,