Skip to content

Commit a5f7732

Browse files
committed
fix(store-devtools): resolve extension dispatcher payloads with configured action creators
The extension serializes action creators with JSON.stringify, so passing creator functions directly produced null entries in its dispatcher, and NgRx createAction creators are anonymous functions with no usable name. Dispatching also failed: the extension forwards dispatcher payloads ({ selected, args }) to connect() subscribers as-is, and unwrapAction only handled string payloads, so the payload was dispatched as an action without a type. Normalize the configured creators into the { name, func, args } shape the extension renders (record keys or the creator's type become the display names), and resolve { selected, args } payloads back through the configured creators when the extension dispatches.
1 parent a65b7ed commit a5f7732

3 files changed

Lines changed: 230 additions & 6 deletions

File tree

modules/store-devtools/spec/extension.spec.ts

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,40 @@ describe('DevtoolsExtension', () => {
181181
// Subscription needed or else extension connection will not be established.
182182
devtoolsExtension.actions$.subscribe(() => null);
183183
expect(reduxDevtoolsExtension.connect).toHaveBeenCalledWith(
184-
expect.objectContaining({ actionCreators: [bookRented, bookReturned] })
184+
expect.objectContaining({
185+
actionCreators: [
186+
{ name: '[Books] Rent', func: bookRented, args: ['props'] },
187+
{ name: '[Books] Return', func: bookReturned, args: ['props'] },
188+
],
189+
})
190+
);
191+
});
192+
193+
it('should connect with action creators named by record keys', () => {
194+
const bookRented = createAction(
195+
'[Books] Rent',
196+
props<{ id: number; customerId: number }>()
197+
);
198+
const allBooksReturned = createAction('[Books] Return All');
199+
200+
const { devtoolsExtension, reduxDevtoolsExtension } = testSetup({
201+
config: createConfig({
202+
actionCreators: {
203+
rentBook: bookRented,
204+
returnAllBooks: allBooksReturned,
205+
},
206+
}),
207+
});
208+
209+
// Subscription needed or else extension connection will not be established.
210+
devtoolsExtension.actions$.subscribe(() => null);
211+
expect(reduxDevtoolsExtension.connect).toHaveBeenCalledWith(
212+
expect.objectContaining({
213+
actionCreators: [
214+
{ name: 'rentBook', func: bookRented, args: ['props'] },
215+
{ name: 'returnAllBooks', func: allBooksReturned, args: [] },
216+
],
217+
})
185218
);
186219
});
187220

@@ -245,6 +278,85 @@ describe('DevtoolsExtension', () => {
245278
});
246279
}
247280

281+
describe('actions dispatched with configured action creators', () => {
282+
const bookRented = createAction(
283+
'[Books] Rent',
284+
props<{ id: number; customerId: number }>()
285+
);
286+
const allBooksReturned = createAction('[Books] Return All');
287+
const booksSearched = createAction(
288+
'[Books] Search',
289+
(query: string, page: number) => ({ query, page })
290+
);
291+
292+
function dispatchFromExtension(
293+
config: StoreDevtoolsConfig,
294+
payload: unknown
295+
) {
296+
const { devtoolsExtension, extensionConnection } = testSetup({ config });
297+
let unwrappedAction: Action | undefined = undefined;
298+
devtoolsExtension.actions$.subscribe((action) => {
299+
return (unwrappedAction = action);
300+
});
301+
302+
const [callback] = extensionConnection.subscribe.mock.lastCall;
303+
callback({ type: ExtensionActionTypes.START });
304+
callback({ type: ExtensionActionTypes.ACTION, payload });
305+
return unwrappedAction;
306+
}
307+
308+
it('should create the action with the entered props', () => {
309+
const unwrappedAction = dispatchFromExtension(
310+
createConfig({ actionCreators: [bookRented, allBooksReturned] }),
311+
{
312+
name: '[Books] Rent(props)',
313+
selected: 0,
314+
args: ['{ id: 5, customerId: 12 }'],
315+
rest: '',
316+
}
317+
);
318+
expect(unwrappedAction).toEqual({
319+
type: '[Books] Rent',
320+
id: 5,
321+
customerId: 12,
322+
});
323+
});
324+
325+
it('should create an action without props', () => {
326+
const unwrappedAction = dispatchFromExtension(
327+
createConfig({ actionCreators: [bookRented, allBooksReturned] }),
328+
{ name: '[Books] Return All()', selected: 1, args: [], rest: '' }
329+
);
330+
expect(unwrappedAction).toEqual({ type: '[Books] Return All' });
331+
});
332+
333+
it('should create an action from a function-style creator with rest args', () => {
334+
const unwrappedAction = dispatchFromExtension(
335+
createConfig({ actionCreators: { searchBooks: booksSearched } }),
336+
{
337+
name: 'searchBooks(args)',
338+
selected: 0,
339+
args: [],
340+
rest: '["tolkien", 3]',
341+
}
342+
);
343+
expect(unwrappedAction).toEqual({
344+
type: '[Books] Search',
345+
query: 'tolkien',
346+
page: 3,
347+
});
348+
});
349+
350+
it('should pass the payload through when the selected index is unknown', () => {
351+
const payload = { name: 'unknown()', selected: 99, args: [], rest: '' };
352+
const unwrappedAction = dispatchFromExtension(
353+
createConfig({ actionCreators: [bookRented] }),
354+
payload
355+
);
356+
expect(unwrappedAction).toEqual(payload);
357+
});
358+
});
359+
248360
describe('notify', () => {
249361
it('should send notification with default options', () => {
250362
const { devtoolsExtension, reduxDevtoolsExtension } = testSetup({

modules/store-devtools/src/extension.ts

Lines changed: 109 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,90 @@ export interface ReduxDevtoolsExtensionConfig {
6060
serialize?: boolean | SerializationOptions;
6161
trace?: boolean | (() => string);
6262
traceLimit?: number;
63-
actionCreators?: ActionCreator[] | Record<string, ActionCreator>;
63+
actionCreators?: ActionCreatorDescriptor[];
6464
}
6565

66+
/**
67+
* The shape the Redux DevTools extension expects action creators to be in.
68+
* The extension serializes the entries with `JSON.stringify` (keeping `name`
69+
* and `args` to render its dispatcher) and refers back to an entry by its
70+
* index (`selected`) when an action is dispatched from the extension.
71+
*/
72+
export interface ActionCreatorDescriptor {
73+
name: string;
74+
func: ActionCreator;
75+
args: string[];
76+
}
77+
78+
/**
79+
* The payload the extension sends when an action is dispatched from its
80+
* dispatcher using one of the configured action creators. `selected` is the
81+
* index of the action creator and `args` contains the entered arguments as
82+
* strings of JavaScript.
83+
*/
84+
interface ActionCreatorPayload {
85+
name: string;
86+
selected: number;
87+
args: string[];
88+
rest: string;
89+
}
90+
91+
function isActionCreatorPayload(
92+
action: unknown
93+
): action is ActionCreatorPayload {
94+
return (
95+
typeof action === 'object' &&
96+
action !== null &&
97+
!('type' in action) &&
98+
typeof (action as ActionCreatorPayload).selected === 'number' &&
99+
Array.isArray((action as ActionCreatorPayload).args)
100+
);
101+
}
102+
103+
/**
104+
* Parse the parameter names out of an action creator so the extension can
105+
* render input fields for them in its dispatcher.
106+
*/
107+
function getActionCreatorArgs(actionCreator: ActionCreator): string[] {
108+
const source = String(actionCreator);
109+
const parenthesizedArgs = source.match(/^[^(]*\(([^)]*)\)/);
110+
if (!parenthesizedArgs) {
111+
// arrow function with a single parameter without parentheses
112+
const singleArg = source.match(/^\s*([^=\s(]+)\s*=>/);
113+
return singleArg ? [singleArg[1]] : [];
114+
}
115+
return parenthesizedArgs[1]
116+
.split(',')
117+
.map((arg) =>
118+
arg
119+
.replace(/^\s*\.{3}/, '')
120+
.split('=')[0]
121+
.trim()
122+
)
123+
.filter((arg) => arg !== '');
124+
}
125+
126+
function getActionCreatorDescriptors(
127+
actionCreators: ActionCreator[] | Record<string, ActionCreator>
128+
): ActionCreatorDescriptor[] {
129+
if (Array.isArray(actionCreators)) {
130+
return actionCreators.map((actionCreator) => ({
131+
name: actionCreator.type || actionCreator.name || 'anonymous',
132+
func: actionCreator,
133+
args: getActionCreatorArgs(actionCreator),
134+
}));
135+
}
136+
return Object.keys(actionCreators).map((name) => ({
137+
name,
138+
func: actionCreators[name],
139+
args: getActionCreatorArgs(actionCreators[name]),
140+
}));
141+
}
142+
143+
// indirect eval according to https://esbuild.github.io/content-types/#direct-eval
144+
const evalArg = (arg: string): unknown =>
145+
arg === '' ? undefined : (0, eval)(`(${arg})`);
146+
66147
export interface ReduxDevtoolsExtension {
67148
connect(
68149
options: ReduxDevtoolsExtensionConfig
@@ -74,6 +155,7 @@ export interface ReduxDevtoolsExtension {
74155
export class DevtoolsExtension {
75156
private devtoolsExtension: ReduxDevtoolsExtension;
76157
private extensionConnection!: ReduxDevtoolsExtensionConnection;
158+
private readonly actionCreatorDescriptors?: ActionCreatorDescriptor[];
77159

78160
liftedActions$!: Observable<any>;
79161
actions$!: Observable<any>;
@@ -87,6 +169,9 @@ export class DevtoolsExtension {
87169
private dispatcher: DevtoolsDispatcher
88170
) {
89171
this.devtoolsExtension = devtoolsExtension;
172+
this.actionCreatorDescriptors = config.actionCreators
173+
? getActionCreatorDescriptors(config.actionCreators)
174+
: undefined;
90175
this.createActionStreams();
91176
}
92177

@@ -248,8 +333,27 @@ export class DevtoolsExtension {
248333
}
249334

250335
private unwrapAction(action: Action) {
251-
// indirect eval according to https://esbuild.github.io/content-types/#direct-eval
252-
return typeof action === 'string' ? (0, eval)(`(${action})`) : action;
336+
if (typeof action === 'string') {
337+
// indirect eval according to https://esbuild.github.io/content-types/#direct-eval
338+
return (0, eval)(`(${action})`);
339+
}
340+
// When action creators are configured, the extension dispatches them as a
341+
// `{ selected, args }` payload that refers back to the configured action
342+
// creators instead of a ready-made action.
343+
if (this.actionCreatorDescriptors && isActionCreatorPayload(action)) {
344+
const descriptor = this.actionCreatorDescriptors[action.selected];
345+
if (descriptor) {
346+
const args = action.args.map(evalArg);
347+
if (action.rest) {
348+
const rest = evalArg(action.rest);
349+
if (Array.isArray(rest)) {
350+
args.push(...rest);
351+
}
352+
}
353+
return descriptor.func(...args);
354+
}
355+
}
356+
return action;
253357
}
254358

255359
private getExtensionConfig(config: StoreDevtoolsConfig) {
@@ -271,8 +375,8 @@ export class DevtoolsExtension {
271375
if (config.maxAge !== false /* support === 0 */) {
272376
extensionOptions.maxAge = config.maxAge;
273377
}
274-
if (config.actionCreators) {
275-
extensionOptions.actionCreators = config.actionCreators;
378+
if (this.actionCreatorDescriptors) {
379+
extensionOptions.actionCreators = this.actionCreatorDescriptors;
276380
}
277381
return extensionOptions;
278382
}

projects/www/src/app/pages/guide/store-devtools/config.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ function - called for every action before sending, takes state and action object
5454

5555
array or object of action creators to make available in the extension's dispatcher, so actions can be dispatched manually from the extension, [more information here](https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md#actioncreators).
5656

57+
When an array is given, the creators are listed in the dispatcher under their action type. When an object is given (for example an action group created with `createActionGroup`), the creators are listed under their keys.
58+
5759
```typescript
5860
const bookRented = createAction(
5961
'[Books] Rent',
@@ -65,8 +67,14 @@ const bookReturned = createAction(
6567
);
6668

6769
provideStoreDevtools({
70+
// listed as '[Books] Rent' and '[Books] Return'
6871
actionCreators: [bookRented, bookReturned],
6972
});
73+
74+
provideStoreDevtools({
75+
// listed as 'rentBook' and 'returnBook'
76+
actionCreators: { rentBook: bookRented, returnBook: bookReturned },
77+
});
7078
```
7179

7280
### `connectInZone`

0 commit comments

Comments
 (0)