Skip to content

Commit 1f51567

Browse files
refactor(devtools): devtools destruction on renamed name
1 parent 4e8b74c commit 1f51567

File tree

8 files changed

+109
-69
lines changed

8 files changed

+109
-69
lines changed

apps/demo/src/app/devtools/todo-detail.component.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import { Component, effect, inject, input } from '@angular/core';
22
import { MatCard, MatCardModule } from '@angular/material/card';
33
import { Todo } from './todo-store';
44
import { signalStore, withState } from '@ngrx/signals';
5-
import { withDevtools } from '@angular-architects/ngrx-toolkit';
5+
import {
6+
renameDevtoolsName,
7+
withDevtools,
8+
} from '@angular-architects/ngrx-toolkit';
69

710
/**
811
* This Store can be instantiated multiple times, if the user
@@ -38,4 +41,13 @@ const TodoDetailStore = signalStore(
3841
export class TodoDetailComponent {
3942
readonly #todoDetailStore = inject(TodoDetailStore);
4043
todo = input.required<Todo>();
44+
45+
constructor() {
46+
effect(
47+
() => {
48+
renameDevtoolsName(this.#todoDetailStore, `todo-${this.todo().id}`);
49+
},
50+
{ allowSignalWrites: true }
51+
);
52+
}
4153
}

apps/demo/src/app/devtools/todo-store.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,18 @@ export const TodoStore = signalStore(
5050
updateEntity({ id, changes: { finished: !todo.finished } })
5151
);
5252
},
53-
selectTodo(id: number) {
54-
updateState(store, `select todo ${id}`, {
55-
selectedIds: [...store.selectedIds(), id],
53+
toggleSelectTodo(id: number) {
54+
updateState(store, `select todo ${id}`, ({ selectedIds }) => {
55+
if (selectedIds.includes(id)) {
56+
return {
57+
selectedIds: selectedIds.filter(
58+
(selectedId) => selectedId !== id
59+
),
60+
};
61+
}
62+
return {
63+
selectedIds: [...store.selectedIds(), id],
64+
};
5665
});
5766
},
5867
};

apps/demo/src/app/devtools/todo.component.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ export class TodoComponent {
8686
}
8787

8888
checkboxLabel(todo: Todo) {
89-
this.todoStore.selectTodo(todo.id);
89+
this.todoStore.toggleSelectTodo(todo.id);
9090
}
9191

9292
removeTodo(todo: Todo) {

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

Lines changed: 20 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const dummyConnection: Connection = {
3030
export class DevtoolsSyncer implements OnDestroy {
3131
readonly #stores = signal<StoreRegistry>({});
3232
readonly #isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
33+
#currentId = 1;
3334

3435
readonly #connection: Connection = this.#isBrowser
3536
? window.__REDUX_DEVTOOLS_EXTENSION__
@@ -39,9 +40,6 @@ export class DevtoolsSyncer implements OnDestroy {
3940
: dummyConnection
4041
: dummyConnection;
4142

42-
// keeps track of names which have already been synced. Synced names cannot be renamed
43-
readonly #syncedStoreNames = new Set();
44-
4543
constructor() {
4644
if (!this.#isBrowser) {
4745
return;
@@ -62,7 +60,6 @@ export class DevtoolsSyncer implements OnDestroy {
6260
const stores = this.#stores();
6361
const rootState: Record<string, unknown> = {};
6462
for (const name in stores) {
65-
this.#syncedStoreNames.add(name);
6663
const { store } = stores[name];
6764
rootState[name] = getState(store);
6865
}
@@ -94,41 +91,38 @@ Enable automatic indexing via withDevTools('${storeName}', { indexNames: true })
9491
for (let i = 1; names.includes(storeName); i++) {
9592
storeName = `${name}-${i}`;
9693
}
94+
const id = this.#currentId++;
9795

9896
this.#stores.update((stores) => ({
9997
...stores,
100-
[storeName]: { store, options },
98+
[storeName]: { store, options, id },
10199
}));
100+
101+
return id;
102102
}
103103

104-
removeStore(name: string) {
105-
this.#stores.update((value) => {
106-
const newStore: StoreRegistry = {};
107-
for (const storeName in value) {
108-
if (storeName !== name) {
109-
newStore[storeName] = value[storeName];
104+
removeStore(id: number) {
105+
this.#stores.update((stores) => {
106+
return Object.entries(stores).reduce((newStore, [name, value]) => {
107+
if (value.id === id) {
108+
return newStore;
109+
} else {
110+
return { ...newStore, [name]: value };
110111
}
111-
}
112-
113-
return newStore;
112+
}, {});
114113
});
115114
}
116115

117116
renameStore(oldName: string, newName: string) {
118-
if (this.#syncedStoreNames.has(oldName)) {
119-
throw new Error(
120-
`NgRx Toolkit/DevTools: cannot rename from ${oldName} to ${newName}. ${oldName} has already been send to DevTools.`
121-
);
122-
}
123-
124117
this.#stores.update((stores) => {
118+
if (newName in stores) {
119+
throw new Error(
120+
`NgRx Toolkit/DevTools: cannot rename from ${oldName} to ${newName}. ${oldName} does not exist.`
121+
);
122+
}
123+
125124
const newStore: StoreRegistry = {};
126125
for (const storeName in stores) {
127-
if (storeName === newName) {
128-
throw new Error(
129-
`NgRx Toolkit/DevTools: cannot rename from ${oldName} to ${newName}. ${newName} already exists.`
130-
);
131-
}
132126
if (storeName === oldName) {
133127
newStore[newName] = stores[oldName];
134128
} else {
@@ -143,5 +137,5 @@ Enable automatic indexing via withDevTools('${storeName}', { indexNames: true })
143137

144138
type StoreRegistry = Record<
145139
string,
146-
{ store: StateSource<object>; options: DevtoolsOptions }
140+
{ store: StateSource<object>; options: DevtoolsOptions; id: number }
147141
>;

libs/ngrx-toolkit/src/lib/devtools/rename-devtools-name.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,6 @@ import { renameDevtoolsMethodName } from './with-devtools';
33

44
/**
55
* Renames the name of a store how it appears in the Devtools.
6-
*
7-
* This method has to be executed before the first
8-
* synchronization. In most cases. that's in the constructor.
96
* @param store instance of the SignalStore
107
* @param newName new name for the Devtools
118
*/

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

Lines changed: 40 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,16 @@ import { setupExtensions } from './helpers';
22
import { TestBed, waitForAsync } from '@angular/core/testing';
33
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
44
import { withDevtools } from '../with-devtools';
5-
import { Component, inject } from '@angular/core';
5+
import {
6+
Component,
7+
createEnvironmentInjector,
8+
EnvironmentInjector,
9+
inject,
10+
Injector,
11+
} from '@angular/core';
612
import { provideRouter } from '@angular/router';
713
import { RouterTestingHarness } from '@angular/router/testing';
14+
import { renameDevtoolsName } from '../rename-devtools-name';
815

916
describe('Devtools Basics', () => {
1017
it('should dispatch update', () => {
@@ -38,44 +45,52 @@ describe('Devtools Basics', () => {
3845
);
3946
});
4047

41-
it('should remove the state once destroyed', waitForAsync(async () => {
48+
it('should remove the state once destroyed', () => {
4249
const { sendSpy } = setupExtensions();
4350

4451
const Store = signalStore(withDevtools('flight'));
52+
const childInjector = createEnvironmentInjector(
53+
[Store],
54+
TestBed.inject(EnvironmentInjector)
55+
);
4556

46-
@Component({
47-
selector: 'app-flight-search',
48-
template: ``,
49-
standalone: true,
50-
providers: [Store],
51-
})
52-
class FlightSearchComponent {
53-
store = inject(Store);
54-
}
57+
childInjector.get(Store);
58+
TestBed.flushEffects();
5559

56-
@Component({ selector: 'app-home', template: ``, standalone: true })
57-
class HomeComponent {}
60+
expect(sendSpy).toHaveBeenCalledWith(
61+
{ type: 'Store Update' },
62+
{ flight: {} }
63+
);
5864

59-
TestBed.configureTestingModule({
60-
providers: [
61-
provideRouter([
62-
{ path: '', component: HomeComponent },
63-
{ path: 'flight', component: FlightSearchComponent },
64-
]),
65-
],
66-
});
65+
childInjector.destroy();
66+
TestBed.flushEffects();
67+
expect(sendSpy).toHaveBeenCalledWith({ type: 'Store Update' }, {});
68+
});
69+
70+
it('should remove a renamed state once destroyed', () => {
71+
const { sendSpy } = setupExtensions();
72+
73+
const Store = signalStore(withDevtools('flight'));
74+
const childInjector = createEnvironmentInjector(
75+
[Store],
76+
TestBed.inject(EnvironmentInjector)
77+
);
78+
79+
const store = childInjector.get(Store);
80+
TestBed.flushEffects();
6781

68-
const harness = await RouterTestingHarness.create('flight');
6982
expect(sendSpy).toHaveBeenCalledWith(
7083
{ type: 'Store Update' },
7184
{ flight: {} }
7285
);
73-
await harness.navigateByUrl('/');
86+
87+
renameDevtoolsName(store, 'flights');
88+
childInjector.destroy();
7489
TestBed.flushEffects();
7590
expect(sendSpy).toHaveBeenCalledWith({ type: 'Store Update' }, {});
76-
}));
91+
});
7792

78-
it('should group multiple patchState running before the first synchronization', () => {
93+
it('should group multiple patchState running before the synchronization', () => {
7994
const { sendSpy } = setupExtensions();
8095
const store = TestBed.inject(
8196
signalStore(

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,19 +130,22 @@ Enable automatic indexing via withDevTools('flights', { indexNames: true }), or
130130
);
131131
});
132132

133-
it('should throw on rename after sync', () => {
134-
setupExtensions();
133+
it('should also rename after sync', () => {
134+
const { sendSpy } = setupExtensions();
135135
const Store = signalStore(
136136
{ providedIn: 'root' },
137137
withState({ name: 'Product', price: 10.5 }),
138138
withDevtools('flight')
139139
);
140140
const store = TestBed.inject(Store);
141141

142+
TestBed.flushEffects();
143+
renameDevtoolsName(store, 'flights');
142144
TestBed.flushEffects();
143145

144-
expect(() => renameDevtoolsName(store, 'flights')).toThrow(
145-
'NgRx Toolkit/DevTools: cannot rename from flight to flights. flight has already been send to DevTools.'
146+
expect(sendSpy).toHaveBeenCalledWith(
147+
{ type: 'Store Update' },
148+
{ flights: { name: 'Product', price: 10.5 } }
146149
);
147150
});
148151

libs/ngrx-toolkit/src/lib/devtools/with-devtools.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { signalStoreFeature, withHooks, withMethods } from '@ngrx/signals';
1+
import {
2+
SignalStoreFeature,
3+
signalStoreFeature,
4+
SignalStoreFeatureResult,
5+
withHooks,
6+
withMethods,
7+
} from '@ngrx/signals';
28
import { inject } from '@angular/core';
39
import { DevtoolsSyncer } from './internal/devtools-syncer.service';
410

@@ -53,6 +59,7 @@ export type DevtoolsOptions = {
5359
export const existingNames = new Map<string, unknown>();
5460

5561
export const renameDevtoolsMethodName = '___renameDevtoolsName';
62+
export const uniqueDevtoolsId = '___uniqueDevtoolsId';
5663

5764
/**
5865
* Adds this store as a feature state to the Redux DevTools.
@@ -62,7 +69,7 @@ export const renameDevtoolsMethodName = '___renameDevtoolsName';
6269
* parameter the action name.
6370
*
6471
* The standalone function {@link renameDevtoolsName} can rename
65-
* the store name before the first synchronization.
72+
* the store name.
6673
*
6774
* @param name name of the store as it should appear in the DevTools
6875
* @param options options for the DevTools
@@ -81,17 +88,20 @@ export function withDevtools(
8188
return signalStoreFeature(
8289
withMethods((store) => {
8390
const syncer = inject(DevtoolsSyncer);
84-
syncer.addStore(name, store, finalOptions);
91+
const id = syncer.addStore(name, store, finalOptions);
8592

93+
// TODO: use withProps and symbols
8694
return {
8795
[renameDevtoolsMethodName]: (newName: string) => {
8896
syncer.renameStore(name, newName);
8997
},
90-
} as Record<string, (newName: string) => void>;
98+
[uniqueDevtoolsId]: () => id,
99+
} as Record<string, (newName?: unknown) => unknown>;
91100
}),
92-
withHooks(() => {
101+
withHooks((store) => {
93102
const syncer = inject(DevtoolsSyncer);
94-
return { onDestroy: () => syncer.removeStore(name) };
103+
const id = Number(store[uniqueDevtoolsId]());
104+
return { onDestroy: () => syncer.removeStore(id) };
95105
})
96106
);
97107
}

0 commit comments

Comments
 (0)