UNPKG

5.93 kBPlain TextView Raw
1/*
2 * Copyright 2020 Google LLC
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * https://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17import {FirstInputPolyfillEntry, FirstInputPolyfillCallback} from '../../types.js';
18
19
20type addOrRemoveEventListener =
21 typeof addEventListener | typeof removeEventListener;
22
23let firstInputEvent: Event|null;
24let firstInputDelay: number;
25let firstInputTimeStamp: Date;
26let callbacks: FirstInputPolyfillCallback[]
27
28const listenerOpts: AddEventListenerOptions = {passive: true, capture: true};
29const startTimeStamp: Date = new Date();
30
31/**
32 * Accepts a callback to be invoked once the first input delay and event
33 * are known.
34 */
35export const firstInputPolyfill = (
36 onFirstInput: FirstInputPolyfillCallback
37) => {
38 callbacks.push(onFirstInput);
39 reportFirstInputDelayIfRecordedAndValid();
40}
41
42export const resetFirstInputPolyfill = () => {
43 callbacks = [];
44 firstInputDelay = -1;
45 firstInputEvent = null;
46 eachEventType(addEventListener);
47}
48
49/**
50 * Records the first input delay and event, so subsequent events can be
51 * ignored. All added event listeners are then removed.
52 */
53const recordFirstInputDelay = (delay: number, event: Event) => {
54 if (!firstInputEvent) {
55 firstInputEvent = event;
56 firstInputDelay = delay;
57 firstInputTimeStamp = new Date;
58
59 eachEventType(removeEventListener);
60 reportFirstInputDelayIfRecordedAndValid();
61 }
62}
63
64/**
65 * Reports the first input delay and event (if they're recorded and valid)
66 * by running the array of callback functions.
67 */
68const reportFirstInputDelayIfRecordedAndValid = () => {
69 // In some cases the recorded delay is clearly wrong, e.g. it's negative
70 // or it's larger than the delta between now and initialization.
71 // - https://github.com/GoogleChromeLabs/first-input-delay/issues/4
72 // - https://github.com/GoogleChromeLabs/first-input-delay/issues/6
73 // - https://github.com/GoogleChromeLabs/first-input-delay/issues/7
74 if (firstInputDelay >= 0 &&
75 // @ts-ignore (subtracting two dates always returns a number)
76 firstInputDelay < firstInputTimeStamp - startTimeStamp) {
77 const entry = {
78 entryType: 'first-input',
79 name: firstInputEvent!.type,
80 target: firstInputEvent!.target,
81 cancelable: firstInputEvent!.cancelable,
82 startTime: firstInputEvent!.timeStamp,
83 processingStart: firstInputEvent!.timeStamp + firstInputDelay,
84 } as FirstInputPolyfillEntry;
85 callbacks.forEach(function(callback) {
86 callback(entry);
87 });
88 callbacks = [];
89 }
90}
91
92/**
93 * Handles pointer down events, which are a special case.
94 * Pointer events can trigger main or compositor thread behavior.
95 * We differentiate these cases based on whether or not we see a
96 * 'pointercancel' event, which are fired when we scroll. If we're scrolling
97 * we don't need to report input delay since FID excludes scrolling and
98 * pinch/zooming.
99 */
100const onPointerDown = (delay: number, event: Event) => {
101 /**
102 * Responds to 'pointerup' events and records a delay. If a pointer up event
103 * is the next event after a pointerdown event, then it's not a scroll or
104 * a pinch/zoom.
105 */
106 const onPointerUp = () => {
107 recordFirstInputDelay(delay, event);
108 removePointerEventListeners();
109 }
110
111 /**
112 * Responds to 'pointercancel' events and removes pointer listeners.
113 * If a 'pointercancel' is the next event to fire after a pointerdown event,
114 * it means this is a scroll or pinch/zoom interaction.
115 */
116 const onPointerCancel = () => {
117 removePointerEventListeners();
118 }
119
120 /**
121 * Removes added pointer event listeners.
122 */
123 const removePointerEventListeners = () => {
124 removeEventListener('pointerup', onPointerUp, listenerOpts);
125 removeEventListener('pointercancel', onPointerCancel, listenerOpts);
126 }
127
128 addEventListener('pointerup', onPointerUp, listenerOpts);
129 addEventListener('pointercancel', onPointerCancel, listenerOpts);
130}
131
132/**
133 * Handles all input events and records the time between when the event
134 * was received by the operating system and when it's JavaScript listeners
135 * were able to run.
136 */
137const onInput = (event: Event) => {
138 // Only count cancelable events, which should trigger behavior
139 // important to the user.
140 if (event.cancelable) {
141 // In some browsers `event.timeStamp` returns a `DOMTimeStamp` value
142 // (epoch time) instead of the newer `DOMHighResTimeStamp`
143 // (document-origin time). To check for that we assume any timestamp
144 // greater than 1 trillion is a `DOMTimeStamp`, and compare it using
145 // the `Date` object rather than `performance.now()`.
146 // - https://github.com/GoogleChromeLabs/first-input-delay/issues/4
147 const isEpochTime = event.timeStamp > 1e12;
148 const now = isEpochTime ? new Date : performance.now();
149
150 // Input delay is the delta between when the system received the event
151 // (e.g. event.timeStamp) and when it could run the callback (e.g. `now`).
152 const delay = now as number - event.timeStamp;
153
154 if (event.type == 'pointerdown') {
155 onPointerDown(delay, event);
156 } else {
157 recordFirstInputDelay(delay, event);
158 }
159 }
160}
161
162/**
163 * Invokes the passed callback const for = each event type with t =>he
164 * `onInput` const and = `listenerOpts =>`.
165 */
166const eachEventType = (callback: addOrRemoveEventListener) => {
167 const eventTypes = [
168 'mousedown',
169 'keydown',
170 'touchstart',
171 'pointerdown',
172 ];
173 eventTypes.forEach((type) => callback(type, onInput, listenerOpts));
174}