Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,7 @@ export class NgOptimizedImage implements OnInit, OnChanges {
this.getRewrittenSrc(),
rewrittenSrcset,
this.sizes,
this.imgElement.getAttribute('crossorigin'),
);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,17 @@ export class PreloadLinkCreator {
* @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
* @param crossOrigin The value of the `crossorigin` attribute passed in to the `<img>` tag
*/
createPreloadLinkTag(renderer: Renderer2, src: string, srcset?: string, sizes?: string): void {
createPreloadLinkTag(
renderer: Renderer2,
src: string,
srcset?: string,
sizes?: string,
crossOrigin?: string | null,
): void {
const preloadKey = JSON.stringify([src, getCrossOriginMode(crossOrigin)]);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm questionning wether we need to use the cross origin mode in the key.
Do you see a true usecase where a src would be use with different cross origin mode ?

@SkyZeroZx SkyZeroZx Jul 11, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, indeed, according to our issues

We might want to use a different CDN at runtime or according to user customization. I've also experimented with switching CDNs using some runtime tricks, so I don't think it's an unusual use case.

Or if you're referring to the key, it's more for security to avoid an unwanted case (it would be strange if it wasn't consistent).

Eg the same image endpoint could redirect or return different content depending on whether credentials are included. It may also be used normally in one place and with crossorigin="anonymous" for some usage elsewhere.

Since the browser treats the request mode and credentials mode as part of the preload key, preloads for the same URL but different CORS modes are not interchangeable.


if (
ngDevMode &&
!this.errorShown &&
Expand All @@ -66,18 +75,22 @@ export class PreloadLinkCreator {
);
}

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

this.preloadedImages.add(src);
this.preloadedImages.add(preloadKey);

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

if (crossOrigin != null) {
renderer.setAttribute(preload, 'crossorigin', crossOrigin);
}

if (sizes) {
renderer.setAttribute(preload, 'imageSizes', sizes);
}
Expand All @@ -89,3 +102,11 @@ export class PreloadLinkCreator {
renderer.appendChild(this.document.head, preload);
}
}

function getCrossOriginMode(crossOrigin?: string | null): string | null {
if (crossOrigin == null) {
return null;
}

return crossOrigin.toLowerCase() === 'use-credentials' ? 'use-credentials' : 'anonymous';
}
8 changes: 3 additions & 5 deletions packages/common/src/directives/ng_optimized_image/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,9 @@ import {InjectionToken} from '@angular/core';
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`.
* Helps to keep track of priority images that already have a corresponding preload tag. Each key
* identifies the rewritten image URL and its CORS mode, since preload tags with different CORS
* modes are not interchangeable.
*/
export const PRELOADED_IMAGES = new InjectionToken<Set<string>>(
typeof ngDevMode === 'undefined' || ngDevMode ? 'NG_OPTIMIZED_PRELOADED_IMAGES' : '',
Expand Down
39 changes: 37 additions & 2 deletions packages/common/test/directives/ng_optimized_image_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe('Image directive', () => {
],
});

const template = `<img ngSrc="${src}" width="150" height="50" priority sizes="10vw" ngSrcset="100w">`;
const template = `<img ngSrc="${src}" width="150" height="50" priority sizes="10vw" ngSrcset="100w" crossorigin="anonymous">`;
TestBed.overrideComponent(TestComponent, {set: {template: template}});

const _document = TestBed.inject(DOCUMENT);
Expand Down Expand Up @@ -98,6 +98,7 @@ describe('Image directive', () => {
expect(preloadLink!.getAttribute('imagesizes')).toEqual('10vw');
expect(preloadLink!.getAttribute('imagesrcset')).toEqual(`${rewrittenSrc}?width=100 100w`);
expect(preloadLink!.getAttribute('fetchpriority')).toEqual('high');
expect(preloadLink!.getAttribute('crossorigin')).toEqual('anonymous');

preloadLink!.remove();
});
Expand Down Expand Up @@ -133,7 +134,7 @@ describe('Image directive', () => {

const preloadImages = TestBed.inject(PRELOADED_IMAGES);

expect(preloadImages.has(rewrittenSrc)).toBeTruthy();
expect(preloadImages.has(JSON.stringify([rewrittenSrc, null]))).toBeTruthy();

const preloadLinks = head.querySelectorAll(`link[href="${rewrittenSrc}"]`);

Expand All @@ -142,6 +143,40 @@ describe('Image directive', () => {
preloadLinks[0]!.remove();
});

it('should create separate preload links for images with different CORS modes', async () => {
if (!isBrowser) return;

const src = 'preload-cors/img.png';
const rewrittenSrc = `https://angular.dev/${src}`;

setupTestingModule({
extraProviders: [
{provide: PLATFORM_ID, useValue: PLATFORM_SERVER_ID},
{
provide: IMAGE_LOADER,
useValue: (config: ImageLoaderConfig) => `https://angular.dev/${config.src}`,
},
],
});

const template = `
<img ngSrc="${src}" width="150" height="50" priority>
<img ngSrc="${src}" width="150" height="50" priority crossorigin="anonymous">
`;
TestBed.overrideComponent(TestComponent, {set: {template}});

const _document = TestBed.inject(DOCUMENT);
const fixture = TestBed.createComponent(TestComponent);
await fixture.whenStable();

const preloadLinks = _document.head.querySelectorAll(`link[href="${rewrittenSrc}"]`);
expect(preloadLinks.length).toEqual(2);
expect(preloadLinks[0]!.hasAttribute('crossorigin')).toBeFalse();
expect(preloadLinks[1]!.getAttribute('crossorigin')).toEqual('anonymous');

preloadLinks.forEach((link) => link.remove());
});

it('should warn when the number of preloaded images is larger than the limit', () => {
// Only run this test in a browser since the Node-based DOM mocks don't
// allow to override `HTMLImageElement.prototype.setAttribute` easily.
Expand Down
Loading