From 7851d61f1dd6c8915b34afb5ed0a03fceaeaa869 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sat, 24 Oct 2026 12:51:26 -0700 Subject: [PATCH 01/11] feat(ci): the PR size report measures per-import JS and CSS and flags touch-only budget overruns analyze-pr.js measures each changed component's import: the gzip JavaScript it pulls in (transitive, minified, other packages external) and the gzip CSS of the stylesheet rules that JavaScript references, plus each changed package stylesheet's gzip size, at head and at the merge base. The PR comment shows before, after, and delta, and flags a modified component whose JavaScript grew more than 2 KB or CSS more than 1 KB (INV6, AST-080 FR8). --- .github/scripts/analyze-pr.js | 36 ++++ .github/scripts/analyze-pr.test.mjs | 2 + .github/scripts/generate-pr-comment.js | 63 +++++- .github/scripts/generate-pr-comment.test.mjs | 61 ++++++ .github/scripts/lib/component-size.js | 193 +++++++++++++++++++ .github/scripts/lib/component-size.test.mjs | 102 ++++++++++ 6 files changed, 456 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/lib/component-size.js create mode 100644 .github/scripts/lib/component-size.test.mjs diff --git a/.github/scripts/analyze-pr.js b/.github/scripts/analyze-pr.js index 49fe3b0f704b2..59e9460745358 100644 --- a/.github/scripts/analyze-pr.js +++ b/.github/scripts/analyze-pr.js @@ -14,6 +14,11 @@ const fs = require('node:fs'); const path = require('node:path'); const { execSync } = require('node:child_process'); +const { + gzipBytes: gzipTextBytes, + measureComponentImport, + packageStylesheetPath, +} = require('./lib/component-size'); const { COMPONENT_PACKAGES, componentExportsFromBarrel, @@ -500,6 +505,24 @@ function getComplexity(pkg, componentName) { } } +// A package's prebuilt stylesheet text, read once per tree. +const stylesheetCache = new Map(); +function packageStylesheet(pkg) { + const file = packageStylesheetPath(path.join(process.cwd(), pkg.dir)); + if (!file) return null; + if (!stylesheetCache.has(file)) stylesheetCache.set(file, fs.readFileSync(file, 'utf8')); + return stylesheetCache.get(file); +} + +// The dist module a component's import resolves to: `dist//index.js` in +// a nested package, `dist/.js` in a flat one. +function componentImportEntry(pkg, componentName) { + const dist = path.join(process.cwd(), pkgDist(pkg)); + return pkg.layout === 'flat' + ? path.join(dist, `${componentName}.js`) + : path.join(dist, componentName, 'index.js'); +} + // Get component stats function getComponentStats(pkg, componentName) { // Per-component dist sizing only applies to nested packages that emit a @@ -512,9 +535,18 @@ function getComponentStats(pkg, componentName) { const entries = distDir ? resolveEntries(distDir, pkg) : { esmPath: null, cjsPath: null }; const loc = getComponentLOC(pkg, componentName); const complexity = getComplexity(pkg, componentName); + // What the component's import costs: the gzip JavaScript it pulls in, + // transitive modules included, and the gzip CSS of the stylesheet rules + // that JavaScript references (spec:AST-080 FR8, INV6). + const importSize = measureComponentImport( + componentImportEntry(pkg, componentName), + packageStylesheet(pkg), + ); return { package: pkg.name, + importGzipBytes: importSize.importGzipBytes, + cssGzipBytes: importSize.cssGzipBytes, esmSize: getFileSize(entries.esmPath), esmBytes: getFileSizeBytes(entries.esmPath), cjsSize: getFileSize(entries.cjsPath), @@ -545,6 +577,10 @@ function getPackageBundleStats(pkg) { cjsBytes: getFileSizeBytes(cjsPath), gzipSize: formatBytes(gzipBytes), gzipBytes, + stylesheetGzipBytes: (() => { + const css = packageStylesheet(pkg); + return css === null ? null : gzipTextBytes(css); + })(), }; } diff --git a/.github/scripts/analyze-pr.test.mjs b/.github/scripts/analyze-pr.test.mjs index df62f458587bf..6247bf62a1df3 100644 --- a/.github/scripts/analyze-pr.test.mjs +++ b/.github/scripts/analyze-pr.test.mjs @@ -360,6 +360,8 @@ describe('analyze-pr base and head sizes', () => { expect(analysis.componentStats.Card.esmBytes).toBe(160); expect(analysis.componentStats.Card.cjsBytes).toBeNull(); expect(analysis.baseComponentStats.Card.esmBytes).toBe(100); + expect(analysis.componentStats.Card.importGzipBytes).toBeGreaterThan(0); + expect(analysis.baseComponentStats.Card.importGzipBytes).toBeGreaterThan(0); const [headBundle] = analysis.bundlePackages; const [baseBundle] = analysis.baseBundlePackages; expect(headBundle.package).toBe('@astryxdesign/core'); diff --git a/.github/scripts/generate-pr-comment.js b/.github/scripts/generate-pr-comment.js index 225da03e204d5..f73820475990c 100644 --- a/.github/scripts/generate-pr-comment.js +++ b/.github/scripts/generate-pr-comment.js @@ -97,6 +97,26 @@ function bytesDelta(after, before) { : null; } +// A measured byte count as text, or N/A. Only numbers reach the output. +function sizeText(bytes) { + if (typeof bytes !== 'number' || !Number.isFinite(bytes)) return 'N/A'; + if (Math.abs(bytes) < 1024) return `${bytes}B`; + return `${(bytes / 1024).toFixed(1)}KB`; +} + +function signedSizeText(delta) { + if (delta === null) return 'N/A'; + return delta > 0 ? `+${sizeText(delta)}` : sizeText(delta); +} + +// Touch-only budget per component (architecture:component-style-authoring +// INV6, spec:AST-080 FR8): 2 KB gzip JavaScript, 1 KB gzip CSS. The report +// cannot tell whether growth runs only on a coarse pointer, so it flags any +// growth over budget and asks the author. +const JS_BUDGET_BYTES = 2 * 1024; +const CSS_BUDGET_BYTES = 1024; +const budgetFlags = []; + // Build component stats section let componentSection = ''; if (analysis.newComponents && analysis.newComponents.length > 0) { @@ -110,6 +130,8 @@ if (analysis.newComponents && analysis.newComponents.length > 0) { : ''; componentSection += `
\n${inline(comp)}${pkgBadge}${sbBadge}\n\n`; componentSection += `| Metric | Value |\n|--------|-------|\n`; + componentSection += `| Import (gzip, transitive) | ${sizeText(stats.importGzipBytes)} |\n`; + componentSection += `| CSS (gzip) | ${sizeText(stats.cssGzipBytes)} |\n`; componentSection += `| Entry File (ESM) | ${inline(stats.esmSize) || 'N/A'} |\n`; if (stats.cjsSize) componentSection += `| Entry File (CJS) | ${inline(stats.cjsSize)} |\n`; @@ -153,6 +175,18 @@ if (analysis.modifiedComponents && analysis.modifiedComponents.length > 0) { : `${esmDelta}B` : 'N/A'; + const importDelta = bytesDelta( + stats.importGzipBytes, + baseStats.importGzipBytes, + ); + const cssDelta = bytesDelta(stats.cssGzipBytes, baseStats.cssGzipBytes); + const jsOver = importDelta !== null && importDelta > JS_BUDGET_BYTES; + const cssOver = cssDelta !== null && cssDelta > CSS_BUDGET_BYTES; + if (jsOver || cssOver) + budgetFlags.push({comp, importDelta, cssDelta, jsOver, cssOver}); + + componentSection += `| Import (gzip, transitive) | ${sizeText(baseStats.importGzipBytes)} | ${sizeText(stats.importGzipBytes)} | ${signedSizeText(importDelta)}${jsOver ? ' ⚠️' : ''} |\n`; + componentSection += `| CSS (gzip) | ${sizeText(baseStats.cssGzipBytes)} | ${sizeText(stats.cssGzipBytes)} | ${signedSizeText(cssDelta)}${cssOver ? ' ⚠️' : ''} |\n`; componentSection += `| Entry File (ESM) | ${inline(baseStats.esmSize) || 'N/A'} | ${inline(stats.esmSize) || 'N/A'} | ${esmDeltaStr} |\n`; componentSection += `| Lines of Code | ${inline(baseStats.linesOfCode) || 'N/A'} | ${inline(stats.linesOfCode) || 'N/A'} | - |\n`; componentSection += `| Complexity | ${inline(baseStats.complexityRating) || 'N/A'} | ${inline(stats.complexityRating) || 'N/A'} (${num(stats.complexity)}) | - |\n`; @@ -160,6 +194,20 @@ if (analysis.modifiedComponents && analysis.modifiedComponents.length > 0) { } } +// Components whose growth passes the touch-only budget. +let budgetSection = ''; +if (budgetFlags.length > 0) { + budgetSection += '### ⚠️ Touch-only size budget\n\n'; + budgetSection += + 'These components grew past the touch-only budget (2 KB gzip JavaScript or 1 KB gzip CSS per component). If the growth runs only on a coarse pointer, load its JavaScript lazily so the import does not carry it, or justify the cost in the pull request description; CSS stays in the package stylesheet, so justify it.\n\n'; + budgetSection += + '| Component | JavaScript | CSS |\n|-----------|------------|-----|\n'; + for (const flag of budgetFlags) { + budgetSection += `| ${inline(flag.comp)} | ${signedSizeText(flag.importDelta)}${flag.jsOver ? ' ⚠️' : ''} | ${signedSizeText(flag.cssDelta)}${flag.cssOver ? ' ⚠️' : ''} |\n`; + } + budgetSection += '\n'; +} + // Build accessibility section using shared module const a11ySection = buildA11ySection(a11yReport); @@ -203,6 +251,19 @@ if (bundlePackages.length > 0 && Array.isArray(baseBundles)) { bundleSection += `| ${inline(b.package)} | ${inline(b.esmSize) || 'N/A'} | ${inline(base.gzipSize) || 'N/A'} | ${inline(b.gzipSize) || 'N/A'} | ${signedBytes(delta)} |\n`; } bundleSection += `\n`; + const sheets = bundlePackages.filter( + b => typeof b.stylesheetGzipBytes === 'number', + ); + if (sheets.length > 0) { + bundleSection += `| Package stylesheet | Gzip before | Gzip after | Delta |\n`; + bundleSection += `|--------------------|-------------|------------|-------|\n`; + for (const b of sheets) { + const base = baseBundles.find(entry => entry.package === b.package) || {}; + const delta = bytesDelta(b.stylesheetGzipBytes, base.stylesheetGzipBytes); + bundleSection += `| ${inline(b.package)} | ${sizeText(base.stylesheetGzipBytes)} | ${sizeText(b.stylesheetGzipBytes)} | ${signedSizeText(delta)} |\n`; + } + bundleSection += `\n`; + } } else if (bundlePackages.length > 0) { bundleSection += `| Package | Size (ESM) | Size (CJS) | Gzipped |\n`; bundleSection += `|---------|------------|------------|----------|\n`; @@ -271,7 +332,7 @@ const body = `## PR Analysis Report ${diffModeCaveat}${previewAvailabilitySection}${storybookSection}${sandboxSection}${componentSection || '_No new or modified components detected._\n\n'} -${bundleSection} +${budgetSection}${bundleSection} ${a11ySection} ${visualSection}--- diff --git a/.github/scripts/generate-pr-comment.test.mjs b/.github/scripts/generate-pr-comment.test.mjs index 0ce8d5a3f7024..2419d54b421f3 100644 --- a/.github/scripts/generate-pr-comment.test.mjs +++ b/.github/scripts/generate-pr-comment.test.mjs @@ -225,3 +225,64 @@ describe('generate-pr-comment base sizes', () => { expect(out).toContain('| Package | Size (ESM) | Size (CJS) | Gzipped |'); }); }); + +describe('generate-pr-comment touch-only budget', () => { + function withBase(head, base) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-budget-')); + try { + const baseFile = path.join(dir, 'size-base.json'); + fs.writeFileSync( + baseFile, + JSON.stringify({ + baseComponentStats: {Card: base}, + baseBundlePackages: [], + }), + ); + return runComment( + {...baseAnalysis, componentStats: {Card: head}, bundlePackages: []}, + ['--base-stats', baseFile], + ); + } finally { + fs.rmSync(dir, {recursive: true, force: true}); + } + } + + it('flags JavaScript growth over 2 KB and CSS growth over 1 KB', () => { + const out = withBase( + { + package: '@astryxdesign/core', + importGzipBytes: 10000, + cssGzipBytes: 3000, + }, + { + package: '@astryxdesign/core', + importGzipBytes: 7000, + cssGzipBytes: 1900, + }, + ); + expect(out).toContain('### ⚠️ Touch-only size budget'); + expect(out).toContain( + '| Import (gzip, transitive) | 6.8KB | 9.8KB | +2.9KB ⚠️ |', + ); + expect(out).toContain('| CSS (gzip) | 1.9KB | 2.9KB | +1.1KB ⚠️ |'); + }); + + it('does not flag growth within budget', () => { + const out = withBase( + { + package: '@astryxdesign/core', + importGzipBytes: 9000, + cssGzipBytes: 2900, + }, + { + package: '@astryxdesign/core', + importGzipBytes: 7000, + cssGzipBytes: 1900, + }, + ); + expect(out).not.toContain('Touch-only size budget'); + expect(out).toContain( + '| Import (gzip, transitive) | 6.8KB | 8.8KB | +2.0KB |', + ); + }); +}); diff --git a/.github/scripts/lib/component-size.js b/.github/scripts/lib/component-size.js new file mode 100644 index 0000000000000..84afc491fadef --- /dev/null +++ b/.github/scripts/lib/component-size.js @@ -0,0 +1,193 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file component-size.js + * @input A built package's dist directory, a component's dist entry, and the + * package's prebuilt stylesheet + * @output The gzip size of the JavaScript a component's import pulls in + * (transitive, minified, other packages external) and the gzip size of the + * stylesheet rules that JavaScript references + * @position Shared by analyze-pr.js for the PR size report. Sizes are gzip at + * the default compression level (spec:AST-080 FR8, + * architecture:component-style-authoring INV6). + */ + +const fs = require('node:fs'); +const path = require('node:path'); +const zlib = require('node:zlib'); + +function gzipBytes(text) { + return zlib.gzipSync(Buffer.from(text, 'utf8')).length; +} + +// The package's prebuilt stylesheet: `dist/.css` from its `.css` export +// other than the reset and Tailwind theme files. +function packageStylesheetPath(packageDir) { + try { + const manifest = JSON.parse( + fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8'), + ); + const keys = Object.keys(manifest.exports || {}).filter( + key => + key.endsWith('.css') && + key !== './reset.css' && + key !== './tailwind-theme.css', + ); + for (const key of keys) { + const target = manifest.exports[key]; + const file = typeof target === 'string' ? target : target?.default; + if (!file) continue; + const resolved = path.join(packageDir, file); + if (fs.existsSync(resolved)) return resolved; + } + } catch { + // An unreadable manifest has no stylesheet to measure. + } + return null; +} + +// Bundle what importing `entry` pulls in: every module it reaches inside its +// package, minified, with every bare-specifier import (React, StyleX, other +// Astryx packages) left external. Returns the bundle text, or null. +function bundleImport(entry) { + if (!entry || !fs.existsSync(entry)) return null; + try { + const esbuild = require('esbuild'); + const result = esbuild.buildSync({ + stdin: { + contents: `export * from ${JSON.stringify(path.resolve(entry))};`, + resolveDir: path.dirname(path.resolve(entry)), + loader: 'js', + }, + bundle: true, + minify: true, + format: 'esm', + platform: 'browser', + packages: 'external', + loader: {'.css': 'empty'}, + write: false, + logLevel: 'silent', + }); + return result.outputFiles[0].text; + } catch { + return null; + } +} + +// StyleX atomic class names the bundle references. +function classNamesIn(js) { + return new Set(js.match(/\bx[a-z0-9]{3,12}\b/g) || []); +} + +// Parse CSS into nested blocks: {prelude, children} for block rules and +// {prelude} for statements. Comments are dropped; strings are kept intact. +function parseBlocks(css) { + let index = 0; + function skipComment() { + if (css.startsWith('/*', index)) { + const end = css.indexOf('*/', index + 2); + index = end < 0 ? css.length : end + 2; + return true; + } + return false; + } + function readUntil(stops) { + let out = ''; + while (index < css.length && !stops.includes(css[index])) { + if (skipComment()) continue; + const ch = css[index]; + if (ch === '"' || ch === "'") { + const start = index; + index += 1; + while (index < css.length && css[index] !== ch) { + if (css[index] === '\\') index += 1; + index += 1; + } + index += 1; + out += css.slice(start, index); + continue; + } + out += ch; + index += 1; + } + return out; + } + function parseList() { + const nodes = []; + while (index < css.length) { + const prelude = readUntil(['{', ';', '}']).trim(); + const ch = css[index]; + if (ch === '}') { + index += 1; + break; + } + index += 1; + if (ch === ';' || ch === undefined) { + if (prelude) nodes.push({prelude}); + continue; + } + // A style rule's body holds declarations; an at-rule's body may hold rules. + if ( + prelude.startsWith('@') && + !/^@(font-face|page|property)\b/.test(prelude) + ) { + nodes.push({prelude, children: parseList()}); + } else { + const body = readUntil(['}']); + index += 1; + nodes.push({prelude, body}); + } + } + return nodes; + } + return parseList(); +} + +// The stylesheet rules whose selectors name one of `classes`, inside the +// at-rules that wrap them. +function rulesForClasses(css, classes) { + if (!css || classes.size === 0) return ''; + const classPattern = /\.([A-Za-z_][\w-]*)/g; + function keep(nodes) { + let out = ''; + for (const node of nodes) { + if (node.children) { + if (/^@keyframes\b/.test(node.prelude)) continue; + const inner = keep(node.children); + if (inner) out += `${node.prelude}{${inner}}`; + } else if (node.body !== undefined) { + const names = [...node.prelude.matchAll(classPattern)].map(m => m[1]); + if (names.some(name => classes.has(name))) { + out += `${node.prelude}{${node.body}}`; + } + } + } + return out; + } + return keep(parseBlocks(css)); +} + +/** + * Measure one component import: gzip JavaScript it pulls in and gzip CSS of + * the stylesheet rules that JavaScript references. Null where unmeasurable. + */ +function measureComponentImport(entry, stylesheetText) { + const js = bundleImport(entry); + if (js === null) return {importGzipBytes: null, cssGzipBytes: null}; + const css = stylesheetText + ? rulesForClasses(stylesheetText, classNamesIn(js)) + : null; + return { + importGzipBytes: gzipBytes(js), + cssGzipBytes: css === null ? null : gzipBytes(css), + }; +} + +module.exports = { + gzipBytes, + packageStylesheetPath, + bundleImport, + classNamesIn, + rulesForClasses, + measureComponentImport, +}; diff --git a/.github/scripts/lib/component-size.test.mjs b/.github/scripts/lib/component-size.test.mjs new file mode 100644 index 0000000000000..0e8f505aa6fba --- /dev/null +++ b/.github/scripts/lib/component-size.test.mjs @@ -0,0 +1,102 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file component-size.test.mjs + * Pins how the PR size report measures one component import: the JavaScript + * it pulls in transitively (other packages external) and the stylesheet rules + * that JavaScript references. + */ + +import fs from 'node:fs'; +import {createRequire} from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import {afterEach, describe, expect, it} from 'vitest'; + +const require = createRequire(import.meta.url); +const { + bundleImport, + classNamesIn, + measureComponentImport, + packageStylesheetPath, + rulesForClasses, +} = require('./component-size.js'); + +const roots = []; +function tree(files) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-size-')); + roots.push(root); + for (const [file, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, file)), {recursive: true}); + fs.writeFileSync(path.join(root, file), content); + } + return root; +} + +afterEach(() => { + for (const root of roots.splice(0)) + fs.rmSync(root, {recursive: true, force: true}); +}); + +describe('component import size', () => { + it('bundles modules the import reaches and leaves other packages external', () => { + const root = tree({ + 'dist/Card/index.js': "export {Card} from './Card.js';\n", + 'dist/Card/Card.js': + "import {jsx} from 'react/jsx-runtime';\nimport {helper} from '../shared/helper.js';\nexport function Card() { return jsx('div', {className: 'xcard1 ' + helper()}); }\n", + 'dist/shared/helper.js': + "export function helper() { return 'xhelp22'; }\n", + 'dist/Other/index.js': "export const unrelated = 'xother9';\n", + }); + const js = bundleImport(path.join(root, 'dist/Card/index.js')); + expect(js).toContain('xhelp22'); + expect(js).toContain('react/jsx-runtime'); + expect(js).not.toContain('xother9'); + expect([...classNamesIn(js)].sort()).toEqual(['xcard1', 'xhelp22']); + }); + + it('keeps only the rules whose selectors name a referenced class, with their at-rules', () => { + const css = [ + '@layer reset, astryx-base, astryx-theme;', + '@layer astryx-base{', + '.xcard1{color:red}', + '.xother9{color:blue}', + '@media (pointer: coarse){.xcard1:active{opacity:.5}.xother9{opacity:1}}', + '@keyframes xspin1{to{transform:rotate(1turn)}}', + '}', + ].join(''); + const kept = rulesForClasses(css, new Set(['xcard1'])); + expect(kept).toBe( + '@layer astryx-base{.xcard1{color:red}@media (pointer: coarse){.xcard1:active{opacity:.5}}}', + ); + expect(rulesForClasses(css, new Set())).toBe(''); + }); + + it('measures gzip JavaScript and CSS, and null for a missing entry', () => { + const root = tree({ + 'package.json': JSON.stringify({ + exports: { + './reset.css': './dist/reset.css', + './astryx.css': {default: './dist/astryx.css'}, + }, + }), + 'dist/reset.css': '*{margin:0}', + 'dist/astryx.css': '.xcard1{color:red}.xother9{color:blue}', + 'dist/Card/index.js': "export const Card = () => 'xcard1';\n", + }); + const sheet = packageStylesheetPath(root); + expect(sheet).toBe(path.join(root, 'dist/astryx.css')); + const size = measureComponentImport( + path.join(root, 'dist/Card/index.js'), + fs.readFileSync(sheet, 'utf8'), + ); + expect(size.importGzipBytes).toBeGreaterThan(0); + expect(size.cssGzipBytes).toBeGreaterThan(0); + expect( + measureComponentImport(path.join(root, 'dist/Nope/index.js'), ''), + ).toEqual({ + importGzipBytes: null, + cssGzipBytes: null, + }); + }); +}); From 49fbd93106eea1ee3d68478c16cf58c296be7c52 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sat, 24 Oct 2026 13:34:04 -0700 Subject: [PATCH 02/11] fix(ci): the import figure bundles every module reached, package imports included; CSS keeps named keyframes --- .github/scripts/lib/component-size.js | 38 +++++++++++++++------ .github/scripts/lib/component-size.test.mjs | 26 +++++++++++--- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/.github/scripts/lib/component-size.js b/.github/scripts/lib/component-size.js index 84afc491fadef..3c66f74aa038b 100644 --- a/.github/scripts/lib/component-size.js +++ b/.github/scripts/lib/component-size.js @@ -5,8 +5,8 @@ * @input A built package's dist directory, a component's dist entry, and the * package's prebuilt stylesheet * @output The gzip size of the JavaScript a component's import pulls in - * (transitive, minified, other packages external) and the gzip size of the - * stylesheet rules that JavaScript references + * (one minified bundle of every module it reaches, package imports included) + * and the gzip size of the stylesheet rules that JavaScript references * @position Shared by analyze-pr.js for the PR size report. Sizes are gzip at * the default compression level (spec:AST-080 FR8, * architecture:component-style-authoring INV6). @@ -46,9 +46,10 @@ function packageStylesheetPath(packageDir) { return null; } -// Bundle what importing `entry` pulls in: every module it reaches inside its -// package, minified, with every bare-specifier import (React, StyleX, other -// Astryx packages) left external. Returns the bundle text, or null. +// Bundle what importing `entry` pulls in: every module it reaches, including +// modules reached through package imports (other Astryx packages, React, +// StyleX, third-party libraries), minified for production. Returns the bundle +// text, or null when the import cannot be bundled. function bundleImport(entry) { if (!entry || !fs.existsSync(entry)) return null; try { @@ -63,7 +64,7 @@ function bundleImport(entry) { minify: true, format: 'esm', platform: 'browser', - packages: 'external', + define: {'process.env.NODE_ENV': '"production"'}, loader: {'.css': 'empty'}, write: false, logLevel: 'silent', @@ -144,16 +145,25 @@ function parseBlocks(css) { } // The stylesheet rules whose selectors name one of `classes`, inside the -// at-rules that wrap them. +// at-rules that wrap them, plus the @keyframes those rules name. function rulesForClasses(css, classes) { if (!css || classes.size === 0) return ''; const classPattern = /\.([A-Za-z_][\w-]*)/g; - function keep(nodes) { + const tree = parseBlocks(css); + function keep(nodes, keyframes) { let out = ''; for (const node of nodes) { if (node.children) { - if (/^@keyframes\b/.test(node.prelude)) continue; - const inner = keep(node.children); + const name = /^@(?:-webkit-)?keyframes\s+([\w-]+)/.exec( + node.prelude, + )?.[1]; + if (name !== undefined) { + if (keyframes.has(name)) { + out += `${node.prelude}{${node.children.map(child => `${child.prelude}{${child.body ?? ''}}`).join('')}}`; + } + continue; + } + const inner = keep(node.children, keyframes); if (inner) out += `${node.prelude}{${inner}}`; } else if (node.body !== undefined) { const names = [...node.prelude.matchAll(classPattern)].map(m => m[1]); @@ -164,7 +174,13 @@ function rulesForClasses(css, classes) { } return out; } - return keep(parseBlocks(css)); + const rules = keep(tree, new Set()); + // Keyframes a kept rule names through `animation` or `animation-name`. + const named = new Set(); + for (const match of rules.matchAll(/animation(?:-name)?\s*:\s*([^;}]+)/g)) { + for (const token of match[1].split(/[\s,]+/)) if (token) named.add(token); + } + return named.size === 0 ? rules : keep(tree, named); } /** diff --git a/.github/scripts/lib/component-size.test.mjs b/.github/scripts/lib/component-size.test.mjs index 0e8f505aa6fba..11e573f983e80 100644 --- a/.github/scripts/lib/component-size.test.mjs +++ b/.github/scripts/lib/component-size.test.mjs @@ -39,20 +39,30 @@ afterEach(() => { }); describe('component import size', () => { - it('bundles modules the import reaches and leaves other packages external', () => { + it('bundles every module the import reaches, package imports included', () => { const root = tree({ 'dist/Card/index.js': "export {Card} from './Card.js';\n", 'dist/Card/Card.js': - "import {jsx} from 'react/jsx-runtime';\nimport {helper} from '../shared/helper.js';\nexport function Card() { return jsx('div', {className: 'xcard1 ' + helper()}); }\n", + "import {sheet} from 'fake-sheet';\nimport {helper} from '../shared/helper.js';\nexport function Card() { return sheet('xcard1 ' + helper()); }\n", 'dist/shared/helper.js': "export function helper() { return 'xhelp22'; }\n", 'dist/Other/index.js': "export const unrelated = 'xother9';\n", + 'node_modules/fake-sheet/package.json': JSON.stringify({ + name: 'fake-sheet', + main: 'index.js', + }), + 'node_modules/fake-sheet/index.js': + "export function sheet(name) { return 'xsheet7 ' + name + ' PACKAGE_CODE_MARKER'; }\n", }); const js = bundleImport(path.join(root, 'dist/Card/index.js')); expect(js).toContain('xhelp22'); - expect(js).toContain('react/jsx-runtime'); + expect(js).toContain('PACKAGE_CODE_MARKER'); expect(js).not.toContain('xother9'); - expect([...classNamesIn(js)].sort()).toEqual(['xcard1', 'xhelp22']); + expect([...classNamesIn(js)].sort()).toEqual([ + 'xcard1', + 'xhelp22', + 'xsheet7', + ]); }); it('keeps only the rules whose selectors name a referenced class, with their at-rules', () => { @@ -72,6 +82,14 @@ describe('component import size', () => { expect(rulesForClasses(css, new Set())).toBe(''); }); + it('keeps the @keyframes a kept rule names', () => { + const css = + '@layer astryx-base{.xspin2{animation-name:xrot1}.xother9{animation-name:xrot9}@keyframes xrot1{from{opacity:0}to{opacity:1}}@keyframes xrot9{to{opacity:1}}}'; + expect(rulesForClasses(css, new Set(['xspin2']))).toBe( + '@layer astryx-base{.xspin2{animation-name:xrot1}@keyframes xrot1{from{opacity:0}to{opacity:1}}}', + ); + }); + it('measures gzip JavaScript and CSS, and null for a missing entry', () => { const root = tree({ 'package.json': JSON.stringify({ From bcb837b58c87c0880a420b94a40ec9fe4383e4fd Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sat, 24 Oct 2026 14:07:35 -0700 Subject: [PATCH 03/11] fix(ci): component CSS counts only classes its own package modules reference; resolve folders without an index --- .github/scripts/analyze-pr.js | 10 +++--- .github/scripts/lib/component-size.js | 37 ++++++++++++++++----- .github/scripts/lib/component-size.test.mjs | 28 ++++++++++++++++ 3 files changed, 62 insertions(+), 13 deletions(-) diff --git a/.github/scripts/analyze-pr.js b/.github/scripts/analyze-pr.js index 59e9460745358..8ee0f03b93a48 100644 --- a/.github/scripts/analyze-pr.js +++ b/.github/scripts/analyze-pr.js @@ -515,12 +515,13 @@ function packageStylesheet(pkg) { } // The dist module a component's import resolves to: `dist//index.js` in -// a nested package, `dist/.js` in a flat one. +// a nested package (or `dist//.js` when the folder has no index), +// `dist/.js` in a flat one. function componentImportEntry(pkg, componentName) { const dist = path.join(process.cwd(), pkgDist(pkg)); - return pkg.layout === 'flat' - ? path.join(dist, `${componentName}.js`) - : path.join(dist, componentName, 'index.js'); + if (pkg.layout === 'flat') return path.join(dist, `${componentName}.js`); + const index = path.join(dist, componentName, 'index.js'); + return fs.existsSync(index) ? index : path.join(dist, componentName, `${componentName}.js`); } // Get component stats @@ -541,6 +542,7 @@ function getComponentStats(pkg, componentName) { const importSize = measureComponentImport( componentImportEntry(pkg, componentName), packageStylesheet(pkg), + path.join(process.cwd(), pkgDist(pkg)), ); return { diff --git a/.github/scripts/lib/component-size.js b/.github/scripts/lib/component-size.js index 3c66f74aa038b..0f4ad5deb1247 100644 --- a/.github/scripts/lib/component-size.js +++ b/.github/scripts/lib/component-size.js @@ -50,11 +50,14 @@ function packageStylesheetPath(packageDir) { // modules reached through package imports (other Astryx packages, React, // StyleX, third-party libraries), minified for production. Returns the bundle // text, or null when the import cannot be bundled. -function bundleImport(entry) { +function bundleImportWithInputs(entry) { if (!entry || !fs.existsSync(entry)) return null; try { const esbuild = require('esbuild'); + const workingDir = process.cwd(); const result = esbuild.buildSync({ + absWorkingDir: workingDir, + metafile: true, stdin: { contents: `export * from ${JSON.stringify(path.resolve(entry))};`, resolveDir: path.dirname(path.resolve(entry)), @@ -69,12 +72,21 @@ function bundleImport(entry) { write: false, logLevel: 'silent', }); - return result.outputFiles[0].text; + return { + text: result.outputFiles[0].text, + inputs: Object.keys(result.metafile.inputs) + .filter(input => input !== '') + .map(input => path.resolve(workingDir, input)), + }; } catch { return null; } } +function bundleImport(entry) { + return bundleImportWithInputs(entry)?.text ?? null; +} + // StyleX atomic class names the bundle references. function classNamesIn(js) { return new Set(js.match(/\bx[a-z0-9]{3,12}\b/g) || []); @@ -187,14 +199,21 @@ function rulesForClasses(css, classes) { * Measure one component import: gzip JavaScript it pulls in and gzip CSS of * the stylesheet rules that JavaScript references. Null where unmeasurable. */ -function measureComponentImport(entry, stylesheetText) { - const js = bundleImport(entry); - if (js === null) return {importGzipBytes: null, cssGzipBytes: null}; - const css = stylesheetText - ? rulesForClasses(stylesheetText, classNamesIn(js)) - : null; +function measureComponentImport(entry, stylesheetText, ownDistDir) { + const bundle = bundleImportWithInputs(entry); + if (bundle === null) return {importGzipBytes: null, cssGzipBytes: null}; + // CSS counts only the rules the component's own package modules reference: + // classes from modules reached in other packages belong to their stylesheets. + const ownDir = path.resolve(ownDistDir || path.dirname(entry)) + path.sep; + const classes = new Set(); + for (const input of bundle.inputs) { + if (!input.startsWith(ownDir)) continue; + for (const name of classNamesIn(fs.readFileSync(input, 'utf8'))) + classes.add(name); + } + const css = stylesheetText ? rulesForClasses(stylesheetText, classes) : null; return { - importGzipBytes: gzipBytes(js), + importGzipBytes: gzipBytes(bundle.text), cssGzipBytes: css === null ? null : gzipBytes(css), }; } diff --git a/.github/scripts/lib/component-size.test.mjs b/.github/scripts/lib/component-size.test.mjs index 11e573f983e80..3e7d945cda52d 100644 --- a/.github/scripts/lib/component-size.test.mjs +++ b/.github/scripts/lib/component-size.test.mjs @@ -82,6 +82,34 @@ describe('component import size', () => { expect(rulesForClasses(css, new Set())).toBe(''); }); + it('counts CSS only for classes the component package modules reference', () => { + const root = tree({ + 'packages/lab/dist/Tip/index.js': + "export {Button} from '../../../core/dist/Button.js';\nexport const Tip = () => 'xtip1';\n", + 'packages/core/dist/Button.js': "export const Button = () => 'xbtn9';\n", + 'packages/lab/dist/lab.css': + '.xtip1{color:red}.xbtn9{color:blue;padding:4px;margin:2px}', + }); + const css = fs.readFileSync( + path.join(root, 'packages/lab/dist/lab.css'), + 'utf8', + ); + const own = measureComponentImport( + path.join(root, 'packages/lab/dist/Tip/index.js'), + css, + path.join(root, 'packages/lab/dist'), + ); + const onlyTip = measureComponentImport( + path.join(root, 'packages/lab/dist/Tip/index.js'), + '.xtip1{color:red}', + path.join(root, 'packages/lab/dist'), + ); + expect(own.cssGzipBytes).toBe(onlyTip.cssGzipBytes); + expect( + bundleImport(path.join(root, 'packages/lab/dist/Tip/index.js')), + ).toContain('xbtn9'); + }); + it('keeps the @keyframes a kept rule names', () => { const css = '@layer astryx-base{.xspin2{animation-name:xrot1}.xother9{animation-name:xrot9}@keyframes xrot1{from{opacity:0}to{opacity:1}}@keyframes xrot9{to{opacity:1}}}'; From 9766a0bf074a883c31cf8b5fe7b16cc4281b6832 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sat, 24 Oct 2026 14:39:46 -0700 Subject: [PATCH 04/11] fix(ci): size every public component in a changed folder, family folders included; a folder without an index exists in base --- .github/scripts/analyze-pr.js | 98 +++++++++++++++----- .github/scripts/analyze-pr.test.mjs | 49 +++++++++- .github/scripts/generate-pr-comment.js | 44 ++++++--- .github/scripts/generate-pr-comment.test.mjs | 32 +++++-- .github/scripts/lib/component-size.js | 10 +- 5 files changed, 183 insertions(+), 50 deletions(-) diff --git a/.github/scripts/analyze-pr.js b/.github/scripts/analyze-pr.js index 8ee0f03b93a48..5761ceca1bdf8 100644 --- a/.github/scripts/analyze-pr.js +++ b/.github/scripts/analyze-pr.js @@ -199,7 +199,17 @@ function componentExistsInBase(pkg, componentName) { }); return true; } catch { - return false; + // A nested component folder without an index barrel still exists in base. + if (pkg.layout === 'flat') return false; + try { + const listing = execSync( + `git ls-tree --name-only ${baseBranch} -- ${pkgSrc(pkg)}/${componentName}/`, + { encoding: 'utf8', stdio: 'pipe' }, + ); + return listing.trim().length > 0; + } catch { + return false; + } } } @@ -514,14 +524,65 @@ function packageStylesheet(pkg) { return stylesheetCache.get(file); } -// The dist module a component's import resolves to: `dist//index.js` in -// a nested package (or `dist//.js` when the folder has no index), -// `dist/.js` in a flat one. -function componentImportEntry(pkg, componentName) { - const dist = path.join(process.cwd(), pkgDist(pkg)); - if (pkg.layout === 'flat') return path.join(dist, `${componentName}.js`); - const index = path.join(dist, componentName, 'index.js'); - return fs.existsSync(index) ? index : path.join(dist, componentName, `${componentName}.js`); +// Every canonical public component whose implementation lives in a changed +// directory (nested packages) or file (flat packages), with the dist module +// that defines its export. Family folders that hold several components yield +// each of them. +function sizeTargets(changedFiles) { + const targets = []; + for (const pkg of PACKAGES) { + const sourceDir = path.join(process.cwd(), pkgSrc(pkg)); + const prefix = pkgSrc(pkg) + '/'; + const changed = new Set( + changedFiles + .filter((file) => file.startsWith(prefix)) + .map((file) => file.slice(prefix.length).split('/')[0].replace(/\.tsx?$/, '')), + ); + if (changed.size === 0) continue; + if (pkg.layout === 'flat') { + for (const name of getComponentNames(pkg)) { + if (changed.has(name)) { + targets.push({ key: `${pkg.packageName}/${name}`, pkg, name, distModule: `${name}.js` }); + } + } + continue; + } + const exports = componentExportsFromBarrel(path.join(sourceDir, 'index.ts'), sourceDir); + for (const name of nestedPackageComponentNames(process.cwd(), pkg)) { + const file = exports.get(name); + if (!file) continue; + const relative = path.relative(sourceDir, file); + if (!changed.has(relative.split(path.sep)[0])) continue; + targets.push({ + key: `${pkg.packageName}/${name}`, + pkg, + name, + distModule: relative.replace(/\.tsx?$/, '.js'), + }); + } + } + return targets; +} + +// What each target's import costs in the current tree: the gzip JavaScript it +// pulls in, transitive modules included, and the gzip CSS of the package +// stylesheet rules its own package's modules reference (spec:AST-080 FR8, +// architecture:component-style-authoring INV6). +function measureSizes(targets) { + const sizes = {}; + for (const target of targets) { + const dist = path.join(process.cwd(), pkgDist(target.pkg)); + sizes[target.key] = { + package: target.pkg.name, + ...measureComponentImport( + path.join(dist, target.distModule), + packageStylesheet(target.pkg), + dist, + target.name, + ), + }; + } + return sizes; } // Get component stats @@ -536,19 +597,8 @@ function getComponentStats(pkg, componentName) { const entries = distDir ? resolveEntries(distDir, pkg) : { esmPath: null, cjsPath: null }; const loc = getComponentLOC(pkg, componentName); const complexity = getComplexity(pkg, componentName); - // What the component's import costs: the gzip JavaScript it pulls in, - // transitive modules included, and the gzip CSS of the stylesheet rules - // that JavaScript references (spec:AST-080 FR8, INV6). - const importSize = measureComponentImport( - componentImportEntry(pkg, componentName), - packageStylesheet(pkg), - path.join(process.cwd(), pkgDist(pkg)), - ); - return { package: pkg.name, - importGzipBytes: importSize.importGzipBytes, - cssGzipBytes: importSize.cssGzipBytes, esmSize: getFileSize(entries.esmPath), esmBytes: getFileSizeBytes(entries.esmPath), cjsSize: getFileSize(entries.cjsPath), @@ -693,6 +743,9 @@ function analyze() { // and package lists stay the head's; only the stats are read in the base tree. let baseComponentStats = null; let baseBundlePackages = null; + const targets = sizeTargets(changedFiles); + const componentSizes = baseOnly ? null : measureSizes(targets); + let baseComponentSizes = null; if (baseRoot) { const headDir = process.cwd(); process.chdir(path.resolve(headDir, baseRoot)); @@ -705,6 +758,7 @@ function analyze() { baseBundlePackages = PACKAGES.filter((p) => changedPackages.has(p.name)).map((p) => getPackageBundleStats(p), ); + baseComponentSizes = measureSizes(targets); } finally { process.chdir(headDir); } @@ -716,6 +770,7 @@ function analyze() { baseSha: execSync('git rev-parse HEAD', { cwd: baseRoot, encoding: 'utf8' }).trim(), baseComponentStats, baseBundlePackages, + baseComponentSizes, analyzedAt: new Date().toISOString(), }; fs.writeFileSync(outputFile, JSON.stringify(baseResult, null, 2)); @@ -739,7 +794,8 @@ function analyze() { // or render it in the report should caveat when this is 'two-dot'. diffMode, bundlePackages, - ...(baseRoot ? { baseComponentStats, baseBundlePackages } : {}), + componentSizes, + ...(baseRoot ? { baseComponentStats, baseBundlePackages, baseComponentSizes } : {}), // Back-compat: keep totalBundle pointed at core when core changed, so any // older consumer of this field still resolves. totalBundle: bundlePackages.find((b) => b.package === '@astryxdesign/core') || null, diff --git a/.github/scripts/analyze-pr.test.mjs b/.github/scripts/analyze-pr.test.mjs index 6247bf62a1df3..b678b4dcdd967 100644 --- a/.github/scripts/analyze-pr.test.mjs +++ b/.github/scripts/analyze-pr.test.mjs @@ -295,7 +295,20 @@ describe('analyze-pr base and head sizes', () => { write( repo, 'packages/core/src/index.ts', - "export {Card} from './Card';\n", + "export {Card} from './Card';\nexport {Chat, ChatComposer} from './Chat';\n", + ); + write( + repo, + 'packages/core/src/Chat/index.ts', + "export {Chat} from './Chat';\nexport {ChatComposer} from './ChatComposer';\n", + ); + write(repo, 'packages/core/src/Chat/Chat.tsx', 'export function Chat() {}\n'); + write(repo, 'packages/core/src/Chat/ChatComposer.tsx', 'export function ChatComposer() {}\n'); + write(repo, 'packages/core/src/Chat/Chat.doc.mjs', "export const docs = {\n name: 'Chat',\n};\n"); + write( + repo, + 'packages/core/src/Chat/ChatComposer.doc.mjs', + "export const docs = {\n name: 'ChatComposer',\n};\n", ); write( repo, @@ -323,7 +336,11 @@ describe('analyze-pr base and head sizes', () => { path.join(repo, 'packages/core/src/Card/Card.tsx'), 'export const changed = true;\n', ); - git(repo, ['commit', '-qam', 'touch Card']); + fs.appendFileSync( + path.join(repo, 'packages/core/src/Chat/ChatComposer.tsx'), + 'export const changed = true;\n', + ); + git(repo, ['commit', '-qam', 'touch Card and the Chat family']); // Babel writes ES modules as `.js` in a "type": "module" package. write(baseRoot, 'packages/core/dist/Card/index.js', 'x'.repeat(100)); @@ -333,6 +350,19 @@ describe('analyze-pr base and head sizes', () => { 'export const base = 1;\n'.repeat(10), ); write(repo, 'packages/core/dist/Card/index.js', 'x'.repeat(160)); + write(baseRoot, 'packages/core/dist/Card/Card.js', 'export function Card() {}\n'); + write( + repo, + 'packages/core/dist/Card/Card.js', + 'export function Card() { return "a longer head body"; }\nexport const changed = true;\n', + ); + write(baseRoot, 'packages/core/dist/Chat/Chat.js', 'export function Chat() {}\n'); + write(repo, 'packages/core/dist/Chat/Chat.js', 'export function Chat() {}\n'); + write( + repo, + 'packages/core/dist/Chat/ChatComposer.js', + 'export function ChatComposer() { return "new"; }\n', + ); write( repo, 'packages/core/dist/index.js', @@ -360,8 +390,18 @@ describe('analyze-pr base and head sizes', () => { expect(analysis.componentStats.Card.esmBytes).toBe(160); expect(analysis.componentStats.Card.cjsBytes).toBeNull(); expect(analysis.baseComponentStats.Card.esmBytes).toBe(100); - expect(analysis.componentStats.Card.importGzipBytes).toBeGreaterThan(0); - expect(analysis.baseComponentStats.Card.importGzipBytes).toBeGreaterThan(0); + // Every public component in a changed folder is sized, family folders + // included, at head and at the merge base. + expect(Object.keys(analysis.componentSizes).sort()).toEqual([ + 'core/Card', + 'core/Chat', + 'core/ChatComposer', + ]); + expect(analysis.componentSizes['core/Card'].importGzipBytes).toBeGreaterThan( + analysis.baseComponentSizes['core/Card'].importGzipBytes, + ); + expect(analysis.componentSizes['core/ChatComposer'].importGzipBytes).toBeGreaterThan(0); + expect(analysis.baseComponentSizes['core/ChatComposer'].importGzipBytes).toBeNull(); const [headBundle] = analysis.bundlePackages; const [baseBundle] = analysis.baseBundlePackages; expect(headBundle.package).toBe('@astryxdesign/core'); @@ -392,6 +432,7 @@ describe('analyze-pr base and head sizes', () => { expect(baseOnly.baseSha).toBe(git(repo, ['rev-parse', 'main'])); expect(baseOnly.baseComponentStats.Card.esmBytes).toBe(100); expect(baseOnly.componentStats).toBeUndefined(); + expect(baseOnly.baseComponentSizes['core/Chat'].importGzipBytes).toBeGreaterThan(0); } finally { fs.rmSync(repo, {recursive: true, force: true}); } diff --git a/.github/scripts/generate-pr-comment.js b/.github/scripts/generate-pr-comment.js index f73820475990c..a7f6942fd0bca 100644 --- a/.github/scripts/generate-pr-comment.js +++ b/.github/scripts/generate-pr-comment.js @@ -52,6 +52,7 @@ if (baseStatsFile) { const base = JSON.parse(fs.readFileSync(baseStatsFile, 'utf8')); analysis.baseComponentStats = base.baseComponentStats || undefined; analysis.baseBundlePackages = base.baseBundlePackages || undefined; + analysis.baseComponentSizes = base.baseComponentSizes || undefined; } catch (e) { console.error('Warning: Could not read base stats:', e.message); } @@ -130,8 +131,6 @@ if (analysis.newComponents && analysis.newComponents.length > 0) { : ''; componentSection += `
\n${inline(comp)}${pkgBadge}${sbBadge}\n\n`; componentSection += `| Metric | Value |\n|--------|-------|\n`; - componentSection += `| Import (gzip, transitive) | ${sizeText(stats.importGzipBytes)} |\n`; - componentSection += `| CSS (gzip) | ${sizeText(stats.cssGzipBytes)} |\n`; componentSection += `| Entry File (ESM) | ${inline(stats.esmSize) || 'N/A'} |\n`; if (stats.cjsSize) componentSection += `| Entry File (CJS) | ${inline(stats.cjsSize)} |\n`; @@ -175,18 +174,6 @@ if (analysis.modifiedComponents && analysis.modifiedComponents.length > 0) { : `${esmDelta}B` : 'N/A'; - const importDelta = bytesDelta( - stats.importGzipBytes, - baseStats.importGzipBytes, - ); - const cssDelta = bytesDelta(stats.cssGzipBytes, baseStats.cssGzipBytes); - const jsOver = importDelta !== null && importDelta > JS_BUDGET_BYTES; - const cssOver = cssDelta !== null && cssDelta > CSS_BUDGET_BYTES; - if (jsOver || cssOver) - budgetFlags.push({comp, importDelta, cssDelta, jsOver, cssOver}); - - componentSection += `| Import (gzip, transitive) | ${sizeText(baseStats.importGzipBytes)} | ${sizeText(stats.importGzipBytes)} | ${signedSizeText(importDelta)}${jsOver ? ' ⚠️' : ''} |\n`; - componentSection += `| CSS (gzip) | ${sizeText(baseStats.cssGzipBytes)} | ${sizeText(stats.cssGzipBytes)} | ${signedSizeText(cssDelta)}${cssOver ? ' ⚠️' : ''} |\n`; componentSection += `| Entry File (ESM) | ${inline(baseStats.esmSize) || 'N/A'} | ${inline(stats.esmSize) || 'N/A'} | ${esmDeltaStr} |\n`; componentSection += `| Lines of Code | ${inline(baseStats.linesOfCode) || 'N/A'} | ${inline(stats.linesOfCode) || 'N/A'} | - |\n`; componentSection += `| Complexity | ${inline(baseStats.complexityRating) || 'N/A'} | ${inline(stats.complexityRating) || 'N/A'} (${num(stats.complexity)}) | - |\n`; @@ -194,6 +181,33 @@ if (analysis.modifiedComponents && analysis.modifiedComponents.length > 0) { } } +// Import and CSS size of every public component whose implementation changed, +// family folders included, before and after. +let sizeSection = ''; +const componentSizes = analysis.componentSizes || {}; +const baseComponentSizes = analysis.baseComponentSizes || {}; +const sizeKeys = Object.keys(componentSizes).sort(); +if (sizeKeys.length > 0) { + sizeSection += '### Component Size\n\n'; + sizeSection += + '| Component | Import before | Import after | Delta | CSS before | CSS after | Delta |\n'; + sizeSection += + '|-----------|---------------|--------------|-------|------------|-----------|-------|\n'; + for (const key of sizeKeys) { + const head = componentSizes[key] || {}; + const base = baseComponentSizes[key] || {}; + const importDelta = bytesDelta(head.importGzipBytes, base.importGzipBytes); + const cssDelta = bytesDelta(head.cssGzipBytes, base.cssGzipBytes); + const jsOver = importDelta !== null && importDelta > JS_BUDGET_BYTES; + const cssOver = cssDelta !== null && cssDelta > CSS_BUDGET_BYTES; + if (jsOver || cssOver) + budgetFlags.push({comp: key, importDelta, cssDelta, jsOver, cssOver}); + sizeSection += `| ${inline(key)} | ${sizeText(base.importGzipBytes)} | ${sizeText(head.importGzipBytes)} | ${signedSizeText(importDelta)}${jsOver ? ' ⚠️' : ''} | ${sizeText(base.cssGzipBytes)} | ${sizeText(head.cssGzipBytes)} | ${signedSizeText(cssDelta)}${cssOver ? ' ⚠️' : ''} |\n`; + } + sizeSection += + '\nSizes are gzip. Import counts every module the import reaches; CSS counts the package stylesheet rules its own package modules reference.\n\n'; +} + // Components whose growth passes the touch-only budget. let budgetSection = ''; if (budgetFlags.length > 0) { @@ -332,7 +346,7 @@ const body = `## PR Analysis Report ${diffModeCaveat}${previewAvailabilitySection}${storybookSection}${sandboxSection}${componentSection || '_No new or modified components detected._\n\n'} -${budgetSection}${bundleSection} +${sizeSection}${budgetSection}${bundleSection} ${a11ySection} ${visualSection}--- diff --git a/.github/scripts/generate-pr-comment.test.mjs b/.github/scripts/generate-pr-comment.test.mjs index 2419d54b421f3..c1b02c57ce48e 100644 --- a/.github/scripts/generate-pr-comment.test.mjs +++ b/.github/scripts/generate-pr-comment.test.mjs @@ -234,12 +234,17 @@ describe('generate-pr-comment touch-only budget', () => { fs.writeFileSync( baseFile, JSON.stringify({ - baseComponentStats: {Card: base}, + baseComponentSizes: {'core/BottomSheet': base}, baseBundlePackages: [], }), ); return runComment( - {...baseAnalysis, componentStats: {Card: head}, bundlePackages: []}, + { + ...baseAnalysis, + modifiedComponents: [], + componentSizes: {'core/BottomSheet': head}, + bundlePackages: [], + }, ['--base-stats', baseFile], ); } finally { @@ -247,7 +252,7 @@ describe('generate-pr-comment touch-only budget', () => { } } - it('flags JavaScript growth over 2 KB and CSS growth over 1 KB', () => { + it('flags JavaScript growth over 2 KB and CSS growth over 1 KB, family folders included', () => { const out = withBase( { package: '@astryxdesign/core', @@ -262,9 +267,8 @@ describe('generate-pr-comment touch-only budget', () => { ); expect(out).toContain('### ⚠️ Touch-only size budget'); expect(out).toContain( - '| Import (gzip, transitive) | 6.8KB | 9.8KB | +2.9KB ⚠️ |', + '| core/BottomSheet | 6.8KB | 9.8KB | +2.9KB ⚠️ | 1.9KB | 2.9KB | +1.1KB ⚠️ |', ); - expect(out).toContain('| CSS (gzip) | 1.9KB | 2.9KB | +1.1KB ⚠️ |'); }); it('does not flag growth within budget', () => { @@ -282,7 +286,23 @@ describe('generate-pr-comment touch-only budget', () => { ); expect(out).not.toContain('Touch-only size budget'); expect(out).toContain( - '| Import (gzip, transitive) | 6.8KB | 8.8KB | +2.0KB |', + '| core/BottomSheet | 6.8KB | 8.8KB | +2.0KB | 1.9KB | 2.8KB | +1000B |', ); }); + + it('does not flag a component that is new at head', () => { + const out = withBase( + { + package: '@astryxdesign/core', + importGzipBytes: 90000, + cssGzipBytes: 9000, + }, + { + package: '@astryxdesign/core', + importGzipBytes: null, + cssGzipBytes: null, + }, + ); + expect(out).not.toContain('Touch-only size budget'); + }); }); diff --git a/.github/scripts/lib/component-size.js b/.github/scripts/lib/component-size.js index 0f4ad5deb1247..fd7eb1574a3ac 100644 --- a/.github/scripts/lib/component-size.js +++ b/.github/scripts/lib/component-size.js @@ -50,7 +50,7 @@ function packageStylesheetPath(packageDir) { // modules reached through package imports (other Astryx packages, React, // StyleX, third-party libraries), minified for production. Returns the bundle // text, or null when the import cannot be bundled. -function bundleImportWithInputs(entry) { +function bundleImportWithInputs(entry, exportName) { if (!entry || !fs.existsSync(entry)) return null; try { const esbuild = require('esbuild'); @@ -59,7 +59,9 @@ function bundleImportWithInputs(entry) { absWorkingDir: workingDir, metafile: true, stdin: { - contents: `export * from ${JSON.stringify(path.resolve(entry))};`, + contents: exportName + ? `export {${exportName}} from ${JSON.stringify(path.resolve(entry))};` + : `export * from ${JSON.stringify(path.resolve(entry))};`, resolveDir: path.dirname(path.resolve(entry)), loader: 'js', }, @@ -199,8 +201,8 @@ function rulesForClasses(css, classes) { * Measure one component import: gzip JavaScript it pulls in and gzip CSS of * the stylesheet rules that JavaScript references. Null where unmeasurable. */ -function measureComponentImport(entry, stylesheetText, ownDistDir) { - const bundle = bundleImportWithInputs(entry); +function measureComponentImport(entry, stylesheetText, ownDistDir, exportName) { + const bundle = bundleImportWithInputs(entry, exportName); if (bundle === null) return {importGzipBytes: null, cssGzipBytes: null}; // CSS counts only the rules the component's own package modules reference: // classes from modules reached in other packages belong to their stylesheets. From 79d5be8193031664b63a97f966569c3793e7dc6f Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sat, 24 Oct 2026 15:25:34 -0700 Subject: [PATCH 05/11] fix(ci): the import figure counts only eagerly loaded chunks; aliases are sized through the package entry --- .github/scripts/analyze-pr.js | 20 +++++-- .github/scripts/lib/component-size.js | 63 ++++++++++++++++----- .github/scripts/lib/component-size.test.mjs | 12 ++++ 3 files changed, 76 insertions(+), 19 deletions(-) diff --git a/.github/scripts/analyze-pr.js b/.github/scripts/analyze-pr.js index 5761ceca1bdf8..69b72bfcb4480 100644 --- a/.github/scripts/analyze-pr.js +++ b/.github/scripts/analyze-pr.js @@ -572,15 +572,23 @@ function measureSizes(targets) { const sizes = {}; for (const target of targets) { const dist = path.join(process.cwd(), pkgDist(target.pkg)); - sizes[target.key] = { - package: target.pkg.name, - ...measureComponentImport( - path.join(dist, target.distModule), + let size = measureComponentImport( + path.join(dist, target.distModule), + packageStylesheet(target.pkg), + dist, + target.name, + ); + // An alias (`export {A as B}`) is not exported by its defining module; + // measure it through the package entry instead. + if (size.importGzipBytes === null) { + size = measureComponentImport( + path.join(dist, 'index.js'), packageStylesheet(target.pkg), dist, target.name, - ), - }; + ); + } + sizes[target.key] = {package: target.pkg.name, ...size}; } return sizes; } diff --git a/.github/scripts/lib/component-size.js b/.github/scripts/lib/component-size.js index fd7eb1574a3ac..7f8d72e61ed7c 100644 --- a/.github/scripts/lib/component-size.js +++ b/.github/scripts/lib/component-size.js @@ -52,20 +52,29 @@ function packageStylesheetPath(packageDir) { // text, or null when the import cannot be bundled. function bundleImportWithInputs(entry, exportName) { if (!entry || !fs.existsSync(entry)) return null; + const os = require('node:os'); + const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-size-')); try { const esbuild = require('esbuild'); const workingDir = process.cwd(); + const entryFile = path.join(scratch, 'entry.mjs'); + fs.writeFileSync( + entryFile, + exportName + ? `export {${exportName}} from ${JSON.stringify(path.resolve(entry))};` + : `export * from ${JSON.stringify(path.resolve(entry))};`, + ); + // Code splitting keeps modules reached only through import() in their own + // chunks, so the figure counts what the import loads eagerly: the entry + // chunk and every chunk it imports statically. + const outdir = path.join(scratch, 'out'); const result = esbuild.buildSync({ absWorkingDir: workingDir, + entryPoints: [entryFile], + outdir, metafile: true, - stdin: { - contents: exportName - ? `export {${exportName}} from ${JSON.stringify(path.resolve(entry))};` - : `export * from ${JSON.stringify(path.resolve(entry))};`, - resolveDir: path.dirname(path.resolve(entry)), - loader: 'js', - }, bundle: true, + splitting: true, minify: true, format: 'esm', platform: 'browser', @@ -74,14 +83,42 @@ function bundleImportWithInputs(entry, exportName) { write: false, logLevel: 'silent', }); - return { - text: result.outputFiles[0].text, - inputs: Object.keys(result.metafile.inputs) - .filter(input => input !== '') - .map(input => path.resolve(workingDir, input)), - }; + const outputs = result.metafile.outputs; + const byPath = new Map( + result.outputFiles.map(file => [ + path.relative(workingDir, file.path), + file.text, + ]), + ); + const entryOutput = Object.keys(outputs).find( + key => outputs[key].entryPoint !== undefined, + ); + const eager = new Set(); + const pending = [entryOutput]; + while (pending.length > 0) { + const key = pending.pop(); + if (key === undefined || eager.has(key)) continue; + eager.add(key); + for (const imported of outputs[key].imports || []) { + if (imported.kind === 'import-statement' && !imported.external) { + pending.push(imported.path); + } + } + } + const inputs = new Set(); + let text = ''; + for (const key of eager) { + text += byPath.get(key) ?? ''; + for (const input of Object.keys(outputs[key].inputs || {})) { + inputs.add(path.resolve(workingDir, input)); + } + } + inputs.delete(entryFile); + return {text, inputs: [...inputs]}; } catch { return null; + } finally { + fs.rmSync(scratch, {recursive: true, force: true}); } } diff --git a/.github/scripts/lib/component-size.test.mjs b/.github/scripts/lib/component-size.test.mjs index 3e7d945cda52d..d4da28dcfd0cb 100644 --- a/.github/scripts/lib/component-size.test.mjs +++ b/.github/scripts/lib/component-size.test.mjs @@ -110,6 +110,18 @@ describe('component import size', () => { ).toContain('xbtn9'); }); + it('counts only what the import loads eagerly, not import() targets', () => { + const root = tree({ + 'dist/Menu/index.js': + "export function Menu() { return import('./Touch.js').then(m => m.touch()); }\n", + 'dist/Menu/Touch.js': + "export function touch() { return 'LAZY_TOUCH_CODE'; }\n", + }); + const js = bundleImport(path.join(root, 'dist/Menu/index.js')); + expect(js).not.toContain('LAZY_TOUCH_CODE'); + expect(js).toContain('import('); + }); + it('keeps the @keyframes a kept rule names', () => { const css = '@layer astryx-base{.xspin2{animation-name:xrot1}.xother9{animation-name:xrot9}@keyframes xrot1{from{opacity:0}to{opacity:1}}@keyframes xrot9{to{opacity:1}}}'; From 89ec5a7fb246eede3bfd1c87f6d7146ab857d250 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sat, 24 Oct 2026 16:04:45 -0700 Subject: [PATCH 06/11] fix(ci): CSS counts classes from lazily loaded chunks too; aliases use the ESM entry; no empty-components line beside the size table --- .github/scripts/analyze-pr.js | 3 ++- .github/scripts/generate-pr-comment.js | 2 +- .github/scripts/lib/component-size.js | 8 +++++--- .github/scripts/lib/component-size.test.mjs | 20 ++++++++++++++++++++ 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/.github/scripts/analyze-pr.js b/.github/scripts/analyze-pr.js index 69b72bfcb4480..46d230db60adf 100644 --- a/.github/scripts/analyze-pr.js +++ b/.github/scripts/analyze-pr.js @@ -581,8 +581,9 @@ function measureSizes(targets) { // An alias (`export {A as B}`) is not exported by its defining module; // measure it through the package entry instead. if (size.importGzipBytes === null) { + const {esmPath} = resolveEntries(dist, target.pkg); size = measureComponentImport( - path.join(dist, 'index.js'), + esmPath, packageStylesheet(target.pkg), dist, target.name, diff --git a/.github/scripts/generate-pr-comment.js b/.github/scripts/generate-pr-comment.js index a7f6942fd0bca..b0daf5872c359 100644 --- a/.github/scripts/generate-pr-comment.js +++ b/.github/scripts/generate-pr-comment.js @@ -345,7 +345,7 @@ const diffModeCaveat = const body = `## PR Analysis Report -${diffModeCaveat}${previewAvailabilitySection}${storybookSection}${sandboxSection}${componentSection || '_No new or modified components detected._\n\n'} +${diffModeCaveat}${previewAvailabilitySection}${storybookSection}${sandboxSection}${componentSection || (sizeSection ? '' : '_No new or modified components detected._\n\n')} ${sizeSection}${budgetSection}${bundleSection} ${a11ySection} ${visualSection}--- diff --git a/.github/scripts/lib/component-size.js b/.github/scripts/lib/component-size.js index 7f8d72e61ed7c..e31840de77b55 100644 --- a/.github/scripts/lib/component-size.js +++ b/.github/scripts/lib/component-size.js @@ -105,10 +105,12 @@ function bundleImportWithInputs(entry, exportName) { } } } - const inputs = new Set(); let text = ''; - for (const key of eager) { - text += byPath.get(key) ?? ''; + for (const key of eager) text += byPath.get(key) ?? ''; + // CSS ships in the package stylesheet whether its module loads eagerly or + // through import(), so class names come from every chunk. + const inputs = new Set(); + for (const key of Object.keys(outputs)) { for (const input of Object.keys(outputs[key].inputs || {})) { inputs.add(path.resolve(workingDir, input)); } diff --git a/.github/scripts/lib/component-size.test.mjs b/.github/scripts/lib/component-size.test.mjs index d4da28dcfd0cb..846ddaaf468cc 100644 --- a/.github/scripts/lib/component-size.test.mjs +++ b/.github/scripts/lib/component-size.test.mjs @@ -122,6 +122,26 @@ describe('component import size', () => { expect(js).toContain('import('); }); + it('counts CSS a lazily loaded module references, since it ships in the stylesheet', () => { + const root = tree({ + 'dist/Menu/index.js': + "export function Menu() { return 'xmenu1 ' + import('./Touch.js'); }\n", + 'dist/Menu/Touch.js': "export const touch = 'xtouch1';\n", + }); + const sheet = '.xmenu1{color:red}.xtouch1{padding:44px}.xother1{margin:0}'; + const withLazy = measureComponentImport( + path.join(root, 'dist/Menu/index.js'), + sheet, + path.join(root, 'dist'), + ); + const eagerOnly = measureComponentImport( + path.join(root, 'dist/Menu/index.js'), + '.xmenu1{color:red}.xother1{margin:0}', + path.join(root, 'dist'), + ); + expect(withLazy.cssGzipBytes).toBeGreaterThan(eagerOnly.cssGzipBytes); + }); + it('keeps the @keyframes a kept rule names', () => { const css = '@layer astryx-base{.xspin2{animation-name:xrot1}.xother9{animation-name:xrot9}@keyframes xrot1{from{opacity:0}to{opacity:1}}@keyframes xrot9{to{opacity:1}}}'; From 8eb7e455da96d024e227343b54a40f35af14d658 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sat, 24 Oct 2026 16:05:18 -0700 Subject: [PATCH 07/11] fix(ci): match own-package inputs by real path --- .github/scripts/lib/component-size.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/scripts/lib/component-size.js b/.github/scripts/lib/component-size.js index e31840de77b55..bdc13960fd648 100644 --- a/.github/scripts/lib/component-size.js +++ b/.github/scripts/lib/component-size.js @@ -245,10 +245,13 @@ function measureComponentImport(entry, stylesheetText, ownDistDir, exportName) { if (bundle === null) return {importGzipBytes: null, cssGzipBytes: null}; // CSS counts only the rules the component's own package modules reference: // classes from modules reached in other packages belong to their stylesheets. - const ownDir = path.resolve(ownDistDir || path.dirname(entry)) + path.sep; + // Compare real paths: esbuild reports inputs with symlinks resolved. + const ownDir = + fs.realpathSync(path.resolve(ownDistDir || path.dirname(entry))) + path.sep; const classes = new Set(); for (const input of bundle.inputs) { - if (!input.startsWith(ownDir)) continue; + if (!fs.existsSync(input) || !fs.realpathSync(input).startsWith(ownDir)) + continue; for (const name of classNamesIn(fs.readFileSync(input, 'utf8'))) classes.add(name); } From b5590d799baacc802bdd993aa59e2ec28c1c8166 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sat, 24 Oct 2026 16:39:10 -0700 Subject: [PATCH 08/11] fix(ci): CSS counts only chunks the import can load; headers and footnote describe eager JS and loadable CSS --- .github/scripts/analyze-pr.js | 4 +- .github/scripts/generate-pr-comment.js | 2 +- .github/scripts/lib/component-size.js | 48 +++++++++++++-------- .github/scripts/lib/component-size.test.mjs | 26 ++++++++++- 4 files changed, 56 insertions(+), 24 deletions(-) diff --git a/.github/scripts/analyze-pr.js b/.github/scripts/analyze-pr.js index 46d230db60adf..44fc2180b2ba1 100644 --- a/.github/scripts/analyze-pr.js +++ b/.github/scripts/analyze-pr.js @@ -565,8 +565,8 @@ function sizeTargets(changedFiles) { } // What each target's import costs in the current tree: the gzip JavaScript it -// pulls in, transitive modules included, and the gzip CSS of the package -// stylesheet rules its own package's modules reference (spec:AST-080 FR8, +// loads eagerly, and the gzip CSS of the package stylesheet rules referenced by +// its own package's modules it can load, import() targets included (spec:AST-080 FR8, // architecture:component-style-authoring INV6). function measureSizes(targets) { const sizes = {}; diff --git a/.github/scripts/generate-pr-comment.js b/.github/scripts/generate-pr-comment.js index b0daf5872c359..2f9b6c7a1dc8b 100644 --- a/.github/scripts/generate-pr-comment.js +++ b/.github/scripts/generate-pr-comment.js @@ -205,7 +205,7 @@ if (sizeKeys.length > 0) { sizeSection += `| ${inline(key)} | ${sizeText(base.importGzipBytes)} | ${sizeText(head.importGzipBytes)} | ${signedSizeText(importDelta)}${jsOver ? ' ⚠️' : ''} | ${sizeText(base.cssGzipBytes)} | ${sizeText(head.cssGzipBytes)} | ${signedSizeText(cssDelta)}${cssOver ? ' ⚠️' : ''} |\n`; } sizeSection += - '\nSizes are gzip. Import counts every module the import reaches; CSS counts the package stylesheet rules its own package modules reference.\n\n'; + '\nSizes are gzip. Import counts the JavaScript the import loads eagerly; CSS counts the package stylesheet rules referenced by the modules it can load from its own package, lazily loaded ones included.\n\n'; } // Components whose growth passes the touch-only budget. diff --git a/.github/scripts/lib/component-size.js b/.github/scripts/lib/component-size.js index bdc13960fd648..763fd73f56853 100644 --- a/.github/scripts/lib/component-size.js +++ b/.github/scripts/lib/component-size.js @@ -4,9 +4,10 @@ * @file component-size.js * @input A built package's dist directory, a component's dist entry, and the * package's prebuilt stylesheet - * @output The gzip size of the JavaScript a component's import pulls in - * (one minified bundle of every module it reaches, package imports included) - * and the gzip size of the stylesheet rules that JavaScript references + * @output The gzip size of the JavaScript a component's import loads eagerly + * (the minified entry chunk and the chunks it imports statically, package + * imports included) and the gzip size of the stylesheet rules referenced by + * the modules it can load, import() targets included * @position Shared by analyze-pr.js for the PR size report. Sizes are gzip at * the default compression level (spec:AST-080 FR8, * architecture:component-style-authoring INV6). @@ -46,10 +47,12 @@ function packageStylesheetPath(packageDir) { return null; } -// Bundle what importing `entry` pulls in: every module it reaches, including -// modules reached through package imports (other Astryx packages, React, -// StyleX, third-party libraries), minified for production. Returns the bundle -// text, or null when the import cannot be bundled. +// Bundle what importing `entry` loads eagerly: the entry chunk and the chunks +// it imports statically, including modules reached through package imports +// (other Astryx packages, React, StyleX, third-party libraries), minified for +// production. Also returns the modules of every chunk the import can load, +// import() targets included, for CSS. Returns null when the import cannot be +// bundled. function bundleImportWithInputs(entry, exportName) { if (!entry || !fs.existsSync(entry)) return null; const os = require('node:os'); @@ -93,24 +96,31 @@ function bundleImportWithInputs(entry, exportName) { const entryOutput = Object.keys(outputs).find( key => outputs[key].entryPoint !== undefined, ); - const eager = new Set(); - const pending = [entryOutput]; - while (pending.length > 0) { - const key = pending.pop(); - if (key === undefined || eager.has(key)) continue; - eager.add(key); - for (const imported of outputs[key].imports || []) { - if (imported.kind === 'import-statement' && !imported.external) { - pending.push(imported.path); + // Chunks the entry reaches, following only the given import kinds. + function reachable(kinds) { + const seen = new Set(); + const pending = [entryOutput]; + while (pending.length > 0) { + const key = pending.pop(); + if (key === undefined || seen.has(key)) continue; + seen.add(key); + for (const imported of outputs[key].imports || []) { + if (kinds.includes(imported.kind) && !imported.external) { + pending.push(imported.path); + } } } + return seen; } let text = ''; - for (const key of eager) text += byPath.get(key) ?? ''; + for (const key of reachable(['import-statement'])) { + text += byPath.get(key) ?? ''; + } // CSS ships in the package stylesheet whether its module loads eagerly or - // through import(), so class names come from every chunk. + // through import(), so class names come from every chunk the entry can + // load, dynamic imports included; chunks no kept code imports are ignored. const inputs = new Set(); - for (const key of Object.keys(outputs)) { + for (const key of reachable(['import-statement', 'dynamic-import'])) { for (const input of Object.keys(outputs[key].inputs || {})) { inputs.add(path.resolve(workingDir, input)); } diff --git a/.github/scripts/lib/component-size.test.mjs b/.github/scripts/lib/component-size.test.mjs index 846ddaaf468cc..2566ddf3d1f48 100644 --- a/.github/scripts/lib/component-size.test.mjs +++ b/.github/scripts/lib/component-size.test.mjs @@ -3,8 +3,8 @@ /** * @file component-size.test.mjs * Pins how the PR size report measures one component import: the JavaScript - * it pulls in transitively (other packages external) and the stylesheet rules - * that JavaScript references. + * it loads eagerly and the stylesheet rules referenced by the modules it can + * load. */ import fs from 'node:fs'; @@ -142,6 +142,28 @@ describe('component import size', () => { expect(withLazy.cssGzipBytes).toBeGreaterThan(eagerOnly.cssGzipBytes); }); + it('ignores CSS behind an import() in an export the import does not use', () => { + const root = tree({ + 'dist/Menu/index.js': + "export function Menu() { return 'xmenu1'; }\nexport function Unused() { return import('./Touch.js'); }\n", + 'dist/Menu/Touch.js': "export const touch = 'xtouch1';\n", + }); + const sheet = '.xmenu1{color:red}.xtouch1{padding:44px}'; + const used = measureComponentImport( + path.join(root, 'dist/Menu/index.js'), + sheet, + path.join(root, 'dist'), + 'Menu', + ); + const menuOnly = measureComponentImport( + path.join(root, 'dist/Menu/index.js'), + '.xmenu1{color:red}', + path.join(root, 'dist'), + 'Menu', + ); + expect(used.cssGzipBytes).toBe(menuOnly.cssGzipBytes); + }); + it('keeps the @keyframes a kept rule names', () => { const css = '@layer astryx-base{.xspin2{animation-name:xrot1}.xother9{animation-name:xrot9}@keyframes xrot1{from{opacity:0}to{opacity:1}}@keyframes xrot9{to{opacity:1}}}'; From 111def4d6dd5eec5ce46164f04458611d7bef72d Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sat, 24 Oct 2026 17:17:35 -0700 Subject: [PATCH 09/11] fix(ci): size every component whose bundle reaches a changed module, shared hooks and style modules included --- .github/scripts/analyze-pr.js | 76 +++++++++++++++++---------- .github/scripts/analyze-pr.test.mjs | 43 +++++++++++++++ .github/scripts/lib/component-size.js | 48 +++++++++++++++++ 3 files changed, 140 insertions(+), 27 deletions(-) diff --git a/.github/scripts/analyze-pr.js b/.github/scripts/analyze-pr.js index 44fc2180b2ba1..210af841df3ed 100644 --- a/.github/scripts/analyze-pr.js +++ b/.github/scripts/analyze-pr.js @@ -17,6 +17,7 @@ const { execSync } = require('node:child_process'); const { gzipBytes: gzipTextBytes, measureComponentImport, + bundleInputsByEntry, packageStylesheetPath, } = require('./lib/component-size'); const { @@ -524,41 +525,62 @@ function packageStylesheet(pkg) { return stylesheetCache.get(file); } -// Every canonical public component whose implementation lives in a changed -// directory (nested packages) or file (flat packages), with the dist module -// that defines its export. Family folders that hold several components yield -// each of them. +// Every canonical public component of a package, with the dist module that +// defines its export. +function packageTargets(pkg) { + if (pkg.layout === 'flat') { + return getComponentNames(pkg).map((name) => ({ + key: `${pkg.packageName}/${name}`, + pkg, + name, + distModule: `${name}.js`, + })); + } + const sourceDir = path.join(process.cwd(), pkgSrc(pkg)); + const exports = componentExportsFromBarrel(path.join(sourceDir, 'index.ts'), sourceDir); + const targets = []; + for (const name of nestedPackageComponentNames(process.cwd(), pkg)) { + const file = exports.get(name); + if (!file) continue; + targets.push({ + key: `${pkg.packageName}/${name}`, + pkg, + name, + distModule: path.relative(sourceDir, file).replace(/\.tsx?$/, '.js'), + }); + } + return targets; +} + +// Every canonical public component a change can affect: its own folder or +// file changed, or its bundle, import() targets included, reaches a changed +// module of its package, such as a shared hook or style module. Family folders +// that hold several components yield each of them. function sizeTargets(changedFiles) { + const withoutExtension = (file) => file.replace(/\.(tsx?|jsx?|mjs)$/, ''); const targets = []; for (const pkg of PACKAGES) { - const sourceDir = path.join(process.cwd(), pkgSrc(pkg)); const prefix = pkgSrc(pkg) + '/'; - const changed = new Set( + const changedModules = new Set( changedFiles .filter((file) => file.startsWith(prefix)) - .map((file) => file.slice(prefix.length).split('/')[0].replace(/\.tsx?$/, '')), + .map((file) => withoutExtension(file.slice(prefix.length))), ); - if (changed.size === 0) continue; - if (pkg.layout === 'flat') { - for (const name of getComponentNames(pkg)) { - if (changed.has(name)) { - targets.push({ key: `${pkg.packageName}/${name}`, pkg, name, distModule: `${name}.js` }); - } - } - continue; - } - const exports = componentExportsFromBarrel(path.join(sourceDir, 'index.ts'), sourceDir); - for (const name of nestedPackageComponentNames(process.cwd(), pkg)) { - const file = exports.get(name); - if (!file) continue; - const relative = path.relative(sourceDir, file); - if (!changed.has(relative.split(path.sep)[0])) continue; - targets.push({ - key: `${pkg.packageName}/${name}`, - pkg, - name, - distModule: relative.replace(/\.tsx?$/, '.js'), + if (changedModules.size === 0) continue; + const changedTops = new Set([...changedModules].map((file) => file.split('/')[0])); + const dist = path.join(process.cwd(), pkgDist(pkg)); + // esbuild reports inputs by real path. + const distReal = fs.existsSync(dist) ? fs.realpathSync(dist) : dist; + const all = packageTargets(pkg); + const reach = bundleInputsByEntry(all.map((target) => path.join(dist, target.distModule))); + for (const target of all) { + const top = withoutExtension(target.distModule.split('/')[0]); + const inputs = reach?.get(path.join(dist, target.distModule)) ?? []; + const reachesChange = inputs.some((input) => { + const relative = path.relative(distReal, input); + return !relative.startsWith('..') && changedModules.has(withoutExtension(relative)); }); + if (changedTops.has(top) || reachesChange) targets.push(target); } } return targets; diff --git a/.github/scripts/analyze-pr.test.mjs b/.github/scripts/analyze-pr.test.mjs index b678b4dcdd967..38da8ed1229a6 100644 --- a/.github/scripts/analyze-pr.test.mjs +++ b/.github/scripts/analyze-pr.test.mjs @@ -281,6 +281,49 @@ describe('analyze-pr component ownership', () => { }); describe('analyze-pr base and head sizes', () => { + it('sizes a component whose bundle reaches a changed shared module', () => { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-analyze-reach-')); + const write = (file, content) => { + fs.mkdirSync(path.dirname(path.join(repo, file)), {recursive: true}); + fs.writeFileSync(path.join(repo, file), content); + }; + try { + write('package.json', '{}'); + write('.gitignore', 'dist/\n*.json.out\nanalysis.json\n'); + write('packages/core/package.json', '{"type": "module"}\n'); + write('packages/core/src/index.ts', "export {Card} from './Card';\nexport {Badge} from './Badge';\n"); + write('packages/core/src/Card/index.ts', "export {Card} from './Card';\n"); + write('packages/core/src/Card/Card.tsx', "import {useHold} from '../hooks/useHold';\nexport function Card() { return useHold(); }\n"); + write('packages/core/src/Card/Card.doc.mjs', "export const docs = {\n name: 'Card',\n};\n"); + write('packages/core/src/Badge/index.ts', "export {Badge} from './Badge';\n"); + write('packages/core/src/Badge/Badge.tsx', 'export function Badge() {}\n'); + write('packages/core/src/Badge/Badge.doc.mjs', "export const docs = {\n name: 'Badge',\n};\n"); + write('packages/core/src/hooks/useHold.ts', 'export function useHold() { return 1; }\n'); + git(repo, ['init', '-q', '-b', 'main']); + git(repo, ['config', 'user.email', 'test@test.co']); + git(repo, ['config', 'user.name', 'Test']); + git(repo, ['add', '-A']); + git(repo, ['commit', '-qm', 'base']); + git(repo, ['checkout', '-q', '-b', 'feature']); + write('packages/core/src/hooks/useHold.ts', 'export function useHold() { return 2; }\n'); + git(repo, ['commit', '-qam', 'touch the shared hook only']); + write('packages/core/dist/Card/Card.js', "import {useHold} from '../hooks/useHold.js';\nexport function Card() { return useHold(); }\n"); + write('packages/core/dist/Badge/Badge.js', 'export function Badge() { return 0; }\n'); + write('packages/core/dist/hooks/useHold.js', 'export function useHold() { return 2; }\n'); + write('packages/core/dist/index.js', "export {Card} from './Card/Card.js';\nexport {Badge} from './Badge/Badge.js';\n"); + + execFileSync( + process.execPath, + [SCRIPT, '--base', 'main', '--head', 'HEAD', '--output', 'analysis.json'], + {cwd: repo, encoding: 'utf8'}, + ); + const analysis = JSON.parse(fs.readFileSync(path.join(repo, 'analysis.json'), 'utf8')); + expect(Object.keys(analysis.componentSizes)).toEqual(['core/Card']); + } finally { + fs.rmSync(repo, {recursive: true, force: true}); + } + }); + it('measures ESM entries by module type and reads base sizes from a merge-base build', () => { const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-analyze-size-')); const baseRoot = path.join(repo, '.size-base'); diff --git a/.github/scripts/lib/component-size.js b/.github/scripts/lib/component-size.js index 763fd73f56853..622931e8e3a20 100644 --- a/.github/scripts/lib/component-size.js +++ b/.github/scripts/lib/component-size.js @@ -134,6 +134,53 @@ function bundleImportWithInputs(entry, exportName) { } } +// The modules each entry's bundle reaches, import() targets included, from one +// build over every entry: Map of absolute entry path to absolute input paths. +// Returns null when the entries cannot be bundled together. +function bundleInputsByEntry(entries) { + const existing = entries.filter(entry => fs.existsSync(entry)); + if (existing.length === 0) return new Map(); + const os = require('node:os'); + const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-reach-')); + try { + const esbuild = require('esbuild'); + const workingDir = process.cwd(); + const result = esbuild.buildSync({ + absWorkingDir: workingDir, + entryPoints: existing, + outdir: scratch, + metafile: true, + bundle: true, + format: 'esm', + platform: 'browser', + define: {'process.env.NODE_ENV': '"production"'}, + loader: {'.css': 'empty'}, + write: false, + logLevel: 'silent', + }); + const reach = new Map(); + for (const output of Object.values(result.metafile.outputs)) { + if (output.entryPoint === undefined) continue; + reach.set( + fs.realpathSync(path.resolve(workingDir, output.entryPoint)), + Object.keys(output.inputs).map(input => + path.resolve(workingDir, input), + ), + ); + } + // Key by the paths callers passed, which may differ from real paths. + const byEntry = new Map(); + for (const entry of existing) { + byEntry.set(entry, reach.get(fs.realpathSync(entry)) ?? []); + } + return byEntry; + } catch { + return null; + } finally { + fs.rmSync(scratch, {recursive: true, force: true}); + } +} + function bundleImport(entry) { return bundleImportWithInputs(entry)?.text ?? null; } @@ -279,4 +326,5 @@ module.exports = { classNamesIn, rulesForClasses, measureComponentImport, + bundleInputsByEntry, }; From e69e3231ce5e0530be3c5f04c6d5bbd0328d737f Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sat, 24 Oct 2026 17:50:33 -0700 Subject: [PATCH 10/11] fix(ci): decide reach in the merge-base build too and measure the union --- .github/scripts/analyze-pr.js | 15 ++++++-- .github/scripts/analyze-pr.test.mjs | 47 ++++++++++++++++++++++++++ .github/scripts/generate-pr-comment.js | 7 +++- 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/.github/scripts/analyze-pr.js b/.github/scripts/analyze-pr.js index 210af841df3ed..513d220e5c3b0 100644 --- a/.github/scripts/analyze-pr.js +++ b/.github/scripts/analyze-pr.js @@ -586,6 +586,13 @@ function sizeTargets(changedFiles) { return targets; } +// Targets from either tree, once each by key. +function unionTargets(first, second) { + const byKey = new Map(first.map((target) => [target.key, target])); + for (const target of second) if (!byKey.has(target.key)) byKey.set(target.key, target); + return [...byKey.values()]; +} + // What each target's import costs in the current tree: the gzip JavaScript it // loads eagerly, and the gzip CSS of the package stylesheet rules referenced by // its own package's modules it can load, import() targets included (spec:AST-080 FR8, @@ -774,8 +781,10 @@ function analyze() { // and package lists stay the head's; only the stats are read in the base tree. let baseComponentStats = null; let baseBundlePackages = null; - const targets = sizeTargets(changedFiles); - const componentSizes = baseOnly ? null : measureSizes(targets); + // Components a change can affect, by reach in the head build and, when the + // merge base is built, in the base build too: CI's size-base job builds only + // the base, and a module change can add or remove reach. + let targets = sizeTargets(changedFiles); let baseComponentSizes = null; if (baseRoot) { const headDir = process.cwd(); @@ -789,11 +798,13 @@ function analyze() { baseBundlePackages = PACKAGES.filter((p) => changedPackages.has(p.name)).map((p) => getPackageBundleStats(p), ); + targets = unionTargets(targets, sizeTargets(changedFiles)); baseComponentSizes = measureSizes(targets); } finally { process.chdir(headDir); } } + const componentSizes = baseOnly ? null : measureSizes(targets); if (baseOnly) { const baseResult = { diff --git a/.github/scripts/analyze-pr.test.mjs b/.github/scripts/analyze-pr.test.mjs index 38da8ed1229a6..92a2235d0a337 100644 --- a/.github/scripts/analyze-pr.test.mjs +++ b/.github/scripts/analyze-pr.test.mjs @@ -324,6 +324,53 @@ describe('analyze-pr base and head sizes', () => { } }); + it('decides reach in the merge-base build when only the base is built', () => { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-analyze-basereach-')); + const baseRoot = path.join(repo, '.size-base'); + const write = (root, file, content) => { + fs.mkdirSync(path.dirname(path.join(root, file)), {recursive: true}); + fs.writeFileSync(path.join(root, file), content); + }; + try { + write(repo, 'package.json', '{}'); + write(repo, '.gitignore', 'dist/\n.size-base/\nbase.json\n'); + write(repo, 'packages/core/package.json', '{"type": "module"}\n'); + write(repo, 'packages/core/src/index.ts', "export {Card} from './Card';\nexport {Badge} from './Badge';\n"); + write(repo, 'packages/core/src/Card/index.ts', "export {Card} from './Card';\n"); + write(repo, 'packages/core/src/Card/Card.tsx', "import {useHold} from '../hooks/useHold';\nexport function Card() { return useHold(); }\n"); + write(repo, 'packages/core/src/Card/Card.doc.mjs', "export const docs = {\n name: 'Card',\n};\n"); + write(repo, 'packages/core/src/Badge/index.ts', "export {Badge} from './Badge';\n"); + write(repo, 'packages/core/src/Badge/Badge.tsx', 'export function Badge() {}\n'); + write(repo, 'packages/core/src/Badge/Badge.doc.mjs', "export const docs = {\n name: 'Badge',\n};\n"); + write(repo, 'packages/core/src/hooks/useHold.ts', 'export function useHold() { return 1; }\n'); + git(repo, ['init', '-q', '-b', 'main']); + git(repo, ['config', 'user.email', 'test@test.co']); + git(repo, ['config', 'user.name', 'Test']); + git(repo, ['add', '-A']); + git(repo, ['commit', '-qm', 'base']); + git(repo, ['worktree', 'add', '-q', '--detach', baseRoot, 'HEAD']); + git(repo, ['checkout', '-q', '-b', 'feature']); + write(repo, 'packages/core/src/hooks/useHold.ts', 'export function useHold() { return 2; }\n'); + git(repo, ['commit', '-qam', 'touch the shared hook only']); + // Only the merge base is built, as in the size-base job. + write(baseRoot, 'packages/core/dist/Card/Card.js', "import {useHold} from '../hooks/useHold.js';\nexport function Card() { return useHold(); }\n"); + write(baseRoot, 'packages/core/dist/Badge/Badge.js', 'export function Badge() { return 0; }\n'); + write(baseRoot, 'packages/core/dist/hooks/useHold.js', 'export function useHold() { return 1; }\n'); + write(baseRoot, 'packages/core/dist/index.js', "export {Card} from './Card/Card.js';\nexport {Badge} from './Badge/Badge.js';\n"); + + execFileSync( + process.execPath, + [SCRIPT, '--base', 'main', '--head', 'HEAD', '--base-root', '.size-base', '--base-only', '--output', 'base.json'], + {cwd: repo, encoding: 'utf8'}, + ); + const base = JSON.parse(fs.readFileSync(path.join(repo, 'base.json'), 'utf8')); + expect(Object.keys(base.baseComponentSizes)).toEqual(['core/Card']); + expect(base.baseComponentSizes['core/Card'].importGzipBytes).toBeGreaterThan(0); + } finally { + fs.rmSync(repo, {recursive: true, force: true}); + } + }); + it('measures ESM entries by module type and reads base sizes from a merge-base build', () => { const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-analyze-size-')); const baseRoot = path.join(repo, '.size-base'); diff --git a/.github/scripts/generate-pr-comment.js b/.github/scripts/generate-pr-comment.js index 2f9b6c7a1dc8b..a5b87c81c4185 100644 --- a/.github/scripts/generate-pr-comment.js +++ b/.github/scripts/generate-pr-comment.js @@ -186,7 +186,12 @@ if (analysis.modifiedComponents && analysis.modifiedComponents.length > 0) { let sizeSection = ''; const componentSizes = analysis.componentSizes || {}; const baseComponentSizes = analysis.baseComponentSizes || {}; -const sizeKeys = Object.keys(componentSizes).sort(); +const sizeKeys = [ + ...new Set([ + ...Object.keys(componentSizes), + ...Object.keys(baseComponentSizes), + ]), +].sort(); if (sizeKeys.length > 0) { sizeSection += '### Component Size\n\n'; sizeSection += From fbace5c146cfe4b6cfcb360b36480a98812367b1 Mon Sep 17 00:00:00 2001 From: Cindy Zhang Date: Sat, 24 Oct 2026 18:28:38 -0700 Subject: [PATCH 11/11] fix(ci): measure each tree through its own defining modules --- .github/scripts/analyze-pr.js | 10 ++++--- .github/scripts/analyze-pr.test.mjs | 42 +++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/.github/scripts/analyze-pr.js b/.github/scripts/analyze-pr.js index 513d220e5c3b0..ee2365b36f56d 100644 --- a/.github/scripts/analyze-pr.js +++ b/.github/scripts/analyze-pr.js @@ -784,7 +784,8 @@ function analyze() { // Components a change can affect, by reach in the head build and, when the // merge base is built, in the base build too: CI's size-base job builds only // the base, and a module change can add or remove reach. - let targets = sizeTargets(changedFiles); + const headTargets = sizeTargets(changedFiles); + let targets = headTargets; let baseComponentSizes = null; if (baseRoot) { const headDir = process.cwd(); @@ -798,8 +799,11 @@ function analyze() { baseBundlePackages = PACKAGES.filter((p) => changedPackages.has(p.name)).map((p) => getPackageBundleStats(p), ); - targets = unionTargets(targets, sizeTargets(changedFiles)); - baseComponentSizes = measureSizes(targets); + // Each tree is measured through its own defining modules, so a renamed + // module is not mistaken for a missing one. + const baseTargets = sizeTargets(changedFiles); + baseComponentSizes = measureSizes(unionTargets(baseTargets, headTargets)); + targets = unionTargets(headTargets, baseTargets); } finally { process.chdir(headDir); } diff --git a/.github/scripts/analyze-pr.test.mjs b/.github/scripts/analyze-pr.test.mjs index 92a2235d0a337..7f9e433add79a 100644 --- a/.github/scripts/analyze-pr.test.mjs +++ b/.github/scripts/analyze-pr.test.mjs @@ -371,6 +371,48 @@ describe('analyze-pr base and head sizes', () => { } }); + it('measures the merge base through its own defining module when the pull request renames it', () => { + const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-analyze-rename-')); + const baseRoot = path.join(repo, '.size-base'); + const write = (root, file, content) => { + fs.mkdirSync(path.dirname(path.join(root, file)), {recursive: true}); + fs.writeFileSync(path.join(root, file), content); + }; + try { + write(repo, 'package.json', '{}'); + write(repo, '.gitignore', 'dist/\n.size-base/\nbase.json\n'); + write(repo, 'packages/core/package.json', '{"type": "module"}\n'); + write(repo, 'packages/core/src/index.ts', "export {Card} from './Card';\n"); + write(repo, 'packages/core/src/Card/index.ts', "export {Card} from './Card';\n"); + write(repo, 'packages/core/src/Card/Card.tsx', 'export function Card() {}\n'); + write(repo, 'packages/core/src/Card/Card.doc.mjs', "export const docs = {\n name: 'Card',\n};\n"); + git(repo, ['init', '-q', '-b', 'main']); + git(repo, ['config', 'user.email', 'test@test.co']); + git(repo, ['config', 'user.name', 'Test']); + git(repo, ['add', '-A']); + git(repo, ['commit', '-qm', 'base']); + git(repo, ['worktree', 'add', '-q', '--detach', baseRoot, 'HEAD']); + git(repo, ['checkout', '-q', '-b', 'feature']); + git(repo, ['mv', 'packages/core/src/Card/Card.tsx', 'packages/core/src/Card/CardView.tsx']); + write(repo, 'packages/core/src/Card/index.ts', "export {Card} from './CardView';\n"); + git(repo, ['commit', '-qam', 'rename the defining module']); + // Only the merge base is built; its package entry does not export Card, + // so a figure proves the defining module was measured. + write(baseRoot, 'packages/core/dist/Card/Card.js', 'export function Card() { return 1; }\n'); + write(baseRoot, 'packages/core/dist/index.js', 'export const other = 1;\n'); + + execFileSync( + process.execPath, + [SCRIPT, '--base', 'main', '--head', 'HEAD', '--base-root', '.size-base', '--base-only', '--output', 'base.json'], + {cwd: repo, encoding: 'utf8'}, + ); + const base = JSON.parse(fs.readFileSync(path.join(repo, 'base.json'), 'utf8')); + expect(base.baseComponentSizes['core/Card'].importGzipBytes).toBeGreaterThan(0); + } finally { + fs.rmSync(repo, {recursive: true, force: true}); + } + }); + it('measures ESM entries by module type and reads base sizes from a merge-base build', () => { const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-analyze-size-')); const baseRoot = path.join(repo, '.size-base');