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 |
|
17 | import {FirstInputPolyfillEntry, FirstInputPolyfillCallback} from '../../types.js';
|
18 |
|
19 |
|
20 | type addOrRemoveEventListener =
|
21 | typeof addEventListener | typeof removeEventListener;
|
22 |
|
23 | let firstInputEvent: Event|null;
|
24 | let firstInputDelay: number;
|
25 | let firstInputTimeStamp: Date;
|
26 | let callbacks: FirstInputPolyfillCallback[]
|
27 |
|
28 | const listenerOpts: AddEventListenerOptions = {passive: true, capture: true};
|
29 | const startTimeStamp: Date = new Date();
|
30 |
|
31 | /**
|
32 | * Accepts a callback to be invoked once the first input delay and event
|
33 | * are known.
|
34 | */
|
35 | export const firstInputPolyfill = (
|
36 | onFirstInput: FirstInputPolyfillCallback
|
37 | ) => {
|
38 | callbacks.push(onFirstInput);
|
39 | reportFirstInputDelayIfRecordedAndValid();
|
40 | }
|
41 |
|
42 | export 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 | */
|
53 | const 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 | */
|
68 | const 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 | */
|
100 | const 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 | */
|
137 | const 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 | */
|
166 | const eachEventType = (callback: addOrRemoveEventListener) => {
|
167 | const eventTypes = [
|
168 | 'mousedown',
|
169 | 'keydown',
|
170 | 'touchstart',
|
171 | 'pointerdown',
|
172 | ];
|
173 | eventTypes.forEach((type) => callback(type, onInput, listenerOpts));
|
174 | }
|