Skip to content

Commit 7109d2f

Browse files
fix(mosaic): queue machine events sent before the first start (#10513)
1 parent 11907aa commit 7109d2f

4 files changed

Lines changed: 82 additions & 22 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

‎packages/mosaic/src/machine/__tests__/machine.test.ts‎

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,19 +30,35 @@ describe('createMachine — introspection (the swingset seam)', () => {
3030
});
3131

3232
describe('createActor — pre-start safety', () => {
33-
it('send() is a no-op before start() — does not run transitions without entry actions', () => {
33+
it('send() before start() runs no transition until start()', () => {
3434
const actor = createActor(createDeleteOrgMachine(() => Promise.resolve()));
3535
const before = actor.getSnapshot();
3636

3737
actor.send({ type: 'OPEN' });
3838

3939
expect(actor.getSnapshot()).toBe(before);
40-
expect(actor.getSnapshot().value).toBe('idle');
40+
actor.start();
41+
expect(actor.getSnapshot().value).toBe('confirming');
4142
});
4243

43-
it('can() returns false before start()', () => {
44+
it('can() before start() answers for the initial state', () => {
4445
const actor = createActor(createDeleteOrgMachine(() => Promise.resolve()));
45-
expect(actor.can({ type: 'OPEN' })).toBe(false);
46+
expect(actor.can({ type: 'OPEN' })).toBe(true);
47+
expect(actor.can({ type: 'CANCEL' })).toBe(false);
48+
});
49+
50+
it('can() before start() answers for the initial state resolved from setContext()', () => {
51+
const actor = createActor(
52+
createMachine<{ done: boolean }, { type: 'GO' }>({
53+
initial: context => (context.done ? 'end' : 'start'),
54+
context: { done: false },
55+
states: { start: { on: { GO: 'end' } }, end: {} },
56+
}),
57+
);
58+
59+
actor.setContext({ done: true });
60+
61+
expect(actor.can({ type: 'GO' })).toBe(false);
4662
});
4763
});
4864

@@ -854,12 +870,47 @@ describe('actor restart — stop/start resumes the current state', () => {
854870
expect(actor.getSnapshot().value).toBe('idle');
855871
});
856872

857-
it('ignores send before the first start', () => {
858-
const actor = createActor(createDeleteOrgMachine(() => Promise.resolve()));
859-
actor.send({ type: 'OPEN' });
873+
it('applies events sent before the first start in order, after entering the initial state', () => {
874+
const entered: string[] = [];
875+
const actor = createActor(
876+
createMachine<object, { type: 'NEXT' }>({
877+
initial: 'a',
878+
states: {
879+
a: { entry: () => entered.push('a'), on: { NEXT: 'b' } },
880+
b: { entry: () => entered.push('b'), on: { NEXT: 'c' } },
881+
c: { entry: () => entered.push('c') },
882+
},
883+
}),
884+
);
885+
actor.send({ type: 'NEXT' });
886+
actor.send({ type: 'NEXT' });
887+
888+
expect(entered).toEqual([]);
860889
actor.start();
861890

862-
expect(actor.getSnapshot().value).toBe('idle');
891+
expect(entered).toEqual(['a', 'b', 'c']);
892+
expect(actor.getSnapshot().value).toBe('c');
893+
});
894+
895+
it('keeps events sent before the first start when the initial entry stops the actor', () => {
896+
let stopOnEntry = () => {};
897+
const actor = createActor(
898+
createMachine<object, { type: 'NEXT' }>({
899+
initial: 'a',
900+
states: { a: { entry: () => stopOnEntry(), on: { NEXT: 'b' } }, b: {} },
901+
}),
902+
);
903+
stopOnEntry = () => {
904+
stopOnEntry = () => {};
905+
actor.stop();
906+
};
907+
actor.send({ type: 'NEXT' });
908+
909+
actor.start();
910+
expect(actor.getSnapshot().value).toBe('a');
911+
actor.start();
912+
913+
expect(actor.getSnapshot().value).toBe('b');
863914
});
864915

865916
it('keeps a teleported actor inert across a restart', () => {

‎packages/mosaic/src/machine/create-actor.ts‎

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export function createActor<TContext extends object, TEvent extends EventObject,
8282
// Bumped whenever we leave an invoking state, so a stale promise resolving
8383
// after the fact is ignored — no transition.
8484
let invocationToken = 0;
85-
let receivedWhileStopped: (() => boolean)[] = [];
85+
const deferred: (() => boolean)[] = [];
8686

8787
// Pending `after` timers — cleared when the state is exited, paused while the actor is stopped.
8888
let afterTimers: AfterTimer[] = [];
@@ -182,9 +182,7 @@ export function createActor<TContext extends object, TEvent extends EventObject,
182182
return;
183183
}
184184
if (status === 'stopped') {
185-
receivedWhileStopped.push(
186-
() => token === invocationToken && takeInvokeTransition(transitionConfig, settledEvent),
187-
);
185+
deferred.push(() => token === invocationToken && takeInvokeTransition(transitionConfig, settledEvent));
188186
return;
189187
}
190188
if (status === 'active' && takeInvokeTransition(transitionConfig, settledEvent)) {
@@ -252,9 +250,14 @@ export function createActor<TContext extends object, TEvent extends EventObject,
252250
scheduleAfterTimer(timer.delay, timer.transitionConfig);
253251
}
254252
pausedAfterTimers = [];
255-
const received = receivedWhileStopped;
256-
receivedWhileStopped = [];
257-
for (const apply of received) {
253+
}
254+
255+
function applyDeferred(): void {
256+
while (status === 'active') {
257+
const apply = deferred.shift();
258+
if (!apply) {
259+
return;
260+
}
258261
apply();
259262
}
260263
}
@@ -315,6 +318,7 @@ export function createActor<TContext extends object, TEvent extends EventObject,
315318
value = resolveInitial();
316319
enterState(INIT_EVENT);
317320
}
321+
applyDeferred();
318322
commit();
319323
return actor;
320324
},
@@ -333,11 +337,11 @@ export function createActor<TContext extends object, TEvent extends EventObject,
333337
},
334338

335339
send(event) {
336-
if (initialized && status === 'stopped') {
337-
receivedWhileStopped.push(() => takeEventTransition(event));
340+
if (!started) {
341+
deferred.push(() => takeEventTransition(event));
338342
return;
339343
}
340-
if (started && takeEventTransition(event)) {
344+
if (takeEventTransition(event)) {
341345
commit();
342346
}
343347
},
@@ -362,7 +366,7 @@ export function createActor<TContext extends object, TEvent extends EventObject,
362366
},
363367

364368
can(event) {
365-
if (!started || status !== 'active') {
369+
if (status !== 'active') {
366370
return false;
367371
}
368372
const transition = pickTransition(normalizeTransition(stateOf(value).on?.[event.type], event), event);
@@ -375,6 +379,9 @@ export function createActor<TContext extends object, TEvent extends EventObject,
375379
setContext(patch: Partial<TContext>) {
376380
liveContextPatch = { ...liveContextPatch, ...patch };
377381
context = { ...context, ...patch };
382+
if (!initialized) {
383+
value = resolveInitial();
384+
}
378385
},
379386

380387
recheck() {

‎packages/mosaic/src/machine/types.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,15 +214,15 @@ export type Unsubscribe = () => void;
214214

215215
export interface Actor<TContext, TEvent extends EventObject, TStates extends string = string> {
216216
/**
217-
* Run entry actions / immediate transitions / invokes of the initial state.
218-
* After a `stop()`, resume in the current state instead: re-arm its `after`
217+
* Run entry actions / immediate transitions / invokes of the initial state,
218+
* then apply the events sent before this first start, in order. After a `stop()`, resume in the current state instead: re-arm its `after`
219219
* timers, then apply the invoke results and events that arrived while stopped,
220220
* in arrival order.
221221
*/
222222
start: () => Actor<TContext, TEvent, TStates>;
223223
/** Stop the actor, pausing its `after` timers and holding in-flight invoke results and sent events for a later `start()`. */
224224
stop: () => void;
225-
/** Send an event. Ignored before the first `start()` and once the actor is `done`; queued while `stopped`. */
225+
/** Send an event. Ignored once the actor is `done`; queued until `start()` before the first start and while `stopped`. */
226226
send: (event: TEvent) => void;
227227
getSnapshot: () => Snapshot<TContext, TStates>;
228228
/** Subscribe to snapshots; returns an unsubscribe fn (usable with `useSyncExternalStore`). */

0 commit comments

Comments
 (0)