Skip to content

Commit ae4405f

Browse files
karaPawel Kozlowski
authored andcommitted
fix(common): throw if srcset is used with rawSrc (#47082)
Currently if you use a static `srcset` with the image directive, lazy loading no longer works because the image would start to load before the loading attribute could be set to "lazy". This commit throws an error if you try to use `srcset` this way. In a follow-up commit, a new attribute will be added to support responsive images in a lazy-loading-friendly way. PR Close #47082
1 parent bde319e commit ae4405f

File tree

4 files changed

+97
-44
lines changed

4 files changed

+97
-44
lines changed

packages/common/src/directives/ng_optimized_image.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -199,17 +199,20 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
199199
}
200200

201201
/**
202-
* Get a value of the `src` if it's set on a host <img> element.
203-
* This input is needed to verify that there are no `src` and `rawSrc` provided
204-
* at the same time (thus causing an ambiguity on which src to use).
202+
* Get a value of the `src` and `srcset` if they're set on a host <img> element.
203+
* These inputs are needed to verify that there are no conflicting sources provided
204+
* at the same time (thus causing an ambiguity on which src to use) and that images
205+
* don't start to load until a lazy loading strategy is set.
205206
* @internal
206207
*/
207208
@Input() src?: string;
209+
@Input() srcset?: string;
208210

209211
ngOnInit() {
210212
if (ngDevMode) {
211213
assertValidRawSrc(this.rawSrc);
212214
assertNoConflictingSrc(this);
215+
assertNoConflictingSrcset(this);
213216
assertNotBase64Image(this);
214217
assertNotBlobURL(this);
215218
assertRequiredNumberInput(this, this.width, 'width');
@@ -314,9 +317,9 @@ function withLCPImageObserver(
314317
});
315318
}
316319

317-
function imgDirectiveDetails(dir: NgOptimizedImage) {
320+
function imgDirectiveDetails(rawSrc: string) {
318321
return `The NgOptimizedImage directive (activated on an <img> element ` +
319-
`with the \`rawSrc="${dir.rawSrc}"\`)`;
322+
`with the \`rawSrc="${rawSrc}"\`)`;
320323
}
321324

322325
/***** Assert functions *****/
@@ -326,13 +329,25 @@ function assertNoConflictingSrc(dir: NgOptimizedImage) {
326329
if (dir.src) {
327330
throw new RuntimeError(
328331
RuntimeErrorCode.UNEXPECTED_SRC_ATTR,
329-
`${imgDirectiveDetails(dir)} has detected that the \`src\` is also set (to ` +
332+
`${imgDirectiveDetails(dir.rawSrc)} has detected that the \`src\` is also set (to ` +
330333
`\`${dir.src}\`). Please remove the \`src\` attribute from this image. ` +
331334
`The NgOptimizedImage directive will use the \`rawSrc\` to compute ` +
332335
`the final image URL and set the \`src\` itself.`);
333336
}
334337
}
335338

339+
// Verifies that there is no `srcset` set on a host element.
340+
function assertNoConflictingSrcset(dir: NgOptimizedImage) {
341+
if (dir.srcset) {
342+
throw new RuntimeError(
343+
RuntimeErrorCode.UNEXPECTED_SRCSET_ATTR,
344+
`${imgDirectiveDetails(dir.rawSrc)} has detected that the \`srcset\` has been set. ` +
345+
`Please replace the \`srcset\` attribute from this image with \`rawSrcset\`. ` +
346+
`The NgOptimizedImage directive uses \`rawSrcset\` to set the \`srcset\` attribute` +
347+
`at a time that does not disrupt lazy loading.`);
348+
}
349+
}
350+
336351
// Verifies that the `rawSrc` is not a Base64-encoded image.
337352
function assertNotBase64Image(dir: NgOptimizedImage) {
338353
let rawSrc = dir.rawSrc.trim();
@@ -382,7 +397,8 @@ function assertValidRawSrc(value: unknown) {
382397
function postInitInputChangeError(dir: NgOptimizedImage, inputName: string): {} {
383398
return new RuntimeError(
384399
RuntimeErrorCode.UNEXPECTED_INPUT_CHANGE,
385-
`${imgDirectiveDetails(dir)} has detected that the \`${inputName}\` is updated after the ` +
400+
`${imgDirectiveDetails(dir.rawSrc)} has detected that the \`${
401+
inputName}\` is updated after the ` +
386402
`initialization. The NgOptimizedImage directive will not react to this input change.`);
387403
}
388404

@@ -422,7 +438,7 @@ function assertRequiredNumberInput(dir: NgOptimizedImage, inputValue: unknown, i
422438
if (typeof inputValue === 'undefined') {
423439
throw new RuntimeError(
424440
RuntimeErrorCode.REQUIRED_INPUT_MISSING,
425-
`${imgDirectiveDetails(dir)} has detected that the required \`${inputName}\` ` +
441+
`${imgDirectiveDetails(dir.rawSrc)} has detected that the required \`${inputName}\` ` +
426442
`attribute is missing. Please specify the \`${inputName}\` attribute ` +
427443
`on the mentioned element.`);
428444
}

packages/common/src/errors.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@ export const enum RuntimeErrorCode {
2222

2323
// Image directive errors
2424
UNEXPECTED_SRC_ATTR = 2950,
25-
INVALID_INPUT = 2951,
26-
UNEXPECTED_INPUT_CHANGE = 2952,
27-
REQUIRED_INPUT_MISSING = 2953,
28-
LCP_IMG_MISSING_PRIORITY = 2954,
25+
UNEXPECTED_SRCSET_ATTR = 2951,
26+
INVALID_INPUT = 2952,
27+
UNEXPECTED_INPUT_CHANGE = 2953,
28+
REQUIRED_INPUT_MISSING = 2954,
29+
LCP_IMG_MISSING_PRIORITY = 2955,
2930
}

packages/common/test/directives/ng_optimized_image_spec.ts

Lines changed: 68 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import {CommonModule, DOCUMENT} from '@angular/common';
1010
import {IMAGE_LOADER, ImageLoader, ImageLoaderConfig, NgOptimizedImageModule} from '@angular/common/src/directives/ng_optimized_image';
11+
import {RuntimeErrorCode} from '@angular/common/src/errors';
1112
import {Component} from '@angular/core';
1213
import {ComponentFixture, TestBed} from '@angular/core/testing';
1314
import {expect} from '@angular/platform-browser/testing/src/matchers';
@@ -73,12 +74,33 @@ describe('Image directive', () => {
7374
fixture.detectChanges();
7475
})
7576
.toThrowError(
76-
'NG02950: The NgOptimizedImage directive (activated on an <img> element with the ' +
77+
`NG0${
78+
RuntimeErrorCode
79+
.UNEXPECTED_SRC_ATTR}: The NgOptimizedImage directive (activated on an <img> element with the ` +
7780
'`rawSrc="path/img.png"`) has detected that the `src` is also set (to `path/img2.png`). ' +
7881
'Please remove the `src` attribute from this image. The NgOptimizedImage directive will use ' +
7982
'the `rawSrc` to compute the final image URL and set the `src` itself.');
8083
});
8184

85+
it('should throw if both `rawSrc` and `srcset` is present', () => {
86+
setupTestingModule();
87+
88+
const template =
89+
'<img rawSrc="img-100.png" srcset="img-100.png 100w, img-200.png 200w" width="100" height="50">';
90+
expect(() => {
91+
const fixture = createTestComponent(template);
92+
fixture.detectChanges();
93+
})
94+
.toThrowError(
95+
`NG0${
96+
RuntimeErrorCode
97+
.UNEXPECTED_SRCSET_ATTR}: The NgOptimizedImage directive (activated on an <img> element with the ` +
98+
'`rawSrc="img-100.png"`) has detected that the `srcset` has been set. ' +
99+
'Please replace the `srcset` attribute from this image with `rawSrcset`. ' +
100+
'The NgOptimizedImage directive uses `rawSrcset` to set the `srcset` attribute' +
101+
'at a time that does not disrupt lazy loading.');
102+
});
103+
82104
it('should throw if `rawSrc` contains a Base64-encoded image (that starts with `data:`)', () => {
83105
setupTestingModule();
84106

@@ -88,7 +110,9 @@ describe('Image directive', () => {
88110
fixture.detectChanges();
89111
})
90112
.toThrowError(
91-
'NG02951: The NgOptimizedImage directive has detected that the `rawSrc` was set ' +
113+
`NG0${
114+
RuntimeErrorCode
115+
.INVALID_INPUT}: The NgOptimizedImage directive has detected that the \`rawSrc\` was set ` +
92116
'to a Base64-encoded string (' + ANGULAR_LOGO_BASE64.substring(0, 50) + '...). ' +
93117
'Base64-encoded strings are not supported by the NgOptimizedImage directive. ' +
94118
'Use a regular `src` attribute (instead of `rawSrc`) to disable the NgOptimizedImage ' +
@@ -112,7 +136,7 @@ describe('Image directive', () => {
112136
// Note: use RegExp to partially match the error message, since the blob URL
113137
// is created dynamically, so it might be different for each invocation.
114138
const errorMessageRegExp =
115-
/NG02951: The NgOptimizedImage directive has detected that the `rawSrc` was set to a blob URL \(blob:/;
139+
/NG02952: The NgOptimizedImage directive has detected that the `rawSrc` was set to a blob URL \(blob:/;
116140
expect(() => {
117141
const template = '<img rawSrc="' + blobURL + '" width="50" height="50">';
118142
const fixture = createTestComponent(template);
@@ -131,7 +155,9 @@ describe('Image directive', () => {
131155
fixture.detectChanges();
132156
})
133157
.toThrowError(
134-
'NG02953: The NgOptimizedImage directive (activated on an <img> ' +
158+
`NG0${
159+
RuntimeErrorCode
160+
.REQUIRED_INPUT_MISSING}: The NgOptimizedImage directive (activated on an <img> ` +
135161
'element with the `rawSrc="img.png"`) has detected that the required ' +
136162
'`width` attribute is missing. Please specify the `width` attribute ' +
137163
'on the mentioned element.');
@@ -146,7 +172,9 @@ describe('Image directive', () => {
146172
fixture.detectChanges();
147173
})
148174
.toThrowError(
149-
'NG02951: The NgOptimizedImage directive has detected that the `width` ' +
175+
`NG0${
176+
RuntimeErrorCode
177+
.INVALID_INPUT}: The NgOptimizedImage directive has detected that the \`width\` ` +
150178
'has an invalid value: expecting a number that represents the width ' +
151179
'in pixels, but got: `10px`.');
152180
});
@@ -160,7 +188,9 @@ describe('Image directive', () => {
160188
fixture.detectChanges();
161189
})
162190
.toThrowError(
163-
'NG02953: The NgOptimizedImage directive (activated on an <img> ' +
191+
`NG0${
192+
RuntimeErrorCode
193+
.REQUIRED_INPUT_MISSING}: The NgOptimizedImage directive (activated on an <img> ` +
164194
'element with the `rawSrc="img.png"`) has detected that the required ' +
165195
'`height` attribute is missing. Please specify the `height` attribute ' +
166196
'on the mentioned element.');
@@ -175,7 +205,9 @@ describe('Image directive', () => {
175205
fixture.detectChanges();
176206
})
177207
.toThrowError(
178-
'NG02951: The NgOptimizedImage directive has detected that the `height` ' +
208+
`NG0${
209+
RuntimeErrorCode
210+
.INVALID_INPUT}: The NgOptimizedImage directive has detected that the \`height\` ` +
179211
'has an invalid value: expecting a number that represents the height ' +
180212
'in pixels, but got: `10%`.');
181213
});
@@ -189,7 +221,9 @@ describe('Image directive', () => {
189221
fixture.detectChanges();
190222
})
191223
.toThrowError(
192-
'NG02951: The NgOptimizedImage directive has detected that the `rawSrc` ' +
224+
`NG0${
225+
RuntimeErrorCode
226+
.INVALID_INPUT}: The NgOptimizedImage directive has detected that the \`rawSrc\` ` +
193227
'has an invalid value: expecting a non-empty string, but got: `` (empty string).');
194228
});
195229

@@ -202,7 +236,9 @@ describe('Image directive', () => {
202236
fixture.detectChanges();
203237
})
204238
.toThrowError(
205-
'NG02951: The NgOptimizedImage directive has detected that the `rawSrc` ' +
239+
`NG0${
240+
RuntimeErrorCode
241+
.INVALID_INPUT}: The NgOptimizedImage directive has detected that the \`rawSrc\` ` +
206242
'has an invalid value: expecting a non-empty string, but got: ` ` (empty string).');
207243
});
208244

@@ -213,28 +249,29 @@ describe('Image directive', () => {
213249
['priority', true]
214250
];
215251
inputs.forEach(([inputName, value]) => {
216-
it(`should throw if inputs got changed after directive init (the \`${inputName}\` input)`,
217-
() => {
218-
setupTestingModule();
219-
220-
const template =
221-
'<img [rawSrc]="rawSrc" [width]="width" [height]="height" [priority]="priority">';
222-
expect(() => {
223-
// Initial render
224-
const fixture = createTestComponent(template);
225-
fixture.detectChanges();
226-
227-
// Update input (expect to throw)
228-
(fixture.componentInstance as unknown as
229-
{[key: string]: unknown})[inputName as string] = value;
230-
fixture.detectChanges();
231-
})
232-
.toThrowError(
233-
`NG02952: The NgOptimizedImage directive (activated on an <img> element ` +
234-
`with the \`rawSrc="img.png"\`) has detected that the \`${inputName}\` is ` +
235-
`updated after the initialization. The NgOptimizedImage directive will not ` +
236-
`react to this input change.`);
237-
});
252+
it(`should throw if inputs got changed after directive init (the \`${inputName}\` input)`, () => {
253+
setupTestingModule();
254+
255+
const template =
256+
'<img [rawSrc]="rawSrc" [width]="width" [height]="height" [priority]="priority">';
257+
expect(() => {
258+
// Initial render
259+
const fixture = createTestComponent(template);
260+
fixture.detectChanges();
261+
262+
// Update input (expect to throw)
263+
(fixture.componentInstance as unknown as {[key: string]: unknown})[inputName as string] =
264+
value;
265+
fixture.detectChanges();
266+
})
267+
.toThrowError(
268+
`NG0${
269+
RuntimeErrorCode
270+
.UNEXPECTED_INPUT_CHANGE}: The NgOptimizedImage directive (activated on an <img> element ` +
271+
`with the \`rawSrc="img.png"\`) has detected that the \`${inputName}\` is ` +
272+
`updated after the initialization. The NgOptimizedImage directive will not ` +
273+
`react to this input change.`);
274+
});
238275
});
239276
});
240277

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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import {PlaygroundComponent} from './playground';
1919
standalone: true,
2020
imports: [RouterModule],
2121
template: '<router-outlet></router-outlet>',
22-
2322
})
2423
export class RootComponent {
2524
}

0 commit comments

Comments
 (0)