Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-machine-state-names.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 1 addition & 1 deletion packages/mosaic/eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
},
"src/machine/create-actor.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 8
"count": 6
}
},
"src/machine/create-machine.ts": {
Expand Down
60 changes: 30 additions & 30 deletions packages/mosaic/src/machine/create-actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,18 @@ const toArr = <T>(v: T | T[] | undefined): T[] => (!v ? [] : Array.isArray(v) ?
* actor.send({ type: 'TOGGLE' });
* ```
*/
export function createActor<TContext extends object, TEvent extends EventObject>(
machine: StateMachine<TContext, TEvent>,
options: CreateActorOptions<TContext> = {},
): Actor<TContext, TEvent> {
export function createActor<TContext extends object, TEvent extends EventObject, TStates extends string = string>(
machine: StateMachine<TContext, TEvent, TStates>,
options: CreateActorOptions<TContext, TStates> = {},
): Actor<TContext, TEvent, TStates> {
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.
const states = machine.states as unknown as Record<string, StateConfig<TContext, EventObject>>;
const emptyState: StateConfig<TContext, EventObject> = {};
const stateOf = (id: string): StateConfig<TContext, EventObject> => {
const states = machine.states as unknown as Record<string, StateConfig<TContext, EventObject, TStates>>;
const emptyState: StateConfig<TContext, EventObject, TStates> = {};
const stateOf = (id: string): StateConfig<TContext, EventObject, TStates> => {
const state = states[id];
if (state) {
return state;
Expand Down Expand Up @@ -90,9 +90,9 @@ export function createActor<TContext extends object, TEvent extends EventObject>

// The snapshot is cached and only replaced on an actual change, so
// getSnapshot() is referentially stable for useSyncExternalStore.
let snapshot: Snapshot<TContext> = { value, context, status };
let snapshot: Snapshot<TContext, TStates> = { value, context, status };

const listeners: SnapshotListener<TContext>[] = [];
const listeners: SnapshotListener<TContext, TStates>[] = [];

/**
* Normalise a raw `Transition` value into a `TransitionConfig[]` the runtime
Expand All @@ -102,16 +102,16 @@ export function createActor<TContext extends object, TEvent extends EventObject>
* - `TransitionConfig[]` β†’ as-is
* - `TransitionFn` β†’ called immediately; `undefined` return β†’ `[]` (unhandled)
*/
function normalizeTransition(raw: unknown, event: EventObject): TransitionConfig<TContext, EventObject>[] {
function normalizeTransition(raw: unknown, event: EventObject): TransitionConfig<TContext, EventObject, TStates>[] {
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<TContext, EventObject>)({ context, event });
// The cast is required because raw arrives untyped at this internal boundary; callers
// have already narrowed the event type via StateConfig.on[K].
const result = (raw as TransitionFn<TContext, EventObject, TStates>)({ context, event });
if (result === undefined) {
return [];
}
const cfg: TransitionConfig<TContext, EventObject> = { target: result.target };
const cfg: TransitionConfig<TContext, EventObject, TStates> = { target: result.target };
if (result.context !== undefined) {
const patch = result.context;
// SAFETY: Constructing AssignAction inline avoids importing the assign() helper here.
Expand All @@ -120,8 +120,8 @@ export function createActor<TContext extends object, TEvent extends EventObject>
}
return [cfg];
}
return toArr<unknown>(raw).map(e =>
typeof e === 'string' ? { target: e } : (e as TransitionConfig<TContext, EventObject>),
return toArr(raw as TStates | TransitionConfig<TContext, EventObject, TStates>).map(e =>
typeof e === 'string' ? { target: e } : e,
);
}

Expand All @@ -136,9 +136,9 @@ export function createActor<TContext extends object, TEvent extends EventObject>
}

function pickTransition(
transitions: TransitionConfig<TContext, EventObject>[],
transitions: TransitionConfig<TContext, EventObject, TStates>[],
event: EventObject,
): TransitionConfig<TContext, EventObject> | undefined {
): TransitionConfig<TContext, EventObject, TStates> | undefined {
return transitions.find(transition => !transition.guard || transition.guard(context, event));
}

Expand All @@ -153,19 +153,19 @@ export function createActor<TContext extends object, TEvent extends EventObject>
* 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<TContext, EventObject>, event: EventObject): boolean {
const external = transition.target !== undefined;
if (external && !canEnter(transition.target as string, event)) {
function takeTransition(transition: TransitionConfig<TContext, EventObject, TStates>, event: EventObject): boolean {
const { target } = transition;
if (target !== undefined && !canEnter(target, event)) {
return false; // entry guard blocks landing β†’ snapshot unchanged, no notify
}
if (external) {
if (target !== undefined) {
runActions(stateOf(value).exit, event);
invocationToken++; // abandon the invoke of the state we're leaving
clearAfterTimers();
}
runActions(transition.actions, event);
if (external) {
value = transition.target as string;
if (target !== undefined) {
value = target;
enterState(event);
}
return true;
Expand Down Expand Up @@ -299,7 +299,7 @@ export function createActor<TContext extends object, TEvent extends EventObject>
}
}

const actor: Actor<TContext, TEvent> = {
const actor: Actor<TContext, TEvent, TStates> = {
start() {
if (started) {
return actor;
Expand Down Expand Up @@ -351,7 +351,7 @@ export function createActor<TContext extends object, TEvent extends EventObject>
// 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<TContext>): Unsubscribe {
subscribe(listener: SnapshotListener<TContext, TStates>): Unsubscribe {
listeners.push(listener);
return () => {
const i = listeners.indexOf(listener);
Expand Down Expand Up @@ -424,9 +424,9 @@ export function createActor<TContext extends object, TEvent extends EventObject>
* actor.getSnapshot(); // β†’ { value: 'deleting', context: {...}, status: 'active' }
* ```
*/
export function mockActor<TContext extends object, TEvent extends EventObject>(
machine: StateMachine<TContext, TEvent>,
snapshot: { value: string; context?: Partial<TContext> },
): Actor<TContext, TEvent> {
export function mockActor<TContext extends object, TEvent extends EventObject, TStates extends string = string>(
machine: StateMachine<TContext, TEvent, TStates>,
snapshot: { value: TStates; context?: Partial<TContext> },
): Actor<TContext, TEvent, TStates> {
return createActor(machine, { snapshot });
}
2 changes: 1 addition & 1 deletion packages/mosaic/src/machine/create-machine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export function createMachine<
TContext extends object = Record<string, never>,
TEvent extends EventObject = EventObject,
TStates extends string = string,
>(config: MachineConfig<TContext, TEvent, TStates>): StateMachine<TContext, TEvent> {
>(config: MachineConfig<TContext, TEvent, TStates>): StateMachine<TContext, TEvent, TStates> {
return {
id: config.id,
initial: config.initial,
Expand Down
5 changes: 3 additions & 2 deletions packages/mosaic/src/machine/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ import type {
*/
export function setup<TContext extends object, TEvent extends EventObject>() {
return {
createMachine: (config: MachineConfig<TContext, TEvent>): StateMachine<TContext, TEvent> =>
_createMachine<TContext, TEvent>(config),
createMachine: <TStates extends string>(
config: MachineConfig<TContext, TEvent, TStates>,
): StateMachine<TContext, TEvent, TStates> => _createMachine<TContext, TEvent, TStates>(config),

assign: <TEvt extends EventObject = EventObject>(
fn: (context: TContext, event: TEvt) => Partial<TContext>,
Expand Down
84 changes: 78 additions & 6 deletions packages/mosaic/src/machine/types.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
import { describe, expectTypeOf, test } from 'vitest';

import { assign } from './assign';
import { createActor } from './create-actor';
import { createActor, mockActor } from './create-actor';
import { createMachine } from './create-machine';
import { setup } from './setup';
import type { Snapshot, StateMachine } from './types';
import { useActor, useMachine, useSelector } from './use-machine';

// ─── Shared fixture ──────────────────────────────────────────────────────────

type TestContext = { count: number; label: string };

type TestEvent = { type: 'SIMPLE' } | { type: 'WITH_PAYLOAD'; value: string } | { type: 'OPTIONAL'; count?: number };

const machine = createMachine<TestContext, TestEvent>({
const machine = setup<TestContext, TestEvent>().createMachine({
initial: 'idle',
context: { count: 0, label: '' },
states: {
Expand Down Expand Up @@ -103,14 +104,49 @@ describe('snapshot β€” context and value are correctly typed', () => {
expectTypeOf(snap.context).toEqualTypeOf<TestContext>();
});

test('value is string', () => {
test('value is the union of state names', () => {
const snap = actor.getSnapshot();
expectTypeOf(snap.value).toEqualTypeOf<string>();
expectTypeOf(snap.value).toEqualTypeOf<'idle'>();
});

test('full snapshot matches Snapshot<TContext>', () => {
test('full snapshot matches Snapshot<TContext, TStates>', () => {
const snap = actor.getSnapshot();
expectTypeOf(snap).toEqualTypeOf<Snapshot<TestContext>>();
expectTypeOf(snap).toEqualTypeOf<Snapshot<TestContext, 'idle'>>();
});

test('value is the union of state names for a machine built through setup', () => {
const m = setup<TestContext, TestEvent>().createMachine({ initial: 'a', states: { a: {}, b: {} } });
expectTypeOf(createActor(m).getSnapshot().value).toEqualTypeOf<'a' | 'b'>();
});

test('value is the union of state names for a machine built without type arguments', () => {
const m = createMachine({ initial: 'a', states: { a: { on: { NEXT: 'b' } }, b: {} } });
expectTypeOf(createActor(m).getSnapshot().value).toEqualTypeOf<'a' | 'b'>();
});

test('value stays string when createMachine is given only context and event types', () => {
const m = createMachine<TestContext, TestEvent>({
initial: 'a',
context: { count: 0, label: '' },
states: { a: {} },
});
expectTypeOf(createActor(m).getSnapshot().value).toEqualTypeOf<string>();
});

test('useMachine, useActor and useSelector keep the union of state names', () => {
expectTypeOf(useMachine(machine)[0].value).toEqualTypeOf<'idle'>();
expectTypeOf(useActor(actor)[0].value).toEqualTypeOf<'idle'>();
expectTypeOf(useSelector(actor, snap => snap.value)).toEqualTypeOf<'idle'>();
});

test('createActor rejects a snapshot value that is not a state', () => {
// @ts-expect-error β€” 'missing' is not a state
createActor(machine, { snapshot: { value: 'missing' } });
});

test('mockActor rejects a value that is not a state', () => {
// @ts-expect-error β€” 'missing' is not a state
mockActor(machine, { value: 'missing' });
});
});

Expand Down Expand Up @@ -258,6 +294,42 @@ describe('setup β€” eliminates repetitive generic params', () => {
});
});

test('invalid initial is rejected', () => {
make({
// @ts-expect-error β€” 'missing' is not a state
initial: 'missing',
context: { count: 0, label: '' },
states: { idle: {}, active: {} },
});
});

test('invalid transition target is rejected', () => {
make({
initial: 'idle',
context: { count: 0, label: '' },
states: {
idle: {
// @ts-expect-error β€” 'nonexistent' is not a state
on: { SIMPLE: 'nonexistent' },
},
active: {},
},
});
});

test('invalid target in a long-form transition is rejected', () => {
make({
initial: 'idle',
context: { count: 0, label: '' },
states: {
idle: {
// @ts-expect-error β€” 'nonexistent' is not a state
always: { target: 'nonexistent' },
},
},
});
});

test('on key constraint still holds β€” unknown event types are rejected', () => {
make({
initial: 'idle',
Expand Down
Loading
Loading