diff --git a/goldens/public-api/common/errors.api.md b/goldens/public-api/common/errors.api.md index ff719b033d1..5622f253600 100644 --- a/goldens/public-api/common/errors.api.md +++ b/goldens/public-api/common/errors.api.md @@ -31,6 +31,8 @@ export const enum RuntimeErrorCode { // (undocumented) PARENT_NG_SWITCH_NOT_FOUND = 2000, // (undocumented) + PLACEHOLDER_DIMENSION_LIMIT_EXCEEDED = 2967, + // (undocumented) PRIORITY_IMG_MISSING_PRECONNECT_TAG = 2956, // (undocumented) REQUIRED_INPUT_MISSING = 2954, diff --git a/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts b/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts index f53cadf953c..34c9d8cebb7 100644 --- a/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts +++ b/packages/common/src/directives/ng_optimized_image/ng_optimized_image.ts @@ -117,6 +117,12 @@ const FIXED_SRCSET_HEIGHT_LIMIT = 1080; */ export const PLACEHOLDER_BLUR_AMOUNT = 15; +/** + * Placeholder dimension (height or width) limit in pixels. Angular produces a warning + * when this limit is crossed. + */ +const PLACEHOLDER_DIMENSION_LIMIT = 1000; + /** * Used to warn or error when the user provides an overly large dataURL for the placeholder * attribute. @@ -364,7 +370,7 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy { /** * A URL or data URL for an image to be used as a placeholder while this image loads. */ - @Input({transform: booleanOrDataUrlAttribute}) placeholder?: string | boolean; + @Input({transform: booleanOrUrlAttribute}) placeholder?: string | boolean; /** * Configuration object for placeholder settings. Options: @@ -518,6 +524,10 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy { }); } } + + if (ngDevMode && changes['placeholder']?.currentValue && !this.isServer) { + assertPlaceholderDimensions(this, this.imgElement); + } } private callImageLoader( @@ -651,7 +661,7 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy { width: placeholderResolution, isPlaceholder: true, })})`; - } else if (typeof placeholderInput === 'string' && placeholderInput.startsWith('data:')) { + } else if (typeof placeholderInput === 'string') { return `url(${placeholderInput})`; } return null; @@ -1286,6 +1296,28 @@ function assetPriorityCountBelowThreshold() { IMGS_WITH_PRIORITY_ATTR_COUNT++; } +/** + * Warns if placeholder's dimension are over a threshold. + * + * This assert function is meant to only run on the browser. + */ +function assertPlaceholderDimensions(dir: NgOptimizedImage, imgElement: HTMLImageElement) { + const computedStyle = window.getComputedStyle(imgElement); + let renderedWidth = parseFloat(computedStyle.getPropertyValue('width')); + let renderedHeight = parseFloat(computedStyle.getPropertyValue('height')); + + if (renderedWidth > PLACEHOLDER_DIMENSION_LIMIT || renderedHeight > PLACEHOLDER_DIMENSION_LIMIT) { + console.warn( + formatRuntimeError( + RuntimeErrorCode.PLACEHOLDER_DIMENSION_LIMIT_EXCEEDED, + `${imgDirectiveDetails(dir.ngSrc)} it uses a placeholder image, but at least one of ` + + `of the dimensions attribute (height or width) exceeds the limit of ${PLACEHOLDER_DIMENSION_LIMIT}px. ` + + `To fix this, use a smaller image as a placeholder.`, + ), + ); + } +} + function round(input: number): number | string { return Number.isInteger(input) ? input : input.toFixed(2); } @@ -1301,8 +1333,8 @@ function unwrapSafeUrl(value: string | SafeValue): string { // Transform function to handle inputs which may be booleans, strings, or string representations // of boolean values. Used for the placeholder attribute. -export function booleanOrDataUrlAttribute(value: boolean | string): boolean | string { - if (typeof value === 'string' && value.startsWith(`data:`)) { +export function booleanOrUrlAttribute(value: boolean | string): boolean | string { + if (typeof value === 'string' && value !== 'true' && value !== 'false' && value !== '') { return value; } return booleanAttribute(value); diff --git a/packages/common/src/errors.ts b/packages/common/src/errors.ts index 6970f1c2def..bd1dd5878a4 100644 --- a/packages/common/src/errors.ts +++ b/packages/common/src/errors.ts @@ -40,4 +40,5 @@ export const enum RuntimeErrorCode { LCP_IMG_NGSRC_MODIFIED = 2964, OVERSIZED_PLACEHOLDER = 2965, TOO_MANY_PRIORITY_ATTRIBUTES = 2966, + PLACEHOLDER_DIMENSION_LIMIT_EXCEEDED = 2967, } diff --git a/packages/common/test/directives/ng_optimized_image_spec.ts b/packages/common/test/directives/ng_optimized_image_spec.ts index 83304d0e57a..2fe6e7e1e3f 100644 --- a/packages/common/test/directives/ng_optimized_image_spec.ts +++ b/packages/common/test/directives/ng_optimized_image_spec.ts @@ -1129,6 +1129,38 @@ describe('Image directive', () => { ); }); } + + it('should add a background-image tag when placeholder is provided as a URL', () => { + setupTestingModule({imageLoader}); + const template = + ''; + + const fixture = createTestComponent(template); + fixture.detectChanges(); + const nativeElement = fixture.nativeElement as HTMLElement; + const img = nativeElement.querySelector('img')!; + // Double quotes removed to account for different browser behavior. + expect(img.getAttribute('style')?.replace(/"/g, '').replace(/\s/g, '')).toBe( + `background-size:cover;background-position:50%50%;background-repeat:no-repeat;background-image:url(https://mysite.com/assets/my-image.png);filter:blur(${PLACEHOLDER_BLUR_AMOUNT}px);`, + ); + }); + + // DataURLs get stripped from background-image attribute in Node, but not browsers. + it('should add a background-image tag when placeholder is provided as relative URL', () => { + setupTestingModule({imageLoader}); + const template = + ''; + + const fixture = createTestComponent(template); + fixture.detectChanges(); + const nativeElement = fixture.nativeElement as HTMLElement; + const img = nativeElement.querySelector('img')!; + // Double quotes removed to account for different browser behavior. + expect(img.getAttribute('style')?.replace(/"/g, '').replace(/\s/g, '')).toBe( + `background-size:cover;background-position:50%50%;background-repeat:no-repeat;background-image:url(../../assets/my-image.png);filter:blur(${PLACEHOLDER_BLUR_AMOUNT}px);`, + ); + }); + it('should add a background-image tag when placeholder is provided without value', () => { setupTestingModule({imageLoader}); const template = ''; @@ -1276,6 +1308,40 @@ describe('Image directive', () => { `performance, generate a smaller data URL placeholder.`, ); }); + + if (isBrowser) { + it('should throw if the placeholder height exceeds the threshold', () => { + setUpModuleNoLoader(); + + const template = ``; + + const consoleWarnSpy = spyOn(console, 'warn'); + const fixture = createTestComponent(template); + fixture.detectChanges(); + expect(consoleWarnSpy.calls.count()).toBe(1); + expect(consoleWarnSpy.calls.argsFor(0)[0]).toMatch( + new RegExp(`NG0${RuntimeErrorCode.PLACEHOLDER_DIMENSION_LIMIT_EXCEEDED}:`), + ); + }); + + it('should throw if the placeholder width exceeds the threshold', () => { + setUpModuleNoLoader(); + + const template = ``; + + const consoleWarnSpy = spyOn(console, 'warn'); + const fixture = createTestComponent(template); + fixture.detectChanges(); + expect(consoleWarnSpy.calls.count()).toBe(1); + expect(consoleWarnSpy.calls.argsFor(0)[0]).toMatch( + new RegExp(`NG0${RuntimeErrorCode.PLACEHOLDER_DIMENSION_LIMIT_EXCEEDED}:`), + ); + }); + } }); describe('preconnect detector', () => {