Skip to content

Commit

Permalink
fix(store): reduce change detection cycles with pending tasks
Browse files Browse the repository at this point in the history
  • Loading branch information
arturovt committed Dec 20, 2024
1 parent 4499a53 commit 721afef
Show file tree
Hide file tree
Showing 2 changed files with 53 additions and 15 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
67 changes: 52 additions & 15 deletions packages/store/src/pending-tasks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { inject, PendingTasks } from '@angular/core';
import { DestroyRef, inject, PendingTasks } from '@angular/core';
import { buffer, debounceTime, filter } from 'rxjs';

import { Actions, ActionStatus } from './actions-stream';
import { withNgxsPreboot } from './standalone-features/preboot';
Expand All @@ -13,22 +14,58 @@ 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<unknown>();

// 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(context => {
if (context.status === ActionStatus.Dispatched) {
executedActions.add(context.action);
removeTaskFn ||= pendingTasks.add();
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.
// We use `buffer` to collect action contexts because, if we only use
// `debounceTime(0)`, we may lose action contexts that are never removed from the set.
buffer(actions$.pipe(debounceTime(0)))
)
.subscribe(contexts => {
for (const context of contexts) {
if (!executedActions.has(context.action)) {
continue;
}

executedActions.delete(context.action);

const actionToRemoveTaskFnMap = new Map<any, () => 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;
}
}
}
});
});
});
}

0 comments on commit 721afef

Please sign in to comment.