From 03df126104b2163f7321da74d8afa7465dbbfe67 Mon Sep 17 00:00:00 2001 From: arturovt Date: Thu, 19 Dec 2024 13:02:19 +0200 Subject: [PATCH] fix(store): reduce change detection cycles with pending tasks --- CHANGELOG.md | 1 + packages/store/src/pending-tasks.ts | 63 ++++++++++++++++++++++------- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ec8af6a5..0d1155f30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ $ npm install @ngxs/store@dev ### To become next patch version - Fix(store): Add root store initializer guard [#2278](https://github.com/ngxs/store/pull/2278) +- Fix(store): Reduce change detection cycles with pending tasks [#2280](https://github.com/ngxs/store/pull/2280) ### 19.0.0 2024-12-3 diff --git a/packages/store/src/pending-tasks.ts b/packages/store/src/pending-tasks.ts index f9e6c320d..6a35db40e 100644 --- a/packages/store/src/pending-tasks.ts +++ b/packages/store/src/pending-tasks.ts @@ -1,4 +1,5 @@ -import { inject, PendingTasks } from '@angular/core'; +import { DestroyRef, inject, PendingTasks } from '@angular/core'; +import { debounceTime, filter } from 'rxjs'; import { Actions, ActionStatus } from './actions-stream'; import { withNgxsPreboot } from './standalone-features/preboot'; @@ -13,22 +14,54 @@ import { withNgxsPreboot } from './standalone-features/preboot'; */ export function withNgxsPendingTasks() { return withNgxsPreboot(() => { - const pendingTasks = inject(PendingTasks); const actions$ = inject(Actions); + const destroyRef = inject(DestroyRef); + const pendingTasks = inject(PendingTasks); + + // Removing a pending task via the public API forces a scheduled tick, ensuring that + // stability is async and delayed until there was at least an opportunity to run + // application synchronization. + // Adding a new task every time an action is dispatched drastically increases the + // number of change detection cycles because removing a task schedules a new change + // detection cycle. + // If 10 actions are dispatched with synchronous action handlers, this would trigger + // 10 change detection cycles in a row, potentially leading to an + // `INFINITE_CHANGE_DETECTION` error. + let removeTaskFn: VoidFunction | null = null; + + const executedActions = new Set(); + + // If the app is forcely destroyed before all actions are completed, + // we clean up the set of actions being executed to prevent memory leaks + // and remove the pending task to stabilize the app. + destroyRef.onDestroy(() => executedActions.clear()); + + actions$ + .pipe( + filter(ctx => { + if (ctx.status === ActionStatus.Dispatched) { + removeTaskFn ||= pendingTasks.add(); + executedActions.add(ctx.action); + return false; + } else { + return true; + } + }), + // Every time an action is completed, we debounce the stream to ensure only one + // task is removed, even if multiple synchronous actions are completed in a row. + debounceTime(0) + ) + .subscribe(ctx => { + if (!executedActions.has(ctx.action)) { + return; + } + + executedActions.delete(ctx.action); - const actionToRemoveTaskFnMap = new Map void>(); - - actions$.subscribe(ctx => { - if (ctx.status === ActionStatus.Dispatched) { - const removeTaskFn = pendingTasks.add(); - actionToRemoveTaskFnMap.set(ctx.action, removeTaskFn); - } else { - const removeTaskFn = actionToRemoveTaskFnMap.get(ctx.action); - if (typeof removeTaskFn === 'function') { - actionToRemoveTaskFnMap.delete(ctx.action); - removeTaskFn(); + if (executedActions.size === 0) { + removeTaskFn?.(); + removeTaskFn = null; } - } - }); + }); }); }