Skip to content

Commit

Permalink
Adds first input delay performance metric (#8884)
Browse files Browse the repository at this point in the history
* 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 <joe.haddad@zeit.co>
  • Loading branch information
housseindjirdeh and Timer authored Apr 13, 2020
1 parent 0ccce72 commit 04ceba4
Show file tree
Hide file tree
Showing 6 changed files with 187 additions and 6 deletions.
3 changes: 3 additions & 0 deletions packages/next/build/webpack-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
20 changes: 14 additions & 6 deletions packages/next/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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() {
Expand All @@ -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 }) {
Expand Down
29 changes: 29 additions & 0 deletions packages/next/next-server/lib/fid-measure.js
Original file line number Diff line number Diff line change
@@ -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,
})
}
}
})
}
124 changes: 124 additions & 0 deletions packages/next/next-server/lib/fid.ts
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions packages/next/next-server/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const defaultConfig: { [key: string]: any } = {
basePath: '',
sassOptions: {},
pageEnv: false,
measureFid: false,
},
future: {
excludeDefaultMomentLocales: false,
Expand Down
16 changes: 16 additions & 0 deletions packages/next/pages/_document.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -278,6 +279,20 @@ export class Head extends Component<
})
}

getFidPolyfill(): JSX.Element | null {
if (!process.env.__NEXT_FID_POLYFILL) {
return null
}

return (
<script
dangerouslySetInnerHTML={{
__html: `(${fidPolyfill})(addEventListener, removeEventListener)`,
}}
/>
)
}

render() {
const {
styles,
Expand Down Expand Up @@ -517,6 +532,7 @@ export class Head extends Component<
{styles || null}
</>
)}
{this.getFidPolyfill()}
{React.createElement(React.Fragment, {}, ...(headTags || []))}
</head>
)
Expand Down

0 comments on commit 04ceba4

Please sign in to comment.