Skip to content

Commit 1837efb

Browse files
atcastledylhunn
authored andcommittedJul 11, 2023
feat(common): Allow ngSrc to be changed post-init (#50683)
Remove thrown error when ngSrc is modified after an NgOptimizedImage image is initialized PR Close #50683
1 parent c27a1e6 commit 1837efb

File tree

7 files changed

+204
-42
lines changed

7 files changed

+204
-42
lines changed
 

‎goldens/public-api/common/errors.md

+2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ export const enum RuntimeErrorCode {
1515
// (undocumented)
1616
LCP_IMG_MISSING_PRIORITY = 2955,
1717
// (undocumented)
18+
LCP_IMG_NGSRC_MODIFIED = 2964,
19+
// (undocumented)
1820
MISSING_BUILTIN_LOADER = 2962,
1921
// (undocumented)
2022
MISSING_NECESSARY_LOADER = 2963,

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

+44-9
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ import {assertDevMode} from './asserts';
1515
import {imgDirectiveDetails} from './error_helper';
1616
import {getUrl} from './url';
1717

18+
interface ObservedImageState {
19+
priority: boolean;
20+
modified: boolean;
21+
alreadyWarnedPriority: boolean;
22+
alreadyWarnedModified: boolean;
23+
}
24+
1825
/**
1926
* Observer that detects whether an image with `NgOptimizedImage`
2027
* is treated as a Largest Contentful Paint (LCP) element. If so,
@@ -28,9 +35,7 @@ import {getUrl} from './url';
2835
@Injectable({providedIn: 'root'})
2936
export class LCPImageObserver implements OnDestroy {
3037
// Map of full image URLs -> original `ngSrc` values.
31-
private images = new Map<string, string>();
32-
// Keep track of images for which `console.warn` was produced.
33-
private alreadyWarned = new Set<string>();
38+
private images = new Map<string, ObservedImageState>();
3439

3540
private window: Window|null = null;
3641
private observer: PerformanceObserver|null = null;
@@ -65,31 +70,51 @@ export class LCPImageObserver implements OnDestroy {
6570
// Exclude `data:` and `blob:` URLs, since they are not supported by the directive.
6671
if (imgSrc.startsWith('data:') || imgSrc.startsWith('blob:')) return;
6772

68-
const imgNgSrc = this.images.get(imgSrc);
69-
if (imgNgSrc && !this.alreadyWarned.has(imgSrc)) {
70-
this.alreadyWarned.add(imgSrc);
73+
const img = this.images.get(imgSrc);
74+
if (!img) return;
75+
if (!img.priority && !img.alreadyWarnedPriority) {
76+
img.alreadyWarnedPriority = true;
7177
logMissingPriorityWarning(imgSrc);
7278
}
79+
if (img.modified && !img.alreadyWarnedModified) {
80+
img.alreadyWarnedModified = true;
81+
logModifiedWarning(imgSrc);
82+
}
7383
});
7484
observer.observe({type: 'largest-contentful-paint', buffered: true});
7585
return observer;
7686
}
7787

78-
registerImage(rewrittenSrc: string, originalNgSrc: string) {
88+
registerImage(rewrittenSrc: string, originalNgSrc: string, isPriority: boolean) {
7989
if (!this.observer) return;
80-
this.images.set(getUrl(rewrittenSrc, this.window!).href, originalNgSrc);
90+
const newObservedImageState: ObservedImageState = {
91+
priority: isPriority,
92+
modified: false,
93+
alreadyWarnedModified: false,
94+
alreadyWarnedPriority: false
95+
};
96+
this.images.set(getUrl(rewrittenSrc, this.window!).href, newObservedImageState);
8197
}
8298

8399
unregisterImage(rewrittenSrc: string) {
84100
if (!this.observer) return;
85101
this.images.delete(getUrl(rewrittenSrc, this.window!).href);
86102
}
87103

104+
updateImage(originalSrc: string, newSrc: string) {
105+
const originalUrl = getUrl(originalSrc, this.window!).href;
106+
const img = this.images.get(originalUrl);
107+
if (img) {
108+
img.modified = true;
109+
this.images.set(getUrl(newSrc, this.window!).href, img);
110+
this.images.delete(originalUrl);
111+
}
112+
}
113+
88114
ngOnDestroy() {
89115
if (!this.observer) return;
90116
this.observer.disconnect();
91117
this.images.clear();
92-
this.alreadyWarned.clear();
93118
}
94119
}
95120

@@ -102,3 +127,13 @@ function logMissingPriorityWarning(ngSrc: string) {
102127
`"priority" in order to prioritize its loading. ` +
103128
`To fix this, add the "priority" attribute.`));
104129
}
130+
131+
function logModifiedWarning(ngSrc: string) {
132+
const directiveDetails = imgDirectiveDetails(ngSrc);
133+
console.warn(formatRuntimeError(
134+
RuntimeErrorCode.LCP_IMG_NGSRC_MODIFIED,
135+
`${directiveDetails} this image is the Largest Contentful Paint (LCP) ` +
136+
`element and has had its "ngSrc" attribute modified. This can cause ` +
137+
`slower loading performance. It is recommended not to modify the "ngSrc" ` +
138+
`property on any image which could be the LCP element.`));
139+
}

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

+44-26
Original file line numberDiff line numberDiff line change
@@ -362,18 +362,17 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
362362
assertNotMissingBuiltInLoader(this.ngSrc, this.imageLoader);
363363
assertNoNgSrcsetWithoutLoader(this, this.imageLoader);
364364
assertNoLoaderParamsWithoutLoader(this, this.imageLoader);
365+
366+
if (this.lcpObserver !== null) {
367+
const ngZone = this.injector.get(NgZone);
368+
ngZone.runOutsideAngular(() => {
369+
this.lcpObserver!.registerImage(this.getRewrittenSrc(), this.ngSrc, this.priority);
370+
});
371+
}
372+
365373
if (this.priority) {
366374
const checker = this.injector.get(PreconnectLinkChecker);
367375
checker.assertPreconnect(this.getRewrittenSrc(), this.ngSrc);
368-
} else {
369-
// Monitor whether an image is an LCP element only in case
370-
// the `priority` attribute is missing. Otherwise, an image
371-
// has the necessary settings and no extra checks are required.
372-
if (this.lcpObserver !== null) {
373-
ngZone.runOutsideAngular(() => {
374-
this.lcpObserver!.registerImage(this.getRewrittenSrc(), this.ngSrc);
375-
});
376-
}
377376
}
378377
}
379378
this.setHostAttributes();
@@ -400,36 +399,21 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
400399

401400
// The `src` and `srcset` attributes should be set last since other attributes
402401
// could affect the image's loading behavior.
403-
const rewrittenSrc = this.getRewrittenSrc();
404-
this.setHostAttribute('src', rewrittenSrc);
405-
406-
let rewrittenSrcset: string|undefined = undefined;
402+
const rewrittenSrcset = this.updateSrcAndSrcset();
407403

408404
if (this.sizes) {
409405
this.setHostAttribute('sizes', this.sizes);
410406
}
411-
412-
if (this.ngSrcset) {
413-
rewrittenSrcset = this.getRewrittenSrcset();
414-
} else if (this.shouldGenerateAutomaticSrcset()) {
415-
rewrittenSrcset = this.getAutomaticSrcset();
416-
}
417-
418-
if (rewrittenSrcset) {
419-
this.setHostAttribute('srcset', rewrittenSrcset);
420-
}
421-
422407
if (this.isServer && this.priority) {
423408
this.preloadLinkCreator.createPreloadLinkTag(
424-
this.renderer, rewrittenSrc, rewrittenSrcset, this.sizes);
409+
this.renderer, this.getRewrittenSrc(), rewrittenSrcset, this.sizes);
425410
}
426411
}
427412

428413
/** @nodoc */
429414
ngOnChanges(changes: SimpleChanges) {
430415
if (ngDevMode) {
431416
assertNoPostInitInputChange(this, changes, [
432-
'ngSrc',
433417
'ngSrcset',
434418
'width',
435419
'height',
@@ -441,6 +425,17 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
441425
'disableOptimizedSrcset',
442426
]);
443427
}
428+
if (changes['ngSrc'] && !changes['ngSrc'].isFirstChange()) {
429+
const oldSrc = this._renderedSrc;
430+
this.updateSrcAndSrcset(true);
431+
const newSrc = this._renderedSrc;
432+
if (this.lcpObserver !== null && oldSrc && newSrc && oldSrc !== newSrc) {
433+
const ngZone = this.injector.get(NgZone);
434+
ngZone.runOutsideAngular(() => {
435+
this.lcpObserver?.updateImage(oldSrc, newSrc);
436+
});
437+
}
438+
}
444439
}
445440

446441
private callImageLoader(configWithoutCustomParams: Omit<ImageLoaderConfig, 'loaderParams'>):
@@ -508,6 +503,29 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
508503
return finalSrcs.join(', ');
509504
}
510505

506+
private updateSrcAndSrcset(forceSrcRecalc = false): string|undefined {
507+
if (forceSrcRecalc) {
508+
// Reset cached value, so that the followup `getRewrittenSrc()` call
509+
// will recalculate it and update the cache.
510+
this._renderedSrc = null;
511+
}
512+
513+
const rewrittenSrc = this.getRewrittenSrc();
514+
this.setHostAttribute('src', rewrittenSrc);
515+
516+
let rewrittenSrcset: string|undefined = undefined;
517+
if (this.ngSrcset) {
518+
rewrittenSrcset = this.getRewrittenSrcset();
519+
} else if (this.shouldGenerateAutomaticSrcset()) {
520+
rewrittenSrcset = this.getAutomaticSrcset();
521+
}
522+
523+
if (rewrittenSrcset) {
524+
this.setHostAttribute('srcset', rewrittenSrcset);
525+
}
526+
return rewrittenSrcset;
527+
}
528+
511529
private getFixedSrcset(): string {
512530
const finalSrcs = DENSITY_SRCSET_MULTIPLIERS.map(multiplier => `${this.callImageLoader({
513531
src: this.ngSrc,

‎packages/common/src/errors.ts

+1
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,5 @@ export const enum RuntimeErrorCode {
3636
TOO_MANY_PRELOADED_IMAGES = 2961,
3737
MISSING_BUILTIN_LOADER = 2962,
3838
MISSING_NECESSARY_LOADER = 2963,
39+
LCP_IMG_NGSRC_MODIFIED = 2964,
3940
}

‎packages/common/test/directives/ng_optimized_image_spec.ts

+100-3
Original file line numberDiff line numberDiff line change
@@ -642,9 +642,8 @@ describe('Image directive', () => {
642642
});
643643

644644
const inputs = [
645-
['ngSrc', 'new-img.png'], ['width', 10], ['height', 20], ['priority', true], ['fill', true],
646-
['loading', true], ['sizes', '90vw'], ['disableOptimizedSrcset', true],
647-
['loaderParams', '{foo: "test1"}']
645+
['width', 10], ['height', 20], ['priority', true], ['fill', true], ['loading', true],
646+
['sizes', '90vw'], ['disableOptimizedSrcset', true], ['loaderParams', '{foo: "test1"}']
648647
];
649648
inputs.forEach(([inputName, value]) => {
650649
it(`should throw if the \`${inputName}\` input changed after directive initialized the input`,
@@ -692,6 +691,35 @@ describe('Image directive', () => {
692691
}).toThrowError(new RegExp(expectedErrorMessage));
693692
});
694693
});
694+
it(`should not throw if ngSrc changed after directive is initialized`, () => {
695+
@Component({
696+
selector: 'test-cmp',
697+
template: `<img
698+
[ngSrc]="ngSrc"
699+
[width]="width"
700+
[height]="height"
701+
[loading]="loading"
702+
[sizes]="sizes"
703+
>`
704+
})
705+
class TestComponent {
706+
width = 100;
707+
height = 50;
708+
ngSrc = 'img.png';
709+
loading = false;
710+
sizes = '100vw';
711+
}
712+
713+
setupTestingModule({component: TestComponent});
714+
715+
// Initial render
716+
const fixture = TestBed.createComponent(TestComponent);
717+
fixture.detectChanges();
718+
expect(() => {
719+
fixture.componentInstance.ngSrc = 'newImg.png';
720+
fixture.detectChanges();
721+
}).not.toThrowError(new RegExp('was updated after initialization'));
722+
});
695723
});
696724

697725
describe('lazy loading', () => {
@@ -1259,6 +1287,75 @@ describe('Image directive', () => {
12591287
expect(imgs[1].src.trim()).toBe(`${IMG_BASE_URL}/img-2.png`);
12601288
});
12611289

1290+
it('should use the image loader to update `src` if `ngSrc` updated', () => {
1291+
@Component({
1292+
selector: 'test-cmp',
1293+
template: `<img
1294+
[ngSrc]="ngSrc"
1295+
width="300"
1296+
height="300"
1297+
>`
1298+
})
1299+
class TestComponent {
1300+
ngSrc = `img.png`;
1301+
}
1302+
const imageLoader = (config: ImageLoaderConfig) => `${IMG_BASE_URL}/${config.src}`;
1303+
setupTestingModule({imageLoader, component: TestComponent});
1304+
const fixture = TestBed.createComponent(TestComponent);
1305+
fixture.detectChanges();
1306+
1307+
let nativeElement = fixture.nativeElement as HTMLElement;
1308+
let imgs = nativeElement.querySelectorAll('img')!;
1309+
expect(imgs[0].src).toBe(`${IMG_BASE_URL}/img.png`);
1310+
1311+
fixture.componentInstance.ngSrc = 'updatedImg.png';
1312+
fixture.detectChanges();
1313+
expect(imgs[0].src).toBe(`${IMG_BASE_URL}/updatedImg.png`);
1314+
});
1315+
1316+
it('should use the image loader to update `srcset` if `ngSrc` updated', () => {
1317+
@Component({
1318+
selector: 'test-cmp',
1319+
template: `<img
1320+
[ngSrc]="ngSrc"
1321+
width="300"
1322+
height="300"
1323+
sizes="100vw"
1324+
>`
1325+
})
1326+
class TestComponent {
1327+
ngSrc = `img.png`;
1328+
}
1329+
const imageLoader = (config: ImageLoaderConfig) => {
1330+
const width = config.width ? `?w=${config.width}` : ``;
1331+
return `${IMG_BASE_URL}/${config.src}${width}`;
1332+
};
1333+
setupTestingModule({imageLoader, component: TestComponent});
1334+
const fixture = TestBed.createComponent(TestComponent);
1335+
fixture.detectChanges();
1336+
1337+
let nativeElement = fixture.nativeElement as HTMLElement;
1338+
let imgs = nativeElement.querySelectorAll('img')!;
1339+
expect(imgs[0].getAttribute('srcset'))
1340+
.toBe(`${IMG_BASE_URL}/img.png?w=640 640w, ${IMG_BASE_URL}/img.png?w=750 750w, ${
1341+
IMG_BASE_URL}/img.png?w=828 828w, ${IMG_BASE_URL}/img.png?w=1080 1080w, ${
1342+
IMG_BASE_URL}/img.png?w=1200 1200w, ${IMG_BASE_URL}/img.png?w=1920 1920w, ${
1343+
IMG_BASE_URL}/img.png?w=2048 2048w, ${IMG_BASE_URL}/img.png?w=3840 3840w`);
1344+
1345+
fixture.componentInstance.ngSrc = 'updatedImg.png';
1346+
nativeElement = fixture.nativeElement as HTMLElement;
1347+
imgs = nativeElement.querySelectorAll('img')!;
1348+
fixture.detectChanges();
1349+
expect(imgs[0].getAttribute('srcset'))
1350+
.toBe(`${IMG_BASE_URL}/updatedImg.png?w=640 640w, ${
1351+
IMG_BASE_URL}/updatedImg.png?w=750 750w, ${IMG_BASE_URL}/updatedImg.png?w=828 828w, ${
1352+
IMG_BASE_URL}/updatedImg.png?w=1080 1080w, ${
1353+
IMG_BASE_URL}/updatedImg.png?w=1200 1200w, ${
1354+
IMG_BASE_URL}/updatedImg.png?w=1920 1920w, ${
1355+
IMG_BASE_URL}/updatedImg.png?w=2048 2048w, ${
1356+
IMG_BASE_URL}/updatedImg.png?w=3840 3840w`);
1357+
});
1358+
12621359
it('should pass absolute URLs defined in the `ngSrc` to custom image loaders provided via the `IMAGE_LOADER` token',
12631360
() => {
12641361
const imageLoader = (config: ImageLoaderConfig) => `${config.src}?rewritten=true`;

‎packages/core/test/bundling/image-directive/e2e/lcp-check/lcp-check.e2e-spec.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -15,22 +15,24 @@ import {collectBrowserLogs} from '../browser-logs-util';
1515
describe('NgOptimizedImage directive', () => {
1616
it('should log a warning when a `priority` is missing on an LCP image', async () => {
1717
await browser.get('/e2e/lcp-check');
18-
18+
// Wait for ngSrc to be modified
19+
await new Promise(resolve => setTimeout(resolve, 600));
1920
// Verify that both images were rendered.
2021
const imgs = element.all(by.css('img'));
2122
let srcB = await imgs.get(0).getAttribute('src');
2223
expect(srcB.endsWith('b.png')).toBe(true);
2324
const srcA = await imgs.get(1).getAttribute('src');
24-
expect(srcA.endsWith('a.png')).toBe(true);
25+
expect(srcA.endsWith('logo-500w.jpg')).toBe(true);
2526
// The `b.png` image is used twice in a template.
2627
srcB = await imgs.get(2).getAttribute('src');
2728
expect(srcB.endsWith('b.png')).toBe(true);
2829

2930
// Make sure that only one warning is in the console for image `a.png`,
3031
// since the `b.png` should be below the fold and not treated as an LCP element.
3132
const logs = await collectBrowserLogs(logging.Level.WARNING);
32-
expect(logs.length).toEqual(1);
33+
expect(logs.length).toEqual(2);
3334
// Verify that the error code and the image src are present in the error message.
3435
expect(logs[0].message).toMatch(/NG02955.*?a\.png/);
36+
expect(logs[1].message).toMatch(/NG02964.*?logo-500w\.jpg/);
3537
});
3638
});

‎packages/core/test/bundling/image-directive/e2e/lcp-check/lcp-check.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {Component} from '@angular/core';
2323
<br>
2424
2525
<!-- 'a.png' should be treated as an LCP element -->
26-
<img ngSrc="/e2e/a.png" width="2500" height="2500">
26+
<img [ngSrc]=imageSrc width="2500" height="2500">
2727
2828
<br>
2929
@@ -35,4 +35,11 @@ import {Component} from '@angular/core';
3535
`,
3636
})
3737
export class LcpCheckComponent {
38+
imageSrc = '/e2e/a.png';
39+
40+
ngOnInit() {
41+
setTimeout(() => {
42+
this.imageSrc = '/e2e/logo-500w.jpg';
43+
}, 500);
44+
}
3845
}

0 commit comments

Comments
 (0)
Please sign in to comment.