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 19, 2024
1 parent 4499a53 commit 1b533f6
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 { 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);

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();
}
}
// 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 uncompletedActions = new Set<any>();

destroyRef.onDestroy(() => {
// 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.
uncompletedActions.clear();
removeTaskFn?.();
removeTaskFn = null;
});

actions$
.pipe(
filter(ctx => {
if (ctx.status === ActionStatus.Dispatched) {
removeTaskFn ||= pendingTasks.add();
uncompletedActions.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 (!uncompletedActions.has(ctx.action)) {
return;
}

uncompletedActions.delete(ctx.action);

if (uncompletedActions.size === 0) {
removeTaskFn?.();
removeTaskFn = null;
}
});
});
}

0 comments on commit 1b533f6

Please sign in to comment.