From 04ceba4309b8eabde04a931fa220ad006924d44e Mon Sep 17 00:00:00 2001 From: Houssein Djirdeh Date: Mon, 13 Apr 2020 11:46:46 -0700 Subject: [PATCH] Adds first input delay performance metric (#8884) * measures fid * updates typings, fixes logic, updates per review comments * update to es5 * separate clearMeasures * use relayer * creates fid polyfll render helper + simplifies measure * switch to dynamic import * creates fid experimental flag * removes unecessary time-to-first-input metric * removes hydration measure removes * default flag to false Co-authored-by: Joe Haddad --- packages/next/build/webpack-config.ts | 3 + packages/next/client/index.js | 20 ++- packages/next/next-server/lib/fid-measure.js | 29 +++++ packages/next/next-server/lib/fid.ts | 124 +++++++++++++++++++ packages/next/next-server/server/config.ts | 1 + packages/next/pages/_document.tsx | 16 +++ 6 files changed, 187 insertions(+), 6 deletions(-) create mode 100644 packages/next/next-server/lib/fid-measure.js create mode 100644 packages/next/next-server/lib/fid.ts diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index f6b12ed31053b..3910972ad21af 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -822,6 +822,9 @@ export default async function getBaseWebpackConfig( 'process.env.__NEXT_ROUTER_BASEPATH': JSON.stringify( config.experimental.basePath ), + 'process.env.__NEXT_FID_POLYFILL': JSON.stringify( + config.experimental.measureFid + ), ...(isServer ? { // Fix bad-actors in the npm ecosystem (e.g. `node-formidable`) diff --git a/packages/next/client/index.js b/packages/next/client/index.js index f1ac8ad697e04..c223565926606 100644 --- a/packages/next/client/index.js +++ b/packages/next/client/index.js @@ -345,7 +345,18 @@ function markHydrateComplete() { 'beforeRender' ) performance.measure('Next.js-hydration', 'beforeRender', 'afterHydrate') + if (onPerfEntry) { + if (process.env.__NEXT_FID_POLYFILL) { + import('../next-server/lib/fid-measure') + .then(mod => { + mod.default(onPerfEntry) + }) + .catch(err => { + console.error('Error measuring First Input Delay', err) + }) + } + performance.getEntriesByName('Next.js-hydration').forEach(onPerfEntry) performance.getEntriesByName('beforeRender').forEach(onPerfEntry) } @@ -375,6 +386,9 @@ function markRenderComplete() { .forEach(onPerfEntry) } clearMarks() + ;['Next.js-route-change-to-render', 'Next.js-render'].forEach(measure => + performance.clearMeasures(measure) + ) } function clearMarks() { @@ -384,12 +398,6 @@ function clearMarks() { 'afterRender', 'routeChange', ].forEach(mark => performance.clearMarks(mark)) - ;[ - 'Next.js-before-hydration', - 'Next.js-hydration', - 'Next.js-route-change-to-render', - 'Next.js-render', - ].forEach(measure => performance.clearMeasures(measure)) } function AppContainer({ children }) { diff --git a/packages/next/next-server/lib/fid-measure.js b/packages/next/next-server/lib/fid-measure.js new file mode 100644 index 0000000000000..c868443f10bb0 --- /dev/null +++ b/packages/next/next-server/lib/fid-measure.js @@ -0,0 +1,29 @@ +/* global hydrationMetrics */ + +export default onPerfEntry => { + hydrationMetrics.onInputDelay((delay, event) => { + const hydrationMeasures = performance.getEntriesByName( + 'Next.js-hydration', + 'measure' + ) + + if (hydrationMeasures.length > 0) { + const { startTime, duration } = hydrationMeasures[0] + const hydrateEnd = startTime + duration + + if (event.timeStamp > hydrateEnd) { + onPerfEntry({ + name: 'first-input-delay-after-hydration', + startTime: event.timeStamp, + duration: delay, + }) + } else { + onPerfEntry({ + name: 'first-input-delay-before-hydration', + startTime: event.timeStamp, + duration: delay, + }) + } + } + }) +} diff --git a/packages/next/next-server/lib/fid.ts b/packages/next/next-server/lib/fid.ts new file mode 100644 index 0000000000000..548e1ab69e805 --- /dev/null +++ b/packages/next/next-server/lib/fid.ts @@ -0,0 +1,124 @@ +/** + * This is a modified version of the First Input Delay polyfill + * https://github.com/GoogleChromeLabs/first-input-delay + * + * It checks for a first input before and after hydration + */ + +type DelayCallback = (delay: number, event: Event) => void +type addEventListener = ( + type: string, + listener: EventListener, + listenerOpts: EventListenerOptions +) => void +type removeEventListener = addEventListener + +function fidPolyfill( + addEventListener: addEventListener, + removeEventListener: removeEventListener +) { + var firstInputEvent: Event + var firstInputDelay: number + var firstInputTimeStamp: number + + var callbacks: DelayCallback[] = [] + + var listenerOpts = { passive: true, capture: true } + var startTimeStamp = +new Date() + + var pointerup = 'pointerup' + var pointercancel = 'pointercancel' + + function onInputDelay(callback: DelayCallback) { + callbacks.push(callback) + reportInputDelayIfRecordedAndValid() + } + + function recordInputDelay(delay: number, evt: Event) { + firstInputEvent = evt + firstInputDelay = delay + firstInputTimeStamp = +new Date() + + reportInputDelayIfRecordedAndValid() + } + + function reportInputDelayIfRecordedAndValid() { + var hydrationMeasures = performance.getEntriesByName( + 'Next.js-hydration', + 'measure' + ) + var firstInputStart = firstInputTimeStamp - startTimeStamp + + if ( + firstInputDelay >= 0 && + firstInputDelay < firstInputStart && + (hydrationMeasures.length === 0 || + hydrationMeasures[0].startTime < firstInputStart) + ) { + callbacks.forEach(function(callback) { + callback(firstInputDelay, firstInputEvent) + }) + + // If the app is already hydrated, that means the first "post-hydration" input + // has been measured and listeners can be removed + if (hydrationMeasures.length > 0) { + eachEventType(removeEventListener) + callbacks = [] + } + } + } + + function onPointerDown(delay: number, evt: Event) { + function onPointerUp() { + recordInputDelay(delay, evt) + } + + function onPointerCancel() { + removePointerEventListeners() + } + + function removePointerEventListeners() { + removeEventListener(pointerup, onPointerUp, listenerOpts) + removeEventListener(pointercancel, onPointerCancel, listenerOpts) + } + + addEventListener(pointerup, onPointerUp, listenerOpts) + addEventListener(pointercancel, onPointerCancel, listenerOpts) + } + + function onInput(evt: Event) { + if (evt.cancelable) { + var isEpochTime = evt.timeStamp > 1e12 + var now = isEpochTime ? +new Date() : performance.now() + + var delay = now - evt.timeStamp + + if (evt.type === 'pointerdown') { + onPointerDown(delay, evt) + } else { + recordInputDelay(delay, evt) + } + } + } + + function eachEventType(callback: addEventListener | removeEventListener) { + var eventTypes = [ + 'click', + 'mousedown', + 'keydown', + 'touchstart', + 'pointerdown', + ] + eventTypes.forEach(function(eventType) { + callback(eventType, onInput, listenerOpts) + }) + } + + eachEventType(addEventListener) + + var context = self as any + context['hydrationMetrics'] = context['hydrationMetrics'] || {} + context['hydrationMetrics']['onInputDelay'] = onInputDelay +} + +export default fidPolyfill diff --git a/packages/next/next-server/server/config.ts b/packages/next/next-server/server/config.ts index 2150382c854c7..32a28690235aa 100644 --- a/packages/next/next-server/server/config.ts +++ b/packages/next/next-server/server/config.ts @@ -55,6 +55,7 @@ const defaultConfig: { [key: string]: any } = { basePath: '', sassOptions: {}, pageEnv: false, + measureFid: false, }, future: { excludeDefaultMomentLocales: false, diff --git a/packages/next/pages/_document.tsx b/packages/next/pages/_document.tsx index 23104cdb0cc5c..9014be2d30644 100644 --- a/packages/next/pages/_document.tsx +++ b/packages/next/pages/_document.tsx @@ -13,6 +13,7 @@ import { DocumentInitialProps, DocumentProps, } from '../next-server/lib/utils' +import fidPolyfill from '../next-server/lib/fid' import { cleanAmpPath } from '../next-server/server/utils' import { htmlEscapeJsonString } from '../server/htmlescape' @@ -278,6 +279,20 @@ export class Head extends Component< }) } + getFidPolyfill(): JSX.Element | null { + if (!process.env.__NEXT_FID_POLYFILL) { + return null + } + + return ( +