Skip to content

Commit b2a8f1f

Browse files
feat: add type check for existence of glitched tracking
1 parent 5878a01 commit b2a8f1f

6 files changed

Lines changed: 117 additions & 34 deletions

File tree

libs/ngrx-toolkit/src/lib/devtools/features/with-glitch-tracking.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { createDevtoolsFeature } from '../internal/devtools-feature';
22
import { GlitchTrackerService } from '../internal/glitch-tracker.service';
33

4+
export const GLITCH_TRACKING_FEATURE = 'GLITCH_TRACKING_FEATURE' as const;
5+
46
/**
57
* It tracks all state changes of the State, including intermediary updates
68
* that are typically suppressed by Angular's glitch-free mechanism.
@@ -31,5 +33,10 @@ import { GlitchTrackerService } from '../internal/glitch-tracker.service';
3133
* Without `withGlitchTracking`, the DevTools would only show the final value of 3.
3234
*/
3335
export function withGlitchTracking() {
34-
return createDevtoolsFeature({ tracker: GlitchTrackerService });
36+
return createDevtoolsFeature(
37+
{
38+
tracker: GlitchTrackerService,
39+
},
40+
GLITCH_TRACKING_FEATURE,
41+
);
3542
}

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,18 @@ export type DevtoolsInnerOptions = {
2323
* We use them (function calls) instead of a config object,
2424
* because of tree-shaking.
2525
*/
26-
export type DevtoolsFeature = {
26+
export type DevtoolsFeature<Name extends string> = {
2727
[DEVTOOLS_FEATURE]: true;
28+
name: Name;
2829
} & Partial<DevtoolsOptions>;
2930

30-
export function createDevtoolsFeature(
31+
export function createDevtoolsFeature<Name extends string = ''>(
3132
options: DevtoolsOptions,
32-
): DevtoolsFeature {
33+
name: Name = '' as Name,
34+
): DevtoolsFeature<Name> {
3335
return {
3436
[DEVTOOLS_FEATURE]: true,
3537
...options,
38+
name,
3639
};
3740
}

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

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
withEventHandlers,
1717
} from '@ngrx/signals/events';
1818
import { delay, tap } from 'rxjs';
19+
import { withDisabledNameIndices } from '../features/with-disabled-name-indicies';
1920
import { withGlitchTracking } from '../features/with-glitch-tracking';
2021
import { updateState } from '../update-state';
2122
import { withDevtools } from '../with-devtools';
@@ -128,28 +129,6 @@ describe('withTrackedReducer', () => {
128129
);
129130
});
130131

131-
it('should not have a race condition between the reducer and the tracker', () => {
132-
const { sendSpy } = setup();
133-
134-
const Store = signalStore(
135-
{ providedIn: 'root' },
136-
withState({ count: 0 }),
137-
withTrackedReducer(
138-
on(testEvents.bump, (_, state) => ({ count: state.count + 1 })),
139-
),
140-
// Injecting this here, means the reducer gets notified before the tracker
141-
withDevtools('store', withGlitchTracking()),
142-
);
143-
144-
TestBed.inject(Store);
145-
dispatchBumpEvent();
146-
147-
expect(sendSpy).toHaveBeenLastCalledWith(
148-
{ type: '[Spec Store] bump' },
149-
{ store: { count: 1 } },
150-
);
151-
});
152-
153132
it('should not label a synchronous patch in an event handler (event tracking only on reducer level)', async () => {
154133
const { sendSpy, withBasicStore } = setup();
155134

@@ -222,6 +201,29 @@ describe('withTrackedReducer', () => {
222201
{ store: { count: 1 } },
223202
);
224203
});
204+
205+
describe('types', () => {
206+
it('should fail if `withDevtools` is not used', () => {
207+
signalStore(withState({ count: 0 }), withTrackedReducer());
208+
});
209+
it('should fail during runtime if `withDevtools` is missing glitched tracking', () => {
210+
signalStore(withDevtools('store'), withTrackedReducer());
211+
});
212+
it('should succeed if `withDevtools` is used with glitched tracking', () => {
213+
// In order to have a type-safe test
214+
signalStore(
215+
withDevtools('store', withGlitchTracking()),
216+
withTrackedReducer(),
217+
);
218+
});
219+
220+
it('should also work with multiple devtools features', () => {
221+
signalStore(
222+
withDevtools('store', withGlitchTracking(), withDisabledNameIndices()),
223+
withTrackedReducer(),
224+
);
225+
});
226+
});
225227
});
226228

227229
function dispatchBumpEvent() {
@@ -233,7 +235,7 @@ function setup() {
233235

234236
function withBasicStore(name: string) {
235237
return signalStoreFeature(
236-
withDevtools(name, withGlitchTracking()),
238+
withDevtools(name, withGlitchTracking(), withDisabledNameIndices()),
237239
withState({ count: 0 }),
238240
);
239241
}
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1-
import { withDevtools } from './with-devtools';
1+
import { signalStoreFeature, withProps } from '@ngrx/signals';
2+
import { DEVTOOL_PROP, withDevtools } from './with-devtools';
23

34
/**
45
* Stub for DevTools integration. Can be used to disable DevTools in production.
56
*/
6-
export const withDevToolsStub: typeof withDevtools = () => (store) => store;
7+
export const withDevToolsStub: typeof withDevtools = () =>
8+
signalStoreFeature(withProps(() => ({ [DEVTOOL_PROP]: [] as [] })));

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

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
signalStoreFeature,
66
withHooks,
77
withMethods,
8+
withProps,
89
} from '@ngrx/signals';
910
import { DefaultTracker } from './internal/default-tracker';
1011
import {
@@ -15,7 +16,8 @@ import { DevtoolsSyncer } from './internal/devtools-syncer.service';
1516
import { ReduxDevtoolsExtension } from './internal/models';
1617

1718
// Users requested that we export this type: https://github.com/angular-architects/ngrx-toolkit/issues/178
18-
export type DevtoolsFeature = DevtoolsFeatureInternal;
19+
export type DevtoolsFeature<Name extends string = string> =
20+
DevtoolsFeatureInternal<Name>;
1921

2022
declare global {
2123
interface Window {
@@ -25,6 +27,8 @@ declare global {
2527

2628
export const renameDevtoolsMethodName = '___renameDevtoolsName';
2729
export const uniqueDevtoolsId = '___uniqueDevtoolsId';
30+
// Used to declare the existence of the devtools extension
31+
export const DEVTOOL_PROP = Symbol('DEVTOOL_PROP');
2832

2933
/**
3034
* Adds this store as a feature state to the Redux DevTools.
@@ -39,6 +43,51 @@ export const uniqueDevtoolsId = '___uniqueDevtoolsId';
3943
* @param name name of the store as it should appear in the DevTools
4044
* @param features features to extend or modify the behavior of the Devtools
4145
*/
46+
export function withDevtools(
47+
name: string,
48+
): SignalStoreFeature<
49+
EmptyFeatureResult,
50+
EmptyFeatureResult & { props: { [DEVTOOL_PROP]: [] } }
51+
>;
52+
53+
export function withDevtools<DV1 extends DevtoolsFeature>(
54+
name: string,
55+
feature: DV1,
56+
): SignalStoreFeature<
57+
EmptyFeatureResult,
58+
EmptyFeatureResult & { props: { [DEVTOOL_PROP]: DV1['name'] } }
59+
>;
60+
61+
export function withDevtools<
62+
DV1 extends DevtoolsFeature,
63+
DV2 extends DevtoolsFeature,
64+
>(
65+
name: string,
66+
feature1: DV1,
67+
feature2: DV2,
68+
): SignalStoreFeature<
69+
EmptyFeatureResult,
70+
EmptyFeatureResult & {
71+
props: { [DEVTOOL_PROP]: DV1['name'] | DV2['name'] };
72+
}
73+
>;
74+
75+
export function withDevtools<
76+
DV1 extends DevtoolsFeature,
77+
DV2 extends DevtoolsFeature,
78+
DV3 extends DevtoolsFeature,
79+
>(
80+
name: string,
81+
feature1: DV1,
82+
feature2: DV2,
83+
feature3: DV3,
84+
): SignalStoreFeature<
85+
EmptyFeatureResult,
86+
EmptyFeatureResult & {
87+
props: { [DEVTOOL_PROP]: DV1['name'] | DV2['name'] | DV3['name'] };
88+
}
89+
>;
90+
4291
export function withDevtools(name: string, ...features: DevtoolsFeature[]) {
4392
return signalStoreFeature(
4493
withMethods(() => {
@@ -54,6 +103,9 @@ export function withDevtools(name: string, ...features: DevtoolsFeature[]) {
54103
[uniqueDevtoolsId]: () => id,
55104
} as Record<string, (newName?: unknown) => unknown>;
56105
}),
106+
withProps(() => ({
107+
[DEVTOOL_PROP]: features.filter((f) => f.name).map((f) => f.name),
108+
})),
57109
withHooks((store) => {
58110
const syncer = inject(DevtoolsSyncer);
59111
const id = String(store[uniqueDevtoolsId]());
@@ -74,5 +126,8 @@ export function withDevtools(name: string, ...features: DevtoolsFeature[]) {
74126
},
75127
};
76128
}),
77-
) as SignalStoreFeature<EmptyFeatureResult, EmptyFeatureResult>;
129+
) as SignalStoreFeature<
130+
EmptyFeatureResult,
131+
EmptyFeatureResult & { props: { [DEVTOOL_PROP]: unknown } }
132+
>;
78133
}

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

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,31 @@ import {
1313
withEventHandlers,
1414
} from '@ngrx/signals/events';
1515
import { tap } from 'rxjs/operators';
16+
import { GLITCH_TRACKING_FEATURE } from './features/with-glitch-tracking';
1617
import { updateState } from './update-state';
18+
import { DEVTOOL_PROP } from './with-devtools';
1719

18-
export function withTrackedReducer<State extends object>(
20+
export function withTrackedReducer<
21+
State extends object,
22+
DevtoolsFeatureName extends string,
23+
>(
1924
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2025
...caseReducers: CaseReducerResult<State, any>[]
2126
): SignalStoreFeature<
22-
EmptyFeatureResult & { state: State },
27+
EmptyFeatureResult & {
28+
state: State;
29+
props: {
30+
[DEVTOOL_PROP]: typeof GLITCH_TRACKING_FEATURE extends DevtoolsFeatureName
31+
? DevtoolsFeatureName
32+
: 'NO GLITCHED TRACKING ACTIVATED';
33+
};
34+
},
2335
EmptyFeatureResult
2436
> {
2537
return signalStoreFeature(
26-
{ state: type<State>() },
38+
{
39+
state: type<State>(),
40+
},
2741
withEventHandlers((store, events = inject(ReducerEvents)) =>
2842
caseReducers.map((caseReducer) =>
2943
events.on(...caseReducer.events).pipe(

0 commit comments

Comments
 (0)