Skip to content

Commit 75e6297

Browse files
yharaskrikthePunderWoman
authored andcommitted
feat(common): add <link> preload tag on server for priority img (#47343)
This commit adds a logic that generates preload tags for priority images, when rendering happens on the server (e.g. Angular Universal). PR Close #47343
1 parent f467c9e commit 75e6297

File tree

6 files changed

+313
-25
lines changed

6 files changed

+313
-25
lines changed

goldens/public-api/common/errors.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export const enum RuntimeErrorCode {
2525
// (undocumented)
2626
REQUIRED_INPUT_MISSING = 2954,
2727
// (undocumented)
28+
TOO_MANY_PRELOADED_IMAGES = 2961,
29+
// (undocumented)
2830
UNEXPECTED_DEV_MODE_CHECK_IN_PROD_MODE = 2958,
2931
// (undocumented)
3032
UNEXPECTED_INPUT_CHANGE = 2953,

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

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {Directive, ElementRef, inject, InjectionToken, Injector, Input, NgZone, OnChanges, OnDestroy, OnInit, Renderer2, SimpleChanges, ɵformatRuntimeError as formatRuntimeError, ɵRuntimeError as RuntimeError} from '@angular/core';
9+
import {Directive, ElementRef, inject, InjectionToken, Injector, Input, NgZone, OnChanges, OnDestroy, OnInit, PLATFORM_ID, Renderer2, SimpleChanges, ɵformatRuntimeError as formatRuntimeError, ɵRuntimeError as RuntimeError} from '@angular/core';
1010

1111
import {RuntimeErrorCode} from '../../errors';
12+
import {isPlatformServer} from '../../platform_id';
1213

1314
import {imgDirectiveDetails} from './error_helper';
1415
import {IMAGE_LOADER} from './image_loaders/image_loader';
1516
import {LCPImageObserver} from './lcp_image_observer';
1617
import {PreconnectLinkChecker} from './preconnect_link_checker';
18+
import {PreloadLinkCreator} from './preload-link-creator';
1719

1820
/**
1921
* When a Base64-encoded image is passed as an input to the `NgOptimizedImage` directive,
@@ -207,6 +209,8 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
207209
private renderer = inject(Renderer2);
208210
private imgElement: HTMLImageElement = inject(ElementRef).nativeElement;
209211
private injector = inject(Injector);
212+
private readonly isServer = isPlatformServer(inject(PLATFORM_ID));
213+
private readonly preloadLinkChecker = inject(PreloadLinkCreator);
210214

211215
// a LCP image observer - should be injected only in the dev mode
212216
private lcpObserver = ngDevMode ? this.injector.get(LCPImageObserver) : null;
@@ -386,14 +390,28 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
386390
this.setHostAttribute('fetchpriority', this.getFetchPriority());
387391
// The `src` and `srcset` attributes should be set last since other attributes
388392
// could affect the image's loading behavior.
389-
this.setHostAttribute('src', this.getRewrittenSrc());
393+
const rewrittenSrc = this.getRewrittenSrc();
394+
this.setHostAttribute('src', rewrittenSrc);
395+
396+
let rewrittenSrcset: string|undefined = undefined;
397+
390398
if (this.sizes) {
391399
this.setHostAttribute('sizes', this.sizes);
392400
}
401+
393402
if (this.ngSrcset) {
394-
this.setHostAttribute('srcset', this.getRewrittenSrcset());
403+
rewrittenSrcset = this.getRewrittenSrcset();
395404
} else if (!this._disableOptimizedSrcset && !this.srcset) {
396-
this.setHostAttribute('srcset', this.getAutomaticSrcset());
405+
rewrittenSrcset = this.getAutomaticSrcset();
406+
}
407+
408+
if (rewrittenSrcset) {
409+
this.setHostAttribute('srcset', rewrittenSrcset);
410+
}
411+
412+
if (this.isServer && this.priority) {
413+
this.preloadLinkChecker.createPreloadLinkTag(
414+
this.renderer, rewrittenSrc, rewrittenSrcset, this.sizes);
397415
}
398416
}
399417

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {inject, Injectable, Renderer2, ɵRuntimeError as RuntimeError} from '@angular/core';
10+
11+
import {DOCUMENT} from '../../dom_tokens';
12+
import {RuntimeErrorCode} from '../../errors';
13+
14+
import {DEFAULT_PRELOADED_IMAGES_LIMIT, PRELOADED_IMAGES} from './tokens';
15+
16+
/**
17+
* @description Contains the logic needed to track and add preload link tags to the `<head>` tag. It
18+
* will also track what images have already had preload link tags added so as to not duplicate link
19+
* tags.
20+
*
21+
* In dev mode this service will validate that the number of preloaded images does not exceed the
22+
* configured default preloaded images limit: {@link DEFAULT_PRELOADED_IMAGES_LIMIT}.
23+
*/
24+
@Injectable({providedIn: 'root'})
25+
export class PreloadLinkCreator {
26+
private readonly preloadedImages = inject(PRELOADED_IMAGES);
27+
private readonly document = inject(DOCUMENT);
28+
29+
/**
30+
* @description Add a preload `<link>` to the `<head>` of the `index.html` that is served from the
31+
* server while using Angular Universal and SSR to kick off image loads for high priority images.
32+
*
33+
* The `sizes` (passed in from the user) and `srcset` (parsed and formatted from `ngSrcset`)
34+
* properties used to set the corresponding attributes, `imagesizes` and `imagesrcset`
35+
* respectively, on the preload `<link>` tag so that the correctly sized image is preloaded from
36+
* the CDN.
37+
*
38+
* {@link https://web.dev/preload-responsive-images/#imagesrcset-and-imagesizes}
39+
*
40+
* @param renderer The `Renderer2` passed in from the directive
41+
* @param src The original src of the image that is set on the `ngSrc` input.
42+
* @param srcset The parsed and formatted srcset created from the `ngSrcset` input
43+
* @param sizes The value of the `sizes` attribute passed in to the `<img>` tag
44+
*/
45+
createPreloadLinkTag(renderer: Renderer2, src: string, srcset?: string, sizes?: string): void {
46+
if (ngDevMode) {
47+
if (this.preloadedImages.size >= DEFAULT_PRELOADED_IMAGES_LIMIT) {
48+
throw new RuntimeError(
49+
RuntimeErrorCode.TOO_MANY_PRELOADED_IMAGES,
50+
ngDevMode &&
51+
`The \`NgOptimizedImage\` directive has detected that more than ` +
52+
`${DEFAULT_PRELOADED_IMAGES_LIMIT} images were marked as priority. ` +
53+
`This might negatively affect an overall performance of the page. ` +
54+
`To fix this, remove the "priority" attribute from images with less priority.`);
55+
}
56+
}
57+
58+
if (this.preloadedImages.has(src)) {
59+
return;
60+
}
61+
62+
this.preloadedImages.add(src);
63+
64+
const preload = renderer.createElement('link');
65+
renderer.setAttribute(preload, 'as', 'image');
66+
renderer.setAttribute(preload, 'href', src);
67+
renderer.setAttribute(preload, 'rel', 'preload');
68+
69+
if (sizes) {
70+
renderer.setAttribute(preload, 'imageSizes', sizes);
71+
}
72+
73+
if (srcset) {
74+
renderer.setAttribute(preload, 'imageSrcset', srcset);
75+
}
76+
77+
renderer.appendChild(this.document.head, preload);
78+
}
79+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {InjectionToken} from '@angular/core';
10+
11+
/**
12+
* In SSR scenarios, a preload `<link>` element is generated for priority images.
13+
* Having a large number of preload tags may negatively affect the performance,
14+
* so we warn developers (by throwing an error) if the number of preloaded images
15+
* is above a certain threshold. This const specifies this threshold.
16+
*/
17+
export const DEFAULT_PRELOADED_IMAGES_LIMIT = 5;
18+
19+
/**
20+
* Helps to keep track of priority images that already have a corresponding
21+
* preload tag (to avoid generating multiple preload tags with the same URL).
22+
*
23+
* This Set tracks the original src passed into the `ngSrc` input not the src after it has been
24+
* run through the specified `IMAGE_LOADER`.
25+
*/
26+
export const PRELOADED_IMAGES = new InjectionToken<Set<string>>(
27+
'NG_OPTIMIZED_PRELOADED_IMAGES', {providedIn: 'root', factory: () => new Set<string>()});

packages/common/src/errors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,5 @@ export const enum RuntimeErrorCode {
3131
UNEXPECTED_DEV_MODE_CHECK_IN_PROD_MODE = 2958,
3232
INVALID_LOADER_ARGUMENTS = 2959,
3333
OVERSIZED_IMAGE = 2960,
34+
TOO_MANY_PRELOADED_IMAGES = 2961,
3435
}

0 commit comments

Comments
 (0)