Skip to content

Commit d5fd51e

Browse files
sonukapooramishne
authored andcommitted
fix(core): prevent event replay double-invocation when element hydrates before app stability
When `withEventReplay()` is enabled and a component hydrates before the application becomes stable (e.g. while a pending HTTP request is in flight), a user interaction on the hydrated element triggers both the real DOM listener registered by Angular and the jsaction replay path. This causes the event handler to be invoked twice. The root cause is that `listenToDomEvent` registers the same `wrappedListener` both as a stashed jsaction handler (via `stashEventListenerImpl`) and as a native DOM listener (via `renderer.listen`). When the user interacts after hydration but before app stability, jsaction queues the event because no dispatcher is registered yet. Once the app stabilises and `initEventReplay` runs, jsaction replays the queued event through `invokeListeners`, which calls the stashed handler a second time. The fix tracks dispatched `(event, element)` pairs in a `WeakMap<Event, WeakSet<Element>>`. The native DOM listener wrapper records each pair via `markEventHandledForElement`, and `invokeListeners` skips replay for any pair already present. Keying by element (rather than event alone) preserves incremental hydration behaviour, where jsaction legitimately replays the same event on a different element (the deferred block content) from the one that originally triggered hydration. Fixes #67328
1 parent 67a5f00 commit d5fd51e

3 files changed

Lines changed: 78 additions & 1 deletion

File tree

packages/core/src/event_delegation_utils.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,11 +101,30 @@ export const JSACTION_EVENT_CONTRACT = new InjectionToken<EventContractDetails>(
101101
},
102102
);
103103

104+
// Tracks (event, element) pairs already dispatched by a real DOM listener
105+
// post-hydration. Keyed per element so that jsaction can still replay the same
106+
// event on a *different* element (e.g. incremental hydration replays on a
107+
// deferred block's element, not the trigger that originally fired). Prevents
108+
// double-invocation when a component hydrates before app stability (#67328).
109+
const handledEventElements = new WeakMap<Event, WeakSet<Element>>();
110+
111+
export function markEventHandledForElement(event: Event, element: Element): void {
112+
let elements = handledEventElements.get(event);
113+
if (!elements) {
114+
elements = new WeakSet<Element>();
115+
handledEventElements.set(event, elements);
116+
}
117+
elements.add(element);
118+
}
119+
104120
export function invokeListeners(event: Event, currentTarget: Element | null) {
105121
const handlerFns = currentTarget?.__jsaction_fns?.get(event.type);
106122
if (!handlerFns || !currentTarget?.isConnected) {
107123
return;
108124
}
125+
if (currentTarget && handledEventElements.get(event)?.has(currentTarget)) {
126+
return;
127+
}
109128
for (const handler of handlerFns) {
110129
handler(event);
111130
}

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

Lines changed: 12 additions & 1 deletion
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+
markEventHandledForElement,
3132
stashEventListenerImpl,
3233
type WrappedEventCallback,
3334
} from '../../event_delegation_utils';
@@ -162,7 +163,17 @@ export function listenToDomEvent(
162163

163164
stashEventListenerImpl(lView, target, eventName, wrappedListener);
164165

165-
const cleanupFn = renderer.listen(target as RElement, eventName, wrappedListener);
166+
// Wrap the real DOM listener to mark the (event, element) pair as handled.
167+
// This prevents jsaction from replaying events that were already dispatched
168+
// by a real DOM listener post-hydration while keeping replay working for
169+
// other elements (e.g. incremental hydration — see #67328).
170+
const domListener = (event: Event) => {
171+
if (!eventTargetResolver) {
172+
markEventHandledForElement(event, native as unknown as Element);
173+
}
174+
return wrappedListener(event);
175+
};
176+
const cleanupFn = renderer.listen(target as RElement, eventName, domListener);
166177

167178
// We skip cleaning up animation event types to ensure leaving animation events can be used.
168179
// These events should be automatically garbage collected anyway after the element is

packages/platform-server/test/event_replay_spec.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,53 @@ describe('event replay', () => {
9090
window._ejsas = {};
9191
});
9292

93+
// Issue #67328: post-hydration events must not be replayed
94+
it('should not replay an event that was already handled by a real DOM listener after component hydration', async () => {
95+
const onClickSpy = jasmine.createSpy();
96+
let removeTask: (() => void) | null = null;
97+
98+
@Component({
99+
selector: 'app',
100+
template: `<button id="btn" (click)="onClick()"></button>`,
101+
})
102+
class AppComponent {
103+
constructor() {
104+
if (isPlatformBrowser(inject(PLATFORM_ID))) {
105+
removeTask = inject(PendingTasks).add();
106+
}
107+
}
108+
onClick = onClickSpy;
109+
}
110+
111+
const hydrationFeatures = () => [withEventReplay()];
112+
const html = await ssr(AppComponent, {hydrationFeatures});
113+
const ssrContents = getAppContents(html);
114+
const doc = getDocument();
115+
116+
prepareEnvironment(doc, ssrContents);
117+
resetTViewsFor(AppComponent);
118+
119+
// Hydrate the component (registers real DOM listeners) but keep app unstable
120+
const appRef = await hydrate(doc, AppComponent, {hydrationFeatures});
121+
appRef.tick();
122+
123+
// Click AFTER hydration: real DOM listener fires once, jsaction also queues it
124+
const btn = doc.getElementById('btn')!;
125+
btn.click();
126+
127+
// Real DOM listener should have fired once
128+
expect(onClickSpy).toHaveBeenCalledTimes(1);
129+
130+
// Let the app become stable — triggers whenStable() which kicks off jsaction replay
131+
removeTask!();
132+
await appRef.whenStable();
133+
appRef.tick();
134+
135+
// Without the fix, jsaction replay fires the handler a second time (count = 2).
136+
// With the fix, the post-hydration event must not be replayed again (count = 1).
137+
expect(onClickSpy).toHaveBeenCalledTimes(1);
138+
});
139+
93140
it('should work for elements with local refs', async () => {
94141
const onClickSpy = jasmine.createSpy();
95142

0 commit comments

Comments
 (0)