diff --git a/.changeset/mosaic-machine-activity-resume.md b/.changeset/mosaic-machine-activity-resume.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-machine-activity-resume.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 72ba218449c..a07511e7b6a 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -87,7 +87,9 @@ "playwright": "1.62.1", "qrcode.react": "4.2.0", "react": "catalog:react", + "react-19": "npm:react@19.2.8", "react-dom": "catalog:react", + "react-dom-19": "npm:react-dom@19.2.8", "tsdown": "catalog:repo", "vitest-axe": "^0.1.0" }, diff --git a/packages/mosaic/src/components/form/__tests__/use-form.concurrency.feature.test.tsx b/packages/mosaic/src/components/form/__tests__/use-form.concurrency.feature.test.tsx new file mode 100644 index 00000000000..a8f460f04cc --- /dev/null +++ b/packages/mosaic/src/components/form/__tests__/use-form.concurrency.feature.test.tsx @@ -0,0 +1,86 @@ +import { act, renderHook } from '@testing-library/react'; +import { Activity, type ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { deferred, tick } from '../../../__tests__/async'; +import type { FieldFeedback } from '../form-submit-error'; +import { useForm, type UseFormOptions } from '../use-form'; + +function renderFormInActivity(options: UseFormOptions) { + let mode: 'visible' | 'hidden' = 'visible'; + const wrapper = ({ children }: { children: ReactNode }) => {children}; + const view = renderHook(() => useForm(options), { wrapper }); + const setMode = (next: typeof mode) => { + mode = next; + act(() => view.rerender()); + }; + return { result: view.result, hide: () => setMode('hidden'), show: () => setMode('visible') }; +} + +describe.runIf(Activity)('useForm inside Activity', () => { + it('runs a submit queued behind an async check that settled while hidden', async () => { + const check = deferred(); + const onSubmit = vi.fn(() => Promise.resolve()); + const form = renderFormInActivity({ + initialValues: { password: '' }, + fields: { password: { validateAsync: () => check.promise } }, + onSubmit, + }); + act(() => form.result.current.setValue('password', 'ab')); + act(() => form.result.current.submit()); + + form.hide(); + await act(async () => { + check.resolve(undefined); + await tick(); + }); + form.show(); + await act(tick); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith({ password: 'ab' }); + expect(form.result.current.isSubmitting).toBe(false); + }); + + it('finishes a submit that settled while hidden and keeps the submitted values', async () => { + const save = deferred(); + const onSubmit = vi.fn(() => save.promise); + const form = renderFormInActivity({ initialValues: { name: 'Acme' }, onSubmit }); + act(() => form.result.current.setValue('name', 'Globex')); + act(() => form.result.current.submit()); + expect(form.result.current.isSubmitting).toBe(true); + + form.hide(); + await act(async () => { + save.resolve(); + await tick(); + }); + form.show(); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(form.result.current.isSubmitting).toBe(false); + act(() => form.result.current.submit()); + expect(onSubmit).toHaveBeenCalledTimes(2); + expect(onSubmit).toHaveBeenLastCalledWith({ name: 'Globex' }); + }); + + it('shows a submit error that arrived while hidden', async () => { + const save = deferred(); + const form = renderFormInActivity({ + initialValues: { name: 'Acme' }, + onSubmit: () => save.promise, + errorFallback: 'Could not save', + }); + act(() => form.result.current.submit()); + + form.hide(); + await act(async () => { + save.reject(new Error('boom')); + await tick(); + }); + form.show(); + + expect(form.result.current.isSubmitting).toBe(false); + expect(form.result.current.error).toBe('Could not save'); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.concurrency.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.concurrency.feature.test.tsx new file mode 100644 index 00000000000..cb4fb004d8e --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-email-section/__tests__/user-profile-email-section.concurrency.feature.test.tsx @@ -0,0 +1,68 @@ +import { act, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Activity } from 'react'; +import { describe, expect, it } from 'vitest'; + +import { holdRequests, serveFapi, VERIFICATION_CODE } from '../../../../__tests__/feature/fake-fapi'; +import { fapiClient, fapiSession, fapiUser } from '../../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../../__tests__/feature/render'; +import { UserProfileEmailSection } from '../user-profile-email-section'; + +function signedIn() { + const user = fapiUser({ id: 'user_1', first_name: 'Alice', username: 'alicesmith', email_addresses: [] }); + return { client: fapiClient([fapiSession({ id: 'sess_1', user })]) }; +} + +function inActivity(mode: 'visible' | 'hidden') { + return ( + + + + ); +} + +describe.runIf(Activity)('the user profile email section inside Activity', () => { + it('keeps the code step after being hidden and shown again', async () => { + serveFapi(signedIn()); + const view = await renderWithClerk(inActivity('visible')); + const actor = userEvent.setup(); + + await actor.click(screen.getByRole('button', { name: 'Add email' })); + await actor.type(screen.getByRole('textbox', { name: 'Email' }), 'new@example.com'); + await actor.click(screen.getByRole('button', { name: 'Continue' })); + const code = await screen.findByRole('textbox', { name: 'Verification code' }); + await waitFor(() => expect(code).not.toHaveAttribute('aria-disabled')); + + act(() => view.rerender(inActivity('hidden'))); + act(() => view.rerender(inActivity('visible'))); + + expect(screen.getByRole('dialog')).toBeVisible(); + await actor.click(screen.getByRole('textbox', { name: 'Verification code' })); + await actor.keyboard(VERIFICATION_CODE); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(screen.getByRole('group', { name: 'Email' })).toHaveTextContent('new@example.com'); + expect(screen.getByRole('group', { name: 'Email' })).not.toHaveTextContent('Unverified'); + }); + + it('keeps waiting for a code that was in flight when hidden', async () => { + serveFapi(signedIn()); + const view = await renderWithClerk(inActivity('visible')); + const actor = userEvent.setup(); + const prepare = holdRequests('post', '/v1/me/email_addresses/:id/prepare_verification'); + + await actor.click(screen.getByRole('button', { name: 'Add email' })); + await actor.type(screen.getByRole('textbox', { name: 'Email' }), 'new@example.com'); + await actor.click(screen.getByRole('button', { name: 'Continue' })); + await waitFor(() => expect(prepare.requests).toHaveLength(1)); + + act(() => view.rerender(inActivity('hidden'))); + prepare.release(); + act(() => view.rerender(inActivity('visible'))); + + const code = await screen.findByRole('textbox', { name: 'Verification code' }); + await waitFor(() => expect(code).not.toHaveAttribute('aria-disabled')); + await actor.click(code); + await actor.keyboard(VERIFICATION_CODE); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + }); +}); diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-section.concurrency.feature.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-section.concurrency.feature.test.tsx new file mode 100644 index 00000000000..62ab185f2f0 --- /dev/null +++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-section.concurrency.feature.test.tsx @@ -0,0 +1,74 @@ +import { act, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Activity } from 'react'; +import { describe, expect, it } from 'vitest'; + +import { holdRequests, serveFapi, VERIFICATION_CODE } from '../../../../__tests__/feature/fake-fapi'; +import { fapiClient, fapiEnvironment, fapiSession, fapiUser } from '../../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../../__tests__/feature/render'; +import { UserProfilePhoneSection } from '../user-profile-phone-section'; + +function signedIn() { + const user = fapiUser({ id: 'user_1', first_name: 'Alice', username: 'alicesmith', phone_numbers: [] }); + return { + environment: fapiEnvironment({ attributes: { phone_number: { enabled: true } } }), + client: fapiClient([fapiSession({ id: 'sess_1', user })]), + }; +} + +function inActivity(mode: 'visible' | 'hidden') { + return ( + + + + ); +} + +async function verify(actor: ReturnType) { + const code = await screen.findByRole('textbox', { name: 'Verification code' }); + await waitFor(() => expect(code).not.toHaveAttribute('aria-disabled')); + await actor.click(code); + await actor.keyboard(VERIFICATION_CODE); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(screen.getByRole('group', { name: 'Phone' })).toHaveTextContent('+1 (801) 555-0100'); + expect(screen.getByRole('group', { name: 'Phone' })).not.toHaveTextContent('Unverified'); +} + +describe.runIf(Activity)('the user profile phone section inside Activity', () => { + it('keeps the code step after being hidden and shown again', async () => { + serveFapi(signedIn()); + const view = await renderWithClerk(inActivity('visible')); + const actor = userEvent.setup(); + + await actor.click(screen.getByRole('button', { name: 'Add phone number' })); + await actor.type(screen.getByRole('textbox', { name: 'Phone' }), '8015550100'); + await actor.click(screen.getByRole('button', { name: 'Send code' })); + const code = await screen.findByRole('textbox', { name: 'Verification code' }); + await waitFor(() => expect(code).not.toHaveAttribute('aria-disabled')); + + act(() => view.rerender(inActivity('hidden'))); + act(() => view.rerender(inActivity('visible'))); + + expect(screen.getByRole('dialog')).toBeVisible(); + await verify(actor); + }); + + it('keeps waiting for a code that was in flight when hidden', async () => { + serveFapi(signedIn()); + const view = await renderWithClerk(inActivity('visible')); + const actor = userEvent.setup(); + const prepare = holdRequests('post', '/v1/me/phone_numbers/:id/prepare_verification'); + + await actor.click(screen.getByRole('button', { name: 'Add phone number' })); + await actor.type(screen.getByRole('textbox', { name: 'Phone' }), '8015550100'); + await actor.click(screen.getByRole('button', { name: 'Send code' })); + await waitFor(() => expect(prepare.requests).toHaveLength(1)); + + act(() => view.rerender(inActivity('hidden'))); + prepare.release(); + act(() => view.rerender(inActivity('visible'))); + + expect(prepare.requests).toHaveLength(1); + await verify(actor); + }); +}); diff --git a/packages/mosaic/src/machine/__tests__/machine.test.ts b/packages/mosaic/src/machine/__tests__/machine.test.ts index 82d03619cb3..2037210bb73 100644 --- a/packages/mosaic/src/machine/__tests__/machine.test.ts +++ b/packages/mosaic/src/machine/__tests__/machine.test.ts @@ -659,12 +659,221 @@ describe('actor restart — StrictMode start/stop/start cycle', () => { // Stopped mid-invoke (e.g. Suspense remount) then restarted. actor.stop(); actor.start(); - expect(actor.getSnapshot().status).toBe('active'); + expect(actor.getSnapshot()).toMatchObject({ value: 'deleting', status: 'active' }); // The invoke must not fire a second time. expect(invokeCount).toBe(1); }); }); +describe('actor restart — stop/start resumes the current state', () => { + it('stays in a state without an invoke', () => { + const actor = createActor(createDeleteOrgMachine(() => Promise.resolve())); + actor.start(); + actor.send({ type: 'OPEN' }); + actor.send({ type: 'TYPE', value: 'Acme' }); + + actor.stop(); + actor.start(); + + expect(actor.getSnapshot()).toMatchObject({ value: 'confirming', status: 'active' }); + expect(actor.getSnapshot().context.confirmValue).toBe('Acme'); + actor.send({ type: 'CANCEL' }); + expect(actor.getSnapshot().value).toBe('idle'); + }); + + it('applies an invoke that resolved while stopped once started again', async () => { + const gate = deferred(); + const actor = createActor(createLoaderMachine(() => gate.promise)); + actor.start(); + actor.send({ type: 'FETCH' }); + + actor.stop(); + gate.resolve('loaded'); + await tick(); + expect(actor.getSnapshot()).toMatchObject({ value: 'loading', status: 'stopped' }); + + actor.start(); + expect(actor.getSnapshot()).toMatchObject({ value: 'success', status: 'done' }); + expect(actor.getSnapshot().context.data).toBe('loaded'); + }); + + it('applies an invoke that rejected while stopped once started again', async () => { + const gate = deferred(); + const actor = createActor(createLoaderMachine(() => gate.promise)); + actor.start(); + actor.send({ type: 'FETCH' }); + + actor.stop(); + gate.reject('boom'); + await tick(); + actor.start(); + + expect(actor.getSnapshot()).toMatchObject({ value: 'failure', status: 'active' }); + expect(actor.getSnapshot().context.error).toBe('boom'); + }); + + it('handles an invoke that settles after the restart exactly once', async () => { + const gate = deferred(); + const seen: string[] = []; + const actor = createActor(createLoaderMachine(() => gate.promise)); + actor.start(); + actor.send({ type: 'FETCH' }); + + actor.stop(); + actor.start(); + actor.subscribe(snapshot => seen.push(snapshot.value)); + gate.resolve('late'); + await tick(); + + expect(actor.getSnapshot().value).toBe('success'); + expect(actor.getSnapshot().context.data).toBe('late'); + expect(seen).toEqual(['success']); + }); + + it('runs entry actions and the invoke once across a StrictMode start/stop/start', async () => { + const entry = vi.fn(); + const src = vi.fn(() => Promise.resolve('ok')); + const machine = createMachine<{ out: string }, { type: 'never' }>({ + initial: 'working', + context: { out: '' }, + states: { + working: { + entry, + invoke: { + src, + onDone: { + target: 'done', + actions: assign<{ out: string }, DoneInvokeEvent>((_, event) => ({ out: event.output })), + }, + }, + }, + done: { type: 'final' }, + }, + }); + const actor = createActor(machine); + + actor.start(); + actor.stop(); + actor.start(); + await tick(); + + expect(entry).toHaveBeenCalledTimes(1); + expect(src).toHaveBeenCalledTimes(1); + expect(actor.getSnapshot()).toMatchObject({ value: 'done', status: 'done', context: { out: 'ok' } }); + }); + + it('never notifies or schedules work once stopped for good', async () => { + vi.useFakeTimers(); + try { + const gate = deferred(); + const machine = createMachine, { type: 'never' }>({ + initial: 'working', + states: { + working: { invoke: { src: () => gate.promise, onDone: 'done' }, after: { 1000: 'done' } }, + done: {}, + }, + }); + const actor = createActor(machine); + actor.start(); + const listener = vi.fn(); + actor.subscribe(listener); + + actor.stop(); + listener.mockClear(); + expect(vi.getTimerCount()).toBe(0); + + gate.resolve('ignored'); + await vi.runAllTimersAsync(); + + expect(listener).not.toHaveBeenCalled(); + expect(actor.getSnapshot()).toMatchObject({ value: 'working', status: 'stopped' }); + } finally { + vi.useRealTimers(); + } + }); + + it('queues events sent while stopped and applies them in order on start', () => { + const actor = createActor(createDeleteOrgMachine(() => Promise.resolve())); + actor.start(); + actor.stop(); + const listener = vi.fn(); + actor.subscribe(listener); + + actor.send({ type: 'OPEN' }); + actor.send({ type: 'TYPE', value: 'Acme' }); + expect(actor.getSnapshot()).toMatchObject({ value: 'idle', status: 'stopped' }); + expect(listener).not.toHaveBeenCalled(); + + actor.start(); + + expect(actor.getSnapshot()).toMatchObject({ value: 'confirming', status: 'active' }); + expect(actor.getSnapshot().context.confirmValue).toBe('Acme'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('applies an invoke result and events in the order they arrived while stopped', async () => { + const gate = deferred(); + const actor = createActor(createDeleteOrgMachine(() => gate.promise)); + actor.start(); + actor.send({ type: 'OPEN' }); + actor.send({ type: 'TYPE', value: 'Acme Inc' }); + actor.send({ type: 'CONFIRM' }); + + actor.stop(); + gate.reject('boom'); + await tick(); + actor.send({ type: 'TYPE', value: 'retry' }); + actor.start(); + + expect(actor.getSnapshot()).toMatchObject({ + value: 'confirming', + context: { error: 'boom', confirmValue: 'retry' }, + }); + }); + + it('drops an invoke result when an event queued before it leaves the invoking state', async () => { + const gate = deferred(); + const machine = createMachine, { type: 'GO' } | { type: 'CANCEL' }>({ + initial: 'idle', + states: { + idle: { on: { GO: 'working' } }, + working: { invoke: { src: () => gate.promise, onDone: 'done' }, on: { CANCEL: 'idle' } }, + done: {}, + }, + }); + const actor = createActor(machine); + actor.start(); + actor.send({ type: 'GO' }); + + actor.stop(); + actor.send({ type: 'CANCEL' }); + gate.resolve('late'); + await tick(); + actor.start(); + + expect(actor.getSnapshot().value).toBe('idle'); + }); + + it('ignores send before the first start', () => { + const actor = createActor(createDeleteOrgMachine(() => Promise.resolve())); + actor.send({ type: 'OPEN' }); + actor.start(); + + expect(actor.getSnapshot().value).toBe('idle'); + }); + + it('keeps a teleported actor inert across a restart', () => { + const src = vi.fn(() => Promise.resolve()); + const actor = mockActor(createDeleteOrgMachine(src), { value: 'deleting', context: { error: 'shown' } }); + + actor.stop(); + actor.start(); + + expect(actor.getSnapshot()).toMatchObject({ value: 'deleting', status: 'active', context: { error: 'shown' } }); + expect(src).not.toHaveBeenCalled(); + }); +}); + describe('createActor — after (delayed transitions)', () => { beforeEach(() => { vi.useFakeTimers(); @@ -847,6 +1056,30 @@ describe('createActor — after (delayed transitions)', () => { vi.advanceTimersByTime(60_000); expect(actor.getSnapshot().value).toBe('expired'); }); + + it('does not fire while stopped and re-arms the timer of the current state on start', () => { + const machine = createMachine, { type: 'SEND' }>({ + initial: 'idle', + states: { + idle: { on: { SEND: 'codeSent' } }, + codeSent: { after: { 60_000: 'expired' } }, + expired: {}, + }, + }); + const actor = createActor(machine); + actor.start(); + actor.send({ type: 'SEND' }); + + actor.stop(); + vi.advanceTimersByTime(120_000); + expect(actor.getSnapshot()).toMatchObject({ value: 'codeSent', status: 'stopped' }); + + actor.start(); + vi.advanceTimersByTime(59_999); + expect(actor.getSnapshot().value).toBe('codeSent'); + vi.advanceTimersByTime(1); + expect(actor.getSnapshot().value).toBe('expired'); + }); }); describe('createActor — inline transition functions (TransitionFn)', () => { diff --git a/packages/mosaic/src/machine/__tests__/use-machine.concurrency.feature.test.tsx b/packages/mosaic/src/machine/__tests__/use-machine.concurrency.feature.test.tsx new file mode 100644 index 00000000000..8161f58b911 --- /dev/null +++ b/packages/mosaic/src/machine/__tests__/use-machine.concurrency.feature.test.tsx @@ -0,0 +1,156 @@ +import { act, render, screen } from '@testing-library/react'; +import { Activity, StrictMode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { deferred } from '../../__tests__/async'; +import { createMachine } from '../create-machine'; +import type { StateMachine } from '../types'; +import { useMachine } from '../use-machine'; + +type FlowEvent = { type: 'START' } | { type: 'NEXT' }; + +function createFlowMachine(work: () => Promise) { + return createMachine, FlowEvent>({ + initial: 'idle', + states: { + idle: { on: { START: 'working' } }, + working: { invoke: { src: work, onDone: 'review' } }, + review: { on: { NEXT: 'finished' } }, + finished: { type: 'final' }, + }, + }); +} + +type Send = (event: FlowEvent) => void; + +function Flow({ + machine, + onDone, + onSend, +}: { + machine: StateMachine, FlowEvent>; + onDone?: () => void; + onSend?: (send: Send) => void; +}) { + const [snapshot, send] = useMachine(machine, { onDone }); + onSend?.(send); + return ( + <> + {snapshot.value} + + + + ); +} + +function renderInActivity(props: Parameters[0]) { + const ui = (mode: 'visible' | 'hidden') => ( + + + + ); + const view = render(ui('visible')); + return { + hide: () => act(() => view.rerender(ui('hidden'))), + show: () => act(() => view.rerender(ui('visible'))), + }; +} + +const state = () => screen.getByRole('status'); +const click = (name: string) => act(() => screen.getByRole('button', { name }).click()); + +describe.runIf(Activity)('useMachine inside Activity', () => { + it('keeps the current state and keeps rendering transitions after being shown again', async () => { + const machine = createFlowMachine(() => Promise.resolve()); + const activity = renderInActivity({ machine }); + click('Start'); + await act(async () => {}); + expect(state()).toHaveTextContent('review'); + + activity.hide(); + activity.show(); + + expect(state()).toHaveTextContent('review'); + click('Next'); + expect(state()).toHaveTextContent('finished'); + }); + + it('renders an invoke result that settled while hidden', async () => { + const gate = deferred(); + const work = vi.fn(() => gate.promise); + const activity = renderInActivity({ machine: createFlowMachine(work) }); + click('Start'); + expect(state()).toHaveTextContent('working'); + + activity.hide(); + await act(async () => gate.resolve()); + activity.show(); + + expect(state()).toHaveTextContent('review'); + expect(work).toHaveBeenCalledTimes(1); + }); + + it('applies an event sent while hidden once shown again', async () => { + let send: Send = () => {}; + const machine = createFlowMachine(() => Promise.resolve()); + const activity = renderInActivity({ machine, onSend: next => (send = next) }); + click('Start'); + await act(async () => {}); + + activity.hide(); + act(() => send({ type: 'NEXT' })); + activity.show(); + + expect(state()).toHaveTextContent('finished'); + }); + + it('calls onDone once for a flow that finished before being hidden and shown again', async () => { + const onDone = vi.fn(); + const activity = renderInActivity({ machine: createFlowMachine(() => Promise.resolve()), onDone }); + click('Start'); + await act(async () => {}); + click('Next'); + expect(onDone).toHaveBeenCalledTimes(1); + + activity.hide(); + activity.show(); + + expect(state()).toHaveTextContent('finished'); + expect(onDone).toHaveBeenCalledTimes(1); + }); +}); + +describe('useMachine in StrictMode', () => { + it('runs entry actions and the invoke once', async () => { + const entry = vi.fn(); + const work = vi.fn(() => Promise.resolve()); + const machine = createMachine, FlowEvent>({ + initial: 'working', + states: { + working: { entry, invoke: { src: work, onDone: 'review' } }, + review: {}, + }, + }); + + render( + + + , + ); + await act(async () => {}); + + expect(state()).toHaveTextContent('review'); + expect(entry).toHaveBeenCalledTimes(1); + expect(work).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/mosaic/src/machine/create-actor.ts b/packages/mosaic/src/machine/create-actor.ts index d527868354d..c10655d07db 100644 --- a/packages/mosaic/src/machine/create-actor.ts +++ b/packages/mosaic/src/machine/create-actor.ts @@ -21,6 +21,12 @@ import { AFTER, ASSIGN, INIT, INVOKE_DONE, INVOKE_ERROR, RECHECK } from './types const INIT_EVENT: AnyEventObject = { type: INIT }; +interface AfterTimer { + id: ReturnType; + delay: number; + transitionConfig: unknown; +} + // Collapse toArray into single helper: returns empty array, original if array, or wrapped value const toArr = (v: T | T[] | undefined): T[] => (!v ? [] : Array.isArray(v) ? v : [v]); @@ -70,14 +76,17 @@ export function createActor // A teleported actor is already "started" and inert: start() must not re-run // entry/always/invoke for the state it was dropped into. let started = teleport !== undefined; + let initialized = started; let status: Snapshot['status'] = stateOf(value).type === 'final' ? 'done' : 'active'; - // Bumped whenever we leave an invoking state (or stop), so a stale promise - // resolving after the fact is ignored — no transition, no setState-after-stop. + // 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)[] = []; - // Pending `after` timer IDs — cleared when the state is exited or the actor stops. - let afterTimers: ReturnType[] = []; + // Pending `after` timers — cleared when the state is exited, paused while the actor is stopped. + let afterTimers: AfterTimer[] = []; + let pausedAfterTimers: AfterTimer[] = []; // The snapshot is cached and only replaced on an actual change, so // getSnapshot() is referentially stable for useSyncExternalStore. @@ -168,44 +177,39 @@ export function createActor return; } const token = ++invocationToken; + const settle = (transitionConfig: unknown, settledEvent: AnyEventObject) => { + if (token !== invocationToken) { + return; + } + if (status === 'stopped') { + receivedWhileStopped.push( + () => token === invocationToken && takeInvokeTransition(transitionConfig, settledEvent), + ); + return; + } + if (status === 'active' && takeInvokeTransition(transitionConfig, settledEvent)) { + commit(); + } + }; // SAFETY: startInvoke is called with the actor's internal EventObject, but // InvokeConfig.src is typed to accept (context, TEvent | DoneInvokeEvent | ErrorInvokeEvent). // The cast suppresses that mismatch; src implementations receive the INIT event // on state entry and typically ignore it. The runtime views events through an // event-agnostic lens (line 57) for this reason. Promise.resolve(invoke.src(context, event as never)).then( - (output: unknown) => { - if (status !== 'active' || token !== invocationToken) { - return; - } - const doneEvent = { type: INVOKE_DONE, output }; - const transition = pickTransition(normalizeTransition(invoke.onDone, doneEvent), doneEvent); - if (!transition) { - return; - } - if (takeTransition(transition, doneEvent)) { - commit(); - } - }, - (error: unknown) => { - if (status !== 'active' || token !== invocationToken) { - return; - } - const errorEvent = { type: INVOKE_ERROR, error }; - const transition = pickTransition(normalizeTransition(invoke.onError, errorEvent), errorEvent); - if (!transition) { - return; - } - if (takeTransition(transition, errorEvent)) { - commit(); - } - }, + (output: unknown) => settle(invoke.onDone, { type: INVOKE_DONE, output }), + (error: unknown) => settle(invoke.onError, { type: INVOKE_ERROR, error }), ); } + function takeInvokeTransition(transitionConfig: unknown, event: EventObject): boolean { + const transition = pickTransition(normalizeTransition(transitionConfig, event), event); + return transition !== undefined && takeTransition(transition, event); + } + function clearAfterTimers(): void { - for (const id of afterTimers) { - clearTimeout(id); + for (const timer of afterTimers) { + clearTimeout(timer.id); } afterTimers = []; } @@ -216,25 +220,53 @@ export function createActor return; } for (const [delayStr, raw] of Object.entries(afterConfig)) { - const delay = Number(delayStr); - const id = setTimeout(() => { - afterTimers = afterTimers.filter(t => t !== id); + scheduleAfterTimer(Number(delayStr), raw); + } + } + + function scheduleAfterTimer(delay: number, transitionConfig: unknown): void { + const timer: AfterTimer = { + delay, + transitionConfig, + id: setTimeout(() => { + afterTimers = afterTimers.filter(t => t !== timer); if (status !== 'active') { return; } const afterEvent: AfterEvent = { type: AFTER, delay }; - const transition = pickTransition(normalizeTransition(raw, afterEvent), afterEvent); + const transition = pickTransition(normalizeTransition(transitionConfig, afterEvent), afterEvent); if (!transition) { return; } if (takeTransition(transition, afterEvent)) { commit(); } - }, delay); - afterTimers.push(id); + }, delay), + }; + afterTimers.push(timer); + } + + function resume(): void { + status = stateOf(value).type === 'final' ? 'done' : 'active'; + for (const timer of pausedAfterTimers) { + scheduleAfterTimer(timer.delay, timer.transitionConfig); + } + pausedAfterTimers = []; + const received = receivedWhileStopped; + receivedWhileStopped = []; + for (const apply of received) { + apply(); } } + function takeEventTransition(event: EventObject): boolean { + if (status !== 'active') { + return false; + } + const transition = pickTransition(normalizeTransition(stateOf(value).on?.[event.type], event), event); + return transition !== undefined && takeTransition(transition, event); + } + /** Entry side of a state: entry actions, then immediate/invoke resolution. */ function enterState(event: EventObject): void { const stateConfig = stateOf(value); @@ -273,12 +305,16 @@ export function createActor return actor; } started = true; - status = 'active'; - // Reset state and context so a restart (e.g. after StrictMode stop/start) - // begins from idle rather than re-entering and re-invoking a mid-flight state. - context = { ...machine.context, ...liveContextPatch }; - value = resolveInitial(); - enterState(INIT_EVENT); + if (initialized) { + resume(); + } else { + initialized = true; + status = 'active'; + // The first start seats on the initial state resolved from the latest injected context. + context = { ...machine.context, ...liveContextPatch }; + value = resolveInitial(); + enterState(INIT_EVENT); + } commit(); return actor; }, @@ -289,7 +325,7 @@ export function createActor } started = false; // allow restart (e.g. StrictMode effect cleanup + remount) status = 'stopped'; - invocationToken++; // abandon any in-flight invoke + pausedAfterTimers = afterTimers; clearAfterTimers(); snapshot = { value, context, status }; notifyListeners(); @@ -297,16 +333,13 @@ export function createActor }, send(event) { - if (!started || status !== 'active') { + if (initialized && status === 'stopped') { + receivedWhileStopped.push(() => takeEventTransition(event)); return; } - const transition = pickTransition(normalizeTransition(stateOf(value).on?.[event.type], event), event); - if (!transition) { - return; - } // event not handled in this state → ignored - if (takeTransition(transition, event)) { + if (started && takeEventTransition(event)) { commit(); - } // entry-blocked → no commit, no notify + } }, getSnapshot() { diff --git a/packages/mosaic/src/machine/types.ts b/packages/mosaic/src/machine/types.ts index 5ae3a960161..c4bb4384124 100644 --- a/packages/mosaic/src/machine/types.ts +++ b/packages/mosaic/src/machine/types.ts @@ -211,11 +211,16 @@ export type SnapshotListener = (snapshot: Snapshot) => void; export type Unsubscribe = () => void; export interface Actor { - /** Run entry actions / immediate transitions / invokes of the initial state. */ + /** + * Run entry actions / immediate transitions / invokes of the initial state. + * 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 and abandon any in-flight invoke. */ + /** 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 once the actor is `done` or `stopped`. */ + /** Send an event. Ignored before the first `start()` and once the actor is `done`; queued while `stopped`. */ send: (event: TEvent) => void; getSnapshot: () => Snapshot; /** Subscribe to snapshots; returns an unsubscribe fn (usable with `useSyncExternalStore`). */ diff --git a/packages/mosaic/src/machine/use-machine.ts b/packages/mosaic/src/machine/use-machine.ts index 2a52ad61a3a..19bb181176c 100644 --- a/packages/mosaic/src/machine/use-machine.ts +++ b/packages/mosaic/src/machine/use-machine.ts @@ -43,6 +43,7 @@ export function useMachine( actorRef.current = createActor(machine, options); } const actor = actorRef.current; + const snapshot = useSyncExternalStore(actor.subscribe, actor.getSnapshot, actor.getSnapshot); useEffect(() => { actor.start(); @@ -58,12 +59,12 @@ export function useMachine( } }); - const snapshot = useSyncExternalStore(actor.subscribe, actor.getSnapshot, actor.getSnapshot); - const onDoneRef = useRef(options?.onDone); onDoneRef.current = options?.onDone; + const doneReportedRef = useRef(false); useEffect(() => { - if (snapshot.status === 'done') { + if (snapshot.status === 'done' && !doneReportedRef.current) { + doneReportedRef.current = true; onDoneRef.current?.(); } }, [snapshot.status]); diff --git a/packages/mosaic/vitest.config.mts b/packages/mosaic/vitest.config.mts index fc25dc15cf9..1f7c2b5858d 100644 --- a/packages/mosaic/vitest.config.mts +++ b/packages/mosaic/vitest.config.mts @@ -7,6 +7,7 @@ import { defineConfig } from 'vitest/config'; const mosaicPath = resolve(import.meta.dirname, 'src'); const featureTests = 'src/**/*.feature.test.tsx'; const webkitTests = 'src/**/*.webkit.test.tsx'; +const concurrencyTests = 'src/**/*.concurrency.feature.test.tsx'; export default defineConfig({ plugins: [ @@ -74,6 +75,39 @@ export default defineConfig({ }, }, }, + { + extends: true, + publicDir: 'test/public', + cacheDir: 'node_modules/.vite-react-19', + resolve: { + alias: [ + { find: /^react-dom(\/.*)?$/, replacement: 'react-dom-19$1' }, + { find: /^react(\/.*)?$/, replacement: 'react-19$1' }, + ], + }, + optimizeDeps: { + include: [ + '@clerk/clerk-js', + '@wallet-standard/core', + '@testing-library/jest-dom/matchers', + '@testing-library/react', + '@testing-library/user-event', + 'msw', + 'msw/browser', + ], + }, + test: { + name: 'concurrency-react-19', + include: [concurrencyTests], + setupFiles: [resolve(import.meta.dirname, 'vitest.setup.browser.mts')], + browser: { + enabled: true, + headless: true, + provider: playwright(), + instances: [{ browser: 'chromium' }], + }, + }, + }, { extends: true, test: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 032116ad7a8..6d7354122ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -896,9 +896,15 @@ importers: react: specifier: 18.3.1 version: 18.3.1 + react-19: + specifier: npm:react@19.2.8 + version: react@19.2.8 react-dom: specifier: 18.3.1 version: 18.3.1(react@18.3.1) + react-dom-19: + specifier: npm:react-dom@19.2.8 + version: react-dom@19.2.8(react@18.3.1) tsdown: specifier: catalog:repo version: 0.22.2(@arethetypeswrong/core@0.18.3)(publint@0.3.18)(tsx@4.23.14)(typescript@6.0.3)(unrun@0.3.1(synckit@0.11.11))(vue-tsc@3.2.4(typescript@6.0.3)) @@ -12958,6 +12964,11 @@ packages: peerDependencies: react: 18.3.1 + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: 18.3.1 + react-grab@0.1.44: resolution: {integrity: sha512-bDEwBdI90ljq2lhUtPqmWis/HwYB/CvfT0m5i+P9F83Pt0Ot8o9XL8v00s9jcWzdQUlsFDzmq2FO2CHUe8JY8A==} hasBin: true @@ -13017,6 +13028,10 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + read-pkg-up@7.0.1: resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==} engines: {node: '>=8'} @@ -22040,9 +22055,9 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.23.14)(yaml@2.9.0)) + vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.23.14)(yaml@2.9.0)) optionalDependencies: - '@vitest/browser': 5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.23.14)(yaml@2.9.0))(vitest@5.0.0) + '@vitest/browser': 5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.23.14)(yaml@2.9.0))(vitest@5.0.0) '@vitest/expect@5.0.2': dependencies: @@ -22109,7 +22124,7 @@ snapshots: pathe: 2.0.3 sirv: 3.0.2 tinyrainbow: 3.1.1 - vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.23.14)(yaml@2.9.0)) + vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.23.14)(yaml@2.9.0)) '@vitest/utils@5.0.0': dependencies: @@ -29863,6 +29878,11 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 + react-dom@19.2.8(react@18.3.1): + dependencies: + react: 18.3.1 + scheduler: 0.27.0 + react-grab@0.1.44(react@18.3.1): dependencies: '@react-grab/cli': 0.1.44 @@ -29941,6 +29961,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + react@19.2.8: {} + read-pkg-up@7.0.1: dependencies: find-up: 4.1.0