Skip to content

Commit e785d99

Browse files
fix(mosaic): resume machine actors after stop instead of resetting them (#10477)
1 parent 0fabfa4 commit e785d99

9 files changed

Lines changed: 390 additions & 80 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: 234 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -659,12 +659,221 @@ describe('actor restart — StrictMode start/stop/start cycle', () => {
659659
// Stopped mid-invoke (e.g. Suspense remount) then restarted.
660660
actor.stop();
661661
actor.start();
662-
expect(actor.getSnapshot().status).toBe('active');
662+
expect(actor.getSnapshot()).toMatchObject({ value: 'deleting', status: 'active' });
663663
// The invoke must not fire a second time.
664664
expect(invokeCount).toBe(1);
665665
});
666666
});
667667

668+
describe('actor restart — stop/start resumes the current state', () => {
669+
it('stays in a state without an invoke', () => {
670+
const actor = createActor(createDeleteOrgMachine(() => Promise.resolve()));
671+
actor.start();
672+
actor.send({ type: 'OPEN' });
673+
actor.send({ type: 'TYPE', value: 'Acme' });
674+
675+
actor.stop();
676+
actor.start();
677+
678+
expect(actor.getSnapshot()).toMatchObject({ value: 'confirming', status: 'active' });
679+
expect(actor.getSnapshot().context.confirmValue).toBe('Acme');
680+
actor.send({ type: 'CANCEL' });
681+
expect(actor.getSnapshot().value).toBe('idle');
682+
});
683+
684+
it('applies an invoke that resolved while stopped once started again', async () => {
685+
const gate = deferred<string>();
686+
const actor = createActor(createLoaderMachine(() => gate.promise));
687+
actor.start();
688+
actor.send({ type: 'FETCH' });
689+
690+
actor.stop();
691+
gate.resolve('loaded');
692+
await tick();
693+
expect(actor.getSnapshot()).toMatchObject({ value: 'loading', status: 'stopped' });
694+
695+
actor.start();
696+
expect(actor.getSnapshot()).toMatchObject({ value: 'success', status: 'done' });
697+
expect(actor.getSnapshot().context.data).toBe('loaded');
698+
});
699+
700+
it('applies an invoke that rejected while stopped once started again', async () => {
701+
const gate = deferred<string>();
702+
const actor = createActor(createLoaderMachine(() => gate.promise));
703+
actor.start();
704+
actor.send({ type: 'FETCH' });
705+
706+
actor.stop();
707+
gate.reject('boom');
708+
await tick();
709+
actor.start();
710+
711+
expect(actor.getSnapshot()).toMatchObject({ value: 'failure', status: 'active' });
712+
expect(actor.getSnapshot().context.error).toBe('boom');
713+
});
714+
715+
it('handles an invoke that settles after the restart exactly once', async () => {
716+
const gate = deferred<string>();
717+
const seen: string[] = [];
718+
const actor = createActor(createLoaderMachine(() => gate.promise));
719+
actor.start();
720+
actor.send({ type: 'FETCH' });
721+
722+
actor.stop();
723+
actor.start();
724+
actor.subscribe(snapshot => seen.push(snapshot.value));
725+
gate.resolve('late');
726+
await tick();
727+
728+
expect(actor.getSnapshot().value).toBe('success');
729+
expect(actor.getSnapshot().context.data).toBe('late');
730+
expect(seen).toEqual(['success']);
731+
});
732+
733+
it('runs entry actions and the invoke once across a StrictMode start/stop/start', async () => {
734+
const entry = vi.fn();
735+
const src = vi.fn(() => Promise.resolve('ok'));
736+
const machine = createMachine<{ out: string }, { type: 'never' }>({
737+
initial: 'working',
738+
context: { out: '' },
739+
states: {
740+
working: {
741+
entry,
742+
invoke: {
743+
src,
744+
onDone: {
745+
target: 'done',
746+
actions: assign<{ out: string }, DoneInvokeEvent<string>>((_, event) => ({ out: event.output })),
747+
},
748+
},
749+
},
750+
done: { type: 'final' },
751+
},
752+
});
753+
const actor = createActor(machine);
754+
755+
actor.start();
756+
actor.stop();
757+
actor.start();
758+
await tick();
759+
760+
expect(entry).toHaveBeenCalledTimes(1);
761+
expect(src).toHaveBeenCalledTimes(1);
762+
expect(actor.getSnapshot()).toMatchObject({ value: 'done', status: 'done', context: { out: 'ok' } });
763+
});
764+
765+
it('never notifies or schedules work once stopped for good', async () => {
766+
vi.useFakeTimers();
767+
try {
768+
const gate = deferred<string>();
769+
const machine = createMachine<Record<string, never>, { type: 'never' }>({
770+
initial: 'working',
771+
states: {
772+
working: { invoke: { src: () => gate.promise, onDone: 'done' }, after: { 1000: 'done' } },
773+
done: {},
774+
},
775+
});
776+
const actor = createActor(machine);
777+
actor.start();
778+
const listener = vi.fn();
779+
actor.subscribe(listener);
780+
781+
actor.stop();
782+
listener.mockClear();
783+
expect(vi.getTimerCount()).toBe(0);
784+
785+
gate.resolve('ignored');
786+
await vi.runAllTimersAsync();
787+
788+
expect(listener).not.toHaveBeenCalled();
789+
expect(actor.getSnapshot()).toMatchObject({ value: 'working', status: 'stopped' });
790+
} finally {
791+
vi.useRealTimers();
792+
}
793+
});
794+
795+
it('queues events sent while stopped and applies them in order on start', () => {
796+
const actor = createActor(createDeleteOrgMachine(() => Promise.resolve()));
797+
actor.start();
798+
actor.stop();
799+
const listener = vi.fn();
800+
actor.subscribe(listener);
801+
802+
actor.send({ type: 'OPEN' });
803+
actor.send({ type: 'TYPE', value: 'Acme' });
804+
expect(actor.getSnapshot()).toMatchObject({ value: 'idle', status: 'stopped' });
805+
expect(listener).not.toHaveBeenCalled();
806+
807+
actor.start();
808+
809+
expect(actor.getSnapshot()).toMatchObject({ value: 'confirming', status: 'active' });
810+
expect(actor.getSnapshot().context.confirmValue).toBe('Acme');
811+
expect(listener).toHaveBeenCalledTimes(1);
812+
});
813+
814+
it('applies an invoke result and events in the order they arrived while stopped', async () => {
815+
const gate = deferred<void>();
816+
const actor = createActor(createDeleteOrgMachine(() => gate.promise));
817+
actor.start();
818+
actor.send({ type: 'OPEN' });
819+
actor.send({ type: 'TYPE', value: 'Acme Inc' });
820+
actor.send({ type: 'CONFIRM' });
821+
822+
actor.stop();
823+
gate.reject('boom');
824+
await tick();
825+
actor.send({ type: 'TYPE', value: 'retry' });
826+
actor.start();
827+
828+
expect(actor.getSnapshot()).toMatchObject({
829+
value: 'confirming',
830+
context: { error: 'boom', confirmValue: 'retry' },
831+
});
832+
});
833+
834+
it('drops an invoke result when an event queued before it leaves the invoking state', async () => {
835+
const gate = deferred<string>();
836+
const machine = createMachine<Record<string, never>, { type: 'GO' } | { type: 'CANCEL' }>({
837+
initial: 'idle',
838+
states: {
839+
idle: { on: { GO: 'working' } },
840+
working: { invoke: { src: () => gate.promise, onDone: 'done' }, on: { CANCEL: 'idle' } },
841+
done: {},
842+
},
843+
});
844+
const actor = createActor(machine);
845+
actor.start();
846+
actor.send({ type: 'GO' });
847+
848+
actor.stop();
849+
actor.send({ type: 'CANCEL' });
850+
gate.resolve('late');
851+
await tick();
852+
actor.start();
853+
854+
expect(actor.getSnapshot().value).toBe('idle');
855+
});
856+
857+
it('ignores send before the first start', () => {
858+
const actor = createActor(createDeleteOrgMachine(() => Promise.resolve()));
859+
actor.send({ type: 'OPEN' });
860+
actor.start();
861+
862+
expect(actor.getSnapshot().value).toBe('idle');
863+
});
864+
865+
it('keeps a teleported actor inert across a restart', () => {
866+
const src = vi.fn(() => Promise.resolve());
867+
const actor = mockActor(createDeleteOrgMachine(src), { value: 'deleting', context: { error: 'shown' } });
868+
869+
actor.stop();
870+
actor.start();
871+
872+
expect(actor.getSnapshot()).toMatchObject({ value: 'deleting', status: 'active', context: { error: 'shown' } });
873+
expect(src).not.toHaveBeenCalled();
874+
});
875+
});
876+
668877
describe('createActor — after (delayed transitions)', () => {
669878
beforeEach(() => {
670879
vi.useFakeTimers();
@@ -847,6 +1056,30 @@ describe('createActor — after (delayed transitions)', () => {
8471056
vi.advanceTimersByTime(60_000);
8481057
expect(actor.getSnapshot().value).toBe('expired');
8491058
});
1059+
1060+
it('does not fire while stopped and re-arms the timer of the current state on start', () => {
1061+
const machine = createMachine<Record<string, never>, { type: 'SEND' }>({
1062+
initial: 'idle',
1063+
states: {
1064+
idle: { on: { SEND: 'codeSent' } },
1065+
codeSent: { after: { 60_000: 'expired' } },
1066+
expired: {},
1067+
},
1068+
});
1069+
const actor = createActor(machine);
1070+
actor.start();
1071+
actor.send({ type: 'SEND' });
1072+
1073+
actor.stop();
1074+
vi.advanceTimersByTime(120_000);
1075+
expect(actor.getSnapshot()).toMatchObject({ value: 'codeSent', status: 'stopped' });
1076+
1077+
actor.start();
1078+
vi.advanceTimersByTime(59_999);
1079+
expect(actor.getSnapshot().value).toBe('codeSent');
1080+
vi.advanceTimersByTime(1);
1081+
expect(actor.getSnapshot().value).toBe('expired');
1082+
});
8501083
});
8511084

8521085
describe('createActor — inline transition functions (TransitionFn)', () => {

‎packages/mosaic/src/machine/__tests__/use-machine.test.tsx‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -137,15 +137,15 @@ describe('useSelector — re-renders only on the selected slice', () => {
137137
}
138138

139139
render(<ReadsA />);
140-
expect(renders).toHaveBeenCalledTimes(1);
140+
renders.mockClear();
141141

142142
// Unrelated change → selected slice (`a`) is unchanged → no re-render.
143143
act(() => actor.send({ type: 'INC_B' }));
144-
expect(renders).toHaveBeenCalledTimes(1);
144+
expect(renders).not.toHaveBeenCalled();
145145

146146
// Selected change → re-render.
147147
act(() => actor.send({ type: 'INC_A' }));
148-
expect(renders).toHaveBeenCalledTimes(2);
148+
expect(renders).toHaveBeenCalled();
149149
expect(screen.getByText('1')).toBeInTheDocument();
150150
});
151151

@@ -166,11 +166,11 @@ describe('useSelector — re-renders only on the selected slice', () => {
166166
}
167167

168168
render(<ReadsA />);
169-
expect(renders).toHaveBeenCalledTimes(1);
169+
renders.mockClear();
170170

171171
act(() => actor.send({ type: 'INC_A' }));
172172
act(() => actor.send({ type: 'INC_A' }));
173-
expect(renders).toHaveBeenCalledTimes(1);
173+
expect(renders).not.toHaveBeenCalled();
174174
// Still shows the stale (first) value because the equality fn suppressed updates.
175175
expect(screen.getByText('0')).toBeInTheDocument();
176176
});
@@ -246,6 +246,24 @@ describe('useMachine — onDone', () => {
246246
expect(onDone).toHaveBeenCalledTimes(1);
247247
});
248248

249+
it('calls onDone once when the actor restarts in a final state', () => {
250+
const onDone = vi.fn();
251+
const machine = createMachine<object, { type: 'GO' }>({
252+
initial: 'done',
253+
context: {},
254+
states: { done: { type: 'final' } },
255+
});
256+
257+
function Comp() {
258+
const [snapshot] = useMachine(machine, { onDone });
259+
return <output>{snapshot.value}</output>;
260+
}
261+
262+
render(<Comp />, { reactStrictMode: true });
263+
264+
expect(onDone).toHaveBeenCalledTimes(1);
265+
});
266+
249267
it('always invokes the latest onDone prop, not a stale closure', async () => {
250268
const gate = deferred<void>();
251269
const staleCallback = vi.fn();

‎packages/mosaic/src/machine/__tests__/wizard-migration.test.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -497,16 +497,16 @@ describe('Wizard AFTER — useSelector scopes re-renders to the step slice', ()
497497
}
498498

499499
render(<StepIndicator />);
500-
expect(renders).toHaveBeenCalledTimes(1);
501500
expect(screen.getByTestId('current')).toHaveTextContent('intro');
501+
renders.mockClear();
502502

503503
// A GOTO to the current step is a no-op → no notify → no re-render.
504504
act(() => actor.send({ type: 'GOTO', step: 'intro' }));
505-
expect(renders).toHaveBeenCalledTimes(1);
505+
expect(renders).not.toHaveBeenCalled();
506506

507-
// A real navigation changes the selected slice → exactly one re-render.
507+
// A real navigation changes the selected slice → re-render.
508508
act(() => actor.send({ type: 'NEXT' }));
509-
expect(renders).toHaveBeenCalledTimes(2);
509+
expect(renders).toHaveBeenCalled();
510510
expect(screen.getByTestId('current')).toHaveTextContent('details');
511511
});
512512
});

0 commit comments

Comments
 (0)