Skip to content

fix: avoid spurious imperative-action warning in enqueueActions#5528

Open
ATOM00blue wants to merge 1 commit into
statelyai:mainfrom
ATOM00blue:fix/enqueueactions-send-warning
Open

fix: avoid spurious imperative-action warning in enqueueActions#5528
ATOM00blue wants to merge 1 commit into
statelyai:mainfrom
ATOM00blue:fix/enqueueactions-send-warning

Conversation

@ATOM00blue
Copy link
Copy Markdown

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 an enqueueActions block using builtin creators (enqueue.assign(…), enqueue.sendTo(self, …), etc.).

Root cause: the module-global executingCustomAction flag, set during a custom action's exec(), was still true while the other actor's enqueueActions was being resolved, so its builtin action creators wrongly tripped the dev warning. Collecting enqueued actions is part of resolving enqueueActions, not executing a custom action, so the flag is now cleared (and restored) around collect().

Added two regression tests and a changeset.

Test plan

  • pnpm test:core (1739 passing)
  • New tests fail without the fix, pass with it

Closes #5343

…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.
Copilot AI review requested due to automatic review settings May 21, 2026 02:33
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 21, 2026

🦋 Changeset detected

Latest commit: 0ab675d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
xstate Patch

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

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 executingCustomAction around enqueueActions.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_VALUE and executed the enqueue.sendTo(self, { type: 'PING' }) path. Add an assertion that PING was processed (e.g. via an action spy on PING or by inspecting the child snapshot) so the test can’t pass if the synchronous send/enqueueActions resolution 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.

Comment on lines +174 to +199
// `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);
}
Comment on lines +4348 to +4396
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();
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.

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.

Comment on lines +182 to +183
const wasExecutingCustomAction = executingCustomAction;
setExecutingCustomAction(false);
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.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Using actor.send outputs a warning that assign is not imperative when sent to an actor using enqueueActions

3 participants