@koordinates/xstate-tree 3.0.0-beta.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ While xstate-tree manages your application state, it does not have a mechanism f
|
|
|
15
15
|
|
|
16
16
|
At Koordinates we use xstate-tree for all new UI development. Our desktop application, built on top of [Kart](https://kartproject.org/) our Geospatial version control system, is built entirely with xstate-tree using GraphQL for global state.
|
|
17
17
|
|
|
18
|
-
A minimal example of a single machine tree ([CodeSandbox](https://codesandbox.io/s/xstate-tree-b0el6e-forked-4i6txh?file=/src/index.tsx)):
|
|
18
|
+
A minimal example of a single machine tree (See [CodeSandbox](https://codesandbox.io/s/xstate-tree-b0el6e-forked-4i6txh?file=/src/index.tsx) for slightly expanded multi-machine example):
|
|
19
19
|
|
|
20
20
|
```tsx
|
|
21
21
|
import React from "react";
|
|
@@ -36,6 +36,7 @@ type Events =
|
|
|
36
36
|
type Context = { incremented: number };
|
|
37
37
|
|
|
38
38
|
// If this tree had more than a single machine the slots to render child machines into would be defined here
|
|
39
|
+
// see the codesandbox example for an expanded demonstration that uses slots
|
|
39
40
|
const slots = [];
|
|
40
41
|
|
|
41
42
|
// A standard xstate machine, nothing extra is needed for xstate-tree
|
|
@@ -66,7 +66,7 @@ export function buildCreateRoute(history, basePath) {
|
|
|
66
66
|
}
|
|
67
67
|
return parentRoutes;
|
|
68
68
|
}
|
|
69
|
-
return ({ event, matcher, reverser, paramsSchema, querySchema, }) => {
|
|
69
|
+
return ({ event, matcher, reverser, paramsSchema, querySchema, redirect, }) => {
|
|
70
70
|
let fullParamsSchema = paramsSchema;
|
|
71
71
|
let parentRoute = baseRoute;
|
|
72
72
|
while (fullParamsSchema && parentRoute) {
|
|
@@ -82,6 +82,7 @@ export function buildCreateRoute(history, basePath) {
|
|
|
82
82
|
paramsSchema,
|
|
83
83
|
querySchema,
|
|
84
84
|
parent: baseRoute,
|
|
85
|
+
redirect,
|
|
85
86
|
matcher: matcher,
|
|
86
87
|
reverser: reverser,
|
|
87
88
|
// @ts-ignore :cry:
|
|
@@ -142,7 +143,7 @@ export function buildCreateRoute(history, basePath) {
|
|
|
142
143
|
const parentRoutes = getParentArray();
|
|
143
144
|
const baseUrl = parentRoutes
|
|
144
145
|
.map((route) => route.reverser({ params }))
|
|
145
|
-
.
|
|
146
|
+
.reduce((fullUrl, urlPartial) => joinRoutes(fullUrl, urlPartial), "");
|
|
146
147
|
return `${joinRoutes(baseUrl, reverser({ params, query }))}`;
|
|
147
148
|
},
|
|
148
149
|
// @ts-ignore :cry:
|
|
@@ -3,7 +3,7 @@ import { matchRoute } from "../matchRoute";
|
|
|
3
3
|
/**
|
|
4
4
|
* @internal
|
|
5
5
|
*/
|
|
6
|
-
export function handleLocationChange(routes, basePath, path, search,
|
|
6
|
+
export function handleLocationChange(routes, basePath, path, search, meta) {
|
|
7
7
|
console.debug("[xstate-tree] Matching routes", basePath, path, search, meta);
|
|
8
8
|
const match = matchRoute(routes, basePath, path, search);
|
|
9
9
|
console.debug("[xstate-tree] Match result", match);
|
|
@@ -14,9 +14,11 @@ export function handleLocationChange(routes, basePath, path, search, setActiveRo
|
|
|
14
14
|
};
|
|
15
15
|
// @ts-ignore the event won't match GlobalEvents
|
|
16
16
|
broadcast(fourOhFour);
|
|
17
|
+
return;
|
|
17
18
|
}
|
|
18
19
|
else if (match.type === "match-error") {
|
|
19
20
|
console.error("Error matching route for", location.pathname);
|
|
21
|
+
return;
|
|
20
22
|
}
|
|
21
23
|
else {
|
|
22
24
|
const matchedEvent = match.event;
|
|
@@ -29,7 +31,7 @@ export function handleLocationChange(routes, basePath, path, search, setActiveRo
|
|
|
29
31
|
routingEvents.push(route.parent.getEvent({ params, query: {}, meta: { ...(meta !== null && meta !== void 0 ? meta : {}) } }));
|
|
30
32
|
route = route.parent;
|
|
31
33
|
}
|
|
32
|
-
|
|
34
|
+
const clonedRoutingEvents = [...routingEvents];
|
|
33
35
|
while (routingEvents.length > 0) {
|
|
34
36
|
const event = routingEvents.pop();
|
|
35
37
|
// copy the originalUrl to all parent events
|
|
@@ -39,5 +41,9 @@ export function handleLocationChange(routes, basePath, path, search, setActiveRo
|
|
|
39
41
|
}
|
|
40
42
|
// @ts-ignore the event won't match GlobalEvents
|
|
41
43
|
broadcast(matchedEvent);
|
|
44
|
+
return {
|
|
45
|
+
events: [...clonedRoutingEvents, match.event],
|
|
46
|
+
matchedRoute: match.route,
|
|
47
|
+
};
|
|
42
48
|
}
|
|
43
49
|
}
|
package/lib/xstate-tree.d.ts
CHANGED
|
@@ -39,6 +39,7 @@ export declare type AnyRoute = {
|
|
|
39
39
|
querySchema?: Z.ZodObject<any>;
|
|
40
40
|
matcher: (url: string, query: ParsedQuery<string> | undefined) => any;
|
|
41
41
|
reverser: any;
|
|
42
|
+
redirect?: any;
|
|
42
43
|
};
|
|
43
44
|
|
|
44
45
|
/**
|
|
@@ -105,6 +106,7 @@ export declare function buildCreateRoute(history: XstateTreeHistory, basePath: s
|
|
|
105
106
|
paramsSchema?: TParamsSchema | undefined;
|
|
106
107
|
querySchema?: TQuerySchema | undefined;
|
|
107
108
|
meta?: TMeta | undefined;
|
|
109
|
+
redirect?: RouteRedirect<MergeRouteTypes<RouteParams<TBaseRoute>, ResolveZodType<TParamsSchema>>, ResolveZodType<TQuerySchema>, MergeRouteTypes<RouteMeta<TBaseRoute>, TMeta> & SharedMeta> | undefined;
|
|
108
110
|
}) => Route<MergeRouteTypes<RouteParams<TBaseRoute>, ResolveZodType<TParamsSchema>>, ResolveZodType<TQuerySchema>, TEvent, MergeRouteTypes<RouteMeta<TBaseRoute>, TMeta> & SharedMeta>;
|
|
109
111
|
route<TBaseRoute_1 extends AnyRoute>(baseRoute?: TBaseRoute_1 | undefined): <TEvent_1 extends string, TParamsSchema_1 extends Z.ZodObject<any, "strip", Z.ZodTypeAny, {
|
|
110
112
|
[x: string]: any;
|
|
@@ -114,11 +116,12 @@ export declare function buildCreateRoute(history: XstateTreeHistory, basePath: s
|
|
|
114
116
|
[x: string]: any;
|
|
115
117
|
}, {
|
|
116
118
|
[x: string]: any;
|
|
117
|
-
}> | undefined, TMeta_1 extends Record<string, unknown>>({ event, matcher, reverser, paramsSchema, querySchema, }: {
|
|
119
|
+
}> | undefined, TMeta_1 extends Record<string, unknown>>({ event, matcher, reverser, paramsSchema, querySchema, redirect, }: {
|
|
118
120
|
event: TEvent_1;
|
|
119
121
|
paramsSchema?: TParamsSchema_1 | undefined;
|
|
120
122
|
querySchema?: TQuerySchema_1 | undefined;
|
|
121
123
|
meta?: TMeta_1 | undefined;
|
|
124
|
+
redirect?: RouteRedirect<MergeRouteTypes<RouteParams<TBaseRoute_1>, ResolveZodType<TParamsSchema_1>>, ResolveZodType<TQuerySchema_1>, MergeRouteTypes<RouteMeta<TBaseRoute_1>, TMeta_1> & SharedMeta> | undefined;
|
|
122
125
|
/**
|
|
123
126
|
* Determines if the route matches the given url and query
|
|
124
127
|
*
|
|
@@ -500,6 +503,7 @@ export declare type Route<TParams, TQuery, TEvent, TMeta> = {
|
|
|
500
503
|
parent?: AnyRoute;
|
|
501
504
|
paramsSchema?: Z.ZodObject<any>;
|
|
502
505
|
querySchema?: Z.ZodObject<any>;
|
|
506
|
+
redirect?: RouteRedirect<TParams, TQuery, TMeta>;
|
|
503
507
|
};
|
|
504
508
|
|
|
505
509
|
/**
|
|
@@ -545,6 +549,13 @@ export declare type RouteMeta<T> = T extends Route<any, any, any, infer TMeta> ?
|
|
|
545
549
|
*/
|
|
546
550
|
export declare type RouteParams<T> = T extends Route<infer TParams, any, any, any> ? TParams : undefined;
|
|
547
551
|
|
|
552
|
+
declare type RouteRedirect<TParams, TQuery, TMeta> = (args: MakeEmptyObjectPropertiesOptional<{
|
|
553
|
+
params: TParams;
|
|
554
|
+
query: TQuery;
|
|
555
|
+
meta?: TMeta;
|
|
556
|
+
abortSignal: AbortSignal;
|
|
557
|
+
}>) => Promise<undefined | RouteArguments<Partial<TParams>, Partial<TQuery>, TMeta>>;
|
|
558
|
+
|
|
548
559
|
/**
|
|
549
560
|
* @public
|
|
550
561
|
*/
|
package/lib/xstateTree.js
CHANGED
|
@@ -6,7 +6,7 @@ import { handleLocationChange, RoutingContext, } from "./routing";
|
|
|
6
6
|
import { useActiveRouteEvents } from "./routing/providers";
|
|
7
7
|
import { useConstant } from "./useConstant";
|
|
8
8
|
import { useService } from "./useService";
|
|
9
|
-
import { isLikelyPageLoad } from "./utils";
|
|
9
|
+
import { assertIsDefined, isLikelyPageLoad } from "./utils";
|
|
10
10
|
export const emitter = new TinyEmitter();
|
|
11
11
|
/**
|
|
12
12
|
* @public
|
|
@@ -173,6 +173,7 @@ export function buildRootComponent(machine, routing) {
|
|
|
173
173
|
}
|
|
174
174
|
const RootComponent = function XstateTreeRootComponent() {
|
|
175
175
|
const [_, __, interpreter] = useMachine(machine, { devTools: true });
|
|
176
|
+
const [activeRoute, setActiveRoute] = useState(undefined);
|
|
176
177
|
const activeRouteEventsRef = useRef([]);
|
|
177
178
|
const [forceRenderValue, forceRender] = useState(false);
|
|
178
179
|
const setActiveRouteEvents = (events) => {
|
|
@@ -187,11 +188,72 @@ export function buildRootComponent(machine, routing) {
|
|
|
187
188
|
emitter.off("event", handler);
|
|
188
189
|
};
|
|
189
190
|
}, [interpreter]);
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
if (activeRoute === undefined) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const controller = new AbortController();
|
|
196
|
+
const routes = [activeRoute];
|
|
197
|
+
let route = activeRoute;
|
|
198
|
+
while (route.parent) {
|
|
199
|
+
routes.unshift(route.parent);
|
|
200
|
+
route = route.parent;
|
|
201
|
+
}
|
|
202
|
+
const routeEventPairs = [];
|
|
203
|
+
const activeRoutesEvent = activeRouteEventsRef.current.find((e) => e.type === activeRoute.event);
|
|
204
|
+
assertIsDefined(activeRoutesEvent);
|
|
205
|
+
for (let i = 0; i < routes.length; i++) {
|
|
206
|
+
const route = routes[i];
|
|
207
|
+
const routeEvent = activeRouteEventsRef.current[i];
|
|
208
|
+
routeEventPairs.push([route, routeEvent]);
|
|
209
|
+
}
|
|
210
|
+
const routePairsWithRedirects = routeEventPairs.filter(([route]) => {
|
|
211
|
+
return route.redirect !== undefined;
|
|
212
|
+
});
|
|
213
|
+
const redirectPromises = routePairsWithRedirects.map(([route, event]) => {
|
|
214
|
+
assertIsDefined(route.redirect);
|
|
215
|
+
return route.redirect({
|
|
216
|
+
signal: controller.signal,
|
|
217
|
+
query: event.query,
|
|
218
|
+
params: event.params,
|
|
219
|
+
meta: event.meta,
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
void Promise.all(redirectPromises).then((redirects) => {
|
|
223
|
+
var _a, _b, _c;
|
|
224
|
+
const didAnyRedirect = redirects.some((x) => x !== undefined);
|
|
225
|
+
if (!didAnyRedirect || controller.signal.aborted) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const routeArguments = redirects.reduce((args, redirect) => {
|
|
229
|
+
if (redirect) {
|
|
230
|
+
args.query = { ...args.query, ...redirect.query };
|
|
231
|
+
args.params = { ...args.params, ...redirect.params };
|
|
232
|
+
args.meta = { ...args.meta, ...redirect.meta };
|
|
233
|
+
}
|
|
234
|
+
return args;
|
|
235
|
+
}, {
|
|
236
|
+
// since the redirect results are partials, need to merge them with the original event
|
|
237
|
+
// params/query to ensure that all params/query are present
|
|
238
|
+
query: { ...((_a = activeRoutesEvent.query) !== null && _a !== void 0 ? _a : {}) },
|
|
239
|
+
params: { ...((_b = activeRoutesEvent.params) !== null && _b !== void 0 ? _b : {}) },
|
|
240
|
+
meta: { ...((_c = activeRoutesEvent.meta) !== null && _c !== void 0 ? _c : {}) },
|
|
241
|
+
});
|
|
242
|
+
activeRoute.navigate(routeArguments);
|
|
243
|
+
});
|
|
244
|
+
return () => {
|
|
245
|
+
controller.abort();
|
|
246
|
+
};
|
|
247
|
+
}, [activeRoute]);
|
|
190
248
|
useEffect(() => {
|
|
191
249
|
if (routing) {
|
|
192
250
|
const { getPathName = () => window.location.pathname, getQueryString = () => window.location.search, } = routing;
|
|
193
251
|
const queryString = getQueryString();
|
|
194
|
-
handleLocationChange(routing.routes, routing.basePath, getPathName(), getQueryString(),
|
|
252
|
+
const result = handleLocationChange(routing.routes, routing.basePath, getPathName(), getQueryString(), { onloadEvent: isLikelyPageLoad() });
|
|
253
|
+
if (result) {
|
|
254
|
+
setActiveRouteEvents(result.events);
|
|
255
|
+
setActiveRoute({ ...result.matchedRoute });
|
|
256
|
+
}
|
|
195
257
|
// Hack to ensure the initial location doesn't have undefined state
|
|
196
258
|
// It's not supposed to, but it does for some reason
|
|
197
259
|
// And the history library ignores popstate events with undefined state
|
|
@@ -202,7 +264,11 @@ export function buildRootComponent(machine, routing) {
|
|
|
202
264
|
if (routing) {
|
|
203
265
|
const unsub = routing.history.listen((location) => {
|
|
204
266
|
var _a;
|
|
205
|
-
handleLocationChange(routing.routes, routing.basePath, location.pathname, location.search,
|
|
267
|
+
const result = handleLocationChange(routing.routes, routing.basePath, location.pathname, location.search, (_a = location.state) === null || _a === void 0 ? void 0 : _a.meta);
|
|
268
|
+
if (result) {
|
|
269
|
+
setActiveRouteEvents(result.events);
|
|
270
|
+
setActiveRoute({ ...result.matchedRoute });
|
|
271
|
+
}
|
|
206
272
|
});
|
|
207
273
|
return () => {
|
|
208
274
|
unsub();
|
package/package.json
CHANGED