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', () => {