Skip to content

Commit 7ab0a8d

Browse files
thePunderWomanmmalerba
authored andcommitted
fix(core): prevents event replay from being called on comment nodes (#60130)
In some rare cases with directives, it is possible that the stash function might be called on a comment node. This actually verifies that the node is an element and exits otherwise. fixes: #60070 PR Close #60130
1 parent 8a73327 commit 7ab0a8d

File tree

4 files changed

+143
-9
lines changed

4 files changed

+143
-9
lines changed

packages/core/src/event_delegation_utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@ export const sharedStashFunction = (rEl: RElement, eventType: string, listenerFn
5757
};
5858

5959
export const sharedMapFunction = (rEl: RElement, jsActionMap: Map<string, Set<Element>>) => {
60-
let blockName = rEl.getAttribute(DEFER_BLOCK_SSR_ID_ATTRIBUTE) ?? '';
6160
const el = rEl as unknown as Element;
61+
let blockName = el.getAttribute(DEFER_BLOCK_SSR_ID_ATTRIBUTE) ?? '';
6262
const blockSet = jsActionMap.get(blockName) ?? new Set<Element>();
6363
if (!blockSet.has(el)) {
6464
blockSet.add(el);

packages/core/src/hydration/event_replay.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {ENVIRONMENT_INITIALIZER, Injector} from '../di';
2323
import {inject} from '../di/injector_compatibility';
2424
import {Provider} from '../di/interface/provider';
2525
import {setStashFn} from '../render3/instructions/listener';
26-
import {RElement} from '../render3/interfaces/renderer_dom';
26+
import {RElement, RNode} from '../render3/interfaces/renderer_dom';
2727
import {CLEANUP, LView, TView} from '../render3/interfaces/view';
2828
import {unwrapRNode} from '../render3/util/view_utils';
2929

@@ -106,9 +106,13 @@ export function withEventReplay(): Provider[] {
106106
if (!appsWithEventReplay.has(appRef)) {
107107
const jsActionMap = inject(JSACTION_BLOCK_ELEMENT_MAP);
108108
if (shouldEnableEventReplay(injector)) {
109-
setStashFn((rEl: RElement, eventName: string, listenerFn: VoidFunction) => {
110-
sharedStashFunction(rEl, eventName, listenerFn);
111-
sharedMapFunction(rEl, jsActionMap);
109+
setStashFn((rEl: RNode, eventName: string, listenerFn: VoidFunction) => {
110+
// If a user binds to a ng-container and uses a directive that binds using a host listener,
111+
// this element could be a comment node. So we need to ensure we have an actual element
112+
// node before stashing anything.
113+
if ((rEl as Node).nodeType !== Node.ELEMENT_NODE) return;
114+
sharedStashFunction(rEl as RElement, eventName, listenerFn);
115+
sharedMapFunction(rEl as RElement, jsActionMap);
112116
});
113117
}
114118
}

packages/core/src/render3/instructions/listener.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {NotificationSource} from '../../change_detection/scheduling/zoneless_sch
1212
import {assertIndexInRange} from '../../util/assert';
1313
import {TNode, TNodeType} from '../interfaces/node';
1414
import {GlobalTargetResolver, Renderer} from '../interfaces/renderer';
15-
import {RElement} from '../interfaces/renderer_dom';
15+
import {RElement, RNode} from '../interfaces/renderer_dom';
1616
import {isComponentHost, isDirectiveHost} from '../interfaces/type_checks';
1717
import {CLEANUP, CONTEXT, LView, RENDERER, TView} from '../interfaces/view';
1818
import {assertTNodeType} from '../node_assert';
@@ -37,7 +37,7 @@ import {DirectiveDef} from '../interfaces/definition';
3737
* an actual implementation when the event replay feature is enabled via
3838
* `withEventReplay()` call.
3939
*/
40-
let stashEventListener = (el: RElement, eventName: string, listenerFn: (e?: any) => any) => {};
40+
let stashEventListener = (el: RNode, eventName: string, listenerFn: (e?: any) => any) => {};
4141

4242
export function setStashFn(fn: typeof stashEventListener) {
4343
stashEventListener = fn;
@@ -218,7 +218,7 @@ export function listenerInternal(
218218
processOutputs = false;
219219
} else {
220220
listenerFn = wrapListener(tNode, lView, context, listenerFn);
221-
stashEventListener(native, eventName, listenerFn);
221+
stashEventListener(target as RElement, eventName, listenerFn);
222222
const cleanupFn = renderer.listen(target as RElement, eventName, listenerFn);
223223
ngDevMode && ngDevMode.rendererAddEventListener++;
224224

packages/platform-server/test/event_replay_spec.ts

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,15 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {APP_ID, Component, destroyPlatform, ErrorHandler, PLATFORM_ID} from '@angular/core';
9+
import {
10+
APP_ID,
11+
Component,
12+
destroyPlatform,
13+
Directive,
14+
ErrorHandler,
15+
HostListener,
16+
PLATFORM_ID,
17+
} from '@angular/core';
1018
import {withEventReplay} from '@angular/platform-browser';
1119

1220
import {EventPhase} from '@angular/core/primitives/event-dispatch';
@@ -183,6 +191,128 @@ describe('event replay', () => {
183191
expect(outerOnClickSpy).toHaveBeenCalledBefore(innerOnClickSpy);
184192
});
185193

194+
describe('host bindings', () => {
195+
it('should not error when when binding to document:click on a container', async () => {
196+
const clickSpy = jasmine.createSpy();
197+
@Directive({
198+
selector: '[add-listener]',
199+
})
200+
class AddGlobalListener {
201+
@HostListener('document:click')
202+
handleClick = clickSpy;
203+
}
204+
205+
@Component({
206+
selector: 'app',
207+
template: `
208+
<ng-container add-listener>
209+
<button id="click-me">Click me!</button>
210+
</ng-container>`,
211+
imports: [AddGlobalListener],
212+
})
213+
class AppComponent {}
214+
215+
const appId = 'custom-app-id';
216+
const providers = [{provide: APP_ID, useValue: appId}];
217+
const hydrationFeatures = () => [withEventReplay()];
218+
219+
const html = await ssr(AppComponent, {envProviders: providers, hydrationFeatures});
220+
const ssrContents = getAppContents(html);
221+
const doc = getDocument();
222+
223+
prepareEnvironment(doc, ssrContents);
224+
resetTViewsFor(AppComponent);
225+
const clickMe = doc.getElementById('click-me')!;
226+
clickMe.click();
227+
await hydrate(doc, AppComponent, {
228+
envProviders: [{provide: PLATFORM_ID, useValue: 'browser'}, ...providers],
229+
hydrationFeatures,
230+
});
231+
232+
expect(clickSpy).not.toHaveBeenCalled();
233+
});
234+
235+
it('should not error when when binding to window:click on a container', async () => {
236+
const clickSpy = jasmine.createSpy();
237+
@Directive({
238+
selector: '[add-listener]',
239+
})
240+
class AddGlobalListener {
241+
@HostListener('window:click')
242+
handleClick = clickSpy;
243+
}
244+
245+
@Component({
246+
selector: 'app',
247+
template: `
248+
<ng-container add-listener>
249+
<button id="click-me">Click me!</button>
250+
</ng-container>`,
251+
imports: [AddGlobalListener],
252+
})
253+
class AppComponent {}
254+
255+
const appId = 'custom-app-id';
256+
const providers = [{provide: APP_ID, useValue: appId}];
257+
const hydrationFeatures = () => [withEventReplay()];
258+
259+
const html = await ssr(AppComponent, {envProviders: providers, hydrationFeatures});
260+
const ssrContents = getAppContents(html);
261+
const doc = getDocument();
262+
263+
prepareEnvironment(doc, ssrContents);
264+
resetTViewsFor(AppComponent);
265+
const clickMe = doc.getElementById('click-me')!;
266+
clickMe.click();
267+
await hydrate(doc, AppComponent, {
268+
envProviders: [{provide: PLATFORM_ID, useValue: 'browser'}, ...providers],
269+
hydrationFeatures,
270+
});
271+
272+
expect(clickSpy).not.toHaveBeenCalled();
273+
});
274+
275+
it('should not error when when binding to body:click on a container', async () => {
276+
const clickSpy = jasmine.createSpy();
277+
@Directive({
278+
selector: '[add-listener]',
279+
})
280+
class AddGlobalListener {
281+
@HostListener('body:click')
282+
handleClick = clickSpy;
283+
}
284+
285+
@Component({
286+
selector: 'app',
287+
template: `
288+
<ng-container add-listener>
289+
<button id="click-me">Click me!</button>
290+
</ng-container>`,
291+
imports: [AddGlobalListener],
292+
})
293+
class AppComponent {}
294+
295+
const appId = 'custom-app-id';
296+
const providers = [{provide: APP_ID, useValue: appId}];
297+
const hydrationFeatures = () => [withEventReplay()];
298+
299+
const html = await ssr(AppComponent, {envProviders: providers, hydrationFeatures});
300+
const ssrContents = getAppContents(html);
301+
const doc = getDocument();
302+
303+
prepareEnvironment(doc, ssrContents);
304+
resetTViewsFor(AppComponent);
305+
const clickMe = doc.getElementById('click-me')!;
306+
clickMe.click();
307+
await hydrate(doc, AppComponent, {
308+
envProviders: [{provide: PLATFORM_ID, useValue: 'browser'}, ...providers],
309+
hydrationFeatures,
310+
});
311+
312+
expect(clickSpy).not.toHaveBeenCalled();
313+
});
314+
});
315+
186316
it('should remove jsaction attributes, but continue listening to events.', async () => {
187317
@Component({
188318
standalone: true,

0 commit comments

Comments
 (0)