1/*! 2 * uni-vitals: real-user Core Web Vitals, with the element attribution that 3 * Search Console cannot give us. 4 * 5 * Added 2026-09-30. Search Console reported CLS 0.90 across 1,365 6 * /journals/:slug URLs, and that number could not be reproduced on the SSR 7 * path or the SPA path, at full speed or throttled to 400kbps with a 4x CPU 8 * slowdown. CrUX tells you a page shifts; it never tells you what moved. 9 * This does. 10 * 11 * No dependency on purpose: the web-vitals package would have to be bundled 12 * into twenty Angular apps, and this file is served straight from /static so 13 * it can change without rebuilding any of them. 14 * 15 * Deliberately cheap: three PerformanceObservers, one sendBeacon when the 16 * page is hidden, nothing on the critical path, and it fails silently. 17 */ 18(function () { 19 'use strict'; 20 try { 21 if (!window.PerformanceObserver || !navigator.sendBeacon) return; 22 // Never measure prerendered or restored-from-bfcache loads: their timings 23 // are not what a visitor experienced. 24 if (document.prerendering) return; 25 26 var ENDPOINT = '/api/v2/vitals/'; 27 var sent = false; 28 var cls = { value: 0, element: '' }; 29 var lcp = { value: 0, element: '' }; 30 var inp = { value: 0, element: '' }; 31 32 function describe(node) { 33 if (!node || node.nodeType !== 1) return ''; 34 var tag = (node.tagName || '').toLowerCase(); 35 var id = node.id ? '#' + node.id : ''; 36 var cn = ''; 37 if (node.className && typeof node.className === 'string') { 38 cn = '.' + node.className.trim().split(/\s+/).slice(0, 3).join('.'); 39 } 40 return (tag + id + cn).slice(0, 120); 41 } 42 43 function observe(type, cb, opts) { 44 try { 45 var o = new PerformanceObserver(function (l) { l.getEntries().forEach(cb); }); 46 o.observe(Object.assign({ type: type, buffered: true }, opts || {})); 47 return o; 48 } catch (e) { return null; } 49 } 50 51 // CLS is the largest SESSION WINDOW, not the running total: a window ends 52 // after a 1s gap or 5s of wall clock. Summing every shift over the life of 53 // a long-lived SPA page overstates it, and the number then cannot be 54 // compared against Search Console, which is the entire point of this file. 55 var winValue = 0, winFirst = 0, winLast = 0, winWorst = 0, winElement = ''; 56 observe('layout-shift', function (e) { 57 if (e.hadRecentInput) return; 58 if (winValue && e.startTime - winLast < 1000 && e.startTime - winFirst < 5000) { 59 winValue += e.value; 60 winLast = e.startTime; 61 } else { 62 winValue = e.value; 63 winFirst = winLast = e.startTime; 64 winWorst = 0; 65 winElement = ''; 66 } 67 if (e.value > winWorst) { 68 winWorst = e.value; 69 var src = (e.sources || [])[0]; 70 if (src && src.node) winElement = describe(src.node); 71 } 72 if (winValue > cls.value) { 73 cls.value = winValue; 74 cls.element = winElement; 75 } 76 }); 77 78 // LCP stops at the first interaction or when the page is first hidden. 79 // Without this a tab left open in the background reports its LCP as 80 // however long it sat there: the first samples this file collected 81 // included a 53,304ms "LCP", which is a backgrounded tab, not a page load. 82 var lcpFrozen = false; 83 observe('largest-contentful-paint', function (e) { 84 if (lcpFrozen) return; 85 lcp.value = e.startTime; 86 lcp.element = describe(e.element) || (e.url ? ('url:' + e.url.slice(-60)) : ''); 87 }); 88 function freezeLcp() { lcpFrozen = true; } 89 ['keydown', 'click', 'pointerdown'].forEach(function (t) { 90 addEventListener(t, freezeLcp, { once: true, capture: true }); 91 }); 92 93 // INP approximated by the worst qualifying interaction, which is what the 94 // p75 of this metric is driven by in practice. 95 observe('event', function (e) { 96 if (!e.interactionId || e.duration <= inp.value) return; 97 inp.value = e.duration; 98 inp.element = describe(e.target); 99 }, { durationThreshold: 40 }); 100 101 function send() { 102 if (sent) return; 103 sent = true; 104 try { 105 var v = []; 106 if (cls.value > 0) v.push({ n: 'CLS', v: +cls.value.toFixed(4), e: cls.element }); 107 if (lcp.value > 0) v.push({ n: 'LCP', v: Math.round(lcp.value), e: lcp.element }); 108 if (inp.value > 0) v.push({ n: 'INP', v: Math.round(inp.value), e: inp.element }); 109 if (!v.length) return; 110 var conn = navigator.connection && navigator.connection.effectiveType; 111 var body = JSON.stringify({ 112 p: location.pathname, 113 m: window.matchMedia && window.matchMedia('(max-width: 768px)').matches, 114 c: conn || '', 115 v: v 116 }); 117 navigator.sendBeacon(ENDPOINT, new Blob([body], { type: 'application/json' })); 118 } catch (e) { /* a beacon must never break the page */ } 119 } 120 121 // visibilitychange, not unload: unload does not fire reliably on mobile 122 // and is what makes naive RUM under-report phones, the devices that 123 // actually fail these metrics. 124 addEventListener('visibilitychange', function () { 125 if (document.visibilityState === 'hidden') { freezeLcp(); send(); } 126 }, true); 127 addEventListener('pagehide', send, true); 128 } catch (e) { /* never break a page over measurement */ } 129})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.