Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(common): add <link> preload tag on server for priority img #47343

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions goldens/public-api/common/errors.md
Expand Up @@ -25,6 +25,8 @@ export const enum RuntimeErrorCode {
// (undocumented)
REQUIRED_INPUT_MISSING = 2954,
// (undocumented)
TOO_MANY_PRELOADED_IMAGES = 2961,
// (undocumented)
UNEXPECTED_DEV_MODE_CHECK_IN_PROD_MODE = 2958,
// (undocumented)
UNEXPECTED_INPUT_CHANGE = 2953,
Expand Down
Expand Up @@ -6,14 +6,16 @@
* found in the LICENSE file at https://angular.io/license
*/

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

import {RuntimeErrorCode} from '../../errors';
import {isPlatformServer} from '../../platform_id';

import {imgDirectiveDetails} from './error_helper';
import {IMAGE_LOADER} from './image_loaders/image_loader';
import {LCPImageObserver} from './lcp_image_observer';
import {PreconnectLinkChecker} from './preconnect_link_checker';
import {PreloadLinkCreator} from './preload-link-creator';

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

// a LCP image observer - should be injected only in the dev mode
private lcpObserver = ngDevMode ? this.injector.get(LCPImageObserver) : null;
Expand Down Expand Up @@ -386,14 +390,28 @@ export class NgOptimizedImage implements OnInit, OnChanges, OnDestroy {
this.setHostAttribute('fetchpriority', this.getFetchPriority());
// The `src` and `srcset` attributes should be set last since other attributes
// could affect the image's loading behavior.
this.setHostAttribute('src', this.getRewrittenSrc());
const rewrittenSrc = this.getRewrittenSrc();
this.setHostAttribute('src', rewrittenSrc);

let rewrittenSrcset: string|undefined = undefined;

if (this.sizes) {
this.setHostAttribute('sizes', this.sizes);
}

if (this.ngSrcset) {
this.setHostAttribute('srcset', this.getRewrittenSrcset());
rewrittenSrcset = this.getRewrittenSrcset();
} else if (!this._disableOptimizedSrcset && !this.srcset) {
this.setHostAttribute('srcset', this.getAutomaticSrcset());
rewrittenSrcset = this.getAutomaticSrcset();
}

if (rewrittenSrcset) {
this.setHostAttribute('srcset', rewrittenSrcset);
}

if (this.isServer && this.priority) {
yharaskrik marked this conversation as resolved.
Show resolved Hide resolved
this.preloadLinkChecker.createPreloadLinkTag(
this.renderer, rewrittenSrc, rewrittenSrcset, this.sizes);
}
}

Expand Down
@@ -0,0 +1,79 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {inject, Injectable, Renderer2, ɵRuntimeError as RuntimeError} from '@angular/core';

import {DOCUMENT} from '../../dom_tokens';
import {RuntimeErrorCode} from '../../errors';

import {DEFAULT_PRELOADED_IMAGES_LIMIT, PRELOADED_IMAGES} from './tokens';

/**
* @description Contains the logic needed to track and add preload link tags to the `<head>` tag. It
* will also track what images have already had preload link tags added so as to not duplicate link
* tags.
*
* In dev mode this service will validate that the number of preloaded images does not exceed the
* configured default preloaded images limit: {@link DEFAULT_PRELOADED_IMAGES_LIMIT}.
*/
@Injectable({providedIn: 'root'})
export class PreloadLinkCreator {
private readonly preloadedImages = inject(PRELOADED_IMAGES);
private readonly document = inject(DOCUMENT);

/**
* @description Add a preload `<link>` to the `<head>` of the `index.html` that is served from the
* server while using Angular Universal and SSR to kick off image loads for high priority images.
*
* The `sizes` (passed in from the user) and `srcset` (parsed and formatted from `ngSrcset`)
* properties used to set the corresponding attributes, `imagesizes` and `imagesrcset`
* respectively, on the preload `<link>` tag so that the correctly sized image is preloaded from
* the CDN.
*
* {@link https://web.dev/preload-responsive-images/#imagesrcset-and-imagesizes}
*
* @param renderer The `Renderer2` passed in from the directive
* @param src The original src of the image that is set on the `ngSrc` input.
* @param srcset The parsed and formatted srcset created from the `ngSrcset` input
* @param sizes The value of the `sizes` attribute passed in to the `<img>` tag
*/
createPreloadLinkTag(renderer: Renderer2, src: string, srcset?: string, sizes?: string): void {
if (ngDevMode) {
if (this.preloadedImages.size >= DEFAULT_PRELOADED_IMAGES_LIMIT) {
throw new RuntimeError(
RuntimeErrorCode.TOO_MANY_PRELOADED_IMAGES,
ngDevMode &&
`The \`NgOptimizedImage\` directive has detected that more than ` +
`${DEFAULT_PRELOADED_IMAGES_LIMIT} images were marked as priority. ` +
`This might negatively affect an overall performance of the page. ` +
`To fix this, remove the "priority" attribute from images with less priority.`);
}
}

if (this.preloadedImages.has(src)) {
return;
}

this.preloadedImages.add(src);

const preload = renderer.createElement('link');
renderer.setAttribute(preload, 'as', 'image');
renderer.setAttribute(preload, 'href', src);
renderer.setAttribute(preload, 'rel', 'preload');

if (sizes) {
renderer.setAttribute(preload, 'imageSizes', sizes);
}

if (srcset) {
renderer.setAttribute(preload, 'imageSrcset', srcset);
}

renderer.appendChild(this.document.head, preload);
}
}
27 changes: 27 additions & 0 deletions packages/common/src/directives/ng_optimized_image/tokens.ts
@@ -0,0 +1,27 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {InjectionToken} from '@angular/core';

/**
* In SSR scenarios, a preload `<link>` element is generated for priority images.
* Having a large number of preload tags may negatively affect the performance,
* so we warn developers (by throwing an error) if the number of preloaded images
* is above a certain threshold. This const specifies this threshold.
*/
export const DEFAULT_PRELOADED_IMAGES_LIMIT = 5;

/**
* Helps to keep track of priority images that already have a corresponding
* preload tag (to avoid generating multiple preload tags with the same URL).
*
* This Set tracks the original src passed into the `ngSrc` input not the src after it has been
* run through the specified `IMAGE_LOADER`.
*/
export const PRELOADED_IMAGES = new InjectionToken<Set<string>>(
'NG_OPTIMIZED_PRELOADED_IMAGES', {providedIn: 'root', factory: () => new Set<string>()});
1 change: 1 addition & 0 deletions packages/common/src/errors.ts
Expand Up @@ -31,4 +31,5 @@ export const enum RuntimeErrorCode {
UNEXPECTED_DEV_MODE_CHECK_IN_PROD_MODE = 2958,
INVALID_LOADER_ARGUMENTS = 2959,
OVERSIZED_IMAGE = 2960,
TOO_MANY_PRELOADED_IMAGES = 2961,
}