Skip to content

Commit 3874969

Browse files
SkyZeroZxthePunderWoman
authored andcommitted
fix(common): fix LCP image detection with duplicate URLs
Addresses an issue where the LCP image observer incorrectly identified LCP elements when the same image URL was used multiple times on a page Fixes angular#53278
1 parent d7ddebc commit 3874969

6 files changed

Lines changed: 141 additions & 13 deletions

File tree

packages/common/src/directives/ng_optimized_image/lcp_image_observer.ts

Lines changed: 61 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ interface ObservedImageState {
2525
modified: boolean;
2626
alreadyWarnedPriority: boolean;
2727
alreadyWarnedModified: boolean;
28+
count: number;
2829
}
2930

3031
/**
@@ -94,29 +95,77 @@ export class LCPImageObserver implements OnDestroy {
9495

9596
registerImage(rewrittenSrc: string, isPriority: boolean) {
9697
if (!this.observer) return;
97-
const newObservedImageState: ObservedImageState = {
98-
priority: isPriority,
99-
modified: false,
100-
alreadyWarnedModified: false,
101-
alreadyWarnedPriority: false,
102-
};
103-
this.images.set(getUrl(rewrittenSrc, this.window!).href, newObservedImageState);
98+
const url = getUrl(rewrittenSrc, this.window!).href;
99+
const existingState = this.images.get(url);
100+
101+
if (existingState) {
102+
// If any instance has priority, the URL is considered to have priority
103+
existingState.priority = existingState.priority || isPriority;
104+
existingState.count++;
105+
} else {
106+
const newObservedImageState: ObservedImageState = {
107+
priority: isPriority,
108+
modified: false,
109+
alreadyWarnedModified: false,
110+
alreadyWarnedPriority: false,
111+
count: 1,
112+
};
113+
this.images.set(url, newObservedImageState);
114+
}
104115
}
105116

106117
unregisterImage(rewrittenSrc: string) {
107118
if (!this.observer) return;
108-
this.images.delete(getUrl(rewrittenSrc, this.window!).href);
119+
const url = getUrl(rewrittenSrc, this.window!).href;
120+
const existingState = this.images.get(url);
121+
122+
if (existingState) {
123+
existingState.count--;
124+
if (existingState.count <= 0) {
125+
this.images.delete(url);
126+
}
127+
}
109128
}
110129

111130
updateImage(originalSrc: string, newSrc: string) {
112131
if (!this.observer) return;
113132
const originalUrl = getUrl(originalSrc, this.window!).href;
114-
const img = this.images.get(originalUrl);
115-
if (img) {
116-
img.modified = true;
117-
this.images.set(getUrl(newSrc, this.window!).href, img);
133+
const newUrl = getUrl(newSrc, this.window!).href;
134+
135+
// URL hasn't changed
136+
if (originalUrl === newUrl) return;
137+
138+
const originalState = this.images.get(originalUrl);
139+
if (!originalState) return;
140+
141+
// Decrement count for original URL
142+
originalState.count--;
143+
if (originalState.count <= 0) {
118144
this.images.delete(originalUrl);
119145
}
146+
147+
// Add or update entry for new URL
148+
const newState = this.images.get(newUrl);
149+
if (newState) {
150+
// Merge if original had priority, new should too
151+
newState.priority = newState.priority || originalState.priority;
152+
newState.modified = true;
153+
// Preserve warning flags from the original state to avoid duplicate warnings
154+
newState.alreadyWarnedPriority =
155+
newState.alreadyWarnedPriority || originalState.alreadyWarnedPriority;
156+
newState.alreadyWarnedModified =
157+
newState.alreadyWarnedModified || originalState.alreadyWarnedModified;
158+
newState.count++;
159+
} else {
160+
// Create new entry, preserving state from the image that moved
161+
this.images.set(newUrl, {
162+
priority: originalState.priority,
163+
modified: true,
164+
alreadyWarnedModified: originalState.alreadyWarnedModified,
165+
alreadyWarnedPriority: originalState.alreadyWarnedPriority,
166+
count: 1,
167+
});
168+
}
120169
}
121170

122171
ngOnDestroy() {

packages/core/test/bundling/image-directive/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ ng_project(
1212
"e2e/image-perf-warnings-lazy/image-perf-warnings-lazy.ts",
1313
"e2e/image-perf-warnings-oversized/image-perf-warnings-oversized.ts",
1414
"e2e/image-perf-warnings-oversized/svg-no-perf-oversized-warnings.ts",
15+
"e2e/lcp-check-duplicate/lcp-check-duplicate.ts",
1516
"e2e/lcp-check/lcp-check.ts",
1617
"e2e/oversized-image/oversized-image.ts",
1718
"e2e/preconnect-check/preconnect-check.ts",
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
/* tslint:disable:no-console */
10+
import {browser, by, element} from 'protractor';
11+
import {logging} from 'selenium-webdriver';
12+
13+
import {collectBrowserLogs} from '../browser-logs-util';
14+
15+
describe('NgOptimizedImage directive', () => {
16+
it('should log a warning when a `priority` is missing on an LCP image', async () => {
17+
await browser.get('/e2e/lcp-check-duplicate');
18+
// Verify that both images were rendered.
19+
const imgs = element.all(by.css('img'));
20+
let srcB = await imgs.get(0).getAttribute('src');
21+
expect(srcB.endsWith('b.png')).toBe(true);
22+
let srcA = await imgs.get(1).getAttribute('src');
23+
expect(srcA.endsWith('a.png')).toBe(true);
24+
// The `b.png` and `a.png` images are used twice in a template.
25+
srcB = await imgs.get(2).getAttribute('src');
26+
expect(srcB.endsWith('b.png')).toBe(true);
27+
srcA = await imgs.get(3).getAttribute('src');
28+
expect(srcA.endsWith('a.png')).toBe(true);
29+
30+
// Make sure that no warnings are in the console for image `a.png`,
31+
// since the first instance has the `priority` attribute, and is the LCP element.
32+
const logs = await collectBrowserLogs(logging.Level.SEVERE);
33+
expect(logs.length).toEqual(0);
34+
});
35+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {NgOptimizedImage} from '@angular/common';
10+
import {Component} from '@angular/core';
11+
12+
@Component({
13+
selector: 'lcp-check',
14+
imports: [NgOptimizedImage],
15+
template: `
16+
<!--
17+
'b.png' should *not* be treated as an LCP element,
18+
since there is a bigger one right below it
19+
-->
20+
<img ngSrc="/e2e/b.png" width="5" height="5" />
21+
22+
<br />
23+
24+
<!-- 'a.png' should be treated as an LCP element, and has priority -->
25+
<img ngSrc="/e2e/a.png" width="2500" height="2500" priority />
26+
27+
<br />
28+
29+
<!--
30+
'b.png' should *not* be treated as an LCP element here
31+
as well, since it's below the fold
32+
-->
33+
<img ngSrc="/e2e/b.png" width="10" height="10" />
34+
35+
<!-- 'a.png' doesn't have priority, and is not the LCP element -->
36+
<img ngSrc="/e2e/a.png" width="1000" height="1000" />
37+
38+
<br />
39+
`,
40+
})
41+
export class LcpCheckDuplicate {}

packages/core/test/bundling/image-directive/e2e/oversized-image/oversized-image.e2e-spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {browser, by, element, ExpectedConditions} from 'protractor';
9+
import {browser} from 'protractor';
1010
import {logging} from 'selenium-webdriver';
1111

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

packages/core/test/bundling/image-directive/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
} from './e2e/oversized-image/oversized-image';
2828
import {PreconnectCheckComponent} from './e2e/preconnect-check/preconnect-check';
2929
import {PlaygroundComponent} from './playground';
30+
import {LcpCheckDuplicate} from './e2e/lcp-check-duplicate/lcp-check-duplicate';
3031

3132
@Component({
3233
selector: 'app-root',
@@ -42,6 +43,7 @@ const ROUTES = [
4243
// Paths below are used for e2e testing:
4344
{path: 'e2e/basic', component: BasicComponent},
4445
{path: 'e2e/lcp-check', component: LcpCheckComponent},
46+
{path: 'e2e/lcp-check-duplicate', component: LcpCheckDuplicate},
4547
{path: 'e2e/image-perf-warnings-lazy', component: ImagePerfWarningsLazyComponent},
4648
{path: 'e2e/image-perf-warnings-oversized', component: ImagePerfWarningsOversizedComponent},
4749
{path: 'e2e/svg-no-perf-oversized-warnings', component: SvgNoOversizedPerfWarningsComponent},

0 commit comments

Comments
 (0)