Skip to content

Commit 30e9c62

Browse files
fix(core): fix memory leak with event replay
This ensures event replay does not hold on to elements after an application has been destroyed. fixes: angular#59261
1 parent af4e521 commit 30e9c62

4 files changed

Lines changed: 182 additions & 21 deletions

File tree

packages/core/src/core_private_export.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export {
9595
IS_INCREMENTAL_HYDRATION_ENABLED as ɵIS_INCREMENTAL_HYDRATION_ENABLED,
9696
JSACTION_BLOCK_ELEMENT_MAP as ɵJSACTION_BLOCK_ELEMENT_MAP,
9797
IS_ENABLED_BLOCKING_INITIAL_NAVIGATION as ɵIS_ENABLED_BLOCKING_INITIAL_NAVIGATION,
98+
EVENT_REPLAY_QUEUE as ɵEVENT_REPLAY_QUEUE,
9899
} from './hydration/tokens';
99100
export {
100101
HydrationStatus as ɵHydrationStatus,

packages/core/src/hydration/event_replay.ts

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import {
3030
JSACTION_BLOCK_ELEMENT_MAP,
3131
EVENT_REPLAY_ENABLED_DEFAULT,
3232
IS_EVENT_REPLAY_ENABLED,
33+
EVENT_REPLAY_QUEUE,
34+
EventReplayQueue,
3335
} from './tokens';
3436
import {
3537
sharedStashFunction,
@@ -57,11 +59,6 @@ const appsWithEventReplay = new WeakSet<ApplicationRef>();
5759
*/
5860
const EAGER_CONTENT_LISTENERS_KEY = '';
5961

60-
/**
61-
* A list of block events that need to be replayed
62-
*/
63-
let blockEventQueue: {event: Event; currentTarget: Element}[] = [];
64-
6562
/**
6663
* Determines whether Event Replay feature should be activated on the client.
6764
*/
@@ -295,23 +292,25 @@ function hydrateAndInvokeBlockListeners(
295292
event: Event,
296293
currentTarget: Element,
297294
) {
298-
blockEventQueue.push({event, currentTarget});
299-
triggerHydrationFromBlockName(injector, blockName, replayQueuedBlockEvents);
295+
const queue = injector.get(EVENT_REPLAY_QUEUE);
296+
queue.push({event, currentTarget});
297+
triggerHydrationFromBlockName(injector, blockName, createReplayQueuedBlockEventsFn(queue));
300298
}
301299

302-
function replayQueuedBlockEvents(hydratedBlocks: string[]) {
303-
// clone the queue
304-
const queue = [...blockEventQueue];
305-
const hydrated = new Set<string>(hydratedBlocks);
306-
// empty it
307-
blockEventQueue = [];
308-
for (let {event, currentTarget} of queue) {
309-
const blockName = currentTarget.getAttribute(DEFER_BLOCK_SSR_ID_ATTRIBUTE)!;
310-
if (hydrated.has(blockName)) {
311-
invokeListeners(event, currentTarget);
312-
} else {
313-
// requeue events that weren't yet hydrated
314-
blockEventQueue.push({event, currentTarget});
300+
function createReplayQueuedBlockEventsFn(queue: EventReplayQueue) {
301+
return (hydratedBlocks: string[]) => {
302+
const hydrated = new Set<string>(hydratedBlocks);
303+
const newQueue: EventReplayQueue = [];
304+
for (let {event, currentTarget} of queue) {
305+
const blockName = currentTarget.getAttribute(DEFER_BLOCK_SSR_ID_ATTRIBUTE)!;
306+
if (hydrated.has(blockName)) {
307+
invokeListeners(event, currentTarget);
308+
} else {
309+
// requeue events that weren't yet hydrated
310+
newQueue.push({event, currentTarget});
311+
}
315312
}
316-
}
313+
queue.length = 0;
314+
queue.push(...newQueue);
315+
};
317316
}

packages/core/src/hydration/tokens.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,21 @@ export const IS_EVENT_REPLAY_ENABLED = new InjectionToken<boolean>(
4949

5050
export const EVENT_REPLAY_ENABLED_DEFAULT = false;
5151

52+
/**
53+
* A type of the queue that stores events occurring during the hydration process.
54+
*/
55+
export type EventReplayQueue = {
56+
event: Event;
57+
currentTarget: Element;
58+
}[];
59+
60+
export const EVENT_REPLAY_QUEUE = new InjectionToken<EventReplayQueue>(
61+
typeof ngDevMode !== 'undefined' && ngDevMode ? 'EVENT_REPLAY_QUEUE' : '',
62+
{
63+
factory: () => [],
64+
},
65+
);
66+
5267
/**
5368
* Internal token that indicates whether incremental hydration support
5469
* is enabled.

packages/platform-server/test/event_replay_spec.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
PendingTasks,
1818
PLATFORM_ID,
1919
ɵgetDocument as getDocument,
20+
ɵEVENT_REPLAY_QUEUE as EVENT_REPLAY_QUEUE,
2021
} from '@angular/core';
2122
import {isPlatformBrowser} from '@angular/common';
2223
import {
@@ -697,4 +698,149 @@ describe('event replay', () => {
697698
);
698699
});
699700
});
701+
702+
describe('event replay queue', () => {
703+
it('should be empty on init', async () => {
704+
@Component({
705+
selector: 'app',
706+
template: '<input (click)="onClick()" />',
707+
})
708+
class SimpleComponent {
709+
onClick() {}
710+
}
711+
712+
const hydrationFeatures = () => [withEventReplay()];
713+
const html = await ssr(SimpleComponent, {hydrationFeatures});
714+
const ssrContents = getAppContents(html);
715+
const doc = getDocument();
716+
prepareEnvironment(doc, ssrContents);
717+
resetTViewsFor(SimpleComponent);
718+
const appRef = await hydrate(doc, SimpleComponent, {hydrationFeatures});
719+
const queue = appRef.injector.get(EVENT_REPLAY_QUEUE);
720+
expect(queue.length).toBe(0);
721+
});
722+
723+
it('should be different for different apps', async () => {
724+
@Component({
725+
selector: 'app',
726+
template: '<input (click)="onClick()" />',
727+
})
728+
class SimpleComponent {
729+
onClick() {}
730+
}
731+
732+
const hydrationFeatures = () => [withEventReplay()];
733+
const html = await ssr(SimpleComponent, {hydrationFeatures});
734+
const ssrContents = getAppContents(html);
735+
const doc = getDocument();
736+
prepareEnvironment(doc, ssrContents);
737+
resetTViewsFor(SimpleComponent);
738+
739+
const appRef1 = await hydrate(doc, SimpleComponent, {hydrationFeatures});
740+
const queue1 = appRef1.injector.get(EVENT_REPLAY_QUEUE);
741+
742+
const appRef2 = await hydrate(doc, SimpleComponent, {hydrationFeatures});
743+
const queue2 = appRef2.injector.get(EVENT_REPLAY_QUEUE);
744+
745+
expect(queue1).not.toBe(queue2);
746+
});
747+
748+
it('should clear the queue after events are replayed', async () => {
749+
@Component({
750+
selector: 'app',
751+
template: `
752+
@defer (on interaction(trigger)) {
753+
<div id="content" (click)="onClick()"></div>
754+
} @placeholder {
755+
<button id="trigger">Trigger</button>
756+
}
757+
`,
758+
})
759+
class SimpleComponent {
760+
onClick() {}
761+
}
762+
763+
const hydrationFeatures = () => [withEventReplay()];
764+
const html = await ssr(SimpleComponent, {hydrationFeatures});
765+
const ssrContents = getAppContents(html);
766+
const doc = getDocument();
767+
prepareEnvironment(doc, ssrContents);
768+
resetTViewsFor(SimpleComponent);
769+
770+
const appRef = await hydrate(doc, SimpleComponent, {hydrationFeatures});
771+
const queue = appRef.injector.get(EVENT_REPLAY_QUEUE);
772+
const trigger = doc.getElementById('trigger')!;
773+
// This should queue the event
774+
trigger.click();
775+
776+
// Wait for hydration to complete
777+
await appRef.whenStable();
778+
779+
// The queue should be cleared after replay/hydration cycle completion
780+
// Note: We might need to wait for idle/microtasks if the replay is async.
781+
// But verify expectation:
782+
// The current implementation requeues if not hydrated.
783+
// But here we expect it to hydrate.
784+
785+
// For this test to trigger replay we need to ensure the block hydrates.
786+
// interaction(trigger) hydrates on click.
787+
788+
// Check that queue is handled.
789+
// queue size initially should be 0.
790+
// After click, it might briefly be 1 if we inspect synchronously?
791+
// but `triggerHydrationFromBlockName` is called.
792+
// Eventually it should be empty again.
793+
// Since `invokeRegisteredReplayListeners` triggers hydration directly and pushes to queue.
794+
795+
// We can inspect the queue if we want.
796+
// But mainly we want to ensure no crash and cleanup happens.
797+
798+
// wait for replay
799+
await new Promise((resolve) => setTimeout(resolve, 100));
800+
expect(queue.length).toBe(0);
801+
});
802+
803+
it('should release event queue references on app destroy', async () => {
804+
let appRef: any;
805+
const appId = 'app-id-for-memory-test';
806+
{
807+
@Component({
808+
selector: 'app',
809+
template: '<input (click)="onClick()" />',
810+
})
811+
class SimpleComponent {
812+
onClick() {}
813+
}
814+
815+
const providers = [{provide: APP_ID, useValue: appId}];
816+
const hydrationFeatures = () => [withEventReplay()];
817+
const html = await ssr(SimpleComponent, {
818+
hydrationFeatures,
819+
envProviders: providers,
820+
});
821+
const ssrContents = getAppContents(html);
822+
const doc = getDocument();
823+
prepareEnvironment(doc, ssrContents);
824+
resetTViewsFor(SimpleComponent);
825+
826+
appRef = await hydrate(doc, SimpleComponent, {
827+
hydrationFeatures,
828+
envProviders: providers,
829+
});
830+
831+
// Access queue to make sure it exists
832+
const queue = appRef.injector.get(EVENT_REPLAY_QUEUE);
833+
expect(queue).toBeInstanceOf(Array);
834+
835+
// Simulate event in queue
836+
queue.push({event: new Event('click'), currentTarget: doc.createElement('div')});
837+
expect(queue.length).toBe(1);
838+
839+
appRef.destroy();
840+
}
841+
842+
// Verify global cleanup
843+
expect(window._ejsas![appId]).toBeUndefined();
844+
});
845+
});
700846
});

0 commit comments

Comments
 (0)