Skip to content

Commit 57f3386

Browse files
karaPawel Kozlowski
authored andcommitted
feat(common): support custom srcset attributes in NgOptimizedImage (#47082)
This commit adds a `rawSrcset` attribute as a replacement for the `srcset` attribute. The `srcset` attribute cannot be set statically on the image directive because it would cause images to start downloading before the "loading" attribute could be set to "lazy". Changing the name to `rawSrcset` allows the directive to control the timing of image loading. It also makes it possible to support custom loaders for `srcset` file names. Rather than having to repeat the image origin for each image, the existing `rawSrc` value and image loader can be composed to generate each full URL string. The developer must only provide the necessary widths for the `srcset`. For example, the developer might write: ```markup <img rawSrc="hermes.jpg" rawSrcset="100w, 200w" ... /> ``` with a loader like: ```js const loader = (config) => `path/${config.src}?w=${config.width}`; ``` and the img tag will ultimately be set up as something like: ```markup <img src="path/hermes.jpg?w=100" srcset="https://github.com/angular/angular/commit/path/hermes.jpg?w=100 100w, https://github.com/angular/angular/commit/path/hermes.jpg?w=200 200w" .../> ``` PR Close #47082
1 parent ae4405f commit 57f3386

5 files changed

Lines changed: 341 additions & 28 deletions

File tree

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,23 @@
77
// @public
88
export const enum RuntimeErrorCode {
99
// (undocumented)
10-
INVALID_INPUT = 2951,
10+
INVALID_INPUT = 2952,
1111
// (undocumented)
1212
INVALID_PIPE_ARGUMENT = 2100,
1313
// (undocumented)
14-
LCP_IMG_MISSING_PRIORITY = 2954,
14+
LCP_IMG_MISSING_PRIORITY = 2955,
1515
// (undocumented)
1616
NG_FOR_MISSING_DIFFER = -2200,
1717
// (undocumented)
1818
PARENT_NG_SWITCH_NOT_FOUND = 2000,
1919
// (undocumented)
20-
REQUIRED_INPUT_MISSING = 2953,
20+
REQUIRED_INPUT_MISSING = 2954,
2121
// (undocumented)
22-
UNEXPECTED_INPUT_CHANGE = 2952,
22+
UNEXPECTED_INPUT_CHANGE = 2953,
2323
// (undocumented)
24-
UNEXPECTED_SRC_ATTR = 2950
24+
UNEXPECTED_SRC_ATTR = 2950,
25+
// (undocumented)
26+
UNEXPECTED_SRCSET_ATTR = 2951
2527
}
2628

2729
// (No @packageDocumentation comment for this package)

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

Lines changed: 66 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,18 @@ const noopImageLoader = (config: ImageLoaderConfig) => config.src;
4141
*/
4242
const BASE64_IMG_MAX_LENGTH_IN_ERROR = 50;
4343

44+
/**
45+
* RegExpr to determine whether a src in a srcset is using width descriptors.
46+
* Should match something like: "100w, 200w".
47+
*/
48+
const VALID_WIDTH_DESCRIPTOR_SRCSET = /^((\s*\d+w\s*(,|$)){1,})$/;
49+
50+
/**
51+
* RegExpr to determine whether a src in a srcset is using density descriptors.
52+
* Should match something like: "1x, 2x".
53+
*/
54+
const VALID_DENSITY_DESCRIPTOR_SRCSET = /^((\s*\d(\.\d)?x\s*(,|$)){1,})$/;
55+
4456
/**
4557
* Special token that allows to configure a function that will be used to produce an image URL based
4658
* on the specified input.
@@ -105,7 +117,7 @@ class LCPImageObserver implements OnDestroy {
105117
const imgRawSrc = this.images.get(imgSrc);
106118
if (imgRawSrc && !this.alreadyWarned.has(imgSrc)) {
107119
this.alreadyWarned.add(imgSrc);
108-
const directiveDetails = imgDirectiveDetails({rawSrc: imgRawSrc} as any);
120+
const directiveDetails = imgDirectiveDetails(imgRawSrc);
109121
console.warn(formatRuntimeError(
110122
RuntimeErrorCode.LCP_IMG_MISSING_PRIORITY,
111123
`${directiveDetails}: the image was detected as the Largest Contentful Paint (LCP) ` +
@@ -163,6 +175,17 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
163175
*/
164176
@Input() rawSrc!: string;
165177

178+
/**
179+
* A comma separated list of width or density descriptors.
180+
* The image name will be taken from `rawSrc` and combined with the list of width or density
181+
* descriptors to generate the final `srcset` property of the image.
182+
*
183+
* Example:
184+
* <img rawSrc="hello.jpg" rawSrcset="100w, 200w" /> =>
185+
* <img src="path/hello.jpg" srcset="path/hello.jpg?w=100 100w, path/hello.jpg?w=200 200w" />
186+
*/
187+
@Input() rawSrcset!: string;
188+
166189
/**
167190
* The intrinsic width of the image in px.
168191
*/
@@ -201,7 +224,8 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
201224
/**
202225
* Get a value of the `src` and `srcset` if they're set on a host <img> element.
203226
* 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
227+
* at the same time (e.g. `src` and `rawSrc` together or `srcset` and `rawSrcset`,
228+
* thus causing an ambiguity on which src to use) and that images
205229
* don't start to load until a lazy loading strategy is set.
206230
* @internal
207231
*/
@@ -210,7 +234,8 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
210234

211235
ngOnInit() {
212236
if (ngDevMode) {
213-
assertValidRawSrc(this.rawSrc);
237+
assertNonEmptyInput('rawSrc', this.rawSrc);
238+
assertValidRawSrcset(this.rawSrcset);
214239
assertNoConflictingSrc(this);
215240
assertNoConflictingSrcset(this);
216241
assertNotBase64Image(this);
@@ -229,14 +254,18 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
229254
}
230255
this.setHostAttribute('loading', this.getLoadingBehavior());
231256
this.setHostAttribute('fetchpriority', this.getFetchPriority());
232-
// The `src` attribute should be set last since other attributes
257+
// The `src` and `srcset` attributes should be set last since other attributes
233258
// could affect the image's loading behavior.
234259
this.setHostAttribute('src', this.getRewrittenSrc());
260+
if (this.rawSrcset) {
261+
this.setHostAttribute('srcset', this.getRewrittenSrcset());
262+
}
235263
}
236264

237265
ngOnChanges(changes: SimpleChanges) {
238266
if (ngDevMode) {
239-
assertNoPostInitInputChange(this, changes, ['rawSrc', 'width', 'height', 'priority']);
267+
assertNoPostInitInputChange(
268+
this, changes, ['rawSrc', 'rawSrcset', 'width', 'height', 'priority']);
240269
}
241270
}
242271

@@ -256,6 +285,16 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
256285
return this.imageLoader(imgConfig);
257286
}
258287

288+
private getRewrittenSrcset(): string {
289+
const widthSrcSet = VALID_WIDTH_DESCRIPTOR_SRCSET.test(this.rawSrcset);
290+
const finalSrcs = this.rawSrcset.split(',').filter(src => src !== '').map(srcStr => {
291+
srcStr = srcStr.trim();
292+
const width = widthSrcSet ? parseFloat(srcStr) : parseFloat(srcStr) * this.width!;
293+
return `${this.imageLoader({src: this.rawSrc, width})} ${srcStr}`;
294+
});
295+
return finalSrcs.join(', ');
296+
}
297+
259298
ngOnDestroy() {
260299
if (ngDevMode && !this.priority) {
261300
// An image is only registered in dev mode, try to unregister only in dev mode as well.
@@ -329,7 +368,9 @@ function assertNoConflictingSrc(dir: NgOptimizedImage) {
329368
if (dir.src) {
330369
throw new RuntimeError(
331370
RuntimeErrorCode.UNEXPECTED_SRC_ATTR,
332-
`${imgDirectiveDetails(dir.rawSrc)} has detected that the \`src\` is also set (to ` +
371+
`${
372+
imgDirectiveDetails(
373+
dir.rawSrc)} has detected that the \`src\` has already been set (to ` +
333374
`\`${dir.src}\`). Please remove the \`src\` attribute from this image. ` +
334375
`The NgOptimizedImage directive will use the \`rawSrc\` to compute ` +
335376
`the final image URL and set the \`src\` itself.`);
@@ -379,19 +420,35 @@ function assertNotBlobURL(dir: NgOptimizedImage) {
379420
}
380421
}
381422

382-
// Verifies that the `rawSrc` is set to a non-empty string.
383-
function assertValidRawSrc(value: unknown) {
423+
// Verifies that the input is set to a non-empty string.
424+
function assertNonEmptyInput(name: string, value: unknown) {
384425
const isString = typeof value === 'string';
385426
const isEmptyString = isString && value.trim() === '';
386427
if (!isString || isEmptyString) {
387428
const extraMessage = isEmptyString ? ' (empty string)' : '';
388429
throw new RuntimeError(
389430
RuntimeErrorCode.INVALID_INPUT,
390-
`The NgOptimizedImage directive has detected that the \`rawSrc\` has an invalid value: ` +
431+
`The NgOptimizedImage directive has detected that the \`${name}\` has an invalid value: ` +
391432
`expecting a non-empty string, but got: \`${value}\`${extraMessage}.`);
392433
}
393434
}
394435

436+
// Verifies that the `rawSrcset` is in a valid format, e.g. "100w, 200w" or "1x, 2x"
437+
export function assertValidRawSrcset(value: unknown) {
438+
if (value == null) return;
439+
assertNonEmptyInput('rawSrcset', value);
440+
const isValidSrcset = VALID_WIDTH_DESCRIPTOR_SRCSET.test(value as string) ||
441+
VALID_DENSITY_DESCRIPTOR_SRCSET.test(value as string);
442+
443+
if (!isValidSrcset) {
444+
throw new RuntimeError(
445+
RuntimeErrorCode.INVALID_INPUT,
446+
`The NgOptimizedImage directive has detected that the \`rawSrcset\` has an invalid value: ` +
447+
`expecting width descriptors (e.g. "100w, 200w") or density descriptors (e.g. "1x, 2x"), ` +
448+
`but got: \`${value}\``);
449+
}
450+
}
451+
395452
// Creates a `RuntimeError` instance to represent a situation when an input is set after
396453
// the directive has initialized.
397454
function postInitInputChangeError(dir: NgOptimizedImage, inputName: string): {} {

0 commit comments

Comments
 (0)