Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,23 @@ interface ObservedImageState {
count: number;
}

/**
* These soft navigation entry types are not yet included in TypeScript's DOM typings.
*
* WebIDL definitions:
* - https://wicg.github.io/soft-navigations/#sec-interaction-contentful-paint-interface
* - https://wicg.github.io/soft-navigations/#performancesoftnavigation
*/
interface InteractionContentfulPaintEntry extends PerformanceEntry {
readonly interactionId: number;
readonly largestContentfulPaint: LargestContentfulPaint;
}

interface PerformanceSoftNavigationEntry extends PerformanceEntry {
readonly interactionId: number;
getLargestInteractionContentfulPaint(): InteractionContentfulPaintEntry | null;
}

/**
* Observer that detects whether an image with `NgOptimizedImage`
* is treated as a Largest Contentful Paint (LCP) element. If so,
Expand All @@ -45,6 +62,7 @@ export class LCPImageObserver implements OnDestroy {

private window: Window | null = inject(DOCUMENT).defaultView;
private observer: PerformanceObserver | null = null;
private currentSoftNavigationInteractionId: number | null = null;

constructor() {
assertDevMode('LCP checker');
Expand All @@ -65,34 +83,79 @@ export class LCPImageObserver implements OnDestroy {
const observer = new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
if (entries.length === 0) return;
// We use the latest entry produced by the `PerformanceObserver` as the best
// signal on which element is actually an LCP one. As an example, the first image to load on
// a page, by virtue of being the only thing on the page so far, is often a LCP candidate
// and gets reported by PerformanceObserver, but isn't necessarily the LCP element.
const lcpElement = entries[entries.length - 1];

// Cast to `any` due to missing `element` on the `LargestContentfulPaint` type of entry.
// See https://developer.mozilla.org/en-US/docs/Web/API/LargestContentfulPaint
const imgSrc = (lcpElement as any).element?.src ?? '';

// Exclude `data:` and `blob:` URLs, since they are not supported by the directive.
if (imgSrc.startsWith('data:') || imgSrc.startsWith('blob:')) return;

const img = this.images.get(imgSrc);
if (!img) return;
if (!img.priority && !img.alreadyWarnedPriority) {
img.alreadyWarnedPriority = true;
logMissingPriorityError(imgSrc);

// Entries of different types may be delivered out of order, so process them by end time.
// See https://github.com/w3c/performance-timeline/issues/224.
entries.sort((a, b) => a.startTime + a.duration - (b.startTime + b.duration));

let latestInitialLcp: LargestContentfulPaint | null = null;

for (const entry of entries) {
if (entry.entryType === 'largest-contentful-paint') {
// We use the latest entry produced by the `PerformanceObserver` as the best
// signal on which element is actually an LCP one. As an example, the first image to load
// on a page, by virtue of being the only thing on the page so far, is often a LCP candidate
// and gets reported by PerformanceObserver, but isn't necessarily the LCP element.
latestInitialLcp = entry as LargestContentfulPaint;
} else if (entry.entryType === 'soft-navigation') {
this.processSoftNavigationEntry(entry as PerformanceSoftNavigationEntry);
} else if (entry.entryType === 'interaction-contentful-paint') {
this.processInteractionContentfulPaintEntry(entry as InteractionContentfulPaintEntry);
}
}
if (img.modified && !img.alreadyWarnedModified) {
img.alreadyWarnedModified = true;
logModifiedWarning(imgSrc);

if (latestInitialLcp) {
this.processLcpEntry(latestInitialLcp);
}
});
observer.observe({type: 'largest-contentful-paint', buffered: true});

if (
PerformanceObserver.supportedEntryTypes.includes('soft-navigation') &&
PerformanceObserver.supportedEntryTypes.includes('interaction-contentful-paint')
) {
observer.observe({type: 'soft-navigation', buffered: true});
// The soft navigation getter recovers earlier candidates; only future ICP updates are needed.
observer.observe({type: 'interaction-contentful-paint'});
}

return observer;
}

private processSoftNavigationEntry(entry: PerformanceSoftNavigationEntry): void {
this.currentSoftNavigationInteractionId = entry.interactionId;

// The largest candidate may have been emitted before the soft navigation was detected.
const largestInteractionContentfulPaint = entry.getLargestInteractionContentfulPaint();
if (largestInteractionContentfulPaint) {
this.processLcpEntry(largestInteractionContentfulPaint.largestContentfulPaint);
}
}

private processInteractionContentfulPaintEntry(entry: InteractionContentfulPaintEntry): void {
// Interaction contentful paints are emitted for all interactions, not only soft navigations.
if (entry.interactionId !== this.currentSoftNavigationInteractionId) return;
this.processLcpEntry(entry.largestContentfulPaint);
}

private processLcpEntry(entry: LargestContentfulPaint): void {
const imgSrc = entry.element instanceof HTMLImageElement ? entry.element.src : '';

// Exclude `data:` and `blob:` URLs, since they are not supported by the directive.
if (imgSrc.startsWith('data:') || imgSrc.startsWith('blob:')) return;

const img = this.images.get(imgSrc);
if (!img) return;
if (!img.priority && !img.alreadyWarnedPriority) {
img.alreadyWarnedPriority = true;
logMissingPriorityError(imgSrc);
}
if (img.modified && !img.alreadyWarnedModified) {
img.alreadyWarnedModified = true;
logModifiedWarning(imgSrc);
}
}

registerImage(rewrittenSrc: string, isPriority: boolean) {
if (!this.observer) return;
const url = getUrl(rewrittenSrc, this.window!).href;
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/bundling/image-directive/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ ng_project(
"e2e/image-perf-warnings-oversized/image-perf-warnings-oversized.ts",
"e2e/image-perf-warnings-oversized/svg-no-perf-oversized-warnings.ts",
"e2e/lcp-check-duplicate/lcp-check-duplicate.ts",
"e2e/lcp-check-soft-navigation/lcp-check-soft-navigation.ts",
"e2e/lcp-check/lcp-check.ts",
"e2e/oversized-image/oversized-image.ts",
"e2e/preconnect-check/preconnect-check.ts",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import {browser, by, element} from 'protractor';
import {logging} from 'selenium-webdriver';

import {collectBrowserLogs} from '../browser-logs-util';

describe('NgOptimizedImage directive', () => {
it('should log a warning when `priority` is missing on a soft navigation LCP image', async () => {
await browser.get('/e2e/lcp-check-soft-navigation');

await element(by.id('navigate-to-soft-navigation-lcp')).click();

expect(await browser.getCurrentUrl()).toContain('/e2e/lcp-check-soft-navigation-target');
const imageSrc = await element(by.id('soft-navigation-lcp')).getAttribute('src');
expect(imageSrc.endsWith('logo-1500w.jpg')).toBe(true);

// Allow the image paint and the PerformanceObserver callback to complete.
await new Promise((resolve) => setTimeout(resolve, 500));

const logs = (await collectBrowserLogs(logging.Level.SEVERE)).filter((log) =>
log.message.includes('NG02955'),
);
expect(logs.length).toBeGreaterThanOrEqual(1);
expect(logs.some((log) => /NG02955.*?logo-1500w\.jpg/.test(log.message))).toBe(true);
});

it('should ignore a later interaction contentful paint that is not a soft navigation', async () => {
await browser.get('/e2e/lcp-check-soft-navigation');
await element(by.id('navigate-to-soft-navigation-lcp')).click();

const softNavigationUrl = await browser.getCurrentUrl();
expect(softNavigationUrl).toContain('/e2e/lcp-check-soft-navigation-target');

// Confirm that this test established an active soft navigation.
await new Promise((resolve) => setTimeout(resolve, 500));
const softNavigationLogs = await collectBrowserLogs(logging.Level.SEVERE);
expect(softNavigationLogs.some((log) => /NG02955.*?logo-1500w\.jpg/.test(log.message))).toBe(
true,
);

await element(by.id('render-image-without-navigation')).click();

expect(await browser.getCurrentUrl()).toEqual(softNavigationUrl);
const imageSrc = await element(by.id('non-navigation-image')).getAttribute('src');
expect(imageSrc.endsWith('logo-500w.jpg')).toBe(true);

await new Promise((resolve) => setTimeout(resolve, 500));

const logs = (await collectBrowserLogs(logging.Level.SEVERE)).filter(
(log) => log.message.includes('NG02955') && log.message.includes('logo-500w.jpg'),
);
expect(logs).toEqual([]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/

import {NgOptimizedImage} from '@angular/common';
import {Component, signal} from '@angular/core';
import {RouterLink} from '@angular/router';

@Component({
selector: 'lcp-check-soft-navigation-start',
imports: [NgOptimizedImage, RouterLink],
template: `
<img ngSrc="/e2e/b.png" width="5" height="5" priority />
<a id="navigate-to-soft-navigation-lcp" routerLink="/e2e/lcp-check-soft-navigation-target">
Navigate
</a>
`,
})
export class LcpCheckSoftNavigationStart {}

@Component({
selector: 'lcp-check-soft-navigation-target',
imports: [NgOptimizedImage],
template: `
<button id="render-image-without-navigation" (click)="showNonNavigationImage.set(true)">
Render image
</button>

@if (showNonNavigationImage()) {
<img id="non-navigation-image" ngSrc="/e2e/logo-500w.jpg" width="500" height="500" />
} @else {
<img id="soft-navigation-lcp" ngSrc="/e2e/logo-1500w.jpg" width="1500" height="1500" />
}
`,
})
export class LcpCheckSoftNavigationTarget {
showNonNavigationImage = signal(false);
}
6 changes: 6 additions & 0 deletions packages/core/test/bundling/image-directive/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ import {ImagePerfWarningsLazyComponent} from './e2e/image-perf-warnings-lazy/ima
import {ImagePerfWarningsOversizedComponent} from './e2e/image-perf-warnings-oversized/image-perf-warnings-oversized';
import {SvgNoOversizedPerfWarningsComponent} from './e2e/image-perf-warnings-oversized/svg-no-perf-oversized-warnings';
import {LcpCheckDuplicate} from './e2e/lcp-check-duplicate/lcp-check-duplicate';
import {
LcpCheckSoftNavigationStart,
LcpCheckSoftNavigationTarget,
} from './e2e/lcp-check-soft-navigation/lcp-check-soft-navigation';
import {LcpCheckComponent} from './e2e/lcp-check/lcp-check';
import {
OversizedImageComponentFailing,
Expand All @@ -50,6 +54,8 @@ const ROUTES = [
{path: 'e2e/basic', component: BasicComponent},
{path: 'e2e/lcp-check', component: LcpCheckComponent},
{path: 'e2e/lcp-check-duplicate', component: LcpCheckDuplicate},
{path: 'e2e/lcp-check-soft-navigation', component: LcpCheckSoftNavigationStart},
{path: 'e2e/lcp-check-soft-navigation-target', component: LcpCheckSoftNavigationTarget},
{path: 'e2e/image-perf-warnings-lazy', component: ImagePerfWarningsLazyComponent},
{path: 'e2e/image-perf-warnings-oversized', component: ImagePerfWarningsOversizedComponent},
{path: 'e2e/svg-no-perf-oversized-warnings', component: SvgNoOversizedPerfWarningsComponent},
Expand Down
Loading