Skip to content

Commit b380fdd

Browse files
karaPawel Kozlowski
authored andcommitted
feat(common): add a density cap for image srcsets (#47082)
With this commit, the NgOptimizedImage directive will throw a runtime error if it detects that one of the density descriptors in rawSrcset is higher than 3x. It's generally not recommended to use density descriptors higher than ~2, as it causes image to download at very large sizes on mobile screens (thus slowing down LCP). The density max is set conservatively to 3 in case apps expect users to zoom in. In future commits, we may want to throw even at densities > than 2 and provide a configuration override for the zoom case. PR Close #47082
1 parent 59ea528 commit b380fdd

File tree

2 files changed

+66
-4
lines changed

2 files changed

+66
-4
lines changed

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

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,19 @@ const VALID_WIDTH_DESCRIPTOR_SRCSET = /^((\s*\d+w\s*(,|$)){1,})$/;
3636
*/
3737
const VALID_DENSITY_DESCRIPTOR_SRCSET = /^((\s*\d(\.\d)?x\s*(,|$)){1,})$/;
3838

39+
/**
40+
* Srcset values with a density descriptor higher than this value will actively
41+
* throw an error. Such densities are not permitted as they cause image sizes
42+
* to be unreasonably large and slow down LCP.
43+
*/
44+
export const ABSOLUTE_SRCSET_DENSITY_CAP = 3;
45+
46+
/**
47+
* Used only in error message text to communicate best practices, as we will
48+
* only throw based on the slightly more conservative ABSOLUTE_SRCSET_DENSITY_CAP.
49+
*/
50+
export const RECOMMENDED_SRCSET_DENSITY_CAP = 2;
51+
3952
/**
4053
* Used to determine whether two aspect ratios are similar in value.
4154
*/
@@ -359,15 +372,37 @@ function assertNonEmptyInput(name: string, value: unknown) {
359372
export function assertValidRawSrcset(value: unknown) {
360373
if (value == null) return;
361374
assertNonEmptyInput('rawSrcset', value);
362-
const isValidSrcset = VALID_WIDTH_DESCRIPTOR_SRCSET.test(value as string) ||
363-
VALID_DENSITY_DESCRIPTOR_SRCSET.test(value as string);
375+
const stringVal = value as string;
376+
const isValidWidthDescriptor = VALID_WIDTH_DESCRIPTOR_SRCSET.test(stringVal);
377+
const isValidDensityDescriptor = VALID_DENSITY_DESCRIPTOR_SRCSET.test(stringVal);
364378

379+
if (isValidDensityDescriptor) {
380+
assertUnderDensityCap(stringVal);
381+
}
382+
383+
const isValidSrcset = isValidWidthDescriptor || isValidDensityDescriptor;
365384
if (!isValidSrcset) {
366385
throw new RuntimeError(
367386
RuntimeErrorCode.INVALID_INPUT,
368387
`The NgOptimizedImage directive has detected that the \`rawSrcset\` has an invalid value: ` +
369388
`expecting width descriptors (e.g. "100w, 200w") or density descriptors (e.g. "1x, 2x"), ` +
370-
`but got: \`${value}\``);
389+
`but got: \`${stringVal}\``);
390+
}
391+
}
392+
393+
function assertUnderDensityCap(value: string) {
394+
const underDensityCap =
395+
value.split(',').every(num => num === '' || parseFloat(num) <= ABSOLUTE_SRCSET_DENSITY_CAP);
396+
if (!underDensityCap) {
397+
throw new RuntimeError(
398+
RuntimeErrorCode.INVALID_INPUT,
399+
`The NgOptimizedImage directive has detected that the \`rawSrcset\` contains an unsupported image density:` +
400+
`\`${value}\`. NgOptimizedImage generally recommends a max image density of ` +
401+
`${RECOMMENDED_SRCSET_DENSITY_CAP}x but supports image densities up to ` +
402+
`${ABSOLUTE_SRCSET_DENSITY_CAP}x. The human eye cannot distinguish between image densities ` +
403+
`greater than ${RECOMMENDED_SRCSET_DENSITY_CAP}x - which makes them unnecessary for ` +
404+
`most use cases. Images that will be pinch-zoomed are typically the primary use case for` +
405+
`${ABSOLUTE_SRCSET_DENSITY_CAP}x images. Please remove the high density descriptor and try again.`);
371406
}
372407
}
373408

packages/common/test/directives/ng_optimized_image_spec.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {expect} from '@angular/platform-browser/testing/src/matchers';
1414
import {withHead} from '@angular/private/testing';
1515

1616
import {IMAGE_LOADER, ImageLoader, ImageLoaderConfig} from '../../src/directives/ng_optimized_image/image_loaders/image_loader';
17-
import {assertValidRawSrcset, NgOptimizedImageModule} from '../../src/directives/ng_optimized_image/ng_optimized_image';
17+
import {ABSOLUTE_SRCSET_DENSITY_CAP, assertValidRawSrcset, NgOptimizedImageModule, RECOMMENDED_SRCSET_DENSITY_CAP} from '../../src/directives/ng_optimized_image/ng_optimized_image';
1818
import {PRECONNECT_CHECK_BLOCKLIST} from '../../src/directives/ng_optimized_image/preconnect_link_checker';
1919

2020
describe('Image directive', () => {
@@ -322,6 +322,33 @@ describe('Image directive', () => {
322322
`but got: \`100q, 200q\``);
323323
});
324324

325+
it('should throw if rawSrcset exceeds the density cap', () => {
326+
const imageLoader = (config: ImageLoaderConfig) => {
327+
const width = config.width ? `-${config.width}` : ``;
328+
return window.location.origin + `/path/${config.src}${width}.png`;
329+
};
330+
setupTestingModule({imageLoader});
331+
332+
const template = `
333+
<img rawSrc="img" rawSrcset="1x, 2x, 3x, 4x, 5x" width="100" height="50">
334+
`;
335+
expect(() => {
336+
const fixture = createTestComponent(template);
337+
fixture.detectChanges();
338+
})
339+
.toThrowError(
340+
`NG0${
341+
RuntimeErrorCode
342+
.INVALID_INPUT}: The NgOptimizedImage directive has detected that the \`rawSrcset\` contains an unsupported image density:` +
343+
`\`1x, 2x, 3x, 4x, 5x\`. NgOptimizedImage generally recommends a max image density of ` +
344+
`${RECOMMENDED_SRCSET_DENSITY_CAP}x but supports image densities up to ` +
345+
`${ABSOLUTE_SRCSET_DENSITY_CAP}x. The human eye cannot distinguish between image densities ` +
346+
`greater than ${
347+
RECOMMENDED_SRCSET_DENSITY_CAP}x - which makes them unnecessary for ` +
348+
`most use cases. Images that will be pinch-zoomed are typically the primary use case for` +
349+
`${ABSOLUTE_SRCSET_DENSITY_CAP}x images. Please remove the high density descriptor and try again.`);
350+
});
351+
325352
it('should throw if width srcset is missing a comma', () => {
326353
expect(() => {
327354
assertValidRawSrcset('100w 200w');

0 commit comments

Comments
 (0)