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)

OP OpenVibers committed 24 days ago · a6f9f398f8 · 2 files, +60 −3

Changes

ddiff --git a/public/js/ov-rum.js b/public/js/ov-rum.js
iindex 900054d1..e185aabc 100644
---- a/public/js/ov-rum.js
++++ b/public/js/ov-rum.js
@@@ -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;
ddiff --git a/test/rum.test.js b/test/rum.test.js
iindex 7bcd630b..d108fd8b 100644
---- a/test/rum.test.js
++++ b/test/rum.test.js
@@@ -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); });