8000 fix(core): prevent stash listener conflicts by arturovt · Pull Request #59635 · angular/angular · GitHub
[go: up one dir, main page]

Skip to content

fix(core): prevent stash listener conflicts #59635

New issue

Have a 8000 question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
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
9 changes: 5 additions & 4 deletions packages/core/src/hydration/event_replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import {APP_ID} from '../application/application_tokens';
import {performanceMarkFeature} from '../util/performance';
import {triggerHydrationFromBlockName} from '../defer/triggering';
import {isIncrementalHydrationEnabled} from './utils';
import {setStashFn} from '../render3/view/listeners';
import {clearStashFn, setStashFn} from '../render3/view/listeners';

/** Apps in which we've enabled event replay.
* This is to prevent initializing event replay more than once per app.
Expand Down Expand Up @@ -106,7 +106,8 @@ export function withEventReplay(): Provider[] {
if (!appsWithEventReplay.has(appRef)) {
const jsActionMap = inject(JSACTION_BLOCK_ELEMENT_MAP);
if (shouldEnableEventReplay(injector)) {
setStashFn((rEl: RNode, eventName: string, listenerFn: VoidFunction) => {
const appId = injector.get(APP_ID);
setStashFn(appId, (rEl: RNode, eventName: string, listenerFn: VoidFunction) => {
// If a user binds to a ng-container and uses a directive that binds using a host listener,
// this element could be a comment node. So we need to ensure we have an actual element
// node before stashing anything.
Expand All @@ -122,7 +123,6 @@ export function withEventReplay(): Provider[] {
{
provide: APP_BOOTSTRAP_LISTENER,
useFactory: () => {
const appId = inject(APP_ID);
const appRef = inject(ApplicationRef);
const {injector} = appRef;

Expand All @@ -140,14 +140,15 @@ export function withEventReplay(): Provider[] {
appsWithEventReplay.delete(appRef);
// Ensure that we're always safe calling this in the browser.
if (typeof ngServerMode !== 'undefined' && !ngServerMode) {
const appId = injector.get(APP_ID);
// `_ejsa` should be deleted when the app is destroyed, ensuring that
// no elements are still captured in the global list and are not prevented
// from being garbage collected.
clearAppScopedEarlyEventContract(appId);
// Clean up the reference to the function set by the environment initializer,
// as the function closure may capture injected elements and prevent them
// from being properly garbage collected.
setStashFn(() => {});
clearStashFn(appId);
}
});

Expand Down
19 changes: 14 additions & 5 deletions packages/core/src/render3/view/listeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {RElement, RNode} from '../interfaces/renderer_dom';
import {GlobalTargetResolver, Renderer} from '../interfaces/renderer';
import {assertNotSame} from '../../util/assert';
import {handleUncaughtError} from '../instructions/shared';
import {APP_ID} from '../../application/application_tokens';

/** Shorthand for an event listener callback function to reduce duplication. */
export type EventCallback = (event?: any) => any;
Expand All @@ -34,15 +35,21 @@ export type EventCallback = (event?: any) => any;
export type WrappedEventCallback = EventCallback & {__wrapped: boolean};

/**
* Contains a reference to a function that disables event replay feature
* Represents a signature of a function that disables event replay feature
* for server-side rendered applications. This function is overridden with
* an actual implementation when the event replay feature is enabled via
* `withEventReplay()` call.
*/
let stashEventListener = (el: RNode, eventName: string, listenerFn: EventCallback) => {};
type StashEventListener = (el: RNode, eventName: string, listenerFn: EventCallback) => void;

export function setStashFn(fn: typeof stashEventListener) {
stashEventListener = fn;
const stashEventListeners = new Map<string, StashEventListener>();

export function setStashFn(appId: string, fn: StashEventListener) {
stashEventListeners.set(appId, fn);
}

export function clearStashFn(appId: string) {
stashEventListeners.delete(appId);
}

/**
Expand Down Expand Up @@ -172,7 +179,9 @@ export function listenToDomEvent(
} else {
const native = getNativeByTNode(tNode, lView) as RElement;
const target = eventTargetResolver ? eventTargetResolver(native) : native;
stashEventListener(target as RElement, eventName, wrappedListener);
const appId = lView[INJECTOR].get(APP_ID);
const stashEventListener = stashEventListeners.get(appId);
stashEventListener?.(target as RElement, eventName, wrappedListener);
Comment on lines +182 to +184
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@arturovt thanks for the improvement 👍

I think we should optimize this code path by moving the logic into the stashEventListener function (or a function that manages its assignment). Otherwise, we invoke this code for all listeners all the time, when we only need it in case the event replay feature is enabled (explicitly or via incremental hydration).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get your comment. I'm wondering whether we should be checking if event replay feature is enabled in this code path and only then stash the event listener. I have no ideas on having that functionality inside the stashEventListener, I mean I don't get how it would technically look like.

If I understand correctly, stashing is called every time when listener is added, basically like this:

Element.prototype.addEventListener = function (
  this: Element,
  type: string,
  listener: EventListenerOrEventListenerObject,
  options?: boolean | AddEventListenerOptions,
) {
  const stash = stashEventListeners.get(appId);
  stash?.(this, type, listener as VoidFunction);
  return originalAddEventListener.call(this, type, listener, options);
};

Another question is, should we clear the stash event listener once the hydration is done to avoid stashing it after it's functioning in the browser?

Copy link
Contributor Author
@arturovt arturovt Apr 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let stashEventListenerImpl = (
  lView: LView,
  target: RElement,
  eventName: string,
  wrappedListener: EventCallback,
) => {};

export function enableStashEventListenerImpl() {
  stashEventListenerImpl = (
    lView: LView,
    target: RElement,
    eventName: string,
    wrappedListener: EventCallback,
  ) => {
    const appId = lView[INJECTOR].get(APP_ID);
    const stashEventListener = stashEventListeners.get(appId);
    stashEventListener?.(target as RElement, eventName, wrappedListener);
  };
}

And in the code:

stashEventListenerImpl(lView, target, eventName, wrappedListener);

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would avoid an injector lookup and a Map lookup every time the listener() is called unless the replay is enabled.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the solution described in #59635 (comment) looks great and something that I had in mind as well, thank you 👍


const cleanupFn = renderer.listen(target as RElement, eventName, wrappedListener);
const idxOrTargetGetter = eventTargetResolver
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -611,6 +611,7 @@
"signal",
"signalAsReadonlyFn",
"signalSetFn",
"stashEventListeners",
"storeLViewOnDestroy",
"storeListenerCleanup",
"stringify",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,7 @@
"signal",
"signalAsReadonlyFn",
"signalSetFn",
"stashEventListeners",
"storeLViewOnDestroy",
"storeListenerCleanup",
"stringify",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,7 @@
"split",
"squashSegmentGroup",
"standardizeConfig",
"stashEventListeners",
"storeLViewOnDestroy",
"storeListenerCleanup",
"stringify",
Expand Down
71 changes: 70 additions & 1 deletion packages/platform-server/test/event_replay_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ import {
PLATFORM_ID,
} from '@angular/core';
import {isPlatformBrowser} from '@angular/common';
import {withEventReplay} from '@angular/platform-browser';
import {
bootstrapApplication,
provideClientHydration,
withEventReplay,
} from '@angular/platform-browser';

import {EventPhase} from '@angular/core/primitives/event-dispatch';

Expand Down Expand Up @@ -111,6 +115,71 @@ describe('event replay', () => {
expect(onClickSpy).toHaveBeenCalled();
});

it('stash event listeners should not conflict when multiple apps are bootstrapped', async () => {
const onClickSpy = jasmine.createSpy();

@Component({
selector: 'app',
standalone: true,
template: `
<button id="btn-1" (click)="onClick()"></button>
`,
})
class AppComponent_1 {
onClick = onClickSpy;
}

@Component({
selector: 'app-2',
standalone: true,
template: `
<button id="btn-2" (click)="onClick()"></button>
`,
})
class AppComponent_2 {
onClick() {}
}

const hydrationFeatures = () => [withEventReplay()];
const docHtml = `
<html>
<head></head>
<body>
${EVENT_DISPATCH_SCRIPT}
<app></app>
<app-2></app-2>
</body>
</html>
`;
const html = await ssr(AppComponent_1, {hydrationFeatures, doc: docHtml});
const ssrContents = getAppContents(html);
const doc = getDocument();

prepareEnvironment(doc, ssrContents);
resetTViewsFor(AppComponent_1);

const btn = doc.getElementById('btn-1')!;
btn.click();

// It's hard to server-side render multiple applications in this
// particular unit test and hydrate them on the client, so instead,
// let's render the application with `provideClientHydration` to enable
// event replay features and ensure the stash event listener is set.
await bootstrapApplication(AppComponent_2, {
providers: [
provideClientHydration(withEventReplay()),
{provide: APP_ID, useValue: 'random_name'},
],
});

// Now let's hydrate the second application and ensure that the
// button click event has been replayed.
const appRef = await hydrate(doc, AppComponent_1, {hydrationFeatures});
appRef.tick();

expect(onClickSpy).toHaveBeenCalled();
});

it('should cleanup `window._ejsas[appId]` once app is destroyed', async () => {
@Component({
selector: 'app',
Expand Down
0