diff --git a/.github/workflows/cwv-stats-monthly.yml b/.github/workflows/cwv-stats-monthly.yml index 5b72108c..210a59ac 100644 --- a/.github/workflows/cwv-stats-monthly.yml +++ b/.github/workflows/cwv-stats-monthly.yml @@ -26,7 +26,7 @@ jobs: run: | container_id=$(docker run -d cwv-stats) docker wait "$container_id" - docker cp "$container_id:/app/cwv-stats.json" ./packages/cwv-stats/cwv-stats.json + docker cp "$container_id:/app/cwv-stats.json" ./packages/docs/src/content/cwv/cwv-stats.json cat ./packages/cwv-stats/cwv-stats.json docker rm "$container_id" diff --git a/.prettierignore b/.prettierignore index 6d692181..0065553a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -11,4 +11,4 @@ **/output .changeset/*.md pnpm-lock.yaml -/packages/cwv-stats/cwv-stats.json +/packages/docs/src/content/cwv/cwv-stats.json diff --git a/packages/cwv-stats/cwv-stats.json b/packages/cwv-stats/cwv-stats.json deleted file mode 100644 index e64926cd..00000000 --- a/packages/cwv-stats/cwv-stats.json +++ /dev/null @@ -1,58 +0,0 @@ -[ - { - "framework": "Svelte", - "date": "2026-04-01", - "overall": { - "mobile": 0.5257320161204038, - "desktop": 0.6419379360899193 - }, - "lcp": { - "mobile": 0.6814389308900151, - "desktop": 0.8373465373397545 - }, - "cls": { - "mobile": 0.813545042884055, - "desktop": 0.7491705988870825 - }, - "fcp": { - "mobile": 0.6394810765447064, - "desktop": 0.8271154276979034 - }, - "ttfb": { - "mobile": 0.5857041465673832, - "desktop": 0.7353218166228582 - }, - "inp": { - "mobile": 0.7613325312077004, - "desktop": 0.9738081902942048 - } - }, - { - "framework": "Next.js", - "date": "2026-04-01", - "overall": { - "mobile": 0.32837317168530766, - "desktop": 0.5218013684903662 - }, - "lcp": { - "mobile": 0.5239420471475185, - "desktop": 0.728655890907357 - }, - "cls": { - "mobile": 0.6885431949314789, - "desktop": 0.694173332525821 - }, - "fcp": { - "mobile": 0.5064211838536264, - "desktop": 0.7747447878222359 - }, - "ttfb": { - "mobile": 0.47751544287661296, - "desktop": 0.6729342084684966 - }, - "inp": { - "mobile": 0.5834672671143503, - "desktop": 0.9403191203369371 - } - } -] \ No newline at end of file diff --git a/packages/cwv-stats/src/cwv/cwv.ts b/packages/cwv-stats/src/cwv/cwv.ts index 06ca1ee8..314eaec9 100644 --- a/packages/cwv-stats/src/cwv/cwv.ts +++ b/packages/cwv-stats/src/cwv/cwv.ts @@ -7,6 +7,7 @@ import { // httparchive allows us to pull FID but does not include any metrics at this current time so we can ignore it. type FrameworkCWV = { + id: string framework: Framework date: string } & { @@ -31,12 +32,9 @@ export async function getLatestFrameworksCWV(): Promise> { } async function getHttpArchiveCWV() { - const url = new URL('https://cdn.httparchive.org/v1/cwv') - frameworks.forEach((framework) => - url.searchParams.append('technology', framework), - ) - url.searchParams.append('geo', 'ALL') - url.searchParams.append('rank', 'ALL') + const baseUrl = 'https://cdn.httparchive.org/v1/cwv' + const queryString = frameworks.map(buildFrameworkQueryParam).join('&') + const url = `${baseUrl}?${queryString}&geo=ALL&rank=ALL` const response = await fetch(url) if (!response.ok) { @@ -48,6 +46,10 @@ async function getHttpArchiveCWV() { return data } +function buildFrameworkQueryParam(framework: Framework) { + return `technology=${framework.replace(' ', '+')}` +} + function getLatestCWVForFrameworks(frameworksCWV: HTTPArchiveCWVSnapshot[]) { const latestStats = new Map() @@ -70,6 +72,7 @@ function validateAllCWVIsSameDate( function buildFrameworkCWV(latestFrameworkCWV: HTTPArchiveCWVSnapshot[]) { const frameworkVitals = latestFrameworkCWV.map((stat) => ({ + id: stat.technology.toLowerCase().replace(/[.\s]/g, '-'), framework: stat.technology, date: stat.date, overall: getCWV('overall', stat), @@ -91,13 +94,23 @@ function getCWV(cwv: HTTPArchiveCWV, stat: HTTPArchiveCWVSnapshot) { ) } - const hasMobileVital = vital.mobile.tested > 0 - const hasDesktopVital = vital.desktop.tested > 0 + const vitalMobile = vital.mobile ?? { + tested: 0, + good_number: 0, + } + + const vitalDesktop = vital.desktop ?? { + tested: 0, + good_number: 0, + } + + const hasMobileVital = vitalMobile.tested > 0 + const hasDesktopVital = vitalDesktop.tested > 0 return { - mobile: hasMobileVital ? vital.mobile.good_number / vital.mobile.tested : 0, + mobile: hasMobileVital ? vitalMobile.good_number / vitalMobile.tested : 0, desktop: hasDesktopVital - ? vital.desktop.good_number / vital.desktop.tested + ? vitalDesktop.good_number / vitalDesktop.tested : 0, } } diff --git a/packages/cwv-stats/src/frameworks/frameworks.ts b/packages/cwv-stats/src/frameworks/frameworks.ts index 31a560aa..686ea568 100644 --- a/packages/cwv-stats/src/frameworks/frameworks.ts +++ b/packages/cwv-stats/src/frameworks/frameworks.ts @@ -1,3 +1,10 @@ -export const frameworks = ['Svelte', 'Next.js'] as const +export const frameworks = [ + 'Next.js', + 'SolidStart', + 'Astro', + 'Nuxt.js', + 'SvelteKit', + 'React Router', +] as const export type Framework = (typeof frameworks)[number] diff --git a/packages/cwv-stats/src/httparchive/httparchive.ts b/packages/cwv-stats/src/httparchive/httparchive.ts index 1dcd7b9e..fcc8db56 100644 --- a/packages/cwv-stats/src/httparchive/httparchive.ts +++ b/packages/cwv-stats/src/httparchive/httparchive.ts @@ -20,8 +20,8 @@ export type HTTPArchiveCWV = z.infer const httpArchiveVitalSchema = z.object({ name: httpArchiveCWVSchema, - desktop: httpArchiveDeviceSchema, - mobile: httpArchiveDeviceSchema, + desktop: httpArchiveDeviceSchema.nullish(), + mobile: httpArchiveDeviceSchema.nullish(), }) const httpArchiveCWVSnapshot = z.object({ diff --git a/packages/docs/src/components/ChartTabs.astro b/packages/docs/src/components/ChartTabs.astro index b0258cef..5ec5495a 100644 --- a/packages/docs/src/components/ChartTabs.astro +++ b/packages/docs/src/components/ChartTabs.astro @@ -6,19 +6,29 @@ interface Props { tab2Label: string tab3Label?: string tab4Label?: string + tab5Label?: string } -const { sectionId, label, tab1Label, tab2Label, tab3Label, tab4Label } = - Astro.props +const { + sectionId, + label, + tab1Label, + tab2Label, + tab3Label, + tab4Label, + tab5Label, +} = Astro.props const tab1Id = `${sectionId}-tab-1` const tab2Id = `${sectionId}-tab-2` const tab3Id = `${sectionId}-tab-3` const tab4Id = `${sectionId}-tab-4` +const tab5Id = `${sectionId}-tab-5` const panel1Id = `${sectionId}-panel-1` const panel2Id = `${sectionId}-panel-2` const panel3Id = `${sectionId}-panel-3` const panel4Id = `${sectionId}-panel-4` +const panel5Id = `${sectionId}-panel-5` ---
@@ -71,6 +81,20 @@ const panel4Id = `${sectionId}-panel-4` ) } + { + tab5Label && ( + + ) + }
) } + { + tab5Label && ( + + ) + }
diff --git a/packages/docs/src/components/ComparisonBarChart.astro b/packages/docs/src/components/ComparisonBarChart.astro index 58e3f9b8..77cd589e 100644 --- a/packages/docs/src/components/ComparisonBarChart.astro +++ b/packages/docs/src/components/ComparisonBarChart.astro @@ -3,6 +3,7 @@ import type { ChartDatum } from '../lib/types' interface Props { title: string + description?: string data: ChartDatum[] valueFormat: 'count' | 'mb' | 'kb' | 'ms' | 's' yAxisLabel: string @@ -11,7 +12,7 @@ interface Props { const CHART_HEIGHT_PX = 400 const MOBILE_CHART_HEIGHT_PX = 300 -const { title, data, valueFormat, yAxisLabel } = Astro.props +const { title, description, data, valueFormat, yAxisLabel } = Astro.props const chartData = data.map((d) => ({ name: String(d?.name ?? ''), value: Math.max(0, Number(d.value) || 0), @@ -26,6 +27,7 @@ const chartPayload = JSON.stringify({

{title}

+ {description ?

{description}

: null}
{title} chart
diff --git a/packages/docs/src/components/CoreWebVitalChart.astro b/packages/docs/src/components/CoreWebVitalChart.astro new file mode 100644 index 00000000..8340df89 --- /dev/null +++ b/packages/docs/src/components/CoreWebVitalChart.astro @@ -0,0 +1,35 @@ +--- +import { getCWVDesktopStatsChartData } from '../lib/collections' +import type { CWV } from '../lib/collections' +import ComparisonBarChart from './ComparisonBarChart.astro' + +interface Props { + cwv: CWV +} + +const { cwv } = Astro.props + +const cwvTitle: Record = { + lcp: 'Good Largest Contentful Paint', + cls: 'Good Cumulative Layout Shift', + fcp: 'Good First Contentful Paint', + ttfb: 'Good Time To First Byte', + inp: 'Good Interaction to Next Paint', +} + +const cwvDescription: Record = { + lcp: `Measures how fast a page's main content loads. To provide a good user experience, the LCP should be 2.5 seconds or less.`, + cls: `Measures how much and how far content unexpectedly moves on a page. To provide a good user experience, sites should maintain a CLS score of 0.1 or less for at least 75% of page visits.`, + fcp: `Measures initial loading speed. To provide a good user experience, the FCP should be 1.8 seconds or less.`, + ttfb: `Measures the time between the request for a resource and when the first byte of a response begins to arrive. A good TTFB is less than or equal to 800ms.`, + inp: `Measures overall page responsiveness to user actions. To provide a good user experience, the INP should be 200ms or less.`, +} +--- + + diff --git a/packages/docs/src/components/CoreWebVitalsCharts.astro b/packages/docs/src/components/CoreWebVitalsCharts.astro new file mode 100644 index 00000000..d5fb9202 --- /dev/null +++ b/packages/docs/src/components/CoreWebVitalsCharts.astro @@ -0,0 +1,20 @@ +--- +import ChartTabs from './ChartTabs.astro' +import CoreWebVitalChart from './CoreWebVitalChart.astro' +--- + + + + + + + + diff --git a/packages/docs/src/components/CoreWebVitalsTable.astro b/packages/docs/src/components/CoreWebVitalsTable.astro new file mode 100644 index 00000000..967d43e7 --- /dev/null +++ b/packages/docs/src/components/CoreWebVitalsTable.astro @@ -0,0 +1,22 @@ +--- +import { cwvStats } from '../lib/collections' +import { getFrameworkSlug } from '../lib/utils' +import StatsTable from './StatsTable.astro' + +const columns = [ + { + key: 'framework', + header: 'Framework', + nameCell: true, + href: (row: Record) => + `/framework/${getFrameworkSlug(row.id as string)}`, + }, + { key: 'lcpDesktopPercent', header: 'LCP%' }, + { key: 'clsDesktopPercent', header: 'CLS%' }, + { key: 'fcpDesktopPercent', header: 'FCP%' }, + { key: 'ttfbDesktopPercent', header: 'TTFB%' }, + { key: 'inpDesktopPercent', header: 'INP%' }, +] +--- + + diff --git a/packages/docs/src/content.config.ts b/packages/docs/src/content.config.ts index ee0eb57d..2d66a0d7 100644 --- a/packages/docs/src/content.config.ts +++ b/packages/docs/src/content.config.ts @@ -1,5 +1,5 @@ import { defineCollection } from 'astro:content' -import { glob } from 'astro/loaders' +import { file, glob } from 'astro/loaders' import { z } from 'astro/zod' const timeSchema = z.object({ @@ -111,7 +111,41 @@ const runtimeCollection = defineCollection({ }), }) +const cwvCollection = defineCollection({ + loader: file('src/content/cwv/cwv-stats.json'), + schema: z.object({ + id: z.string(), + framework: z.string(), + date: z.string(), + overall: z.object({ + mobile: z.number(), + desktop: z.number(), + }), + lcp: z.object({ + mobile: z.number(), + desktop: z.number(), + }), + cls: z.object({ + mobile: z.number(), + desktop: z.number(), + }), + fcp: z.object({ + mobile: z.number(), + desktop: z.number(), + }), + ttfb: z.object({ + mobile: z.number(), + desktop: z.number(), + }), + inp: z.object({ + mobile: z.number(), + desktop: z.number(), + }), + }), +}) + export const collections = { devtime: devtimeCollection, runtime: runtimeCollection, + cwv: cwvCollection, } diff --git a/packages/docs/src/content/cwv/cwv-stats.json b/packages/docs/src/content/cwv/cwv-stats.json new file mode 100644 index 00000000..af583e67 --- /dev/null +++ b/packages/docs/src/content/cwv/cwv-stats.json @@ -0,0 +1,176 @@ +[ + { + "id": "sveltekit", + "framework": "SvelteKit", + "date": "2026-05-01", + "overall": { + "mobile": 0.5210084033613446, + "desktop": 0.6167048054919908 + }, + "lcp": { + "mobile": 0.6796462209970136, + "desktop": 0.7981415296640457 + }, + "cls": { + "mobile": 0.8133227417158421, + "desktop": 0.7705923344947735 + }, + "fcp": { + "mobile": 0.604380715817997, + "desktop": 0.7804199694062022 + }, + "ttfb": { + "mobile": 0.5801059446733372, + "desktop": 0.7120402868915001 + }, + "inp": { + "mobile": 0.7564917127071823, + "desktop": 0.9485678113625574 + } + }, + { + "id": "react-router", + "framework": "React Router", + "date": "2026-05-01", + "overall": { + "mobile": 0.2791670651238405, + "desktop": 0.4027513140604468 + }, + "lcp": { + "mobile": 0.4100278451879103, + "desktop": 0.6116633151393278 + }, + "cls": { + "mobile": 0.6457572792208933, + "desktop": 0.6211578561991628 + }, + "fcp": { + "mobile": 0.4121720477742092, + "desktop": 0.6662146838895894 + }, + "ttfb": { + "mobile": 0.5382472732085597, + "desktop": 0.7052101211616565 + }, + "inp": { + "mobile": 0.5659162625214427, + "desktop": 0.9045985703739714 + } + }, + { + "id": "nuxt-js", + "framework": "Nuxt.js", + "date": "2026-05-01", + "overall": { + "mobile": 0.28270924275083675, + "desktop": 0.3930229196447214 + }, + "lcp": { + "mobile": 0.45232693803474205, + "desktop": 0.6580843994285622 + }, + "cls": { + "mobile": 0.5810558442805767, + "desktop": 0.5772465970144578 + }, + "fcp": { + "mobile": 0.4374482363184608, + "desktop": 0.6750714274562458 + }, + "ttfb": { + "mobile": 0.454284430514307, + "desktop": 0.5918318969166427 + }, + "inp": { + "mobile": 0.6582373398492547, + "desktop": 0.9590682017583126 + } + }, + { + "id": "next-js", + "framework": "Next.js", + "date": "2026-05-01", + "overall": { + "mobile": 0.329349427286761, + "desktop": 0.5267716499059955 + }, + "lcp": { + "mobile": 0.5199002426702694, + "desktop": 0.729213311974591 + }, + "cls": { + "mobile": 0.6908568037325353, + "desktop": 0.699733991079024 + }, + "fcp": { + "mobile": 0.500675157955559, + "desktop": 0.7669949081447254 + }, + "ttfb": { + "mobile": 0.479252936427325, + "desktop": 0.6726856449569246 + }, + "inp": { + "mobile": 0.5866836931891384, + "desktop": 0.9409772887887863 + } + }, + { + "id": "astro", + "framework": "Astro", + "date": "2026-05-01", + "overall": { + "mobile": 0.6768284574468085, + "desktop": 0.8117316659601526 + }, + "lcp": { + "mobile": 0.7713736154407376, + "desktop": 0.9188860062476836 + }, + "cls": { + "mobile": 0.9267192068132706, + "desktop": 0.873745213701749 + }, + "fcp": { + "mobile": 0.7136175457447145, + "desktop": 0.9160391954615781 + }, + "ttfb": { + "mobile": 0.6091686350177983, + "desktop": 0.8251350582883139 + }, + "inp": { + "mobile": 0.8617109816461518, + "desktop": 0.9752736881842204 + } + }, + { + "id": "solidstart", + "framework": "SolidStart", + "date": "2026-05-01", + "overall": { + "mobile": 0.6363636363636364, + "desktop": 0.6666666666666666 + }, + "lcp": { + "mobile": 0.9090909090909091, + "desktop": 0.9166666666666666 + }, + "cls": { + "mobile": 0.6363636363636364, + "desktop": 0.6666666666666666 + }, + "fcp": { + "mobile": 0.9090909090909091, + "desktop": 0.9166666666666666 + }, + "ttfb": { + "mobile": 0.8181818181818182, + "desktop": 0.8333333333333334 + }, + "inp": { + "mobile": 1, + "desktop": 1 + } + } +] \ No newline at end of file diff --git a/packages/docs/src/lib/collections.ts b/packages/docs/src/lib/collections.ts index 82e344a1..fa493368 100644 --- a/packages/docs/src/lib/collections.ts +++ b/packages/docs/src/lib/collections.ts @@ -3,6 +3,38 @@ import { formatBytesToMB, formatTimeMs } from './utils' const devtimeEntries = await getCollection('devtime') export const runtimeEntries = await getCollection('runtime') +const cwvEntries = await getCollection('cwv') + +export const cwvStats = cwvEntries + .map((entry) => entry.data) + .sort((a, b) => b.overall.desktop - a.overall.desktop) + .map((stat) => ({ + id: stat.id, + framework: stat.framework, + isFocused: true, + lcpDesktopPercent: Math.floor(stat.lcp.desktop * 100), + lcpMobilePercent: Math.floor(stat.lcp.mobile * 100), + clsDesktopPercent: Math.floor(stat.cls.desktop * 100), + clsMobilePercent: Math.floor(stat.cls.mobile * 100), + fcpDesktopPercent: Math.floor(stat.fcp.desktop * 100), + fcpMobilePercent: Math.floor(stat.fcp.mobile * 100), + ttfbDesktopPercent: Math.floor(stat.ttfb.desktop * 100), + ttfbMobilePercent: Math.floor(stat.ttfb.mobile * 100), + inpDesktopPercent: Math.floor(stat.inp.desktop * 100), + inpMobilePercent: Math.floor(stat.inp.mobile * 100), + })) + +export type CWV = 'lcp' | 'cls' | 'fcp' | 'ttfb' | 'inp' + +export function getCWVDesktopStatsChartData(cwv: CWV) { + return cwvStats + .sort((a, b) => b[`${cwv}DesktopPercent`] - a[`${cwv}DesktopPercent`]) + .map((stat) => ({ + name: stat.framework, + value: stat[`${cwv}DesktopPercent`], + focused: true, + })) +} export const starterStats = devtimeEntries .map((entry) => entry.data) diff --git a/packages/docs/src/pages/run-time.astro b/packages/docs/src/pages/run-time.astro index 683dea29..aeeb68c6 100644 --- a/packages/docs/src/pages/run-time.astro +++ b/packages/docs/src/pages/run-time.astro @@ -14,6 +14,8 @@ import SSRRequestThroughputStatsMethodologyNotes from '../components/SSRRequestT import SSRRequestThroughputCharts from '../components/SSRRequestThroughputCharts.astro' import SSRRequestThroughputStatsTable from '../components/SSRRequestThroughputStatsTable.astro' import Layout from '../layouts/Layout.astro' +import CoreWebVitalsTable from '../components/CoreWebVitalsTable.astro' +import CoreWebVitalsCharts from '../components/CoreWebVitalsCharts.astro' --- @@ -37,4 +39,7 @@ import Layout from '../layouts/Layout.astro' +

Core Web Vitals Desktop

+ +