fix: avoid spurious imperative-action warning in enqueueActions#5528
fix: avoid spurious imperative-action warning in enqueueActions#5528ATOM00blue wants to merge 1 commit into
Conversation
…nchronous cross-actor sends When a custom action synchronously sends an event to another actor whose transition resolves an `enqueueActions` block, the builtin action creators invoked by `enqueue.assign(…)`/`enqueue.sendTo(…)` etc. were seeing the global `executingCustomAction` flag still set from the outer custom action and emitting a misleading "Custom actions should not call `…` directly" warning. Clear the flag while collecting enqueued actions, since collecting is part of resolving `enqueueActions` rather than executing a custom action.
🦋 Changeset detectedLatest commit: 0ab675d The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
Pull request overview
This PR fixes a dev-only warning being incorrectly emitted when a custom action synchronously sends an event to another actor whose transition resolves an enqueueActions block using built-in enqueuers like enqueue.assign(...) and enqueue.sendTo(...). The fix works by temporarily clearing (and restoring) the module-global executingCustomAction flag while enqueueActions is being collected/resolved, preventing built-in action creators from mistaking that resolution phase as “custom action execution”.
Changes:
- Temporarily clears/restores
executingCustomActionaroundenqueueActions.collect()to avoid spurious “Custom actions should not call … directly” warnings during synchronous cross-actor sends. - Adds two regression tests covering the reported scenario and
enqueue.sendTo(self, ...)specifically. - Adds a patch changeset documenting the fix.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| packages/core/src/actions/enqueueActions.ts | Clears/restores executingCustomAction during enqueueActions collection to prevent false warnings. |
| packages/core/src/createActor.ts | Exposes a setter to allow other modules to safely update the module-scoped executingCustomAction flag. |
| packages/core/test/actions.test.ts | Adds regression tests asserting no warning is emitted in the reported synchronous send + enqueueActions scenarios. |
| .changeset/enqueue-actions-send-warning.md | Patch notes entry for the warning fix. |
Comments suppressed due to low confidence (1)
packages/core/test/actions.test.ts:4450
- This regression test only checks that no warning was logged, but it doesn’t assert that the child actor actually handled
SET_VALUEand executed theenqueue.sendTo(self, { type: 'PING' })path. Add an assertion thatPINGwas processed (e.g. via an action spy onPINGor by inspecting the child snapshot) so the test can’t pass if the synchronous send/enqueueActionsresolution stops happening.
const childMachine = createMachine({
types: {} as { events: { type: 'SET_VALUE' } | { type: 'PING' } },
initial: 'active',
states: {
active: {
on: {
SET_VALUE: {
actions: enqueueActions(({ enqueue, self }) => {
enqueue.sendTo(self, { type: 'PING' });
})
},
PING: {}
}
}
}
});
const parentMachine = setup({
types: {} as {
events: { type: 'CALL_CHILD' };
},
actions: {
callChild: (_, params: { child: AnyActorRef }) => {
params.child.send({ type: 'SET_VALUE' });
}
}
}).createMachine({
context: ({ spawn }) => ({ child: spawn(childMachine) }),
initial: 'active',
states: {
active: {
on: {
CALL_CHILD: {
actions: enqueueActions(({ enqueue, context }) => {
enqueue({
type: 'callChild',
params: { child: context.child }
});
})
}
}
}
}
});
const actor = createActor(parentMachine).start();
actor.send({ type: 'CALL_CHILD' });
expect(warnSpy).not.toHaveBeenCalled();
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // `enqueue.assign(…)`, `enqueue.sendTo(…)` and friends call the action | ||
| // creators (`assign()`, `sendTo()`, …) under the hood. Those creators warn | ||
| // when they are invoked while a custom action is executing. Collecting | ||
| // actions here is a part of resolving the `enqueueActions` action and not a | ||
| // part of executing a custom action, so we have to clear the flag to avoid | ||
| // spurious warnings - this can be observed when an executing custom action | ||
| // synchronously sends an event to another actor that resolves | ||
| // `enqueueActions` while processing that event. | ||
| const wasExecutingCustomAction = executingCustomAction; | ||
| setExecutingCustomAction(false); | ||
| try { | ||
| collect( | ||
| { | ||
| context: args.context, | ||
| event: args.event, | ||
| enqueue, | ||
| check: (guard) => | ||
| evaluateGuard(guard, snapshot.context, args.event, snapshot), | ||
| self: actorScope.self, | ||
| system: actorScope.system | ||
| }, | ||
| actionParams | ||
| ); | ||
| } finally { | ||
| setExecutingCustomAction(wasExecutingCustomAction); | ||
| } |
| const childMachine = createMachine({ | ||
| types: {} as { events: { type: 'SET_VALUE' } }, | ||
| context: { someValue: 0 }, | ||
| initial: 'active', | ||
| states: { | ||
| active: { | ||
| on: { | ||
| SET_VALUE: { | ||
| actions: enqueueActions(({ enqueue }) => { | ||
| enqueue.assign({ someValue: 42 }); | ||
| }) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| const parentMachine = setup({ | ||
| types: {} as { | ||
| events: { type: 'CALL_CHILD' }; | ||
| }, | ||
| actions: { | ||
| callChild: (_, params: { child: AnyActorRef }) => { | ||
| params.child.send({ type: 'SET_VALUE' }); | ||
| } | ||
| } | ||
| }).createMachine({ | ||
| context: ({ spawn }) => ({ child: spawn(childMachine) }), | ||
| initial: 'active', | ||
| states: { | ||
| active: { | ||
| on: { | ||
| CALL_CHILD: { | ||
| actions: enqueueActions(({ enqueue, context }) => { | ||
| enqueue({ | ||
| type: 'callChild', | ||
| params: { child: context.child } | ||
| }); | ||
| }) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| }); | ||
|
|
||
| const actor = createActor(parentMachine).start(); | ||
| actor.send({ type: 'CALL_CHILD' }); | ||
|
|
||
| expect(warnSpy).not.toHaveBeenCalled(); |
| const actor = createActor(parentMachine).start(); | ||
| actor.send({ type: 'CALL_CHILD' }); | ||
|
|
||
| expect(warnSpy).not.toHaveBeenCalled(); |
There was a problem hiding this comment.
This one looks like an invalid assertion to me - this test should warn unless my reasoning is off. This would indicate that the fix is misapplied.
| const wasExecutingCustomAction = executingCustomAction; | ||
| setExecutingCustomAction(false); |
There was a problem hiding this comment.
as per: https://github.com/statelyai/xstate/pull/5528/changes#r3280659742
this fix shouldn't happen here - but rather all of those actions.push calls in enqueue.X = should be wrapped instead
Summary
Fixes a spurious "Custom actions should not call
…directly, as it is not imperative" warning logged when a custom action synchronously sends an event to another actor whose transition resolves anenqueueActionsblock using builtin creators (enqueue.assign(…),enqueue.sendTo(self, …), etc.).Root cause: the module-global
executingCustomActionflag, set during a custom action'sexec(), was stilltruewhile the other actor'senqueueActionswas being resolved, so its builtin action creators wrongly tripped the dev warning. Collecting enqueued actions is part of resolvingenqueueActions, not executing a custom action, so the flag is now cleared (and restored) aroundcollect().Added two regression tests and a changeset.
Test plan
pnpm test:core(1739 passing)Closes #5343