Skip to content

Commit 0f32b99

Browse files
authored
feat: Remove FID (#1319)
1 parent 45cb413 commit 0f32b99

File tree

12 files changed

+105
-126
lines changed

12 files changed

+105
-126
lines changed

src/common/vitals/constants.js

+5-1
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@
55
export const VITAL_NAMES = {
66
FIRST_PAINT: 'fp',
77
FIRST_CONTENTFUL_PAINT: 'fcp',
8-
FIRST_INPUT_DELAY: 'fi',
8+
FIRST_INTERACTION: 'fi',
99
LARGEST_CONTENTFUL_PAINT: 'lcp',
1010
CUMULATIVE_LAYOUT_SHIFT: 'cls',
1111
INTERACTION_TO_NEXT_PAINT: 'inp',
1212
TIME_TO_FIRST_BYTE: 'ttfb'
1313
}
14+
15+
export const PERFORMANCE_ENTRY_TYPE = {
16+
FIRST_INPUT: 'first-input'
17+
}

src/common/vitals/first-input-delay.js

-28
This file was deleted.
+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Copyright 2020-2025 New Relic, Inc. All rights reserved.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
import { VitalMetric } from './vital-metric'
6+
import { VITAL_NAMES, PERFORMANCE_ENTRY_TYPE } from './constants'
7+
import { initiallyHidden, isBrowserScope } from '../constants/runtime'
8+
9+
// Note: First Interaction is a legacy NR timing event, not an actual CWV metric
10+
export const firstInteraction = new VitalMetric(VITAL_NAMES.FIRST_INTERACTION)
11+
12+
if (isBrowserScope) {
13+
try {
14+
let observer
15+
// preserve the original behavior where FID is not reported if the page is hidden before the first interaction
16+
if (PerformanceObserver.supportedEntryTypes.includes(PERFORMANCE_ENTRY_TYPE.FIRST_INPUT) && !initiallyHidden) {
17+
observer = new PerformanceObserver((list) => {
18+
const firstInput = list.getEntries()[0]
19+
20+
const attrs = {
21+
type: firstInput.name,
22+
eventTarget: firstInput.target
23+
}
24+
25+
observer.disconnect()
26+
if (!firstInteraction.isValid) {
27+
firstInteraction.update({
28+
value: firstInput.startTime,
29+
attrs
30+
})
31+
}
32+
})
33+
observer.observe({ type: PERFORMANCE_ENTRY_TYPE.FIRST_INPUT, buffered: true })
34+
}
35+
} catch (e) {
36+
// Do nothing.
37+
}
38+
}

src/features/page_view_timing/aggregate/index.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import { FEATURE_NAMES } from '../../../loaders/features/features'
1111
import { AggregateBase } from '../../utils/aggregate-base'
1212
import { cumulativeLayoutShift } from '../../../common/vitals/cumulative-layout-shift'
1313
import { firstContentfulPaint } from '../../../common/vitals/first-contentful-paint'
14-
import { firstInputDelay } from '../../../common/vitals/first-input-delay'
1514
import { firstPaint } from '../../../common/vitals/first-paint'
15+
import { firstInteraction } from '../../../common/vitals/first-interaction'
1616
import { interactionToNextPaint } from '../../../common/vitals/interaction-to-next-paint'
1717
import { largestContentfulPaint } from '../../../common/vitals/largest-contentful-paint'
1818
import { timeToFirstByte } from '../../../common/vitals/time-to-first-byte'
@@ -37,8 +37,8 @@ export class Aggregate extends AggregateBase {
3737
this.waitForFlags(([])).then(() => {
3838
firstPaint.subscribe(this.#handleVitalMetric)
3939
firstContentfulPaint.subscribe(this.#handleVitalMetric)
40-
firstInputDelay.subscribe(this.#handleVitalMetric)
4140
largestContentfulPaint.subscribe(this.#handleVitalMetric)
41+
firstInteraction.subscribe(this.#handleVitalMetric)
4242
interactionToNextPaint.subscribe(this.#handleVitalMetric)
4343
timeToFirstByte.subscribe(({ attrs }) => {
4444
this.addTiming('load', Math.round(attrs.navigationEntry.loadEventEnd))

src/features/session_trace/aggregate/trace/storage.js

-5
Original file line numberDiff line numberDiff line change
@@ -136,11 +136,6 @@ export class TraceStorage {
136136

137137
processPVT (name, value, attrs) {
138138
this.storeTiming({ [name]: value })
139-
if (hasFID(name, attrs)) this.storeEvent({ type: 'fid', target: 'document' }, 'document', value, value + attrs.fid)
140-
141-
function hasFID (name, attrs) {
142-
return name === 'fi' && !!attrs && typeof attrs.fid === 'number'
143-
}
144139
}
145140

146141
storeTiming (timingEntry, isAbsoluteTimestamp = false) {

tests/components/page_view_timing/aggregate.test.js

-16
Original file line numberDiff line numberDiff line change
@@ -5,27 +5,18 @@ import { VITAL_NAMES } from '../../../src/common/vitals/constants'
55

66
// Note: these callbacks fire right away unlike the real web-vitals API which are async-on-trigger
77
jest.mock('web-vitals/attribution', () => ({
8-
// eslint-disable-next-line
98
onCLS: jest.fn((cb) => cb({
109
value: 0.1119,
1110
attribution: {}
1211
})),
13-
// eslint-disable-next-line
1412
onFCP: jest.fn((cb) => cb({
1513
value: 1,
1614
attribution: {}
1715
})),
18-
// eslint-disable-next-line
19-
onFID: jest.fn(cb => cb({
20-
value: 1234,
21-
attribution: { eventTime: 5, eventType: 'pointerdown' }
22-
})),
23-
// eslint-disable-next-line
2416
onINP: jest.fn((cb) => cb({
2517
value: 8,
2618
attribution: {}
2719
})),
28-
// eslint-disable-next-line
2920
onLCP: jest.fn((cb) => cb({
3021
value: 1,
3122
attribution: {}
@@ -90,13 +81,6 @@ test('LCP event with CLS attribute', () => {
9081
}
9182
})
9283

93-
test('sends expected FI attributes when available', () => {
94-
expect(timingsAggregate.events.get()[0].data.length).toBeGreaterThanOrEqual(1)
95-
const fiPayload = timingsAggregate.events.get()[0].data.find(x => x.name === 'fi')
96-
expect(fiPayload.value).toEqual(5)
97-
expect(fiPayload.attrs).toEqual(expect.objectContaining({ type: 'pointerdown', fid: 1234, cls: 0.1119, ...expectedNetworkInfo }))
98-
})
99-
10084
test('sends CLS node with right val on vis change', () => {
10185
let clsNode = timingsAggregate.events.get()[0].data.find(tn => tn.name === VITAL_NAMES.CUMULATIVE_LAYOUT_SHIFT)
10286
expect(clsNode).toBeUndefined()

tests/components/page_view_timing/index.test.js

-16
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,18 @@ import { VITAL_NAMES } from '../../../src/common/vitals/constants'
22

33
// Note: these callbacks fire right away unlike the real web-vitals API which are async-on-trigger
44
jest.mock('web-vitals/attribution', () => ({
5-
// eslint-disable-next-line
65
onCLS: jest.fn((cb) => cb({
76
value: 0.1119,
87
attribution: {}
98
})),
10-
// eslint-disable-next-line
119
onFCP: jest.fn((cb) => cb({
1210
value: 1,
1311
attribution: {}
1412
})),
15-
// eslint-disable-next-line
16-
onFID: jest.fn(cb => cb({
17-
value: 1234,
18-
attribution: { eventTime: 5, eventType: 'pointerdown' }
19-
})),
20-
// eslint-disable-next-line
2113
onINP: jest.fn((cb) => cb({
2214
value: 8,
2315
attribution: {}
2416
})),
25-
// eslint-disable-next-line
2617
onLCP: jest.fn((cb) => cb({
2718
value: 1,
2819
attribution: {}
@@ -92,13 +83,6 @@ describe('pvt aggregate tests', () => {
9283
}
9384
})
9485

95-
test('sends expected FI attributes when available', () => {
96-
expect(pvtAgg.events.get()[0].data.length).toBeTruthy()
97-
const fiPayload = pvtAgg.events.get()[0].data.find(x => x.name === 'fi')
98-
expect(fiPayload.value).toEqual(5)
99-
expect(fiPayload.attrs).toEqual(expect.objectContaining({ type: 'pointerdown', fid: 1234, cls: 0.1119, ...expectedNetworkInfo }))
100-
})
101-
10286
test('sends CLS node with right val on vis change', () => {
10387
let clsNode = pvtAgg.events.get()[0].data.find(tn => tn.name === VITAL_NAMES.CUMULATIVE_LAYOUT_SHIFT)
10488
expect(clsNode).toBeUndefined()

tests/components/session_trace/aggregate.test.js

+1-6
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ test('creates right nodes', async () => {
3030
document.dispatchEvent(new CustomEvent('DOMContentLoaded')) // simulate natural browser event
3131
window.dispatchEvent(new CustomEvent('load')) // load is actually ignored by Trace as it should be passed by the PVT feature, so it should not be in payload
3232
sessionTraceAggregate.events.storeXhrAgg('xhr', '[200,null,null]', { method: 'GET', status: 200 }, { rxSize: 770, duration: 99, cbTime: 0, time: 217 }) // fake ajax data
33-
sessionTraceAggregate.events.processPVT('fi', 30, { fid: 8 }) // fake pvt data
33+
sessionTraceAggregate.events.processPVT('fi', 30) // fake pvt data
3434

3535
const payload = sessionTraceAggregate.makeHarvestPayload()[0].payload
3636
let res = payload.body
@@ -58,11 +58,6 @@ test('creates right nodes', async () => {
5858
expect(pvt.o).toEqual('document')
5959
expect(pvt.s).toEqual(pvt.e) // that FI has no duration
6060
expect(pvt.t).toEqual('timing')
61-
pvt = res.filter(node => node.n === 'fid')[0]
62-
expect(pvt.o).toEqual('document')
63-
expect(pvt.s).toEqual(30) // that FID has a duration relative to FI'
64-
expect(pvt.e).toEqual(30 + 8)
65-
expect(pvt.t).toEqual('event')
6661

6762
let unknown = res.filter(n => n.o === 'unknown')
6863
expect(unknown.length).toEqual(0) // no events with unknown origin

tests/specs/pvt/timings.e2e.js

+5-8
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import { supportsCumulativeLayoutShift, supportsFirstInputDelay, supportsFirstPaint, supportsInteractionToNextPaint, supportsLargestContentfulPaint } from '../../../tools/browser-matcher/common-matchers.mjs'
1+
import { supportsCumulativeLayoutShift, supportsFirstPaint, supportsInteractionToNextPaint, supportsLargestContentfulPaint, supportsPerformanceEventTiming } from '../../../tools/browser-matcher/common-matchers.mjs'
22
import { testTimingEventsRequest } from '../../../tools/testing-server/utils/expect-tests'
33

4-
const isClickInteractionType = type => type === 'pointerdown' || type === 'mousedown' || type === 'click'
54
const loadersToTest = ['rum', 'spa']
65

76
describe('pvt timings tests', () => {
@@ -69,7 +68,7 @@ describe('pvt timings tests', () => {
6968

7069
describe('interaction related timings', () => {
7170
loadersToTest.forEach(loader => {
72-
it(`FI, FID, INP & LCP for ${loader} agent`, async () => {
71+
it(`FI, INP & LCP for ${loader} agent`, async () => {
7372
const start = Date.now()
7473
await browser.url(
7574
await browser.testHandle.assetURL('basic-click-tracking.html', { loader })
@@ -82,19 +81,17 @@ describe('pvt timings tests', () => {
8281
.then(async () => browser.url(await browser.testHandle.assetURL('/')))
8382
])
8483

85-
if (browserMatch(supportsFirstInputDelay)) {
84+
if (browserMatch(supportsPerformanceEventTiming)) {
85+
// FID is replaced by subscribing to 'first-input'
8686
const fi = timingsHarvests.find(harvest => harvest.request.body.find(t => t.name === 'fi'))
8787
?.request.body.find(timing => timing.name === 'fi')
8888
expect(fi.value).toBeGreaterThanOrEqual(0)
8989
expect(fi.value).toBeLessThan(Date.now() - start)
9090

91+
const isClickInteractionType = type => type === 'pointerdown' || type === 'mousedown' || type === 'click'
9192
const fiType = fi.attributes.find(attr => attr.key === 'type')
9293
expect(isClickInteractionType(fiType.value)).toEqual(true)
9394
expect(fiType.type).toEqual('stringAttribute')
94-
95-
const fid = fi.attributes.find(attr => attr.key === 'fid')
96-
expect(fid.value).toBeGreaterThanOrEqual(0)
97-
expect(fid.type).toEqual('doubleAttribute')
9895
}
9996

10097
if (browserMatch(supportsLargestContentfulPaint)) {
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,43 @@
1+
import { PERFORMANCE_ENTRY_TYPE } from '../../../../src/common/vitals/constants'
2+
13
beforeEach(() => {
24
jest.resetModules()
35
jest.resetAllMocks()
46
jest.clearAllMocks()
5-
})
67

7-
const fidAttribution = {
8-
eventTarget: 'html>body',
9-
eventType: 'pointerdown',
10-
eventTime: 1,
11-
loadState: 'loading'
12-
}
13-
let triggeronFIDCallback
14-
const getFreshFIDImport = async (codeToRun) => {
15-
jest.doMock('web-vitals/attribution', () => ({
16-
onFID: jest.fn(cb => { triggeronFIDCallback = cb; cb({ value: 100, attribution: fidAttribution }) })
8+
const mockPerformanceObserver = jest.fn(cb => ({
9+
// Note: this is an imperfect mock, as observer.disconnect() is not functional
10+
observe: () => {
11+
const callCb = () => {
12+
cb({
13+
getEntries: () => [{
14+
name: PERFORMANCE_ENTRY_TYPE.FIRST_INPUT,
15+
startTime: 1
16+
}]
17+
})
18+
setTimeout(callCb, 250)
19+
}
20+
setTimeout(callCb, 250)
21+
},
22+
disconnect: jest.fn()
1723
}))
18-
const { firstInputDelay } = await import('../../../../src/common/vitals/first-input-delay')
19-
codeToRun(firstInputDelay)
24+
global.PerformanceObserver = mockPerformanceObserver
25+
global.PerformanceObserver.supportedEntryTypes = [PERFORMANCE_ENTRY_TYPE.FIRST_INPUT]
26+
})
27+
28+
const getFreshImport = async (codeToRun) => {
29+
const { firstInteraction } = await import('../../../../src/common/vitals/first-interaction')
30+
codeToRun(firstInteraction)
2031
}
2132

22-
describe('fid', () => {
23-
test('reports fcp from web-vitals', (done) => {
24-
getFreshFIDImport(metric => metric.subscribe(({ value, attrs }) => {
25-
expect(value).toEqual(1)
26-
expect(attrs.type).toEqual(fidAttribution.eventType)
27-
expect(attrs.fid).toEqual(100)
28-
expect(attrs.eventTarget).toEqual(fidAttribution.eventTarget)
29-
expect(attrs.loadState).toEqual(fidAttribution.loadState)
30-
done()
31-
}))
33+
describe('fi (first interaction)', () => {
34+
test('reports fi', (done) => {
35+
getFreshImport(metric => {
36+
metric.subscribe(({ value }) => {
37+
expect(value).toEqual(1)
38+
done()
39+
})
40+
})
3241
})
3342

3443
test('Does NOT report values if initiallyHidden', (done) => {
@@ -38,7 +47,7 @@ describe('fid', () => {
3847
isBrowserScope: true
3948
}))
4049

41-
getFreshFIDImport(metric => {
50+
getFreshImport(metric => {
4251
metric.subscribe(() => {
4352
console.log('should not have reported')
4453
expect(1).toEqual(2)
@@ -50,11 +59,12 @@ describe('fid', () => {
5059
test('does NOT report if not browser scoped', (done) => {
5160
jest.doMock('../../../../src/common/constants/runtime', () => ({
5261
__esModule: true,
62+
initiallyHidden: false,
5363
isBrowserScope: false
5464
}))
5565

56-
getFreshFIDImport(metric => {
57-
metric.subscribe(() => {
66+
getFreshImport(metric => {
67+
metric.subscribe(({ value, attrs }) => {
5868
console.log('should not have reported...')
5969
expect(1).toEqual(2)
6070
})
@@ -65,10 +75,11 @@ describe('fid', () => {
6575
test('multiple subs get same value', done => {
6676
jest.doMock('../../../../src/common/constants/runtime', () => ({
6777
__esModule: true,
78+
initiallyHidden: false,
6879
isBrowserScope: true
6980
}))
7081
let witness = 0
71-
getFreshFIDImport(metric => {
82+
getFreshImport(metric => {
7283
metric.subscribe(({ value }) => {
7384
expect(value).toEqual(1)
7485
witness++
@@ -88,15 +99,14 @@ describe('fid', () => {
8899
isBrowserScope: true
89100
}))
90101
let triggered = 0
91-
getFreshFIDImport(metric => {
92-
metric.subscribe(({ value }) => {
93-
triggered++
94-
expect(value).toEqual(1)
95-
expect(triggered).toEqual(1)
96-
})
97-
triggeronFIDCallback({ value: 'notequal1' })
102+
getFreshImport(metric => metric.subscribe(({ value }) => {
103+
triggered++
104+
expect(value).toEqual(1)
98105
expect(triggered).toEqual(1)
99-
done()
100-
})
106+
setTimeout(() => {
107+
expect(triggered).toEqual(1)
108+
done()
109+
}, 1000)
110+
}))
101111
})
102112
})

0 commit comments

Comments
 (0)