fix(common): Don't run preconnect assertion on the server. (#56213)

The `window` global is patched by domino on the server but the value of `window.location.href` isn't a valid base.

Before this change `getUrl()` would throw when running in devmode on the server.

Fixes #56207

PR Close #56213
This commit is contained in:
Matthieu Riegler 2024-06-01 13:18:10 +02:00 committed by Jessica Janiuk
parent 64f6860a68
commit a55719f55e
4 changed files with 51 additions and 23 deletions

View file

@ -11,14 +11,16 @@ import {
Injectable,
OnDestroy,
ɵformatRuntimeError as formatRuntimeError,
PLATFORM_ID,
} from '@angular/core';
import {DOCUMENT} from '../../dom_tokens';
import {RuntimeErrorCode} from '../../errors';
import {assertDevMode} from './asserts';
import {imgDirectiveDetails} from './error_helper';
import {getUrl} from './url';
import {PlatformLocation} from '../../location';
import {isPlatformBrowser} from '../../platform_id';
interface ObservedImageState {
priority: boolean;
@ -42,14 +44,14 @@ export class LCPImageObserver implements OnDestroy {
// Map of full image URLs -> original `ngSrc` values.
private images = new Map<string, ObservedImageState>();
private window: Window | null = null;
private observer: PerformanceObserver | null = null;
private readonly baseUrl = inject(PlatformLocation).href;
constructor() {
const isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
assertDevMode('LCP checker');
const win = inject(DOCUMENT).defaultView;
if (typeof win !== 'undefined' && typeof PerformanceObserver !== 'undefined') {
this.window = win;
if (isBrowser && typeof PerformanceObserver !== 'undefined') {
this.observer = this.initPerformanceObserver();
}
}
@ -98,20 +100,20 @@ export class LCPImageObserver implements OnDestroy {
alreadyWarnedModified: false,
alreadyWarnedPriority: false,
};
this.images.set(getUrl(rewrittenSrc, this.window!).href, newObservedImageState);
this.images.set(getUrl(rewrittenSrc, this.baseUrl).href, newObservedImageState);
}
unregisterImage(rewrittenSrc: string) {
if (!this.observer) return;
this.images.delete(getUrl(rewrittenSrc, this.window!).href);
this.images.delete(getUrl(rewrittenSrc, this.baseUrl).href);
}
updateImage(originalSrc: string, newSrc: string) {
const originalUrl = getUrl(originalSrc, this.window!).href;
const originalUrl = getUrl(originalSrc, this.baseUrl).href;
const img = this.images.get(originalUrl);
if (img) {
img.modified = true;
this.images.set(getUrl(newSrc, this.window!).href, img);
this.images.set(getUrl(newSrc, this.baseUrl).href, img);
this.images.delete(originalUrl);
}
}

View file

@ -11,7 +11,7 @@ import {
Injectable,
InjectionToken,
ɵformatRuntimeError as formatRuntimeError,
ɵRuntimeError as RuntimeError,
PLATFORM_ID,
} from '@angular/core';
import {DOCUMENT} from '../../dom_tokens';
@ -20,6 +20,8 @@ import {RuntimeErrorCode} from '../../errors';
import {assertDevMode} from './asserts';
import {imgDirectiveDetails} from './error_helper';
import {extractHostname, getUrl} from './url';
import {isPlatformServer} from '../../platform_id';
import {PlatformLocation} from '../../location';
// Set of origins that are always excluded from the preconnect checks.
const INTERNAL_PRECONNECT_CHECK_BLOCKLIST = new Set(['localhost', '127.0.0.1', '0.0.0.0']);
@ -56,6 +58,8 @@ export const PRECONNECT_CHECK_BLOCKLIST = new InjectionToken<Array<string | stri
@Injectable({providedIn: 'root'})
export class PreconnectLinkChecker {
private document = inject(DOCUMENT);
private readonly isServer = isPlatformServer(inject(PLATFORM_ID));
private readonly platformLocation = inject(PlatformLocation);
/**
* Set of <link rel="preconnect"> tags found on this page.
@ -68,16 +72,10 @@ export class PreconnectLinkChecker {
*/
private alreadySeen = new Set<string>();
private window: Window | null = null;
private blocklist = new Set<string>(INTERNAL_PRECONNECT_CHECK_BLOCKLIST);
constructor() {
assertDevMode('preconnect link checker');
const win = this.document.defaultView;
if (typeof win !== 'undefined') {
this.window = win;
}
const blocklist = inject(PRECONNECT_CHECK_BLOCKLIST, {optional: true});
if (blocklist) {
this.populateBlocklist(blocklist);
@ -102,9 +100,9 @@ export class PreconnectLinkChecker {
* @param originalNgSrc ngSrc value
*/
assertPreconnect(rewrittenSrc: string, originalNgSrc: string): void {
if (!this.window) return;
if (this.isServer) return;
const imgUrl = getUrl(rewrittenSrc, this.window);
const imgUrl = getUrl(rewrittenSrc, this.platformLocation.href);
if (this.blocklist.has(imgUrl.hostname) || this.alreadySeen.has(imgUrl.origin)) return;
// Register this origin as seen, so we don't check it again later.
@ -135,7 +133,7 @@ export class PreconnectLinkChecker {
const selector = 'link[rel=preconnect]';
const links: HTMLLinkElement[] = Array.from(this.document.querySelectorAll(selector));
for (let link of links) {
const url = getUrl(link.href, this.window!);
const url = getUrl(link.href, this.platformLocation.href);
preconnectUrls.add(url.origin);
}
return preconnectUrls;

View file

@ -7,9 +7,9 @@
*/
// Converts a string that represents a URL into a URL class instance.
export function getUrl(src: string, win: Window): URL {
export function getUrl(src: string, href: string): URL {
// Don't use a base URL is the URL is absolute.
return isAbsoluteUrl(src) ? new URL(src) : new URL(src, win.location.href);
return isAbsoluteUrl(src) ? new URL(src) : new URL(src, href);
}
// Checks whether a URL is absolute (i.e. starts with `http://` or `https://`).

View file

@ -6,7 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
import {CommonModule, DOCUMENT, IMAGE_CONFIG, ImageConfig} from '@angular/common';
import {CommonModule, DOCUMENT, IMAGE_CONFIG, ImageConfig, PlatformLocation} from '@angular/common';
import {RuntimeErrorCode} from '@angular/common/src/errors';
import {PLATFORM_SERVER_ID} from '@angular/common/src/platform_id';
import {ChangeDetectionStrategy, Component, PLATFORM_ID, Provider, Type} from '@angular/core';
@ -32,6 +32,7 @@ import {
RECOMMENDED_SRCSET_DENSITY_CAP,
} from '../../src/directives/ng_optimized_image/ng_optimized_image';
import {PRECONNECT_CHECK_BLOCKLIST} from '../../src/directives/ng_optimized_image/preconnect_link_checker';
import {MockPlatformLocation} from '@angular/common/testing';
describe('Image directive', () => {
describe('preload <link> element on a server', () => {
@ -1243,6 +1244,9 @@ describe('Image directive', () => {
it(
'should log a warning if there is no preconnect link for a priority image',
withHead('', () => {
// The warning is only logged on the client
if (!isBrowser) return;
setupTestingModule({imageLoader});
const consoleWarnSpy = spyOn(console, 'warn');
@ -1281,6 +1285,9 @@ describe('Image directive', () => {
it(
"should log a warning if there is a preconnect, but it doesn't match the priority image",
withHead('<link rel="preconnect" href="http://angular.io">', () => {
// The warning is only logged on the client
if (!isBrowser) return;
setupTestingModule({imageLoader});
const consoleWarnSpy = spyOn(console, 'warn');
@ -1305,6 +1312,9 @@ describe('Image directive', () => {
withHead(
'<link rel="preload" href="https://angular.io/assets/images/logos/angular/angular.svg" as="image">',
() => {
// The warning is only logged on the client
if (!isBrowser) return;
setupTestingModule({imageLoader});
const consoleWarnSpy = spyOn(console, 'warn');
@ -2165,6 +2175,14 @@ function setupTestingModule(config?: {
{provide: DOCUMENT, useValue: window.document},
...(config?.noLoader ? [] : [{provide: IMAGE_LOADER, useValue: loader}]),
...extraProviders,
{
provide: PlatformLocation,
useFactory: () => {
return new MockPlatformLocation({
appBaseHref: 'https://angular.io/',
});
},
},
];
if (config?.imageConfig) {
providers.push({provide: IMAGE_CONFIG, useValue: config.imageConfig});
@ -2185,7 +2203,17 @@ function setUpModuleNoLoader() {
TestBed.configureTestingModule({
declarations: [TestComponent],
imports: [CommonModule, NgOptimizedImage],
providers: [{provide: DOCUMENT, useValue: window.document}],
providers: [
{provide: DOCUMENT, useValue: window.document},
{
provide: PlatformLocation,
useFactory: () => {
return new MockPlatformLocation({
appBaseHref: 'https://angular.io/',
});
},
},
],
});
}