Skip to content

Commit e5e4a6d

Browse files
authored
Defer CLS logic until after onFCP callback (#297)
* Defer CLS logic until after onFCP callback * Address review feedback
1 parent cd560dc commit e5e4a6d

24 files changed

+467
-212
lines changed

.eslintrc

Lines changed: 40 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,7 @@
1212
"overrides": [
1313
{
1414
"files": "wdio.conf.js",
15-
"extends": [
16-
"eslint:recommended", "google"
17-
],
15+
"extends": ["eslint:recommended"],
1816
"rules": {
1917
"max-len": "off"
2018
}
@@ -25,61 +23,72 @@
2523
"$": false,
2624
"browser": false
2725
},
28-
"extends": [
29-
"eslint:recommended", "google"
30-
],
26+
"extends": ["eslint:recommended"],
3127
"rules": {
32-
"comma-dangle": ["error", {
33-
"arrays": "always-multiline",
34-
"objects": "always-multiline",
35-
"imports": "always-multiline",
36-
"exports": "always-multiline",
37-
"functions": "never"
38-
}],
28+
"comma-dangle": [
29+
"error",
30+
{
31+
"arrays": "always-multiline",
32+
"objects": "always-multiline",
33+
"imports": "always-multiline",
34+
"exports": "always-multiline",
35+
"functions": "never"
36+
}
37+
],
3938
"indent": ["error", 2],
4039
"no-invalid-this": "off",
41-
"max-len": [2, {
42-
"ignorePattern": "^\\s*import|= require\\(|^\\s*it\\(|^\\s*describe\\(",
43-
"ignoreUrls": true
44-
}],
45-
"space-before-function-paren": ["error", {
40+
"max-len": [
41+
2,
42+
{
43+
"ignorePattern": "^\\s*import|= require\\(|^\\s*it\\(|^\\s*describe\\(",
44+
"ignoreUrls": true
45+
}
46+
],
47+
"space-before-function-paren": [
48+
"error",
49+
{
4650
"anonymous": "always",
4751
"named": "never",
4852
"asyncArrow": "always"
49-
}]
53+
}
54+
]
5055
}
5156
},
5257
{
5358
"files": "src/**/*.ts",
5459
"parser": "@typescript-eslint/parser",
55-
"extends": [
56-
"plugin:@typescript-eslint/recommended"
57-
],
60+
"extends": ["plugin:@typescript-eslint/recommended"],
5861
"rules": {
5962
"@typescript-eslint/no-non-null-assertion": "off",
6063
"@typescript-eslint/no-use-before-define": "off",
6164
"@typescript-eslint/explicit-function-return-type": "off",
6265
"@typescript-eslint/explicit-module-boundary-types": "off",
6366
"@typescript-eslint/ban-ts-comment": "off",
6467
"@typescript-eslint/camelcase": "off",
65-
"comma-dangle": ["error", {
66-
"arrays": "always-multiline",
67-
"objects": "always-multiline",
68-
"imports": "always-multiline",
69-
"exports": "always-multiline",
70-
"functions": "never"
71-
}],
68+
"comma-dangle": [
69+
"error",
70+
{
71+
"arrays": "always-multiline",
72+
"objects": "always-multiline",
73+
"imports": "always-multiline",
74+
"exports": "always-multiline",
75+
"functions": "never"
76+
}
77+
],
7278
"indent": ["error", 2],
7379
"node/no-missing-import": "off",
7480
"node/no-unsupported-features/es-syntax": "off",
7581
"node/no-missing-require": "off",
7682
"node/shebang": "off",
7783
"no-dupe-class-members": "off",
78-
"space-before-function-paren": ["error", {
84+
"space-before-function-paren": [
85+
"error",
86+
{
7987
"anonymous": "always",
8088
"named": "never",
8189
"asyncArrow": "always"
82-
}]
90+
}
91+
]
8392
},
8493
"parserOptions": {
8594
"ecmaVersion": 2018,

.husky/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
_

package-lock.json

Lines changed: 0 additions & 20 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,6 @@
164164
"body-parser": "^1.20.0",
165165
"chromedriver": "^107.0.3",
166166
"eslint": "^8.24.0",
167-
"eslint-config-google": "^0.14.0",
168167
"express": "^4.18.1",
169168
"fs-extra": "^10.1.0",
170169
"husky": "^8.0.1",

src/lib/onHidden.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,10 @@ export interface OnHiddenCallback {
1818
(event: Event): void;
1919
}
2020

21-
export const onHidden = (cb: OnHiddenCallback, once?: boolean) => {
21+
export const onHidden = (cb: OnHiddenCallback) => {
2222
const onHiddenOrPageHide = (event: Event) => {
2323
if (event.type === 'pagehide' || document.visibilityState === 'hidden') {
2424
cb(event);
25-
if (once) {
26-
removeEventListener('visibilitychange', onHiddenOrPageHide, true);
27-
removeEventListener('pagehide', onHiddenOrPageHide, true);
28-
}
2925
}
3026
};
3127
addEventListener('visibilitychange', onHiddenOrPageHide, true);

src/lib/runOnce.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* Copyright 2022 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+
export interface RunOnceCallback {
18+
(arg: unknown): void;
19+
}
20+
21+
export const runOnce = (cb: RunOnceCallback) => {
22+
let called = false;
23+
return (arg: unknown) => {
24+
if (!called) {
25+
cb(arg);
26+
called = true;
27+
}
28+
};
29+
};

src/onCLS.ts

Lines changed: 71 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@
1717
import {onBFCacheRestore} from './lib/bfcache.js';
1818
import {initMetric} from './lib/initMetric.js';
1919
import {observe} from './lib/observe.js';
20-
import {onHidden} from './lib/onHidden.js';
2120
import {bindReporter} from './lib/bindReporter.js';
2221
import {doubleRAF} from './lib/doubleRAF.js';
23-
import {whenActivated} from './lib/whenActivated.js';
22+
import {onHidden} from './lib/onHidden.js';
23+
import {runOnce} from './lib/runOnce.js';
2424
import {onFCP} from './onFCP.js';
2525
import {CLSMetric, CLSReportCallback, ReportOpts} from './types.js';
2626

@@ -49,99 +49,88 @@ export const onCLS = (onReport: CLSReportCallback, opts?: ReportOpts) => {
4949
// Set defaults
5050
opts = opts || {};
5151

52-
whenActivated(() => {
53-
// https://web.dev/cls/#what-is-a-good-cls-score
54-
const thresholds = [0.1, 0.25];
52+
// Start monitoring FCP so we can only report CLS if FCP is also reported.
53+
// Note: this is done to match the current behavior of CrUX.
54+
onFCP(
55+
runOnce(() => {
56+
// https://web.dev/cls/#what-is-a-good-cls-score
57+
const thresholds = [0.1, 0.25];
5558

56-
let metric = initMetric('CLS');
57-
let report: ReturnType<typeof bindReporter>;
59+
let metric = initMetric('CLS', 0);
60+
let report: ReturnType<typeof bindReporter>;
5861

59-
let fcpValue = -1;
60-
let sessionValue = 0;
61-
let sessionEntries: PerformanceEntry[] = [];
62-
63-
const onReportWrapped: CLSReportCallback = (arg) => {
64-
if (fcpValue > -1) {
65-
onReport(arg);
66-
}
67-
};
62+
let sessionValue = 0;
63+
let sessionEntries: PerformanceEntry[] = [];
6864

69-
// const handleEntries = (entries: Metric['entries']) => {
70-
const handleEntries = (entries: LayoutShift[]) => {
71-
entries.forEach((entry) => {
72-
// Only count layout shifts without recent user input.
73-
if (!entry.hadRecentInput) {
74-
const firstSessionEntry = sessionEntries[0];
75-
const lastSessionEntry = sessionEntries[sessionEntries.length - 1];
65+
// const handleEntries = (entries: Metric['entries']) => {
66+
const handleEntries = (entries: LayoutShift[]) => {
67+
entries.forEach((entry) => {
68+
// Only count layout shifts without recent user input.
69+
if (!entry.hadRecentInput) {
70+
const firstSessionEntry = sessionEntries[0];
71+
const lastSessionEntry = sessionEntries[sessionEntries.length - 1];
7672

77-
// If the entry occurred less than 1 second after the previous entry
78-
// and less than 5 seconds after the first entry in the session,
79-
// include the entry in the current session. Otherwise, start a new
80-
// session.
81-
if (
82-
sessionValue &&
83-
entry.startTime - lastSessionEntry.startTime < 1000 &&
84-
entry.startTime - firstSessionEntry.startTime < 5000
85-
) {
86-
sessionValue += entry.value;
87-
sessionEntries.push(entry);
88-
} else {
89-
sessionValue = entry.value;
90-
sessionEntries = [entry];
73+
// If the entry occurred less than 1 second after the previous entry
74+
// and less than 5 seconds after the first entry in the session,
75+
// include the entry in the current session. Otherwise, start a new
76+
// session.
77+
if (
78+
sessionValue &&
79+
entry.startTime - lastSessionEntry.startTime < 1000 &&
80+
entry.startTime - firstSessionEntry.startTime < 5000
81+
) {
82+
sessionValue += entry.value;
83+
sessionEntries.push(entry);
84+
} else {
85+
sessionValue = entry.value;
86+
sessionEntries = [entry];
87+
}
9188
}
89+
});
9290

93-
// If the current session value is larger than the current CLS value,
94-
// update CLS and the entries contributing to it.
95-
if (sessionValue > metric.value) {
96-
metric.value = sessionValue;
97-
metric.entries = sessionEntries;
98-
report();
99-
}
100-
}
101-
});
102-
};
103-
104-
const po = observe('layout-shift', handleEntries);
105-
if (po) {
106-
report = bindReporter(
107-
onReportWrapped,
108-
metric,
109-
thresholds,
110-
opts!.reportAllChanges
111-
);
112-
113-
// Start monitoring FCP so we can only report CLS if FCP is also reported.
114-
// Note: this is done to match the current behavior of CrUX.
115-
// Also, if there have not been any layout shifts when FCP is dispatched,
116-
// call "report" with a zero value
117-
onFCP((fcpMetric) => {
118-
fcpValue = fcpMetric.value;
119-
if (metric.value < 0) {
120-
metric.value = 0;
91+
// If the current session value is larger than the current CLS value,
92+
// update CLS and the entries contributing to it.
93+
if (sessionValue > metric.value) {
94+
metric.value = sessionValue;
95+
metric.entries = sessionEntries;
12196
report();
12297
}
123-
});
124-
125-
onHidden(() => {
126-
handleEntries(po.takeRecords() as CLSMetric['entries']);
127-
report(true);
128-
});
98+
};
12999

130-
// Only report after a bfcache restore if the `PerformanceObserver`
131-
// successfully registered.
132-
onBFCacheRestore(() => {
133-
sessionValue = 0;
134-
fcpValue = -1;
135-
metric = initMetric('CLS', 0);
100+
const po = observe('layout-shift', handleEntries);
101+
if (po) {
136102
report = bindReporter(
137-
onReportWrapped,
103+
onReport,
138104
metric,
139105
thresholds,
140106
opts!.reportAllChanges
141107
);
142108

143-
doubleRAF(() => report());
144-
});
145-
}
146-
});
109+
onHidden(() => {
110+
handleEntries(po.takeRecords() as CLSMetric['entries']);
111+
report(true);
112+
});
113+
114+
// Only report after a bfcache restore if the `PerformanceObserver`
115+
// successfully registered.
116+
onBFCacheRestore(() => {
117+
sessionValue = 0;
118+
metric = initMetric('CLS', 0);
119+
report = bindReporter(
120+
onReport,
121+
metric,
122+
thresholds,
123+
opts!.reportAllChanges
124+
);
125+
126+
doubleRAF(() => report());
127+
});
128+
129+
// Queue a task to report (if nothing else triggers a report first).
130+
// This allows CLS to be reported as soon as FCP fires when
131+
// `reportAllChanges` is true.
132+
setTimeout(report, 0);
133+
}
134+
})
135+
);
147136
};

0 commit comments

Comments
 (0)