refactor(common): allow string urls in NgOptimizedImage input (#54901)

With this commit it is now possible to pass an url as placeholder for the image directive.

PR Close #54901
This commit is contained in:
Matthieu Riegler
2024-04-01 13:08:51 +02:00
committed by Jessica Janiuk
parent 341a116d61
commit f407e3ce6f
4 changed files with 105 additions and 4 deletions
+2
View File
@@ -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,
@@ -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);
+1
View File
@@ -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,
}
@@ -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 =
'<img ngSrc="path/img.png" width="400" height="300" placeholder="https://mysite.com/assets/my-image.png" />';
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 =
'<img ngSrc="path/img.png" width="400" height="300" placeholder="../../assets/my-image.png" />';
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 = '<img ngSrc="path/img.png" width="400" height="300" placeholder />';
@@ -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 = `<img ngSrc="path/img.png" width="100" height="100" style="width:1001px; height: 300px" placeholder="data:image/png;base64,${'a'.repeat(
100,
)}">`;
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 = `<img ngSrc="path/img.png" width="100" height="100" style="height:1001px; width: 300px" placeholder="data:image/png;base64,${'a'.repeat(
100,
)}">`;
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', () => {