From 0ab675dc1abff2c7772cb806b2a55af508a153a6 Mon Sep 17 00:00:00 2001 From: ATOM00blue <219721791+ATOM00blue@users.noreply.github.com> Date: Thu, 21 May 2026 07:55:13 +0530 Subject: [PATCH] fix: avoid spurious imperative-action warning in enqueueActions on synchronous cross-actor sends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/enqueue-actions-send-warning.md | 5 + packages/core/src/actions/enqueueActions.ts | 42 +++++--- packages/core/src/createActor.ts | 4 + packages/core/test/actions.test.ts | 108 ++++++++++++++++++++ 4 files changed, 147 insertions(+), 12 deletions(-) create mode 100644 .changeset/enqueue-actions-send-warning.md diff --git a/.changeset/enqueue-actions-send-warning.md b/.changeset/enqueue-actions-send-warning.md new file mode 100644 index 0000000000..74bf63078e --- /dev/null +++ b/.changeset/enqueue-actions-send-warning.md @@ -0,0 +1,5 @@ +--- +'xstate': patch +--- + +Fixed a spurious "Custom actions should not call \`…\` directly, as it is not imperative" warning that was logged when a custom action synchronously sent an event to another actor that resolved an `enqueueActions` block using builtin action creators (e.g. `enqueue.assign(…)` or `enqueue.sendTo(self, …)`). diff --git a/packages/core/src/actions/enqueueActions.ts b/packages/core/src/actions/enqueueActions.ts index 4757f59627..832df37d2c 100644 --- a/packages/core/src/actions/enqueueActions.ts +++ b/packages/core/src/actions/enqueueActions.ts @@ -1,4 +1,8 @@ import isDevelopment from '#is-development'; +import { + executingCustomAction, + setExecutingCustomAction +} from '../createActor.ts'; import { Guard, evaluateGuard } from '../guards.ts'; import { Action, @@ -167,18 +171,32 @@ function resolveEnqueueActions( actions.push(emit(...args)); }; - collect( - { - context: args.context, - event: args.event, - enqueue, - check: (guard) => - evaluateGuard(guard, snapshot.context, args.event, snapshot), - self: actorScope.self, - system: actorScope.system - }, - actionParams - ); + // `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); + } return [snapshot, undefined, actions]; } diff --git a/packages/core/src/createActor.ts b/packages/core/src/createActor.ts index 2db20c4f7c..0bdebc300c 100644 --- a/packages/core/src/createActor.ts +++ b/packages/core/src/createActor.ts @@ -23,6 +23,10 @@ import type { createMachine } from './createMachine.ts'; export let executingCustomAction: boolean = false; +export const setExecutingCustomAction = (value: boolean) => { + executingCustomAction = value; +}; + import type { ActorScope, AnyActorLogic, diff --git a/packages/core/test/actions.test.ts b/packages/core/test/actions.test.ts index edc268742d..698d0d8dbf 100644 --- a/packages/core/test/actions.test.ts +++ b/packages/core/test/actions.test.ts @@ -4342,6 +4342,114 @@ describe('actions', () => { `); }); + it('should not warn when a custom action synchronously sends an event that resolves `enqueueActions` calling builtin action creators (#5343)', () => { + const warnSpy = vi.spyOn(console, 'warn'); + + 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(); + }); + + it('should not warn when `enqueue.sendTo(self, …)` is used inside `enqueueActions` of a synchronously triggered actor (#5343)', () => { + const warnSpy = vi.spyOn(console, 'warn'); + + 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(); + }); + it('inline actions should not leak into provided actions object', async () => { const actions = {};