diff --git a/.github/scripts/frontend-js-size.mts b/.github/scripts/frontend-js-size.mts index 4d6dd0a0bc..b4a61e659a 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,11 +71,13 @@ 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; @@ -73,10 +86,8 @@ function collectStartupKeys(manifest: Manifest) { 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); - } + keys.add(key); + for (const importKey of chunk.imports ?? []) visit(importKey); } visit(entryKey); @@ -102,26 +113,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 +155,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 +346,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 +365,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 +414,75 @@ 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 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 = row.beforeFile ?? row.afterFile ?? ''; 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 +492,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..51b71c075f --- /dev/null +++ b/.github/scripts/frontend-js-size.test.mts @@ -0,0 +1,85 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import assert from 'node:assert/strict'; +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 }) { + 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, '{}'); + await util.run(process.execPath, [reportScript, beforeDir, afterDir, beforeStats, afterStats, reportFile]); + 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>/); +});