Skip to content

Commit 8e48814

Browse files
committed
ensure vitals are consistent and have sane fallbacks and baselines
1 parent d2fe240 commit 8e48814

2 files changed

Lines changed: 76 additions & 48 deletions

File tree

src/common/v2/mfe-vitals.js

Lines changed: 50 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ const isObservable = (node) => {
7272
* @returns {boolean}
7373
*/
7474
const isInMFE = (node, id) => {
75+
if (!node || !id) return false
7576
try {
7677
let curr = node.nodeType === 1 ? node : node.parentElement
7778
while (curr?.tagName) {
@@ -85,16 +86,18 @@ const isInMFE = (node, id) => {
8586
/**
8687
* Create mutation observer for MFE nodes
8788
* @param {string} id - MFE ID to track
88-
* @param {Function} onMatch - Callback when matching node is added
89+
* @param {Function} onMatch - Callback when matching node is *added*
8990
* @returns {MutationObserver}
9091
*/
9192
const observeMutations = (id, onMatch) => {
9293
const obs = new globalScope.MutationObserver(mutations => {
93-
mutations.forEach(m => m.addedNodes.forEach(node => {
94-
if (isObservable(node) && isInMFE(node, id)) {
95-
onMatch(node, obs)
96-
}
97-
}))
94+
mutations.forEach(m => {
95+
m.addedNodes.forEach(node => {
96+
if (isObservable(node) && isInMFE(node, id)) {
97+
onMatch(node, obs)
98+
}
99+
})
100+
})
98101
})
99102
obs.observe(globalScope.document, { childList: true, subtree: true })
100103
return obs
@@ -126,47 +129,63 @@ const observePerformance = (observers, config, onEntry) => {
126129
* @returns {{fcp: object|null, lcp: object|null, cls: object|null, inp: object|null, disconnect: Function}}
127130
*/
128131
export function trackMFEVitals (id, timings) {
132+
let fcpObservedAt = null
133+
let lcpObservedAt = null
134+
135+
const getTimeRelativeToScriptStart = (capturedAt) => {
136+
if (capturedAt === null) return null
137+
return capturedAt - (timings?.scriptStart || timings?.registeredAt || 0)
138+
}
139+
129140
const vitals = {
130-
fcp: null,
131-
lcp: null,
132-
cls: null,
133-
inp: null,
141+
fcp: {
142+
get value () { return getTimeRelativeToScriptStart(fcpObservedAt) }
143+
},
144+
lcp: {
145+
get value () { return getTimeRelativeToScriptStart(lcpObservedAt) }
146+
},
147+
cls: {
148+
value: null
149+
},
150+
inp: {
151+
value: null
152+
},
134153
disconnect: () => {}
135154
}
136155

137156
if (!id || !isBrowserScope || !globalScope.MutationObserver || !globalScope.PerformanceObserver) return vitals
138157

139158
const observers = []
140159

141-
const getNowRelativeToScriptStart = (capturedAt) => {
142-
return capturedAt - (timings?.scriptStart || timings?.registeredAt || 0)
160+
const populateVitalMinimums = () => {
161+
fcpObservedAt ??= now()
162+
lcpObservedAt ??= now()
163+
vitals.cls.value ??= 0
143164
}
144165

166+
// if the MFE has already rendered something on the page before we could set up listeners, just populate vital minimums immediately
167+
if (globalScope.document?.querySelector(`[data-nr-mfe-id="${id}"]`)) populateVitalMinimums()
168+
145169
// Track FCP - first contentful paint
146-
observeMutations(id, (node, obs) => {
147-
if (vitals.fcp === null) {
148-
const capturedAt = now()
149-
vitals.fcp = {
150-
get value () { return getNowRelativeToScriptStart(capturedAt) }
151-
}
152-
obs.disconnect()
153-
}
170+
observeMutations(id, (_, obs) => {
171+
// An observed "FCP" means _something_ rendered, so at minimum we can populate all the baseline values for the vitals
172+
populateVitalMinimums()
173+
obs.disconnect()
154174
})
155175

156176
// Track LCP - largest contentful paint
157177
let largestSize = 0
158178
const lcpObs = observeMutations(id, (node) => {
179+
// an observed "LCP" means _something_ rendered, so at minimum we can make sure all the baseline values are populated for the vitals
180+
populateVitalMinimums()
159181
try {
160182
const elem = node.nodeType === 1 ? node : node.parentElement
161183
if (!elem) return
162184
const rect = elem.getBoundingClientRect()
163185
const size = rect.width * rect.height
164186
if (size > largestSize) {
165187
largestSize = size
166-
const capturedAt = now()
167-
vitals.lcp = {
168-
get value () { return getNowRelativeToScriptStart(capturedAt) }
169-
}
188+
lcpObservedAt = now()
170189
}
171190
} catch (e) {
172191
// Element may be detached from DOM
@@ -177,10 +196,11 @@ export function trackMFEVitals (id, timings) {
177196
// Track CLS - cumulative layout shift
178197
// Initialize CLS to 0 if browser supports it
179198
observePerformance(observers, { type: 'layout-shift', buffered: true }, (entry) => {
180-
vitals.cls ??= { value: 0 }
181199
if (entry.hadRecentInput) return
182200
;(entry.sources || []).some(source => {
183201
if (isInMFE(source.node, id)) {
202+
// an observed "CLS" means _something_ rendered for the MFE, so at minimum we can make sure all the baseline values are populated for the vitals
203+
populateVitalMinimums()
184204
vitals.cls.value += entry.value
185205
return true
186206
}
@@ -190,11 +210,11 @@ export function trackMFEVitals (id, timings) {
190210

191211
// Track INP - interaction to next paint
192212
observePerformance(observers, { type: 'event', buffered: true, durationThreshold: 16 }, (entry) => {
193-
if (!entry.interactionId || !entry.target || !isInMFE(entry.target, id)) return
194-
if (vitals.inp === null || entry.duration > vitals.inp.value) {
195-
vitals.inp = {
196-
value: entry.duration
197-
}
213+
if (!entry.interactionId || !isInMFE(entry.target, id)) return
214+
if (vitals.inp.value === null || entry.duration > vitals.inp.value) {
215+
// an observed "INP" means _something_ rendered for the MFE, so at minimum we can make sure all the baseline values are populated for the vitals
216+
populateVitalMinimums()
217+
vitals.inp.value = entry.duration
198218
}
199219
})
200220

tests/unit/common/v2/mfe-vitals.test.js

Lines changed: 26 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ describe('trackMFEVitals', () => {
1212
let mutationCallbacks
1313
let performanceCallbacks
1414
let timings
15+
const clockBaseline = 11
1516

1617
beforeEach(async () => {
1718
jest.resetModules()
@@ -62,7 +63,9 @@ describe('trackMFEVitals', () => {
6263
isBrowserScope: true
6364
}))
6465

65-
// Mock now() to return incrementing values
66+
// Mock now() to return incrementing values.
67+
// The first relative LCP tick lands on `clockBaseline + 1` because the collector
68+
// consumes one clock tick when it observes the node and another when it wins the size check.
6669
let timeCounter = 100
6770
jest.doMock('../../../../src/common/timing/now', () => ({
6871
now: jest.fn(() => timeCounter++)
@@ -81,7 +84,8 @@ describe('trackMFEVitals', () => {
8184
describe('FCP tracking', () => {
8285
it('should track FCP when contentful element is added to MFE', () => {
8386
const vitals = trackMFEVitals('test-mfe', timings)
84-
expect(vitals.fcp).toBeNull()
87+
// The collector seeds FCP as soon as it sees the MFE marker on the page.
88+
expect(vitals.fcp.value).toBe(clockBaseline - 1)
8589

8690
// Create a mock element with text content inside MFE container
8791
const mockContainer = {
@@ -107,7 +111,7 @@ describe('trackMFEVitals', () => {
107111
}]))
108112

109113
expect(vitals.fcp).toBeDefined()
110-
expect(vitals.fcp.value).toBe(10)
114+
expect(vitals.fcp.value).toBe(clockBaseline - 1)
111115
})
112116

113117
it('should use scriptStart as the timestamp anchor for FCP', () => {
@@ -132,7 +136,7 @@ describe('trackMFEVitals', () => {
132136

133137
mutationCallbacks.forEach(cb => cb([{ addedNodes: [mockElement] }]))
134138

135-
expect(vitals.fcp.value).toBe(10)
139+
expect(vitals.fcp.value).toBe(clockBaseline - 1)
136140
})
137141
})
138142

@@ -163,7 +167,7 @@ describe('trackMFEVitals', () => {
163167
mutationCallbacks.forEach(cb => cb([{ addedNodes: [mockElement] }]))
164168

165169
expect(vitals.lcp).toBeDefined()
166-
expect(vitals.lcp.value).toBe(11)
170+
expect(vitals.lcp.value).toBe(clockBaseline + 1)
167171
})
168172

169173
it('should update LCP when larger element is added', () => {
@@ -204,8 +208,8 @@ describe('trackMFEVitals', () => {
204208

205209
mutationCallbacks.forEach(cb => cb([{ addedNodes: [largeElement] }]))
206210

207-
expect(firstLcp).toBe(11)
208-
expect(vitals.lcp.value).toBe(12)
211+
expect(firstLcp).toBe(clockBaseline + 1)
212+
expect(vitals.lcp.value).toBe(clockBaseline + 2)
209213
})
210214

211215
it('should extract URL from element background image', () => {
@@ -237,7 +241,7 @@ describe('trackMFEVitals', () => {
237241

238242
mutationCallbacks.forEach(cb => cb([{ addedNodes: [mockElement] }]))
239243

240-
expect(vitals.lcp.value).toBe(11)
244+
expect(vitals.lcp.value).toBe(clockBaseline + 1)
241245
})
242246
})
243247

@@ -352,7 +356,7 @@ describe('trackMFEVitals', () => {
352356

353357
const vitals = trackMFEVitals('missing-mfe', timings)
354358

355-
expect(vitals.cls).toBeNull()
359+
expect(vitals.cls.value).toBeNull()
356360
})
357361
})
358362

@@ -491,12 +495,15 @@ describe('trackMFEVitals', () => {
491495
]
492496
})
493497

494-
expect(vitals.inp).toBeNull()
498+
expect(vitals.inp.value).toBeNull()
495499
})
496500
})
497501

498502
describe('Element scope validation', () => {
499503
it('should only track elements within the specified MFE', () => {
504+
const mockGlobalScope = require('../../../../src/common/constants/runtime').globalScope
505+
mockGlobalScope.document.querySelector = jest.fn(() => null)
506+
500507
const vitals = trackMFEVitals('mfe-a', timings)
501508

502509
const mfeBContainer = {
@@ -518,7 +525,7 @@ describe('trackMFEVitals', () => {
518525

519526
mutationCallbacks.forEach(cb => cb([{ addedNodes: [mfeBElement] }]))
520527

521-
expect(vitals.fcp).toBeNull()
528+
expect(vitals.fcp.value).toBeNull()
522529
})
523530

524531
it('should track nested elements within MFE', () => {
@@ -545,9 +552,9 @@ describe('trackMFEVitals', () => {
545552
mutationCallbacks.forEach(cb => cb([{ addedNodes: [childElement] }]))
546553

547554
expect(vitals.fcp).toBeDefined()
548-
expect(vitals.fcp.value).toBe(10)
555+
expect(vitals.fcp.value).toBe(clockBaseline - 1)
549556
expect(vitals.lcp).toBeDefined()
550-
expect(vitals.lcp.value).toBe(11)
557+
expect(vitals.lcp.value).toBe(clockBaseline + 1)
551558
})
552559
})
553560

@@ -593,10 +600,11 @@ describe('trackMFEVitals', () => {
593600
it('should return empty vitals when id is missing', () => {
594601
const vitals = trackMFEVitals('', timings)
595602

596-
expect(vitals.fcp).toBeNull()
597-
expect(vitals.lcp).toBeNull()
598-
expect(vitals.cls).toBeNull()
599-
expect(vitals.inp).toBeNull()
603+
// The collector still returns the vitals shell, but the value getters are never populated.
604+
expect(Object.getOwnPropertyDescriptor(vitals.fcp, 'value')?.get).toEqual(expect.any(Function))
605+
expect(Object.getOwnPropertyDescriptor(vitals.lcp, 'value')?.get).toEqual(expect.any(Function))
606+
expect(vitals.cls.value).toBeNull()
607+
expect(vitals.inp.value).toBeNull()
600608
})
601609

602610
it('should handle elements without id or className', () => {
@@ -622,7 +630,7 @@ describe('trackMFEVitals', () => {
622630

623631
mutationCallbacks.forEach(cb => cb([{ addedNodes: [mockElement] }]))
624632

625-
expect(vitals.lcp.value).toBe(11)
633+
expect(vitals.lcp.value).toBe(clockBaseline + 1)
626634
})
627635

628636
it('should handle elements with className as object', () => {

0 commit comments

Comments
 (0)