Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Save a reference to the LCP and CLS elements in case they are removed from the DOM #562

Open
wants to merge 14 commits into
base: v5
Choose a base branch
from
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

### v5.0.0-rc.1 (???)

- **[BREAKING]** LCP `element` renamed to `target` ([#562](https://github.com/GoogleChrome/web-vitals/pull/562))
- LCP and CLS attribution now saves the target selector and element for when the element is removed from the DOM ([#562](https://github.com/GoogleChrome/web-vitals/pull/562))

### v5.0.0-rc.0 (2024-10-03)

- **[BREAKING]** Remove the deprecated `onFID()` function ([#519](https://github.com/GoogleChrome/web-vitals/pull/519))
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ function sendToGoogleAnalytics({name, delta, value, id, attribution}) {
eventParams.debug_target = attribution.interactionTarget;
break;
case 'LCP':
eventParams.debug_target = attribution.element;
eventParams.debug_target = attribution.target;
break;
}

Expand Down Expand Up @@ -926,10 +926,15 @@ interface INPAttribution {

```ts
interface LCPAttribution {
/**
* A selector identifying the element corresponding to the largest contentful paint
* for the page.
*/
target?: string;
/**
* The element corresponding to the largest contentful paint for the page.
*/
element?: string;
targetElement?: Node;
/**
* The URL (if applicable) of the LCP image resource. If the LCP element
* is a text node, this value will not be set.
Expand Down
31 changes: 29 additions & 2 deletions src/attribution/onCLS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,21 @@

import {getLoadState} from '../lib/getLoadState.js';
import {getSelector} from '../lib/getSelector.js';
import {onCLS as unattributedOnCLS} from '../onCLS.js';
import {
onCLS as unattributedOnCLS,
entryPreProcessingCallbacks,
} from '../onCLS.js';
import {
CLSAttribution,
CLSMetric,
CLSMetricWithAttribution,
ReportOpts,
} from '../types.js';

// A reference to the layout shift target node in case it's removed before reporting.
const layoutShiftTargetMap: WeakMap<LayoutShift, Node | undefined> =
new WeakMap();

const getLargestLayoutShiftEntry = (entries: LayoutShift[]) => {
return entries.reduce((a, b) => (a.value > b.value ? a : b));
};
Expand All @@ -42,7 +49,11 @@ const attributeCLS = (metric: CLSMetric): CLSMetricWithAttribution => {
const largestSource = getLargestLayoutShiftSource(largestEntry.sources);
if (largestSource) {
attribution = {
largestShiftTarget: getSelector(largestSource.node),
largestShiftTarget: getSelector(
largestSource.node ?? layoutShiftTargetMap.get(largestEntry),
),
largestShiftTargetElement:
largestSource.node ?? layoutShiftTargetMap.get(largestEntry),
largestShiftTime: largestEntry.startTime,
largestShiftValue: largestEntry.value,
largestShiftSource: largestSource,
Expand All @@ -61,6 +72,22 @@ const attributeCLS = (metric: CLSMetric): CLSMetricWithAttribution => {
return metricWithAttribution;
};

// Get a reference to the layout shift target element in case it's removed from the DOM
// later.
const savelayoutShiftLargestTarget = (layoutShiftEntry: LayoutShift) => {
if (
layoutShiftEntry?.sources &&
!layoutShiftTargetMap.get(layoutShiftEntry)
) {
layoutShiftTargetMap.set(
layoutShiftEntry,
getLargestLayoutShiftSource(layoutShiftEntry.sources)?.node,
);
}
};

entryPreProcessingCallbacks.push(savelayoutShiftLargestTarget);

/**
* Calculates the [CLS](https://web.dev/articles/cls) value for the current page and
* calls the `callback` function once the value is ready to be reported, along
Expand Down
21 changes: 19 additions & 2 deletions src/attribution/onLCP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,20 @@

import {getNavigationEntry} from '../lib/getNavigationEntry.js';
import {getSelector} from '../lib/getSelector.js';
import {onLCP as unattributedOnLCP} from '../onLCP.js';
import {
onLCP as unattributedOnLCP,
entryPreProcessingCallbacks,
} from '../onLCP.js';
import {
LCPAttribution,
LCPMetric,
LCPMetricWithAttribution,
ReportOpts,
} from '../types.js';

// A reference to the LCP target node in case it's removed before reporting.
const lcpTargetMap: WeakMap<LargestContentfulPaint, Node> = new WeakMap();

const attributeLCP = (metric: LCPMetric): LCPMetricWithAttribution => {
// Use a default object if no other attribution has been set.
let attribution: LCPAttribution = {
Expand Down Expand Up @@ -65,7 +71,8 @@ const attributeLCP = (metric: LCPMetric): LCPMetricWithAttribution => {
);

attribution = {
element: getSelector(lcpEntry.element),
target: getSelector(lcpEntry.element ?? lcpTargetMap.get(lcpEntry)),
targetElement: lcpEntry.element ?? lcpTargetMap.get(lcpEntry),
timeToFirstByte: ttfb,
resourceLoadDelay: lcpRequestStart - ttfb,
resourceLoadDuration: lcpResponseEnd - lcpRequestStart,
Expand All @@ -92,6 +99,16 @@ const attributeLCP = (metric: LCPMetric): LCPMetricWithAttribution => {
return metricWithAttribution;
};

// Get a reference to the LCP target element in case it's removed from the DOM
// later.
const saveLCPTarget = (lcpEntry: LargestContentfulPaint) => {
if (lcpEntry.element && !lcpTargetMap.get(lcpEntry)) {
lcpTargetMap.set(lcpEntry, lcpEntry.element);
}
};

entryPreProcessingCallbacks.push(saveLCPTarget);

/**
* Calculates the [LCP](https://web.dev/articles/lcp) value for the current page and
* calls the `callback` function once the value is ready (along with the
Expand Down
9 changes: 9 additions & 0 deletions src/onCLS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ import {CLSMetric, MetricRatingThresholds, ReportOpts} from './types.js';
/** Thresholds for CLS. See https://web.dev/articles/cls#what_is_a_good_cls_score */
export const CLSThresholds: MetricRatingThresholds = [0.1, 0.25];

interface EntryPreProcessingHook {
(entry: LayoutShift): void;
}

export const entryPreProcessingCallbacks: EntryPreProcessingHook[] = [];

/**
* Calculates the [CLS](https://web.dev/articles/cls) value for the current page and
* calls the `callback` function once the value is ready to be reported, along
Expand Down Expand Up @@ -65,6 +71,9 @@ export const onCLS = (
for (const entry of entries) {
// Only count layout shifts without recent user input.
if (!entry.hadRecentInput) {
for (const cb of entryPreProcessingCallbacks) {
cb(entry);
}
const firstSessionEntry = sessionEntries[0];
const lastSessionEntry = sessionEntries.at(-1);

Expand Down
9 changes: 9 additions & 0 deletions src/onLCP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ import {LCPMetric, MetricRatingThresholds, ReportOpts} from './types.js';
/** Thresholds for LCP. See https://web.dev/articles/lcp#what_is_a_good_lcp_score */
export const LCPThresholds: MetricRatingThresholds = [2500, 4000];

interface EntryPreProcessingHook {
(entry: LargestContentfulPaint): void;
}

export const entryPreProcessingCallbacks: EntryPreProcessingHook[] = [];

/**
* Calculates the [LCP](https://web.dev/articles/lcp) value for the current page and
* calls the `callback` function once the value is ready (along with the
Expand Down Expand Up @@ -57,6 +63,9 @@ export const onLCP = (
}

for (const entry of entries) {
for (const cb of entryPreProcessingCallbacks) {
cb(entry);
}
// Only report if the page wasn't hidden prior to LCP.
if (entry.startTime < visibilityWatcher.firstHiddenTime) {
// The startTime attribute returns the value of the renderTime if it is
Expand Down
6 changes: 6 additions & 0 deletions src/types/cls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ export interface CLSAttribution {
* CLS score occurred.
*/
largestShiftTarget?: string;
/**
* The first element (in document order) that shifted when the single
* largest layout shift contributing to the page's
* CLS score occurred.
*/
largestShiftTargetElement?: Node;
/**
* The time when the single largest layout shift contributing to the page's
* CLS score occurred.
Expand Down
7 changes: 6 additions & 1 deletion src/types/lcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,15 @@ export interface LCPMetric extends Metric {
* to help identify issues happening to real-users in the field.
*/
export interface LCPAttribution {
/**
* A selector identifying the element corresponding to the largest contentful paint
* for the page.
*/
target?: string;
/**
* The element corresponding to the largest contentful paint for the page.
*/
element?: string;
targetElement?: Node;
/**
* The URL (if applicable) of the LCP image resource. If the LCP element
* is a text node, this value will not be set.
Expand Down
56 changes: 56 additions & 0 deletions test/e2e/onCLS-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -835,6 +835,62 @@ describe('onCLS()', async function () {

assert.deepEqual(cls.attribution, {});
});

it('reports the target when removed (reportAllChanges === false)', async function () {
if (!browserSupportsCLS) this.skip();

await navigateTo(`/test/cls?noLayoutShifts=1&attribution=1`, {
readyState: 'complete',
});

// Wait until the page is loaded and content is visible before triggering
// a layout shift.
await firstContentfulPaint();

await triggerLayoutShift();

await browser.execute(() => {
// Remove target element
document.querySelector('h1').remove();
// Remove other elements too to prevent them reporting
document.querySelector('#p1').remove();
document.querySelector('#p5').remove();
});

await stubVisibilityChange('hidden');

await beaconCountIs(1);

const [cls] = await getBeacons();
// Note this should be the reduced selector without the full path
assert.equal(cls.attribution.largestShiftTarget, 'h1');
});

it('reports the target (reportAllChanges === true)', async function () {
// We can't guarantee the order or reporting removed targets with
// reportAllChanges so don't even try. Just test the target without
// removal to compare to previous test
if (!browserSupportsCLS) this.skip();

await navigateTo(
`/test/cls?noLayoutShifts=1&attribution=1&reportAllChanges=1`,
{
readyState: 'complete',
},
);

// Wait until the page is loaded and content is visible before triggering
// a layout shift.
await firstContentfulPaint();
await clearBeacons();

await triggerLayoutShift();
await beaconCountIs(1);

const [cls] = await getBeacons();
// Note this should be the full selector with the full path
assert.equal(cls.attribution.largestShiftTarget, 'html>body>main>h1');
});
});
});

Expand Down
54 changes: 54 additions & 0 deletions test/e2e/onINP-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,60 @@ describe('onINP()', async function () {
const [inp1] = await getBeacons();
assert(inp1.attribution.longAnimationFrameEntries.length > 0);
});

it('includes target even when removed (reportAllChanges === false)', async function () {
if (!browserSupportsLoAF) this.skip();

await navigateTo('/test/inp?attribution=1&pointerdown=100', {
readyState: 'interactive',
});

const h1 = await $('h1');
await simulateUserLikeClick(h1);

await browser.execute(() => {
// Remove target element
document.querySelector('h1').remove();
});

await nextFrame();

await stubVisibilityChange('hidden');
await beaconCountIs(1);

const [inp] = await getBeacons();
// Note this should be the reduced selector without the full path
assert.equal(inp.attribution.interactionTarget, 'h1');
});

it('includes target (reportAllChanges === true)', async function () {
// We can't guarantee the order or reporting removed targets with
// reportAllChanges so don't even try. Just test the target without
// removal to compare to previous test
if (!browserSupportsLoAF) this.skip();

await navigateTo(
'/test/inp?attribution=1&pointerdown=100&reportAllChanges=1',
{
readyState: 'interactive',
},
);

const h1 = await $('h1');
await simulateUserLikeClick(h1);

// Can't guarantee order so let's wait for beacon
await beaconCountIs(1);

await browser.execute(() => {
// Remove target element
document.querySelector('h1').remove();
});

const [inp] = await getBeacons();
// Note this should be the full selector with the full path
assert.equal(inp.attribution.interactionTarget, 'html>body>main>h1');
});
});
});

Expand Down
Loading
Loading