Skip to content

Commit f118a75

Browse files
committed
Add plugin to measure component render timings
1 parent 48ce21f commit f118a75

File tree

2 files changed

+138
-1
lines changed

2 files changed

+138
-1
lines changed

astro.config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@ import { sidebar } from './astro.sidebar';
77
import { devServerFileWatcher } from './config/integrations/dev-server-file-watcher';
88
import { sitemap } from './config/integrations/sitemap';
99
import { localesConfig } from './config/locales';
10+
import { astroComponentTimer } from './config/plugins/component-timing';
1011
import { starlightPluginLlmsTxt } from './config/plugins/llms-txt';
11-
import { starlightPluginSmokeTest } from './config/plugins/smoke-test';
1212
import { rehypeTasklistEnhancer } from './config/plugins/rehype-tasklist-enhancer';
1313
import { remarkFallbackLang } from './config/plugins/remark-fallback-lang';
14+
import { starlightPluginSmokeTest } from './config/plugins/smoke-test';
1415

1516
/* https://docs.netlify.com/configure-builds/environment-variables/#read-only-variables */
1617
const NETLIFY_PREVIEW_SITE = process.env.CONTEXT !== 'production' && process.env.DEPLOY_PRIME_URL;
@@ -72,6 +73,7 @@ export default defineConfig({
7273
plugins: [starlightPluginSmokeTest(), starlightPluginLlmsTxt()],
7374
}),
7475
sitemap(),
76+
astroComponentTimer(),
7577
],
7678
trailingSlash: 'always',
7779
scopedStyleStrategy: 'where',

config/plugins/component-timing.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import type { AstroIntegration } from 'astro';
2+
import { fileURLToPath } from 'node:url';
3+
4+
const BRAND = '/*____ASTRO_COMPONENT_TIMER____*/';
5+
const DATA_STORE_KEY = '__ASTRO_COMPONENT_TIMINGS_STORE__';
6+
globalThis[DATA_STORE_KEY] ??= {};
7+
8+
declare global {
9+
var __ASTRO_COMPONENT_TIMINGS_STORE__: Record<string, number[]>;
10+
}
11+
12+
export function astroComponentTimer() {
13+
return {
14+
name: 'astro-component-timer',
15+
hooks: {
16+
'astro:config:setup'({ updateConfig, config, command }) {
17+
const srcDir = fileURLToPath(config.srcDir);
18+
const localId = (id: string) => {
19+
const srcRelative = id.replace(srcDir, './');
20+
const nmIndex = srcRelative.lastIndexOf('/node_modules/');
21+
return nmIndex !== -1 ? srcRelative.slice(nmIndex + 14) : srcRelative;
22+
};
23+
updateConfig({
24+
vite: {
25+
plugins: [
26+
{
27+
name: 'vite-plugin-astro-component-timer',
28+
shouldTransformCachedModule({ id }) {
29+
if (id.endsWith('.astro')) {
30+
true;
31+
}
32+
},
33+
transform(src, id) {
34+
// if (!id.includes('/node_modules/')) console.log('ID:', id);
35+
if (!id.endsWith('.astro')) {
36+
return;
37+
}
38+
if (src.includes(BRAND)) {
39+
console.log('ALREADY BRANDED:', id);
40+
return;
41+
}
42+
43+
let newCode = `${BRAND}${src}`;
44+
45+
// Start timer at beginning of `$$createComponent()` function.
46+
const createComponentFnOpen = newCode.match(/(?<=\$\$createComponent\([^{]+)({)/);
47+
if (!createComponentFnOpen || !createComponentFnOpen.index) {
48+
console.log('MISSING CREATE COMPONENT IN', id);
49+
return;
50+
}
51+
const mainLabel = JSON.stringify(localId(id));
52+
newCode =
53+
newCode.slice(0, createComponentFnOpen.index + 1) +
54+
`const __t_render_start__ = performance.now();` +
55+
newCode.slice(createComponentFnOpen.index + 1);
56+
57+
// End timer after `$$render` function runs.
58+
const renderMatch = newCode.match(/return (?<renderCall>\$\$render`.+`;)\n/s);
59+
if (!renderMatch || !renderMatch.index || !renderMatch.groups?.renderCall) {
60+
console.log('MISSING RENDER CALL IN', localId(id));
61+
return;
62+
}
63+
newCode =
64+
newCode.slice(0, renderMatch.index) +
65+
`
66+
const astroComponentTimerRenderResult = ${renderMatch.groups.renderCall}
67+
const __dur = performance.now() - __t_render_start__;
68+
globalThis[${JSON.stringify(DATA_STORE_KEY)}][${mainLabel}] ??= [];
69+
globalThis[${JSON.stringify(DATA_STORE_KEY)}][${mainLabel}].push(__dur);${
70+
command === 'dev' ? `console.log(__dur.toFixed(4) + 'ms', ${mainLabel});` : ''
71+
}
72+
return astroComponentTimerRenderResult;
73+
` +
74+
newCode.slice(renderMatch.index + renderMatch[0].length);
75+
76+
return { code: newCode };
77+
},
78+
},
79+
],
80+
},
81+
});
82+
},
83+
'astro:build:done'() {
84+
const timings = Object.entries(globalThis[DATA_STORE_KEY])
85+
.map(([name, timings]) => {
86+
timings.sort();
87+
const totalTime = timings.reduce((cur, val) => cur + val, 0);
88+
return [
89+
name,
90+
{
91+
avg: totalTime / timings.length,
92+
min: timings[0],
93+
median:
94+
timings.length % 2
95+
? timings[Math.floor(timings.length / 2)]
96+
: (timings[timings.length / 2 - 1] + timings[timings.length / 2]) / 2,
97+
max: timings.at(-1)!,
98+
stdev: Math.sqrt(
99+
timings
100+
.map((t) => Math.pow(t - totalTime / timings.length, 2))
101+
.reduce((cur, val) => cur + val, 0) / timings.length
102+
),
103+
total: totalTime,
104+
count: timings.length,
105+
},
106+
] as const;
107+
})
108+
.sort(([, a], [, b]) => b.avg - a.avg)
109+
.map(
110+
([name, { avg, min, median, max, stdev, count, total }]) =>
111+
[
112+
name,
113+
{
114+
count,
115+
'min (ms)': ms(min),
116+
'median (ms)': ms(median),
117+
'max (ms)': ms(max),
118+
'mean (ms)': ms(avg),
119+
σ: ms(stdev),
120+
'total (ms)': ms(total),
121+
},
122+
] as const
123+
);
124+
125+
console.log('\nComponent render timings (25 slowest):');
126+
console.table(Object.fromEntries(timings.slice(0, 25)));
127+
console.log('');
128+
},
129+
},
130+
} satisfies AstroIntegration;
131+
}
132+
133+
function ms(val: number) {
134+
return Math.round(val * 1000) / 1000;
135+
}

0 commit comments

Comments
 (0)