@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
- .join("");
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, setActiveRouteEvents, meta) {
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
- setActiveRouteEvents([...routingEvents, match.event]);
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
  }
@@ -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(), setActiveRouteEvents, { onloadEvent: isLikelyPageLoad() });
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, setActiveRouteEvents, (_a = location.state) === null || _a === void 0 ? void 0 : _a.meta);
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
@@ -2,7 +2,7 @@
2
2
  "name": "@koordinates/xstate-tree",
3
3
  "main": "lib/index.js",
4
4
  "types": "lib/xstate-tree.d.ts",
5
- "version": "3.0.0-beta.1",
5
+ "version": "3.0.0",
6
6
  "license": "MIT",
7
7
  "description": "Build UIs with Actors using xstate and React",
8
8
  "keywords": [