Skip to content

Commit c9cfe4c

Browse files
feat(devtools): enhance withEventsTracking to support glitch-free tracking and add tests
1 parent 9c539d3 commit c9cfe4c

File tree

2 files changed

+130
-4
lines changed

2 files changed

+130
-4
lines changed
Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { inject } from '@angular/core';
2-
import { Events } from '@ngrx/signals/events';
2+
import { Dispatcher, Events } from '@ngrx/signals/events';
33
import { createDevtoolsFeature } from '../internal/devtools-feature';
4+
import { GlitchTrackerService } from '../internal/glitch-tracker.service';
45

56
/**
67
* Automatically infers DevTools action names from NgRx SignalStore events.
@@ -9,13 +10,59 @@ import { createDevtoolsFeature } from '../internal/devtools-feature';
910
* the event's type as the upcoming DevTools action name. When the corresponding
1011
* reducer mutates state, the DevTools sync will use that name instead of
1112
* the default "Store Update".
13+
*
14+
* By default (withGlitchTracking = true), the `GlitchTrackerService` is used to capture
15+
* all intermediate updates (glitched states). To use the default, glitch-free tracker
16+
* and synchronize only stable state transitions, set `withGlitchTracking` to `false`.
17+
*
18+
* @param {{ withGlitchTracking?: boolean }} [options] Options to configure tracking behavior.
19+
* @param {boolean} [options.withGlitchTracking=true] Enable capturing intermediate (glitched) state updates.
20+
* @returns Devtools feature enabling events-based action naming; glitched tracking is enabled by default.
21+
* Set `withGlitchTracking: false` to use glitch-free tracking instead.
22+
* @example
23+
* // Capture intermediate updates (default)
24+
* withDevtools('counter', withEventsTracking());
25+
* @example
26+
* // Glitch-free tracking (only stable transitions)
27+
* withDevtools('counter', withEventsTracking({ withGlitchTracking: false }));
28+
* @see withGlitchTracking
1229
*/
13-
export function withEventsTracking() {
30+
export function withEventsTracking(
31+
options: { withGlitchTracking: boolean } = { withGlitchTracking: true },
32+
) {
33+
const useGlitchTracking = options.withGlitchTracking === true;
1434
return createDevtoolsFeature({
35+
tracker: useGlitchTracking ? GlitchTrackerService : undefined,
1536
eventsTracking: true,
1637
onInit: ({ trackEvents }) => {
17-
const events = inject(Events);
18-
trackEvents(events.on());
38+
if (useGlitchTracking) {
39+
trackEvents(getReducerEvents().on());
40+
} else {
41+
trackEvents(inject(Events).on());
42+
}
1943
},
2044
});
2145
}
46+
47+
/**
48+
* Returns the synchronous reducer event stream exposed by the dispatcher.
49+
*
50+
* NgRx's `Dispatcher` delivers events to `ReducerEvents` immediately but feeds
51+
* the public `Events` stream via `queueScheduler`. When `GlitchTrackerService`
52+
* captures the state change synchronously, the queued `Events` emission arrives
53+
* too late and DevTools records the update as `Store Update`. Tapping into the
54+
* reducer stream keeps event names and state changes aligned on the same tick.
55+
*
56+
* TODO(@ngrx): expose a synchronous events API (similar to what `withReducer` uses)
57+
* so consumers do not need to reach into dispatcher internals.
58+
*/
59+
function getReducerEvents() {
60+
type ReducerEventsLike = {
61+
on(): ReturnType<Dispatcher['events']['on']>;
62+
};
63+
64+
const dispatcher = inject(Dispatcher) as unknown as {
65+
reducerEvents: ReducerEventsLike;
66+
};
67+
return dispatcher.reducerEvents;
68+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { EnvironmentInjector, runInInjectionContext } from '@angular/core';
2+
import { TestBed } from '@angular/core/testing';
3+
import { signalStore, type, withState } from '@ngrx/signals';
4+
import {
5+
eventGroup,
6+
injectDispatch,
7+
on,
8+
withReducer,
9+
} from '@ngrx/signals/events';
10+
import { withEventsTracking } from '../features/with-events-tracking';
11+
import { withDevtools } from '../with-devtools';
12+
import { setupExtensions } from './helpers.spec';
13+
14+
const testEvents = eventGroup({
15+
source: 'Spec Store',
16+
events: {
17+
bump: type<void>(),
18+
},
19+
});
20+
21+
describe('withEventsTracking', () => {
22+
it('should send a glitched update on event', async () => {
23+
const { sendSpy } = setupExtensions();
24+
25+
const Store = signalStore(
26+
{ providedIn: 'root' },
27+
withDevtools('store-a', withEventsTracking({ withGlitchTracking: true })),
28+
withState({ count: 0 }),
29+
withReducer(
30+
on(testEvents.bump, (_event, state) => ({ count: state.count + 1 })),
31+
),
32+
);
33+
34+
TestBed.inject(Store);
35+
36+
runInInjectionContext(TestBed.inject(EnvironmentInjector), () => {
37+
injectDispatch(testEvents).bump();
38+
});
39+
40+
expect(sendSpy).toHaveBeenLastCalledWith(
41+
{ type: '[Spec Store] bump' },
42+
{ 'store-a': { count: 1 } },
43+
);
44+
});
45+
46+
it('should emit two glitched updates when two stores react to the same event', async () => {
47+
const { sendSpy } = setupExtensions();
48+
49+
const StoreA = signalStore(
50+
{ providedIn: 'root' },
51+
withDevtools('store-a', withEventsTracking({ withGlitchTracking: true })),
52+
withState({ count: 0 }),
53+
withReducer(
54+
on(testEvents.bump, (_event, state) => ({ count: state.count + 1 })),
55+
),
56+
);
57+
58+
const StoreB = signalStore(
59+
{ providedIn: 'root' },
60+
withDevtools('store-b', withEventsTracking({ withGlitchTracking: true })),
61+
withState({ count: 0 }),
62+
withReducer(
63+
on(testEvents.bump, (_event, state) => ({ count: state.count + 1 })),
64+
),
65+
);
66+
67+
TestBed.inject(StoreA);
68+
TestBed.inject(StoreB);
69+
70+
runInInjectionContext(TestBed.inject(EnvironmentInjector), () => {
71+
injectDispatch(testEvents).bump();
72+
});
73+
74+
expect(sendSpy).toHaveBeenLastCalledWith(
75+
{ type: '[Spec Store] bump' },
76+
{ 'store-a': { count: 1 }, 'store-b': { count: 1 } },
77+
);
78+
});
79+
});

0 commit comments

Comments
 (0)