8000 feat(common): support decoding in NgOptimizedImage (#61905) · angular/angular@ef10aa4 · GitHub
[go: up one dir, main page]

Skip to content

Commit ef10aa4

Browse files
arturovtAndrewKushnir
authored andcommitted
feat(common): support decoding in NgOptimizedImage (#61905)
This commit adds the ability to set the decoding attribute in NgOptimizedImage. It proxies the binding onto the host image element. If no binding is provided, it defaults to "auto", which matches the browser's default behavior. This approach avoids any breaking changes resulting from the update. PR Close #61905
1 parent b7ab5fa commit ef10aa4

File tree

3 files changed

+99
-1
lines changed

3 files changed

+99
-1
lines changed

goldens/public-api/common/index.api.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -603,6 +603,7 @@ export abstract class NgLocalization {
603603
// @public
604604
export class NgOptimizedImage implements OnInit, OnChanges {
605605
constructor();
606+
decoding?: 'sync' | 'async' | 'auto';
606607
disableOptimizedSrcset: boolean;
607608
fill: boolean;
608609
protected generatePlaceholder(placeholderInput: string | boolean): string | boolean | null;
@@ -636,7 +637,7 @@ export class NgOptimizedImage implements OnInit, OnChanges {
636637
sizes?: string;
637638
width: number | undefined;
638639
// (undocumented)
639-
static ɵdir: i0.ɵɵDirectiveDeclaration<NgOptimizedImage, "img[ngSrc]", never, { "ngSrc": { "alias": "ngSrc"; "required": true; }; "ngSrcset": { "alias": "ngSrcset"; "required": false; }; "sizes": { "alias": "sizes"; "required": false; }; "width": { "alias": "width"; "required": false; }; "height": { "alias": "height"; "required": false; }; "loading": { "alias": "loading"; "required": false; }; "priority": { "alias": "priority"; "required": false; }; "loaderParams": { "alias": "loaderParams"; "required": false; }; "disableOptimizedSrcset": { "alias": "disableOptimizedSrcset"; "required": false; }; "fill": { "alias": "fill"; "required": false; }; "placeholder": { "alias": "placeholder"; "required": false; }; "placeholderConfig": { "alias": "placeholderConfig"; "required": false; }; "src": { "alias": "src"; "required": false; }; "srcset": { "alias": "srcset"; "required": false; }; }, {}, never, never, true, never>;
640+
static ɵdir: i0.ɵɵDirectiveDeclaration<NgOptimizedImage, "img[ngSrc]", never, { "ngSrc": { "alias": "ngSrc"; "required": true; }; "ngSrcset": { "alias": "ngSrcset"; "required": false; }; "sizes": { "alias": "sizes"; "required": false; }; "width": { "alias": "width"; "required": false; }; "height": { "alias": "height"; "required": false; }; "decoding": { "alias": "decoding"; "required": false; }; "loading": { "alias": "loading"; "required": false; }; "priority": { "alias": "priority"; "required": false; }; "loaderParams": { "alias": "loaderParams"; "required": false; }; "disableOptimizedSrcset": { "alias": "disableOptimizedSrcset"; "required": false; }; "fill": { "alias": "fill"; "required": false; }; "placeholder": { "alias": "placeholder"; "required": false; }; "placeholderConfig": { "alias": "placeholderConfig"; "required": false; }; "src": { "alias": "src"; "required": false; }; "srcset": { "alias": "srcset"; "required": false; }; }, {}, never, never, true, never>;
640641
// (undocumented)
641642
static ɵfac: i0.ɵɵFactoryDeclaration<NgOptimizedImage, never>;
642643
}

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,18 @@ export class NgOptimizedImage implements OnInit, OnChanges {
335335
*/
336336
@Input({transform: numberAttribute}) height: number | undefined;
337337

338+
/**
339+
* The desired decoding behavior for the image. Defaults to `auto`
340+
* if not explicitly set, matching native browser behavior.
341+
*
342+
* Use `async` to decode the image off the main thread (non-blocking),
343+
* `sync` for immediate decoding (blocking), or `auto` to let the
344+
* browser decide the optimal strategy.
345+
*
346+
* [Spec](https://html.spec.whatwg.org/multipage/images.html#image-decoding-hint)
347+
*/
348+
@Input() decoding?: 'sync' | 'async' | 'auto';
349+
338350
/**
339351
* The desired loading behavior (lazy, eager, or auto). Defaults to `lazy`,
340352
* which is recommended for most images.
@@ -444,6 +456,7 @@ export class NgOptimizedImage implements OnInit, OnChanges {
444456
);
445457
}
446458
assertValidLoadingInput(this);
459+
assertValidDecodingInput(this);
447460
if (!this.ngSrcset) {
448461
assertNoComplexSizes(this);
449462
}
@@ -484,6 +497,7 @@ export class NgOptimizedImage implements OnInit, OnChanges {
484497

485498
this.setHostAttribute('loading', this.getLoadingBehavior());
486499
this.setHostAttribute('fetchpriority', this.getFetchPriority());
500+
this.setHostAttribute('decoding', this.getDecoding());
487501

488502
// The `data-ng-img` attribute flags an image as using the directive, to allow
489503
// for analysis of the directive's performance.
@@ -581,6 +595,20 @@ export class NgOptimizedImage implements OnInit, OnChanges {
581595
return this.priority ? 'high' : 'auto';
582596
}
583597

598+
private getDecoding(): string {
599+
if (this.priority) {
600+
// `sync` means the image is decoded immediately when it's loaded,
601+
// reducing the risk of content shifting later (important for LCP).
602+
// If we're marking an image as priority, we want it decoded and
603+
// painted as early as possible.
604+
return 'sync';
605+
}
606+
// Returns the value of the `decoding` attribute, defaulting to `auto`
607+
// if not explicitly provided. This mimics native browser behavior and
608+
// avoids breaking changes when no decoding strategy is specified.
609+
return this.decoding ?? 'auto';
610+
}
611+
584612
private getRewrittenSrc(): string {
585613
// ImageLoaderConfig supports setting a width property. However, we're not setting width here
586614
// because if the developer uses rendered width instead of intrinsic width in the HTML width
@@ -1236,6 +1264,21 @@ function assertValidLoadingInput(dir: NgOptimizedImage) {
12361264
}
12371265
}
12381266

1267+
/**
1268+
* Verifies that the `decoding` attribute is set to a valid input.
1269+
*/
1270+
function assertValidDecodingInput(dir: NgOptimizedImage) {
1271+
const validInputs = ['sync', 'async', 'auto'];
1272+
if (typeof dir.decoding === 'string' && !validInputs.includes(dir.decoding)) {
1273+
throw new RuntimeError(
1274+
RuntimeErrorCode.INVALID_INPUT,
1275+
`${imgDirectiveDetails(dir.ngSrc)} the \`decoding\` attribute ` +
1276+
`has an invalid value (\`${dir.decoding}\`). ` +
1277+
`To fix this, provide a valid value ("sync", "async", or "auto").`,
1278+
);
1279+
}
1280+
}
1281+
12391282
/**
12401283
* Warns if NOT using a loader (falling back to the generic loader) and
12411284
* the image appears to be hosted on one of the image CDNs for which

packages/common/test/directives/ng_optimized_image_spec.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,60 @@ describe('Image directive', () => {
804804
});
805805
});
806806

807+
describe('decoding attribute', () => {
808+
it('should throw for invalid loading inputs', () => {
809+
setupTestingModule();
810+
811+
const template =
812+
'<img ngSrc="path/img.png" width="150" height="150" decoding="unknown_value">';
813+
expect(() => {
814+
const fixture = createTestComponent(template);
815+
fixture.detectChanges();
816+
}).toThrowError(
817+
'NG02952: The NgOptimizedImage directive ' +
818+
'(activated on an <img> element with the `ngSrc="path/img.png"`) has detected ' +
819+
'that the `decoding` attribute has an invalid value (`unknown_value`). ' +
820+
'To fix this, provide a valid value ("sync", "async", or "auto").',
821+
);
822+
});
823+
824+
it('should set the decoding to "auto" by default', () => {
825+
setupTestingModule();
826+
827+
const template = '<img ngSrc="path/img.png" width="150" height="150">';
828+
const fixture = createTestComponent(template);
829+
fixture.detectChanges();
830+
831+
const nativeElement = fixture.nativeElement as HTMLElement;
832+
const img = nativeElement.querySelector('img')!;
833+
expect(img.getAttribute('decoding')).toEqual('auto');
834+
});
835+
836+
it('should set the decoding to sync for priority images', () => {
837+
setupTestingModule();
838+
839+
const template = '<img ngSrc="path/img.png" width="150" height="50" priority>';
840+
const fixture = createTestComponent(template);
841+
fixture.detectChanges();
842+
843+
const nativeElement = fixture.nativeElement as HTMLElement;
844+
const img = nativeElement.querySelector('img')!;
845+
expect(img.getAttribute('decoding')).toEqual('sync');
846+
});
847+
848+
it('should override the default decoding behavior', () => {
849+
setupTestingModule();
850+
851+
const template = '<img ngSrc="path/img.png" width="150" height="150" decoding="async">';
852+
const fixture = createTestComponent(template);
853+
fixture.detectChanges();
854+
855+
const nativeElement = fixture.nativeElement as HTMLElement;
856+
const img = nativeElement.querySelector('img')!;
857+
expect(img.getAttribute('decoding')).toEqual('async');
858+
});
859+
});
860+
807861
describe('loading attribute', () => {
808862
it('should override the default loading behavior for non-priority images', () => {
809863
setupTestingModule();

0 commit comments

Comments
 (0)
0