Skip to content

Commit 6bcb31d

Browse files
committed
fix(core): prevent double invocation of load listener on img/iframe during event replay
When SSR with `withEventReplay()` is used in zoneless mode, Angular registers a native DOM listener on elements like `<img>` via `renderer.listen`, and also stashes the same listener in `__jsaction_fns` for replay. After hydration, `invokeListeners` calls the handler once (the replay), but the browser independently fires a native `load` event on the element as it finishes loading — triggering the listener a second time. The fix introduces a counter map on the element (keyed by event type) that `invokeListeners` increments before calling handlers. The native DOM listener then checks this map via `shouldSkipNativeListener` and skips itself if a replay already handled the event. The check is limited to elements that load external resources on their own (`img`, `iframe`, `script`, `link`, `object`, `embed`, `input`) — bubbling events like `click` on other elements are unaffected. `shouldSkipNativeListener` follows the `enableImpl` pattern: it defaults to `() => false` so terser/esbuild eliminate the branch entirely in apps that don't use event replay. `enableSkipNativeListenerImpl()` is called from the `withEventReplay()` environment initializer to swap in the real implementation. Fixes #59260
1 parent 337e6e7 commit 6bcb31d

5 files changed

Lines changed: 116 additions & 1 deletion

File tree

packages/core/src/event_delegation_utils.ts

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,26 @@
88

99
// tslint:disable:no-duplicate-imports
1010
import type {EventContract} from '../primitives/event-dispatch';
11-
import {Attribute} from '../primitives/event-dispatch';
11+
import {Attribute, EventPhase} from '../primitives/event-dispatch';
1212
import {APP_ID} from './application/application_tokens';
1313
import {InjectionToken} from './di';
1414
import type {RElement, RNode} from './render3/interfaces/renderer_dom';
1515
import {INJECTOR, type LView} from './render3/interfaces/view';
1616

1717
export const DEFER_BLOCK_SSR_ID_ATTRIBUTE = 'ngb';
1818

19+
/**
20+
* A symbol used to track which event types have been replayed on a given element via
21+
* `invokeListeners`. This prevents the native DOM listener (registered by the `listener`
22+
* instruction in templates) from also firing for an event that was already handled by
23+
* the event-replay mechanism, which would cause the handler to be called twice.
24+
*/
25+
const REPLAYED_EVENTS_KEY: unique symbol = /* @__PURE__ */ Symbol('ngReplayedEvents');
26+
1927
declare global {
2028
interface Element {
2129
__jsaction_fns: Map<string, Function[]> | undefined;
30+
[REPLAYED_EVENTS_KEY]?: Map<string, number>;
2231
}
2332
}
2433

@@ -106,11 +115,68 @@ export function invokeListeners(event: Event, currentTarget: Element | null) {
106115
if (!handlerFns || !currentTarget?.isConnected) {
107116
return;
108117
}
118+
// Track that we're about to replay this event so the native DOM listener can detect
119+
// it was already handled and skip itself.
120+
const replayedEvents = currentTarget[REPLAYED_EVENTS_KEY] ?? new Map<string, number>();
121+
replayedEvents.set(event.type, (replayedEvents.get(event.type) ?? 0) + 1);
122+
currentTarget[REPLAYED_EVENTS_KEY] = replayedEvents;
109123
for (const handler of handlerFns) {
110124
handler(event);
111125
}
112126
}
113127

128+
// Elements whose resources load independently and can fire `load`/`error` on their own
129+
// after hydration. Other elements (div, span, etc.) only get events from user interaction,
130+
// so they don't need this treatment.
131+
const AUTO_LOAD_ELEMENTS = /^(IMG|IFRAME|SCRIPT|LINK|OBJECT|EMBED|INPUT)$/;
132+
133+
// Guards against calling enableSkipNativeListenerImpl more than once.
134+
let isSkipNativeListenerImplEnabled = false;
135+
136+
let _shouldSkipNativeListenerImpl: (event: Event) => boolean = () => false;
137+
138+
/**
139+
* Returns whether the native DOM listener should be skipped for this event because
140+
* `invokeListeners` already called it during replay.
141+
*
142+
* Defaults to `() => false` so that terser/esbuild can inline the constant and drop
143+
* the branch in apps that don't use event replay. The real implementation is swapped
144+
* in by `enableSkipNativeListenerImpl` when `withEventReplay()` is configured.
145+
*/
146+
export function shouldSkipNativeListener(event: Event): boolean {
147+
return _shouldSkipNativeListenerImpl(event);
148+
}
149+
150+
/**
151+
* Installs the real `shouldSkipNativeListener` implementation. Called by `withEventReplay()`
152+
* so the logic is only included in bundles that actually need event replay.
153+
*/
154+
export function enableSkipNativeListenerImpl(): void {
155+
if (!isSkipNativeListenerImplEnabled) {
156+
_shouldSkipNativeListenerImpl = (event: Event): boolean => {
157+
// The replay invocation arrives with eventPhase === EventPhase.REPLAY — let it through.
158+
// We only want to suppress the native DOM listener that fires afterwards.
159+
if (event.eventPhase === EventPhase.REPLAY) return false;
160+
161+
const target = (event.currentTarget ?? event.target) as Element | null;
162+
// Only relevant for elements that load resources on their own. For everything else
163+
// (div, button, etc.) the native listener should always fire normally.
164+
if (!target || !AUTO_LOAD_ELEMENTS.test(target.tagName)) {
165+
return false;
166+
}
167+
168+
const map = target[REPLAYED_EVENTS_KEY];
169+
const count = map?.get(event.type);
170+
if (!count) return false;
171+
172+
count <= 1 ? map!.delete(event.type) : map!.set(event.type, count - 1);
173+
return true;
174+
};
175+
176+
isSkipNativeListenerImplEnabled = true;
177+
}
178+
}
179+
114180
/** Shorthand for an event listener callback function to reduce duplication. */
115181
export type EventCallback = (event?: any) => any;
116182

packages/core/src/hydration/event_replay.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
invokeListeners,
4343
removeListeners,
4444
enableStashEventListenerImpl,
45+
enableSkipNativeListenerImpl,
4546
setStashFn,
4647
} from '../event_delegation_utils';
4748
import {APP_ID} from '../application/application_tokens';
@@ -108,6 +109,7 @@ export function withEventReplay(): Provider[] {
108109
const jsActionMap = inject(JSACTION_BLOCK_ELEMENT_MAP);
109110
if (shouldEnableEventReplay(injector)) {
110111
enableStashEventListenerImpl();
112+
enableSkipNativeListenerImpl();
111113
const appId = injector.get(APP_ID);
112114
const clearStashFn = setStashFn(
113115
appId,

packages/core/src/render3/view/listeners.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {assertNotSame} from '../../util/assert';
2828
import {handleUncaughtError} from '../instructions/shared';
2929
import {
3030
type EventCallback,
31+
shouldSkipNativeListener,
3132
stashEventListenerImpl,
3233
type WrappedEventCallback,
3334
} from '../../event_delegation_utils';
@@ -50,6 +51,13 @@ export function wrapListener(
5051
// Note: we are performing most of the work in the listener function itself
5152
// to optimize listener registration.
5253
return function wrapListenerIn_markDirtyAndPreventDefault(event: any) {
54+
// During event replay, `invokeListeners` calls this function directly. Elements like
55+
// `<img>` may then fire a native `load` event on their own — skip it so the handler
56+
// doesn't run twice. Without event replay, `shouldSkipNativeListener` is a `() => false`
57+
// stub that terser/esbuild inline away, so there's no overhead.
58+
if (shouldSkipNativeListener(event)) {
59+
return false;
60+
}
5361
// In order to be backwards compatible with View Engine, events on component host nodes
5462
// must also mark the component view itself dirty (i.e. the view that it owns).
5563
const startView = isComponentHost(tNode) ? getComponentLViewByIndex(tNode.index, lView) : lView;

packages/core/test/bundling/hydration/bundle.golden_symbols.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"APP_ID",
1313
"APP_ID_ATTRIBUTE_NAME",
1414
"APP_INITIALIZER",
15+
"AUTO_LOAD_ELEMENTS",
1516
"ActionResolver",
1617
"AfterRenderImpl",
1718
"AfterRenderManager",
@@ -245,6 +246,7 @@
245246
"REMOVE_STYLES_ON_COMPONENT_DESTROY",
246247
"REMOVE_STYLES_ON_COMPONENT_DESTROY_DEFAULT",
247248
"RENDERER",
249+
"REPLAYED_EVENTS_KEY",
248250
"REQ_URL",
249251
"RESPONSE_TYPE",
250252
"RendererFactory2",
@@ -350,6 +352,7 @@
350352
"_requestIdleCallback",
351353
"_retrieveDeferBlockDataImpl",
352354
"_retrieveHydrationInfoImpl",
355+
"_shouldSkipNativeListenerImpl",
353356
"_stashEventListenerImpl",
354357
"_wasLastNodeCreated",
355358
"acceptNode",
@@ -510,6 +513,7 @@
510513
"enableLocateOrCreateTextNodeImpl",
511514
"enableRetrieveDeferBlockDataImpl",
512515
"enableRetrieveHydrationInfoImpl",
516+
"enableSkipNativeListenerImpl",
513517
"enableStashEventListenerImpl",
514518
"enterDI",
515519
"enterSkipHydrationBlock",
@@ -756,6 +760,7 @@
756760
"isRootView",
757761
"isScheduler",
758762
"isSchedulerTick",
763+
"isSkipNativeListenerImplEnabled",
759764
"isSsrContentsIntegrity",
760765
"isStashEventListenerImplEnabled",
761766
"isSubscribable",

packages/platform-server/test/event_replay_spec.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,40 @@ describe('event replay', () => {
114114
expect(onClickSpy).toHaveBeenCalled();
115115
});
116116

117+
it('should replay (load) event exactly once', async () => {
118+
// Regression test: https://github.com/angular/angular/issues/59260
119+
// withEventReplay() was calling the (load) handler twice — once via
120+
// invokeRegisteredReplayListeners and again via the listener instruction.
121+
const onLoadSpy = jasmine.createSpy('onLoad');
122+
123+
@Component({
124+
selector: 'app',
125+
template: `<img id="img" src="https://placehold.co/600x400" (load)="onLoad()" />`,
126+
})
127+
class AppComponent {
128+
onLoad = onLoadSpy;
129+
}
130+
131+
const hydrationFeatures = () => [withEventReplay()];
132+
const html = await ssr(AppComponent, {hydrationFeatures});
133+
const ssrContents = getAppContents(html);
134+
const doc = getDocument();
135+
136+
prepareEnvironment(doc, ssrContents);
137+
resetTViewsFor(AppComponent);
138+
139+
const img = doc.getElementById('img')!;
140+
// Simulate the image load event firing before Angular hydrates (e.g. cached image).
141+
// `load` does not bubble, but jsaction registers a capture listener on the document
142+
// so it will still be stashed for replay.
143+
img.dispatchEvent(new Event('load'));
144+
145+
const appRef = await hydrate(doc, AppComponent, {hydrationFeatures});
146+
appRef.tick();
147+
148+
expect(onLoadSpy).toHaveBeenCalledTimes(1);
149+
});
150+
117151
it('stash event listeners should not conflict when multiple apps are bootstrapped', async () => {
118152
const onClickSpy = jasmine.createSpy();
119153

0 commit comments

Comments
 (0)