Skip to content
Closed
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
27 changes: 27 additions & 0 deletions .changeset/purple-signals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'xstate': patch
---

Added a new `compileMachineSignals(machine, options?)` utility to `xstate/graph`.

This compiles a built machine into a signal-oriented transition map where each
signal includes explicit source and target route IDs.

```ts
import { createMachine } from 'xstate';
import { compileMachineSignals } from 'xstate/graph';

const machine = createMachine({
id: 'light',
initial: 'green',
states: {
green: { on: { TIMER: 'yellow' } },
yellow: { on: { TIMER: 'red' } },
red: {}
}
});

const compiled = compileMachineSignals(machine);
// compiled.bySignal.TIMER.routes =>
// [{ source: '#light.green', targets: ['#light.yellow'] }, ...]
```
8 changes: 8 additions & 0 deletions packages/core/src/graph/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
export { TestModel, createTestModel } from './TestModel.ts';
export { adjacencyMapToArray, getAdjacencyMap } from './adjacency.ts';
export { ALWAYS_SIGNAL, compileMachineSignals } from './signals.ts';
export type {
CompileMachineSignalsOptions,
CompiledMachineSignals,
CompiledSignal,
CompiledSignalRoute,
SignalKind
} from './signals.ts';
export {
getStateNodes,
joinPaths,
Expand Down
192 changes: 192 additions & 0 deletions packages/core/src/graph/signals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { NULL_EVENT } from '../constants.ts';
import type { AnyStateMachine, AnyTransitionDefinition } from '../types.ts';
import type { AnyStateNode } from './types.ts';

export const ALWAYS_SIGNAL = 'xstate.always';

export type SignalKind =
| 'event'
| 'always'
| 'after'
| 'route'
| 'done-state'
| 'done-actor'
| 'error-actor'
| 'snapshot-actor';

export interface CompiledSignalRoute {
signal: string;
eventType: string;
kind: SignalKind;
source: string;
targets: string[];
reenter: boolean;
guarded: boolean;
transition: AnyTransitionDefinition;
toJSON: () => {
signal: string;
eventType: string;
kind: SignalKind;
source: string;
targets: string[];
reenter: boolean;
guarded: boolean;
};
}

export interface CompiledSignal {
signal: string;
kind: SignalKind;
routes: CompiledSignalRoute[];
}

export interface CompiledMachineSignals {
routes: CompiledSignalRoute[];
signals: CompiledSignal[];
bySignal: Record<string, CompiledSignal>;
toJSON: () => {
routes: ReturnType<CompiledSignalRoute['toJSON']>[];
signals: Array<{
signal: string;
kind: SignalKind;
routes: ReturnType<CompiledSignalRoute['toJSON']>[];
}>;
};
}

export interface CompileMachineSignalsOptions {
includeEventless?: boolean;
}

function toSignalKind(signal: string): SignalKind {
if (signal === ALWAYS_SIGNAL) {
return 'always';
}
if (signal === 'xstate.route') {
return 'route';
}
if (signal.startsWith('xstate.after.')) {
return 'after';
}
if (signal.startsWith('xstate.done.state.')) {
return 'done-state';
}
if (signal.startsWith('xstate.done.actor.')) {
return 'done-actor';
}
if (signal.startsWith('xstate.error.actor.')) {
return 'error-actor';
}
if (signal.startsWith('xstate.snapshot.')) {
return 'snapshot-actor';
}
return 'event';
}

function collectStateNodes(rootStateNode: AnyStateNode): AnyStateNode[] {
const result: AnyStateNode[] = [];
const stack: AnyStateNode[] = [rootStateNode];

while (stack.length) {
const stateNode = stack.pop()!;
result.push(stateNode);
for (const child of Object.values(stateNode.states)) {
stack.push(child);
}
}

return result.sort((a, b) => a.order - b.order);
}

function createRoute(
eventType: string,
transition: AnyTransitionDefinition
): CompiledSignalRoute {
const signal = eventType === NULL_EVENT ? ALWAYS_SIGNAL : eventType;
const kind = toSignalKind(signal);
const source = `#${transition.source.id}`;
const targets = (transition.target ?? []).map((target) => `#${target.id}`);

const route: CompiledSignalRoute = {
signal,
eventType,
kind,
source,
targets,
reenter: transition.reenter,
guarded: transition.guard !== undefined,
transition,
toJSON: () => ({
signal,
eventType,
kind,
source,
targets,
reenter: transition.reenter,
guarded: transition.guard !== undefined
})
};

return route;
}

export function compileMachineSignals(
machine: AnyStateMachine,
options: CompileMachineSignalsOptions = {}
): CompiledMachineSignals {
const includeEventless = options.includeEventless ?? false;
const routes: CompiledSignalRoute[] = [];
const routesBySignal = new Map<string, CompiledSignalRoute[]>();

const addRoute = (route: CompiledSignalRoute) => {
routes.push(route);
const existing = routesBySignal.get(route.signal);
if (existing) {
existing.push(route);
return;
}
routesBySignal.set(route.signal, [route]);
};

for (const stateNode of collectStateNodes(machine.root)) {
for (const transitions of stateNode.transitions.values()) {
for (const transition of transitions) {
addRoute(createRoute(transition.eventType, transition));
}
}

if (!includeEventless || !stateNode.always) {
continue;
}

for (const transition of stateNode.always) {
addRoute(createRoute(transition.eventType, transition));
}
}

const signals: CompiledSignal[] = Array.from(routesBySignal.entries()).map(
([signal, signalRoutes]) => ({
signal,
kind: toSignalKind(signal),
routes: signalRoutes
})
);

const bySignal = Object.fromEntries(
signals.map((signal) => [signal.signal, signal])
);

return {
routes,
signals,
bySignal,
toJSON: () => ({
routes: routes.map((route) => route.toJSON()),
signals: signals.map((signal) => ({
signal: signal.signal,
kind: signal.kind,
routes: signal.routes.map((route) => route.toJSON())
}))
})
};
}
91 changes: 91 additions & 0 deletions packages/core/src/graph/test/signals.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { createMachine, setup } from '../../index.ts';
import { ALWAYS_SIGNAL, compileMachineSignals } from '../index.ts';

describe('compileMachineSignals()', () => {
it('should compile transitions into explicit signal routes', () => {
const machine = createMachine({
id: 'light',
initial: 'green',
states: {
green: {
on: {
TIMER: 'yellow'
}
},
yellow: {
on: {
TIMER: 'red'
}
},
red: {
always: {
target: 'green'
}
}
}
});

const compiled = compileMachineSignals(machine, { includeEventless: true });

expect(
compiled.bySignal.TIMER.routes.map((route) => ({
source: route.source,
targets: route.targets
}))
).toEqual([
{
source: '#light.green',
targets: ['#light.yellow']
},
{
source: '#light.yellow',
targets: ['#light.red']
}
]);

expect(
compiled.bySignal[ALWAYS_SIGNAL].routes.map((route) => ({
source: route.source,
targets: route.targets
}))
).toEqual([
{
source: '#light.red',
targets: ['#light.green']
}
]);
});

it('should compile xstate.route transitions with explicit route targets', () => {
const machine = setup({}).createMachine({
id: 'app',
initial: 'home',
states: {
home: {
id: 'home',
route: {}
},
settings: {
id: 'settings',
route: {}
},
notRoutable: {
route: {}
}
}
});

const compiled = compileMachineSignals(machine);
const routeSignal = compiled.bySignal['xstate.route'];

expect(routeSignal.kind).toBe('route');
expect(routeSignal.routes.map((route) => route.source)).toEqual([
'#app',
'#app'
]);
expect(routeSignal.routes.map((route) => route.targets).sort()).toEqual([
['#home'],
['#settings']
]);
});
});
Loading