diff --git a/.changeset/mosaic-machine-pre-start-events.md b/.changeset/mosaic-machine-pre-start-events.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-machine-pre-start-events.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/machine/__tests__/machine.test.ts b/packages/mosaic/src/machine/__tests__/machine.test.ts index 216780b340c..8f5243582a0 100644 --- a/packages/mosaic/src/machine/__tests__/machine.test.ts +++ b/packages/mosaic/src/machine/__tests__/machine.test.ts @@ -30,19 +30,35 @@ describe('createMachine — introspection (the swingset seam)', () => { }); describe('createActor — pre-start safety', () => { - it('send() is a no-op before start() — does not run transitions without entry actions', () => { + it('send() before start() runs no transition until start()', () => { const actor = createActor(createDeleteOrgMachine(() => Promise.resolve())); const before = actor.getSnapshot(); actor.send({ type: 'OPEN' }); expect(actor.getSnapshot()).toBe(before); - expect(actor.getSnapshot().value).toBe('idle'); + actor.start(); + expect(actor.getSnapshot().value).toBe('confirming'); }); - it('can() returns false before start()', () => { + it('can() before start() answers for the initial state', () => { const actor = createActor(createDeleteOrgMachine(() => Promise.resolve())); - expect(actor.can({ type: 'OPEN' })).toBe(false); + expect(actor.can({ type: 'OPEN' })).toBe(true); + expect(actor.can({ type: 'CANCEL' })).toBe(false); + }); + + it('can() before start() answers for the initial state resolved from setContext()', () => { + const actor = createActor( + createMachine<{ done: boolean }, { type: 'GO' }>({ + initial: context => (context.done ? 'end' : 'start'), + context: { done: false }, + states: { start: { on: { GO: 'end' } }, end: {} }, + }), + ); + + actor.setContext({ done: true }); + + expect(actor.can({ type: 'GO' })).toBe(false); }); }); @@ -854,12 +870,47 @@ describe('actor restart — stop/start resumes the current state', () => { expect(actor.getSnapshot().value).toBe('idle'); }); - it('ignores send before the first start', () => { - const actor = createActor(createDeleteOrgMachine(() => Promise.resolve())); - actor.send({ type: 'OPEN' }); + it('applies events sent before the first start in order, after entering the initial state', () => { + const entered: string[] = []; + const actor = createActor( + createMachine({ + initial: 'a', + states: { + a: { entry: () => entered.push('a'), on: { NEXT: 'b' } }, + b: { entry: () => entered.push('b'), on: { NEXT: 'c' } }, + c: { entry: () => entered.push('c') }, + }, + }), + ); + actor.send({ type: 'NEXT' }); + actor.send({ type: 'NEXT' }); + + expect(entered).toEqual([]); actor.start(); - expect(actor.getSnapshot().value).toBe('idle'); + expect(entered).toEqual(['a', 'b', 'c']); + expect(actor.getSnapshot().value).toBe('c'); + }); + + it('keeps events sent before the first start when the initial entry stops the actor', () => { + let stopOnEntry = () => {}; + const actor = createActor( + createMachine({ + initial: 'a', + states: { a: { entry: () => stopOnEntry(), on: { NEXT: 'b' } }, b: {} }, + }), + ); + stopOnEntry = () => { + stopOnEntry = () => {}; + actor.stop(); + }; + actor.send({ type: 'NEXT' }); + + actor.start(); + expect(actor.getSnapshot().value).toBe('a'); + actor.start(); + + expect(actor.getSnapshot().value).toBe('b'); }); it('keeps a teleported actor inert across a restart', () => { diff --git a/packages/mosaic/src/machine/create-actor.ts b/packages/mosaic/src/machine/create-actor.ts index c10655d07db..f3863b56e45 100644 --- a/packages/mosaic/src/machine/create-actor.ts +++ b/packages/mosaic/src/machine/create-actor.ts @@ -82,7 +82,7 @@ export function createActor // Bumped whenever we leave an invoking state, so a stale promise resolving // after the fact is ignored — no transition. let invocationToken = 0; - let receivedWhileStopped: (() => boolean)[] = []; + const deferred: (() => boolean)[] = []; // Pending `after` timers — cleared when the state is exited, paused while the actor is stopped. let afterTimers: AfterTimer[] = []; @@ -182,9 +182,7 @@ export function createActor return; } if (status === 'stopped') { - receivedWhileStopped.push( - () => token === invocationToken && takeInvokeTransition(transitionConfig, settledEvent), - ); + deferred.push(() => token === invocationToken && takeInvokeTransition(transitionConfig, settledEvent)); return; } if (status === 'active' && takeInvokeTransition(transitionConfig, settledEvent)) { @@ -252,9 +250,14 @@ export function createActor scheduleAfterTimer(timer.delay, timer.transitionConfig); } pausedAfterTimers = []; - const received = receivedWhileStopped; - receivedWhileStopped = []; - for (const apply of received) { + } + + function applyDeferred(): void { + while (status === 'active') { + const apply = deferred.shift(); + if (!apply) { + return; + } apply(); } } @@ -315,6 +318,7 @@ export function createActor value = resolveInitial(); enterState(INIT_EVENT); } + applyDeferred(); commit(); return actor; }, @@ -333,11 +337,11 @@ export function createActor }, send(event) { - if (initialized && status === 'stopped') { - receivedWhileStopped.push(() => takeEventTransition(event)); + if (!started) { + deferred.push(() => takeEventTransition(event)); return; } - if (started && takeEventTransition(event)) { + if (takeEventTransition(event)) { commit(); } }, @@ -362,7 +366,7 @@ export function createActor }, can(event) { - if (!started || status !== 'active') { + if (status !== 'active') { return false; } const transition = pickTransition(normalizeTransition(stateOf(value).on?.[event.type], event), event); @@ -375,6 +379,9 @@ export function createActor setContext(patch: Partial) { liveContextPatch = { ...liveContextPatch, ...patch }; context = { ...context, ...patch }; + if (!initialized) { + value = resolveInitial(); + } }, recheck() { diff --git a/packages/mosaic/src/machine/types.ts b/packages/mosaic/src/machine/types.ts index c4bb4384124..0e68c1cb216 100644 --- a/packages/mosaic/src/machine/types.ts +++ b/packages/mosaic/src/machine/types.ts @@ -212,15 +212,15 @@ export type Unsubscribe = () => void; export interface Actor { /** - * Run entry actions / immediate transitions / invokes of the initial state. - * After a `stop()`, resume in the current state instead: re-arm its `after` + * Run entry actions / immediate transitions / invokes of the initial state, + * then apply the events sent before this first start, in order. After a `stop()`, resume in the current state instead: re-arm its `after` * timers, then apply the invoke results and events that arrived while stopped, * in arrival order. */ start: () => Actor; /** Stop the actor, pausing its `after` timers and holding in-flight invoke results and sent events for a later `start()`. */ stop: () => void; - /** Send an event. Ignored before the first `start()` and once the actor is `done`; queued while `stopped`. */ + /** Send an event. Ignored once the actor is `done`; queued until `start()` before the first start and while `stopped`. */ send: (event: TEvent) => void; getSnapshot: () => Snapshot; /** Subscribe to snapshots; returns an unsubscribe fn (usable with `useSyncExternalStore`). */