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
This commit is contained in:
Jessica Janiuk
2025-02-26 16:24:42 -05:00
committed by Miles Malerba
parent 8a73327ba0
commit 7ab0a8d1e7
4 changed files with 143 additions and 9 deletions
@@ -6,7 +6,15 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {APP_ID, Component, destroyPlatform, ErrorHandler, PLATFORM_ID} from '@angular/core';
import {
APP_ID,
Component,
destroyPlatform,
Directive,
ErrorHandler,
HostListener,
PLATFORM_ID,
} from '@angular/core';
import {withEventReplay} from '@angular/platform-browser';
import {EventPhase} from '@angular/core/primitives/event-dispatch';
@@ -183,6 +191,128 @@ describe('event replay', () => {
expect(outerOnClickSpy).toHaveBeenCalledBefore(innerOnClickSpy);
});
describe('host bindings', () => {
it('should not error when when binding to document:click on a container', async () => {
const clickSpy = jasmine.createSpy();
@Directive({
selector: '[add-listener]',
})
class AddGlobalListener {
@HostListener('document:click')
handleClick = clickSpy;
}
@Component({
selector: 'app',
template: `
<ng-container add-listener>
<button id="click-me">Click me!</button>
</ng-container>`,
imports: [AddGlobalListener],
})
class AppComponent {}
const appId = 'custom-app-id';
const providers = [{provide: APP_ID, useValue: appId}];
const hydrationFeatures = () => [withEventReplay()];
const html = await ssr(AppComponent, {envProviders: providers, hydrationFeatures});
const ssrContents = getAppContents(html);
const doc = getDocument();
prepareEnvironment(doc, ssrContents);
resetTViewsFor(AppComponent);
const clickMe = doc.getElementById('click-me')!;
clickMe.click();
await hydrate(doc, AppComponent, {
envProviders: [{provide: PLATFORM_ID, useValue: 'browser'}, ...providers],
hydrationFeatures,
});
expect(clickSpy).not.toHaveBeenCalled();
});
it('should not error when when binding to window:click on a container', async () => {
const clickSpy = jasmine.createSpy();
@Directive({
selector: '[add-listener]',
})
class AddGlobalListener {
@HostListener('window:click')
handleClick = clickSpy;
}
@Component({
selector: 'app',
template: `
<ng-container add-listener>
<button id="click-me">Click me!</button>
</ng-container>`,
imports: [AddGlobalListener],
})
class AppComponent {}
const appId = 'custom-app-id';
const providers = [{provide: APP_ID, useValue: appId}];
const hydrationFeatures = () => [withEventReplay()];
const html = await ssr(AppComponent, {envProviders: providers, hydrationFeatures});
const ssrContents = getAppContents(html);
const doc = getDocument();
prepareEnvironment(doc, ssrContents);
resetTViewsFor(AppComponent);
const clickMe = doc.getElementById('click-me')!;
clickMe.click();
await hydrate(doc, AppComponent, {
envProviders: [{provide: PLATFORM_ID, useValue: 'browser'}, ...providers],
hydrationFeatures,
});
expect(clickSpy).not.toHaveBeenCalled();
});
it('should not error when when binding to body:click on a container', async () => {
const clickSpy = jasmine.createSpy();
@Directive({
selector: '[add-listener]',
})
class AddGlobalListener {
@HostListener('body:click')
handleClick = clickSpy;
}
@Component({
selector: 'app',
template: `
<ng-container add-listener>
<button id="click-me">Click me!</button>
</ng-container>`,
imports: [AddGlobalListener],
})
class AppComponent {}
const appId = 'custom-app-id';
const providers = [{provide: APP_ID, useValue: appId}];
const hydrationFeatures = () => [withEventReplay()];
const html = await ssr(AppComponent, {envProviders: providers, hydrationFeatures});
const ssrContents = getAppContents(html);
const doc = getDocument();
prepareEnvironment(doc, ssrContents);
resetTViewsFor(AppComponent);
const clickMe = doc.getElementById('click-me')!;
clickMe.click();
await hydrate(doc, AppComponent, {
envProviders: [{provide: PLATFORM_ID, useValue: 'browser'}, ...providers],
hydrationFeatures,
});
expect(clickSpy).not.toHaveBeenCalled();
});
});
it('should remove jsaction attributes, but continue listening to events.', async () => {
@Component({
standalone: true,