Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions apps/demo/e2e/events-sample.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Action } from '@ngrx/store';
import { expect, test } from '@playwright/test';

test.describe('events-sample + devtools', () => {
test('DevTools are syncing events and payloads', async ({ page }) => {
await page.goto('');

await page.evaluate(() => {
window['devtoolsSpy'] = [];

window['__REDUX_DEVTOOLS_EXTENSION__'] = {
connect: () => {
return {
send: (data: Action) => {
window['devtoolsSpy'].push(data);
},
};
},
};
});

await page.getByRole('link', { name: 'Events + DevTools Sample' }).click();

// Filter by title
await page.getByPlaceholder('Filter by title or author...').fill('Hobbit');

// Add Book (Random)
await page.getByRole('button', { name: 'Add Book' }).click();

// Clear Selection
await page.getByRole('button', { name: 'Clear Selection' }).click();

const devtoolsActions = await page.evaluate(() => window['devtoolsSpy']);

// Check if filterUpdated event was tracked with the expected payload
const filterEvent = devtoolsActions.find(
(a) => a.type === '[Book Store] filterUpdated',
);
expect(filterEvent).toBeDefined();
expect(filterEvent).toEqual({
type: '[Book Store] filterUpdated',
payload: { filter: 'Hobbit' },
});

// Check selectionCleared event
const clearSelectionEvent = devtoolsActions.find(
(a) => a.type === '[Book Store] selectionCleared',
);
expect(clearSelectionEvent).toBeDefined();
expect(clearSelectionEvent).toEqual({
type: '[Book Store] selectionCleared',
});

// Check bookAdded event (payload will contain random book, so just check if payload has book object)
const bookAddedEvent = devtoolsActions.find(
(a) => a.type === '[Book Store] bookAdded',
);
expect(bookAddedEvent).toBeDefined();
expect(bookAddedEvent.payload).toBeDefined();
expect(bookAddedEvent.payload.book).toBeDefined();
expect(bookAddedEvent.payload.book).toHaveProperty('title');
});
});
13 changes: 12 additions & 1 deletion docs/docs/with-devtools.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ patchState(this.store, { loading: false });

// updateState is a wrapper around patchState and has an action name as second parameter
updateState(this.store, 'update loading', { loading: false });

// updateState can also accept an EventInstance from @ngrx/signals/events.
// It will use the event type and payload for the DevTools.
updateState(this.store, bookEvents.addBook({ book }), { loading: false });
```

## `renameDevtoolsName()`
Expand Down Expand Up @@ -188,6 +192,7 @@ export const bookEvents = eventGroup({
source: 'Book Store',
events: {
loadBooks: type<void>(),
addBook: type<{ book: Book }>(),
},
});

Expand All @@ -202,10 +207,16 @@ const Store = signalStore(
on(bookEvents.loadBooks, () => ({
books: mockBooks,
})),
// `[Book Store] addBook` will show up in the devtools along with its payload `{ book }`
on(bookEvents.addBook, (event, state) => ({
books: [...state.books, event.book],
})),
),
withHooks({
onInit() {
injectDispatch(bookEvents).loadBooks();
const dispatch = injectDispatch(bookEvents);
dispatch.loadBooks();
dispatch.addBook({ book: { id: 1, title: '1984' } });
},
}),
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
import { EventInstance } from '@ngrx/signals/events';

export const currentActionNames = new Set<string>();

export const currentEvents = new Set<EventInstance<string, any>>();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

to be honest, I think we don't even have to bring a queue for the events. withTrackedReducer should consume the event and immediately send it devtools.

The action names were only necessary when are collecting the state changes via an effect.

Because of that, I think this whole PR could be much shorter.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the comment

I just based it on the existing functionality. Could you explain what do you mean by

withTrackedReducer should consume the event and immediately send it devtools

I could inject DevtoolsSyncer after updateState and call some syncing, but i don't think this is a nice way

caseReducers.map((caseReducer) =>
  events.on(...caseReducer.events).pipe(
    tap((event) => {
      const state = untracked(() => getState(store));
      const result = caseReducer.reducer(event, state);
      const updaters = Array.isArray(result) ? result : [result];

      updateState(store, event.type, ...updaters);
    }),
  ),
),

Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import {
} from '@angular/core';
import { StateSource } from '@ngrx/signals';
import { REDUX_DEVTOOLS_CONFIG } from '../provide-devtools-config';
import { currentActionNames } from './current-action-names';
import { currentActionNames, currentEvents } from './current-action-names';
import { DevtoolsInnerOptions } from './devtools-feature';
import { Connection, StoreRegistry, Tracker } from './models';
import { Action, Connection, StoreRegistry, Tracker } from './models';

const dummyConnection: Connection = {
send: () => void true,
Expand Down Expand Up @@ -74,6 +74,7 @@ export class DevtoolsSyncer implements OnDestroy {

ngOnDestroy(): void {
currentActionNames.clear();
currentEvents.clear();
}

syncToDevTools(changedStatePerId: Record<string, object>) {
Expand All @@ -90,11 +91,21 @@ export class DevtoolsSyncer implements OnDestroy {
...mappedChangedStatePerName,
};

const names = Array.from(currentActionNames);
const type = names.length ? names.join(', ') : 'Store Update';
currentActionNames.clear();
const action: Action = { type: 'Store Update' };

if (currentActionNames.size > 0) {
action.type = Array.from(currentActionNames).join(', ');
} else if (currentEvents.size > 0) {
const events = Array.from(currentEvents);
action.type = events.map((e) => e.type).join(', ');
action.payload =
events.length > 1 ? events.map((e) => e.payload) : events[0].payload;
}

this.#connection.send({ type }, this.#currentState);
this.#connection.send(action, this.#currentState);

currentActionNames.clear();
currentEvents.clear();
}

getNextId() {
Expand Down
2 changes: 1 addition & 1 deletion libs/ngrx-toolkit/src/lib/devtools/internal/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { StateSource } from '@ngrx/signals';
import { ReduxDevtoolsConfig } from '../provide-devtools-config';
import { DevtoolsInnerOptions } from './devtools-feature';

export type Action = { type: string };
export type Action = { type: string; payload?: unknown };
export type Connection = {
send: (action: Action, state: Record<string, unknown>) => void;
};
Expand Down
30 changes: 29 additions & 1 deletion libs/ngrx-toolkit/src/lib/devtools/tests/action-name.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { signalStore, withMethods, withState } from '@ngrx/signals';
import { signalStore, type, withMethods, withState } from '@ngrx/signals';
import { event } from '@ngrx/signals/events';
import { updateState } from '../update-state';
import { withDevtools } from '../with-devtools';
import { setupExtensions } from './helpers.spec';
Expand Down Expand Up @@ -45,4 +46,31 @@ describe('updateState', () => {
{ shop: { name: 'i4' } },
);
});

it('should set the action name and payload using EventInstance', () => {
const { sendSpy } = setupExtensions();

const setNameEvent = event('Set Name', type<{ name: string }>());

const Store = signalStore(
{ providedIn: 'root' },
withDevtools('shop'),
withState({ name: 'Car' }),
withMethods((store) => ({
setName(name: string) {
updateState(store, setNameEvent({ name }), { name });
},
})),
);
const store = TestBed.inject(Store);
TestBed.flushEffects();

store.setName('i4');
TestBed.flushEffects();

expect(sendSpy).toHaveBeenLastCalledWith(
{ type: 'Set Name', payload: { name: 'i4' } },
{ shop: { name: 'i4' } },
);
});
});
28 changes: 27 additions & 1 deletion libs/ngrx-toolkit/src/lib/devtools/tests/update-state.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { signalStore, withMethods } from '@ngrx/signals';
import { setAllEntities, withEntities } from '@ngrx/signals/entities';
import { event } from '@ngrx/signals/events';
import { setLoaded, withCallState } from '../../with-call-state';
import { updateState } from '../update-state';

describe('updateState', () => {
it('should work with multiple updaters', () => {
it('should work with multiple updaters via string action', () => {
interface Item {
id: string;
name: string;
Expand All @@ -26,4 +27,29 @@ describe('updateState', () => {
})),
);
});

it('should work with multiple updaters via EventInstance action', () => {
interface Item {
id: string;
name: string;
}

const itemsLoaded = event('Items loaded successfully');

signalStore(
withEntities<Item>(),
withCallState({ collection: 'items' }),
withMethods((store) => ({
loadItems() {
// This should not cause a type error
updateState(
store,
itemsLoaded(),
setAllEntities([] as Item[]),
setLoaded('items'),
);
},
})),
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const testEvents = eventGroup({
source: 'Spec Store',
events: {
bump: type<void>(),
bumpWithPayload: type<{ amount: number }>(),
},
});

Expand All @@ -52,6 +53,30 @@ describe('withTrackedReducer', () => {
);
});

it('should send a glitched update on event with payload', async () => {
const { sendSpy, withBasicStore } = setup();

const Store = signalStore(
{ providedIn: 'root' },
withBasicStore('store'),
withTrackedReducer(
on(testEvents.bumpWithPayload, (event, state) => ({
count: state.count + event.payload.amount,
})),
),
);
TestBed.inject(Store);

TestBed.inject(Dispatcher).dispatch(
testEvents.bumpWithPayload({ amount: 5 }),
);

expect(sendSpy).toHaveBeenLastCalledWith(
{ type: '[Spec Store] bumpWithPayload', payload: { amount: 5 } },
{ store: { count: 5 } },
);
});

it('should emit two glitched updates when two stores react to the same event', async () => {
const { sendSpy, withBasicStore } = setup();
const StoreA = signalStore(
Expand Down
33 changes: 31 additions & 2 deletions libs/ngrx-toolkit/src/lib/devtools/update-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import {
PartialStateUpdater,
WritableStateSource,
} from '@ngrx/signals';
import { currentActionNames } from './internal/current-action-names';
import { EventInstance } from '@ngrx/signals/events';
import {
currentActionNames,
currentEvents,
} from './internal/current-action-names';

type PatchFn = typeof originalPatchState extends (
arg1: infer First,
Expand Down Expand Up @@ -32,7 +36,32 @@ export function updateState<State extends object>(
...updaters: Array<
Partial<NoInfer<State>> | PartialStateUpdater<NoInfer<State>>
>
): void;
/**
* Wrapper of `patchState` for DevTools integration. Next to updating the state,
* it also sends the action to the DevTools.
* @param stateSource state of Signal Store
* @param eventInstance event instance which provides the action name for DevTools
* @param updaters updater functions or objects
*/
export function updateState<State extends object>(
stateSource: WritableStateSource<State>,
eventInstance: EventInstance<string, any>,
...updaters: Array<
Partial<NoInfer<State>> | PartialStateUpdater<NoInfer<State>>
>
): void;
export function updateState<State extends object>(
stateSource: WritableStateSource<State>,
eventOrEventType: string | EventInstance<string, any>,
...updaters: Array<
Partial<NoInfer<State>> | PartialStateUpdater<NoInfer<State>>
>
): void {
currentActionNames.add(action);
if (typeof eventOrEventType === 'string') {
currentActionNames.add(eventOrEventType);
} else {
currentEvents.add(eventOrEventType);
}
return originalPatchState(stateSource, ...updaters);
}
2 changes: 1 addition & 1 deletion libs/ngrx-toolkit/src/lib/devtools/with-tracked-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function withTrackedReducer<State extends object>(
const result = caseReducer.reducer(event, state);
const updaters = Array.isArray(result) ? result : [result];

updateState(store, event.type, ...updaters);
updateState(store, event, ...updaters);
}),
),
),
Expand Down
Loading