Skip to content

Commit a741fe7

Browse files
[v5] Routable states (#4184)
* Add route + tests * Add route tests * Fix types * Fix types * One down * Default to typegen disabled * Remove stuff * More progress * Some surgery * Actors * This is fun * This is fun * More deletion * Remove ResolveTypegenMeta type param * Renaming * Cleanup * Remove typgenTypes files + mentions * Simplification * Rename TResolvedTypesMeta -> TTypes * More simplification * Strongly type meta keyes * Add test * Clean up State * Consistency: use full state IDs * Strongly type route events * Deep routing * Changeset * Route by id with single event type * Remove reenter prop * Move route transition formatting to root * Update packages/core/src/types.ts Co-authored-by: Mateusz Burzyński <mateuszburzynski@gmail.com> * Update routable states to use '#' prefix for navigation and adjust related tests and type definitions * Add test --------- Co-authored-by: Mateusz Burzyński <mateuszburzynski@gmail.com>
1 parent ad809a0 commit a741fe7

7 files changed

Lines changed: 618 additions & 1 deletion

File tree

.changeset/three-sails-rhyme.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
'xstate': minor
3+
---
4+
5+
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.
6+
7+
```ts
8+
const machine = setup({}).createMachine({
9+
id: 'app',
10+
initial: 'home',
11+
states: {
12+
home: { id: 'home', route: {} },
13+
dashboard: {
14+
initial: 'overview',
15+
states: {
16+
overview: { id: 'overview', route: {} },
17+
settings: { id: 'settings', route: {} }
18+
}
19+
}
20+
}
21+
});
22+
23+
const actor = createActor(machine).start();
24+
25+
// Route directly to deeply nested state from anywhere
26+
actor.send({ type: 'xstate.route', to: '#settings' });
27+
```
28+
29+
Routes support guards for conditional navigation:
30+
31+
```ts
32+
settings: {
33+
id: 'settings',
34+
route: {
35+
guard: ({ context }) => context.role === 'admin'
36+
}
37+
}
38+
```

packages/core/src/StateMachine.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
} from './State.ts';
1010
import { StateNode } from './StateNode.ts';
1111
import {
12+
formatRouteTransitions,
1213
getAllStateNodes,
1314
getStateNodeByPath,
1415
getStateNodes,
@@ -147,6 +148,7 @@ export class StateMachine<
147148
});
148149

149150
this.root._initialize();
151+
formatRouteTransitions(this.root);
150152

151153
this.states = this.root.states; // TODO: remove!
152154
this.events = this.root.events;

packages/core/src/setup.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
MetaObject,
2626
NonReducibleUnknown,
2727
ParameterizedObject,
28+
RoutableStateId,
2829
SetupTypes,
2930
StateNodeConfig,
3031
StateSchema,
@@ -251,7 +252,13 @@ export type SetupReturn<
251252
config: TConfig
252253
) => StateMachine<
253254
TContext,
254-
TEvent,
255+
| TEvent
256+
| ([RoutableStateId<TConfig>] extends [never]
257+
? never
258+
: {
259+
type: 'xstate.route';
260+
to: RoutableStateId<TConfig>;
261+
}),
255262
Cast<
256263
ToChildren<ToProvidedActor<TChildrenMap, TActors>>,
257264
Record<string, AnyActorRef | undefined>

packages/core/src/stateUtils.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,53 @@ export function formatTransitions<
384384
return transitions as Map<string, TransitionDefinition<TContext, any>[]>;
385385
}
386386

387+
/**
388+
* Collects route transitions from all descendants with explicit IDs. Called
389+
* once on the root node to avoid O(N²) repeated traversals.
390+
*/
391+
export function formatRouteTransitions(rootStateNode: AnyStateNode): void {
392+
const routeTransitions: AnyTransitionDefinition[] = [];
393+
const collectRoutes = (states: Record<string, AnyStateNode>) => {
394+
Object.values(states).forEach((sn) => {
395+
if (sn.config.route && sn.config.id) {
396+
const routeId = sn.config.id;
397+
const userGuard = sn.config.route.guard;
398+
const routeGuard = (
399+
args: { context: any; event: any },
400+
params: any
401+
) => {
402+
if (args.event.to !== `#${routeId}`) {
403+
return false;
404+
}
405+
if (!userGuard) {
406+
return true;
407+
}
408+
if (typeof userGuard === 'function') {
409+
return userGuard(args, params);
410+
}
411+
return true;
412+
};
413+
const transition: AnyTransitionConfig = {
414+
...sn.config.route,
415+
guard: routeGuard,
416+
target: `#${routeId}`
417+
};
418+
419+
routeTransitions.push(
420+
formatTransition(rootStateNode, 'xstate.route', transition)
421+
);
422+
}
423+
if (sn.states) {
424+
collectRoutes(sn.states);
425+
}
426+
});
427+
};
428+
collectRoutes(rootStateNode.states);
429+
if (routeTransitions.length > 0) {
430+
rootStateNode.transitions.set('xstate.route', routeTransitions);
431+
}
432+
}
433+
387434
export function formatInitialTransition<
388435
TContext extends MachineContext,
389436
TEvent extends EventObject

packages/core/src/types.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1038,6 +1038,42 @@ export interface StateNodeConfig<
10381038

10391039
/** A default target for a history state */
10401040
target?: string | undefined; // `| undefined` makes `HistoryStateNodeConfig` compatible with this interface (it extends it) under `exactOptionalPropertyTypes`
1041+
route?: RouteTransitionConfig<
1042+
TContext,
1043+
TEvent,
1044+
TEvent,
1045+
TActor,
1046+
TAction,
1047+
TGuard,
1048+
TDelay,
1049+
TEmitted
1050+
>;
1051+
}
1052+
1053+
export interface RouteTransitionConfig<
1054+
TContext extends MachineContext,
1055+
TExpressionEvent extends EventObject,
1056+
TEvent extends EventObject,
1057+
TActor extends ProvidedActor,
1058+
TAction extends ParameterizedObject,
1059+
TGuard extends ParameterizedObject,
1060+
TDelay extends string,
1061+
TEmitted extends EventObject
1062+
> {
1063+
guard?: Guard<TContext, TExpressionEvent, undefined, TGuard>;
1064+
actions?: Actions<
1065+
TContext,
1066+
TExpressionEvent,
1067+
TEvent,
1068+
undefined,
1069+
TActor,
1070+
TAction,
1071+
TGuard,
1072+
TDelay,
1073+
TEmitted
1074+
>;
1075+
meta?: Record<string, any>;
1076+
description?: string;
10411077
}
10421078

10431079
export type AnyStateNodeConfig = StateNodeConfig<
@@ -2499,6 +2535,7 @@ export type ToChildren<TActor extends ProvidedActor> =
24992535

25002536
export type StateSchema = {
25012537
id?: string;
2538+
route?: unknown;
25022539
states?: Record<string, StateSchema>;
25032540

25042541
// Other types
@@ -2542,6 +2579,16 @@ export type StateId<
25422579
}>
25432580
: never);
25442581

2582+
export type RoutableStateId<TSchema extends StateSchema> =
2583+
| (TSchema extends { route: any; id: string } ? `#${TSchema['id']}` : never)
2584+
| (TSchema['states'] extends Record<string, any>
2585+
? Values<{
2586+
[K in keyof TSchema['states'] & string]: RoutableStateId<
2587+
TSchema['states'][K]
2588+
>;
2589+
}>
2590+
: never);
2591+
25452592
export interface StateMachineTypes {
25462593
context: MachineContext;
25472594
events: EventObject;

0 commit comments

Comments
 (0)