diff --git a/CHANGELOG.md b/CHANGELOG.md index 39869a78..f66bc473 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +### v5.0.0-rc.1 (???) + +- **[BREAKING]** Add interoperable TTFB to measure Early Hints consistently ([#566](https://github.com/GoogleChrome/web-vitals/pull/566)) + ### v5.0.0-rc.0 (2024-10-03) - **[BREAKING]** Remove the deprecated `onFID()` function ([#519](https://github.com/GoogleChrome/web-vitals/pull/519)) diff --git a/src/attribution/onLCP.ts b/src/attribution/onLCP.ts index 1faa264d..1659089e 100644 --- a/src/attribution/onLCP.ts +++ b/src/attribution/onLCP.ts @@ -83,7 +83,13 @@ export const onLCP = ( const ttfb = Math.max( 0, - navigationEntry.responseStart - activationStart, + // From Chrome 115 until 133, Chrome reported responseStart as the + // document bytes, rather than Early Hint bytes. Prefer the Early + // Hint bytes (firstInterimResponseStart) for consistency with other + // browers, but only if non-zero (so use || rather than ??) as zero + // indicates no early hints. + (navigationEntry.firstInterimResponseStart || + navigationEntry.responseStart) - activationStart, ); const lcpRequestStart = Math.max( diff --git a/src/onTTFB.ts b/src/onTTFB.ts index 721dca0e..9a87febf 100644 --- a/src/onTTFB.ts +++ b/src/onTTFB.ts @@ -71,14 +71,20 @@ export const onTTFB = ( const navigationEntry = getNavigationEntry(); if (navigationEntry) { + // From Chrome 115 until 133, Chrome reported responseStart as the + // document bytes, rather than Early Hint bytes. Prefer the Early Hint + // bytes (firstInterimResponseStart) for consistency with other + // browers, but only if non-zero (so use || rather than ??) as zero + // indicates no early hints. + const responseStart = + navigationEntry.firstInterimResponseStart || + navigationEntry.responseStart; + // The activationStart reference is used because TTFB should be // relative to page activation rather than navigation start if the // page was prerendered. But in cases where `activationStart` occurs // after the first byte is received, this time should be clamped at 0. - metric.value = Math.max( - navigationEntry.responseStart - getActivationStart(), - 0, - ); + metric.value = Math.max(responseStart - getActivationStart(), 0); metric.entries = [navigationEntry]; report(true); diff --git a/src/types.ts b/src/types.ts index 34d3a7ca..edc3db5f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -53,9 +53,12 @@ declare global { durationThreshold?: number; } - // https://wicg.github.io/nav-speculation/prerendering.html#performance-navigation-timing-extension interface PerformanceNavigationTiming { + // https://wicg.github.io/nav-speculation/prerendering.html#performance-navigation-timing-extension activationStart?: number; + // https://w3c.github.io/resource-timing/#dom-performanceresourcetiming-firstinterimresponsestart + firstInterimResponseStart?: number; + finalResponseHeadersStart?: number; } // https://wicg.github.io/event-timing/#sec-performance-event-timing diff --git a/test/e2e/onTTFB-test.js b/test/e2e/onTTFB-test.js index 07e34fb1..20a167ee 100644 --- a/test/e2e/onTTFB-test.js +++ b/test/e2e/onTTFB-test.js @@ -398,6 +398,44 @@ describe('onTTFB()', async function () { assert.strictEqual(ttfb.attribution.requestDuration, 0); assert.strictEqual(ttfb.attribution.navigationEntry, undefined); }); + + it('reports the correct value for Early Hints', async function () { + await navigateTo( + // '/test/ttfb?responseStart=10&earlyHintsDelay=50&attribution=1', + '/test/ttfb?earlyHintsDelay=50&attribution=1', + ); + + const ttfb = await getTTFBBeacon(); + + if ('finalResponseHeadersStart' in ttfb.attribution.navigationEntry) { + assert.strictEqual( + ttfb.value, + ttfb.attribution.navigationEntry.responseStart, + ); + assert.strictEqual( + ttfb.value, + ttfb.attribution.navigationEntry.firstInterimResponseStart, + ); + assert( + ttfb.value < + ttfb.attribution.navigationEntry.finalResponseHeadersStart, + ); + } else if ( + 'firstInterimResponseStart' in ttfb.attribution.navigationEntry + ) { + // TODO: Can remove these after Chrome 133 lands and above is used. + assert(ttfb.value < ttfb.attribution.navigationEntry.responseStart); + assert.strictEqual( + ttfb.value, + ttfb.attribution.navigationEntry.firstInterimResponseStart, + ); + } else { + assert.strictEqual( + ttfb.value, + ttfb.attribution.navigationEntry.responseStart, + ); + } + }); }); }); diff --git a/test/server.js b/test/server.js index ff666576..5d13d4b0 100644 --- a/test/server.js +++ b/test/server.js @@ -39,6 +39,19 @@ app.use((req, res, next) => { } }); +// Allow the use of a `earlyHintsDelay` query param to delay any response +// after sending an early hints +app.use((req, res, next) => { + if (req.query && req.query.earlyHintsDelay) { + res.writeEarlyHints({ + 'link': '; rel=preload; as=style', + }); + setTimeout(next, req.query.earlyHintsDelay); + } else { + next(); + } +}); + // Add a "collect" endpoint to simulate analytics beacons. app.post('/collect', bodyParser.text(), (req, res) => { // Uncomment to log the metric when manually testing.