diff --git a/CHANGELOG.md b/CHANGELOG.md index c586569..42eb207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,50 @@ +# [4.1.0-beta.6](https://github.com/koordinates/xstate-tree/compare/v4.1.0-beta.5...v4.1.0-beta.6) (2022-12-07) + + +### feat + +* **routing:** add TestRoutingContext ([1534fd5](https://github.com/koordinates/xstate-tree/commit/1534fd565e807e3dda41c54a11107848c99ccbfd)) + +# [4.1.0-beta.5](https://github.com/koordinates/xstate-tree/compare/v4.1.0-beta.4...v4.1.0-beta.5) (2022-12-07) + + +### fix + +* **builders:** v2 builder view triggering rule of hooks ([cfd7b5a](https://github.com/koordinates/xstate-tree/commit/cfd7b5a86308cc6653950bc2beef08201d27c1ef)) + +# [4.1.0-beta.4](https://github.com/koordinates/xstate-tree/compare/v4.1.0-beta.3...v4.1.0-beta.4) (2022-12-06) + + +### feat + +* **routing:** useRouteArgsIfActive ([f28dc6f](https://github.com/koordinates/xstate-tree/commit/f28dc6f1555fa5313cdffde9d132bd0009027591)) + +# [4.1.0-beta.3](https://github.com/koordinates/xstate-tree/compare/v4.1.0-beta.2...v4.1.0-beta.3) (2022-12-06) + + +### feat + +* **routing:** useIsRouteActive ([9b7b689](https://github.com/koordinates/xstate-tree/commit/9b7b689a7546f994ea72dd3256d8e4807e6038f3)) + +# [4.1.0-beta.2](https://github.com/koordinates/xstate-tree/compare/v4.1.0-beta.1...v4.1.0-beta.2) (2022-11-29) + + +### feat + +* **routing:** history argument is now a function ([5297c8b](https://github.com/koordinates/xstate-tree/commit/5297c8b681a15a41a464f94ae48c888844df50ed)) + +# [4.1.0-beta.1](https://github.com/koordinates/xstate-tree/compare/v4.0.1...v4.1.0-beta.1) (2022-11-29) + + +### chore + +* **view:** deprecate inState ([4c8ad20](https://github.com/koordinates/xstate-tree/commit/4c8ad20fc0386566ecedd82d7ddef47d33f6bfa7)), closes [#33](https://github.com/koordinates/xstate-tree/issues/33) + + +### feat + +* **builders:** builders 2.0 ([f285acd](https://github.com/koordinates/xstate-tree/commit/f285acdde07e277bff84698cdcf71bea3b2c8abc)), closes [#14](https://github.com/koordinates/xstate-tree/issues/14) + ## [4.0.1](https://github.com/koordinates/xstate-tree/compare/v4.0.0...v4.0.1) (2022-11-10) diff --git a/README.md b/README.md index b7d83ff..8c5e93a 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,7 @@ import { createRoot } from "react-dom/client"; import { createMachine } from "xstate"; import { assign } from "@xstate/immer"; import { - buildSelectors, - buildActions, - buildView, - buildXStateTreeMachine, + createXStateTreeMachine buildRootComponent } from "@koordinates/xstate-tree"; @@ -35,10 +32,6 @@ type Events = | { type: "INCREMENT"; amount: number }; type Context = { incremented: number }; -// If this tree had more than a single machine the slots to render child machines into would be defined here -// see the codesandbox example for an expanded demonstration that uses slots -const slots = []; - // A standard xstate machine, nothing extra is needed for xstate-tree const machine = createMachine( { @@ -74,39 +67,42 @@ const machine = createMachine( } ); -// Selectors to transform the machines state into a representation useful for the view -const selectors = buildSelectors(machine, (ctx, canHandleEvent) => ({ - canIncrement: canHandleEvent({ type: "INCREMENT", amount: 1 }), - showSecret: ctx.incremented > 10, - count: ctx.incremented -})); - -// Actions to abstract away the details of sending events to the machine -const actions = buildActions(machine, selectors, (send, selectors) => ({ - increment(amount: number) { - send({ - type: "INCREMENT", - amount: selectors.count > 4 ? amount * 2 : amount - }); +const RootMachine = createXStateTreeMachine(machine, { + // Selectors to transform the machines state into a representation useful for the view + selectors({ ctx, canHandleEvent, inState }) { + return { + canIncrement: canHandleEvent({ type: "INCREMENT", amount: 1 }), + showSecret: ctx.incremented > 10, + count: ctx.incremented, + active: inState("active") + } }, - switch() { - send({ type: "SWITCH_CLICKED" }); - } -})); - -// A view to bring it all together -// the return value is a plain React view that can be rendered anywhere by passing in the needed props -// the view has no knowledge of the machine it's bound to -const view = buildView( - machine, - selectors, - actions, - slots, - ({ actions, selectors, inState }) => { + // Actions to abstract away the details of sending events to the machine + actions({ send, selectors }) { + return { + increment(amount: number) { + send({ + type: "INCREMENT", + amount: selectors.count > 4 ? amount * 2 : amount + }); + }, + switch() { + send({ type: "SWITCH_CLICKED" }); + } + } + }, + + // If this tree had more than a single machine the slots to render child machines into would be defined here + // see the codesandbox example for an expanded demonstration that uses slots + slots: [], + // A view to bring it all together + // the return value is a plain React view that can be rendered anywhere by passing in the needed props + // the view has no knowledge of the machine it's bound to + view({ actions, selectors }) { return (

Count: {selectors.count}

); - } -); - -// Stapling the machine, selectors, actions, view, and slots together -const RootMachine = buildXStateTreeMachine(machine, { - selectors, - actions, - view, - slots + }, }); // Build the React host for the tree @@ -145,16 +133,16 @@ Each machine that forms the tree representing your UI has an associated set of s - Selector functions are provided with the current context of the machine, a function to determine if it can handle a given event and a function to determine if it is in a given state, and expose the returned result to the view. - Action functions are provided with the `send` method bound to the machines interpreter and the result of calling the selector function - Slots are how children of the machine are exposed to the view. They can be either single slot for a single actor, or multi slot for when you have a list of actors. - - View functions are React views provided with the output of the selector and action functions, a function to determine if the machine is in a given state, and the currently active slots + - View functions are React views provided with the output of the selector and action functions, and the currently active slots ## API -To assist in making xstate-tree easy to use with TypeScript there are "builder" functions for selectors, actions, views and the final XState tree machine itself. These functions primarily exist to type the arguments passed into the selector/action/view functions. +To assist in making xstate-tree easy to use with TypeScript there is the `createXStateTreeMachine` function for typing selectors, actions and view arguments and stapling the resulting functions to the xstate machine -* `buildSelectors`, first argument is the machine we are creating selectors for, second argument is the selector factory which receives the machines context as the first argument. It also memoizes the selector factory for better rendering performance -* `buildActions`, first argument is the machine we are creating actions for, the second argument is the result of `buildSelectors` and the third argument is the actions factory which receives an XState `send` function and the result of calling the selectors factory. It also memoizes the selector factory for better rendering performance -* `buildView`, first argument is the machine we are creating a view for, second argument is the selector factory, third argument is the actions factory, fourth argument is the array of slots and the fifth argument is the view function itself which gets supplied the selectors, actions, slots and `inState` method as props. It wraps the view in a React.memo -* `buildXStateTreeMachine` takes the results of `buildSelectors`, `buildActions`, `buildView` and the list of slots and returns an xstate-tree compatible machine +`createXStateTreeMachine` accepts the xstate machine as the first argument and takes an options argument with the following fields, it is important the fields are defined in this order or TypeScript will infer the wrong types: +* `selectors`, receives an object with `ctx`, `inState`, and `canHandleEvent` fields. `ctx` is the machines current context, `inState` is the xstate `state.matches` function to allow determining if the machine is in a given state, and `canHandleEvent` accepts an event object and returns whether the machine will do anything in response to that event in it's current state +* `actions`, receives an object with `send` and `selectors` fields. `send` is the xstate `send` function bound to the machine, and `selectors` is the result of calling the selector function +* `view`, is a React component that receives `actions`, `selectors`, and `slots` as props. `actions` and `selectors` being the result of the action/selector functions and `slots` being an object with keys as the slot names and the values the slots React component Full API docs coming soon, see [#20](https://github.com/koordinates/xstate-tree/issues/20) @@ -203,15 +191,6 @@ It is relatively simple to display xstate-tree views directly in Storybook. Sinc There are a few utilities in xstate-tree to make this easier -#### `buildViewProps` -This is a builder function that accepts a view to provide typings and then an object containing -actions/selector fields. With the typings it provides these fields are type safe and you can autocomplete them. - -It returns the props object and extends it with an `inState` factory function, so you can destructure it for use in Stories. The `inState` function accepts a state string as an argument, and returns a function that returns true if the state supplied matches that. So you can easily render the view in a specific machine state in the Story -``` -const { actions, selectors, inState } = buildViewProps(view, { actions: {], selectors: {} }); -``` - #### `genericSlotsTestingDummy` This is a simple Proxy object that renders a
containing the name of the slot whenever rendering diff --git a/examples/todomvc/App.tsx b/examples/todomvc/App.tsx index b8a0e2a..e304cb1 100644 --- a/examples/todomvc/App.tsx +++ b/examples/todomvc/App.tsx @@ -1,12 +1,9 @@ import { broadcast, - buildActions, - buildSelectors, - buildView, - buildXStateTreeMachine, multiSlot, Link, RoutingEvent, + createXStateTreeMachine, } from "@koordinates/xstate-tree"; import { assign } from "@xstate/immer"; import React from "react"; @@ -29,7 +26,6 @@ type Events = | RoutingEvent; const TodosSlot = multiSlot("Todos"); -const slots = [TodosSlot]; const machine = /** @xstate-layout N4IgpgJg5mDOIC5QBcD2FUFoCGAHXAdAMaoB2EArkWgE4DEiouqsAlsq2YyAB6ICMAFgBMBAJwTBANgkBWAOwAOWQGYpwgDQgAngMEEp-RYNn8FABmHmxMlQF87WtBhz46AZQCaAOQDCAfQAVAHkAEWD3bmY2Di4kXkRMeSkCEWTzRUUbYUV5c1ktXQRMflKCMyUxQXk1Y2FShyd0LDxcDwAJYIB1fwBBX0CASQA1AFEgsIiolnZOUm4+BBUagitzFWFZasVzczMpQsThFeVFFV35fjzLQUaQZxa3d06e3oAZN4nwyPjo2bjQIt+FJFKsxNYxLIbLJNoIxIpDsUVFDUsJBGcVGIrPxjrdHPdmq42s9uv5fMEALIABTeo0Co1CXymvxmsXm8UWJWsBB2ljUVThe2EBx0iWRYlR6JUmOxuIc+NI6Dg3AeROIZEo1FQNGmMTmC0SslBVRqIJE-HywsEgkRVwIlWqan40tM-DuqtaBEVgWa8BZeoBCQQV1EUhUFSyjrNNtFwZs4kjpysKkUwjU7sJnoAFthYD6MH6mKz9RzDeYCDCwxGTfzbfH4VUk+tU+n8R78Lr-uzAYkLeW0lIMll1Ll8ojMGiUhGzkIU2JWw4gA */ createMachine( @@ -111,48 +107,50 @@ const machine = } ); -const selectors = buildSelectors(machine, (ctx) => ({ - get count() { - const completedCount = ctx.todos.filter((t) => t.completed).length; - const activeCount = ctx.todos.length - completedCount; +export const TodoApp = createXStateTreeMachine(machine, { + selectors({ ctx, inState }) { + return { + get count() { + const completedCount = ctx.todos.filter((t) => t.completed).length; + const activeCount = ctx.todos.length - completedCount; - return ctx.filter === "completed" ? completedCount : activeCount; - }, - get countText() { - const count = this.count; - const plural = count === 1 ? "" : "s"; - - return `item${plural} ${ctx.filter === "completed" ? "completed" : "left"}`; - }, - allCompleted: ctx.todos.every((t) => t.completed), - haveCompleted: ctx.todos.some((t) => t.completed), - allTodosClass: ctx.filter === "all" ? "selected" : undefined, - activeTodosClass: ctx.filter === "active" ? "selected" : undefined, - completedTodosClass: ctx.filter === "completed" ? "selected" : undefined, -})); - -const actions = buildActions(machine, selectors, () => ({ - addTodo(title: string) { - const trimmed = title.trim(); + return ctx.filter === "completed" ? completedCount : activeCount; + }, + get countText() { + const count = this.count; + const plural = count === 1 ? "" : "s"; - if (trimmed.length > 0) { - broadcast({ type: "TODO_CREATED", text: trimmed }); - } - }, - completeAll(completed: boolean) { - broadcast({ type: "TODO_ALL_COMPLETED", completed }); + return `item${plural} ${ + ctx.filter === "completed" ? "completed" : "left" + }`; + }, + allCompleted: ctx.todos.every((t) => t.completed), + haveCompleted: ctx.todos.some((t) => t.completed), + allTodosClass: ctx.filter === "all" ? "selected" : undefined, + activeTodosClass: ctx.filter === "active" ? "selected" : undefined, + completedTodosClass: ctx.filter === "completed" ? "selected" : undefined, + hasTodos: inState("hasTodos"), + }; }, - clearCompleted() { - broadcast({ type: "TODO_COMPLETED_CLEARED" }); + actions() { + return { + addTodo(title: string) { + const trimmed = title.trim(); + + if (trimmed.length > 0) { + broadcast({ type: "TODO_CREATED", text: trimmed }); + } + }, + completeAll(completed: boolean) { + broadcast({ type: "TODO_ALL_COMPLETED", completed }); + }, + clearCompleted() { + broadcast({ type: "TODO_COMPLETED_CLEARED" }); + }, + }; }, -})); - -const view = buildView( - machine, - selectors, - actions, - slots, - ({ inState, actions, selectors, slots }) => { + slots: [TodosSlot], + View({ actions, selectors, slots }) { return ( <>
@@ -170,7 +168,7 @@ const view = buildView( /> - {inState("hasTodos") && ( + {selectors.hasTodos && ( <>
); - } -); - -export const TodoApp = buildXStateTreeMachine(machine, { - view, - actions, - selectors, - slots, + }, }); diff --git a/examples/todomvc/Todo.tsx b/examples/todomvc/Todo.tsx index cd83047..7f76f16 100644 --- a/examples/todomvc/Todo.tsx +++ b/examples/todomvc/Todo.tsx @@ -1,9 +1,6 @@ import { broadcast, - buildActions, - buildSelectors, - buildView, - buildXStateTreeMachine, + createXStateTreeMachine, RoutingEvent, type PickEvent, } from "@koordinates/xstate-tree"; @@ -149,51 +146,52 @@ const machine = } ); -const selectors = buildSelectors(machine, (ctx) => ({ - text: ctx.todo.text, - completed: ctx.todo.completed, - id: ctx.todo.id, - editedText: ctx.editedText, -})); - -const actions = buildActions(machine, selectors, (send, selectors) => ({ - complete() { - broadcast({ - type: "TODO_COMPLETED", - id: selectors.id, - }); - }, - delete() { - broadcast({ - type: "TODO_DELETED", - id: selectors.id, - }); - }, - textChange(text: string) { - send({ type: "EDIT_CHANGED", text }); +export const TodoMachine = createXStateTreeMachine(machine, { + selectors({ ctx, inState }) { + return { + text: ctx.todo.text, + completed: ctx.todo.completed, + id: ctx.todo.id, + editedText: ctx.editedText, + editing: inState("visible.edit"), + viewing: inState("visible.view"), + }; }, - submitEdit() { - send({ type: "EDIT_SUBMITTED" }); - }, - startEditing() { - send({ type: "EDIT" }); + actions({ selectors, send }) { + return { + complete() { + broadcast({ + type: "TODO_COMPLETED", + id: selectors.id, + }); + }, + delete() { + broadcast({ + type: "TODO_DELETED", + id: selectors.id, + }); + }, + textChange(text: string) { + send({ type: "EDIT_CHANGED", text }); + }, + submitEdit() { + send({ type: "EDIT_SUBMITTED" }); + }, + startEditing() { + send({ type: "EDIT" }); + }, + }; }, -})); - -const view = buildView( - machine, - selectors, - actions, - [], - ({ inState, selectors: { completed, text, editedText }, actions }) => { + View({ + selectors: { completed, editedText, text, editing, viewing }, + actions, + }) { return (
  • actions.startEditing()} > - {inState("visible.view") && ( + {viewing && (
    )} - {inState("visible.edit") && ( + {editing && ( ); - } -); - -export const TodoMachine = buildXStateTreeMachine(machine, { - view, - actions, - selectors, - slots: [], + }, }); diff --git a/examples/todomvc/routes.ts b/examples/todomvc/routes.ts index 88e3d44..da86725 100644 --- a/examples/todomvc/routes.ts +++ b/examples/todomvc/routes.ts @@ -2,7 +2,7 @@ import { buildCreateRoute, XstateTreeHistory } from "@koordinates/xstate-tree"; import { createBrowserHistory } from "history"; export const history: XstateTreeHistory = createBrowserHistory(); -const createRoute = buildCreateRoute(history, "/"); +const createRoute = buildCreateRoute(() => history, "/"); export const allTodos = createRoute.simpleRoute()({ url: "/", diff --git a/jest.config.js b/jest.config.js index b464408..79611f6 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,7 +9,7 @@ module.exports = { setupFilesAfterEnv: ["./src/setupScript.ts"], globals: { "ts-jest": { - tsConfig: "./tsconfig.json", + tsconfig: "./tsconfig.json", isolatedModules: true, }, }, diff --git a/package-lock.json b/package-lock.json index c4730f0..62249d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@koordinates/xstate-tree", - "version": "4.0.1", + "version": "4.1.0-beta.6", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@koordinates/xstate-tree", - "version": "4.0.1", + "version": "4.1.0-beta.6", "license": "MIT", "dependencies": { "fast-memoize": "^2.5.2", @@ -22,7 +22,7 @@ "@saithodev/semantic-release-backmerge": "^2.1.2", "@testing-library/dom": "^8.14.0", "@testing-library/jest-dom": "^5.16.1", - "@testing-library/react": "^10.4.8", + "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "@types/history": "^4.7.7", "@types/jest": "^28.1.4", @@ -61,6 +61,7 @@ }, "peerDependencies": { "@xstate/react": "^3.x", + "react": ">= 16.8.0 < 19.0.0", "xstate": ">= 4.20 < 5.0.0", "zod": "^3.x" } @@ -694,19 +695,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/runtime-corejs3": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.18.6.tgz", - "integrity": "sha512-cOu5wH2JFBgMjje+a+fz2JNIWU4GzYpl05oSob3UDvBEh6EuIn+TXFHMmBbhSb+k/4HMzgKCQfEEDArAWNF9Cw==", - "dev": true, - "dependencies": { - "core-js-pure": "^3.20.2", - "regenerator-runtime": "^0.13.4" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/template": { "version": "7.18.10", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.18.10.tgz", @@ -1809,22 +1797,6 @@ "@types/yargs-parser": "*" } }, - "node_modules/@jest/types": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", - "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", - "dev": true, - "dependencies": { - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^15.0.0", - "chalk": "^4.0.0" - }, - "engines": { - "node": ">= 10.14.2" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz", @@ -2860,67 +2832,21 @@ } }, "node_modules/@testing-library/react": { - "version": "10.4.8", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-10.4.8.tgz", - "integrity": "sha512-clgpFR6QHiRRcdhFfAKDhH8UXpNASyfkkANhtCsCVBnai+O+mK1rGtMES+Apc7ql5Wyxu7j8dcLiC4pV5VblHA==", + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-13.4.0.tgz", + "integrity": "sha512-sXOGON+WNTh3MLE9rve97ftaZukN3oNf2KjDy7YTx6hcTO2uuLHuCGynMDhFwGw/jYf4OJ2Qk0i4i79qMNNkyw==", "dev": true, "dependencies": { - "@babel/runtime": "^7.10.3", - "@testing-library/dom": "^7.17.1" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "react": "*", - "react-dom": "*" - } - }, - "node_modules/@testing-library/react/node_modules/@testing-library/dom": { - "version": "7.31.2", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.31.2.tgz", - "integrity": "sha512-3UqjCpey6HiTZT92vODYLPxTBWlM8ZOOjr3LX5F37/VRipW2M1kX6I/Cm4VXzteZqfGfagg8yXywpcOgQBlNsQ==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", - "@types/aria-query": "^4.2.0", - "aria-query": "^4.2.2", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.6", - "lz-string": "^1.4.4", - "pretty-format": "^26.6.2" + "@testing-library/dom": "^8.5.0", + "@types/react-dom": "^18.0.0" }, "engines": { - "node": ">=10" - } - }, - "node_modules/@testing-library/react/node_modules/aria-query": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-4.2.2.tgz", - "integrity": "sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==", - "dev": true, - "dependencies": { - "@babel/runtime": "^7.10.2", - "@babel/runtime-corejs3": "^7.10.2" - }, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/@testing-library/react/node_modules/pretty-format": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", - "integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==", - "dev": true, - "dependencies": { - "@jest/types": "^26.6.2", - "ansi-regex": "^5.0.0", - "ansi-styles": "^4.0.0", - "react-is": "^17.0.1" + "node": ">=12" }, - "engines": { - "node": ">= 10" + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" } }, "node_modules/@testing-library/user-event": { @@ -3225,15 +3151,6 @@ "integrity": "sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==", "dev": true }, - "node_modules/@types/yargs": { - "version": "15.0.14", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.14.tgz", - "integrity": "sha512-yEJzHoxf6SyQGhBhIYGXQDSCkJjB6HohDShto7m8vaKg9Yp0Yn8+71J9eakh2bnPg6BfsH9PRMhiRTZnd4eXGQ==", - "dev": true, - "dependencies": { - "@types/yargs-parser": "*" - } - }, "node_modules/@types/yargs-parser": { "version": "21.0.0", "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz", @@ -4755,17 +4672,6 @@ "safe-buffer": "~5.1.1" } }, - "node_modules/core-js-pure": { - "version": "3.23.3", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.23.3.tgz", - "integrity": "sha512-XpoouuqIj4P+GWtdyV8ZO3/u4KftkeDVMfvp+308eGMhCrA3lVDSmAxO0c6GGOcmgVlaKDrgWVMo49h2ab/TDA==", - "dev": true, - "hasInstallScript": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -19063,16 +18969,6 @@ "regenerator-runtime": "^0.13.4" } }, - "@babel/runtime-corejs3": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.18.6.tgz", - "integrity": "sha512-cOu5wH2JFBgMjje+a+fz2JNIWU4GzYpl05oSob3UDvBEh6EuIn+TXFHMmBbhSb+k/4HMzgKCQfEEDArAWNF9Cw==", - "dev": true, - "requires": { - "core-js-pure": "^3.20.2", - "regenerator-runtime": "^0.13.4" - } - }, "@babel/template": { "version": "7.18.10", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.18.10.tgz", @@ -19971,19 +19867,6 @@ } } }, - "@jest/types": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", - "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", - "dev": true, - "requires": { - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^15.0.0", - "chalk": "^4.0.0" - } - }, "@jridgewell/gen-mapping": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz", @@ -20791,53 +20674,14 @@ } }, "@testing-library/react": { - "version": "10.4.8", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-10.4.8.tgz", - "integrity": "sha512-clgpFR6QHiRRcdhFfAKDhH8UXpNASyfkkANhtCsCVBnai+O+mK1rGtMES+Apc7ql5Wyxu7j8dcLiC4pV5VblHA==", + "version": "13.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-13.4.0.tgz", + "integrity": "sha512-sXOGON+WNTh3MLE9rve97ftaZukN3oNf2KjDy7YTx6hcTO2uuLHuCGynMDhFwGw/jYf4OJ2Qk0i4i79qMNNkyw==", "dev": true, "requires": { - "@babel/runtime": "^7.10.3", - "@testing-library/dom": "^7.17.1" - }, - "dependencies": { - "@testing-library/dom": { - "version": "7.31.2", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-7.31.2.tgz", - "integrity": "sha512-3UqjCpey6HiTZT92vODYLPxTBWlM8ZOOjr3LX5F37/VRipW2M1kX6I/Cm4VXzteZqfGfagg8yXywpcOgQBlNsQ==", - "dev": true, - "requires": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^4.2.0", - "aria-query": "^4.2.2", - "chalk": "^4.1.0", - "dom-accessibility-api": "^0.5.6", - "lz-string": "^1.4.4", - "pretty-format": "^26.6.2" - } - }, - "aria-query": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-4.2.2.tgz", - "integrity": "sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==", - "dev": true, - "requires": { - "@babel/runtime": "^7.10.2", - "@babel/runtime-corejs3": "^7.10.2" - } - }, - "pretty-format": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", - "integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==", - "dev": true, - "requires": { - "@jest/types": "^26.6.2", - "ansi-regex": "^5.0.0", - "ansi-styles": "^4.0.0", - "react-is": "^17.0.1" - } - } + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^8.5.0", + "@types/react-dom": "^18.0.0" } }, "@testing-library/user-event": { @@ -21125,15 +20969,6 @@ "integrity": "sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==", "dev": true }, - "@types/yargs": { - "version": "15.0.14", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.14.tgz", - "integrity": "sha512-yEJzHoxf6SyQGhBhIYGXQDSCkJjB6HohDShto7m8vaKg9Yp0Yn8+71J9eakh2bnPg6BfsH9PRMhiRTZnd4eXGQ==", - "dev": true, - "requires": { - "@types/yargs-parser": "*" - } - }, "@types/yargs-parser": { "version": "21.0.0", "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz", @@ -22203,12 +22038,6 @@ "safe-buffer": "~5.1.1" } }, - "core-js-pure": { - "version": "3.23.3", - "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.23.3.tgz", - "integrity": "sha512-XpoouuqIj4P+GWtdyV8ZO3/u4KftkeDVMfvp+308eGMhCrA3lVDSmAxO0c6GGOcmgVlaKDrgWVMo49h2ab/TDA==", - "dev": true - }, "core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", diff --git a/package.json b/package.json index 515cc39..2751db1 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@koordinates/xstate-tree", "main": "lib/index.js", "types": "lib/xstate-tree.d.ts", - "version": "4.0.1", + "version": "4.1.0-beta.6", "license": "MIT", "description": "Build UIs with Actors using xstate and React", "keywords": [ @@ -33,7 +33,7 @@ "@saithodev/semantic-release-backmerge": "^2.1.2", "@testing-library/dom": "^8.14.0", "@testing-library/jest-dom": "^5.16.1", - "@testing-library/react": "^10.4.8", + "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", "@types/history": "^4.7.7", "@types/jest": "^28.1.4", @@ -73,7 +73,8 @@ "peerDependencies": { "@xstate/react": "^3.x", "xstate": ">= 4.20 < 5.0.0", - "zod": "^3.x" + "zod": "^3.x", + "react": ">= 16.8.0 < 19.0.0" }, "scripts": { "lint": "eslint 'src/**/*'", diff --git a/src/builders.ts b/src/builders.ts index c1002ac..14a57cc 100644 --- a/src/builders.ts +++ b/src/builders.ts @@ -13,17 +13,17 @@ import { Slot } from "./slots"; import { AnyActions, AnySelector, + CanHandleEvent, MatchesFrom, OutputFromSelector, - Selectors, + V1Selectors as LegacySelectors, + V2BuilderMeta, ViewProps, - XStateTreeMachineMeta, - XstateTreeMachineStateSchema, + XStateTreeMachineMetaV1, + XstateTreeMachineStateSchemaV1, + XstateTreeMachineStateSchemaV2, } from "./types"; -type CanHandleEvent = ( - e: EventFrom -) => boolean; /** * @public * @@ -39,6 +39,7 @@ type CanHandleEvent = ( * @param machine - The machine to create the selectors for * @param selectors - The selector function * @returns The selectors - ready to be passed to {@link buildActions} + * @deprecated use {@link createXStateTreeMachine} instead */ export function buildSelectors< TMachine extends AnyStateMachine, @@ -52,7 +53,12 @@ export function buildSelectors< inState: MatchesFrom, __currentState: never ) => TSelectors -): Selectors, TSelectors, MatchesFrom> { +): LegacySelectors< + TContext, + EventFrom, + TSelectors, + MatchesFrom +> { let lastState: never | undefined = undefined; let lastCachedResult: TSelectors | undefined = undefined; let lastCtxRef: TContext | undefined = undefined; @@ -98,6 +104,7 @@ export function buildSelectors< * @param selectors - The selectors function * @param actions - The action function * @returns The actions function - ready to be passed to {@link buildView} + * @deprecated use {@link createXStateTreeMachine} instead * */ export function buildActions< TMachine extends AnyStateMachine, @@ -129,6 +136,7 @@ export function buildActions< * @param slots - The array of slots that can be rendered by the view * @param view - The view function * @returns The React view + * @deprecated use {@link createXStateTreeMachine} instead */ export function buildView< TMachine extends AnyStateMachine, @@ -165,6 +173,7 @@ export function buildView< * @param machine - The machine to staple the selectors/actions/slots/view to * @param metadata - The xstate-tree metadata to staple to the machine * @returns The xstate-tree machine, ready to be invoked by other xstate-machines or used with `buildRootComponent` + * @deprecated use {@link createXStateTreeMachine} instead */ export function buildXStateTreeMachine< TMachine extends AnyStateMachine, @@ -172,10 +181,10 @@ export function buildXStateTreeMachine< TActions extends AnyActions >( machine: TMachine, - meta: XStateTreeMachineMeta + meta: XStateTreeMachineMetaV1 ): StateMachine< ContextFrom, - XstateTreeMachineStateSchema, + XstateTreeMachineStateSchemaV1, EventFrom, any, any, @@ -184,8 +193,71 @@ export function buildXStateTreeMachine< > { const copiedMeta = { ...meta }; copiedMeta.xstateTreeMachine = true; - machine.config.meta = { ...machine.config.meta, ...copiedMeta }; - machine.meta = { ...machine.meta, ...copiedMeta }; + machine.config.meta = { + ...machine.config.meta, + ...copiedMeta, + builderVersion: 1, + }; + machine.meta = { ...machine.meta, ...copiedMeta, builderVersion: 1 }; + + return machine; +} + +/** + * @public + * Creates an xstate-tree machine from an xstate-machine + * + * Accepts an options object defining the selectors/actions/slots and view for the xstate-tree machine + * + * Selectors/slots/actions can be omitted from the options object and will default to + * - actions: an empty object + * - selectors: the context of the machine + * - slots: an empty array + * + * @param machine - The xstate machine to create the xstate-tree machine from + * @param options - the xstate-tree options + */ +export function createXStateTreeMachine< + TMachine extends AnyStateMachine, + TSelectorsOutput = ContextFrom, + TActionsOutput = Record, + TSlots extends readonly Slot[] = [] +>( + machine: TMachine, + options: V2BuilderMeta +): StateMachine< + ContextFrom, + XstateTreeMachineStateSchemaV2< + TMachine, + TSelectorsOutput, + TActionsOutput, + TSlots + >, + EventFrom, + any, + any, + any, + any +> { + const selectors = options.selectors ?? (({ ctx }) => ctx); + const actions = options.actions ?? (() => ({})); + + const xstateTreeMeta = { + selectors, + actions, + View: options.View, + slots: options.slots ?? [], + }; + machine.meta = { + ...machine.meta, + ...xstateTreeMeta, + builderVersion: 2, + }; + machine.config.meta = { + ...machine.config.meta, + ...xstateTreeMeta, + builderVersion: 2, + }; return machine; } diff --git a/src/index.ts b/src/index.ts index e90c2ef..61610ec 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,9 @@ export { type RouteArgumentFunctions, buildCreateRoute, matchRoute, + useIsRouteActive, + useRouteArgsIfActive, + TestRoutingContext, } from "./routing"; export { loggingMetaOptions } from "./useService"; export { lazy } from "./lazy"; diff --git a/src/routing/createRoute/createRoute.spec.ts b/src/routing/createRoute/createRoute.spec.ts index 950f232..5d2369f 100644 --- a/src/routing/createRoute/createRoute.spec.ts +++ b/src/routing/createRoute/createRoute.spec.ts @@ -7,7 +7,7 @@ import { assert } from "../../utils"; import { buildCreateRoute } from "./createRoute"; const hist = createMemoryHistory<{ meta?: unknown }>(); -const createRoute = buildCreateRoute(hist, "/"); +const createRoute = buildCreateRoute(() => hist, "/"); describe("createRoute", () => { describe("createRoute.dynamicRoute", () => { @@ -56,7 +56,7 @@ describe("createRoute", () => { const route = createRoute.simpleRoute()({ url: "/foo", event: "GO_FOO" }); expect(route.basePath).toBe("/"); - expect(route.history).toBe(hist); + expect(route.history()).toBe(hist); }); describe("route schemas", () => { @@ -313,7 +313,7 @@ describe("createRoute", () => { const hist: XstateTreeHistory = createMemoryHistory(); const spy = jest.fn(); hist.push = spy as any; - const createRoute = buildCreateRoute(hist, "/"); + const createRoute = buildCreateRoute(() => hist, "/"); const route = createRoute.simpleRoute()({ url: "/foo/:fooId", event: "GO_FOO", @@ -335,7 +335,7 @@ describe("createRoute", () => { const hist: XstateTreeHistory = createMemoryHistory(); const spy = jest.fn(); hist.replace = spy as any; - const createRoute = buildCreateRoute(hist, "/"); + const createRoute = buildCreateRoute(() => hist, "/"); const route = createRoute.simpleRoute()({ url: "/foo/:fooId", event: "GO_FOO", diff --git a/src/routing/createRoute/createRoute.ts b/src/routing/createRoute/createRoute.ts index 2377cd7..7fa1b50 100644 --- a/src/routing/createRoute/createRoute.ts +++ b/src/routing/createRoute/createRoute.ts @@ -148,7 +148,7 @@ export type Route = { * Event type for this route */ event: TEvent; - history: XstateTreeHistory; + history: () => XstateTreeHistory; basePath: string; parent?: AnyRoute; paramsSchema?: Z.ZodObject; @@ -166,7 +166,7 @@ export type AnyRoute = { getEvent: any; event: string; basePath: string; - history: XstateTreeHistory; + history: () => XstateTreeHistory; parent?: AnyRoute; paramsSchema?: Z.ZodObject; querySchema?: Z.ZodObject; @@ -262,7 +262,10 @@ type ResolveZodType | undefined> = undefined extends T * @param history - the history object to use for this route factory, this needs to be the same one used in the trees root component * @param basePath - the base path for this route factory */ -export function buildCreateRoute(history: XstateTreeHistory, basePath: string) { +export function buildCreateRoute( + history: () => XstateTreeHistory, + basePath: string +) { function navigate({ history, url, @@ -535,7 +538,7 @@ export function buildCreateRoute(history: XstateTreeHistory, basePath: string) { navigate({ url: joinRoutes(this.basePath, url), meta, - history: this.history, + history: this.history(), }); }, }; diff --git a/src/routing/handleLocationChange/handleLocationChange.spec.ts b/src/routing/handleLocationChange/handleLocationChange.spec.ts index 89e79c4..e34126d 100644 --- a/src/routing/handleLocationChange/handleLocationChange.spec.ts +++ b/src/routing/handleLocationChange/handleLocationChange.spec.ts @@ -7,7 +7,8 @@ import { RoutingEvent } from "../routingEvent"; import { handleLocationChange, Routing404Event } from "./handleLocationChange"; -const createRoute = buildCreateRoute(createMemoryHistory(), "/"); +const hist = createMemoryHistory<{ meta?: unknown }>(); +const createRoute = buildCreateRoute(() => hist, "/"); const foo = createRoute.simpleRoute()({ url: "/foo", event: "GO_FOO" }); const bar = createRoute.simpleRoute(foo)({ url: "/bar", event: "GO_BAR" }); const routes = [foo, bar]; diff --git a/src/routing/index.ts b/src/routing/index.ts index f8d9673..da775c1 100644 --- a/src/routing/index.ts +++ b/src/routing/index.ts @@ -20,5 +20,7 @@ export { handleLocationChange, type Routing404Event, } from "./handleLocationChange"; +export { useIsRouteActive } from "./useIsRouteActive"; +export { useRouteArgsIfActive } from "./useRouteArgsIfActive"; -export { RoutingContext } from "./providers"; +export { RoutingContext, TestRoutingContext } from "./providers"; diff --git a/src/routing/matchRoute/matchRoute.spec.ts b/src/routing/matchRoute/matchRoute.spec.ts index dfe8d72..101f4fd 100644 --- a/src/routing/matchRoute/matchRoute.spec.ts +++ b/src/routing/matchRoute/matchRoute.spec.ts @@ -6,7 +6,7 @@ import { buildCreateRoute } from "../createRoute"; import { matchRoute } from "./matchRoute"; const hist = createMemoryHistory<{ meta?: unknown }>(); -const createRoute = buildCreateRoute(hist, "/"); +const createRoute = buildCreateRoute(() => hist, "/"); describe("matchRoute", () => { const route1 = createRoute.simpleRoute()({ url: "/route1", event: "ROUTE_1" }); const route2 = createRoute.simpleRoute()({ diff --git a/src/routing/providers.ts b/src/routing/providers.ts deleted file mode 100644 index 07704c2..0000000 --- a/src/routing/providers.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { createContext, MutableRefObject, useContext } from "react"; - -type RoutingContext = { - activeRouteEvents?: MutableRefObject; -}; - -export const RoutingContext = createContext( - undefined -); - -function useRoutingContext() { - const context = useContext(RoutingContext); - - if (context === undefined) { - throw new Error( - "useRoutingContext must be used within a RoutingContext provider" - ); - } - - return context; -} - -export function useActiveRouteEvents() { - try { - const context = useRoutingContext(); - - return context.activeRouteEvents?.current; - } catch { - return undefined; - } -} diff --git a/src/routing/providers.tsx b/src/routing/providers.tsx new file mode 100644 index 0000000..67f75c4 --- /dev/null +++ b/src/routing/providers.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { createContext, MutableRefObject, useContext } from "react"; + +import { RoutingEvent } from "./routingEvent"; + +type Context = { + activeRouteEvents?: MutableRefObject[]>; +}; + +export const RoutingContext = createContext(undefined); + +function useRoutingContext() { + const context = useContext(RoutingContext); + + if (context === undefined) { + throw new Error( + "useRoutingContext must be used within a RoutingContext provider" + ); + } + + return context; +} + +export function useActiveRouteEvents() { + try { + const context = useRoutingContext(); + + return context.activeRouteEvents?.current; + } catch { + return undefined; + } +} + +/** + * @public + * + * Renders the xstate-tree routing context. Designed for use in tests/storybook + * for components that make use of routing hooks but aren't part of an xstate-tree view + * + * @param activeRouteEvents - The active route events to use in the context + */ +export function TestRoutingContext({ + activeRouteEvents, + children, +}: { + activeRouteEvents: RoutingEvent[]; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/src/routing/useHref.spec.ts b/src/routing/useHref.spec.ts index c7a7e59..ca5ec41 100644 --- a/src/routing/useHref.spec.ts +++ b/src/routing/useHref.spec.ts @@ -5,7 +5,7 @@ import { buildCreateRoute } from "./createRoute"; import { useHref } from "./useHref"; const hist = createMemoryHistory<{ meta?: unknown }>(); -const createRoute = buildCreateRoute(hist, "/foo"); +const createRoute = buildCreateRoute(() => hist, "/foo"); const route = createRoute.simpleRoute()({ url: "/bar/:type(valid)", event: "GO_BAR", diff --git a/src/routing/useIsRouteActive.spec.tsx b/src/routing/useIsRouteActive.spec.tsx new file mode 100644 index 0000000..33b1e53 --- /dev/null +++ b/src/routing/useIsRouteActive.spec.tsx @@ -0,0 +1,86 @@ +import { renderHook } from "@testing-library/react"; +import { createMemoryHistory } from "history"; +import React from "react"; + +import { buildCreateRoute } from "./createRoute"; +import { RoutingContext } from "./providers"; +import { useIsRouteActive } from "./useIsRouteActive"; + +const createRoute = buildCreateRoute(() => createMemoryHistory(), "/"); +const fooRoute = createRoute.simpleRoute()({ + event: "foo", + url: "/", +}); +const barRoute = createRoute.simpleRoute()({ + event: "bar", + url: "/", +}); +describe("useIsRouteActive", () => { + it("returns false if the supplied route is not part of the activeRouteEvents in the routing context", () => { + const { result } = renderHook(() => useIsRouteActive(fooRoute), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current).toBe(false); + }); + + it("throws an error if not called within the RoutingContext", () => { + expect(() => renderHook(() => useIsRouteActive(fooRoute))).toThrow(); + }); + + it("returns true if the supplied route is part of the activeRouteEvents in the routing context", () => { + const { result } = renderHook(() => useIsRouteActive(fooRoute), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current).toBe(true); + }); + + it("handles multiple routes where one is active", () => { + const { result } = renderHook(() => useIsRouteActive(fooRoute, barRoute), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current).toBe(true); + }); +}); diff --git a/src/routing/useIsRouteActive.tsx b/src/routing/useIsRouteActive.tsx new file mode 100644 index 0000000..5f4db44 --- /dev/null +++ b/src/routing/useIsRouteActive.tsx @@ -0,0 +1,25 @@ +import { AnyRoute } from "./createRoute"; +import { useActiveRouteEvents } from "./providers"; + +/** + * @public + * Accepts Routes and returns true if any route is currently active. False if not. + * + * If used outside of a RoutingContext, an error will be thrown. + * @param routes - the routes to check + * @returns true if any route is active, false if not + * @throws if used outside of an xstate-tree root + */ +export function useIsRouteActive(...routes: AnyRoute[]): boolean { + const activeRouteEvents = useActiveRouteEvents(); + + if (!activeRouteEvents) { + throw new Error( + "useIsRouteActive must be used within a RoutingContext. Are you using it outside of an xstate-tree Root?" + ); + } + + return activeRouteEvents.some((activeRouteEvent) => { + return routes.some((route) => activeRouteEvent.type === route.event); + }); +} diff --git a/src/routing/useRouteArgsIfActive.spec.tsx b/src/routing/useRouteArgsIfActive.spec.tsx new file mode 100644 index 0000000..4515dc2 --- /dev/null +++ b/src/routing/useRouteArgsIfActive.spec.tsx @@ -0,0 +1,59 @@ +import { renderHook } from "@testing-library/react"; +import { createMemoryHistory } from "history"; +import React from "react"; +import { z } from "zod"; + +import { buildCreateRoute } from "./createRoute"; +import { RoutingContext } from "./providers"; +import { useRouteArgsIfActive } from "./useRouteArgsIfActive"; + +const createRoute = buildCreateRoute(() => createMemoryHistory(), "/"); +const fooRoute = createRoute.simpleRoute()({ + event: "foo", + url: "/:foo", + paramsSchema: z.object({ foo: z.string() }), + querySchema: z.object({ bar: z.string() }), +}); +describe("useRouteArgsIfActive", () => { + it("returns undefined if the route is not active", () => { + const { result } = renderHook(() => useRouteArgsIfActive(fooRoute), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current).toBe(undefined); + }); + + it("returns the routes arguments if the route is active", () => { + const { result } = renderHook(() => useRouteArgsIfActive(fooRoute), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current).toEqual({ + params: { foo: "bar" }, + query: { bar: "baz" }, + meta: {}, + }); + }); +}); diff --git a/src/routing/useRouteArgsIfActive.tsx b/src/routing/useRouteArgsIfActive.tsx new file mode 100644 index 0000000..c1364d2 --- /dev/null +++ b/src/routing/useRouteArgsIfActive.tsx @@ -0,0 +1,39 @@ +import { assertIsDefined } from "../utils"; + +import { AnyRoute, ArgumentsForRoute } from "./createRoute"; +import { useActiveRouteEvents } from "./providers"; +import { useIsRouteActive } from "./useIsRouteActive"; + +/** + * @public + * Returns the arguments for the given route if the route is active. + * Returns undefined if the route is not active. + * + * @param route - the route to get the arguments for + * @returns the arguments for the given route if the route is active, undefined otherwise + * @throws if used outside of an xstate-tree root + */ +export function useRouteArgsIfActive( + route: TRoute +): ArgumentsForRoute | undefined { + const isActive = useIsRouteActive(route); + const activeRoutes = useActiveRouteEvents(); + + if (!isActive) { + return undefined; + } + + const activeRoute = activeRoutes?.find( + (activeRoute) => activeRoute.type === route.event + ); + assertIsDefined( + activeRoute, + "active route is not defined, but the route is active??" + ); + + return { + params: activeRoute.params, + query: activeRoute.query, + meta: activeRoute.meta, + } as ArgumentsForRoute; +} diff --git a/src/test-app/AppMachine.tsx b/src/test-app/AppMachine.tsx index 186b92a..e244284 100644 --- a/src/test-app/AppMachine.tsx +++ b/src/test-app/AppMachine.tsx @@ -2,15 +2,12 @@ import React from "react"; import { createMachine } from "xstate"; import { - buildView, - buildXStateTreeMachine, buildRootComponent, singleSlot, - buildActions, lazy, - buildSelectors, + createXStateTreeMachine, } from "../"; -import { Link, RoutingEvent } from "../routing"; +import { Link, RoutingEvent, useIsRouteActive } from "../routing"; import { TodosMachine } from "./TodosMachine"; import { homeRoute, settingsRoute, history } from "./routes"; @@ -64,18 +61,21 @@ const AppMachine = } ); -const selectors = buildSelectors(AppMachine, (ctx) => ctx); -const actions = buildActions(AppMachine, selectors, () => ({})); - -const AppView = buildView( - AppMachine, - selectors, - actions, +export const BuiltAppMachine = createXStateTreeMachine(AppMachine, { slots, - ({ slots, inState }) => { + selectors({ inState }) { + return { + showingTodos: inState("todos"), + showingOtherScreen: inState("otherScreen"), + }; + }, + View({ slots, selectors }) { + const isHomeActive = useIsRouteActive(homeRoute); + return ( <> - {inState("todos") && ( +

    {isHomeActive ? "true" : "false"}

    + {selectors.showingTodos && ( <>

    On home

    @@ -83,7 +83,7 @@ const AppView = buildView( )} - {inState("otherScreen") && ( + {selectors.showingOtherScreen && ( <>

    On settings

    @@ -94,17 +94,10 @@ const AppView = buildView( ); - } -); - -export const BuiltAppMachine = buildXStateTreeMachine(AppMachine, { - actions, - selectors, - slots, - view: AppView, + }, }); -export const App = buildRootComponent(BuiltAppMachine, { +export const App = buildRootComponent(BuiltAppMachine as any, { history, basePath: "", routes: [homeRoute, settingsRoute], diff --git a/src/test-app/OtherMachine.tsx b/src/test-app/OtherMachine.tsx index 0c658f1..0536c5c 100644 --- a/src/test-app/OtherMachine.tsx +++ b/src/test-app/OtherMachine.tsx @@ -1,13 +1,7 @@ import React from "react"; import { createMachine } from "xstate"; -import { - buildActions, - buildSelectors, - buildView, - buildXStateTreeMachine, - PickEvent, -} from "../"; +import { createXStateTreeMachine, PickEvent } from "../"; import { RoutingEvent } from "../routing"; import { settingsRoute } from "./routes"; @@ -47,24 +41,20 @@ const machine = createMachine({ }, }); -const selectors = buildSelectors(machine, (_ctx, canHandleEvent) => ({ - canDoTheThing: canHandleEvent({ type: "DO_THE_THING" }), -})); -const actions = buildActions(machine, selectors, () => ({})); -const view = buildView(machine, selectors, actions, [], ({ selectors }) => { - return ( - <> -

    - {selectors.canDoTheThing ? "true" : "false"} -

    -

    Other

    - - ); -}); - -export const OtherMachine = buildXStateTreeMachine(machine, { - view, - slots: [], - actions, - selectors, +export const OtherMachine = createXStateTreeMachine(machine, { + selectors({ canHandleEvent }) { + return { + canDoTheThing: canHandleEvent({ type: "DO_THE_THING" }), + }; + }, + View({ selectors }) { + return ( + <> +

    + {selectors.canDoTheThing ? "true" : "false"} +

    +

    Other

    + + ); + }, }); diff --git a/src/test-app/TodoMachine.tsx b/src/test-app/TodoMachine.tsx index 39ebe3a..c71bab8 100644 --- a/src/test-app/TodoMachine.tsx +++ b/src/test-app/TodoMachine.tsx @@ -3,14 +3,7 @@ import cx from "classnames"; import React from "react"; import { createMachine, assign, sendParent } from "xstate"; -import { - buildSelectors, - buildActions, - buildView, - buildXStateTreeMachine, - Slot, - PickEvent, -} from ".."; +import { Slot, PickEvent, createXStateTreeMachine } from ".."; import { UnmountingTest } from "./unmountingTestFixture"; @@ -135,40 +128,40 @@ const TodoMachine = createMachine({ }, }); -const TodoSelectors = buildSelectors(TodoMachine, (ctx) => ({ - todo: ctx.todo, - edittedTodoText: ctx.edittedTodo, - completed: ctx.completed, -})); - -const TodoActions = buildActions(TodoMachine, TodoSelectors, (send) => ({ - toggle() { - send({ type: "TOGGLE_CLICKED" }); - }, - remove() { - send({ type: "REMOVE" }); - }, - startEditing() { - send({ type: "START_EDITING" }); - }, - finishEditing() { - send({ type: "EDITTING_FINISHED" }); +const BoundTodoMachine = createXStateTreeMachine(TodoMachine, { + selectors({ ctx, inState }) { + return { + todo: ctx.todo, + edittedTodoText: ctx.edittedTodo, + completed: ctx.completed, + editing: inState("editing"), + hidden: inState("hidden"), + }; }, - cancelEditing() { - send({ type: "EDITTING_CANCELLED" }); - }, - updateEdittedTodoText(text: string) { - send({ type: "EDITTED_TODO_UPDATED", updatedText: text }); + actions({ send }) { + return { + toggle() { + send({ type: "TOGGLE_CLICKED" }); + }, + remove() { + send({ type: "REMOVE" }); + }, + startEditing() { + send({ type: "START_EDITING" }); + }, + finishEditing() { + send({ type: "EDITTING_FINISHED" }); + }, + cancelEditing() { + send({ type: "EDITTING_CANCELLED" }); + }, + updateEdittedTodoText(text: string) { + send({ type: "EDITTED_TODO_UPDATED", updatedText: text }); + }, + }; }, -})); - -const TodoView = buildView( - TodoMachine, - TodoSelectors, - TodoActions, - slots, - ({ selectors, actions, inState }) => { - if (inState("hidden")) { + View({ selectors, actions }) { + if (selectors.hidden) { return null; } @@ -176,7 +169,7 @@ const TodoView = buildView(
  • @@ -207,17 +200,11 @@ const TodoView = buildView( actions.cancelEditing(); } }} - autoFocus={inState("editing")} + autoFocus={selectors.editing} />
  • ); - } -); - -const BoundTodoMachine = buildXStateTreeMachine(TodoMachine, { - view: TodoView, - actions: TodoActions, - selectors: TodoSelectors, + }, slots, }); diff --git a/src/test-app/TodosMachine.tsx b/src/test-app/TodosMachine.tsx index 47142b7..c841857 100644 --- a/src/test-app/TodosMachine.tsx +++ b/src/test-app/TodosMachine.tsx @@ -8,14 +8,7 @@ import { ActorRefFrom, } from "xstate"; -import { - broadcast, - buildSelectors, - buildActions, - buildView, - multiSlot, - buildXStateTreeMachine, -} from "../"; +import { broadcast, multiSlot, createXStateTreeMachine } from "../"; import { assert } from "../utils"; import { TodoMachine } from "./TodoMachine"; @@ -171,22 +164,24 @@ const TodosMachine = createMachine( } ); -const TodosSelectors = buildSelectors(TodosMachine, (ctx) => { - return { - todoInput: ctx.newTodo, - allCompleted: ctx.todos.every( - (todoActor) => todoActor.state.context.completed - ), - uncompletedCount: ctx.todos.filter( - (todoActor) => !todoActor.state.context.completed - ).length, - }; -}); - -const TodosActions = buildActions( - TodosMachine, - TodosSelectors, - (send, selectors) => { +const BuiltTodosMachine = createXStateTreeMachine(TodosMachine, { + selectors({ ctx, inState }) { + return { + todoInput: ctx.newTodo, + allCompleted: ctx.todos.every( + (todoActor) => todoActor.state.context.completed + ), + uncompletedCount: ctx.todos.filter( + (todoActor) => !todoActor.state.context.completed + ).length, + loading: inState("loadingTodos"), + haveTodos: inState("haveTodos"), + onActive: inState("haveTodos.active"), + onCompleted: inState("haveTodos.completed"), + onAll: inState("haveTodos.all"), + }; + }, + actions({ send, selectors }) { return { todoInputChanged(newVal: string) { send({ type: "TODO_INPUT_CHANGED", val: newVal }); @@ -213,16 +208,9 @@ const TodosActions = buildActions( send({ type: "COMPLETED_SELECTED" }); }, }; - } -); - -const TodosView = buildView( - TodosMachine, - TodosSelectors, - TodosActions, - slots, - ({ slots, actions, selectors, inState }) => { - if (inState("loadingTodos")) { + }, + View({ slots, actions, selectors }) { + if (selectors.loading) { return

    Loading

    ; } @@ -240,7 +228,7 @@ const TodosView = buildView( data-testid="todo-input" /> - {inState("haveTodos") && ( + {selectors.haveTodos && ( <>
  • @@ -305,13 +293,7 @@ const TodosView = buildView( )} ); - } -); - -const BuiltTodosMachine = buildXStateTreeMachine(TodosMachine, { - view: TodosView, - selectors: TodosSelectors, - actions: TodosActions, + }, slots, }); diff --git a/src/test-app/routes.ts b/src/test-app/routes.ts index ef179de..fede989 100644 --- a/src/test-app/routes.ts +++ b/src/test-app/routes.ts @@ -3,7 +3,7 @@ import { createMemoryHistory } from "history"; import { buildCreateRoute } from "../routing"; export const history = createMemoryHistory(); -const createRoute = buildCreateRoute(history, "/"); +const createRoute = buildCreateRoute(() => history, "/"); export const homeRoute = createRoute.route()({ matcher(url, _query) { if (url === "/") { diff --git a/src/test-app/tests/__snapshots__/itWorks.integration.tsx.snap b/src/test-app/tests/__snapshots__/itWorks.integration.tsx.snap index 7f4e138..b7b78f4 100644 --- a/src/test-app/tests/__snapshots__/itWorks.integration.tsx.snap +++ b/src/test-app/tests/__snapshots__/itWorks.integration.tsx.snap @@ -2,6 +2,11 @@ exports[`Test app renders the initial app 1`] = `
    +

    + true +

    diff --git a/src/test-app/tests/changingInvokedMachineForSlot.integration.tsx b/src/test-app/tests/changingInvokedMachineForSlot.integration.tsx index 51d1637..4cf75ac 100644 --- a/src/test-app/tests/changingInvokedMachineForSlot.integration.tsx +++ b/src/test-app/tests/changingInvokedMachineForSlot.integration.tsx @@ -10,7 +10,7 @@ describe("changing the machine invoked into a slot", () => { it("correctly updates the view to point to the new machine", async () => { const { getByTestId, queryByTestId } = render(); - await delay(5); + await delay(50); await act(() => userEvent.click(getByTestId("swap-to-other-machine"))); await delay(50); diff --git a/src/test-app/tests/interpreterViewsNotUnmountedNeedlessly.integration.tsx b/src/test-app/tests/interpreterViewsNotUnmountedNeedlessly.integration.tsx index 88b5833..4c04671 100644 --- a/src/test-app/tests/interpreterViewsNotUnmountedNeedlessly.integration.tsx +++ b/src/test-app/tests/interpreterViewsNotUnmountedNeedlessly.integration.tsx @@ -17,7 +17,7 @@ describe("Rendering behaviour", () => { await cleanup(); const { getByTestId, getAllByTestId } = render(); - await delay(5); + await delay(50); await act(() => userEvent.type(getByTestId("todo-input"), "test{enter}")); await delay(300); diff --git a/src/test-app/tests/itWorks.integration.tsx b/src/test-app/tests/itWorks.integration.tsx index 59bccda..0cc3f4b 100644 --- a/src/test-app/tests/itWorks.integration.tsx +++ b/src/test-app/tests/itWorks.integration.tsx @@ -8,7 +8,7 @@ describe("Test app", () => { it("renders the initial app", async () => { const { container } = render(); - await delay(5); + await delay(50); expect(container).toMatchSnapshot(); }); }); diff --git a/src/test-app/tests/itWorksWithoutRouting.integration.tsx b/src/test-app/tests/itWorksWithoutRouting.integration.tsx index 9321af9..6ab4009 100644 --- a/src/test-app/tests/itWorksWithoutRouting.integration.tsx +++ b/src/test-app/tests/itWorksWithoutRouting.integration.tsx @@ -4,11 +4,8 @@ import { createMachine } from "xstate"; import { buildRootComponent, - buildSelectors, - buildActions, - buildView, - buildXStateTreeMachine, singleSlot, + createXStateTreeMachine, } from "../../"; const childMachine = createMachine({ @@ -18,28 +15,12 @@ const childMachine = createMachine({ }, }); -const childSelectors = buildSelectors(childMachine, (ctx) => ctx); -const childActions = buildActions(childMachine, childSelectors, () => ({})); -const childView = buildView( - childMachine, - childSelectors, - childActions, - [], - () => { - return

    child

    ; - } -); - -const child = buildXStateTreeMachine(childMachine, { - actions: childActions, - selectors: childSelectors, - slots: [], - view: childView, +const child = createXStateTreeMachine(childMachine, { + View: () =>

    child

    , }); const childSlot = singleSlot("Child"); -const slots = [childSlot]; -const rootMachine = createMachine({ +const rootMachine = createMachine({ initial: "idle", invoke: { src: () => child, @@ -49,22 +30,17 @@ const rootMachine = createMachine({ idle: {}, }, }); -const selectors = buildSelectors(rootMachine, (ctx) => ctx); -const actions = buildActions(rootMachine, selectors, () => ({})); -const view = buildView(rootMachine, selectors, actions, slots, ({ slots }) => { - return ( - <> -

    root

    - - - ); -}); -const root = buildXStateTreeMachine(rootMachine, { - selectors, - actions, - slots, - view, +const root = createXStateTreeMachine(rootMachine, { + slots: [childSlot], + View({ slots }) { + return ( + <> +

    root

    + + + ); + }, }); const RootView = buildRootComponent(root); diff --git a/src/test-app/tests/removingChildActor.integration.tsx b/src/test-app/tests/removingChildActor.integration.tsx index 71a69c9..b959d0d 100644 --- a/src/test-app/tests/removingChildActor.integration.tsx +++ b/src/test-app/tests/removingChildActor.integration.tsx @@ -9,7 +9,7 @@ describe("removing an existing child actor", () => { it("remotes the child actor from the existing multi-slot view when it is stopped", async () => { const { getAllByTestId } = render(); - await delay(5); + await delay(50); await act(() => userEvent.click(getAllByTestId("remove-todo")[0])); await delay(300); diff --git a/src/test-app/tests/routing.integration.tsx b/src/test-app/tests/routing.integration.tsx index cb0a7fb..86de5c9 100644 --- a/src/test-app/tests/routing.integration.tsx +++ b/src/test-app/tests/routing.integration.tsx @@ -11,7 +11,7 @@ describe("Routing", () => { it("sends the latest matched routing event to the newly spawned machine", async () => { const { getByTestId } = render(); - await delay(5); + await delay(50); await act(() => userEvent.click(getByTestId("swap-to-other-machine"))); await delay(50); diff --git a/src/test-app/tests/spawningChildActor.integration.tsx b/src/test-app/tests/spawningChildActor.integration.tsx index 14851d7..589b59c 100644 --- a/src/test-app/tests/spawningChildActor.integration.tsx +++ b/src/test-app/tests/spawningChildActor.integration.tsx @@ -10,7 +10,7 @@ describe("creating a new child actor", () => { await cleanup(); const { getByTestId, getAllByTestId } = render(); - await delay(5); + await delay(50); await act(() => userEvent.type(getByTestId("todo-input"), "test{enter}")); await delay(300); diff --git a/src/test-app/tests/updatingChildActorViaBroadcast.integration.tsx b/src/test-app/tests/updatingChildActorViaBroadcast.integration.tsx index 98fc4b4..19fbb0d 100644 --- a/src/test-app/tests/updatingChildActorViaBroadcast.integration.tsx +++ b/src/test-app/tests/updatingChildActorViaBroadcast.integration.tsx @@ -10,7 +10,7 @@ describe("updating child actors via broadcast", () => { await cleanup(); const { getByTestId, getAllByTestId } = render(); - await delay(5); + await delay(50); await act(() => userEvent.click(getByTestId("update-all"))); await delay(300); diff --git a/src/testingUtilities.tsx b/src/testingUtilities.tsx index 5d60591..e30ad71 100644 --- a/src/testingUtilities.tsx +++ b/src/testingUtilities.tsx @@ -12,7 +12,7 @@ import { import { buildXStateTreeMachine } from "./builders"; import { - XstateTreeMachineStateSchema, + XstateTreeMachineStateSchemaV1, GlobalEvents, ViewProps, AnySelector, @@ -129,7 +129,7 @@ export function buildTestRootComponent< >( machine: StateMachine< TContext, - XstateTreeMachineStateSchema, + XstateTreeMachineStateSchemaV1, EventFrom >, logger: typeof console.log diff --git a/src/tests/asyncRouteRedirects.spec.tsx b/src/tests/asyncRouteRedirects.spec.tsx index 741ad59..2dcb9ab 100644 --- a/src/tests/asyncRouteRedirects.spec.tsx +++ b/src/tests/asyncRouteRedirects.spec.tsx @@ -18,7 +18,7 @@ import { delay } from "../utils"; describe("async route redirects", () => { const hist: XstateTreeHistory = createMemoryHistory(); - const createRoute = buildCreateRoute(hist, "/"); + const createRoute = buildCreateRoute(() => hist, "/"); const parentRoute = createRoute.simpleRoute()({ url: "/:notFoo/", diff --git a/src/types.ts b/src/types.ts index b2db341..13241f8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,6 +3,9 @@ import React from "react"; import type { AnyFunction, AnyStateMachine, + ContextFrom, + EventFrom, + InterpreterFrom, StateFrom, StateMachine, } from "xstate"; @@ -12,7 +15,7 @@ import { Slot, GetSlotNames } from "./slots"; /** * @public */ -export type XStateTreeMachineMeta< +export type XStateTreeMachineMetaV1< TMachine extends AnyStateMachine, TSelectors, TActions extends AnyActions, @@ -35,12 +38,14 @@ export type XStateTreeMachineMeta< /** * @public */ -export type XstateTreeMachineStateSchema< +export type XstateTreeMachineStateSchemaV1< TMachine extends AnyStateMachine, TSelectors extends AnySelector, TActions extends AnyActions > = { - meta: XStateTreeMachineMeta; + meta: XStateTreeMachineMetaV1 & { + builderVersion: 1; + }; }; /** @@ -108,7 +113,7 @@ export type XstateTreeHistory = History<{ /** * @public */ -export type Selectors = ( +export type V1Selectors = ( ctx: TContext, canHandleEvent: (e: TEvent) => boolean, inState: TMatches, @@ -123,14 +128,19 @@ export type MatchesFrom = StateFrom["matches"]; /** * @public */ -export type OutputFromSelector = T extends Selectors +export type OutputFromSelector = T extends V1Selectors< + any, + any, + infer O, + any +> ? O : never; /** * @public */ -export type AnySelector = Selectors; +export type AnySelector = V1Selectors; /** * @public @@ -142,6 +152,79 @@ export type AnyActions = (send: any, selectors: any) => any; */ export type AnyXstateTreeMachine = StateMachine< any, - XstateTreeMachineStateSchema, + | XstateTreeMachineStateSchemaV1 + | XstateTreeMachineStateSchemaV2, any >; + +/** + * @internal + */ +export type CanHandleEvent = ( + e: EventFrom +) => boolean; + +/** + * @public + */ +export type Selectors = (args: { + ctx: ContextFrom; + canHandleEvent: CanHandleEvent; + inState: MatchesFrom; +}) => TOut; + +/** + * @public + */ +export type Actions< + TMachine extends AnyStateMachine, + TSelectorsOutput, + TOut +> = (args: { + send: InterpreterFrom["send"]; + selectors: TSelectorsOutput; +}) => TOut; + +/** + * @public + */ +export type View< + TActionsOutput, + TSelectorsOutput, + TSlots extends readonly Slot[] +> = React.ComponentType<{ + slots: Record, React.ComponentType>; + actions: TActionsOutput; + selectors: TSelectorsOutput; +}>; + +/** + * @public + */ +export type V2BuilderMeta< + TMachine extends AnyStateMachine, + TSelectorsOutput = ContextFrom, + TActionsOutput = Record, + TSlots extends readonly Slot[] = Slot[] +> = { + selectors?: Selectors; + actions?: Actions; + slots?: TSlots; + View: View; +}; + +/** + * @public + */ +export type XstateTreeMachineStateSchemaV2< + TMachine extends AnyStateMachine, + TSelectorsOutput = ContextFrom, + TActionsOutput = Record, + TSlots extends readonly Slot[] = Slot[] +> = { + meta: Required< + V2BuilderMeta & { + builderVersion: 2; + } + >; +}; diff --git a/src/useService.ts b/src/useService.ts index 43063c4..c6774c8 100644 --- a/src/useService.ts +++ b/src/useService.ts @@ -1,7 +1,7 @@ import { useState, useRef, useEffect } from "react"; import { EventObject, Interpreter, InterpreterFrom, AnyState } from "xstate"; -import { AnyXstateTreeMachine, XstateTreeMachineStateSchema } from "./types"; +import { AnyXstateTreeMachine, XstateTreeMachineStateSchemaV1 } from "./types"; import { isEqual } from "./utils"; /** @@ -116,7 +116,7 @@ export function useService< current, children as unknown as Map< string | number, - Interpreter, any, any> + Interpreter, any, any> >, ] as const; } diff --git a/src/xstateTree.spec.tsx b/src/xstateTree.spec.tsx index 610d4a6..578e7b1 100644 --- a/src/xstateTree.spec.tsx +++ b/src/xstateTree.spec.tsx @@ -40,7 +40,7 @@ describe("xstate-tree", () => { const Root = buildRootComponent(XstateTreeMachine); render(); - await delay(10); + await delay(50); expect(renderCallback).toHaveBeenCalledTimes(1); }); }); diff --git a/src/xstateTree.tsx b/src/xstateTree.tsx index 496a64a..152bfbc 100644 --- a/src/xstateTree.tsx +++ b/src/xstateTree.tsx @@ -183,12 +183,7 @@ export function XstateTreeView({ interpreter }: XStateTreeViewProps) { undefined ); - const { - view: View, - actions: actionsFactory, - selectors: selectorsFactory, - slots: interpreterSlots, - } = interpreter.machine.meta!; + const { slots: interpreterSlots } = interpreter.machine.meta!; const slots = useSlots>( interpreter, interpreterSlots.map((x) => x.name) @@ -219,29 +214,67 @@ export function XstateTreeView({ interpreter }: XStateTreeViewProps) { ); }); const actions = useConstant(() => { - return actionsFactory(interpreter.send, selectorsProxy); + switch (interpreter.machine.meta?.builderVersion) { + case 1: + return interpreter.machine.meta!.actions( + interpreter.send, + selectorsProxy + ); + case 2: + return interpreter.machine.meta!.actions({ + send: interpreter.send, + selectors: selectorsProxy, + }); + default: + throw new Error("builderVersion not set"); + } }); if (!current) { return null; } - const selectors = selectorsFactory( - current.context, - canHandleEvent, - inState, - current.value - ); - selectorsRef.current = selectors; + switch (interpreter.machine.meta?.builderVersion) { + case 1: + selectorsRef.current = interpreter.machine.meta!.selectors( + current.context, + canHandleEvent, + inState, + current.value as never + ); + break; + case 2: + selectorsRef.current = interpreter.machine.meta!.selectors({ + ctx: current.context, + canHandleEvent, + inState, + }); + break; + } - return ( - - ); + switch (interpreter.machine.meta?.builderVersion) { + case 1: + const ViewV1 = interpreter.machine.meta!.view; + return ( + + ); + case 2: + const ViewV2 = interpreter.machine.meta!.View; + return ( + + ); + default: + throw new Error("builderVersion not set"); + } } /** @@ -297,8 +330,17 @@ export function buildRootComponent( if (!machine.meta) { throw new Error("Root machine has no meta"); } - if (!machine.meta.view) { - throw new Error("Root machine has no associated view"); + switch (machine.meta.builderVersion) { + case 1: + if (!machine.meta.view) { + throw new Error("Root machine has no associated view"); + } + break; + case 2: + if (!machine.meta.View) { + throw new Error("Root machine has no associated view"); + } + break; } const RootComponent = function XstateTreeRootComponent() { diff --git a/xstate-tree.api.md b/xstate-tree.api.md index c31c913..e68d54c 100644 --- a/xstate-tree.api.md +++ b/xstate-tree.api.md @@ -24,6 +24,12 @@ import { StateMachine } from 'xstate'; import { TypegenDisabled } from 'xstate'; import * as Z from 'zod'; +// @public (undocumented) +export type Actions = (args: { + send: InterpreterFrom["send"]; + selectors: TSelectorsOutput; +}) => TOut; + // @public (undocumented) export type AnyActions = (send: any, selectors: any) => any; @@ -35,7 +41,7 @@ export type AnyRoute = { getEvent: any; event: string; basePath: string; - history: XstateTreeHistory; + history: () => XstateTreeHistory; parent?: AnyRoute; paramsSchema?: Z.ZodObject; querySchema?: Z.ZodObject; @@ -45,10 +51,10 @@ export type AnyRoute = { }; // @public (undocumented) -export type AnySelector = Selectors; +export type AnySelector = V1Selectors; // @public (undocumented) -export type AnyXstateTreeMachine = StateMachine, any>; +export type AnyXstateTreeMachine = StateMachine | XstateTreeMachineStateSchemaV2, any>; // @public (undocumented) export type ArgumentsForRoute = T extends Route ? RouteArguments : never; @@ -56,11 +62,11 @@ export type ArgumentsForRoute = T extends Route["send"]>(__machine: TMachine, __selectors: TSelectors, actions: (send: TSend, selectors: OutputFromSelector) => TActions): (send: TSend, selectors: OutputFromSelector) => TActions; // @public -export function buildCreateRoute(history: XstateTreeHistory, basePath: string): { +export function buildCreateRoute(history: () => XstateTreeHistory, basePath: string): { simpleRoute(baseRoute?: TBaseRoute | undefined): >(__machine: TMachine, selectors: (ctx: TContext, canHandleEvent: CanHandleEvent, inState: MatchesFrom, __currentState: never) => TSelectors): Selectors, TSelectors, MatchesFrom>; +// @public @deprecated +export function buildSelectors>(__machine: TMachine, selectors: (ctx: TContext, canHandleEvent: CanHandleEvent, inState: MatchesFrom, __currentState: never) => TSelectors): V1Selectors, TSelectors, MatchesFrom>; // @public -export function buildTestRootComponent>(machine: StateMachine, EventFrom>, logger: typeof console.log): { +export function buildTestRootComponent>(machine: StateMachine, EventFrom>, logger: typeof console.log): { rootComponent: () => JSX.Element | null; addTransitionListener: (listener: () => void) => void; awaitTransition(): Promise; @@ -125,7 +131,7 @@ export function buildTestRootComponent, TViewProps = ViewProps, TActions, TSlots, TMatches>, TSend = (send: TEvent) => void>(__machine: TMachine, __selectors: TSelectors, __actions: (send: TSend, selectors: OutputFromSelector) => TActions, __slots: TSlots, view: React_2.ComponentType): React_2.ComponentType; // Warning: (ae-forgotten-export) The symbol "InferViewProps" needs to be exported by the entry point index.d.ts @@ -134,8 +140,16 @@ export function buildView>(_view: C, props: Pick>, "actions" | "selectors">): InferViewProps>; +// @public @deprecated +export function buildXStateTreeMachine(machine: TMachine, meta: XStateTreeMachineMetaV1): StateMachine, XstateTreeMachineStateSchemaV1, EventFrom, any, any, any, any>; + +// Warning: (ae-internal-missing-underscore) The name "CanHandleEvent" should be prefixed with an underscore because the declaration is marked as @internal +// +// @internal (undocumented) +export type CanHandleEvent = (e: EventFrom) => boolean; + // @public -export function buildXStateTreeMachine(machine: TMachine, meta: XStateTreeMachineMeta): StateMachine, XstateTreeMachineStateSchema, EventFrom, any, any, any, any>; +export function createXStateTreeMachine, TActionsOutput = Record, TSlots extends readonly Slot[] = []>(machine: TMachine, options: V2BuilderMeta): StateMachine, XstateTreeMachineStateSchemaV2, EventFrom, any, any, any, any>; // @public export const genericSlotsTestingDummy: any; @@ -208,7 +222,7 @@ export function multiSlot(name: T): MultiSlot; export function onBroadcast(handler: (event: GlobalEvents) => void): () => void; // @public (undocumented) -export type OutputFromSelector = T extends Selectors ? O : never; +export type OutputFromSelector = T extends V1Selectors ? O : never; // @public export type Params = T extends { @@ -243,7 +257,7 @@ export type Route = { }) | false; reverser: RouteArgumentFunctions; event: TEvent; - history: XstateTreeHistory; + history: () => XstateTreeHistory; basePath: string; parent?: AnyRoute; paramsSchema?: Z.ZodObject; @@ -302,7 +316,11 @@ export type RoutingEvent = T extends Route = (ctx: TContext, canHandleEvent: (e: TEvent) => boolean, inState: TMatches, __currentState: never) => TSelectors; +export type Selectors = (args: { + ctx: ContextFrom; + canHandleEvent: CanHandleEvent; + inState: MatchesFrom; +}) => TOut; // @public (undocumented) export type SharedMeta = { @@ -326,7 +344,7 @@ export function singleSlot(name: T): SingleSlot; export type Slot = SingleSlot | MultiSlot; // @public -export function slotTestingDummyFactory(name: string): StateMachine>, () => {}, () => {}>, AnyEventObject, any, any, any, any>; @@ -342,6 +360,36 @@ export enum SlotType { // @public (undocumented) export type StyledLink = (props: LinkProps & TStyleProps) => JSX.Element; +// @public +export function TestRoutingContext({ activeRouteEvents, children, }: { + activeRouteEvents: RoutingEvent[]; + children: React_2.ReactNode; +}): JSX.Element; + +// @public +export function useIsRouteActive(...routes: AnyRoute[]): boolean; + +// @public +export function useRouteArgsIfActive(route: TRoute): ArgumentsForRoute | undefined; + +// @public (undocumented) +export type V1Selectors = (ctx: TContext, canHandleEvent: (e: TEvent) => boolean, inState: TMatches, __currentState: never) => TSelectors; + +// @public (undocumented) +export type V2BuilderMeta, TActionsOutput = Record, TSlots extends readonly Slot[] = Slot[]> = { + selectors?: Selectors; + actions?: Actions; + slots?: TSlots; + View: View; +}; + +// @public (undocumented) +export type View = React_2.ComponentType<{ + slots: Record, React_2.ComponentType>; + actions: TActionsOutput; + selectors: TSelectorsOutput; +}>; + // @public (undocumented) export type ViewProps = { slots: Record, React_2.ComponentType>; @@ -357,7 +405,7 @@ export type XstateTreeHistory = History_2<{ }>; // @public (undocumented) -export type XStateTreeMachineMeta = { +export type XStateTreeMachineMetaV1 = { slots: TSlots; view: React_2.ComponentType, ReturnType, TSlots, MatchesFrom>>; selectors: TSelectors; @@ -366,16 +414,27 @@ export type XStateTreeMachineMeta = { - meta: XStateTreeMachineMeta; +export type XstateTreeMachineStateSchemaV1 = { + meta: XStateTreeMachineMetaV1 & { + builderVersion: 1; + }; +}; + +// @public (undocumented) +export type XstateTreeMachineStateSchemaV2, TActionsOutput = Record, TSlots extends readonly Slot[] = Slot[]> = { + meta: Required & { + builderVersion: 2; + }>; }; // Warnings were encountered during analysis: // -// src/routing/createRoute/createRoute.ts:265:78 - (ae-forgotten-export) The symbol "MergeRouteTypes" needs to be exported by the entry point index.d.ts -// src/routing/createRoute/createRoute.ts:265:78 - (ae-forgotten-export) The symbol "ResolveZodType" needs to be exported by the entry point index.d.ts -// src/routing/createRoute/createRoute.ts:301:9 - (ae-forgotten-export) The symbol "RouteRedirect" needs to be exported by the entry point index.d.ts -// src/types.ts:22:3 - (ae-incompatible-release-tags) The symbol "view" is marked as @public, but its signature references "MatchesFrom" which is marked as @internal +// src/routing/createRoute/createRoute.ts:267:19 - (ae-forgotten-export) The symbol "MergeRouteTypes" needs to be exported by the entry point index.d.ts +// src/routing/createRoute/createRoute.ts:267:19 - (ae-forgotten-export) The symbol "ResolveZodType" needs to be exported by the entry point index.d.ts +// src/routing/createRoute/createRoute.ts:304:9 - (ae-forgotten-export) The symbol "RouteRedirect" needs to be exported by the entry point index.d.ts +// src/types.ts:25:3 - (ae-incompatible-release-tags) The symbol "view" is marked as @public, but its signature references "MatchesFrom" which is marked as @internal +// src/types.ts:172:3 - (ae-incompatible-release-tags) The symbol "canHandleEvent" is marked as @public, but its signature references "CanHandleEvent" which is marked as @internal +// src/types.ts:173:3 - (ae-incompatible-release-tags) The symbol "inState" is marked as @public, but its signature references "MatchesFrom" which is marked as @internal // (No @packageDocumentation comment for this package)