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 @@ -783,7 +783,12 @@ export class NgOptimizedImage implements OnInit, OnChanges {
}

private setHostAttribute(name: string, value: string): void {
this.renderer.setAttribute(this.imgElement, name, value);
// The browser selects the image source again after a write, even with an identical value.
// An image that still has a placeholder keeps its writes, so that an image that failed before
// hydration fires `error` again and the placeholder is removed.
if (this.placeholder || this.imgElement.getAttribute(name) !== value) {
this.renderer.setAttribute(this.imgElement, name, value);
}
}
}

Expand Down
34 changes: 33 additions & 1 deletion packages/platform-server/test/event_replay_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
ɵgetDocument as getDocument,
ɵEVENT_REPLAY_QUEUE as EVENT_REPLAY_QUEUE,
} from '@angular/core';
import {isPlatformBrowser} from '@angular/common';
import {IMAGE_LOADER, isPlatformBrowser, NgOptimizedImage} from '@angular/common';
import {
bootstrapApplication,
provideClientHydration,
Expand Down Expand Up @@ -138,6 +138,38 @@ describe('event replay', () => {
expect(onClickSpy).toHaveBeenCalledTimes(1);
});

it('should replay a `load` event that fired before hydration on an optimized image', async () => {
const onLoadSpy = jasmine.createSpy();

@Component({
selector: 'app',
imports: [NgOptimizedImage],
template: `<img id="image" ngSrc="hero.jpg" width="100" height="50" (load)="onLoad()" />`,
})
class AppComponent {
onLoad = onLoadSpy;
}

const envProviders = [
{provide: IMAGE_LOADER, useValue: () => 'https://cdn.example.com/hero.jpg'},
];
const html = await ssr(AppComponent, {envProviders});
const doc = getDocument();
prepareEnvironment(doc, getAppContents(html));
resetTViewsFor(AppComponent);

// The image finishes loading before the app hydrates.
const loadEvent = doc.createEvent('Event');
loadEvent.initEvent('load', false, false);
doc.getElementById('image')!.dispatchEvent(loadEvent);
expect(onLoadSpy).not.toHaveBeenCalled();

const appRef = await hydrate(doc, AppComponent, {envProviders});
await appRef.whenStable();

expect(onLoadSpy).toHaveBeenCalledTimes(1);
});

it('should work for elements with local refs', async () => {
const onClickSpy = jasmine.createSpy();

Expand Down
91 changes: 91 additions & 0 deletions packages/platform-server/test/full_app_hydration_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ import '@angular/localize/init';
import {
CommonModule,
DOCUMENT,
IMAGE_LOADER,
isPlatformBrowser,
isPlatformServer,
NgComponentOutlet,
NgFor,
NgIf,
NgOptimizedImage,
NgSwitch,
NgSwitchCase,
NgTemplateOutlet,
Expand Down Expand Up @@ -62,6 +64,8 @@ import {provideRouter, RouterOutlet, Routes} from '@angular/router';
import {
clearDocument,
getAppContents,
hydrate,
prepareEnvironment,
prepareEnvironmentAndHydrate,
resetTViewsFor,
stripUtilAttributes,
Expand Down Expand Up @@ -8462,6 +8466,93 @@ describe('platform-server full application hydration integration', () => {
});
});

describe('NgOptimizedImage', () => {
const envProviders = [
{provide: IMAGE_LOADER, useValue: () => 'https://cdn.example.com/hero.jpg'},
];
const directiveAttrs = ['src', 'srcset', 'loading', 'fetchpriority', 'decoding', 'ng-img'];

@Component({
selector: 'app',
imports: [NgOptimizedImage],
template: `<img ngSrc="hero.jpg" width="400" height="300" />`,
})
class ImageComponent {}

@Component({
selector: 'app',
imports: [NgOptimizedImage],
template: `
<img
ngSrc="hero.jpg"
width="40"
height="40"
placeholder="data:image/gif;base64,R0lGODlhAQABAAAAACw="
/>
`,
})
class PlaceholderImageComponent {}

async function renderOnServer(component: Type<unknown>): Promise<HTMLImageElement> {
const html = await ssr(component, {envProviders});
resetTViewsFor(component);
prepareEnvironment(doc, html);
return doc.querySelector('img')!;
}

async function hydrateImage(component: Type<unknown>): Promise<void> {
const appRef = await hydrate(doc, component, {envProviders});
await appRef.whenStable();
}

// DOM shims define `setAttribute` as a read-only inherited property, which `spyOn` can't
// replace, so record the calls through an own property instead.
function recordDirectiveWrites(img: HTMLImageElement): string[] {
const written: string[] = [];
const setAttribute = img.setAttribute;
Object.defineProperty(img, 'setAttribute', {
configurable: true,
value(this: Element, name: string, value: string) {
if (directiveAttrs.includes(name)) {
written.push(name);
}
return setAttribute.call(this, name, value);
},
});
return written;
}

it('should not write again the attributes that it rendered on the server', async () => {
const img = await renderOnServer(ImageComponent);
const ssrValues = directiveAttrs.map((name) => img.getAttribute(name));
const written = recordDirectiveWrites(img);

await hydrateImage(ImageComponent);

expect(written).toEqual([]);
expect(directiveAttrs.map((name) => img.getAttribute(name))).toEqual(ssrValues);
});

it('should write an attribute whose value differs from the server', async () => {
const img = await renderOnServer(ImageComponent);
img.setAttribute('src', 'https://cdn.example.com/stale.jpg');

await hydrateImage(ImageComponent);

expect(img.getAttribute('src')).toBe('https://cdn.example.com/hero.jpg');
});

it('should write again the attributes of an image that still has a placeholder', async () => {
const img = await renderOnServer(PlaceholderImageComponent);
const written = recordDirectiveWrites(img);

await hydrateImage(PlaceholderImageComponent);

expect(written).toContain('src');
expect(img.getAttribute('style')).toContain('background-image');
});
});

describe('Router', () => {
it('should wait for lazy routes before triggering post-hydration cleanup', async () => {
const ngZone = TestBed.inject(NgZone);
Expand Down