openvibers/openvibe.live Commits
RUM: a background tab's paints are not load times (FCP/LCP count only before the page was first hidden: channel pages reported 28 s), and the ResizeObserver loop notice is not an error (#278)
Changes
@@@ -13,8 +13,22 @@const watch = (type, fn, extra) => {try { new PerformanceObserver((list) => list.getEntries().forEach(fn)).observe(Object.assign({ type, buffered: true }, extra)); } catch (e) { /* not in this browser */ }};-- watch('paint', (e) => { if (e.name === 'first-contentful-paint') m.fcp = e.startTime; });-- watch('largest-contentful-paint', (e) => { m.lcp = e.startTime; });++ // A page opened in a background tab paints only when it is shown, so its FCP and LCP say when the visitor looked,++ // not how fast it loaded (channel and moments pages reported 28-29 s on 2026-10-20). As web-vitals does, paints++ // count only before the page was first hidden: the browser's visibility-state history (buffered, so it is there++ // though this script loads late), else the state now. A prerendered page is timed from its activation.++ let firstHidden = Infinity;++ try {++ const vis = performance.getEntriesByType('visibility-state');++ if (vis && vis.length) { const h = vis.find((e) => e.name === 'hidden'); if (h) firstHidden = h.startTime; }++ else if (document.visibilityState === 'hidden') firstHidden = 0;++ } catch (e) { if (document.visibilityState === 'hidden') firstHidden = 0; }++ addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden' && firstHidden === Infinity) firstHidden = performance.now(); }, true);++ const nav0 = performance.getEntriesByType && performance.getEntriesByType('navigation')[0];++ const activation = (nav0 && nav0.activationStart) || 0;++ const shown = (t) => (t < firstHidden ? Math.max(0, t - activation) : 0); // 0 = not measured (dropped server-side)++ watch('paint', (e) => { if (e.name === 'first-contentful-paint') m.fcp = shown(e.startTime); });++ watch('largest-contentful-paint', (e) => { const t = shown(e.startTime); if (t) m.lcp = t; });// CLS as web-vitals defines it: the worst window of shifts under 1 s apart, at most 5 s long.let win = 0, first = 0, last = 0;watch('layout-shift', (e) => {@@@ -25,7 +39,9 @@});watch('longtask', (e) => { if (m.fcp && e.startTime > m.fcp) m.tbt += Math.max(0, e.duration - 50); });watch('event', (e) => { if (e.interactionId && e.duration > m.inp) m.inp = e.duration; }, { durationThreshold: 40 });-- const err = (msg) => { if (m.errs.length < 3) m.errs.push(String(msg).slice(0, 140)); };++ // "ResizeObserver loop completed with undelivered notifications" is the browser deferring a resize callback to the++ // next frame, not a failure (and the only "error" in the 10-20 snapshot): it is not counted.++ const err = (msg) => { if (m.errs.length < 3 && !/^ResizeObserver loop/.test(String(msg))) m.errs.push(String(msg).slice(0, 140)); };addEventListener('error', (e) => err(e.message || 'error'));addEventListener('unhandledrejection', (e) => err('unhandled rejection: ' + ((e.reason && e.reason.message) || e.reason)));let sent = false;@@@ -114,5 +114,46 @@ const { createRum, pageKind, deviceClass } = require('../server/web/rum');assert.ok(/navigator\.webdriver \|\| \/bot\|crawl\|spider[^/]*headless/.test(client), 'automated browsers (crawlers, headless runs) send nothing');assert.ok(!/userAgent[^)]*sendBeacon|JSON\.stringify\(\{[^}]*userAgent/.test(client), 'the user agent is checked, never sent');++ // ov-rum.js in a sandbox: what it sends for a page shown at once, one opened in a background tab, and its errors.++ const runClient = ({ visibility, paints, lcp, errors = [] }) => {++ const sent = [];++ const handlers = {};++ const observers = {};++ const vm = require('vm');++ const ctx = {++ navigator: { sendBeacon: (url, body) => { sent.push(JSON.parse(body)); return true; }, webdriver: false, userAgent: 'Mozilla/5.0 (Linux; Android 14) Chrome/140 Mobile' },++ document: { visibilityState: 'visible' },++ location: { pathname: '/@goosely' }, innerWidth: 390, window: {},++ performance: {++ now: () => 30000,++ getEntriesByType: (t) => (t === 'visibility-state' ? visibility : t === 'navigation' ? [{ name: 'https://openvibe.live/@goosely', responseStart: 120, activationStart: 0 }] : []),++ },++ PerformanceObserver: class { constructor(cb) { this.cb = cb; } observe(o) { observers[o.type] = this.cb; } },++ addEventListener: (type, fn) => { handlers[type] = fn; },++ URL, JSON, Math, String, Object, Infinity,++ };++ ctx.window = ctx;++ vm.createContext(ctx);++ vm.runInContext(client, ctx);++ const feed = (type, entries) => observers[type] && observers[type]({ getEntries: () => entries });++ feed('paint', paints);++ feed('largest-contentful-paint', lcp);++ for (const message of errors) handlers.error({ message });++ ctx.document.visibilityState = 'hidden';++ handlers.visibilitychange();++ return sent[0];++ };++ const shownAtOnce = runClient({ visibility: [{ name: 'visible', startTime: 0 }], paints: [{ name: 'first-contentful-paint', startTime: 900 }], lcp: [{ startTime: 1400 }] });++ assert.deepStrictEqual([shownAtOnce.fcp, shownAtOnce.lcp], [900, 1400], 'a page shown at once keeps its paints');++ // Opened in a background tab (hidden from the start), shown 28 s later: its paints say when the visitor looked.++ const background = runClient({ visibility: [{ name: 'hidden', startTime: 0 }, { name: 'visible', startTime: 28000 }], paints: [{ name: 'first-contentful-paint', startTime: 28488 }], lcp: [{ startTime: 28712 }] });++ assert.deepStrictEqual([background.fcp, background.lcp], [0, 0], 'a background tab sends no FCP or LCP (the 28-29 s samples of 2026-10-20)');++ assert.strictEqual(background.ttfb, 120, 'its other timings still count');++ // Hidden after its first paint but before the largest one: the first counts, the late one does not.++ const leftEarly = runClient({ visibility: [{ name: 'visible', startTime: 0 }, { name: 'hidden', startTime: 2000 }], paints: [{ name: 'first-contentful-paint', startTime: 800 }], lcp: [{ startTime: 1200 }, { startTime: 9000 }] });++ assert.deepStrictEqual([leftEarly.fcp, leftEarly.lcp], [800, 1200]);++ const errs = runClient({ visibility: [], paints: [], lcp: [], errors: ['ResizeObserver loop completed with undelivered notifications.', 'TypeError: x is undefined'] }).errs;++ assert.deepStrictEqual(errs, ['TypeError: x is undefined'], 'the ResizeObserver notice is not an error');++console.log('rum: all checks passed');})().catch((e) => { console.error(e); process.exit(1); });
@@ -13,8 +13,22 @@
@@ -13,8 +13,22 @@
const watch = (type, fn, extra) => {
const watch = (type, fn, extra) => {
try { new PerformanceObserver((list) => list.getEntries().forEach(fn)).observe(Object.assign({ type, buffered: true }, extra)); } catch (e) { /* not in this browser */ }
try { new PerformanceObserver((list) => list.getEntries().forEach(fn)).observe(Object.assign({ type, buffered: true }, extra)); } catch (e) { /* not in this browser */ }
};
};
- watch('paint', (e) => { if (e.name === 'first-contentful-paint') m.fcp = e.startTime; });
- watch('largest-contentful-paint', (e) => { m.lcp = e.startTime; });
+ // A page opened in a background tab paints only when it is shown, so its FCP and LCP say when the visitor looked,
+ // not how fast it loaded (channel and moments pages reported 28-29 s on 2026-10-20). As web-vitals does, paints
+ // count only before the page was first hidden: the browser's visibility-state history (buffered, so it is there
+ // though this script loads late), else the state now. A prerendered page is timed from its activation.
+ let firstHidden = Infinity;
+ try {
+ const vis = performance.getEntriesByType('visibility-state');
+ if (vis && vis.length) { const h = vis.find((e) => e.name === 'hidden'); if (h) firstHidden = h.startTime; }
+ else if (document.visibilityState === 'hidden') firstHidden = 0;
+ } catch (e) { if (document.visibilityState === 'hidden') firstHidden = 0; }
+ addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden' && firstHidden === Infinity) firstHidden = performance.now(); }, true);
+ const nav0 = performance.getEntriesByType && performance.getEntriesByType('navigation')[0];
+ const activation = (nav0 && nav0.activationStart) || 0;
+ const shown = (t) => (t < firstHidden ? Math.max(0, t - activation) : 0); // 0 = not measured (dropped server-side)
+ watch('paint', (e) => { if (e.name === 'first-contentful-paint') m.fcp = shown(e.startTime); });
+ watch('largest-contentful-paint', (e) => { const t = shown(e.startTime); if (t) m.lcp = t; });
// CLS as web-vitals defines it: the worst window of shifts under 1 s apart, at most 5 s long.
// CLS as web-vitals defines it: the worst window of shifts under 1 s apart, at most 5 s long.
let win = 0, first = 0, last = 0;
let win = 0, first = 0, last = 0;
watch('layout-shift', (e) => {
watch('layout-shift', (e) => {
@@ -25,7 +39,9 @@
@@ -25,7 +39,9 @@
});
});
watch('longtask', (e) => { if (m.fcp && e.startTime > m.fcp) m.tbt += Math.max(0, e.duration - 50); });
watch('longtask', (e) => { if (m.fcp && e.startTime > m.fcp) m.tbt += Math.max(0, e.duration - 50); });
watch('event', (e) => { if (e.interactionId && e.duration > m.inp) m.inp = e.duration; }, { durationThreshold: 40 });
watch('event', (e) => { if (e.interactionId && e.duration > m.inp) m.inp = e.duration; }, { durationThreshold: 40 });
- const err = (msg) => { if (m.errs.length < 3) m.errs.push(String(msg).slice(0, 140)); };
+ // "ResizeObserver loop completed with undelivered notifications" is the browser deferring a resize callback to the
+ // next frame, not a failure (and the only "error" in the 10-20 snapshot): it is not counted.
+ const err = (msg) => { if (m.errs.length < 3 && !/^ResizeObserver loop/.test(String(msg))) m.errs.push(String(msg).slice(0, 140)); };
addEventListener('error', (e) => err(e.message || 'error'));
addEventListener('error', (e) => err(e.message || 'error'));
addEventListener('unhandledrejection', (e) => err('unhandled rejection: ' + ((e.reason && e.reason.message) || e.reason)));
addEventListener('unhandledrejection', (e) => err('unhandled rejection: ' + ((e.reason && e.reason.message) || e.reason)));
let sent = false;
let sent = false;
@@ -114,5 +114,46 @@ const { createRum, pageKind, deviceClass } = require('../server/web/rum');
@@ -114,5 +114,46 @@ const { createRum, pageKind, deviceClass } = require('../server/web/rum');
assert.ok(/navigator\.webdriver \|\| \/bot\|crawl\|spider[^/]*headless/.test(client), 'automated browsers (crawlers, headless runs) send nothing');
assert.ok(/navigator\.webdriver \|\| \/bot\|crawl\|spider[^/]*headless/.test(client), 'automated browsers (crawlers, headless runs) send nothing');
assert.ok(!/userAgent[^)]*sendBeacon|JSON\.stringify\(\{[^}]*userAgent/.test(client), 'the user agent is checked, never sent');
assert.ok(!/userAgent[^)]*sendBeacon|JSON\.stringify\(\{[^}]*userAgent/.test(client), 'the user agent is checked, never sent');
+ // ov-rum.js in a sandbox: what it sends for a page shown at once, one opened in a background tab, and its errors.
+ const runClient = ({ visibility, paints, lcp, errors = [] }) => {
+ const sent = [];
+ const handlers = {};
+ const observers = {};
+ const vm = require('vm');
+ const ctx = {
+ navigator: { sendBeacon: (url, body) => { sent.push(JSON.parse(body)); return true; }, webdriver: false, userAgent: 'Mozilla/5.0 (Linux; Android 14) Chrome/140 Mobile' },
+ document: { visibilityState: 'visible' },
+ location: { pathname: '/@goosely' }, innerWidth: 390, window: {},
+ performance: {
+ now: () => 30000,
+ getEntriesByType: (t) => (t === 'visibility-state' ? visibility : t === 'navigation' ? [{ name: 'https://openvibe.live/@goosely', responseStart: 120, activationStart: 0 }] : []),
+ },
+ PerformanceObserver: class { constructor(cb) { this.cb = cb; } observe(o) { observers[o.type] = this.cb; } },
+ addEventListener: (type, fn) => { handlers[type] = fn; },
+ URL, JSON, Math, String, Object, Infinity,
+ };
+ ctx.window = ctx;
+ vm.createContext(ctx);
+ vm.runInContext(client, ctx);
+ const feed = (type, entries) => observers[type] && observers[type]({ getEntries: () => entries });
+ feed('paint', paints);
+ feed('largest-contentful-paint', lcp);
+ for (const message of errors) handlers.error({ message });
+ ctx.document.visibilityState = 'hidden';
+ handlers.visibilitychange();
+ return sent[0];
+ };
+ const shownAtOnce = runClient({ visibility: [{ name: 'visible', startTime: 0 }], paints: [{ name: 'first-contentful-paint', startTime: 900 }], lcp: [{ startTime: 1400 }] });
+ assert.deepStrictEqual([shownAtOnce.fcp, shownAtOnce.lcp], [900, 1400], 'a page shown at once keeps its paints');
+ // Opened in a background tab (hidden from the start), shown 28 s later: its paints say when the visitor looked.
+ const background = runClient({ visibility: [{ name: 'hidden', startTime: 0 }, { name: 'visible', startTime: 28000 }], paints: [{ name: 'first-contentful-paint', startTime: 28488 }], lcp: [{ startTime: 28712 }] });
+ assert.deepStrictEqual([background.fcp, background.lcp], [0, 0], 'a background tab sends no FCP or LCP (the 28-29 s samples of 2026-10-20)');
+ assert.strictEqual(background.ttfb, 120, 'its other timings still count');
+ // Hidden after its first paint but before the largest one: the first counts, the late one does not.
+ const leftEarly = runClient({ visibility: [{ name: 'visible', startTime: 0 }, { name: 'hidden', startTime: 2000 }], paints: [{ name: 'first-contentful-paint', startTime: 800 }], lcp: [{ startTime: 1200 }, { startTime: 9000 }] });
+ assert.deepStrictEqual([leftEarly.fcp, leftEarly.lcp], [800, 1200]);
+ const errs = runClient({ visibility: [], paints: [], lcp: [], errors: ['ResizeObserver loop completed with undelivered notifications.', 'TypeError: x is undefined'] }).errs;
+ assert.deepStrictEqual(errs, ['TypeError: x is undefined'], 'the ResizeObserver notice is not an error');
+
console.log('rum: all checks passed');
console.log('rum: all checks passed');
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });