From dc85c739730bd384c80feee28acaf5fb93c61e41 Mon Sep 17 00:00:00 2001 From: arturovt Date: Sat, 22 Jul 2023 23:18:44 +0300 Subject: [PATCH] fix(store-devtools): reduce CD cycles by listening `message` outside of Angular This commit updates the extension connection setup and wraps it with `runOutsideAngular` to prevent running change detection too frequently. This ensures that change detection is only triggered when all asynchronous actions have been processed. Closes #3839 --- modules/store-devtools/spec/config.spec.ts | 5 +++ modules/store-devtools/src/config.ts | 7 ++++ modules/store-devtools/src/devtools.ts | 39 ++++++++++++++++++++-- modules/store-devtools/src/extension.ts | 16 +++++++-- modules/store-devtools/src/zone-config.ts | 10 ++++++ 5 files changed, 71 insertions(+), 6 deletions(-) create mode 100644 modules/store-devtools/src/zone-config.ts diff --git a/modules/store-devtools/spec/config.spec.ts b/modules/store-devtools/spec/config.spec.ts index b5c12100e7..0751cc7a34 100644 --- a/modules/store-devtools/spec/config.spec.ts +++ b/modules/store-devtools/spec/config.spec.ts @@ -30,6 +30,7 @@ describe('StoreDevtoolsOptions', () => { features: defaultFeatures, trace: false, traceLimit: 75, + connectOutsideZone: false, }); }); @@ -67,6 +68,7 @@ describe('StoreDevtoolsOptions', () => { features: { test: true, }, + connectOutsideZone: false, }); }); @@ -84,6 +86,7 @@ describe('StoreDevtoolsOptions', () => { trace: false, traceLimit: 75, features: defaultFeatures, + connectOutsideZone: false, }); }); @@ -107,6 +110,7 @@ describe('StoreDevtoolsOptions', () => { export: true, test: true, }, + connectOutsideZone: false, }); }); @@ -133,6 +137,7 @@ describe('StoreDevtoolsOptions', () => { }, trace: false, traceLimit: 75, + connectOutsideZone: false, }); }); }); diff --git a/modules/store-devtools/src/config.ts b/modules/store-devtools/src/config.ts index e6a014f81b..cf1939b8e3 100644 --- a/modules/store-devtools/src/config.ts +++ b/modules/store-devtools/src/config.ts @@ -114,6 +114,12 @@ export class StoreDevtoolsConfig { * Maximum stack trace frames to be stored (in case trace option was provided as true). */ traceLimit?: number; + + /** + * The property determines whether the extension connection is established within the + * Angular zone or not. It is set to `false` by default. + */ + connectOutsideZone?: boolean; } export const STORE_DEVTOOLS_CONFIG = new InjectionToken( @@ -165,6 +171,7 @@ export function createConfig( dispatch: true, // Dispatch custom actions or action creators test: true, // Generate tests for the selected actions }, + connectOutsideZone: false, }; const options = diff --git a/modules/store-devtools/src/devtools.ts b/modules/store-devtools/src/devtools.ts index 8c30841636..bb1b5f2ce8 100644 --- a/modules/store-devtools/src/devtools.ts +++ b/modules/store-devtools/src/devtools.ts @@ -11,6 +11,7 @@ import { } from '@ngrx/store'; import { merge, + MonoTypeOperatorFunction, Observable, Observer, queueScheduler, @@ -31,6 +32,7 @@ import { } from './utils'; import { DevtoolsDispatcher } from './devtools-dispatcher'; import { PERFORM_ACTION } from './actions'; +import { ZoneConfig, injectZoneConfig } from './zone-config'; @Injectable() export class StoreDevtools implements Observer, OnDestroy { @@ -69,11 +71,20 @@ export class StoreDevtools implements Observer, OnDestroy { const liftedReducer$ = reducers$.pipe(map(liftReducer)); + const zoneConfig = injectZoneConfig(config.connectOutsideZone!); + const liftedStateSubject = new ReplaySubject(1); this.liftedStateSubscription = liftedAction$ .pipe( withLatestFrom(liftedReducer$), + // The extension would post messages back outside of the Angular zone + // because we call `connect()` wrapped with `runOutsideAngular`. We run change + // detection only once at the end after all the required asynchronous tasks have + // been processed (for instance, `setInterval` scheduled by the `timeout` operator). + // We have to re-enter the Angular zone before the `scan` since it runs the reducer + // which must be run within the Angular zone. + emitInZone(zoneConfig), scan< [any, ActionReducer], { @@ -110,9 +121,11 @@ export class StoreDevtools implements Observer, OnDestroy { } }); - this.extensionStartSubscription = extension.start$.subscribe(() => { - this.refresh(); - }); + this.extensionStartSubscription = extension.start$ + .pipe(emitInZone(zoneConfig)) + .subscribe(() => { + this.refresh(); + }); const liftedState$ = liftedStateSubject.asObservable() as Observable; @@ -196,3 +209,23 @@ export class StoreDevtools implements Observer, OnDestroy { this.dispatch(new Actions.PauseRecording(status)); } } + +/** + * If the devtools extension is connected out of the Angular zone, + * this operator will emit all events within the zone. + */ +function emitInZone({ + ngZone, + connectOutsideZone, +}: ZoneConfig): MonoTypeOperatorFunction { + return (source) => + connectOutsideZone + ? new Observable((subscriber) => + source.subscribe({ + next: (value) => ngZone.run(() => subscriber.next(value)), + error: (error) => ngZone.run(() => subscriber.error(error)), + complete: () => ngZone.run(() => subscriber.complete()), + }) + ) + : source; +} diff --git a/modules/store-devtools/src/extension.ts b/modules/store-devtools/src/extension.ts index e125e114bb..49cd38a134 100644 --- a/modules/store-devtools/src/extension.ts +++ b/modules/store-devtools/src/extension.ts @@ -31,6 +31,7 @@ import { shouldFilterActions, unliftState, } from './utils'; +import { injectZoneConfig } from './zone-config'; export const ExtensionActionTypes = { START: 'START', @@ -77,6 +78,8 @@ export class DevtoolsExtension { actions$!: Observable; start$!: Observable; + private zoneConfig = injectZoneConfig(this.config.connectOutsideZone!); + constructor( @Inject(REDUX_DEVTOOLS_EXTENSION) devtoolsExtension: ReduxDevtoolsExtension, @Inject(STORE_DEVTOOLS_CONFIG) private config: StoreDevtoolsConfig, @@ -168,9 +171,16 @@ export class DevtoolsExtension { } return new Observable((subscriber) => { - const connection = this.devtoolsExtension.connect( - this.getExtensionConfig(this.config) - ); + const connection = this.zoneConfig.connectOutsideZone + ? // To reduce change detection cycles, we need to run the `connect` method + // outside of the Angular zone. The `connect` method adds a `message` + // event listener to communicate with an extension using `window.postMessage` + // and handle message events. + this.zoneConfig.ngZone.runOutsideAngular(() => + this.devtoolsExtension.connect(this.getExtensionConfig(this.config)) + ) + : this.devtoolsExtension.connect(this.getExtensionConfig(this.config)); + this.extensionConnection = connection; connection.init(); diff --git a/modules/store-devtools/src/zone-config.ts b/modules/store-devtools/src/zone-config.ts new file mode 100644 index 0000000000..28063e6699 --- /dev/null +++ b/modules/store-devtools/src/zone-config.ts @@ -0,0 +1,10 @@ +import { NgZone, inject } from '@angular/core'; + +export type ZoneConfig = + | { connectOutsideZone: true; ngZone: NgZone } + | { connectOutsideZone: false; ngZone: null }; + +export function injectZoneConfig(connectOutsideZone: boolean) { + const ngZone = connectOutsideZone ? inject(NgZone) : null; + return { ngZone, connectOutsideZone } as ZoneConfig; +}