Skip to content

fix(core): do not keep elements created after hydration for event replay - #71927

Open
arturovt wants to merge 1 commit into
angular:mainfrom
arturovt:fix/core-event-replay-skip-client-elements
Open

arturovt wants to merge 1 commit into
angular:mainfrom
arturovt:fix/core-event-replay-skip-client-elements

Conversation

@arturovt

Copy link
Copy Markdown
Contributor

Please note that this fixes a real production issue; it wasn't found speculatively through AI code analysis.

In some of our applications, memory kept growing with event replay enabled. The page used more memory after every route change.

Event replay keeps every element that has an event listener, so it can replay early events on it later. After hydration, Angular clears this list once. But then it keeps adding new elements to it, and nothing reads them again. For example:

  • A list with 100 rows, where each row has a (click) listener, adds 100 elements every time the list is shown again.
  • After a route change, the elements of the old page stay in memory, together with their listeners and components.

Only elements that come from the server have a jsaction attribute, and only these elements can be replayed. With this fix, Angular does not keep other elements anymore. This works with and without incremental hydration.

Fixes #66506

@angular-robot angular-robot Bot added the area: core Issues related to the framework runtime label Nov 10, 2026
@ngbot ngbot Bot added this to the Backlog milestone Nov 10, 2026
@pullapprove
pullapprove Bot requested a review from crisbeto November 10, 2026 19:56
In some of our applications, memory kept growing with event replay enabled.
The page used more memory after every route change.

Event replay keeps every element that has an event listener, so it can replay
early events on it later. After hydration, Angular clears this list once. But
then it keeps adding new elements to it, and nothing reads them again.
For example:

- A list with 100 rows, where each row has a `(click)` listener, adds 100
  elements every time the list is shown again.
- After a route change, the elements of the old page stay in memory, together
  with their listeners and components.

Only elements that come from the server have a `jsaction` attribute, and only
these elements can be replayed. With this fix, Angular does not keep other
elements anymore. This works with and without incremental hydration.

Fixes angular#66506
@arturovt
arturovt force-pushed the fix/core-event-replay-skip-client-elements branch from d5e6da8 to 77d3b0f Compare November 10, 2026 20:26

@JeanMeche JeanMeche left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AGENT: Thanks for tackling this long-standing issue (#66506)! Preventing client-created elements from leaking into jsActionMap is critical.

A few questions and observations regarding the implementation and lifecycle:

  1. Post-hydration overhead on hot paths:
    enableStashEventListenerImpl hooks into listenToDomEvent. With this change, every event listener created throughout the entire lifetime of the SPA (route navigations, table scrolling, dynamic @for/@if components, dialogs) will perform:
    lView[INJECTOR].get(APP_ID) -> stashEventListeners.get() -> (rEl as Element).hasAttribute('jsaction').
    Since (rEl as Element).hasAttribute is a DOM read, running this on every event listener binding post-hydration introduces unnecessary overhead on hot rendering paths.

  2. Lifecycle of clearStashFn():
    When incremental hydration is disabled (withNoIncrementalHydration()), event replay is 100% complete once appRef.whenStable() resolves (eventContract.cleanUp() is called and early events are already dispatched).
    Could we call clearStashFn() directly inside whenStable() rather than waiting for appRef.onDestroy()? Once clearStashFn() is called, stashEventListeners.get(appId) is undefined and listenToDomEvent incurs no DOM checks.

  3. Invariance for eager content (blockName === ''):
    Relying solely on hasAttribute('jsaction') means that if any client-created element happens to have a jsaction attribute (e.g. apps integrating with Wiz / hybrid setups, custom elements, or templates with [attr.jsaction]), it will still be pushed to jsActionMap.get('') and leak. Since eager content listeners are only replayed at initial stability, should we simply refuse to stash any element with blockName === '' once initial whenStable() has settled?

  4. Test assertions:
    Could we also assert that doc.getElementById('late-btn')?.__jsaction_fns is undefined, to ensure no handler closures are retained on the element?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: core Issues related to the framework runtime

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HostListener/output() memory leak in SSR

3 participants