Skip to content

Commit

Permalink
feat(common): support custom srcset attributes in NgOptimizedImage (#…
Browse files Browse the repository at this point in the history
…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="path/hermes.jpg?w=100 100w, path/hermes.jpg?w=200 200w" .../>
```

PR Close #47082
  • Loading branch information
kara authored and Pawel Kozlowski committed Aug 16, 2022
1 parent ae4405f commit 57f3386
Show file tree
Hide file tree
Showing 5 changed files with 341 additions and 28 deletions.
12 changes: 7 additions & 5 deletions goldens/public-api/common/errors.md
Expand Up @@ -7,21 +7,23 @@
// @public
export const enum RuntimeErrorCode {
// (undocumented)
INVALID_INPUT = 2951,
INVALID_INPUT = 2952,
// (undocumented)
INVALID_PIPE_ARGUMENT = 2100,
// (undocumented)
LCP_IMG_MISSING_PRIORITY = 2954,
LCP_IMG_MISSING_PRIORITY = 2955,
// (undocumented)
NG_FOR_MISSING_DIFFER = -2200,
// (undocumented)
PARENT_NG_SWITCH_NOT_FOUND = 2000,
// (undocumented)
REQUIRED_INPUT_MISSING = 2953,
REQUIRED_INPUT_MISSING = 2954,
// (undocumented)
UNEXPECTED_INPUT_CHANGE = 2952,
UNEXPECTED_INPUT_CHANGE = 2953,
// (undocumented)
UNEXPECTED_SRC_ATTR = 2950
UNEXPECTED_SRC_ATTR = 2950,
// (undocumented)
UNEXPECTED_SRCSET_ATTR = 2951
}

// (No @packageDocumentation comment for this package)
Expand Down
75 changes: 66 additions & 9 deletions packages/common/src/directives/ng_optimized_image.ts
Expand Up @@ -41,6 +41,18 @@ const noopImageLoader = (config: ImageLoaderConfig) => config.src;
*/
const BASE64_IMG_MAX_LENGTH_IN_ERROR = 50;

/**
* RegExpr to determine whether a src in a srcset is using width descriptors.
* Should match something like: "100w, 200w".
*/
const VALID_WIDTH_DESCRIPTOR_SRCSET = /^((\s*\d+w\s*(,|$)){1,})$/;

/**
* RegExpr to determine whether a src in a srcset is using density descriptors.
* Should match something like: "1x, 2x".
*/
const VALID_DENSITY_DESCRIPTOR_SRCSET = /^((\s*\d(\.\d)?x\s*(,|$)){1,})$/;

/**
* Special token that allows to configure a function that will be used to produce an image URL based
* on the specified input.
Expand Down Expand Up @@ -105,7 +117,7 @@ class LCPImageObserver implements OnDestroy {
const imgRawSrc = this.images.get(imgSrc);
if (imgRawSrc && !this.alreadyWarned.has(imgSrc)) {
this.alreadyWarned.add(imgSrc);
const directiveDetails = imgDirectiveDetails({rawSrc: imgRawSrc} as any);
const directiveDetails = imgDirectiveDetails(imgRawSrc);
console.warn(formatRuntimeError(
RuntimeErrorCode.LCP_IMG_MISSING_PRIORITY,
`${directiveDetails}: the image was detected as the Largest Contentful Paint (LCP) ` +
Expand Down Expand Up @@ -163,6 +175,17 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
*/
@Input() rawSrc!: string;

/**
* A comma separated list of width or density descriptors.
* The image name will be taken from `rawSrc` and combined with the list of width or density
* descriptors to generate the final `srcset` property of the image.
*
* Example:
* <img rawSrc="hello.jpg" rawSrcset="100w, 200w" /> =>
* <img src="path/hello.jpg" srcset="path/hello.jpg?w=100 100w, path/hello.jpg?w=200 200w" />
*/
@Input() rawSrcset!: string;

/**
* The intrinsic width of the image in px.
*/
Expand Down Expand Up @@ -201,7 +224,8 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
/**
* Get a value of the `src` and `srcset` if they're set on a host <img> element.
* These inputs are needed to verify that there are no conflicting sources provided
* at the same time (thus causing an ambiguity on which src to use) and that images
* at the same time (e.g. `src` and `rawSrc` together or `srcset` and `rawSrcset`,
* thus causing an ambiguity on which src to use) and that images
* don't start to load until a lazy loading strategy is set.
* @internal
*/
Expand All @@ -210,7 +234,8 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {

ngOnInit() {
if (ngDevMode) {
assertValidRawSrc(this.rawSrc);
assertNonEmptyInput('rawSrc', this.rawSrc);
assertValidRawSrcset(this.rawSrcset);
assertNoConflictingSrc(this);
assertNoConflictingSrcset(this);
assertNotBase64Image(this);
Expand All @@ -229,14 +254,18 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
}
this.setHostAttribute('loading', this.getLoadingBehavior());
this.setHostAttribute('fetchpriority', this.getFetchPriority());
// The `src` attribute should be set last since other attributes
// The `src` and `srcset` attributes should be set last since other attributes
// could affect the image's loading behavior.
this.setHostAttribute('src', this.getRewrittenSrc());
if (this.rawSrcset) {
this.setHostAttribute('srcset', this.getRewrittenSrcset());
}
}

ngOnChanges(changes: SimpleChanges) {
if (ngDevMode) {
assertNoPostInitInputChange(this, changes, ['rawSrc', 'width', 'height', 'priority']);
assertNoPostInitInputChange(
this, changes, ['rawSrc', 'rawSrcset', 'width', 'height', 'priority']);
}
}

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

private getRewrittenSrcset(): string {
const widthSrcSet = VALID_WIDTH_DESCRIPTOR_SRCSET.test(this.rawSrcset);
const finalSrcs = this.rawSrcset.split(',').filter(src => src !== '').map(srcStr => {
srcStr = srcStr.trim();
const width = widthSrcSet ? parseFloat(srcStr) : parseFloat(srcStr) * this.width!;
return `${this.imageLoader({src: this.rawSrc, width})} ${srcStr}`;
});
return finalSrcs.join(', ');
}

ngOnDestroy() {
if (ngDevMode && !this.priority) {
// An image is only registered in dev mode, try to unregister only in dev mode as well.
Expand Down Expand Up @@ -329,7 +368,9 @@ function assertNoConflictingSrc(dir: NgOptimizedImage) {
if (dir.src) {
throw new RuntimeError(
RuntimeErrorCode.UNEXPECTED_SRC_ATTR,
`${imgDirectiveDetails(dir.rawSrc)} has detected that the \`src\` is also set (to ` +
`${
imgDirectiveDetails(
dir.rawSrc)} has detected that the \`src\` has already been set (to ` +
`\`${dir.src}\`). Please remove the \`src\` attribute from this image. ` +
`The NgOptimizedImage directive will use the \`rawSrc\` to compute ` +
`the final image URL and set the \`src\` itself.`);
Expand Down Expand Up @@ -379,19 +420,35 @@ function assertNotBlobURL(dir: NgOptimizedImage) {
}
}

// Verifies that the `rawSrc` is set to a non-empty string.
function assertValidRawSrc(value: unknown) {
// Verifies that the input is set to a non-empty string.
function assertNonEmptyInput(name: string, value: unknown) {
const isString = typeof value === 'string';
const isEmptyString = isString && value.trim() === '';
if (!isString || isEmptyString) {
const extraMessage = isEmptyString ? ' (empty string)' : '';
throw new RuntimeError(
RuntimeErrorCode.INVALID_INPUT,
`The NgOptimizedImage directive has detected that the \`rawSrc\` has an invalid value: ` +
`The NgOptimizedImage directive has detected that the \`${name}\` has an invalid value: ` +
`expecting a non-empty string, but got: \`${value}\`${extraMessage}.`);
}
}

// Verifies that the `rawSrcset` is in a valid format, e.g. "100w, 200w" or "1x, 2x"
export function assertValidRawSrcset(value: unknown) {
if (value == null) return;
assertNonEmptyInput('rawSrcset', value);
const isValidSrcset = VALID_WIDTH_DESCRIPTOR_SRCSET.test(value as string) ||
VALID_DENSITY_DESCRIPTOR_SRCSET.test(value as string);

if (!isValidSrcset) {
throw new RuntimeError(
RuntimeErrorCode.INVALID_INPUT,
`The NgOptimizedImage directive has detected that the \`rawSrcset\` has an invalid value: ` +
`expecting width descriptors (e.g. "100w, 200w") or density descriptors (e.g. "1x, 2x"), ` +
`but got: \`${value}\``);
}
}

// Creates a `RuntimeError` instance to represent a situation when an input is set after
// the directive has initialized.
function postInitInputChangeError(dir: NgOptimizedImage, inputName: string): {} {
Expand Down

0 comments on commit 57f3386

Please sign in to comment.