diff --git a/packages/docs/src/components/ClientSideRenderedCharts.astro b/packages/docs/src/components/ClientSideRenderedCharts.astro index d8336819..05d36839 100644 --- a/packages/docs/src/components/ClientSideRenderedCharts.astro +++ b/packages/docs/src/components/ClientSideRenderedCharts.astro @@ -1,7 +1,9 @@ --- import ClientSideRenderedFPChart from './ClientSideRenderedFPChart.astro' import ClientSideRenderedFCPChart from './ClientSideRenderedFCPChart.astro' +import RenderedInteractionCharts from './RenderedInteractionCharts.astro' --- + diff --git a/packages/docs/src/components/ClientSideRenderedStatsTable.astro b/packages/docs/src/components/ClientSideRenderedStatsTable.astro index 705538f1..864f7990 100644 --- a/packages/docs/src/components/ClientSideRenderedStatsTable.astro +++ b/packages/docs/src/components/ClientSideRenderedStatsTable.astro @@ -15,6 +15,10 @@ const columns = [ }, { key: 'firstPaintMs', header: 'First Paint' }, { key: 'fcpMs', header: 'FCP' }, + { key: 'interactionLatencyMs', header: 'Interaction Latency' }, + { key: 'inputDelayMs', header: 'Input Delay' }, + { key: 'processingDurationMs', header: 'Processing Duration' }, + { key: 'presentationDelayMs', header: 'Presentation Delay' }, ] const tableData = clientSideRenderedStats diff --git a/packages/docs/src/components/FrameworkClientRenderedVersionCharts.astro b/packages/docs/src/components/FrameworkClientRenderedVersionCharts.astro index 4d1991da..06b2382f 100644 --- a/packages/docs/src/components/FrameworkClientRenderedVersionCharts.astro +++ b/packages/docs/src/components/FrameworkClientRenderedVersionCharts.astro @@ -1,11 +1,37 @@ --- import VersionLineChart from './VersionLineChart.astro' +import InteractionBreakdownChart from './InteractionBreakdownChart.astro' interface Props { versions: unknown[] } const { versions } = Astro.props + +const interactionData = versions.flatMap((version) => { + if (version == null || typeof version !== 'object') return [] + const stats = version as Record + const tests = stats.clientSideRenderedTests + if (tests == null || typeof tests !== 'object') return [] + const interaction = (tests as Record).interactionTests + if (interaction == null || typeof interaction !== 'object') return [] + const values = interaction as Record + if ( + typeof values.interactionLatencyMs !== 'number' || + typeof values.inputDelayMs !== 'number' || + typeof values.processingDurationMs !== 'number' || + typeof values.presentationDelayMs !== 'number' + ) + return [] + + return { + name: `v${String(stats.frameworkVersion ?? 'Unknown')}`, + interactionLatencyMs: values.interactionLatencyMs, + inputDelayMs: values.inputDelayMs, + processingDurationMs: values.processingDurationMs, + presentationDelayMs: values.presentationDelayMs, + } +}) --- +{ + interactionData.length > 0 ? ( + + ) : null +} diff --git a/packages/docs/src/components/FrameworkServerRenderedVersionCharts.astro b/packages/docs/src/components/FrameworkServerRenderedVersionCharts.astro index af53c3f6..9dd24c4b 100644 --- a/packages/docs/src/components/FrameworkServerRenderedVersionCharts.astro +++ b/packages/docs/src/components/FrameworkServerRenderedVersionCharts.astro @@ -1,11 +1,37 @@ --- import VersionLineChart from './VersionLineChart.astro' +import InteractionBreakdownChart from './InteractionBreakdownChart.astro' interface Props { versions: unknown[] } const { versions } = Astro.props + +const interactionData = versions.flatMap((version) => { + if (version == null || typeof version !== 'object') return [] + const stats = version as Record + const tests = stats.serverSideRenderedTests + if (tests == null || typeof tests !== 'object') return [] + const interaction = (tests as Record).interactionTests + if (interaction == null || typeof interaction !== 'object') return [] + const values = interaction as Record + if ( + typeof values.interactionLatencyMs !== 'number' || + typeof values.inputDelayMs !== 'number' || + typeof values.processingDurationMs !== 'number' || + typeof values.presentationDelayMs !== 'number' + ) + return [] + + return { + name: `v${String(stats.frameworkVersion ?? 'Unknown')}`, + interactionLatencyMs: values.interactionLatencyMs, + inputDelayMs: values.inputDelayMs, + processingDurationMs: values.processingDurationMs, + presentationDelayMs: values.presentationDelayMs, + } +}) --- +{ + interactionData.length > 0 ? ( + + ) : null +} diff --git a/packages/docs/src/components/InteractionBreakdownChart.astro b/packages/docs/src/components/InteractionBreakdownChart.astro new file mode 100644 index 00000000..1c964acb --- /dev/null +++ b/packages/docs/src/components/InteractionBreakdownChart.astro @@ -0,0 +1,587 @@ +--- +import { formatChartValue } from '../lib/chart' + +interface InteractionBreakdownDatum { + name: string + focused?: boolean + interactionLatencyMs: number + inputDelayMs: number + processingDurationMs: number + presentationDelayMs: number +} + +interface Props { + title: string + data: InteractionBreakdownDatum[] + orientation?: 'horizontal' | 'vertical' +} + +const { title, data, orientation = 'horizontal' } = Astro.props +const maxLatency = Math.max( + 1, + ...data.map((datum) => datum.interactionLatencyMs), +) +const chartHeight = Math.max(80, data.length * 56 + 24) + +function getMedian(values: number[]) { + const sortedValues = [...values].sort((a, b) => a - b) + const middleIndex = Math.floor(sortedValues.length / 2) + + return sortedValues.length % 2 === 0 + ? (sortedValues[middleIndex - 1]! + sortedValues[middleIndex]!) / 2 + : sortedValues[middleIndex]! +} + +const medianLatency = getMedian(data.map((datum) => datum.interactionLatencyMs)) +const focusedMedianLatency = getMedian( + data + .filter((datum) => datum.focused !== false) + .map((datum) => datum.interactionLatencyMs), +) + +function segmentSize(value: number) { + return `${(value / maxLatency) * 100}%` +} + +const FRAMEWORK_LOGOS: Record = { + Astro: { + dark: '/framework-logos/astro-gradient.svg', + light: '/framework-logos/astro-dark.svg', + }, + 'Baseline HTML': { dark: '/framework-logos/html5.svg' }, + Gatsby: { dark: '/framework-logos/gatsby.svg' }, + 'Next.js': { + dark: '/framework-logos/nextdotjs-light.svg', + light: '/framework-logos/nextdotjs.svg', + }, + Nuxt: { dark: '/framework-logos/nuxt.svg' }, + 'Nuxt.js': { dark: '/framework-logos/nuxt.svg' }, + 'React Router': { dark: '/framework-logos/reactrouter.svg' }, + SolidStart: { dark: '/framework-logos/solid-start.svg' }, + SvelteKit: { dark: '/framework-logos/svelte.svg' }, + 'TanStack Start': { dark: '/tanstack-circle-logo.png' }, + WordPress: { dark: '/framework-logos/wordpress.svg' }, +} +--- + +
+
+

{title}

+

+ Total latency is split into the three consecutive phases reported by + Lighthouse. +

+
    +
  • + The input delay, which starts when the user initiates + an interaction with the page, and ends when the event callbacks for the + interaction begin to run. +
  • +
  • + The processing duration, which consists of the time it + takes for event callbacks to run to completion. +
  • +
  • + The presentation delay, which is the time it takes for + the browser to present the next frame which contains the visual result + of the interaction. +
  • +
+
+ + { + orientation === 'horizontal' ? ( +
+ + + {data.map((datum) => { + const logo = FRAMEWORK_LOGOS[datum.name] + return ( +
+ +
+ + 0.85, + }} + style={`left: ${segmentSize(datum.interactionLatencyMs)}`} + > + {formatChartValue(datum.interactionLatencyMs, 'ms')} + +
+
+ ) + })} +
+ ) : ( +
+
+ {data.map((datum) => ( +
+ + {formatChartValue(datum.interactionLatencyMs, 'ms')} + + + {datum.name} +
+ ))} +
+
+ ) + } +
+ + + + + + + + + + + + + { + data.map((datum) => ( + + + + + + + + )) + } + +
{title} data
Framework or versionInteraction latencyInput delayProcessing durationPresentation delay
{datum.name}{formatChartValue(datum.interactionLatencyMs, 'ms')}{formatChartValue(datum.inputDelayMs, 'ms')}{formatChartValue(datum.processingDurationMs, 'ms')}{formatChartValue(datum.presentationDelayMs, 'ms')}
+
+
+ + diff --git a/packages/docs/src/components/RenderedInteractionCharts.astro b/packages/docs/src/components/RenderedInteractionCharts.astro new file mode 100644 index 00000000..28ae25c8 --- /dev/null +++ b/packages/docs/src/components/RenderedInteractionCharts.astro @@ -0,0 +1,21 @@ +--- +import { getRenderedInteractionBreakdownData } from '../lib/collections' +import InteractionBreakdownChart from './InteractionBreakdownChart.astro' + +interface Props { + testsKey: 'clientSideRenderedTests' | 'serverSideRenderedTests' +} + +const { testsKey } = Astro.props +const data = getRenderedInteractionBreakdownData(testsKey).sort( + (a, b) => + a.interactionLatencyMs - b.interactionLatencyMs || + a.name.localeCompare(b.name), +) +--- + +{ + data.length > 0 ? ( + + ) : null +} diff --git a/packages/docs/src/components/ServerSideRenderedCharts.astro b/packages/docs/src/components/ServerSideRenderedCharts.astro index d7d79f76..99bdc92c 100644 --- a/packages/docs/src/components/ServerSideRenderedCharts.astro +++ b/packages/docs/src/components/ServerSideRenderedCharts.astro @@ -1,7 +1,9 @@ --- import ServerSideRenderedFPChart from './ServerSideRenderedFPChart.astro' import ServerSideRenderedFCPChart from './ServerSideRenderedFCPChart.astro' +import RenderedInteractionCharts from './RenderedInteractionCharts.astro' --- + diff --git a/packages/docs/src/components/ServerSideRenderedStatsTable.astro b/packages/docs/src/components/ServerSideRenderedStatsTable.astro index ce4f889f..f9ba91bb 100644 --- a/packages/docs/src/components/ServerSideRenderedStatsTable.astro +++ b/packages/docs/src/components/ServerSideRenderedStatsTable.astro @@ -15,6 +15,10 @@ const columns = [ }, { key: 'firstPaintMs', header: 'First Paint' }, { key: 'fcpMs', header: 'FCP' }, + { key: 'interactionLatencyMs', header: 'Interaction Latency' }, + { key: 'inputDelayMs', header: 'Input Delay' }, + { key: 'processingDurationMs', header: 'Processing Duration' }, + { key: 'presentationDelayMs', header: 'Presentation Delay' }, ] const tableData = serverSideRenderedStats diff --git a/packages/docs/src/lib/collections.ts b/packages/docs/src/lib/collections.ts index 0819c205..b86284c6 100644 --- a/packages/docs/src/lib/collections.ts +++ b/packages/docs/src/lib/collections.ts @@ -156,13 +156,26 @@ export const serverSideRenderedStats = runtimeEntries framework.serverSideRenderedTests != null && Number.isFinite(framework.serverSideRenderedTests.firstPaintMs), ) - .map((framework) => ({ - name: framework.name, - package: framework.package, - isFocused: framework.isFocused, - firstPaintMs: `${framework.serverSideRenderedTests!.firstPaintMs}ms`, - fcpMs: `${framework.serverSideRenderedTests!.fcpMs}ms`, - })) + .map((framework) => { + const tests = framework.serverSideRenderedTests! + const interaction = tests.interactionTests + + return { + name: framework.name, + package: framework.package, + isFocused: framework.isFocused, + firstPaintMs: `${tests.firstPaintMs}ms`, + fcpMs: `${tests.fcpMs}ms`, + interactionLatencyMs: formatMilliseconds( + interaction?.interactionLatencyMs, + ), + inputDelayMs: formatMilliseconds(interaction?.inputDelayMs), + processingDurationMs: formatMilliseconds( + interaction?.processingDurationMs, + ), + presentationDelayMs: formatMilliseconds(interaction?.presentationDelayMs), + } + }) export const clientSideRenderedStats = runtimeEntries .map((entry) => entry.data) @@ -172,13 +185,30 @@ export const clientSideRenderedStats = runtimeEntries Number.isFinite(framework.clientSideRenderedTests.firstPaintMs), ) .sort((a, b) => a.order - b.order) - .map((framework) => ({ - name: framework.name, - package: framework.package, - isFocused: framework.isFocused, - firstPaintMs: `${framework.clientSideRenderedTests!.firstPaintMs}ms`, - fcpMs: `${framework.clientSideRenderedTests!.fcpMs}ms`, - })) + .map((framework) => { + const tests = framework.clientSideRenderedTests! + const interaction = tests.interactionTests + + return { + name: framework.name, + package: framework.package, + isFocused: framework.isFocused, + firstPaintMs: `${tests.firstPaintMs}ms`, + fcpMs: `${tests.fcpMs}ms`, + interactionLatencyMs: formatMilliseconds( + interaction?.interactionLatencyMs, + ), + inputDelayMs: formatMilliseconds(interaction?.inputDelayMs), + processingDurationMs: formatMilliseconds( + interaction?.processingDurationMs, + ), + presentationDelayMs: formatMilliseconds(interaction?.presentationDelayMs), + } + }) + +function formatMilliseconds(value: number | undefined) { + return value != null && Number.isFinite(value) ? `${value}ms` : '—' +} export const depsStats = starterStats.map((f) => ({ name: f.name, @@ -350,6 +380,25 @@ export const chartClientSideRenderedFCPData = runtimeEntries focused: f.isFocused, })) +type RenderedTestsKey = 'clientSideRenderedTests' | 'serverSideRenderedTests' +export function getRenderedInteractionBreakdownData( + testsKey: RenderedTestsKey, +) { + return runtimeEntries + .map((entry) => entry.data) + .sort((a, b) => a.order - b.order) + .flatMap((framework) => { + const interaction = framework[testsKey]?.interactionTests + if (interaction == null) return [] + + return { + name: framework.name, + focused: framework.isFocused, + ...interaction, + } + }) +} + export const coreJsTableData = starterStats.map((f) => { const hasCorejs = (f.vendoredCoreJsUnnecessaryModules?.length ?? 0) > 0 return {