From 43f76fffdfb0d3566e24859c7ccea7e1859153ae Mon Sep 17 00:00:00 2001 From: anurag629 Date: Thu, 29 Oct 2026 21:05:08 +0530 Subject: [PATCH] perf(widget): rename more private members in the minified builds, checked by a test --- .changeset/widget-mangle-private.md | 5 +++ CONTRIBUTING.md | 2 +- packages/widget/mangle.ts | 34 ++++++++++++++++ packages/widget/tests/mangle.test.ts | 59 ++++++++++++++++++++++++++++ packages/widget/vite.config.ts | 3 +- packages/widget/vite.panel.config.ts | 3 +- 6 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 .changeset/widget-mangle-private.md create mode 100644 packages/widget/mangle.ts create mode 100644 packages/widget/tests/mangle.test.ts diff --git a/.changeset/widget-mangle-private.md b/.changeset/widget-mangle-private.md new file mode 100644 index 00000000..a40c47f8 --- /dev/null +++ b/.changeset/widget-mangle-private.md @@ -0,0 +1,5 @@ +--- +"@chatcops/widget": patch +--- + +The panel is about 240 B smaller gzipped (16.6 kB) and the single-file build about 300 B (18.62 kB): the minifier now also renames 55 more private class members, each checked to be used only inside its class. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 036da9bc..27fda05b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -210,7 +210,7 @@ ANTHROPIC_API_KEY=sk-... npx tsx src/examples/express-server.ts `npx` downloads `tsx` the first time. -The widget has a size budget, gzipped: 5 kB for the script tag loader (`dist/chatcops.min.js`), 17 kB for the chat panel it loads when the chat opens (`dist/chatcops.panel.js`), 2 kB for the pre-chat form chunk, 19 kB for the single-file build (`dist/chatcops.full.min.js`) and 30 kB for the unminified ESM build. CI checks them with `pnpm size` after a build (limits in `.size-limit.json`). +The widget has a size budget, gzipped: 5 kB for the script tag loader (`dist/chatcops.min.js`), 17 kB for the chat panel it loads when the chat opens (`dist/chatcops.panel.js`), 2 kB for the pre-chat form chunk, 19 kB for the single-file build (`dist/chatcops.full.min.js`) and 30 kB for the unminified ESM build. CI checks them with `pnpm size` after a build (limits in `.size-limit.json`). To save bytes, the minifier renames the private class members listed in `packages/widget/mangle.ts`. A name can go on that list only if the code touches it through `this` and nowhere else, which `tests/mangle.test.ts` checks. A config field, a request body key or a DOM property renamed there would break only the CDN build. ### Browser tests diff --git a/packages/widget/mangle.ts b/packages/widget/mangle.ts new file mode 100644 index 00000000..f71d336e --- /dev/null +++ b/packages/widget/mangle.ts @@ -0,0 +1,34 @@ +/** + * Private class members the minifier renames to short names in the panel + * and single-file builds, to keep them in their size budgets. Renaming a + * property renames it everywhere in the bundle, so a name that is also a + * config field, a key in a request body, a stored field or a DOM property + * would break the build that sites load from the CDN, and only there. + * tests/mangle.test.ts checks every name in CHECKED. + */ + +/** Names from before the check. Internal on both ends, and the e2e suite runs against the minified build. */ +const EARLIER = [ + 'mounted', 'fab', 'panel', 'bubble', 'preChatFormEl', 'namespace', 'hydrated', 'isStreaming', 'destroyed', + 'abortController', 'retryRequests', 'eventHandlers', 'containerEl', 'autoOpenTimer', 'unreadReplies', + 'savedOverflow', 'shadow', 'unreadLabel', 'openLabel', 'closeLabel', 'badge', 'el', 'inline', + 'messagesContainer', 'isVisible', 'typingEl', 'suggestionsEl', 'area', 'textarea', 'sendBtn', 'onSend', 'timerId', +]; + +/** + * Declared `private`, and only ever read or written as `this.`: never + * an object key, never on another object, never in a string. + */ +export const CHECKED = [ + 'announcement', 'autoResize', 'cleanupEmptyActionContainers', 'clearError', 'createActionButton', + 'createActionsContainer', 'createField', 'createMessageElement', 'createRequest', 'disclosureEl', 'endStream', + 'ensureActionsContainer', 'errorElements', 'escapeHtml', 'fieldElements', 'findLatestRegeneratableAssistant', + 'findMessage', 'findUserMessageForAssistant', 'getErrorMessage', 'handleKeydown', 'handlePreChatSubmit', + 'handleRegenerate', 'handleRetry', 'handleSend', 'handleSubmit', 'isStale', 'isValidEmail', 'lockScroll', + 'markFailed', 'nearBottom', 'pageData', 'panelLabels', 'renderDisclosure', 'renderHeader', 'renderMessageElement', + 'renderStream', 'renderSuggestions', 'resolveContainer', 'resolveErrorType', 'restoreScroll', 'saveHistory', + 'scrollToBottom', 'sendToServer', 'sessionKey', 'setHidden', 'showChatView', 'showError', + 'showLatestRegenerateButton', 'streams', 'submitBtn', 'ttlMs', 'updateSubmitState', 'userDataSentWith', 'userFromSite', +]; + +export const MANGLED_PROPERTIES = new RegExp(`^(?:${[...EARLIER, ...CHECKED].join('|')})$`); diff --git a/packages/widget/tests/mangle.test.ts b/packages/widget/tests/mangle.test.ts new file mode 100644 index 00000000..c56ffeab --- /dev/null +++ b/packages/widget/tests/mangle.test.ts @@ -0,0 +1,59 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { CHECKED } from '../mangle.js'; + +// The minifier renames these everywhere in the bundle. Each one has to be a +// private member that the code only touches through `this`, or the CDN build +// breaks where the unit tests (which run on src) can't see it. + +const root = join(__dirname, '..'); +const files: string[] = []; +const walk = (dir: string) => { + for (const name of readdirSync(dir)) { + const path = join(dir, name); + if (statSync(path).isDirectory()) walk(path); + else if (name.endsWith('.ts')) files.push(path); + } +}; +walk(join(root, 'src')); +const source = files.map((file) => readFileSync(file, 'utf8')).join('\n'); +const api = readFileSync(join(root, '../../api/widget.api.md'), 'utf8'); +const domPrototypes = [ + window, + Document.prototype, + ShadowRoot.prototype, + HTMLElement.prototype, + HTMLButtonElement.prototype, + HTMLTextAreaElement.prototype, + HTMLInputElement.prototype, + HTMLFormElement.prototype, + HTMLSelectElement.prototype, + Storage.prototype, + Response.prototype, + AbortController.prototype, +]; + +describe.each(CHECKED)('the mangled property %s', (name) => { + it('is declared private', () => { + expect(source).toMatch(new RegExp(`^\\s+private (?:readonly |async )*${name}\\b`, 'm')); + }); + + it('is only used through this', () => { + const elsewhere = source.match(new RegExp(`.{0,30}(? { + const lines = source.split('\n').filter((line) => !/^\s+private\b/.test(line) && !/^\s*(\/\/|\*)/.test(line)); + const keyed = lines.find((line) => new RegExp(`(^|[^.\\w])${name}\\??\\s*:`).test(line.replace(/'[^']*'|"[^"]*"|`[^`]*`/g, ''))); + expect(keyed).toBeUndefined(); + expect(source.replaceAll(`this.${name}`, '')).not.toMatch(new RegExp(`[{,]\\s*${name}\\s*[,}]`)); + expect(source).not.toMatch(new RegExp(`['"\`]${name}['"\`]`)); + }); + + it('is not part of the public API or a DOM name', () => { + expect(api).not.toMatch(new RegExp(`\\b${name}\\b`)); + expect(domPrototypes.filter((proto) => name in proto)).toEqual([]); + }); +}); diff --git a/packages/widget/vite.config.ts b/packages/widget/vite.config.ts index 7ad9d65d..2e3a2aba 100644 --- a/packages/widget/vite.config.ts +++ b/packages/widget/vite.config.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs'; import { defineConfig } from 'vite'; import { resolve } from 'path'; +import { MANGLED_PROPERTIES } from './mangle'; const { version } = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf8')) as { version: string }; @@ -21,7 +22,7 @@ export default defineConfig({ compress: { passes: 2 }, mangle: { properties: { - regex: /^(?:mounted|fab|panel|bubble|preChatFormEl|namespace|hydrated|isStreaming|destroyed|abortController|retryRequests|eventHandlers|containerEl|autoOpenTimer|unreadReplies|savedOverflow|shadow|unreadLabel|openLabel|closeLabel|badge|el|inline|messagesContainer|isVisible|typingEl|suggestionsEl|area|textarea|sendBtn|onSend|timerId)$/, + regex: MANGLED_PROPERTIES, }, }, }, diff --git a/packages/widget/vite.panel.config.ts b/packages/widget/vite.panel.config.ts index 741bd95e..ae509053 100644 --- a/packages/widget/vite.panel.config.ts +++ b/packages/widget/vite.panel.config.ts @@ -1,6 +1,7 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { defineConfig } from 'vite'; import { resolve } from 'node:path'; +import { MANGLED_PROPERTIES } from './mangle'; // One translation per file in src/locales (de.ts, pt.ts, ...), each built to // dist/locales/.js for the loader to fetch next to the panel. @@ -27,7 +28,7 @@ export default defineConfig({ compress: { passes: 2 }, mangle: { properties: { - regex: /^(?:mounted|fab|panel|bubble|preChatFormEl|namespace|hydrated|isStreaming|destroyed|abortController|retryRequests|eventHandlers|containerEl|autoOpenTimer|unreadReplies|savedOverflow|shadow|unreadLabel|openLabel|closeLabel|badge|el|inline|messagesContainer|isVisible|typingEl|suggestionsEl|area|textarea|sendBtn|onSend|timerId)$/, + regex: MANGLED_PROPERTIES, }, }, },