Skip to content

Commit b778ca2

Browse files
refactor(devtools): extract tracking logic from DevtoolsSyncer
This is a pre-requisite for `withGlitchTracking()` feature. `DefaultTracker` will track the states via an `effect`, but it is possible to override it with another tracker implementation per SignalStore. At the moment, there is just one implementation, but `withGlitchTracking()` will follow in the next commit.
1 parent 97c0031 commit b778ca2

15 files changed

+184
-80
lines changed

libs/ngrx-toolkit/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
export { withDevToolsStub } from './lib/devtools/with-dev-tools-stub';
22
export { withDevtools } from './lib/devtools/with-devtools';
3-
export { withDisabledNameIndices } from './lib/devtools/with-disabled-name-indicies';
4-
export { withMapper } from './lib/devtools/with-mapper';
3+
export { withDisabledNameIndices } from './lib/devtools/features/with-disabled-name-indicies';
4+
export { withMapper } from './lib/devtools/features/with-mapper';
55
export { patchState, updateState } from './lib/devtools/update-state';
66
export { renameDevtoolsName } from './lib/devtools/rename-devtools-name';
77

libs/ngrx-toolkit/src/lib/devtools/with-disabled-name-indicies.ts renamed to libs/ngrx-toolkit/src/lib/devtools/features/with-disabled-name-indicies.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createDevtoolsFeature } from './devtools-feature';
1+
import { createDevtoolsFeature } from '../internal/devtools-feature';
22

33
/**
44
* If multiple instances of the same SignalStore class
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { Injectable } from '@angular/core';
2+
3+
@Injectable({ providedIn: 'root' })
4+
export class GlitchTrackingService {}
5+
6+
/**
7+
* Track all state changes of the State, including intermediary updates
8+
* that are typically suppressed by Angular's glitch-free mechanism.
9+
*/
10+
export function withGlitchTracking() {
11+
throw new Error('Not implemented');
12+
}

libs/ngrx-toolkit/src/lib/devtools/with-mapper.ts renamed to libs/ngrx-toolkit/src/lib/devtools/features/with-mapper.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createDevtoolsFeature, Mapper } from './devtools-feature';
1+
import { createDevtoolsFeature, Mapper } from '../internal/devtools-feature';
22

33
/**
44
* Allows you to define a function to map the state.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { effect, Injectable, signal } from '@angular/core';
2+
import { StateSource } from '@ngrx/signals';
3+
import { Tracker, TrackerStores } from './models';
4+
5+
@Injectable({ providedIn: 'root' })
6+
export class DefaultTracker implements Tracker {
7+
readonly #stores = signal<TrackerStores>({});
8+
#trackCallback: undefined | (() => void);
9+
10+
#trackingEffect = effect(() => {
11+
if (this.#trackCallback === undefined) {
12+
throw new Error('no callback function defined');
13+
}
14+
this.#stores(); // track stores
15+
this.#trackCallback();
16+
});
17+
18+
track(id: string, store: StateSource<object>): void {
19+
this.#stores.update((value) => ({
20+
...value,
21+
[id]: store,
22+
}));
23+
}
24+
25+
onChange(callback: () => void): void {
26+
this.#trackCallback = callback;
27+
}
28+
29+
removeStore(id: string) {
30+
this.#stores.update((stores) =>
31+
Object.entries(stores).reduce((newStore, [storeId, state]) => {
32+
if (storeId !== id) {
33+
newStore[storeId] = state;
34+
}
35+
return newStore;
36+
}, {} as TrackerStores)
37+
);
38+
}
39+
40+
getStores(): Record<string, StateSource<object>> {
41+
return Object.entries(this.#stores()).reduce((states, [key, store]) => {
42+
states[key] = store;
43+
return states;
44+
}, {} as Record<string, StateSource<object>>);
45+
}
46+
}

libs/ngrx-toolkit/src/lib/devtools/devtools-feature.ts renamed to libs/ngrx-toolkit/src/lib/devtools/internal/devtools-feature.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
1+
import { Tracker } from './models';
2+
13
export const DEVTOOLS_FEATURE = Symbol('DEVTOOLS_FEATURE');
24

35
export type Mapper = (state: object) => object;
46

57
export type DevtoolsOptions = {
6-
indexNames: boolean; // defines if names should be indexed.
7-
map: Mapper; // defines a mapper for the state.
8+
indexNames?: boolean; // defines if names should be indexed.
9+
map?: Mapper; // defines a mapper for the state.
10+
tracker?: new () => Tracker; // defines a tracker for the state
11+
};
12+
13+
export type DevtoolsInnerOptions = {
14+
indexNames: boolean;
15+
map: Mapper;
16+
tracker: Tracker;
817
};
918

1019
/**
@@ -19,7 +28,7 @@ export type DevtoolsFeature = {
1928
} & Partial<DevtoolsOptions>;
2029

2130
export function createDevtoolsFeature(
22-
options: Partial<DevtoolsOptions>
31+
options: DevtoolsOptions
2332
): DevtoolsFeature {
2433
return {
2534
[DEVTOOLS_FEATURE]: true,

libs/ngrx-toolkit/src/lib/devtools/internal/devtools-syncer.service.ts

Lines changed: 64 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
import {
2-
effect,
32
inject,
43
Injectable,
54
OnDestroy,
65
PLATFORM_ID,
76
signal,
87
} from '@angular/core';
9-
import { currentActionNames } from './currrent-action-names';
8+
import { currentActionNames } from './current-action-names';
109
import { isPlatformBrowser } from '@angular/common';
11-
import { Connection } from '../with-devtools';
1210
import { getState, StateSource } from '@ngrx/signals';
13-
import { DevtoolsOptions } from '../devtools-feature';
11+
import { DevtoolsInnerOptions } from './devtools-feature';
12+
import { throwIfNull } from '../../shared/throw-if-null';
13+
import { Connection, StoreRegistry, Tracker } from './models';
1414

1515
const dummyConnection: Connection = {
1616
send: () => void true,
@@ -31,6 +31,7 @@ const dummyConnection: Connection = {
3131
export class DevtoolsSyncer implements OnDestroy {
3232
readonly #stores = signal<StoreRegistry>({});
3333
readonly #isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
34+
readonly #trackers = [] as Tracker[];
3435
#currentId = 1;
3536

3637
readonly #connection: Connection = this.#isBrowser
@@ -52,37 +53,24 @@ export class DevtoolsSyncer implements OnDestroy {
5253
'NgRx Toolkit/DevTools: Redux DevTools Extension is not available.'
5354
);
5455
}
55-
56-
effect(() => {
57-
if (!this.#connection) {
58-
return;
59-
}
60-
61-
const stores = this.#stores();
62-
const rootState: Record<string, unknown> = {};
63-
for (const name in stores) {
64-
const { store, options } = stores[name];
65-
rootState[name] = options.map(getState(store));
66-
}
67-
68-
const names = Array.from(currentActionNames);
69-
const type = names.length ? names.join(', ') : 'Store Update';
70-
currentActionNames.clear();
71-
72-
this.#connection.send({ type }, rootState);
73-
});
7456
}
7557

7658
ngOnDestroy(): void {
7759
currentActionNames.clear();
7860
}
7961

80-
addStore(name: string, store: StateSource<object>, options: DevtoolsOptions) {
62+
addStore(
63+
name: string,
64+
store: StateSource<object>,
65+
options: DevtoolsInnerOptions
66+
) {
8167
let storeName = name;
82-
const names = Object.keys(this.#stores());
68+
const names = Object.values(this.#stores()).map((store) => store.name);
8369

8470
if (names.includes(storeName)) {
85-
const { options } = this.#stores()[storeName];
71+
const { options } = throwIfNull(
72+
Object.values(this.#stores()).find((store) => store.name === storeName)
73+
);
8674
if (!options.indexNames) {
8775
throw new Error(`An instance of the store ${storeName} already exists. \
8876
Enable automatic indexing via withDevTools('${storeName}', { indexNames: true }), or rename it upon instantiation.`);
@@ -92,55 +80,75 @@ Enable automatic indexing via withDevTools('${storeName}', { indexNames: true })
9280
for (let i = 1; names.includes(storeName); i++) {
9381
storeName = `${name}-${i}`;
9482
}
95-
const id = this.#currentId++;
96-
83+
const id = String(this.#currentId++);
9784
this.#stores.update((stores) => ({
9885
...stores,
99-
[storeName]: { store, options, id },
86+
[id]: { name: storeName, options },
10087
}));
10188

89+
const tracker = options.tracker;
90+
if (!this.#trackers.includes(tracker)) {
91+
this.#trackers.push(tracker);
92+
}
93+
94+
tracker.track(id, store);
95+
tracker.onChange(() => this.syncToDevTools());
96+
10297
return id;
10398
}
10499

105-
removeStore(id: number) {
106-
this.#stores.update((stores) => {
107-
return Object.entries(stores).reduce((newStore, [name, value]) => {
108-
if (value.id === id) {
109-
return newStore;
110-
} else {
111-
return { ...newStore, [name]: value };
100+
syncToDevTools() {
101+
const trackerStores = this.#trackers.reduce(
102+
(acc, tracker) => ({ ...acc, ...tracker.getStores() }),
103+
{} as Record<string, StateSource<object>>
104+
);
105+
const rootState = Object.entries(trackerStores).reduce(
106+
(acc, [id, store]) => {
107+
const { options, name } = this.#stores()[id];
108+
acc[name] = options.map(getState(store));
109+
return acc;
110+
},
111+
{} as Record<string, unknown>
112+
);
113+
114+
const names = Array.from(currentActionNames);
115+
const type = names.length ? names.join(', ') : 'Store Update';
116+
currentActionNames.clear();
117+
118+
this.#connection.send({ type }, rootState);
119+
}
120+
121+
removeStore(id: string) {
122+
for (const tracker of this.#trackers) {
123+
tracker.removeStore(id);
124+
}
125+
this.#stores.update((stores) =>
126+
Object.entries(stores).reduce((newStore, [storeId, value]) => {
127+
if (storeId !== id) {
128+
newStore[storeId] = value;
112129
}
113-
}, {});
114-
});
130+
return newStore;
131+
}, {} as StoreRegistry)
132+
);
115133
}
116134

117135
renameStore(oldName: string, newName: string) {
118136
this.#stores.update((stores) => {
119-
if (newName in stores) {
137+
const storeNames = Object.values(stores).map((store) => store.name);
138+
if (storeNames.includes(newName)) {
120139
throw new Error(
121140
`NgRx Toolkit/DevTools: cannot rename from ${oldName} to ${newName}. ${newName} is already assigned to another SignalStore instance.`
122141
);
123142
}
124143

125-
const newStore: StoreRegistry = {};
126-
for (const storeName in stores) {
127-
if (storeName === oldName) {
128-
newStore[newName] = stores[oldName];
144+
return Object.entries(stores).reduce((newStore, [id, value]) => {
145+
if (value.name === oldName) {
146+
newStore[id] = { ...value, name: newName };
129147
} else {
130-
newStore[storeName] = stores[storeName];
148+
newStore[id] = value;
131149
}
132-
}
133-
134-
return newStore;
150+
return newStore;
151+
}, {} as StoreRegistry);
135152
});
136153
}
137154
}
138-
139-
type StoreRegistry = Record<
140-
string,
141-
{
142-
store: StateSource<object>;
143-
options: DevtoolsOptions;
144-
id: number;
145-
}
146-
>;

libs/ngrx-toolkit/src/lib/devtools/internal/glitch-tracker.service.ts

Whitespace-only changes.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { StateSource } from '@ngrx/signals';
2+
import { DevtoolsInnerOptions } from './devtools-feature';
3+
4+
export type Action = { type: string };
5+
export type Connection = {
6+
send: (action: Action, state: Record<string, unknown>) => void;
7+
};
8+
export type ReduxDevtoolsExtension = {
9+
connect: (options: { name: string }) => Connection;
10+
};
11+
12+
export type StoreRegistry = Record<
13+
string,
14+
{
15+
options: DevtoolsInnerOptions;
16+
name: string;
17+
}
18+
>;
19+
20+
export type Tracker = {
21+
track(id: string, store: StateSource<object>): void;
22+
onChange(callback: () => void): void;
23+
removeStore(id: string): void;
24+
getStores: () => Record<string, StateSource<object>>;
25+
};
26+
27+
export type TrackerStores = Record<string, StateSource<object>>;

libs/ngrx-toolkit/src/lib/devtools/internal/tracker.ts

Whitespace-only changes.

libs/ngrx-toolkit/src/lib/devtools/tests/naming.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
runInInjectionContext,
1010
} from '@angular/core';
1111
import { renameDevtoolsName } from '../rename-devtools-name';
12-
import { withDisabledNameIndices } from '../with-disabled-name-indicies';
12+
import { withDisabledNameIndices } from '../features/with-disabled-name-indicies';
1313

1414
describe('withDevtools / renaming', () => {
1515
it('should automatically index multiple instances', () => {

libs/ngrx-toolkit/src/lib/devtools/tests/with-mapper.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { setupExtensions } from './helpers.spec';
22
import { TestBed } from '@angular/core/testing';
33
import { signalStore, withState } from '@ngrx/signals';
4-
import { withMapper } from '../with-mapper';
4+
import { withMapper } from '../features/with-mapper';
55
import { withDevtools } from '../with-devtools';
66

77
function domRemover(state: Record<string, unknown>) {

libs/ngrx-toolkit/src/lib/devtools/update-state.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { patchState as originalPatchState } from '@ngrx/signals';
22
import { PartialStateUpdater, WritableStateSource } from '@ngrx/signals';
33
import { Prettify } from '../shared/prettify';
4-
import { currentActionNames } from './internal/currrent-action-names';
4+
import { currentActionNames } from './internal/current-action-names';
55

66
type PatchFn = typeof originalPatchState extends (
77
arg1: infer First,

0 commit comments

Comments
 (0)