fix: report page load timing after load handlers complete

This commit is contained in:
whyour
2026-10-17 09:03:50 +08:00
parent 1a152575cc
commit a1104ef47c
3 changed files with 257 additions and 23 deletions
+187
View File
@@ -0,0 +1,187 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ts = require('typescript');
function fixture({ readyState = 'loading', navigation, legacy } = {}) {
const callbacks = new Map();
const listeners = new Set();
const reports = [];
let nextId = 0;
const previousOnload = () => {};
const window = {
onload: previousOnload,
performance: {
...(navigation === undefined
? {}
: { getEntriesByType: () => [navigation] }),
timing: legacy,
},
setTimeout(fn) {
callbacks.set(++nextId, fn);
return nextId;
},
clearTimeout(id) {
callbacks.delete(id);
},
addEventListener(type, fn, options) {
assert.equal(type, 'load');
assert.equal(options.once, true);
listeners.add(fn);
},
removeEventListener(type, fn) {
assert.equal(type, 'load');
listeners.delete(fn);
},
};
const compiled = ts.transpileModule(
fs.readFileSync(
path.join(__dirname, '../../src/utils/navigationTiming.ts'),
'utf8',
),
{
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
},
},
).outputText;
const module = { exports: {} };
new Function('module', 'exports', 'window', 'document', compiled)(
module,
module.exports,
window,
{ readyState },
);
const observe = () =>
module.exports.observePageLoadTiming((v) => reports.push(v));
return {
reports,
window,
previousOnload,
observe,
load() {
for (const fn of [...listeners]) {
listeners.delete(fn);
fn();
}
},
flush() {
const pending = [...callbacks.values()];
callbacks.clear();
for (const fn of pending) fn();
},
};
}
const timing = () => ({
startTime: 0,
responseStart: 10.4,
responseEnd: 14,
domInteractive: 30,
domComplete: 50,
loadEventEnd: 70.6,
});
test('loadEventEnd is read after load handlers complete, with real positive elapsed time', () => {
const navigation = { ...timing(), loadEventEnd: 0 };
const f = fixture({ navigation });
f.observe();
f.load();
assert.equal(f.reports.length, 0);
navigation.loadEventEnd = 70.6;
f.flush();
assert.deepEqual(f.reports, [
{ responseTime: 10, domReadyTime: 16, domParseTime: 20, loadTime: 71 },
]);
f.load();
f.flush();
assert.equal(f.reports.length, 1);
});
test('late mounts report an already loaded page without replacing another onload handler', () => {
const f = fixture({ readyState: 'complete', navigation: timing() });
const dispose = f.observe();
assert.equal(f.reports.length, 0);
f.flush();
assert.equal(f.reports[0].loadTime, 71);
dispose();
assert.equal(f.window.onload, f.previousOnload);
});
test('legacy epoch timings retain their own navigationStart baseline', () => {
const f = fixture({
readyState: 'complete',
legacy: {
navigationStart: 1700000000000,
responseStart: 1700000000010,
responseEnd: 1700000000014,
domInteractive: 1700000000030,
domComplete: 1700000000050,
loadEventEnd: 1700000000071,
},
});
f.observe();
f.flush();
assert.deepEqual(f.reports[0], {
responseTime: 10,
domReadyTime: 16,
domParseTime: 20,
loadTime: 71,
});
});
test('navigation entries take precedence without mixing relative and epoch clocks', () => {
const f = fixture({
readyState: 'complete',
navigation: timing(),
legacy: { navigationStart: 1700000000000, loadEventEnd: 0 },
});
f.observe();
f.flush();
assert.equal(f.reports[0].loadTime, 71);
});
test('incomplete, negative and nonfinite timings are omitted rather than displayed as fabricated zeroes', () => {
const f = fixture({
readyState: 'complete',
navigation: {
...timing(),
responseStart: NaN,
domInteractive: 10,
domComplete: Infinity,
loadEventEnd: 0,
},
});
f.observe();
f.flush();
assert.ok(Object.values(f.reports[0]).every((v) => v === undefined));
});
test('unmount removes a pending load listener and cancels a queued report', () => {
for (const loaded of [false, true]) {
const f = fixture({ navigation: timing() });
const dispose = f.observe();
if (loaded) f.load();
dispose();
f.load();
f.flush();
assert.equal(f.reports.length, 0);
}
});
test('strict-effect remounts leave exactly one active reporter', () => {
const f = fixture({ readyState: 'complete', navigation: timing() });
f.observe()();
f.observe();
f.flush();
assert.equal(f.reports.length, 1);
});
test('missing performance timings produce no report', () => {
const f = fixture({ readyState: 'complete' });
f.observe();
f.flush();
assert.equal(f.reports.length, 0);
});