diff --git a/.github/scripts/frontend-js-size.mts b/.github/scripts/frontend-js-size.mts index 4d6dd0a0bc..04a92679c7 100644 --- a/.github/scripts/frontend-js-size.mts +++ b/.github/scripts/frontend-js-size.mts @@ -40,13 +40,24 @@ function escapeCell(value: string) { type Manifest = Record; +const stableNamedChunks = new Set(['vue', 'i18n']); + type FileEntry = { - key: string; + comparisonKey: string | null; displayName: string; file: string; + manifestKeys: string[]; size: number; }; +type CollectedReport = { + manifest: Manifest; + chunks: FileEntry[]; + comparableChunks: Record; + chunksByManifestKey: Record; + startupFiles: string[]; +}; + function entryDisplayName(entry: FileEntry) { if (!entry) return ''; return entry.displayName || entry.file; @@ -60,23 +71,26 @@ function findEntryKey(manifest: Manifest) { ?? null; } -function stableChunkKey(manifestKey: string, chunk: Manifest[string]) { - return chunk.src ?? (chunk.name ? `chunk:${chunk.name}` : manifestKey); +function stableChunkKey(chunk: Manifest[string]) { + if (chunk.src != null) return `src:${util.normalizePath(chunk.src)}`; + if (chunk.name != null && stableNamedChunks.has(chunk.name)) return `named:${chunk.name}`; + return null; } -function collectStartupKeys(manifest: Manifest) { +function collectStartupManifestKeys(manifest: Manifest) { const entryKey = findEntryKey(manifest); const keys = new Set(); - if (entryKey == null) return keys; + if (entryKey == null) throw new Error('Unable to find frontend startup entry in Vite manifest.'); - function visit(key: string) { + function visit(key: string, importedBy?: string) { if (keys.has(key)) return; const chunk = manifest[key]; - if (!chunk || !chunk.file?.endsWith('.js')) return; - keys.add(stableChunkKey(key, chunk)); - for (const importKey of chunk.imports ?? []) { - visit(importKey); - } + const importContext = importedBy == null ? '' : ` imported by "${importedBy}"`; + if (chunk == null) throw new Error(`Startup manifest key "${key}"${importContext} is missing.`); + if (chunk.file == null || chunk.file.length === 0) throw new Error(`Startup manifest key "${key}"${importContext} has no output file.`); + if (!chunk.file.endsWith('.js')) throw new Error(`Startup manifest key "${key}"${importContext} resolves to non-JavaScript output "${chunk.file}".`); + keys.add(key); + for (const importKey of chunk.imports ?? []) visit(importKey, key); } visit(entryKey); @@ -102,26 +116,41 @@ async function resolveBuiltFile(outDir: string, file: string) { }; } -async function collectReport(repoDir: string) { +async function collectReport(repoDir: string): Promise { const outDir = path.join(repoDir, 'built/_frontend_vite_'); const manifestPath = path.join(outDir, 'manifest.json'); const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Manifest; - const byKey = new Map(); - const byFile = new Set(); + const chunksByFile = new Map(); + const comparableChunks = new Map(); + const chunksByManifestKey = new Map(); - for (const [key, chunk] of Object.entries(manifest)) { + for (const [manifestKey, chunk] of Object.entries(manifest)) { if (!chunk.file?.endsWith('.js')) continue; const builtFile = await resolveBuiltFile(outDir, chunk.file); - const size = await util.fileSize(builtFile.absolutePath); - const stableKey = stableChunkKey(key, chunk); - const displayName = chunk.src ?? chunk.name ?? key; - byKey.set(stableKey, { - key: stableKey, - displayName, - file: builtFile.relativePath, - size, - }); - byFile.add(builtFile.relativePath); + const comparisonKey = stableChunkKey(chunk); + let entry = chunksByFile.get(builtFile.relativePath); + if (entry == null) { + entry = { + comparisonKey, + displayName: chunk.src ?? chunk.name ?? manifestKey, + file: builtFile.relativePath, + manifestKeys: [manifestKey], + size: await util.fileSize(builtFile.absolutePath), + }; + chunksByFile.set(entry.file, entry); + } else if (entry.comparisonKey !== comparisonKey) { + throw new Error(`Conflicting identities for ${entry.file}`); + } else { + entry.manifestKeys.push(manifestKey); + } + chunksByManifestKey.set(manifestKey, entry); + if (comparisonKey != null) { + const existing = comparableChunks.get(comparisonKey); + if (existing != null && existing.file !== entry.file) { + throw new Error(`Duplicate stable chunk key "${comparisonKey}": ${existing.file}, ${entry.file}`); + } + comparableChunks.set(comparisonKey, entry); + } } const localeDir = path.join(outDir, locale); @@ -129,21 +158,29 @@ async function collectReport(repoDir: string) { for await (const fullPath of util.traverseDirectory(localeDir)) { if (!fullPath.endsWith('.js')) continue; const relativePath = util.normalizePath(path.relative(outDir, fullPath)); - if (byFile.has(relativePath)) continue; - const size = await util.fileSize(fullPath); - byKey.set(relativePath, { - key: relativePath, + if (chunksByFile.has(relativePath)) continue; + chunksByFile.set(relativePath, { + comparisonKey: null, displayName: relativePath, file: relativePath, - size, + manifestKeys: [], + size: await util.fileSize(fullPath), }); } } + const startupFiles = new Set(); + for (const manifestKey of collectStartupManifestKeys(manifest)) { + const entry = chunksByManifestKey.get(manifestKey); + if (entry != null) startupFiles.add(entry.file); + } + return { manifest, - chunks: Object.fromEntries(byKey), - startupKeys: [...collectStartupKeys(manifest)], + chunks: [...chunksByFile.values()], + comparableChunks: Object.fromEntries(comparableChunks), + chunksByManifestKey: Object.fromEntries(chunksByManifestKey), + startupFiles: [...startupFiles], }; } @@ -312,16 +349,17 @@ function renderVisualizerSummaryTable(before: ReturnType>, after: Awaited>) { - return keys.map((key) => { - const beforeEntry = before.chunks[key]; - const afterEntry = after.chunks[key]; +function getChunkComparisonRows(keys: string[], before: Record, after: Record) { + return keys.map(key => { + const beforeEntry = before[key]; + const afterEntry = after[key]; const beforeSize = beforeEntry?.size ?? 0; const afterSize = afterEntry?.size ?? 0; return { key, name: entryDisplayName(beforeEntry ?? afterEntry), - chunkFile: beforeEntry?.file ?? afterEntry?.file, + beforeFile: beforeEntry?.file, + afterFile: afterEntry?.file, beforeSize, afterSize, changeType: beforeEntry == null ? 'added' : afterEntry == null ? 'removed' : beforeSize !== afterSize ? 'updated' : 'unchanged', @@ -330,6 +368,36 @@ function getChunkComparisonRows(keys: string[], before: Awaited sum + chunk.size, 0); +} + +function generatedAggregate(before: FileEntry[], after: FileEntry[]): ChunkAggregate { + const beforeGenerated = before.filter(chunk => chunk.comparisonKey == null); + const afterGenerated = after.filter(chunk => chunk.comparisonKey == null); + return { + beforeSize: sumChunkSizes(beforeGenerated), + afterSize: sumChunkSizes(afterGenerated), + beforeCount: beforeGenerated.length, + afterCount: afterGenerated.length, + }; +} + +function comparableMap(chunks: FileEntry[]) { + const entries: [string, FileEntry][] = []; + for (const chunk of chunks) { + if (chunk.comparisonKey != null) entries.push([chunk.comparisonKey, chunk]); + } + return Object.fromEntries(entries); +} + function summarizeChunkChanges(rows: ReturnType) { return { updated: rows.filter((row) => row.changeType === 'updated').length, @@ -349,60 +417,81 @@ function compareChunkComparisonRows(a: ReturnType || a.name.localeCompare(b.name); } -function chunkMarkdownTable(rows: ReturnType, total?: { beforeSize: number; afterSize: number }) { - if (rows.length === 0) return '_No data_'; +function chunkFileDisplay(row: ReturnType[number]) { + if (row.beforeFile == null) return row.afterFile ?? ''; + if (row.afterFile == null || row.beforeFile === row.afterFile) return row.beforeFile; + return `${row.beforeFile} → ${row.afterFile}`; +} + +function chunkMarkdownTable( + rows: ReturnType, + total?: { beforeSize: number; afterSize: number }, + generated?: ChunkAggregate, +) { + if (rows.length === 0 && total == null && generated == null) return '_No data_'; const lines = [ '| Chunk | Before | After | Δ | Δ (%) |', '| --- | ---: | ---: | ---: | ---: |', ]; + let hasSummaryRow = false; if (total != null) { lines.push(`| (total) | ${util.formatBytes(total.beforeSize)} | ${util.formatBytes(total.afterSize)} | ${util.calcAndFormatDeltaBytes(total.beforeSize, total.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(total.beforeSize, total.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); - lines.push('| | | | | |'); + hasSummaryRow = true; } + if (generated != null && (generated.beforeCount > 0 || generated.afterCount > 0)) { + lines.push(`| (other generated chunks) | ${util.formatBytes(generated.beforeSize)} | ${util.formatBytes(generated.afterSize)} | ${util.calcAndFormatDeltaBytes(generated.beforeSize, generated.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(generated.beforeSize, generated.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); + hasSummaryRow = true; + } + if (hasSummaryRow && rows.length > 0) lines.push('| | | | | |'); + for (const row of rows) { + const chunkFile = chunkFileDisplay(row); if (row.changeType === 'added') { - lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(row.chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`); + lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{orange}{\\text{( + )}}$ |`); } else if (row.changeType === 'removed') { - lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(row.chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`); + lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | $\\color{green}{\\text{( - )}}$ |`); } else { - lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(row.chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); + lines.push(`|
\`${escapeCell(row.name)}\` \`${escapeCell(chunkFile)}\`
| ${util.formatBytes(row.beforeSize)} | ${util.formatBytes(row.afterSize)} | ${util.calcAndFormatDeltaBytes(row.beforeSize, row.afterSize, 1000)} | ${util.calcAndFormatDeltaPercent(row.beforeSize, row.afterSize, 0.1).replaceAll('\\%', '\\\\%')} |`); } } + if (generated != null && (generated.beforeCount > 0 || generated.afterCount > 0)) { + lines.push(''); + lines.push(`_${generated.beforeCount} before / ${generated.afterCount} after generated chunks are grouped._`); + } return lines.join('\n'); } function renderFrontendChunkReport(before: Awaited>, after: Awaited>) { - const commonChunkKeys = Object.keys(before.chunks).filter((key) => after.chunks[key] != null); - const addedChunkKeys = Object.keys(after.chunks).filter((key) => before.chunks[key] == null); - const removedChunkKeys = Object.keys(before.chunks).filter((key) => after.chunks[key] == null); - const allChunkKeys = [ - ...commonChunkKeys, - ...addedChunkKeys, - ...removedChunkKeys, - ]; - //const comparisonRows = getChunkComparisonRows(commonChunkKeys, before, after); - const allComparisonRows = getChunkComparisonRows(allChunkKeys, before, after); + const beforeComparable = before.comparableChunks; + const afterComparable = after.comparableChunks; + const allChunkKeys = [...new Set([...Object.keys(beforeComparable), ...Object.keys(afterComparable)])]; + const allComparisonRows = getChunkComparisonRows(allChunkKeys, beforeComparable, afterComparable); const changedRows = allComparisonRows.filter((row) => row.changeType !== 'unchanged'); const diffSummary = summarizeChunkChanges(changedRows); const diffTotal = { - beforeSize: allComparisonRows.reduce((sum, row) => sum + row.beforeSize, 0), - afterSize: allComparisonRows.reduce((sum, row) => sum + row.afterSize, 0), + beforeSize: sumChunkSizes(before.chunks), + afterSize: sumChunkSizes(after.chunks), }; + const diffGenerated = generatedAggregate(before.chunks, after.chunks); const diffRows = changedRows.sort(compareChunkComparisonRows).slice(0, 30); // TODO: 実際に30を超えて切り捨てられたrowがあった場合はその旨をmarkdown内に表示するようにする - const startupKeys = new Set([ - ...before.startupKeys, - ...after.startupKeys, - ]); - const startupComparisonRows = getChunkComparisonRows([...startupKeys], before, after); + const beforeStartupFiles = new Set(before.startupFiles); + const afterStartupFiles = new Set(after.startupFiles); + const beforeStartupChunks = before.chunks.filter(chunk => beforeStartupFiles.has(chunk.file)); + const afterStartupChunks = after.chunks.filter(chunk => afterStartupFiles.has(chunk.file)); + const beforeStartupComparable = comparableMap(beforeStartupChunks); + const afterStartupComparable = comparableMap(afterStartupChunks); + const startupKeys = [...new Set([...Object.keys(beforeStartupComparable), ...Object.keys(afterStartupComparable)])]; + const startupComparisonRows = getChunkComparisonRows(startupKeys, beforeStartupComparable, afterStartupComparable); const startupRows = startupComparisonRows.sort(compareChunkComparisonRows); const startupSummary = summarizeChunkChanges(startupComparisonRows); const startupTotal = { - beforeSize: startupComparisonRows.reduce((sum, row) => sum + row.beforeSize, 0), - afterSize: startupComparisonRows.reduce((sum, row) => sum + row.afterSize, 0), + beforeSize: sumChunkSizes(beforeStartupChunks), + afterSize: sumChunkSizes(afterStartupChunks), }; + const startupGenerated = generatedAggregate(beforeStartupChunks, afterStartupChunks); //const largeRows = comparisonRows // .sort((a, b) => b.sortSize - a.sortSize || a.name.localeCompare(b.name)) @@ -412,14 +501,14 @@ function renderFrontendChunkReport(before: Awaited', `${formatChunkChangeSummary('Chunk size diff', diffSummary)}`, '', - chunkMarkdownTable(diffRows, diffTotal), + chunkMarkdownTable(diffRows, diffTotal, diffGenerated), '', '', '', '
', `${formatChunkChangeSummary('Startup chunk size', startupSummary)}`, '', - chunkMarkdownTable(startupRows, startupTotal), + chunkMarkdownTable(startupRows, startupTotal, startupGenerated), '', `_Startup chunks are the Vite entry for \`src/_boot_.ts\` and its static imports._`, '', diff --git a/.github/scripts/frontend-js-size.test.mts b/.github/scripts/frontend-js-size.test.mts new file mode 100644 index 0000000000..347d21548c --- /dev/null +++ b/.github/scripts/frontend-js-size.test.mts @@ -0,0 +1,233 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test, { type TestContext } from 'node:test'; +import * as util from './utility.mts'; + +type Manifest = Record; + +const repoDir = path.resolve(import.meta.dirname, '../..'); +const reportScript = path.join(repoDir, '.github/scripts/frontend-js-size.mts'); + +async function writeBuild(repo: string, manifest: Manifest, sizes: Record) { + const outDir = path.join(repo, 'built/_frontend_vite_/'); + await fs.mkdir(path.join(outDir, 'ja-JP'), { recursive: true }); + await fs.writeFile(path.join(outDir, 'manifest.json'), JSON.stringify(manifest)); + for (const [file, size] of Object.entries(sizes)) { + const localizedFile = file.replace(/^scripts\//, ''); + const outputFile = path.join(outDir, 'ja-JP', localizedFile); + await fs.mkdir(path.dirname(outputFile), { recursive: true }); + await fs.writeFile(outputFile, Buffer.alloc(size)); + } +} + +async function runReport(t: TestContext, before: { manifest: Manifest; sizes: Record }, after: { manifest: Manifest; sizes: Record }, expectFailure = false) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'frontend-js-size-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const beforeDir = path.join(root, 'before'); + const afterDir = path.join(root, 'after'); + const beforeStats = path.join(root, 'before-stats.json'); + const afterStats = path.join(root, 'after-stats.json'); + const reportFile = path.join(root, 'report.md'); + await writeBuild(beforeDir, before.manifest, before.sizes); + await writeBuild(afterDir, after.manifest, after.sizes); + await fs.writeFile(beforeStats, '{}'); + await fs.writeFile(afterStats, '{}'); + const args = [reportScript, beforeDir, afterDir, beforeStats, afterStats, reportFile]; + if (expectFailure) { + return new Promise((resolve, reject) => { + execFile(process.execPath, args, (error, _stdout, stderr) => { + if (error == null) { + reject(new Error('Expected frontend report script to fail')); + } else { + resolve(stderr); + } + }); + }); + } + await util.run(process.execPath, args); + return fs.readFile(reportFile, 'utf8'); +} + +function fixture(suffix: string, generatedName: string, sizes: { entry: number; generatedA: number; generatedB: number; vue: number; i18n: number }) { + const files = { + entry: `scripts/entry-${suffix}.js`, + generatedA: `scripts/generated-a-${suffix}.js`, + generatedB: `scripts/generated-b-${suffix}.js`, + vue: `scripts/vue-${suffix}.js`, + i18n: `scripts/i18n-${suffix}.js`, + }; + return { + manifest: { + 'src/_boot_.ts': { file: files.entry, src: 'src/_boot_.ts', name: 'entry', isEntry: true, imports: ['_generatedA', '_generatedB', '_vue', '_i18n'] }, + _generatedA: { file: files.generatedA, name: generatedName }, + _generatedB: { file: files.generatedB, name: generatedName }, + _vue: { file: files.vue, name: 'vue' }, + _i18n: { file: files.i18n, name: 'i18n' }, + }, + sizes: Object.fromEntries(Object.entries(files).map(([key, file]) => [file, sizes[key as keyof typeof sizes]])), + }; +} + +test('groups generated chunks while preserving full and startup totals', async t => { + const report = await runReport( + t, + fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }), + fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), + ); + + assert.match(report, /\| \(total\) \| 220 B \| 275 B \|/); + assert.equal(report.match(/\| \(other generated chunks\) \| 30 B \| 70 B \|/g)?.length, 2); + assert.equal(report.match(/_2 before \/ 2 after generated chunks are grouped\._/g)?.length, 2); + assert.doesNotMatch(report, /`(?:dist|esm)`<\/summary>/); + assert.match(report, /`src\/_boot_\.ts`<\/summary>/); + assert.match(report, /`vue`<\/summary>/); +}); + +test('fails instead of overwriting duplicate stable chunk keys', async t => { + const duplicateVue = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); + duplicateVue.manifest._vueDuplicate = { file: 'scripts/vue-duplicate-before.js', name: 'vue' }; + duplicateVue.sizes['scripts/vue-duplicate-before.js'] = 60; + + const diagnostic = await runReport( + t, + duplicateVue, + fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), + true, + ); + assert.match(diagnostic, /Duplicate stable chunk key "named:vue".*vue-before\.js.*vue-duplicate-before\.js/); +}); + +test('shows both filenames for an updated stable chunk', async t => { + const report = await runReport( + t, + fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }), + fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), + ); + + assert.match(report, /`ja-JP\/entry-before\.js → ja-JP\/entry-after\.js`/); + assert.match(report, /`ja-JP\/vue-before\.js → ja-JP\/vue-after\.js`/); +}); + +test('fails descriptively when the startup entry is missing', async t => { + const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); + delete before.manifest['src/_boot_.ts']; + delete before.sizes['scripts/entry-before.js']; + + const diagnostic = await runReport( + t, + before, + fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), + true, + ); + assert.match(diagnostic, /Unable to find frontend startup entry in Vite manifest/); +}); + +test('fails descriptively when a static import is missing from the manifest', async t => { + const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); + before.manifest['src/_boot_.ts'].imports = ['_missing']; + + const diagnostic = await runReport( + t, + before, + fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), + true, + ); + assert.match(diagnostic, /Startup manifest key "_missing".*is missing/); +}); + +test('fails descriptively when a static import has no output file', async t => { + const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); + before.manifest['src/_boot_.ts'].imports = ['_malformed']; + before.manifest._malformed = { name: 'malformed' }; + + const diagnostic = await runReport( + t, + before, + fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), + true, + ); + assert.match(diagnostic, /Startup manifest key "_malformed".*has no output file/); +}); + +test('fails descriptively when a static import resolves to a non-JavaScript output', async t => { + const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); + before.manifest['src/_boot_.ts'].imports = ['_malformed']; + before.manifest._malformed = { file: 'assets/malformed.css', name: 'malformed' }; + + const diagnostic = await runReport( + t, + before, + fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), + true, + ); + assert.match(diagnostic, /Startup manifest key "_malformed".*non-JavaScript output "assets\/malformed\.css"/); +}); + +test('keeps source-backed additions and removals as individual rows', async t => { + const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); + before.manifest._removed = { file: 'scripts/removed-before.js', src: 'src/removed.ts' }; + before.sizes['scripts/removed-before.js'] = 12; + const after = fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }); + after.manifest._added = { file: 'scripts/added-after.js', src: 'src/added.ts' }; + after.sizes['scripts/added-after.js'] = 13; + + const report = await runReport(t, before, after); + + assert.match(report, /`src\/added\.ts`<\/summary> `ja-JP\/added-after\.js` <\/details> \| 0 B \| 13 B \|/); + assert.match(report, /`src\/removed\.ts`<\/summary> `ja-JP\/removed-before\.js` <\/details> \| 12 B \| 0 B \|/); +}); + +test('counts an unmanifested localized JavaScript file in totals and the generated aggregate', async t => { + const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); + before.sizes['scripts/unmanifested-before.js'] = 15; + + const report = await runReport( + t, + before, + fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), + ); + + assert.match(report, /\| \(total\) \| 235 B \| 275 B \|/); + assert.match(report, /\| \(other generated chunks\) \| 45 B \| 70 B \|/); + assert.match(report, /_3 before \/ 2 after generated chunks are grouped\._/); +}); + +test('counts duplicate manifest entries for one physical output only once', async t => { + const before = fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }); + before.manifest._generatedAlias = { file: 'scripts/generated-a-before.js', name: 'dist' }; + + const report = await runReport( + t, + before, + fixture('after', 'esm', { entry: 110, generatedA: 30, generatedB: 40, vue: 45, i18n: 50 }), + ); + + assert.match(report, /\| \(total\) \| 220 B \| 275 B \|/); + assert.match(report, /_2 before \/ 2 after generated chunks are grouped\._/); +}); + +test('does not count generated-aggregate-only changes as individual chunk changes', async t => { + const report = await runReport( + t, + fixture('before', 'dist', { entry: 100, generatedA: 10, generatedB: 20, vue: 40, i18n: 50 }), + fixture('after', 'esm', { entry: 100, generatedA: 30, generatedB: 40, vue: 40, i18n: 50 }), + ); + + assert.match(report, /Chunk size diff \(0 updated, 0 added, 0 removed\)<\/summary>/); + assert.match(report, /Startup chunk size \(0 updated, 0 added, 0 removed\)<\/summary>/); + assert.equal(report.match(/\| \(other generated chunks\) \| 30 B \| 70 B \|/g)?.length, 2); +}); diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 63ae5c7397..d3c745aea6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -18,6 +18,8 @@ on: - packages/misskey-reversi/** - packages/shared/eslint.config.js - scripts/check-dts*.mjs + - .github/scripts/frontend-js-size*.mts + - .github/scripts/utility.mts - .github/workflows/lint.yml - package.json pull_request: @@ -34,6 +36,8 @@ on: - packages/misskey-reversi/** - packages/shared/eslint.config.js - scripts/check-dts*.mjs + - .github/scripts/frontend-js-size*.mts + - .github/scripts/utility.mts - .github/workflows/lint.yml - package.json jobs: @@ -136,3 +140,12 @@ jobs: - run: pnpm i --frozen-lockfile - run: node --test scripts/check-dts.test.mjs - run: pnpm check-dts + + frontend-bundle-report-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.3 + - uses: actions/setup-node@v6.4.0 + with: + node-version-file: '.node-version' + - run: node --test .github/scripts/frontend-js-size.test.mts diff --git a/docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md b/docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md new file mode 100644 index 0000000000..95e0de006b --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-frontend-bundle-generated-chunks-design.md @@ -0,0 +1,153 @@ +# Frontend bundle report: generated chunk handling + +## Background + +The frontend bundle report compares the Vite manifests produced for the pull request base and head builds. Chunks with a `src` value are currently identified by that source path. Chunks without `src` are identified by Rolldown's generated `name`. + +Generated names are not unique or stable identities. Multiple unrelated shared chunks can all be called `dist`, `esm`, `index`, or a similar path-derived name. The current collection logic stores those chunks in a `Map` under `chunk:`, so later entries overwrite earlier entries. This produces two problems: + +1. unrelated chunks can be compared as if they were the same chunk; and +2. overwritten chunks are omitted from the reported total. + +Individual size changes for these generated shared chunks are not sufficiently actionable to justify heuristic matching. + +## Goals + +- Never compare unrelated generated chunks as the same chunk. +- Keep generated chunks out of the individual chunk-diff rows. +- Preserve the contribution of every physical JavaScript chunk in totals. +- Show the aggregate size change of generated chunks so unexplained bundle growth remains visible. +- Apply the same rules to the full chunk report and the startup chunk report. +- Continue comparing source-backed chunks and intentionally named chunks. + +## Non-goals + +- Inferring chunk identity from module-set similarity. +- Preserving an `updated` relationship for every shared chunk after code splitting changes. +- Changing the frontend's code-splitting strategy or output filenames. +- Suppressing specific names such as `esm` or `dist` with a denylist. + +## Approaches considered + +### Denylist generated-looking names + +Exclude names such as `esm`, `dist`, `lib`, and `index`. + +This is not selected because Rolldown can generate many other names, and a denylist can also hide an intentionally named chunk. It would leave the underlying name collision and total-size bug in place. + +### Compare only stable identities and aggregate the rest + +Classify chunks by whether the report has a stable semantic identity. Source-backed chunks and explicitly supported manual chunk names remain individually comparable. All other generated chunks are retained as physical files but shown only as an aggregate. + +This is the selected approach. It removes false comparisons without introducing matching heuristics, and it keeps all bytes visible. + +### Match shared chunks by their module sets + +Use visualizer metadata to compare exact or similar sets of module IDs. + +This could retain more individual diffs, but splits, merges, dependency-version paths, and small module movements make the matching policy complex and potentially misleading. It can be added later if aggregate data proves insufficient. + +## Design + +### Separate physical chunks from comparable identities + +`collectReport` will no longer use a single map keyed by the comparison identity as its source of truth. It will collect every resolved JavaScript output file exactly once into a physical chunk collection. + +Each physical chunk contains: + +- its resolved relative output path; +- its byte size; +- its manifest key, when present; +- its display name; and +- an optional comparison key. + +The physical output path is used only for de-duplication within one build. It is not used to match changed chunks across builds. + +### Comparison-key classification + +A chunk receives a comparison key only in one of these cases: + +1. `chunk.src` is present: use `src:`. +2. `chunk.name` is in an explicit allowlist of intentionally stable manual chunks: use `named:`. + +The initial stable-name allowlist is: + +- `vue` +- `i18n` + +These names correspond to the explicit `codeSplitting.groups` configuration in `packages/frontend/vite.config.ts`. + +All other manifest chunks without `src`, including generated names such as `esm` and `dist`, receive no comparison key. JavaScript files found in the localized output directory but absent from the manifest also receive no comparison key. + +If a supposedly stable comparison key occurs more than once within one build, the report must not silently overwrite it. Collection will fail with a descriptive error because duplicate `src` or allowlisted manual names violate the assumptions required for a correct comparison. + +### Full chunk report + +The report computes three independent values: + +1. **Total:** the sum of every unique physical JavaScript chunk. +2. **Generated chunk aggregate:** the sum of chunks without a comparison key. +3. **Individual rows:** before/after comparisons for stable comparison keys only. + +The table starts with the existing `(total)` row. When either build contains generated chunks, it then includes one aggregate row such as: + +```text +(other generated chunks) +``` + +This row compares aggregate sizes, not individual chunk identities. Generated chunks do not participate in the updated/added/removed row counts. + +The report includes a short note stating how many generated chunks were grouped on each side. This makes the scope of the aggregate explicit without listing noisy filenames. + +### Startup chunk report + +Startup traversal continues to follow the entry chunk's static `imports`, but it records manifest keys or resolved physical file paths rather than generated comparison keys. This prevents two startup chunks named `dist` from collapsing into one. + +Startup totals include every unique physical startup chunk. Stable startup chunks receive individual rows, while startup chunks without stable identities contribute to the same `(other generated chunks)` aggregate row within the startup table. + +### Displayed filenames + +For an individually comparable chunk, the details should expose both filenames when they differ. A single before-side filename must not imply that the after size belongs to that same file. + +The display can use one filename when unchanged and `before → after` when the content-hashed filename differs. + +The generated aggregate row does not display an arbitrary representative filename. + +## Data flow + +1. Read each build's Vite manifest. +2. Resolve localized output files and collect unique physical chunks. +3. Attach optional stable comparison keys using the classification rules. +4. Traverse startup imports using manifest identities and map them to physical chunks. +5. Calculate full and startup totals from physical chunks. +6. Calculate generated aggregates from chunks without comparison keys. +7. Compare only unique stable keys for individual rows. +8. Render totals, generated aggregates, and stable rows. + +## Error handling + +- A manifest entry whose expected localized JavaScript file is absent remains a fatal report-generation error. +- Duplicate physical output paths are de-duplicated and counted once. +- Duplicate stable comparison keys fail with an error that includes the key and conflicting files. +- Missing or malformed manifest data remains fatal rather than producing a partial report. + +## Testing + +Add focused fixtures or pure-function tests covering: + +- two unrelated `dist` chunks are both counted in total and grouped into the generated aggregate; +- `dist` and `esm` chunks never produce individual comparison rows; +- source-backed chunks with the same `src` are reported as updated; +- source-backed additions and removals remain visible; +- allowlisted `vue` and `i18n` chunks remain individually comparable; +- duplicate stable keys produce a descriptive error instead of overwriting; +- full totals equal the sum of all unique physical chunks; +- startup totals include multiple same-name generated chunks exactly once each; +- generated aggregate changes do not affect the updated/added/removed summary counts; and +- differing before/after filenames are rendered without attributing both sizes to one file. + +Validation should include the focused tests and repository lint. No CHANGELOG entry is required because this changes developer-facing CI reporting rather than Misskey user behavior. + +## Future extension + +If aggregate generated-chunk data is later found insufficient, visualizer module metadata can support a separate opt-in analysis. That extension must preserve the physical-chunk accounting introduced here and must not reintroduce name-based identity.