diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ec8af6a5..e69aeadef 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 [#2279](https://github.com/ngxs/store/pull/2279) ### 19.0.0 2024-12-3 diff --git a/packages/store/src/pending-tasks.ts b/packages/store/src/pending-tasks.ts index f9e6c320d..0bd25def9 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, Subject } from 'rxjs'; import { Actions, ActionStatus } from './actions-stream'; import { withNgxsPreboot } from './standalone-features/preboot'; @@ -15,19 +16,37 @@ export function withNgxsPendingTasks() { return withNgxsPreboot(() => { const pendingTasks = inject(PendingTasks); const actions$ = inject(Actions); + const destroyRef = inject(DestroyRef); + const actionCompleted$ = new Subject(); + destroyRef.onDestroy(() => actionCompleted$.complete()); - const actionToRemoveTaskFnMap = new Map void>(); + let removeTaskFn: VoidFunction | null = null; + + // 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. + actionCompleted$ + .pipe( + // 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(() => { + removeTaskFn?.(); + removeTaskFn = null; + }); actions$.subscribe(ctx => { if (ctx.status === ActionStatus.Dispatched) { - const removeTaskFn = pendingTasks.add(); - actionToRemoveTaskFnMap.set(ctx.action, removeTaskFn); + removeTaskFn ||= pendingTasks.add(); } else { - const removeTaskFn = actionToRemoveTaskFnMap.get(ctx.action); - if (typeof removeTaskFn === 'function') { - actionToRemoveTaskFnMap.delete(ctx.action); - removeTaskFn(); - } + actionCompleted$.next(); } }); });