diff --git a/.changeset/three-sails-rhyme.md b/.changeset/three-sails-rhyme.md new file mode 100644 index 0000000000..c1bd9cf491 --- /dev/null +++ b/.changeset/three-sails-rhyme.md @@ -0,0 +1,38 @@ +--- +'xstate': minor +--- + +Added routable states. States with `route: {}` and an explicit `id` can be navigated to from anywhere via a single `{ type: 'xstate.route', to: '#id' }` event. + +```ts +const machine = setup({}).createMachine({ + id: 'app', + initial: 'home', + states: { + home: { id: 'home', route: {} }, + dashboard: { + initial: 'overview', + states: { + overview: { id: 'overview', route: {} }, + settings: { id: 'settings', route: {} } + } + } + } +}); + +const actor = createActor(machine).start(); + +// Route directly to deeply nested state from anywhere +actor.send({ type: 'xstate.route', to: '#settings' }); +``` + +Routes support guards for conditional navigation: + +```ts +settings: { + id: 'settings', + route: { + guard: ({ context }) => context.role === 'admin' + } +} +``` diff --git a/packages/core/src/StateMachine.ts b/packages/core/src/StateMachine.ts index f070310083..5755468b79 100644 --- a/packages/core/src/StateMachine.ts +++ b/packages/core/src/StateMachine.ts @@ -9,6 +9,7 @@ import { } from './State.ts'; import { StateNode } from './StateNode.ts'; import { + formatRouteTransitions, getAllStateNodes, getInitialStateNodes, getStateNodeByPath, @@ -148,6 +149,7 @@ export class StateMachine< }); this.root._initialize(); + formatRouteTransitions(this.root); this.states = this.root.states; // TODO: remove! this.events = this.root.events; diff --git a/packages/core/src/setup.ts b/packages/core/src/setup.ts index 8d2ce7a6a0..a421d2e219 100644 --- a/packages/core/src/setup.ts +++ b/packages/core/src/setup.ts @@ -25,6 +25,7 @@ import { MetaObject, NonReducibleUnknown, ParameterizedObject, + RoutableStateId, SetupTypes, StateNodeConfig, StateSchema, @@ -251,7 +252,13 @@ type SetupReturn< config: TConfig ) => StateMachine< TContext, - TEvent, + | TEvent + | ([RoutableStateId] extends [never] + ? never + : { + type: 'xstate.route'; + to: RoutableStateId; + }), Cast< ToChildren>, Record diff --git a/packages/core/src/stateUtils.ts b/packages/core/src/stateUtils.ts index 9e0cbe35ed..a4460dd85d 100644 --- a/packages/core/src/stateUtils.ts +++ b/packages/core/src/stateUtils.ts @@ -383,6 +383,53 @@ export function formatTransitions< return transitions as Map[]>; } +/** + * Collects route transitions from all descendants with explicit IDs. Called + * once on the root node to avoid O(N²) repeated traversals. + */ +export function formatRouteTransitions(rootStateNode: AnyStateNode): void { + const routeTransitions: AnyTransitionDefinition[] = []; + const collectRoutes = (states: Record) => { + Object.values(states).forEach((sn) => { + if (sn.config.route && sn.config.id) { + const routeId = sn.config.id; + const userGuard = sn.config.route.guard; + const routeGuard = ( + args: { context: any; event: any }, + params: any + ) => { + if (args.event.to !== `#${routeId}`) { + return false; + } + if (!userGuard) { + return true; + } + if (typeof userGuard === 'function') { + return userGuard(args, params); + } + return true; + }; + const transition: AnyTransitionConfig = { + ...sn.config.route, + guard: routeGuard, + target: `#${routeId}` + }; + + routeTransitions.push( + formatTransition(rootStateNode, 'xstate.route', transition) + ); + } + if (sn.states) { + collectRoutes(sn.states); + } + }); + }; + collectRoutes(rootStateNode.states); + if (routeTransitions.length > 0) { + rootStateNode.transitions.set('xstate.route', routeTransitions); + } +} + export function formatInitialTransition< TContext extends MachineContext, TEvent extends EventObject diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7e516e7948..65793b694f 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1038,6 +1038,42 @@ export interface StateNodeConfig< /** A default target for a history state */ target?: string | undefined; // `| undefined` makes `HistoryStateNodeConfig` compatible with this interface (it extends it) under `exactOptionalPropertyTypes` + route?: RouteTransitionConfig< + TContext, + TEvent, + TEvent, + TActor, + TAction, + TGuard, + TDelay, + TEmitted + >; +} + +export interface RouteTransitionConfig< + TContext extends MachineContext, + TExpressionEvent extends EventObject, + TEvent extends EventObject, + TActor extends ProvidedActor, + TAction extends ParameterizedObject, + TGuard extends ParameterizedObject, + TDelay extends string, + TEmitted extends EventObject +> { + guard?: Guard; + actions?: Actions< + TContext, + TExpressionEvent, + TEvent, + undefined, + TActor, + TAction, + TGuard, + TDelay, + TEmitted + >; + meta?: Record; + description?: string; } export type AnyStateNodeConfig = StateNodeConfig< @@ -2499,6 +2535,7 @@ export type ToChildren = export type StateSchema = { id?: string; + route?: unknown; states?: Record; // Other types @@ -2542,6 +2579,16 @@ export type StateId< }> : never); +export type RoutableStateId = + | (TSchema extends { route: any; id: string } ? `#${TSchema['id']}` : never) + | (TSchema['states'] extends Record + ? Values<{ + [K in keyof TSchema['states'] & string]: RoutableStateId< + TSchema['states'][K] + >; + }> + : never); + export interface StateMachineTypes { context: MachineContext; events: EventObject; diff --git a/packages/core/test/route.test.ts b/packages/core/test/route.test.ts new file mode 100644 index 0000000000..cc2b6782f1 --- /dev/null +++ b/packages/core/test/route.test.ts @@ -0,0 +1,439 @@ +import { createActor, setup } from '../src'; + +describe('route', () => { + it('should transition directly to a route if route is an empty transition config', () => { + const machine = setup({}).createMachine({ + id: 'test', + initial: 'a', + states: { + a: {}, + b: { + id: 'b', + route: {} + }, + c: {} + } + }); + + const actor = createActor(machine).start(); + + actor.send({ + type: 'xstate.route', + to: '#b' + }); + + expect(actor.getSnapshot().value).toEqual('b'); + + // c has no route, so this should not transition + actor.send({ + type: 'xstate.route', + to: '#c' + } as any); + + expect(actor.getSnapshot().value).toEqual('b'); + }); + + it('should transition directly to a route if guard passes', () => { + const machine = setup({}).createMachine({ + id: 'test', + initial: 'a', + states: { + a: {}, + b: { + id: 'b', + route: { + guard: () => false + } + }, + c: { + id: 'c', + route: { + guard: () => true + } + } + } + }); + + const actor = createActor(machine).start(); + + expect(actor.getSnapshot().value).toEqual('a'); + + actor.send({ + type: 'xstate.route', + to: '#b' + }); + + expect(actor.getSnapshot().value).toEqual('a'); + + actor.send({ + type: 'xstate.route', + to: '#c' + }); + + expect(actor.getSnapshot().value).toEqual('c'); + }); + + it('should work with parallel states', () => { + const todoMachine = setup({}).createMachine({ + id: 'todos', + type: 'parallel', + states: { + todo: { + initial: 'new', + states: { + new: {}, + editing: {} + } + }, + filter: { + initial: 'all', + states: { + all: { + id: 'filter-all', + route: {} + }, + active: { + id: 'filter-active', + route: {} + }, + completed: { + id: 'filter-completed', + route: {} + } + } + } + } + }); + + const todoActor = createActor(todoMachine).start(); + + expect(todoActor.getSnapshot().value).toEqual({ + todo: 'new', + filter: 'all' + }); + + todoActor.send({ + type: 'xstate.route', + to: '#filter-active' + }); + + expect(todoActor.getSnapshot().value).toEqual({ + todo: 'new', + filter: 'active' + }); + }); + + it('route events are strongly typed', () => { + const machine = setup({ + types: { + events: {} as never + } + }).createMachine({ + id: 'root', + initial: 'aRoute', + states: { + aRoute: { + id: 'aRoute', + route: {} + }, + notARoute: { + initial: 'childRoute', + states: { + childRoute: { + id: 'childRoute', + route: {} + } + } + } + } + }); + + const actor = createActor(machine).start(); + + actor.send({ + type: 'xstate.route', + to: '#aRoute' + }); + + actor.send({ + type: 'xstate.route', + to: '#childRoute' + }); + + actor.send({ + type: 'xstate.route', + // @ts-expect-error - 'notARoute' has no route config + to: 'notARoute' + }); + + actor.send({ + type: 'xstate.route', + // @ts-expect-error - 'root' is not routable + to: 'root' + }); + + actor.send({ + type: 'xstate.route', + // @ts-expect-error - 'blahblah' does not exist + to: 'blahblah' + }); + }); + + it('route config without id should not generate route events', () => { + const machine = setup({ + types: { + events: {} as never + } + }).createMachine({ + id: 'test', + initial: 'a', + states: { + a: { + // route without id — should NOT be routable + route: {} + }, + b: { + id: 'b', + route: {} + } + } + }); + + const actor = createActor(machine).start(); + + // Only 'b' should be a valid route target + actor.send({ + type: 'xstate.route', + to: '#b' + }); + + expect(actor.getSnapshot().value).toEqual('b'); + }); + + it('machine.root.on should include route events', () => { + const machine = setup({}).createMachine({ + id: 'test', + initial: 'a', + states: { + a: {}, + b: { + id: 'b', + route: {} + }, + c: { + id: 'c', + route: { + guard: () => true + } + } + } + }); + + expect(machine.root.on['xstate.route']).toBeDefined(); + }); + + it('nested state on should include route events for child routes', () => { + const machine = setup({}).createMachine({ + id: 'app', + initial: 'home', + states: { + home: { + id: 'home', + route: {} + }, + dashboard: { + id: 'dashboard', + initial: 'overview', + route: {}, + states: { + overview: { + id: 'overview', + route: {} + }, + settings: { + id: 'settings', + route: {} + } + } + } + } + }); + + const a = createActor(machine).start(); + a.send({ + type: 'xstate.route', + to: '#overview' + }); + + expect(a.getSnapshot().value).toEqual({ dashboard: 'overview' }); + + // All routes should be accessible via 'xstate.route' + expect(machine.root.on['xstate.route']).toBeDefined(); + }); + + it('parallel state on should include route events', () => { + const machine = setup({}).createMachine({ + id: 'todos', + type: 'parallel', + states: { + list: { + initial: 'idle', + states: { + idle: {}, + loading: {} + } + }, + filter: { + initial: 'all', + states: { + all: { + id: 'filter-all', + route: {} + }, + active: { + id: 'filter-active', + route: {} + }, + completed: { + id: 'filter-completed', + route: {} + } + } + } + } + }); + + // Routes should be accessible + expect(machine.root.on['xstate.route']).toBeDefined(); + }); + + it('should route to deeply nested state from anywhere', () => { + const machine = setup({}).createMachine({ + id: 'app', + initial: 'home', + states: { + home: { + id: 'home', + route: {} + }, + dashboard: { + initial: 'overview', + states: { + overview: { + id: 'overview', + route: {} + } + } + } + } + }); + + const actor = createActor(machine).start(); + + // Should be able to route to deeply nested state from root + expect(actor.getSnapshot().value).toEqual('home'); + + actor.send({ type: 'xstate.route', to: '#overview' }); + + expect(actor.getSnapshot().value).toEqual({ dashboard: 'overview' }); + }); + + it('should re-enter when routing to the current state', () => { + let entries = 0; + const machine = setup({}).createMachine({ + id: 'test', + initial: 'a', + states: { + a: { + id: 'a', + route: {}, + entry: () => { + entries++; + } + } + } + }); + + const actor = createActor(machine).start(); + expect(actor.getSnapshot().value).toEqual('a'); + entries = 0; + + actor.send({ type: 'xstate.route', to: '#a' }); + + expect(actor.getSnapshot().value).toEqual('a'); + expect(entries).toEqual(1); + }); + + it('should route to self with guard', () => { + let allowed = false; + let entries = 0; + const machine = setup({}).createMachine({ + id: 'test', + initial: 'a', + states: { + a: { + id: 'a', + route: { + guard: () => allowed + }, + entry: () => { + entries++; + } + }, + b: { id: 'b', route: {} } + } + }); + + const actor = createActor(machine).start(); + entries = 0; + + actor.send({ type: 'xstate.route', to: '#a' }); + expect(entries).toEqual(0); + + allowed = true; + actor.send({ type: 'xstate.route', to: '#a' }); + expect(entries).toEqual(1); + }); + + it('should not route using dot-separated nested id like #id.nested', () => { + const machine = setup({ + types: { + // needed to avoid AnyEventObject widening + events: {} as never + } + }).createMachine({ + id: 'app', + initial: 'home', + states: { + home: { + id: 'home', + route: {} + }, + dashboard: { + id: 'dashboard', + initial: 'overview', + route: {}, + states: { + overview: { + id: 'overview', + route: {} + } + } + } + } + }); + + const actor = createActor(machine).start(); + + expect(actor.getSnapshot().value).toEqual('home'); + + // Dot-separated ids should not work as route targets + actor.send({ + type: 'xstate.route', + // @ts-expect-error - dot-separated ids are not valid route targets + to: '#dashboard.overview' + }); + + expect(actor.getSnapshot().value).toEqual('home'); + }); +}); diff --git a/packages/core/test/setup.types.test.ts b/packages/core/test/setup.types.test.ts index 619ad9d166..928e730165 100644 --- a/packages/core/test/setup.types.test.ts +++ b/packages/core/test/setup.types.test.ts @@ -2398,6 +2398,43 @@ describe('setup()', () => { ((_accept: ContextFrom) => {})({ myVar: 'whatever' }); }); + + it('should strongly type the state IDs in snapshot.getMeta()', () => { + const machine = setup({}).createMachine({ + id: 'root', + initial: 'parentState', + states: { + parentState: { + meta: {}, + initial: 'childState', + states: { + childState: { + meta: {} + }, + stateWithId: { + id: 'state with id', + meta: {} + } + } + } + } + }); + + const actor = createActor(machine); + + const metaValues = actor.getSnapshot().getMeta(); + + metaValues.root; + metaValues['root.parentState']; + metaValues['root.parentState.childState']; + metaValues['state with id']; + + // @ts-expect-error + metaValues['root.parentState.stateWithId']; + + // @ts-expect-error + metaValues['unknown state']; + }); }); describe('createStateConfig', () => {