From d391e1777cebf2752a004cc1d1fbc06e71bb5b8e Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 5 Oct 2026 19:14:30 -0400 Subject: [PATCH 01/12] fix(mosaic): route synchronous throws from invoke src to onError --- .changeset/mosaic-invoke-sync-throw.md | 2 + .../src/machine/__tests__/machine.test.ts | 15 +++++ packages/mosaic/src/machine/create-actor.ts | 59 ++++++++++--------- 3 files changed, 48 insertions(+), 28 deletions(-) create mode 100644 .changeset/mosaic-invoke-sync-throw.md diff --git a/.changeset/mosaic-invoke-sync-throw.md b/.changeset/mosaic-invoke-sync-throw.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-invoke-sync-throw.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 2e04bc9c76a..97fa5524a9d 100644 --- a/packages/mosaic/src/machine/__tests__/machine.test.ts +++ b/packages/mosaic/src/machine/__tests__/machine.test.ts @@ -226,6 +226,21 @@ describe('createActor — invoke (async)', () => { expect(actor.getSnapshot().context.error).toContain('boom'); }); + it('routes a synchronous throw from src to onError', async () => { + const actor = createActor( + createLoaderMachine(() => { + throw new Error('boom'); + }), + ); + actor.start(); + actor.send({ type: 'FETCH' }); + expect(actor.getSnapshot().value).toBe('loading'); + + await tick(); + expect(actor.getSnapshot().value).toBe('failure'); + expect(actor.getSnapshot().context.error).toContain('boom'); + }); + it('passes the resolved value to onDone as event.output', async () => { const onDone = vi.fn(); const machine = createMachine<{ out: string | null }, { type: 'GO' }>({ diff --git a/packages/mosaic/src/machine/create-actor.ts b/packages/mosaic/src/machine/create-actor.ts index 1b577839796..e9b9a5c42ff 100644 --- a/packages/mosaic/src/machine/create-actor.ts +++ b/packages/mosaic/src/machine/create-actor.ts @@ -160,34 +160,37 @@ export function createActor // 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 => { - 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(); - } - }, - ); + const onDone = (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(); + } + }; + const onError = (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(); + } + }; + try { + Promise.resolve(invoke.src(context, event as never)).then(onDone, onError); + } catch (error) { + queueMicrotask(() => onError(error)); + } } function clearAfterTimers(): void { From 6128ead6e8607f6725269d0b92928fa7065e3896 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 5 Oct 2026 19:15:59 -0400 Subject: [PATCH 02/12] feat(mosaic): add named implementations, provide, and action params to the machine lib --- .../__tests__/implementations.test-d.ts | 325 ++++++++++++++++++ .../machine/__tests__/implementations.test.ts | 184 ++++++++++ .../src/machine/__tests__/machine.test.ts | 2 +- .../machine/__tests__/use-machine.test.tsx | 65 ++++ .../__tests__/wizard-migration.test.tsx | 2 +- packages/mosaic/src/machine/assign.ts | 10 +- packages/mosaic/src/machine/create-actor.ts | 119 +++++-- packages/mosaic/src/machine/create-machine.ts | 22 +- packages/mosaic/src/machine/setup.ts | 76 +++- packages/mosaic/src/machine/types.ts | 183 ++++++++-- packages/mosaic/src/machine/use-machine.ts | 1 + 11 files changed, 920 insertions(+), 69 deletions(-) create mode 100644 packages/mosaic/src/machine/__tests__/implementations.test-d.ts create mode 100644 packages/mosaic/src/machine/__tests__/implementations.test.ts diff --git a/packages/mosaic/src/machine/__tests__/implementations.test-d.ts b/packages/mosaic/src/machine/__tests__/implementations.test-d.ts new file mode 100644 index 00000000000..d651a729b52 --- /dev/null +++ b/packages/mosaic/src/machine/__tests__/implementations.test-d.ts @@ -0,0 +1,325 @@ +import { describe, expectTypeOf, test } from 'vitest'; + +import { createActor } from '../create-actor'; +import { createMachine as plainCreateMachine } from '../create-machine'; +import { setup } from '../setup'; +import type { ActorSrc, DoneInvokeEvent, ErrorInvokeEvent, Guard, StateMachine } from '../types'; + +type Ctx = { value: string; saved: string; error: string | undefined }; +type Evt = { type: 'SAVE' } | { type: 'CHANGE'; value: string } | { type: 'RESET' }; + +const base = setup(); +const { assign } = base; + +const { createMachine, fromPromise } = base.extend({ + guards: { + isSaveable: (ctx, event) => { + expectTypeOf(ctx).toEqualTypeOf(); + expectTypeOf(event).toEqualTypeOf(); + return ctx.value !== ctx.saved; + }, + }, + actions: { + clearError: assign(() => ({ error: undefined })), + log: (ctx, event) => { + expectTypeOf(ctx).toEqualTypeOf(); + expectTypeOf(event).toEqualTypeOf(); + }, + }, + actors: { + save: ctx => { + expectTypeOf(ctx).toEqualTypeOf(); + return Promise.resolve(ctx.value.length); + }, + }, +}); + +const machine = createMachine({ + initial: 'editing', + context: { value: '', saved: '', error: undefined }, + states: { + editing: { + guard: 'isSaveable', + entry: 'log', + exit: ['log', 'clearError'], + on: { SAVE: { target: 'saving', guard: 'isSaveable', actions: 'clearError' } }, + always: { guard: 'isSaveable', actions: ['log'] }, + after: { 1000: { target: 'idle', guard: 'isSaveable', actions: 'log' } }, + }, + saving: { + invoke: { + src: 'save', + onDone: { target: 'idle', guard: 'isSaveable', actions: 'clearError' }, + onError: [{ target: 'editing', guard: 'isSaveable', actions: ['log'] }], + }, + }, + idle: {}, + }, +}); + +describe('named implementations', () => { + test('1. unknown names are rejected', () => { + createMachine({ + initial: 'a', + states: { + a: { + // @ts-expect-error unknown guard name + guard: 'nope', + // @ts-expect-error unknown action name + entry: 'nope', + // @ts-expect-error unknown action name in an array + exit: ['log', 'nope'], + on: { + // @ts-expect-error unknown guard name in `on` + SAVE: { target: 'b', guard: 'nope' }, + // @ts-expect-error unknown action name in `on` + RESET: { actions: 'nope' }, + }, + // @ts-expect-error unknown guard name in `always` + always: { guard: 'nope' }, + // @ts-expect-error unknown action name in `after` + after: { 10: { actions: 'nope' } }, + }, + b: { + invoke: { + // @ts-expect-error unknown actor name + src: 'nope', + // @ts-expect-error unknown action name in `onDone` + onDone: { actions: 'nope' }, + }, + }, + }, + }); + }); + + test('1. a name of one kind is not accepted for another kind', () => { + createMachine({ + initial: 'a', + states: { + // @ts-expect-error `isSaveable` is a guard, not an action + a: { entry: 'isSaveable' }, + // @ts-expect-error `clearError` is an action, not an actor + b: { invoke: { src: 'clearError' } }, + }, + }); + }); + + test('2. valid names compile everywhere (the fixture above)', () => { + expectTypeOf(machine).toExtend>(); + }); + + test('2. names work inside fromPromise transitions', () => { + createMachine({ + initial: 'a', + states: { + a: { + invoke: fromPromise(ctx => Promise.resolve(ctx.value), { onDone: { guard: 'isSaveable', actions: 'log' } }), + }, + }, + }); + }); + + test('3. implementations see the full event union, not the narrowed `on` member', () => { + base + .extend({ + guards: { + isChange: (_, event) => { + expectTypeOf(event).toEqualTypeOf(); + return event.type === 'CHANGE' && event.value.length > 0; + }, + }, + }) + .createMachine({ initial: 'a', states: { a: { on: { CHANGE: { guard: 'isChange' } } } } }); + }); + + test('3. inline functions under `on` still narrow the event', () => { + createMachine({ + initial: 'a', + states: { + a: { + on: { + CHANGE: { + guard: (_, event) => { + expectTypeOf(event).toEqualTypeOf<{ type: 'CHANGE'; value: string }>(); + return true; + }, + }, + }, + }, + }, + }); + }); + + test('3. implementations are checked against Ctx and Evt', () => { + base.extend({ + guards: { + // @ts-expect-error guards return boolean + bad: () => 'yes', + }, + actors: { + // @ts-expect-error actors return a promise + sync: () => 1, + }, + }); + }); + + test('3. a named `src` does not type `onDone` output (raw `src` behaviour)', () => { + createMachine({ + initial: 'a', + states: { + a: { + invoke: { + src: 'save', + onDone: { + actions: assign((_, event) => { + expectTypeOf(event).toExtend(); + expectTypeOf(event.output).toBeAny(); + return {}; + }), + }, + }, + }, + }, + }); + }); + + test('3. extend accumulates names across calls', () => { + const { createMachine: create } = base + .extend({ guards: { first: () => true } }) + .extend({ guards: { second: () => true } }); + create({ initial: 'a', states: { a: { guard: 'first', always: { guard: 'second' } } } }); + }); + + test('4. provide accepts a partial subset of the default names', () => { + const provided = machine.provide({ actors: { save: ctx => Promise.resolve(ctx.saved.length) } }); + expectTypeOf(provided).toEqualTypeOf(machine); + machine.provide({}); + machine.provide({ guards: { isSaveable: () => true } }); + machine.provide({ actions: { log: () => {}, clearError: assign(() => ({ error: 'x' })) } }); + }); + + test('4. provide rejects unknown names', () => { + // @ts-expect-error unknown guard name + machine.provide({ guards: { nope: () => true } }); + // @ts-expect-error unknown actor name + machine.provide({ actors: { nope: () => Promise.resolve(1) } }); + }); + + test('4. provide rejects mismatched signatures', () => { + // @ts-expect-error guards return boolean + machine.provide({ guards: { isSaveable: () => 'yes' } }); + // @ts-expect-error `save` resolves with a number in the defaults + machine.provide({ actors: { save: () => Promise.resolve('x') } }); + // @ts-expect-error implementations receive Ctx + machine.provide({ guards: { isSaveable: (ctx: { other: number }) => ctx.other > 0 } }); + }); + + test('8. action params are typed by the implementation', () => { + const { createMachine: create } = base.extend({ + actions: { + setError: assign((_, _event, params: { message: string }) => ({ error: params.message })), + log: () => {}, + }, + actors: { save: () => Promise.resolve(1) }, + }); + create({ + initial: 'a', + states: { + a: { + entry: [{ type: 'setError', params: { message: 'x' } }, 'log', { type: 'log', params: undefined }], + invoke: { + src: 'save', + onError: { + actions: { + type: 'setError', + params: (ctx, event) => { + expectTypeOf(ctx).toEqualTypeOf(); + expectTypeOf(event).toEqualTypeOf(); + return { message: String(event.error) }; + }, + }, + }, + }, + }, + }, + }); + }); + + test('8. action params are required and checked against the implementation', () => { + const { createMachine: create } = base.extend({ + actions: { setError: assign((_, _event, params: { message: string }) => ({ error: params.message })) }, + }); + create({ + initial: 'a', + states: { + a: { + // @ts-expect-error params are required + entry: 'setError', + // @ts-expect-error params have the wrong shape + exit: { type: 'setError', params: { message: 1 } }, + }, + }, + }); + }); + + test('8. provide keeps the params type of each action', () => { + const provided = base.extend({ + actions: { setError: assign((_, _event, params: { message: string }) => ({ error: params.message })) }, + }); + const machineWithParams = provided.createMachine({ initial: 'a', states: { a: {} } }); + machineWithParams.provide({ + actions: { setError: assign((_, _event, params: { message: string }) => ({ error: params.message })) }, + }); + machineWithParams.provide({ + // @ts-expect-error the implementation expects different params + actions: { setError: assign((_, _event, params: { code: number }) => ({ error: String(params.code) })) }, + }); + }); + + test('5. inline functions still work alongside names', () => { + createMachine({ + initial: 'a', + states: { + a: { + entry: ['log', () => {}], + on: { + SAVE: [ + { guard: 'isSaveable', target: 'b' }, + { guard: () => true, actions: ['clearError'] }, + ], + }, + }, + b: { invoke: { src: ctx => Promise.resolve(ctx.value), onDone: 'a' } }, + }, + }); + }); + + test('6. machines without implementations reject every name', () => { + const plain = base.createMachine({ + initial: 'a', + // @ts-expect-error no guards were registered + states: { a: { guard: 'isSaveable' } }, + }); + expectTypeOf(plain).toExtend>(); + // @ts-expect-error no guards to override + plain.provide({ guards: { isSaveable: () => true } }); + }); + + test('6. the non-setup createMachine is unchanged', () => { + const plain = plainCreateMachine({ + initial: 'a', + context: { value: '', saved: '', error: undefined }, + // @ts-expect-error no names without implementations + states: { a: { entry: 'log' } }, + }); + expectTypeOf(plain).toExtend>(); + }); + + test('7. a machine with implementations stays assignable to StateMachine', () => { + let ref: StateMachine | null = null; + ref = machine; + expectTypeOf(createActor(ref)).toEqualTypeOf(createActor(machine)); + expectTypeOf(ref.implementations.guards.isSaveable).toEqualTypeOf | undefined>(); + expectTypeOf(ref.implementations.actors.save).toEqualTypeOf | undefined>(); + }); +}); diff --git a/packages/mosaic/src/machine/__tests__/implementations.test.ts b/packages/mosaic/src/machine/__tests__/implementations.test.ts new file mode 100644 index 00000000000..a8bd8d2fab3 --- /dev/null +++ b/packages/mosaic/src/machine/__tests__/implementations.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { deferred, tick } from '../../__tests__/async'; +import { createActor, mockActor } from '../create-actor'; +import { setup } from '../setup'; + +type Ctx = { value: string; log: string[] }; +type Evt = { type: 'SAVE' } | { type: 'TYPE'; value: string }; + +const base = setup(); +const { assign } = base; + +const { createMachine } = base.extend({ + guards: { isSaveable: ctx => ctx.value !== '' }, + actions: { + record: assign(ctx => ({ log: [...ctx.log, `saved ${ctx.value}`] })), + type: assign((_, event) => ({ value: event.type === 'TYPE' ? event.value : '' })), + }, + actors: { save: () => Promise.resolve('default') }, +}); + +const machine = createMachine({ + initial: 'editing', + context: { value: '', log: [] }, + states: { + editing: { + on: { + TYPE: { actions: 'type' }, + SAVE: { target: 'saving', guard: 'isSaveable' }, + }, + }, + saving: { + invoke: { src: 'save', onDone: { target: 'saved', actions: 'record' }, onError: 'editing' }, + }, + saved: {}, + }, +}); + +const noImplementations = { guards: {}, actions: {}, actors: {} }; + +describe('named implementations', () => { + it('resolves a named guard, action and src from the default implementations', async () => { + const actor = createActor(machine).start(); + + actor.send({ type: 'SAVE' }); + expect(actor.getSnapshot().value).toBe('editing'); + + actor.send({ type: 'TYPE', value: 'Acme' }); + actor.send({ type: 'SAVE' }); + expect(actor.getSnapshot().value).toBe('saving'); + + await tick(); + expect(actor.getSnapshot().value).toBe('saved'); + expect(actor.getSnapshot().context.log).toEqual(['saved Acme']); + }); + + it('overrides one kind through provide and keeps the other defaults', async () => { + const save = vi.fn(() => Promise.resolve('provided')); + const provided = machine.provide({ actors: { save } }); + const actor = createActor(provided).start(); + + actor.send({ type: 'TYPE', value: 'Acme' }); + actor.send({ type: 'SAVE' }); + await tick(); + + expect(save).toHaveBeenCalledTimes(1); + expect(actor.getSnapshot().context.log).toEqual(['saved Acme']); + }); + + it('leaves the original machine untouched by provide', () => { + const save = () => Promise.resolve('provided'); + const provided = machine.provide({ actors: { save } }); + + expect(provided.implementations.actors.save).toBe(save); + expect(machine.implementations.actors.save).not.toBe(save); + expect(provided.config).toBe(machine.config); + }); + + it('throws when a named guard is not implemented', () => { + const actor = createActor(machine.provide({})).start(); + actor.logic.implementations = noImplementations; + + expect(() => actor.send({ type: 'SAVE' })).toThrow("Guard 'isSaveable' is not implemented."); + }); + + it('throws when a named action is not implemented', () => { + const actor = createActor(machine.provide({})).start(); + actor.logic.implementations = noImplementations; + + expect(() => actor.send({ type: 'TYPE', value: 'Acme' })).toThrow("Action 'type' is not implemented."); + }); + + it('throws when a named src is not implemented', () => { + const actor = createActor(machine.provide({})).start(); + actor.send({ type: 'TYPE', value: 'Acme' }); + actor.logic.implementations = { ...machine.implementations, actors: {} }; + + expect(() => actor.send({ type: 'SAVE' })).toThrow("Actor 'save' is not implemented."); + }); + + it('reads the implementations swapped onto actor.logic at evaluation time', () => { + const actor = createActor(machine.provide({})).start(); + actor.send({ type: 'TYPE', value: 'Acme' }); + + actor.logic.implementations = machine.provide({ guards: { isSaveable: () => false } }).implementations; + actor.send({ type: 'SAVE' }); + + expect(actor.getSnapshot().value).toBe('editing'); + }); + + it('keeps the src an invoke started with when the implementations are swapped mid-flight', async () => { + const started = deferred(); + const swapped = vi.fn(() => Promise.resolve('swapped')); + const actor = createActor(machine.provide({ actors: { save: () => started.promise } })).start(); + actor.send({ type: 'TYPE', value: 'Acme' }); + actor.send({ type: 'SAVE' }); + + actor.logic.implementations = machine.provide({ actors: { save: swapped } }).implementations; + started.resolve('first'); + await tick(); + + expect(swapped).not.toHaveBeenCalled(); + expect(actor.getSnapshot().value).toBe('saved'); + }); + + it('passes static params to a named action', () => { + const record = vi.fn(); + const actor = createActor( + base.extend({ actions: { record } }).createMachine({ + initial: 'a', + context: { value: '', log: [] }, + states: { a: { entry: { type: 'record', params: { label: 'entered' } } } }, + }), + ).start(); + + expect(record).toHaveBeenCalledWith(actor.getSnapshot().context, { type: 'machine.init' }, { label: 'entered' }); + }); + + it('resolves dynamic params from the context and event the action runs with', async () => { + const actor = createActor( + base + .extend({ + actions: { + logError: assign((ctx, _event, params: { message: string }) => ({ log: [...ctx.log, params.message] })), + }, + actors: { save: () => Promise.reject(new Error('offline')) }, + }) + .createMachine({ + initial: 'saving', + context: { value: 'Acme', log: [] }, + states: { + saving: { + invoke: { + src: 'save', + onError: { + target: 'editing', + actions: { + type: 'logError', + params: (ctx, event) => ({ message: `${ctx.value}: ${String(event.error)}` }), + }, + }, + }, + }, + editing: {}, + }, + }), + ).start(); + + await tick(); + + expect(actor.getSnapshot().context.log).toEqual(['Acme: Error: offline']); + }); + + it('resolves names from the implementations of a teleported machine', () => { + const actor = mockActor(machine.provide({ guards: { isSaveable: () => false } }), { + value: 'editing', + context: { value: 'Acme' }, + }); + + actor.send({ type: 'SAVE' }); + + expect(actor.getSnapshot().value).toBe('editing'); + }); +}); diff --git a/packages/mosaic/src/machine/__tests__/machine.test.ts b/packages/mosaic/src/machine/__tests__/machine.test.ts index 97fa5524a9d..0a6a435eb49 100644 --- a/packages/mosaic/src/machine/__tests__/machine.test.ts +++ b/packages/mosaic/src/machine/__tests__/machine.test.ts @@ -609,7 +609,7 @@ describe('pure helpers (testable without an actor)', () => { expect(isAssignAction(action)).toBe(true); // The updater itself is pure and unit-testable in isolation. expect( - action.assignment({ name: 'Acme Inc', confirmValue: '', error: null }, { type: 'TYPE', value: 'hi' }), + action.assignment({ name: 'Acme Inc', confirmValue: '', error: null }, { type: 'TYPE', value: 'hi' }, undefined), ).toEqual({ confirmValue: 'hi' }); }); diff --git a/packages/mosaic/src/machine/__tests__/use-machine.test.tsx b/packages/mosaic/src/machine/__tests__/use-machine.test.tsx index 6052f991b99..872e8a9067f 100644 --- a/packages/mosaic/src/machine/__tests__/use-machine.test.tsx +++ b/packages/mosaic/src/machine/__tests__/use-machine.test.tsx @@ -6,6 +6,7 @@ import { deferred } from '../../__tests__/async'; import { assign } from '../assign'; import { createActor, mockActor } from '../create-actor'; import { createMachine } from '../create-machine'; +import { setup } from '../setup'; import { useActor, useMachine, useSelector } from '../use-machine'; import { createDeleteOrgMachine } from './delete-organization-machine'; @@ -325,3 +326,67 @@ describe('useMachine — live context keeps injected functions current', () => { expect(staleFn).not.toHaveBeenCalled(); }); }); + +describe('useMachine — provided implementations stay current', () => { + type Ctx = Record; + type Ev = { type: 'GO' }; + const { createMachine: createRunnerMachine } = setup().extend({ + actors: { run: () => Promise.resolve() }, + }); + const runnerMachine = createRunnerMachine({ + initial: 'idle', + context: {}, + states: { + idle: { on: { GO: 'running' } }, + running: { invoke: { src: 'run', onDone: 'done', onError: 'done' } }, + done: { type: 'final' }, + }, + }); + + it('invokes the implementation provided on the latest render', async () => { + const gate = deferred(); + const staleFn = vi.fn(() => gate.promise); + const freshFn = vi.fn(() => gate.promise); + const actors: unknown[] = []; + + function Runner({ run }: { run: () => Promise }) { + const [snapshot, send, actor] = useMachine(runnerMachine.provide({ actors: { run } })); + actors.push(actor); + return ( +
+ {snapshot.value} + +
+ ); + } + + const { rerender } = render(); + rerender(); + + fireEvent.click(screen.getByText('Go')); + expect(screen.getByTestId('state')).toHaveTextContent('running'); + + await act(async () => gate.resolve()); + expect(screen.getByTestId('state')).toHaveTextContent('done'); + + expect(freshFn).toHaveBeenCalledTimes(1); + expect(staleFn).not.toHaveBeenCalled(); + expect(new Set(actors).size).toBe(1); + }); + + it('does not re-render when only the provided implementations change', () => { + let renders = 0; + + function Runner({ run }: { run: () => Promise }) { + renders++; + useMachine(runnerMachine.provide({ actors: { run } })); + return null; + } + + const { rerender } = render( Promise.resolve()} />); + const afterMount = renders; + rerender( Promise.resolve()} />); + + expect(renders).toBe(afterMount + 1); + }); +}); diff --git a/packages/mosaic/src/machine/__tests__/wizard-migration.test.tsx b/packages/mosaic/src/machine/__tests__/wizard-migration.test.tsx index 0e354b54479..bcd07177697 100644 --- a/packages/mosaic/src/machine/__tests__/wizard-migration.test.tsx +++ b/packages/mosaic/src/machine/__tests__/wizard-migration.test.tsx @@ -159,7 +159,7 @@ const deriveStepper = (machine: StateMachine, curren isCompleted: currentIndex >= 0 && i < currentIndex, // Same predicate the reducer's `guardHolds` / the stepper's `isReachable` // bind to. The guards are nullary closures, so the args are inert. - isReachable: guard ? guard({ direction: 0, hasNavigated: false }, { type: 'NEXT' }) : true, + isReachable: typeof guard === 'function' ? guard({ direction: 0, hasNavigated: false }, { type: 'NEXT' }) : true, }; }); }; diff --git a/packages/mosaic/src/machine/assign.ts b/packages/mosaic/src/machine/assign.ts index a59754212ba..0c8a1002be3 100644 --- a/packages/mosaic/src/machine/assign.ts +++ b/packages/mosaic/src/machine/assign.ts @@ -12,15 +12,15 @@ import { ASSIGN } from './types'; * The updater is a pure `(context, event) => Partial` function, so it * can be unit-tested on its own without an actor. */ -export function assign( - assignment: (context: TContext, event: TEvent) => Partial, -): AssignAction { +export function assign( + assignment: (context: TContext, event: TEvent, params: TParams) => Partial, +): AssignAction { return { type: ASSIGN, assignment }; } /** Type guard distinguishing an `assign` action from a plain side-effect action. */ -export function isAssignAction( +export function isAssignAction( action: unknown, -): action is AssignAction { +): action is AssignAction { return typeof action === 'object' && action !== null && (action as { type?: unknown }).type === ASSIGN; } diff --git a/packages/mosaic/src/machine/create-actor.ts b/packages/mosaic/src/machine/create-actor.ts index e9b9a5c42ff..e13767eaca3 100644 --- a/packages/mosaic/src/machine/create-actor.ts +++ b/packages/mosaic/src/machine/create-actor.ts @@ -1,12 +1,18 @@ import { isAssignAction } from './assign'; import type { + Action, Actions, Actor, + ActorSrc, AfterEvent, AnyEventObject, + AnyImplementationRefs, AssignAction, CreateActorOptions, EventObject, + Guard, + GuardRef, + MachineImplementations, Snapshot, SnapshotListener, StateConfig, @@ -36,6 +42,16 @@ const toArr = (v: T | T[] | undefined): T[] => (!v ? [] : Array.isArray(v) ? * actor.send({ type: 'TOGGLE' }); * ``` */ +interface RuntimeImplementations { + guards: MachineImplementations['guards']; + actions: Partial>>; + actors: MachineImplementations['actors']; +} + +function isParamsResolver(params: unknown): params is (context: TContext, event: EventObject) => unknown { + return typeof params === 'function'; +} + export function createActor( machine: StateMachine, options: CreateActorOptions = {}, @@ -45,7 +61,59 @@ export function createActor // Internally the actor operates on the broader `EventObject`: invoke done/error // events aren't part of the user's `TEvent` union, so the config is viewed // through an event-agnostic lens to keep the runtime helpers honestly typed. - const states = machine.states as unknown as Record>; + const states = machine.states as unknown as Record< + string, + StateConfig + >; + + function implementations(): RuntimeImplementations { + // SAFETY: same event-agnostic lens as `states` above, applied to the swappable implementations. + // Action params are `unknown` here because each one comes from the ref naming that action, + // which `ParameterizedActionRef` types against the same implementation. + return actor.logic.implementations as unknown as RuntimeImplementations; + } + + function resolveGuard(guard: GuardRef): Guard { + if (typeof guard !== 'string') { + return guard; + } + const resolved = implementations().guards[guard]; + if (resolved === undefined) { + throw new Error(`Guard '${guard}' is not implemented.`); + } + return resolved; + } + + function namedAction(name: string): Action { + const resolved = implementations().actions[name]; + if (resolved === undefined) { + throw new Error(`Action '${name}' is not implemented.`); + } + return resolved; + } + + function runAction( + action: Action, + event: EventObject, + params: TParams, + ): void { + if (isAssignAction(action)) { + context = { ...context, ...action.assignment(context, event, params) }; + } else { + action(context, event, params); + } + } + + function resolveSrc(src: ActorSrc | string): ActorSrc { + if (typeof src !== 'string') { + return src; + } + const resolved = implementations().actors[src]; + if (resolved === undefined) { + throw new Error(`Actor '${src}' is not implemented.`); + } + return resolved; + } // Tracks the latest setContext patch so it survives a stop/start cycle. let liveContextPatch: Partial = options.context ?? {}; @@ -80,7 +148,10 @@ export function createActor * - `TransitionConfig[]` → as-is * - `TransitionFn` → called immediately; `undefined` return → `[]` (unhandled) */ - function normalizeTransition(raw: unknown, event: EventObject): TransitionConfig[] { + function normalizeTransition( + raw: unknown, + event: EventObject, + ): TransitionConfig[] { if (typeof raw === 'function') { // SAFETY: raw is a TransitionFn — (args: {context, event}) => TransitionResult | undefined. // The cast is required because the generic TStates parameter is erased at this internal @@ -89,7 +160,7 @@ export function createActor if (result === undefined) { return []; } - const cfg: TransitionConfig = { target: result.target }; + const cfg: TransitionConfig = { target: result.target }; if (result.context !== undefined) { const patch = result.context; // SAFETY: Constructing AssignAction inline avoids importing the assign() helper here. @@ -99,31 +170,38 @@ export function createActor return [cfg]; } return toArr(raw as any).map(e => - typeof e === 'string' ? { target: e } : (e as TransitionConfig), + typeof e === 'string' + ? { target: e } + : (e as TransitionConfig), ); } - function runActions(actions: Actions | undefined, event: EventObject): void { - for (const action of toArr(actions)) { - if (isAssignAction(action)) { - context = { ...context, ...action.assignment(context, event) }; + function runActions( + actions: Actions | undefined, + event: EventObject, + ): void { + for (const ref of toArr(actions)) { + if (typeof ref === 'function' || isAssignAction(ref)) { + runAction(ref, event, undefined); + } else if (typeof ref === 'string') { + runAction(namedAction(ref), event, undefined); } else { - action(context, event); + runAction(namedAction(ref.type), event, isParamsResolver(ref.params) ? ref.params(context, event) : ref.params); } } } function pickTransition( - transitions: TransitionConfig[], + transitions: TransitionConfig[], event: EventObject, - ): TransitionConfig | undefined { - return transitions.find(transition => !transition.guard || transition.guard(context, event)); + ): TransitionConfig | undefined { + return transitions.find(transition => !transition.guard || resolveGuard(transition.guard)(context, event)); } /** Whether a target state's entry guard currently permits landing on it. */ function canEnter(stateId: string, event: EventObject): boolean { const guard = states[stateId]?.guard; - return !guard || guard(context, event); + return !guard || resolveGuard(guard)(context, event); } /** @@ -131,7 +209,10 @@ export function createActor * Returns `false` — a true no-op — when the target's entry guard blocks it, so * the caller skips the commit and subscribers are never notified. */ - function takeTransition(transition: TransitionConfig, event: EventObject): boolean { + function takeTransition( + transition: TransitionConfig, + event: EventObject, + ): boolean { const external = transition.target !== undefined; if (external && !canEnter(transition.target as string, event)) { return false; // entry guard blocks landing → snapshot unchanged, no notify @@ -154,12 +235,8 @@ export function createActor if (!invoke) { return; } + const src = resolveSrc(invoke.src); const token = ++invocationToken; - // 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. const onDone = (output: unknown) => { if (status !== 'active' || token !== invocationToken) { return; @@ -187,7 +264,7 @@ export function createActor } }; try { - Promise.resolve(invoke.src(context, event as never)).then(onDone, onError); + Promise.resolve(src(context, event)).then(onDone, onError); } catch (error) { queueMicrotask(() => onError(error)); } @@ -255,6 +332,8 @@ export function createActor } const actor: Actor = { + logic: machine, + start() { if (started) { return actor; diff --git a/packages/mosaic/src/machine/create-machine.ts b/packages/mosaic/src/machine/create-machine.ts index ae472bbd4bd..93dc3c4ca84 100644 --- a/packages/mosaic/src/machine/create-machine.ts +++ b/packages/mosaic/src/machine/create-machine.ts @@ -1,4 +1,11 @@ -import type { EventObject, MachineConfig, StateMachine } from './types'; +import type { + EventObject, + ImplementationRefs, + MachineConfig, + MachineImplementations, + NoImplementationRefs, + StateMachine, +} from './types'; /** * Create a state-machine definition from a config object. @@ -24,7 +31,11 @@ export function createMachine< TContext extends object = Record, TEvent extends EventObject = EventObject, TStates extends string = string, ->(config: MachineConfig): StateMachine { + TRefs extends ImplementationRefs = NoImplementationRefs, +>( + config: MachineConfig, + implementations: MachineImplementations = { guards: {}, actions: {}, actors: {} }, +): StateMachine { return { id: config.id, initial: config.initial, @@ -34,5 +45,12 @@ export function createMachine< context: config.context ?? ({} as TContext), states: config.states, config, + implementations, + provide: provided => + createMachine(config, { + guards: { ...implementations.guards, ...provided.guards }, + actions: { ...implementations.actions, ...provided.actions }, + actors: { ...implementations.actors, ...provided.actors }, + }), }; } diff --git a/packages/mosaic/src/machine/setup.ts b/packages/mosaic/src/machine/setup.ts index 201f2a67b72..b1c8c2f29ad 100644 --- a/packages/mosaic/src/machine/setup.ts +++ b/packages/mosaic/src/machine/setup.ts @@ -1,16 +1,58 @@ import { assign as _assign } from './assign'; import { createMachine as _createMachine } from './create-machine'; import type { + Action, + ActorSrc, AssignAction, DoneInvokeEvent, ErrorInvokeEvent, EventObject, + Guard, + ImplementationRefs, InvokeConfig, MachineConfig, + MachineImplementations, + NoImplementationRefs, StateMachine, Transition, } from './types'; +/** The refs of a setup after `extend` adds more named implementations. */ +type ExtendedRefs = { + guards: TRefs['guards'] | (keyof TGuards & string); + actions: TRefs['actions'] | (keyof TActions & string); + actors: TRefs['actors'] | (keyof TActors & string); + actionParams: Omit & TActions; + actorOutputs: Omit & TActors; +}; + +export interface Setup { + createMachine: (config: MachineConfig) => StateMachine; + assign: ( + fn: (context: TContext, event: TEvt, params: TParams) => Partial, + ) => AssignAction; + fromPromise: ( + fn: (context: TContext) => Promise, + config?: { + onDone?: Transition, TStates, TRefs>; + onError?: Transition; + }, + ) => InvokeConfig; + /** + * Register default implementations that the config can reference by name. + * Mirrors XState v5's `setup(...).extend(...)`. + */ + extend: < + TGuards extends Record = Record, + TActions extends Record = Record, + TActors extends Record = Record, + >(implementations: { + guards?: { [K in keyof TGuards]: Guard }; + actions?: { [K in keyof TActions]: Action }; + actors?: { [K in keyof TActors]: ActorSrc }; + }) => Setup>; +} + /** * Pre-bind `TContext` and `TEvent` once per machine file, returning factory * functions that don't require repeating those types at every call site. @@ -38,14 +80,21 @@ import type { * `onError`, or `after` — eliminating the need to write * `assign>` by hand. */ -export function setup() { +export function setup(): Setup< + TContext, + TEvent, + NoImplementationRefs +> { + return withImplementations({ guards: {}, actions: {}, actors: {} }); +} + +function withImplementations( + defaults: MachineImplementations, +): Setup { return { - createMachine: (config: MachineConfig): StateMachine => - _createMachine(config), + createMachine: config => _createMachine(config, defaults), - assign: ( - fn: (context: TContext, event: TEvt) => Partial, - ): AssignAction => _assign(fn), + assign: fn => _assign(fn), /** * Wraps an async function so its resolved type flows into `onDone.actions`. @@ -71,15 +120,22 @@ export function setup() { fromPromise: ( fn: (context: TContext) => Promise, config?: { - onDone?: Transition, TStates>; - onError?: Transition; + onDone?: Transition, TStates, TRefs>; + onError?: Transition; }, - ): InvokeConfig => ({ + ): InvokeConfig => ({ // SAFETY: fn only uses context (no event param), but InvokeConfig.src accepts // (context, event) for parity with state-entry event access. The extra event // parameter is unused; callers receive only context at runtime. - src: fn as unknown as InvokeConfig['src'], + src: fn as unknown as InvokeConfig['src'], ...config, }), + + extend: implementations => + withImplementations({ + guards: { ...defaults.guards, ...implementations.guards }, + actions: { ...defaults.actions, ...implementations.actions }, + actors: { ...defaults.actors, ...implementations.actors }, + }), }; } diff --git a/packages/mosaic/src/machine/types.ts b/packages/mosaic/src/machine/types.ts index 82f00c0e076..2b190e66903 100644 --- a/packages/mosaic/src/machine/types.ts +++ b/packages/mosaic/src/machine/types.ts @@ -20,7 +20,11 @@ export interface AnyEventObject extends EventObject { export type Guard = (context: TContext, event: TEvent) => boolean; /** A side-effecting action — runs for its effect, returns nothing. */ -export type ActionFunction = (context: TContext, event: TEvent) => void; +export type ActionFunction = ( + context: TContext, + event: TEvent, + params: TParams, +) => void; /** Internal tag identifying an {@link assign} action. A symbol so it can never collide with a user value. */ export const ASSIGN = Symbol('assign'); @@ -29,26 +33,85 @@ export const ASSIGN = Symbol('assign'); * The object produced by {@link assign}. Tagged so the runtime can tell a * context-updating action apart from a plain side-effect action. */ -export interface AssignAction { +export interface AssignAction { type: typeof ASSIGN; - assignment: (context: TContext, event: TEvent) => Partial; + assignment: (context: TContext, event: TEvent, params: TParams) => Partial; } /** Either a side-effect or an `assign` context update. */ -export type Action = - | ActionFunction - | AssignAction; +export type Action = + | ActionFunction + | AssignAction; -export type Actions = Action | Action[]; +/** + * The names a machine's config may reference, keyed by implementation kind. + * `actorOutputs` maps each actor name to the value its promise resolves with; + * only {@link StateMachine.provide} reads it. + */ +export interface ImplementationRefs { + guards: string; + actions: string; + actors: string; + actionParams: Record; + actorOutputs: Record; +} + +/** No named implementations: only inline guards, actions and `src` functions are accepted. */ +export interface NoImplementationRefs { + guards: never; + actions: never; + actors: never; + actionParams: Record; + actorOutputs: Record; +} + +/** Any named implementations. The default for {@link StateMachine}, so every machine is assignable to it. */ +export interface AnyImplementationRefs { + guards: string; + actions: string; + actors: string; + actionParams: Record; + actorOutputs: Record; +} + +export type GuardRef = + | Guard + | TRefs['guards']; + +/** A named action with the params its implementation receives. Mirrors XState v5's `{ type, params }`. */ +export type ParameterizedActionRef = { + [K in TRefs['actions']]: { + type: K; + params: TRefs['actionParams'][K] | ((context: TContext, event: TEvent) => TRefs['actionParams'][K]); + }; +}[TRefs['actions']]; + +type ParamlessActionName = { + [K in TRefs['actions']]: undefined extends TRefs['actionParams'][K] ? K : never; +}[TRefs['actions']]; + +export type ActionRef = + | Action + | ParamlessActionName + | ParameterizedActionRef; + +export type Actions = + | ActionRef + | ActionRef[]; /** The long form of a transition. */ -export interface TransitionConfig { +export interface TransitionConfig< + TContext, + TEvent extends EventObject, + TStates extends string = string, + TRefs extends ImplementationRefs = NoImplementationRefs, +> { /** State to enter. Omit for an internal transition (runs actions, stays put). */ target?: TStates; /** Actions to run during the transition, in order. */ - actions?: Actions; + actions?: Actions; /** Only take this transition when the guard passes. */ - guard?: Guard; + guard?: GuardRef; } /** What an inline transition function returns. `undefined` means the event is unhandled. */ @@ -73,10 +136,15 @@ export type TransitionFn = +export type Transition< + TContext, + TEvent extends EventObject, + TStates extends string = string, + TRefs extends ImplementationRefs = NoImplementationRefs, +> = | TStates - | TransitionConfig - | TransitionConfig[] + | TransitionConfig + | TransitionConfig[] | TransitionFn; /** The event type fired when an invoked promise resolves. */ @@ -108,20 +176,35 @@ export interface AfterEvent extends EventObject { delay: number; } +/** A promise-returning function started when a state is entered. */ +export type ActorSrc = ( + context: TContext, + event: TEvent | DoneInvokeEvent | ErrorInvokeEvent, +) => Promise; + /** Invoke a promise on state entry and branch on its settlement. */ export interface InvokeConfig< TContext, TEvent extends EventObject, TOutput = unknown, TStates extends string = string, + TRefs extends ImplementationRefs = NoImplementationRefs, > { - /** Started on entry. The resolved value lands on `onDone` events as `output`. */ - src: (context: TContext, event: TEvent | DoneInvokeEvent | ErrorInvokeEvent) => Promise; - onDone?: Transition, TStates>; - onError?: Transition; + /** + * Started on entry. The resolved value lands on `onDone` events as `output`. + * A name resolves through the machine's `actors` implementations. + */ + src: ActorSrc | TRefs['actors']; + onDone?: Transition, TStates, TRefs>; + onError?: Transition; } -export interface StateConfig { +export interface StateConfig< + TContext, + TEvent extends EventObject, + TStates extends string = string, + TRefs extends ImplementationRefs = NoImplementationRefs, +> { /** * Entry precondition — "may navigation LAND on this state right now?". Checked * uniformly by *every* transition (and the derived initial) that targets this @@ -130,16 +213,16 @@ export interface StateConfig; + guard?: GuardRef; /** * Event-name → transition map. Each key is constrained to `TEvent['type']` * and the transition's guards/actions receive the narrowed event member — * e.g. a guard under `on['SUBMIT']` sees `Extract`, * not the full union. */ - on?: { [K in TEvent['type']]?: Transition, TStates> }; + on?: { [K in TEvent['type']]?: Transition, TStates, TRefs> }; /** Eventless / immediate transitions, evaluated on entry and on {@link Actor.recheck}. */ - always?: Transition; + always?: Transition; /** * Delayed transitions — each key is a delay in milliseconds. The matching * transition fires automatically after the delay unless the state is exited @@ -153,17 +236,17 @@ export interface StateConfig }; + after?: { [delay: number]: Transition }; /** * A promise to invoke on entry. Use {@link PromiseSrc} (created by * `setup().fromPromise`) to carry the resolved type to `onDone.actions`. * A raw `src` function is also accepted — `e.output` is `any` in that case. */ - invoke?: InvokeConfig; + invoke?: InvokeConfig; /** Actions run when the state is entered. */ - entry?: Actions; + entry?: Actions; /** Actions run when the state is exited. */ - exit?: Actions; + exit?: Actions; /** A terminal state — no further events are processed once reached. */ type?: 'final'; } @@ -174,11 +257,42 @@ export interface StateConfig = (context: TContext) => TStates; -export interface MachineConfig { +export interface MachineConfig< + TContext, + TEvent extends EventObject, + TStates extends string = string, + TRefs extends ImplementationRefs = NoImplementationRefs, +> { id?: string; initial: TStates | InitialResolver; context?: TContext; - states: Record>; + states: Record>; +} + +/** + * The implementations a machine resolves named references against at + * evaluation time. Every kind is partial: a missing name throws when used. + */ +export interface MachineImplementations { + guards: Partial>>; + actions: Partial>>; + actors: Partial>>; +} + +/** An empty name set is `{}`, which skips excess-property checks; reject every key instead. */ +type KnownKeysOnly = [TKeys] extends [never] ? Partial> : TMap; + +/** What {@link StateMachine.provide} accepts: any subset of the existing names, with matching signatures. */ +export interface ProvidedImplementations { + guards?: KnownKeysOnly }>; + actions?: KnownKeysOnly< + TRefs['actions'], + { [K in TRefs['actions']]?: Action } + >; + actors?: KnownKeysOnly< + TRefs['actors'], + { [K in TRefs['actors']]?: ActorSrc } + >; } /** @@ -186,12 +300,19 @@ export interface MachineConfig { +export interface StateMachine< + TContext, + TEvent extends EventObject, + TRefs extends ImplementationRefs = AnyImplementationRefs, +> { id: string | undefined; initial: string | InitialResolver; context: TContext; - states: Record>; - config: MachineConfig; + states: Record>; + config: MachineConfig; + implementations: MachineImplementations; + /** A new machine with the same config and these implementations merged over the current ones. */ + provide(implementations: ProvidedImplementations): StateMachine; } export type ActorStatus = 'active' | 'done' | 'stopped'; @@ -210,6 +331,8 @@ export type SnapshotListener = (snapshot: Snapshot) => void; export type Unsubscribe = () => void; export interface Actor { + /** The machine this actor runs. Named guards, actions and `src` resolve through its `implementations` when used. */ + logic: StateMachine; /** Run entry actions / immediate transitions / invokes of the initial state. */ start: () => Actor; /** Stop the actor and abandon any in-flight invoke. */ diff --git a/packages/mosaic/src/machine/use-machine.ts b/packages/mosaic/src/machine/use-machine.ts index 2a52ad61a3a..82d88f3d8c2 100644 --- a/packages/mosaic/src/machine/use-machine.ts +++ b/packages/mosaic/src/machine/use-machine.ts @@ -56,6 +56,7 @@ export function useMachine( if (options?.context) { actor.setContext(options.context); } + actor.logic.implementations = machine.implementations; }); const snapshot = useSyncExternalStore(actor.subscribe, actor.getSnapshot, actor.getSnapshot); From 3eb10aa62841373d116916ca27f4211f92ead409 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 5 Oct 2026 19:17:21 -0400 Subject: [PATCH 03/12] refactor(mosaic): provide the edit field callbacks as implementations --- ...anization-profile-edit-field.controller.ts | 50 +++++++++++-------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-edit-field.controller.ts b/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-edit-field.controller.ts index dca1ceb55b2..0704d47bc6a 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-edit-field.controller.ts +++ b/packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-edit-field.controller.ts @@ -4,8 +4,6 @@ import type { OrganizationProfileFormError } from '../organization-profile.types import { OrganizationProfileSaveError } from '../organization-profile.types'; export interface OrganizationProfileEditFieldContext { - save: (value: string) => Promise; - savedValue: string; value: string; error: OrganizationProfileFormError | undefined; } @@ -16,19 +14,27 @@ export type OrganizationProfileEditFieldEvent = | { type: 'SAVE' } | { type: 'CANCEL' }; -const { createMachine, assign, fromPromise } = setup< - OrganizationProfileEditFieldContext, - OrganizationProfileEditFieldEvent ->(); +const base = setup(); +const { assign } = base; -function notSeated(): Promise { +function notSeated(): Promise { return Promise.reject(new Error('edit-field deps are not seated')); } -function isSaveable(context: OrganizationProfileEditFieldContext): boolean { - return context.value !== context.savedValue && context.value !== ''; +function isSaveable(value: string, savedValue: string): boolean { + return value !== savedValue && value !== ''; } +function openEditor(savedValue: string) { + return assign(() => ({ value: savedValue, error: undefined })); +} + +const { createMachine } = base.extend({ + guards: { isSaveable: context => isSaveable(context.value, '') }, + actions: { openEditor: openEditor('') }, + actors: { save: notSeated }, +}); + function toFormError(cause: unknown): OrganizationProfileFormError { if (cause instanceof OrganizationProfileSaveError) { return { message: cause.message, field: cause.field }; @@ -43,35 +49,31 @@ export const organizationProfileEditFieldMachine = createMachine({ id: 'editOrganizationField', initial: 'idle', context: { - save: notSeated, - savedValue: '', value: '', error: undefined, }, states: { idle: { on: { - OPEN: { - target: 'editing', - actions: assign(context => ({ value: context.savedValue, error: undefined })), - }, + OPEN: { target: 'editing', actions: 'openEditor' }, }, }, editing: { on: { TYPE: { actions: assign((_, event) => ({ value: event.value })) }, - SAVE: { target: 'saving', guard: isSaveable }, + SAVE: { target: 'saving', guard: 'isSaveable' }, CANCEL: { target: 'idle', actions: assign(() => ({ error: undefined })) }, }, }, saving: { - invoke: fromPromise(context => context.save(context.value), { + invoke: { + src: 'save', onDone: { target: 'idle', actions: assign(() => ({ error: undefined })) }, onError: { target: 'editing', actions: assign((_, event) => ({ error: toFormError(event.error) })), }, - }), + }, }, }, }); @@ -96,9 +98,13 @@ export function useOrganizationProfileEditFieldController({ value = '', onSubmit, }: OrganizationProfileEditFieldControllerOptions): OrganizationProfileEditFieldController { - const [snapshot, send] = useMachine(organizationProfileEditFieldMachine, { - context: { save: onSubmit, savedValue: value }, - }); + const [snapshot, send] = useMachine( + organizationProfileEditFieldMachine.provide({ + guards: { isSaveable: context => isSaveable(context.value, value) }, + actions: { openEditor: openEditor(value) }, + actors: { save: context => onSubmit(context.value) }, + }), + ); return { isOpen: snapshot.value === 'editing' || snapshot.value === 'saving', @@ -106,7 +112,7 @@ export function useOrganizationProfileEditFieldController({ value: snapshot.context.value, onValueChange: next => send({ type: 'TYPE', value: next }), onSubmit: () => send({ type: 'SAVE' }), - canSave: isSaveable(snapshot.context), + canSave: isSaveable(snapshot.context.value, value), isSaving: snapshot.value === 'saving', error: snapshot.context.error, }; From 50b7366b30023138788ead047cbc7a0478f7260b Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 5 Oct 2026 19:18:03 -0400 Subject: [PATCH 04/12] refactor(mosaic): provide the rename passkey callbacks as implementations --- .../user-profile-rename-passkey.controller.ts | 49 +++++++++++-------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.controller.ts index 5aa10686cc9..09cdf80154a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-rename-passkey.controller.ts @@ -3,10 +3,7 @@ import { useMachine } from '../../machine/use-machine'; import { userProfilePasskeysMessages as m } from './user-profile-passkeys-section.messages'; interface UserProfileRenamePasskeyContext { - passkeyId: string; - savedName: string; name: string; - onRename: ((id: string, name: string) => void | Promise) | undefined; error: string | undefined; } @@ -16,46 +13,54 @@ type UserProfileRenamePasskeyEvent = | { type: 'SAVE' } | { type: 'CANCEL' }; -const { createMachine, assign, fromPromise } = setup(); +type RenameHandler = (id: string, name: string) => void | Promise; -function isSaveable(context: UserProfileRenamePasskeyContext): boolean { - return Boolean(context.onRename) && context.name.length > 1 && context.name !== context.savedName; +const base = setup(); +const { assign } = base; + +function isSaveable(name: string, savedName: string, onRename: RenameHandler | undefined): boolean { + return Boolean(onRename) && name.length > 1 && name !== savedName; +} + +function openEditor(savedName: string) { + return assign(() => ({ name: savedName, error: undefined })); } +const { createMachine } = base.extend({ + guards: { isSaveable: () => false }, + actions: { openEditor: openEditor('') }, + actors: { rename: (): Promise => Promise.resolve() }, +}); + const userProfileRenamePasskeyMachine = createMachine({ id: 'renamePasskey', initial: 'idle', context: { - passkeyId: '', - savedName: '', name: '', - onRename: undefined, error: undefined, }, states: { idle: { on: { - OPEN: { - target: 'editing', - actions: assign(context => ({ name: context.savedName, error: undefined })), - }, + OPEN: { target: 'editing', actions: 'openEditor' }, }, }, editing: { on: { TYPE: { actions: assign((_, event) => ({ name: event.value })) }, - SAVE: { target: 'saving', guard: isSaveable, actions: assign(() => ({ error: undefined })) }, + SAVE: { target: 'saving', guard: 'isSaveable', actions: assign(() => ({ error: undefined })) }, CANCEL: { target: 'idle', actions: assign(() => ({ error: undefined })) }, }, }, saving: { - invoke: fromPromise(async context => context.onRename?.(context.passkeyId, context.name), { + invoke: { + src: 'rename', onDone: { target: 'idle' }, onError: { target: 'editing', actions: assign((_, event) => ({ error: event.error instanceof Error ? event.error.message : m.saveError })), }, - }), + }, }, }, }); @@ -82,9 +87,13 @@ export function useUserProfileRenamePasskeyController({ name, onRename, }: UserProfileRenamePasskeyControllerOptions): UserProfileRenamePasskeyController { - const [snapshot, send] = useMachine(userProfileRenamePasskeyMachine, { - context: { passkeyId: id, savedName: name, onRename }, - }); + const [snapshot, send] = useMachine( + userProfileRenamePasskeyMachine.provide({ + guards: { isSaveable: context => isSaveable(context.name, name, onRename) }, + actions: { openEditor: openEditor(name) }, + actors: { rename: async context => onRename?.(id, context.name) }, + }), + ); return { isOpen: snapshot.value === 'editing' || snapshot.value === 'saving', @@ -92,7 +101,7 @@ export function useUserProfileRenamePasskeyController({ name: snapshot.context.name, onNameChange: value => send({ type: 'TYPE', value }), onSubmit: () => send({ type: 'SAVE' }), - canSave: isSaveable(snapshot.context), + canSave: isSaveable(snapshot.context.name, name, onRename), isSaving: snapshot.value === 'saving', error: snapshot.context.error, }; From 83a3c1c9ab846eb8f3b03a493e06f414bf92a378 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 5 Oct 2026 19:18:46 -0400 Subject: [PATCH 05/12] refactor(mosaic): provide add email and add phone callbacks as implementations --- .../user-profile-add-email.controller.ts | 55 +++++++++++++------ .../user-profile-add-phone.controller.ts | 55 +++++++++++++------ 2 files changed, 74 insertions(+), 36 deletions(-) diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.controller.ts index 511dab48de7..2f0abcde54a 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-email.controller.ts @@ -11,7 +11,7 @@ export interface UserProfileAddEmailControllerOptions { onVerify: (emailAddress: string, code: string) => Promise; } -interface Context extends UserProfileAddEmailControllerOptions { +interface Context { emailAddress: string; code: string; error: unknown; @@ -27,9 +27,10 @@ type Event = | { type: 'TYPE_CODE'; value: string } | { type: 'SUBMIT'; code?: string }; -const { createMachine, assign, fromPromise } = setup(); +const base = setup(); +const { assign } = base; -function missingDependency(): Promise { +function missingDependency(): Promise { return Promise.reject(new Error('Add email callbacks are missing')); } @@ -40,14 +41,26 @@ function errorMessage(cause: unknown, fallback: string): string | undefined { return cause instanceof Error ? cause.message : fallback; } +function openDialog(initialEmailAddress: string | undefined) { + return assign(() => ({ + emailAddress: initialEmailAddress ?? '', + code: '', + error: undefined, + resendSeconds: 0, + })); +} + +const { createMachine } = base.extend({ + actions: { open: openDialog(undefined) }, + actors: { send: missingDependency, verify: missingDependency }, +}); + const tick = { actions: assign(context => ({ resendSeconds: Math.max(0, context.resendSeconds - 1) })) }; const machine = createMachine({ id: 'addEmail', initial: 'idle', context: { - onSend: missingDependency, - onVerify: missingDependency, emailAddress: '', code: '', error: undefined, @@ -58,12 +71,7 @@ const machine = createMachine({ on: { OPEN: { target: 'email', - actions: assign(context => ({ - emailAddress: context.initialEmailAddress ?? '', - code: '', - error: undefined, - resendSeconds: 0, - })), + actions: 'open', }, }, }, @@ -75,13 +83,14 @@ const machine = createMachine({ }, }, sending: { - invoke: fromPromise(context => context.onSend(context.emailAddress), { + invoke: { + src: 'send', onDone: { target: 'verify', actions: assign(() => ({ code: '', resendSeconds: 12 })) }, onError: { target: 'email', actions: assign((_, event) => ({ error: event.error })), }, - }), + }, }, verify: { on: { @@ -100,23 +109,25 @@ const machine = createMachine({ }, }, resending: { - invoke: fromPromise(context => context.onSend(context.emailAddress), { + invoke: { + src: 'send', onDone: { target: 'verify', actions: assign(() => ({ code: '', resendSeconds: 12 })) }, onError: { target: 'verify', actions: assign((_, event) => ({ error: event.error })), }, - }), + }, }, verifying: { on: { TICK: tick }, - invoke: fromPromise(context => context.onVerify(context.emailAddress, context.code), { + invoke: { + src: 'verify', onDone: 'idle', onError: { target: 'verify', actions: assign((_, event) => ({ error: event.error })), }, - }), + }, }, }, }); @@ -125,7 +136,15 @@ export function useUserProfileAddEmailController( options: UserProfileAddEmailControllerOptions, ): UserProfileAddEmailDialogProps { const m = useMessages('userProfileAddEmail'); - const [snapshot, send] = useMachine(machine, { context: options }); + const [snapshot, send] = useMachine( + machine.provide({ + actions: { open: openDialog(options.initialEmailAddress) }, + actors: { + send: context => options.onSend(context.emailAddress), + verify: context => options.onVerify(context.emailAddress, context.code), + }, + }), + ); const { resendSeconds } = snapshot.context; const open = snapshot.value !== 'idle'; useEffect(() => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.controller.ts b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.controller.ts index 425c7a90d97..750dc15568d 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.controller.ts +++ b/packages/mosaic/src/features/user-profile/user-profile-account-section/user-profile-add-phone.controller.ts @@ -11,7 +11,7 @@ export interface UserProfileAddPhoneControllerOptions { onVerify: (phoneNumber: string, code: string) => Promise; } -interface Context extends UserProfileAddPhoneControllerOptions { +interface Context { phoneNumber: string; code: string; error: unknown; @@ -27,9 +27,10 @@ type Event = | { type: 'TYPE_CODE'; value: string } | { type: 'SUBMIT'; code?: string }; -const { createMachine, assign, fromPromise } = setup(); +const base = setup(); +const { assign } = base; -function missingDependency(): Promise { +function missingDependency(): Promise { return Promise.reject(new Error('Add phone callbacks are missing')); } @@ -40,14 +41,26 @@ function errorMessage(cause: unknown, fallback: string): string | undefined { return cause instanceof Error ? cause.message : fallback; } +function openDialog(initialPhoneNumber: string | undefined) { + return assign(() => ({ + phoneNumber: initialPhoneNumber ?? '', + code: '', + error: undefined, + resendSeconds: 0, + })); +} + +const { createMachine } = base.extend({ + actions: { open: openDialog(undefined) }, + actors: { send: missingDependency, verify: missingDependency }, +}); + const tick = { actions: assign(context => ({ resendSeconds: Math.max(0, context.resendSeconds - 1) })) }; const machine = createMachine({ id: 'addPhone', initial: 'idle', context: { - onSend: missingDependency, - onVerify: missingDependency, phoneNumber: '', code: '', error: undefined, @@ -58,12 +71,7 @@ const machine = createMachine({ on: { OPEN: { target: 'phone', - actions: assign(context => ({ - phoneNumber: context.initialPhoneNumber ?? '', - code: '', - error: undefined, - resendSeconds: 0, - })), + actions: 'open', }, }, }, @@ -75,13 +83,14 @@ const machine = createMachine({ }, }, sending: { - invoke: fromPromise(context => context.onSend(context.phoneNumber), { + invoke: { + src: 'send', onDone: { target: 'verify', actions: assign(() => ({ code: '', resendSeconds: 12 })) }, onError: { target: 'phone', actions: assign((_, event) => ({ error: event.error })), }, - }), + }, }, verify: { on: { @@ -100,23 +109,25 @@ const machine = createMachine({ }, }, resending: { - invoke: fromPromise(context => context.onSend(context.phoneNumber), { + invoke: { + src: 'send', onDone: { target: 'verify', actions: assign(() => ({ code: '', resendSeconds: 12 })) }, onError: { target: 'verify', actions: assign((_, event) => ({ error: event.error })), }, - }), + }, }, verifying: { on: { TICK: tick }, - invoke: fromPromise(context => context.onVerify(context.phoneNumber, context.code), { + invoke: { + src: 'verify', onDone: 'idle', onError: { target: 'verify', actions: assign((_, event) => ({ error: event.error })), }, - }), + }, }, }, }); @@ -125,7 +136,15 @@ export function useUserProfileAddPhoneController( options: UserProfileAddPhoneControllerOptions, ): UserProfileAddPhoneDialogProps { const m = useMessages('userProfileAddPhone'); - const [snapshot, send] = useMachine(machine, { context: options }); + const [snapshot, send] = useMachine( + machine.provide({ + actions: { open: openDialog(options.initialPhoneNumber) }, + actors: { + send: context => options.onSend(context.phoneNumber), + verify: context => options.onVerify(context.phoneNumber, context.code), + }, + }), + ); const { resendSeconds } = snapshot.context; const open = snapshot.value !== 'idle'; useEffect(() => { From c807f9c98a87ab08b53b48291c6845363e1134cc Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 5 Oct 2026 19:19:28 -0400 Subject: [PATCH 06/12] refactor(mosaic): provide reverification callbacks from the last active model --- .../reverification.controller.test.ts | 83 +++++++++++++++++ .../reverification.controller.ts | 92 +++++++++++-------- 2 files changed, 138 insertions(+), 37 deletions(-) create mode 100644 packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.ts diff --git a/packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.ts b/packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.ts new file mode 100644 index 00000000000..444181cb8ec --- /dev/null +++ b/packages/mosaic/src/features/reverification/__tests__/reverification.controller.test.ts @@ -0,0 +1,83 @@ +import { act, renderHook } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { deferred, tick } from '../../../__tests__/async'; +import type { ReverificationController } from '../reverification.controller'; +import { useReverificationController } from '../reverification.controller'; +import type { ReverificationActiveModel, ReverificationModel } from '../reverification.model'; +import type { ReverificationMethod, ReverificationResult } from '../reverification.types'; + +const password: ReverificationMethod = { id: 'password', stage: 'first', strategy: 'password' }; + +function activeModel(overrides: Partial = {}): ReverificationActiveModel { + return { + status: 'active', + supportEmail: 'support@example.com', + start: () => Promise.resolve({ status: 'needs_first_factor', methods: [password], startingMethod: password }), + prepare: () => Promise.resolve(), + attempt: () => Promise.resolve({ status: 'complete', methods: [password], startingMethod: password }), + finish: () => Promise.resolve(), + cancel: () => {}, + ...overrides, + }; +} + +function ready(state: ReverificationController): Extract { + if (state.status !== 'ready') { + throw new Error('expected a ready reverification frame'); + } + return state; +} + +async function renderVerifying(model: ReverificationActiveModel) { + const initialProps: { model: ReverificationModel } = { model }; + const hook = renderHook(({ model }) => useReverificationController(model, () => {}), { initialProps }); + await act(async () => { + await tick(); + }); + return hook; +} + +describe('useReverificationController', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('finishes with the last active callbacks after the model drops back to loading mid-submit', async () => { + const attempt = deferred(); + const finish = vi.fn(() => Promise.resolve()); + const model = activeModel({ attempt: () => attempt.promise, finish }); + const { result, rerender } = await renderVerifying(model); + act(() => { + ready(result.current).onValueChange('hunter2'); + }); + act(() => { + ready(result.current).onSubmit(); + }); + + rerender({ model: { status: 'loading', cancel: () => {} } }); + await act(async () => { + attempt.resolve({ status: 'complete', methods: [password], startingMethod: password }); + await tick(); + }); + + expect(finish).toHaveBeenCalledTimes(1); + }); + + it('keeps the support email for the held frame after the model settles', async () => { + const assign = vi.fn(); + vi.stubGlobal('location', { ...window.location, assign }); + const { result, rerender } = await renderVerifying(activeModel()); + act(() => { + ready(result.current).onShowHelp(); + }); + + rerender({ model: { status: 'settled' } }); + await act(async () => { + await tick(); + }); + ready(result.current).onEmailSupport(); + + expect(assign).toHaveBeenCalledWith('mailto:support@example.com'); + }); +}); diff --git a/packages/mosaic/src/features/reverification/reverification.controller.ts b/packages/mosaic/src/features/reverification/reverification.controller.ts index 43ea26a788a..b8bc23f1e72 100644 --- a/packages/mosaic/src/features/reverification/reverification.controller.ts +++ b/packages/mosaic/src/features/reverification/reverification.controller.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import type { FlowDirection } from '../../components/flow'; import { useMessages } from '../../localization'; @@ -50,9 +50,7 @@ interface ReverificationContext { submitRequested: boolean; frozenActiveMethodId: string | undefined; overlayFrom: OverlayFrom; - supportEmail: string; heldFrom: string; - deps: ReverificationDeps; } type ReverificationEvent = @@ -67,18 +65,21 @@ type ReverificationEvent = | { type: 'BACK' } | { type: 'RESEND' }; -const { createMachine, assign, fromPromise } = setup(); +const base = setup(); +const { assign } = base; function notSeated(): Promise { return Promise.reject(new Error('reverification deps are not seated')); } -const unseatedDeps: ReverificationDeps = { - start: notSeated, - prepare: notSeated, - attempt: notSeated, - finish: notSeated, -}; +const { createMachine } = base.extend({ + actors: { + start: (): Promise => notSeated(), + prepare: (): Promise => notSeated(), + submit: (): Promise => notSeated(), + finish: (): Promise => notSeated(), + }, +}); export const RESEND_COOLDOWN_MS = 30_000; @@ -111,17 +112,17 @@ function selectMethod(ctx: ReverificationContext, id: string): Partial { const method = ctx.activeMethod; - return method && needsPrepare(method) ? ctx.deps.prepare(method) : Promise.resolve(); + return method && needsPrepare(method) ? deps.prepare(method) : Promise.resolve(); } -function submit(ctx: ReverificationContext): Promise { +function submit(ctx: ReverificationContext, deps: ReverificationDeps): Promise { const method = ctx.activeMethod; if (!method) { return Promise.reject(new Error('No active method')); } - return ctx.deps.attempt(method, ctx.inputValue); + return deps.attempt(method, ctx.inputValue); } const applyResult = assign>((_, event) => ({ @@ -194,9 +195,7 @@ export const reverificationMachine = createMachine({ submitRequested: false, frozenActiveMethodId: undefined, overlayFrom: 'factor', - supportEmail: '', heldFrom: '', - deps: unseatedDeps, }, states: { inactive: { @@ -211,10 +210,11 @@ export const reverificationMachine = createMachine({ starting: { on: { RESET: 'inactive', SETTLE: settleFrom('starting') }, - invoke: fromPromise(ctx => ctx.deps.start(), { + invoke: { + src: 'start', onDone: afterResult, onError: 'failed', - }), + }, }, /* @@ -231,7 +231,8 @@ export const reverificationMachine = createMachine({ actions: assign(() => ({ submitRequested: true })), }, }, - invoke: fromPromise(prepareActive, { + invoke: { + src: 'prepare', onDone: [ { guard: (ctx: ReverificationContext) => ctx.submitRequested, @@ -248,7 +249,7 @@ export const reverificationMachine = createMachine({ ...unlockResend(), })), }, - }), + }, }, /* @@ -260,13 +261,14 @@ export const reverificationMachine = createMachine({ methodPickerPreparing: { entry: assign(() => lockResend()), on: { RESET: 'inactive', SETTLE: settleFrom('methodPickerPreparing') }, - invoke: fromPromise(prepareActive, { + invoke: { + src: 'prepare', onDone: 'verifying', onError: { target: 'verifying', actions: assign((_, event) => ({ error: factorError(event.error), ...unlockResend() })), }, - }), + }, }, verifying: { @@ -292,13 +294,14 @@ export const reverificationMachine = createMachine({ submitting: { on: { RESET: 'inactive', SETTLE: settleFrom('submitting') }, - invoke: fromPromise(submit, { + invoke: { + src: 'submit', onDone: afterResult, onError: { target: 'verifying', actions: assign((_, event) => ({ error: factorError(event.error) })), }, - }), + }, }, methodPicker: { @@ -349,10 +352,11 @@ export const reverificationMachine = createMachine({ finishing: { on: { RESET: 'inactive', SETTLE: settleFrom('finishing') }, - invoke: fromPromise(ctx => ctx.deps.finish(), { + invoke: { + src: 'finish', onDone: 'retrying', onError: 'failed', - }), + }, }, // This means we are retrying the action after successful reverification @@ -372,6 +376,13 @@ export const reverificationMachine = createMachine({ }, }); +function seated(deps: ReverificationDeps | null): ReverificationDeps { + if (!deps) { + throw new Error('reverification deps are not seated'); + } + return deps; +} + const pendingStates = new Set(['submitting', 'finishing', 'retrying']); function factorStep(method: ReverificationMethod): ReverificationStep { @@ -438,16 +449,22 @@ function useReverificationState(model: ReverificationModel): ReverificationContr const m = useMessages('reverification'); const active = model.status === 'active' ? model : null; + const lastActive = useRef(active); + useLayoutEffect(() => { + if (active) { + lastActive.current = active; + } + }); + const [snapshot, send] = useMachine( - reverificationMachine, - active - ? { - context: { - supportEmail: active.supportEmail, - deps: active, - }, - } - : undefined, + reverificationMachine.provide({ + actors: { + start: () => seated(lastActive.current).start(), + prepare: ctx => prepareActive(ctx, seated(lastActive.current)), + submit: ctx => submit(ctx, seated(lastActive.current)), + finish: () => seated(lastActive.current).finish(), + }, + }), ); const [now, setNow] = useState(() => Date.now()); @@ -528,8 +545,9 @@ function useReverificationState(model: ReverificationModel): ReverificationContr onShowHelp: () => send({ type: 'SHOW_HELP' }), onBack: () => send({ type: 'BACK' }), onEmailSupport: () => { - if (context.supportEmail) { - window.location.assign(`mailto:${context.supportEmail}`); + const supportEmail = lastActive.current?.supportEmail; + if (supportEmail) { + window.location.assign(`mailto:${supportEmail}`); } }, methods, From cf12beb224236b4934ef2d0110f165b25dbc45c3 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 5 Oct 2026 19:20:10 -0400 Subject: [PATCH 07/12] refactor(mosaic): provide form config as implementations instead of context --- .../src/components/form/form.machine.ts | 164 +++++++++++------- .../src/components/form/use-form.test.ts | 8 +- .../mosaic/src/components/form/use-form.ts | 49 +++--- 3 files changed, 133 insertions(+), 88 deletions(-) diff --git a/packages/mosaic/src/components/form/form.machine.ts b/packages/mosaic/src/components/form/form.machine.ts index c90577f3d88..9ea624c53df 100644 --- a/packages/mosaic/src/components/form/form.machine.ts +++ b/packages/mosaic/src/components/form/form.machine.ts @@ -1,6 +1,5 @@ import type { LocalizableError } from '../../localization'; import { setup } from '../../machine/setup'; -import type { TransitionResult } from '../../machine/types'; import { SaveError } from '../../utils/form-error'; import { keysOf, mapKeys } from '../../utils/object'; import type { FieldFeedback, FormError, FormFieldErrors } from './form-submit-error'; @@ -38,7 +37,7 @@ export interface FormDeps { errorText: (error: LocalizableError) => string; } -export interface FormContext extends FormDeps { +export interface FormContext { values: TValues; baseline: TValues | undefined; touched: Partial>; @@ -54,15 +53,16 @@ export type FormEvent = | { type: 'SUBMIT' } | { type: 'RESET'; values?: TValues }; -export function initialOf(context: FormContext): TValues { - return context.baseline ?? context.initialValues; +export function initialOf(context: FormContext, initialValues: TValues): TValues { + return context.baseline ?? initialValues; } function syncFeedback( context: FormContext, + fields: FieldsConfig | undefined, name: keyof TValues, ): FieldFeedback | undefined { - return context.fields?.[name]?.validate?.(context.values[name], context.values); + return fields?.[name]?.validate?.(context.values[name], context.values); } function settledAsyncFeedback( @@ -75,47 +75,42 @@ function settledAsyncFeedback( export function validatorFeedback( context: FormContext, + fields: FieldsConfig | undefined, name: keyof TValues, ): FieldFeedback | undefined { - return syncFeedback(context, name) ?? context.async[name]?.feedback; + return syncFeedback(context, fields, name) ?? context.async[name]?.feedback; } export function fieldFeedback( context: FormContext, + fields: FieldsConfig | undefined, name: keyof TValues, ): FieldFeedback | undefined { const submitError = context.error?.fields?.[name]; - return submitError === undefined ? validatorFeedback(context, name) : { type: 'error', message: submitError }; + return submitError === undefined ? validatorFeedback(context, fields, name) : { type: 'error', message: submitError }; } -export function firstInvalid(context: FormContext): keyof TValues | undefined { +export function firstInvalid( + context: FormContext, + fields: FieldsConfig | undefined, +): keyof TValues | undefined { return keysOf(context.values).find( - name => (syncFeedback(context, name) ?? settledAsyncFeedback(context, name))?.type === 'error', + name => (syncFeedback(context, fields, name) ?? settledAsyncFeedback(context, name))?.type === 'error', ); } -export function isValid(context: FormContext): boolean { - return context.canSubmit(context.values) && firstInvalid(context) === undefined; +export function isValid( + context: FormContext, + fields: FieldsConfig | undefined, + canSubmit: (values: TValues) => boolean, +): boolean { + return canSubmit(context.values) && firstInvalid(context, fields) === undefined; } function isValidating(context: FormContext): boolean { return keysOf(context.values).some(name => context.async[name]?.pending === true); } -function submitOrStay( - context: FormContext, - patch: Partial>, -): TransitionResult, FormState> { - const next = { ...context, ...patch }; - if (!isValid(next)) { - return { context: { ...patch, submitQueued: false } }; - } - if (isValidating(next)) { - return { context: { ...patch, submitQueued: true } }; - } - return { target: 'submitting', context: { ...patch, submitQueued: false, error: undefined } }; -} - function displayableFields( context: FormContext, fields: FormFieldErrors | undefined, @@ -136,31 +131,36 @@ function displayableFields( function savedFieldErrors( context: FormContext, fields: Partial> | undefined, + errorText: (error: LocalizableError) => string, ): FormFieldErrors { const result: FormFieldErrors = {}; for (const name of keysOf(context.values)) { const error = typeof name === 'string' ? fields?.[name] : undefined; if (error !== undefined) { - result[name] = context.errorText(error); + result[name] = errorText(error); } } return result; } -function toFormError(cause: unknown, context: FormContext): FormError { +function toFormError( + cause: unknown, + context: FormContext, + deps: FormDeps, +): FormError { if (!(cause instanceof FormSubmitError) && !(cause instanceof SaveError)) { console.error(cause); - return { message: context.fallbackMessage }; + return { message: deps.fallbackMessage }; } const error: FormError = cause instanceof FormSubmitError ? { message: cause.banner, fields: displayableFields(context, cause.fields) } : { - message: cause.formError.global ? context.errorText(cause.formError.global) : undefined, - fields: savedFieldErrors(context, cause.formError.fields), + message: cause.formError.global ? deps.errorText(cause.formError.global) : undefined, + fields: savedFieldErrors(context, cause.formError.fields, deps.errorText), }; const visible = (error.message ?? '') !== '' || keysOf(error.fields ?? {}).length > 0; - return visible ? error : { ...error, message: context.fallbackMessage }; + return visible ? error : { ...error, message: deps.fallbackMessage }; } function withoutField( @@ -177,25 +177,58 @@ function withoutField( function asyncStateFor( context: FormContext, + deps: FormDeps, name: keyof TValues, value: TValues[keyof TValues], ): AsyncFieldState | undefined { - if (context.fields?.[name]?.validateAsync === undefined || value === initialOf(context)[name]) { + if (deps.fields?.[name]?.validateAsync === undefined || value === initialOf(context, deps.initialValues)[name]) { return undefined; } return { value, feedback: context.async[name]?.feedback, pending: true }; } -type FormState = 'editing' | 'submitting'; +export function formImplementations(deps: FormDeps) { + const { assign } = setup, FormEvent>(); + + return { + guards: { + invalid: (context: FormContext) => !isValid(context, deps.fields, deps.canSubmit), + }, + actions: { + change: assign((context, _event, { name, value }: { name: keyof TValues; value: TValues[keyof TValues] }) => ({ + values: { ...context.values, [name]: value }, + async: { ...context.async, [name]: asyncStateFor(context, deps, name, value) }, + error: withoutField(context.error, name), + submitQueued: false, + })), + reset: assign((_context, _event, { values }: { values: TValues | undefined }) => ({ + values: values ?? deps.initialValues, + baseline: values, + touched: {}, + async: {}, + error: undefined, + submitQueued: false, + })), + setError: assign((context, _event, { cause }: { cause: unknown }) => ({ + error: toFormError(cause, context, deps), + })), + }, + actors: { + submit: (context: FormContext) => deps.onSubmit(context.values), + }, + }; +} export function createFormMachine(deps: FormDeps) { - const { createMachine, assign, fromPromise } = setup, FormEvent>(); + const base = setup, FormEvent>(); + const { assign } = base; + const { createMachine } = base.extend(formImplementations(deps)); + const touchAll = (context: FormContext) => mapKeys(context.values, (): true => true); return createMachine({ id: 'form', initial: 'editing', context: { - ...deps, values: deps.initialValues, baseline: undefined, touched: {}, @@ -206,47 +239,46 @@ export function createFormMachine(deps: FormDeps ({ - context: { - values: { ...context.values, [event.name]: event.value }, - async: { ...context.async, [event.name]: asyncStateFor(context, event.name, event.value) }, - error: withoutField(context.error, event.name), - submitQueued: false, - }, - }), - TOUCH: ({ context, event }) => ({ context: { touched: { ...context.touched, [event.name]: true } } }), - VALIDATED: ({ context, event }) => { - if (context.async[event.name]?.value !== event.value) { - return undefined; - } - const async = { - ...context.async, - [event.name]: { value: event.value, feedback: event.feedback, pending: false }, - }; - return context.submitQueued ? submitOrStay(context, { async }) : { context: { async } }; + CHANGE: { actions: { type: 'change', params: (_, event) => ({ name: event.name, value: event.value }) } }, + TOUCH: { actions: assign((context, event) => ({ touched: { ...context.touched, [event.name]: true } })) }, + VALIDATED: { + guard: (context, event) => context.async[event.name]?.value === event.value, + actions: assign((context, event) => ({ + async: { + ...context.async, + [event.name]: { value: event.value, feedback: event.feedback, pending: false }, + }, + })), }, - SUBMIT: ({ context }) => submitOrStay(context, { touched: mapKeys(context.values, (): true => true) }), - RESET: ({ context, event }) => ({ - context: { - values: event.values ?? context.initialValues, - baseline: event.values, - touched: {}, - async: {}, - error: undefined, - submitQueued: false, + SUBMIT: [ + { + guard: 'invalid', + actions: assign(context => ({ touched: touchAll(context), submitQueued: false })), }, - }), + { + guard: context => isValidating(context), + actions: assign(context => ({ touched: touchAll(context), submitQueued: true })), + }, + { + target: 'submitting', + actions: assign(context => ({ touched: touchAll(context), submitQueued: false, error: undefined })), + }, + ], + RESET: { actions: { type: 'reset', params: (_, event) => ({ values: event.values }) } }, }, }, submitting: { - invoke: fromPromise(async ctx => ctx.onSubmit(ctx.values), { + invoke: { + src: 'submit', onDone: 'editing', onError: { target: 'editing', - actions: assign((ctx, e) => ({ error: toFormError(e.error, ctx) })), + actions: { type: 'setError', params: (_, event) => ({ cause: event.error }) }, }, - }), + }, }, }, }); } + +export type FormMachine = ReturnType>; diff --git a/packages/mosaic/src/components/form/use-form.test.ts b/packages/mosaic/src/components/form/use-form.test.ts index f7b9ce8be8c..74097e90106 100644 --- a/packages/mosaic/src/components/form/use-form.test.ts +++ b/packages/mosaic/src/components/form/use-form.test.ts @@ -202,14 +202,20 @@ describe('useForm', () => { }); it('reads canSubmit and validators from the current render', () => { + const onSubmit = vi.fn(resolved); const { result, rerender } = renderHook( ({ enabled }: { enabled: boolean }) => - useForm({ initialValues: { username: '' }, onSubmit: resolved, canSubmit: () => enabled }), + useForm({ initialValues: { username: '' }, onSubmit, canSubmit: () => enabled }), { initialProps: { enabled: true } }, ); expect(result.current.canSubmit).toBe(true); rerender({ enabled: false }); expect(result.current.canSubmit).toBe(false); + act(() => result.current.submit()); + expect(onSubmit).not.toHaveBeenCalled(); + rerender({ enabled: true }); + act(() => result.current.submit()); + expect(onSubmit).toHaveBeenCalledTimes(1); }); it('clears the message on the next submit', async () => { diff --git a/packages/mosaic/src/components/form/use-form.ts b/packages/mosaic/src/components/form/use-form.ts index 3c2867fa0d2..a288a9f924a 100644 --- a/packages/mosaic/src/components/form/use-form.ts +++ b/packages/mosaic/src/components/form/use-form.ts @@ -1,11 +1,17 @@ import { useCallback, useId, useRef } from 'react'; import { useErrorText, useMessages } from '../../localization'; -import type { StateMachine } from '../../machine/types'; import { useMachine } from '../../machine/use-machine'; import { keysOf, mapKeys } from '../../utils/object'; -import type { FieldsConfig, FormContext, FormEvent } from './form.machine'; -import { createFormMachine, fieldFeedback, firstInvalid, initialOf, isValid } from './form.machine'; +import type { FieldsConfig, FormDeps, FormMachine } from './form.machine'; +import { + createFormMachine, + fieldFeedback, + firstInvalid, + formImplementations, + initialOf, + isValid, +} from './form.machine'; import type { FieldFeedback } from './form-submit-error'; export interface UseFormOptions { @@ -88,33 +94,38 @@ export function useForm(options: UseFormOptions const elements = useRef(new Map()); const refs = useRef(new Map()); - const deps = { + const canSubmit = options.canSubmit ?? always; + const deps: FormDeps = { initialValues: options.initialValues, fields: options.fields, onSubmit: options.onSubmit, - canSubmit: options.canSubmit ?? always, + canSubmit, fallbackMessage: m.error, errorText, }; - const machineRef = useRef, FormEvent> | null>(null); + const machineRef = useRef | null>(null); if (machineRef.current === null) { machineRef.current = createFormMachine(deps); } - const [snapshot, send, actor] = useMachine(machineRef.current, { context: deps }); - const context = { ...snapshot.context, ...deps }; + const [snapshot, send, actor] = useMachine(machineRef.current.provide(formImplementations(deps))); + const { context } = snapshot; const { values } = context; const focusFirstInvalid = useCallback(() => { - const invalid = firstInvalid(actor.getSnapshot().context); + const invalid = firstInvalid(actor.getSnapshot().context, options.fields); if (invalid !== undefined) { elements.current.get(invalid)?.focus(); } - }, [actor]); + }, [actor, options.fields]); + const submit = useCallback(() => { + send({ type: 'SUBMIT' }); + focusFirstInvalid(); + }, [focusFirstInvalid, send]); const setValue = useCallback( (name: K, value: TValues[K]) => { send({ type: 'CHANGE', name, value }); - const { async, fields, values: next } = actor.getSnapshot().context; - const validateAsync = fields?.[name]?.validateAsync; + const { async, values: next } = actor.getSnapshot().context; + const validateAsync = options.fields?.[name]?.validateAsync; if (validateAsync === undefined || async[name]?.pending !== true || async[name].value !== value) { return; } @@ -122,14 +133,14 @@ export function useForm(options: UseFormOptions const { submitQueued } = actor.getSnapshot().context; send({ type: 'VALIDATED', name, value, feedback }); if (submitQueued) { - focusFirstInvalid(); + submit(); } }; void new Promise(resolve => resolve(validateAsync(value, next))).then(settle, () => settle(undefined), ); }, - [actor, focusFirstInvalid, send], + [actor, options.fields, send, submit], ); const touch = useCallback((name: keyof TValues) => send({ type: 'TOUCH', name }), [send]); const refFor = useCallback((name: keyof TValues): ElementRef => { @@ -147,10 +158,6 @@ export function useForm(options: UseFormOptions refs.current.set(name, ref); return ref; }, []); - const submit = useCallback(() => { - send({ type: 'SUBMIT' }); - focusFirstInvalid(); - }, [focusFirstInvalid, send]); const handleSubmit = useCallback( (event: { preventDefault: () => void }) => { event.preventDefault(); @@ -176,9 +183,9 @@ export function useForm(options: UseFormOptions }); const isSubmitting = snapshot.value === 'submitting' || context.submitQueued; - const initial = initialOf(context); + const initial = initialOf(context, options.initialValues); const fields = mapKeys(values, (name): FormField => { - const feedback = fieldFeedback(context, name); + const feedback = fieldFeedback(context, options.fields, name); const touched = context.touched[name] === true; return { feedback: feedback?.type === 'error' && !touched ? undefined : feedback, @@ -198,7 +205,7 @@ export function useForm(options: UseFormOptions return current.value === 'submitting' || current.context.submitQueued; }, isDirty: keysOf(values).some(name => fields[name].isDirty), - canSubmit: !isSubmitting && isValid(context), + canSubmit: !isSubmitting && isValid(context, options.fields, canSubmit), register, registerValue, setValue, From 05a3026058b9db0bb81e225717905abdab6f054e Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 5 Oct 2026 19:20:59 -0400 Subject: [PATCH 08/12] refactor(mosaic): remove setContext in favor of provided implementations --- packages/mosaic/src/machine/ADOPTION.md | 95 ++++++++++--------- .../machine/__tests__/use-machine.test.tsx | 43 --------- packages/mosaic/src/machine/create-actor.ts | 12 +-- packages/mosaic/src/machine/types.ts | 13 +-- packages/mosaic/src/machine/use-machine.ts | 6 -- .../src/machines/first-factor-machine.ts | 8 +- .../mosaic/src/machines/sign-in-machine.ts | 8 +- 7 files changed, 65 insertions(+), 120 deletions(-) diff --git a/packages/mosaic/src/machine/ADOPTION.md b/packages/mosaic/src/machine/ADOPTION.md index 833566bbbb5..be6b55f01ae 100644 --- a/packages/mosaic/src/machine/ADOPTION.md +++ b/packages/mosaic/src/machine/ADOPTION.md @@ -513,7 +513,6 @@ interface SignInStartContext { activeStrategy: OAuthStrategy | 'email' | null; identifier: string; error: string | null; - signInFn: (params: SignInCreateParams) => Promise; } type SignInStartEvent = @@ -521,57 +520,66 @@ type SignInStartEvent = | { type: 'TYPE_IDENTIFIER'; value: string } | { type: 'SUBMIT_IDENTIFIER' }; -const { createMachine, assign } = setup(); +const base = setup(); +const { assign } = base; -export function createSignInStartMachine(deps: { signInFn: SignInStartContext['signInFn'] }) { - return createMachine({ - initial: 'idle', - context: { activeStrategy: null, identifier: '', error: null, signInFn: deps.signInFn }, - states: { - idle: { - on: { - CLICK_SOCIAL: { - target: 'submitting', - actions: assign((_, e) => ({ activeStrategy: e.strategy })), - }, - TYPE_IDENTIFIER: { - actions: assign((_, e) => ({ identifier: e.value, error: null })), - }, - SUBMIT_IDENTIFIER: { - target: 'submitting', - actions: assign(() => ({ activeStrategy: 'email' as const })), - }, +const { createMachine } = base.extend({ + actors: { + signIn: (_ctx: SignInStartContext): Promise => Promise.reject(new Error('signIn not provided')), + }, +}); + +export const signInStartMachine = createMachine({ + initial: 'idle', + context: { activeStrategy: null, identifier: '', error: null }, + states: { + idle: { + on: { + CLICK_SOCIAL: { + target: 'submitting', + actions: assign((_, e) => ({ activeStrategy: e.strategy })), + }, + TYPE_IDENTIFIER: { + actions: assign((_, e) => ({ identifier: e.value, error: null })), + }, + SUBMIT_IDENTIFIER: { + target: 'submitting', + actions: assign(() => ({ activeStrategy: 'email' as const })), }, }, - submitting: { - // Both entry points converge here. idle's on-handlers are inactive, - // so a second CLICK_SOCIAL while already submitting is dropped automatically. - invoke: { - src: ctx => - ctx.activeStrategy === 'email' - ? ctx.signInFn({ identifier: ctx.identifier }) - : ctx.signInFn({ strategy: ctx.activeStrategy! }), - onDone: 'success', - onError: { - target: 'idle', - actions: assign((_, e) => ({ error: String(e.error), activeStrategy: null })), - }, + }, + submitting: { + // Both entry points converge here. idle's on-handlers are inactive, + // so a second CLICK_SOCIAL while already submitting is dropped automatically. + invoke: { + src: 'signIn', + onDone: 'success', + onError: { + target: 'idle', + actions: assign((_, e) => ({ error: String(e.error), activeStrategy: null })), }, }, - success: { type: 'final' }, }, - }); -} + success: { type: 'final' }, + }, +}); ``` In React, `isLocked` replaces `card.setLoading()` and `activeStrategy` replaces the per-button `status.isLoading` check: ```tsx -const [snapshot, send] = useMachine(signInStartMachine, { - context: { signInFn: params => signIn.create(params) }, - onDone: () => setActive({ session: signIn.createdSessionId }), -}); +const [snapshot, send] = useMachine( + signInStartMachine.provide({ + actors: { + signIn: ctx => + ctx.activeStrategy === 'email' + ? signIn.create({ identifier: ctx.identifier }) + : signIn.create({ strategy: ctx.activeStrategy }), + }, + }), + { onDone: () => setActive({ session: signIn.createdSessionId }) }, +); const isLocked = snapshot.value === 'submitting'; const active = snapshot.context.activeStrategy; @@ -708,10 +716,9 @@ createMachine({ }, }); -// At creation, pass the external error in: -const machine = createSignInStartMachine({ - signInFn: ..., - oauthError: signIn.firstFactorVerification?.error ?? null, +// At creation, seed the external error in: +useMachine(signInStartMachine.provide({ actors: { signIn: ... } }), { + context: { oauthError: signIn.firstFactorVerification?.error ?? null }, }); ``` diff --git a/packages/mosaic/src/machine/__tests__/use-machine.test.tsx b/packages/mosaic/src/machine/__tests__/use-machine.test.tsx index 872e8a9067f..733408cd6df 100644 --- a/packages/mosaic/src/machine/__tests__/use-machine.test.tsx +++ b/packages/mosaic/src/machine/__tests__/use-machine.test.tsx @@ -284,49 +284,6 @@ describe('useMachine — onDone', () => { }); }); -describe('useMachine — live context keeps injected functions current', () => { - it('invokes the latest fn from options.context even when the prop changes between renders', async () => { - const gate = deferred(); - const staleFn = vi.fn(() => gate.promise); - const freshFn = vi.fn(() => gate.promise); - - type Ctx = { fn: () => Promise }; - type Ev = { type: 'GO' }; - const machine = createMachine({ - initial: 'idle', - context: { fn: async () => {} }, - states: { - idle: { on: { GO: 'running' } }, - running: { invoke: { src: (ctx: Ctx) => ctx.fn(), onDone: 'done', onError: 'done' } }, - done: { type: 'final' }, - }, - }); - - function Runner({ run }: { run: () => Promise }) { - // No refs — options.context is synced into the actor on every render. - const [snapshot, send] = useMachine(machine, { context: { fn: run } }); - return ( -
- {snapshot.value} - -
- ); - } - - const { rerender } = render(); - rerender(); - - fireEvent.click(screen.getByText('Go')); - expect(screen.getByTestId('state')).toHaveTextContent('running'); - - await act(async () => gate.resolve()); - expect(screen.getByTestId('state')).toHaveTextContent('done'); - - expect(freshFn).toHaveBeenCalledTimes(1); - expect(staleFn).not.toHaveBeenCalled(); - }); -}); - describe('useMachine — provided implementations stay current', () => { type Ctx = Record; type Ev = { type: 'GO' }; diff --git a/packages/mosaic/src/machine/create-actor.ts b/packages/mosaic/src/machine/create-actor.ts index e13767eaca3..f104328820b 100644 --- a/packages/mosaic/src/machine/create-actor.ts +++ b/packages/mosaic/src/machine/create-actor.ts @@ -115,9 +115,8 @@ export function createActor return resolved; } - // Tracks the latest setContext patch so it survives a stop/start cycle. - let liveContextPatch: Partial = options.context ?? {}; - let context: TContext = { ...machine.context, ...liveContextPatch, ...teleport?.context }; + const seed: Partial = options.context ?? {}; + let context: TContext = { ...machine.context, ...seed, ...teleport?.context }; // `initial` may be derived from context (e.g. furthest-reachable step). const resolveInitial = () => (typeof machine.initial === 'function' ? machine.initial(context) : machine.initial); let value = teleport?.value ?? resolveInitial(); @@ -342,7 +341,7 @@ export function createActor 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 }; + context = { ...machine.context, ...seed }; value = resolveInitial(); enterState(INIT_EVENT); commit(); @@ -407,11 +406,6 @@ export function createActor return transition.target === undefined || canEnter(transition.target, event); }, - setContext(patch: Partial) { - liveContextPatch = { ...liveContextPatch, ...patch }; - context = { ...context, ...patch }; - }, - recheck() { if (status !== 'active') { return; diff --git a/packages/mosaic/src/machine/types.ts b/packages/mosaic/src/machine/types.ts index 2b190e66903..140063c8a98 100644 --- a/packages/mosaic/src/machine/types.ts +++ b/packages/mosaic/src/machine/types.ts @@ -344,13 +344,6 @@ export interface Actor { subscribe: (listener: SnapshotListener) => Unsubscribe; /** Whether the event would be handled (a guard-passing, enterable transition exists) right now. */ can: (event: TEvent) => boolean; - /** - * Silently merge a partial context patch into the running actor — no snapshot - * emitted, no transitions triggered. Use this to keep injected dependencies - * (e.g. an async function from a React prop) current without restarting the actor. - * Patches survive a stop/start cycle (e.g. React StrictMode). - */ - setContext: (patch: Partial) => void; /** * Re-evaluate the current state against live data. Call this when external data * a guard reads (an SWR cache, a store) has changed, so the machine can @@ -367,9 +360,9 @@ export interface Actor { export interface CreateActorOptions { /** - * Runtime context merged over machine defaults at actor creation time. - * Use this to inject dependencies (e.g. an async function from a hook) - * without putting them in the module-level machine definition. + * Context merged over machine defaults when the actor is created. It seeds the + * actor once and is not updated afterwards; pass changing values (e.g. props) + * as implementations through {@link StateMachine.provide}. * Snapshot context takes precedence when both are provided. */ context?: Partial; diff --git a/packages/mosaic/src/machine/use-machine.ts b/packages/mosaic/src/machine/use-machine.ts index 82d88f3d8c2..6f85b3277cb 100644 --- a/packages/mosaic/src/machine/use-machine.ts +++ b/packages/mosaic/src/machine/use-machine.ts @@ -49,13 +49,7 @@ export function useMachine( return () => actor.stop(); }, [actor]); - // Keep injected context (e.g. a function from props) current on every render. - // useLayoutEffect with no deps runs synchronously after every render, before - // paint — ensuring setContext fires before any user event triggers an invoke. useLayoutEffect(() => { - if (options?.context) { - actor.setContext(options.context); - } actor.logic.implementations = machine.implementations; }); diff --git a/packages/mosaic/src/machines/first-factor-machine.ts b/packages/mosaic/src/machines/first-factor-machine.ts index 500f86bd3e6..44128e10cb7 100644 --- a/packages/mosaic/src/machines/first-factor-machine.ts +++ b/packages/mosaic/src/machines/first-factor-machine.ts @@ -67,10 +67,10 @@ function buildAttemptParams(factor: SignInFirstFactor, value: string): AttemptFi * On completion, `context.completionStatus` carries the signIn.status so the * parent machine can route to secondFactor, resetPassword, or complete: * - * const [snapshot, send] = useMachine(firstFactorMachine, { - * context: { factor, attemptFn: signIn.attemptFirstFactor, prepareFn: signIn.prepareFirstFactor }, - * onDone: () => parentSend({ type: 'FACTOR_COMPLETE', nextStatus: snapshot.context.completionStatus }), - * }); + * const [snapshot, send] = useMachine( + * createFirstFactorMachine({ factor, attemptFn: signIn.attemptFirstFactor, prepareFn: signIn.prepareFirstFactor }), + * { onDone: () => parentSend({ type: 'FACTOR_COMPLETE', nextStatus: snapshot.context.completionStatus }) }, + * ); */ export function createFirstFactorMachine(deps: { factor: SignInFirstFactor; diff --git a/packages/mosaic/src/machines/sign-in-machine.ts b/packages/mosaic/src/machines/sign-in-machine.ts index 17f1ada33f6..d4927d02941 100644 --- a/packages/mosaic/src/machines/sign-in-machine.ts +++ b/packages/mosaic/src/machines/sign-in-machine.ts @@ -31,10 +31,10 @@ const { createMachine, assign, fromPromise } = setup * one object with no hidden boolean flags. * * Component usage: - * const [snapshot, send] = useMachine(machine, { - * context: { createAttemptFn: id => signIn.create({ identifier: id }), resetPasswordFn: signIn.resetPassword }, - * onDone: () => setActive({ session: signIn.createdSessionId }).then(() => router.navigate(afterSignInUrl)), - * }); + * const [snapshot, send] = useMachine( + * createSignInMachine({ createAttemptFn: id => signIn.create({ identifier: id }), resetPasswordFn: signIn.resetPassword }), + * { onDone: () => setActive({ session: signIn.createdSessionId }).then(() => router.navigate(afterSignInUrl)) }, + * ); * * Child factor components signal completion by calling: * send({ type: 'FACTOR_COMPLETE', nextStatus: signIn.status }) From 2f9923aae6a9face2b76a9c49ba7af4e85a37d00 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 5 Oct 2026 19:59:06 -0400 Subject: [PATCH 09/12] chore(mosaic): trim machine lib comments that restate the code --- packages/mosaic/src/machine/assign.ts | 1 - packages/mosaic/src/machine/create-actor.ts | 49 +++---------------- packages/mosaic/src/machine/create-machine.ts | 3 -- packages/mosaic/src/machine/setup.ts | 3 -- 4 files changed, 8 insertions(+), 48 deletions(-) diff --git a/packages/mosaic/src/machine/assign.ts b/packages/mosaic/src/machine/assign.ts index 0c8a1002be3..9e8c3cc565d 100644 --- a/packages/mosaic/src/machine/assign.ts +++ b/packages/mosaic/src/machine/assign.ts @@ -18,7 +18,6 @@ export function assign( action: unknown, ): action is AssignAction { diff --git a/packages/mosaic/src/machine/create-actor.ts b/packages/mosaic/src/machine/create-actor.ts index f104328820b..69a27ced6b9 100644 --- a/packages/mosaic/src/machine/create-actor.ts +++ b/packages/mosaic/src/machine/create-actor.ts @@ -25,7 +25,6 @@ import { AFTER, ASSIGN, INIT, INVOKE_DONE, INVOKE_ERROR, RECHECK } from './types const INIT_EVENT: AnyEventObject = { type: INIT }; -// 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]); /** @@ -58,18 +57,13 @@ export function createActor ): Actor { const teleport = options.snapshot; - // Internally the actor operates on the broader `EventObject`: invoke done/error - // events aren't part of the user's `TEvent` union, so the config is viewed - // through an event-agnostic lens to keep the runtime helpers honestly typed. + // Invoke done/error events aren't in `TEvent`, so the runtime reads the config with `EventObject`. const states = machine.states as unknown as Record< string, StateConfig >; function implementations(): RuntimeImplementations { - // SAFETY: same event-agnostic lens as `states` above, applied to the swappable implementations. - // Action params are `unknown` here because each one comes from the ref naming that action, - // which `ParameterizedActionRef` types against the same implementation. return actor.logic.implementations as unknown as RuntimeImplementations; } @@ -117,7 +111,6 @@ export function createActor const seed: Partial = options.context ?? {}; let context: TContext = { ...machine.context, ...seed, ...teleport?.context }; - // `initial` may be derived from context (e.g. furthest-reachable step). const resolveInitial = () => (typeof machine.initial === 'function' ? machine.initial(context) : machine.initial); let value = teleport?.value ?? resolveInitial(); @@ -130,7 +123,6 @@ export function createActor // resolving after the fact is ignored — no transition, no setState-after-stop. let invocationToken = 0; - // Pending `after` timer IDs — cleared when the state is exited or the actor stops. let afterTimers: ReturnType[] = []; // The snapshot is cached and only replaced on an actual change, so @@ -139,22 +131,11 @@ export function createActor const listeners: SnapshotListener[] = []; - /** - * Normalise a raw `Transition` value into a `TransitionConfig[]` the runtime - * can process uniformly. Handles all four arms of the `Transition` union: - * - string → `{ target: string }` - * - `TransitionConfig` → as-is - * - `TransitionConfig[]` → as-is - * - `TransitionFn` → called immediately; `undefined` return → `[]` (unhandled) - */ function normalizeTransition( raw: unknown, event: EventObject, ): TransitionConfig[] { if (typeof raw === 'function') { - // SAFETY: raw is a TransitionFn — (args: {context, event}) => TransitionResult | undefined. - // The cast is required because the generic TStates parameter is erased at this internal - // boundary; callers have already narrowed the event type via StateConfig.on[K]. const result = (raw as TransitionFn)({ context, event }); if (result === undefined) { return []; @@ -162,8 +143,6 @@ export function createActor const cfg: TransitionConfig = { target: result.target }; if (result.context !== undefined) { const patch = result.context; - // SAFETY: Constructing AssignAction inline avoids importing the assign() helper here. - // The ASSIGN symbol is the exact tag isAssignAction checks in runActions. cfg.actions = { type: ASSIGN, assignment: () => patch } as AssignAction; } return [cfg]; @@ -197,7 +176,6 @@ export function createActor return transitions.find(transition => !transition.guard || resolveGuard(transition.guard)(context, event)); } - /** Whether a target state's entry guard currently permits landing on it. */ function canEnter(stateId: string, event: EventObject): boolean { const guard = states[stateId]?.guard; return !guard || resolveGuard(guard)(context, event); @@ -214,11 +192,11 @@ export function createActor ): boolean { const external = transition.target !== undefined; if (external && !canEnter(transition.target as string, event)) { - return false; // entry guard blocks landing → snapshot unchanged, no notify + return false; } if (external) { runActions(states[value].exit, event); - invocationToken++; // abandon the invoke of the state we're leaving + invocationToken++; clearAfterTimers(); } runActions(transition.actions, event); @@ -301,7 +279,6 @@ export function createActor } } - /** Entry side of a state: entry actions, then immediate/invoke resolution. */ function enterState(event: EventObject): void { const stateConfig = states[value]; if (!stateConfig) { @@ -354,7 +331,7 @@ export function createActor } started = false; // allow restart (e.g. StrictMode effect cleanup + remount) status = 'stopped'; - invocationToken++; // abandon any in-flight invoke + invocationToken++; clearAfterTimers(); snapshot = { value, context, status }; for (let i = listeners.length; i--; ) { @@ -370,21 +347,16 @@ export function createActor const transition = pickTransition(normalizeTransition(states[value]?.on?.[event.type], event), event); if (!transition) { return; - } // event not handled in this state → ignored + } if (takeTransition(transition, event)) { commit(); - } // entry-blocked → no commit, no notify + } }, getSnapshot() { return snapshot; }, - // Standard observable contract: `subscribe(cb)` returns an unsubscribe fn. - // This is deliberately the same shape as a nanostores atom's `subscribe`, so - // if nanostores is adopted repo-wide later a `toAtom(actor)` adapter is a - // trivial, non-breaking wrapper over `subscribe` + `getSnapshot` — no need to - // pull the dependency in now. subscribe(listener: SnapshotListener): Unsubscribe { listeners.push(listener); return () => { @@ -412,16 +384,11 @@ export function createActor } const event = { type: RECHECK }; - // Self-correct first: if live external data has made the *current* state - // unenterable (its entry guard no longer holds), re-seat to the freshly - // resolved initial state — the same derivation used on start (e.g. the - // Wizard's furthest-reachable step). `resolveInitial` always lands on an - // enterable step, so this is provably one-shot and cannot loop. if (!canEnter(value, event)) { const reseated = resolveInitial(); if (reseated !== value) { runActions(states[value]?.exit, event); - invocationToken++; // abandon the invoke of the state we're leaving + invocationToken++; clearAfterTimers(); value = reseated; enterState(event); @@ -432,7 +399,7 @@ export function createActor const immediate = pickTransition(normalizeTransition(states[value]?.always, event), event); if (immediate && immediate.target !== undefined && takeTransition(immediate, event)) { - commit(); // nothing applies → no commit, no notify + commit(); } }, }; diff --git a/packages/mosaic/src/machine/create-machine.ts b/packages/mosaic/src/machine/create-machine.ts index 93dc3c4ca84..a2884593bb0 100644 --- a/packages/mosaic/src/machine/create-machine.ts +++ b/packages/mosaic/src/machine/create-machine.ts @@ -39,9 +39,6 @@ export function createMachine< return { id: config.id, initial: config.initial, - // SAFETY: config.context is optional; when omitted the machine has no context. - // The empty object satisfies any TContext at runtime — callers that omit context - // also use Record as TContext so no fields are accessed. context: config.context ?? ({} as TContext), states: config.states, config, diff --git a/packages/mosaic/src/machine/setup.ts b/packages/mosaic/src/machine/setup.ts index b1c8c2f29ad..e9a51f9d3e0 100644 --- a/packages/mosaic/src/machine/setup.ts +++ b/packages/mosaic/src/machine/setup.ts @@ -124,9 +124,6 @@ function withImplementations; }, ): InvokeConfig => ({ - // SAFETY: fn only uses context (no event param), but InvokeConfig.src accepts - // (context, event) for parity with state-entry event access. The extra event - // parameter is unused; callers receive only context at runtime. src: fn as unknown as InvokeConfig['src'], ...config, }), From 195e94ee5ab7d98795dc3b0a022c6d0af054bf34 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 5 Oct 2026 20:01:11 -0400 Subject: [PATCH 10/12] chore(mosaic): drop references to other libraries from machine lib comments --- packages/mosaic/src/machine/setup.ts | 7 +------ packages/mosaic/src/machine/types.ts | 9 ++++----- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/mosaic/src/machine/setup.ts b/packages/mosaic/src/machine/setup.ts index e9a51f9d3e0..aea16f94aa8 100644 --- a/packages/mosaic/src/machine/setup.ts +++ b/packages/mosaic/src/machine/setup.ts @@ -38,10 +38,7 @@ export interface Setup; }, ) => InvokeConfig; - /** - * Register default implementations that the config can reference by name. - * Mirrors XState v5's `setup(...).extend(...)`. - */ + /** Register default implementations that the config can reference by name. */ extend: < TGuards extends Record = Record, TActions extends Record = Record, @@ -98,8 +95,6 @@ function withImplementations()` — a typed wrapper that carries - * `TOutput` to `DoneInvokeEvent` without needing a full actor registry. * * ```ts * const { createMachine, assign, fromPromise } = setup(); diff --git a/packages/mosaic/src/machine/types.ts b/packages/mosaic/src/machine/types.ts index 140063c8a98..9b9c7643fd2 100644 --- a/packages/mosaic/src/machine/types.ts +++ b/packages/mosaic/src/machine/types.ts @@ -1,9 +1,8 @@ /** * Shared types for the Mosaic state-machine library. * - * The design mirrors XState v5's config-object shape (so a machine is statically - * introspectable — see {@link StateMachine.states}) but is trimmed to a tiny, - * dependency-free core: no parallel states, history, SCXML, or spawned actors. + * Machines are plain config objects, so they are statically introspectable + * (see {@link StateMachine.states}). No parallel states, history, or spawned actors. */ /** The minimum shape every event must have. */ @@ -78,7 +77,7 @@ export type GuardRef | TRefs['guards']; -/** A named action with the params its implementation receives. Mirrors XState v5's `{ type, params }`. */ +/** A named action with the params its implementation receives. */ export type ParameterizedActionRef = { [K in TRefs['actions']]: { type: K; @@ -346,7 +345,7 @@ export interface Actor { can: (event: TEvent) => boolean; /** * Re-evaluate the current state against live data. Call this when external data - * a guard reads (an SWR cache, a store) has changed, so the machine can + * a guard reads (a cache, a store) has changed, so the machine can * self-correct: * - if the *current* state's entry guard no longer holds, re-seat to the * freshly resolved initial state (e.g. the Wizard's furthest-reachable step); From 31d06fa2e136f123079c7c2c4f9a47d043cd89e9 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 5 Oct 2026 22:02:57 -0400 Subject: [PATCH 11/12] test(mosaic): assert the useMachine context option only seeds the actor --- .../machine/__tests__/use-machine.test.tsx | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/mosaic/src/machine/__tests__/use-machine.test.tsx b/packages/mosaic/src/machine/__tests__/use-machine.test.tsx index 733408cd6df..333002eeafb 100644 --- a/packages/mosaic/src/machine/__tests__/use-machine.test.tsx +++ b/packages/mosaic/src/machine/__tests__/use-machine.test.tsx @@ -85,6 +85,43 @@ describe('useMachine — context init option', () => { render(); expect(screen.getByText('runtime')).toBeInTheDocument(); }); + + it('only seeds context, so state the machine assigns survives a re-render', async () => { + type Ctx = { name: string }; + const save = vi.fn((_name: string) => Promise.resolve()); + const machine = createMachine({ + initial: 'editing', + context: { name: '' }, + states: { + editing: { + on: { + TYPE: { actions: assign((_, e) => ({ name: e.value })) }, + SAVE: 'saving', + }, + }, + saving: { invoke: { src: ctx => save(ctx.name), onDone: 'editing' } }, + }, + }); + + function Comp({ savedName }: { savedName: string }) { + const [, send] = useMachine(machine, { context: { name: savedName } }); + return ( +
+ + +
+ ); + } + + const { rerender } = render(); + fireEvent.click(screen.getByText('Type')); + rerender(); + await act(async () => { + fireEvent.click(screen.getByText('Save')); + }); + + expect(save).toHaveBeenCalledWith('Acme Inc'); + }); }); describe('useActor + mockActor — render a teleported step', () => { From 877a5994a1b34dd5b7cf0ab7455ff2af4f4433c7 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 5 Oct 2026 22:25:20 -0400 Subject: [PATCH 12/12] docs(mosaic): show sign-in machine deps are fixed at creation --- packages/mosaic/src/machines/first-factor-machine.ts | 6 ++++-- packages/mosaic/src/machines/sign-in-machine.ts | 8 +++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/mosaic/src/machines/first-factor-machine.ts b/packages/mosaic/src/machines/first-factor-machine.ts index 44128e10cb7..c9b3785839e 100644 --- a/packages/mosaic/src/machines/first-factor-machine.ts +++ b/packages/mosaic/src/machines/first-factor-machine.ts @@ -67,10 +67,12 @@ function buildAttemptParams(factor: SignInFirstFactor, value: string): AttemptFi * On completion, `context.completionStatus` carries the signIn.status so the * parent machine can route to secondFactor, resetPassword, or complete: * - * const [snapshot, send] = useMachine( + * const [machine] = useState(() => * createFirstFactorMachine({ factor, attemptFn: signIn.attemptFirstFactor, prepareFn: signIn.prepareFirstFactor }), - * { onDone: () => parentSend({ type: 'FACTOR_COMPLETE', nextStatus: snapshot.context.completionStatus }) }, * ); + * const [snapshot, send] = useMachine(machine, { + * onDone: () => parentSend({ type: 'FACTOR_COMPLETE', nextStatus: snapshot.context.completionStatus }), + * }); */ export function createFirstFactorMachine(deps: { factor: SignInFirstFactor; diff --git a/packages/mosaic/src/machines/sign-in-machine.ts b/packages/mosaic/src/machines/sign-in-machine.ts index d4927d02941..165fa18611f 100644 --- a/packages/mosaic/src/machines/sign-in-machine.ts +++ b/packages/mosaic/src/machines/sign-in-machine.ts @@ -8,7 +8,7 @@ export interface SignInContext { pendingSignOutOfOtherSessions: boolean; pendingStatus: string; error: string | null; - // Injected deps — passed via useMachine options.context so they're always current. + // Injected deps — seeded by createSignInMachine and not updated after the actor starts. createAttemptFn: (identifier: string) => Promise; resetPasswordFn: (params: ResetPasswordParams) => Promise; } @@ -31,10 +31,12 @@ const { createMachine, assign, fromPromise } = setup * one object with no hidden boolean flags. * * Component usage: - * const [snapshot, send] = useMachine( + * const [machine] = useState(() => * createSignInMachine({ createAttemptFn: id => signIn.create({ identifier: id }), resetPasswordFn: signIn.resetPassword }), - * { onDone: () => setActive({ session: signIn.createdSessionId }).then(() => router.navigate(afterSignInUrl)) }, * ); + * const [snapshot, send] = useMachine(machine, { + * onDone: () => setActive({ session: signIn.createdSessionId }).then(() => router.navigate(afterSignInUrl)), + * }); * * Child factor components signal completion by calling: * send({ type: 'FACTOR_COMPLETE', nextStatus: signIn.status })