Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
96a454e
wip
syuilo Jun 27, 2026
2147385
Update measure-frontend-browser-comparison.mts
syuilo Jun 27, 2026
790c84d
Update frontend-browser-report.mts
syuilo Jun 27, 2026
7247535
Merge branch 'develop' into frontend-browser-metrics-workflow-2
syuilo Jun 27, 2026
812b5fb
Update frontend-browser-report.mts
syuilo Jun 27, 2026
0deac44
Update frontend-browser-report.mts
syuilo Jun 27, 2026
364ccd0
Update frontend-browser-report.mts
syuilo Jun 27, 2026
67f25a7
Update frontend-browser-report.mts
syuilo Jun 27, 2026
982d403
Merge branch 'develop' into frontend-browser-metrics-workflow-2
syuilo Jun 27, 2026
ce10ece
Update frontend-browser-report.mts
syuilo Jun 27, 2026
48f6765
Update frontend-browser-report.mts
syuilo Jun 27, 2026
c595117
refactor
syuilo Jun 28, 2026
5856784
fix
syuilo Jun 28, 2026
5f10968
Update chrome.mts
syuilo Jun 28, 2026
5f20223
refactor
syuilo Jun 28, 2026
4a41b14
wip
syuilo Jun 28, 2026
7dcf765
fix
syuilo Jun 28, 2026
8bc8ebc
Update frontend-browser-metrics-report.yml
syuilo Jun 28, 2026
8dc5962
Update frontend-browser-report.mts
syuilo Jun 28, 2026
551162b
Merge branch 'develop' into frontend-browser-metrics-workflow-2
syuilo Jun 28, 2026
0c7ee11
Merge branch 'develop' into frontend-browser-metrics-workflow-2
syuilo Jul 3, 2026
cd953e9
refactor
syuilo Jul 3, 2026
b487f91
wip
syuilo Jul 3, 2026
9858fd1
Merge branch 'develop' into frontend-browser-metrics-workflow-2
syuilo Jul 3, 2026
8f2759e
Update frontend-browser-report.mts
syuilo Jul 3, 2026
5d0dd40
wip
syuilo Jul 3, 2026
80b5ec6
wip
syuilo Jul 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
784 changes: 784 additions & 0 deletions .github/scripts/chrome.mts

Large diffs are not rendered by default.

448 changes: 448 additions & 0 deletions .github/scripts/frontend-browser-detailed-html.mts

Large diffs are not rendered by default.

372 changes: 372 additions & 0 deletions .github/scripts/frontend-browser-report.mts

Large diffs are not rendered by default.

320 changes: 320 additions & 0 deletions .github/scripts/heap-snapshot-util.mts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,326 @@ export type HeapSnapshotReport = {
}[];
};

export const defaultHeapSnapshotBreakdownTopN = 6;

export function createEmptyHeapSnapshotData(): HeapSnapshotData {
const categories = {} as HeapSnapshotData['categories'];
const nodeCounts = {} as HeapSnapshotData['nodeCounts'];
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
categories[category] = 0;
nodeCounts[category] = 0;
}
return {
categories,
nodeCounts,
breakdowns: {} as HeapSnapshotData['breakdowns'],
};
}

function sanitizeHeapSnapshotBreakdownLabel(value: unknown, fallback = 'unknown') {
const label = String(value ?? '').replace(/\s+/g, ' ').trim();
if (label === '') return fallback;
if (label.length <= 80) return label;
return `${label.slice(0, 77)}...`;
}

function classifyHeapSnapshotBreakdown(category: keyof typeof heapSnapshotCategory, type: string, name: string) {
if (category === 'strings') return type;

if (category === 'jsArrays') {
if (type === 'array elements') return 'Array elements';
if (type === 'object' && name === 'Array') return 'Array objects';
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
}

if (category === 'typedArrays') {
if (name === 'system / JSArrayBufferData') return 'ArrayBuffer data';
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`);
}

if (category === 'systemObjects') {
if (name.startsWith('system /')) return sanitizeHeapSnapshotBreakdownLabel(name);
if (name.startsWith('(system ')) return sanitizeHeapSnapshotBreakdownLabel(name);
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
}

if (category === 'otherJsObjects') {
if (type === 'object') return sanitizeHeapSnapshotBreakdownLabel(`object: ${name}`, 'object: unknown');
return type;
}

if (category === 'otherNonJsObjects') {
if (type === 'extra native bytes') return 'Extra native bytes';
if (type === 'native') return sanitizeHeapSnapshotBreakdownLabel(`native: ${name}`, 'native: unknown');
return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
}

if (category === 'code') {
const lowerName = name.toLowerCase();
if (lowerName.includes('bytecode')) return 'bytecode';
if (lowerName.includes('builtin')) return 'builtins';
if (lowerName.includes('regexp')) return 'regexp code';
if (lowerName.includes('stub')) return 'stubs';
return sanitizeHeapSnapshotBreakdownLabel(`code: ${name}`, 'code: unknown');
}

return sanitizeHeapSnapshotBreakdownLabel(`${type}: ${name}`, type);
}

export function collapseHeapSnapshotBreakdown(breakdown: Record<string, number>, topN = defaultHeapSnapshotBreakdownTopN) {
const entries = Object.entries(breakdown)
.filter(([, value]) => value > 0)
.toSorted((a, b) => b[1] - a[1]);

const topEntries = entries.slice(0, topN);
const otherValue = entries
.slice(topN)
.reduce((sum, [, value]) => sum + value, 0);

const collapsed = Object.fromEntries(topEntries);
if (otherValue > 0) collapsed.Other = otherValue;
return collapsed;
}

export function collapseHeapSnapshotBreakdowns(
breakdowns: Partial<Record<keyof typeof heapSnapshotCategory, Record<string, number>>>,
topN = defaultHeapSnapshotBreakdownTopN,
) {
const collapsed = {} as NonNullable<HeapSnapshotData['breakdowns']>;
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
if (category === 'total') continue;

const categoryBreakdown = breakdowns[category];
if (categoryBreakdown == null) continue;

const collapsedCategory = collapseHeapSnapshotBreakdown(categoryBreakdown, topN);
if (Object.keys(collapsedCategory).length > 0) {
collapsed[category] = collapsedCategory;
}
}

return collapsed;
}

// Keep these buckets aligned with Chrome DevTools' heap snapshot Statistics view.
export function analyzeHeapSnapshot(snapshot: any, options: { breakdownTopN?: number } = {}): HeapSnapshotData {
const meta = snapshot?.snapshot?.meta;
const nodes = snapshot?.nodes;
const edges = snapshot?.edges;
const strings = snapshot?.strings;
if (meta == null || !Array.isArray(nodes) || !Array.isArray(edges) || !Array.isArray(strings)) {
throw new Error('Invalid heap snapshot format');
}

const nodeFields = meta.node_fields;
if (!Array.isArray(nodeFields)) throw new Error('Invalid heap snapshot node fields');
const edgeFields = meta.edge_fields;
if (!Array.isArray(edgeFields)) throw new Error('Invalid heap snapshot edge fields');

const typeOffset = nodeFields.indexOf('type');
const nameOffset = nodeFields.indexOf('name');
const selfSizeOffset = nodeFields.indexOf('self_size');
const edgeCountOffset = nodeFields.indexOf('edge_count');
if (typeOffset < 0 || nameOffset < 0 || selfSizeOffset < 0 || edgeCountOffset < 0) {
throw new Error('Heap snapshot is missing required node fields');
}
const edgeTypeOffset = edgeFields.indexOf('type');
const edgeNameOffset = edgeFields.indexOf('name_or_index');
const edgeToNodeOffset = edgeFields.indexOf('to_node');
if (edgeTypeOffset < 0 || edgeNameOffset < 0 || edgeToNodeOffset < 0) {
throw new Error('Heap snapshot is missing required edge fields');
}

const nodeTypeNames = meta.node_types?.[typeOffset];
if (!Array.isArray(nodeTypeNames)) throw new Error('Invalid heap snapshot node types');
const edgeTypeNames = meta.edge_types?.[edgeTypeOffset];
if (!Array.isArray(edgeTypeNames)) throw new Error('Invalid heap snapshot edge types');

const nodeFieldCount = nodeFields.length;
const edgeFieldCount = edgeFields.length;
const nativeType = nodeTypeNames.indexOf('native');
const codeType = nodeTypeNames.indexOf('code');
const hiddenType = nodeTypeNames.indexOf('hidden');
const stringTypes = new Set([
nodeTypeNames.indexOf('string'),
nodeTypeNames.indexOf('concatenated string'),
nodeTypeNames.indexOf('sliced string'),
]);
const internalEdgeType = edgeTypeNames.indexOf('internal');
const extraNativeBytes = Number.isFinite(snapshot.snapshot.extra_native_bytes) ? snapshot.snapshot.extra_native_bytes : 0;
const { categories, nodeCounts } = createEmptyHeapSnapshotData();
const breakdowns = {} as Record<keyof typeof heapSnapshotCategory, Record<string, number>>;
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
if (category !== 'total') breakdowns[category] = {};
}

function addValue(map: Record<string, number>, key: string, value: number) {
map[key] = (map[key] ?? 0) + value;
}

const edgeStartIndexes = new Map<number, number>();
const retainerCounts = new Map<number, number>();
let edgeIndex = 0;
for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
edgeStartIndexes.set(nodeIndex, edgeIndex);
const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0;
for (let i = 0; i < edgeCount; i++, edgeIndex += edgeFieldCount) {
const toNodeIndex = edges[edgeIndex + edgeToNodeOffset];
retainerCounts.set(toNodeIndex, (retainerCounts.get(toNodeIndex) ?? 0) + 1);
}
}

const jsArrayElementNodeIndexes = new Set<number>();

function addCategoryValue(category: keyof typeof heapSnapshotCategory, value: number, type: string, name: string, nodeIndex: number | null = null) {
if (value <= 0) return;
categories[category] += value;
addValue(breakdowns[category], classifyHeapSnapshotBreakdown(category, type, name), value);
if (nodeIndex != null) nodeCounts[category]++;
}

function addJsArrayElementSize(nodeIndex: number) {
const beginEdgeIndex = edgeStartIndexes.get(nodeIndex) ?? 0;
const edgeCount = nodes[nodeIndex + edgeCountOffset] ?? 0;
for (let i = 0, currentEdgeIndex = beginEdgeIndex; i < edgeCount; i++, currentEdgeIndex += edgeFieldCount) {
const edgeType = edges[currentEdgeIndex + edgeTypeOffset];
if (edgeType !== internalEdgeType) continue;

const edgeName = strings[edges[currentEdgeIndex + edgeNameOffset]];
if (edgeName !== 'elements') continue;

const elementsNodeIndex = edges[currentEdgeIndex + edgeToNodeOffset];
if ((retainerCounts.get(elementsNodeIndex) ?? 0) === 1) {
const elementsSize = nodes[elementsNodeIndex + selfSizeOffset] ?? 0;
addCategoryValue('jsArrays', elementsSize, 'array elements', 'Array elements', elementsNodeIndex);
jsArrayElementNodeIndexes.add(elementsNodeIndex);
}
break;
}
}

if (extraNativeBytes > 0) {
addCategoryValue('otherNonJsObjects', extraNativeBytes, 'extra native bytes', 'extra native bytes');
}

for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
const typeId = nodes[nodeIndex + typeOffset];
const type = nodeTypeNames[typeId] ?? 'unknown';
const name = strings[nodes[nodeIndex + nameOffset]] ?? '';
const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0;
categories.total += selfSize;
nodeCounts.total++;

if (typeId === hiddenType) {
addCategoryValue('systemObjects', selfSize, type, name, nodeIndex);
continue;
}

if (typeId === nativeType) {
if (name === 'system / JSArrayBufferData') {
addCategoryValue('typedArrays', selfSize, type, name, nodeIndex);
} else {
addCategoryValue('otherNonJsObjects', selfSize, type, name, nodeIndex);
}
continue;
}

if (typeId === codeType) {
addCategoryValue('code', selfSize, type, name, nodeIndex);
continue;
}

if (stringTypes.has(typeId)) {
addCategoryValue('strings', selfSize, type, name, nodeIndex);
continue;
}

if (name === 'Array') {
addCategoryValue('jsArrays', selfSize, type, name, nodeIndex);
addJsArrayElementSize(nodeIndex);
continue;
}
}

categories.total += extraNativeBytes;

for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += nodeFieldCount) {
if (jsArrayElementNodeIndexes.has(nodeIndex)) continue;

const typeId = nodes[nodeIndex + typeOffset];
if (typeId === hiddenType || typeId === nativeType || typeId === codeType || stringTypes.has(typeId)) continue;

const name = strings[nodes[nodeIndex + nameOffset]] ?? '';
if (name === 'Array') continue;

const type = nodeTypeNames[typeId] ?? 'unknown';
const selfSize = nodes[nodeIndex + selfSizeOffset] ?? 0;
addCategoryValue('otherJsObjects', selfSize, type, name, nodeIndex);
}

return {
categories,
nodeCounts,
breakdowns: collapseHeapSnapshotBreakdowns(breakdowns, options.breakdownTopN),
};
}

function finiteMedian(values: (number | null | undefined)[]) {
const finiteValues = values.filter(value => Number.isFinite(value)) as number[];
if (finiteValues.length === 0) return null;
return util.median(finiteValues);
}

export function summarizeHeapSnapshotDataSamples<T>(
samples: T[],
getData: (sample: T) => HeapSnapshotData | null | undefined,
options: { breakdownTopN?: number } = {},
) {
const data = samples.map(getData);
const categories = {} as HeapSnapshotData['categories'];
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
const value = finiteMedian(data.map(snapshot => snapshot?.categories?.[category]));
if (value != null) categories[category] = value;
}

const nodeCounts = {} as HeapSnapshotData['nodeCounts'];
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
const value = finiteMedian(data.map(snapshot => snapshot?.nodeCounts?.[category]));
if (value != null) nodeCounts[category] = value;
}

if (Object.keys(categories).length === 0) return null;

const breakdowns = {} as NonNullable<HeapSnapshotData['breakdowns']>;
for (const category of Object.keys(heapSnapshotCategory) as (keyof typeof heapSnapshotCategory)[]) {
if (category === 'total') continue;

const childKeys = new Set<string>();
for (const snapshot of data) {
for (const childKey of Object.keys(snapshot?.breakdowns?.[category] ?? {})) {
childKeys.add(childKey);
}
}

const categoryBreakdown = {} as Record<string, number>;
for (const childKey of childKeys) {
const value = finiteMedian(data.map(snapshot => snapshot?.breakdowns?.[category]?.[childKey]));
if (value != null) categoryBreakdown[childKey] = value;
}

const collapsed = collapseHeapSnapshotBreakdown(categoryBreakdown, options.breakdownTopN);
if (Object.keys(collapsed).length > 0) {
breakdowns[category] = collapsed;
}
}

return {
categories,
nodeCounts,
...(Object.keys(breakdowns).length > 0 ? { breakdowns } : {}),
};
}

function getHeapSnapshotCategoryValue(report: HeapSnapshotReport, category: keyof typeof heapSnapshotCategory) {
return report.summary.categories[category];
}
Expand Down
Loading
Loading