From 9a17ecc92232a76c548aa76295a1f42f1270bc09 Mon Sep 17 00:00:00 2001 From: Taylor Lodge Date: Wed, 14 Feb 2024 14:06:47 +1300 Subject: [PATCH] feat(xstate-tree): allow supplying input with buildRootComponent Updates the buildRootComponent function to allow specifying input to be passed to the actor when it's created To enable proper typescript support this required changing the signature of buildRootComponent to accept a single object which contains the machine, optionally routing information, and input depending on whether the machine defines an input type --- examples/todomvc/index.tsx | 11 +-- src/builders.spec.tsx | 13 ++-- src/lazy.spec.tsx | 20 +++--- src/routing/createRoute/createRoute.ts | 15 ++-- src/test-app/AppMachine.tsx | 15 ++-- .../itWorksWithoutRouting.integration.tsx | 2 +- ...lectorsStaleCanHandleEvent.integration.tsx | 15 ++-- src/tests/actionsGetUpdatedSelectors.spec.tsx | 8 +-- src/tests/asyncRouteRedirects.spec.tsx | 10 +-- src/utils.ts | 12 ++++ src/xstateTree.spec.tsx | 71 ++++++++++++++----- src/xstateTree.tsx | 38 +++++++--- xstate-tree.api.md | 24 +++---- 13 files changed, 163 insertions(+), 91 deletions(-) diff --git a/examples/todomvc/index.tsx b/examples/todomvc/index.tsx index 4f5550a..30c1dc4 100644 --- a/examples/todomvc/index.tsx +++ b/examples/todomvc/index.tsx @@ -7,10 +7,13 @@ import { routes, history } from "./routes"; const appRoot = document.getElementById("root"); const root = createRoot(appRoot!); -const App = buildRootComponent(TodoApp, { - basePath: "/", - history, - routes, +const App = buildRootComponent({ + machine: TodoApp, + routing: { + basePath: "/", + history, + routes, + }, }); root.render(); diff --git a/src/builders.spec.tsx b/src/builders.spec.tsx index 03b03c8..4a1ed14 100644 --- a/src/builders.spec.tsx +++ b/src/builders.spec.tsx @@ -11,7 +11,7 @@ describe("xstate-tree builders", () => { describe("viewToMachine", () => { it("takes a React view and wraps it in an xstate-tree machine that renders that view", async () => { const ViewMachine = viewToMachine(() =>
hello world
); - const Root = buildRootComponent(ViewMachine); + const Root = buildRootComponent({ machine: ViewMachine }); const { getByText } = render(); @@ -41,10 +41,13 @@ describe("xstate-tree builders", () => { GO_TO_BAR: BarMachine, }); - const Root = buildRootComponent(routingMachine, { - history: hist, - basePath: "/", - routes: [fooRoute, barRoute], + const Root = buildRootComponent({ + machine: routingMachine, + routing: { + history: hist, + basePath: "/", + routes: [fooRoute, barRoute], + }, }); const { getByText } = render(); diff --git a/src/lazy.spec.tsx b/src/lazy.spec.tsx index e06fabc..15fc41d 100644 --- a/src/lazy.spec.tsx +++ b/src/lazy.spec.tsx @@ -19,7 +19,7 @@ describe("lazy", () => { it("renders null by default when loading", () => { const promiseFactory = () => new Promise(() => void 0); const lazyMachine = lazy(promiseFactory); - const Root = buildRootComponent(lazyMachine); + const Root = buildRootComponent({ machine: lazyMachine }); const { container, rerender } = render(); rerender(); @@ -32,7 +32,7 @@ describe("lazy", () => { const lazyMachine = lazy(promiseFactory, { Loader: () =>

loading

, }); - const Root = buildRootComponent(lazyMachine); + const Root = buildRootComponent({ machine: lazyMachine }); const { container, rerender } = render(); rerender(); @@ -76,14 +76,14 @@ describe("lazy", () => { }); const slots = [lazyMachineSlot]; - const Root = buildRootComponent( - createXStateTreeMachine(rootMachine, { + const Root = buildRootComponent({ + machine: createXStateTreeMachine(rootMachine, { slots, View({ slots }) { return ; }, - }) - ); + }), + }); const { container } = render(); @@ -144,14 +144,14 @@ describe("lazy", () => { }); const slots = [lazyMachineSlot]; - const Root = buildRootComponent( - createXStateTreeMachine(rootMachine, { + const Root = buildRootComponent({ + machine: createXStateTreeMachine(rootMachine, { slots, View({ slots }) { return ; }, - }) - ); + }), + }); const { container } = render(); diff --git a/src/routing/createRoute/createRoute.ts b/src/routing/createRoute/createRoute.ts index 36a9e11..b071ce0 100644 --- a/src/routing/createRoute/createRoute.ts +++ b/src/routing/createRoute/createRoute.ts @@ -3,15 +3,12 @@ import { parse, ParsedQuery, stringify } from "query-string"; import * as Z from "zod"; import { XstateTreeHistory } from "../../types"; -import { type IsEmptyObject } from "../../utils"; +import { + type IsEmptyObject, + type MarkOptionalLikePropertiesOptional, +} from "../../utils"; import { joinRoutes } from "../joinRoutes"; -type EmptyKeys = keyof { - [K in keyof T as IsEmptyObject extends true ? K : never]: T[K]; -}; -type MakeEmptyObjectPropertiesOptional = Omit> & - Partial>>; - /** * @public */ @@ -67,10 +64,10 @@ export type RouteArgumentFunctions< ? (args?: TArgs) => TReturn : EmptyRouteArguments extends true ? (args?: Partial) => TReturn - : (args: MakeEmptyObjectPropertiesOptional) => TReturn; + : (args: MarkOptionalLikePropertiesOptional) => TReturn; type RouteRedirect = ( - args: MakeEmptyObjectPropertiesOptional<{ + args: MarkOptionalLikePropertiesOptional<{ params: TParams; query: TQuery; meta?: TMeta; diff --git a/src/test-app/AppMachine.tsx b/src/test-app/AppMachine.tsx index e63dacb..9259d51 100644 --- a/src/test-app/AppMachine.tsx +++ b/src/test-app/AppMachine.tsx @@ -96,10 +96,13 @@ export const BuiltAppMachine = createXStateTreeMachine(AppMachine, { }, }); -export const App = buildRootComponent(BuiltAppMachine, { - history, - basePath: "", - routes: [homeRoute, settingsRoute], - getPathName: () => "/", - getQueryString: () => "", +export const App = buildRootComponent({ + machine: BuiltAppMachine, + routing: { + history, + basePath: "", + routes: [homeRoute, settingsRoute], + getPathName: () => "/", + getQueryString: () => "", + }, }); diff --git a/src/test-app/tests/itWorksWithoutRouting.integration.tsx b/src/test-app/tests/itWorksWithoutRouting.integration.tsx index 460dccc..a7b53e9 100644 --- a/src/test-app/tests/itWorksWithoutRouting.integration.tsx +++ b/src/test-app/tests/itWorksWithoutRouting.integration.tsx @@ -43,7 +43,7 @@ const root = createXStateTreeMachine(rootMachine, { }, }); -const RootView = buildRootComponent(root); +const RootView = buildRootComponent({ machine: root }); describe("Environment without routing", () => { it("still works without error", () => { diff --git a/src/test-app/tests/selectorsStaleCanHandleEvent.integration.tsx b/src/test-app/tests/selectorsStaleCanHandleEvent.integration.tsx index 9d1f146..68d7191 100644 --- a/src/test-app/tests/selectorsStaleCanHandleEvent.integration.tsx +++ b/src/test-app/tests/selectorsStaleCanHandleEvent.integration.tsx @@ -8,12 +8,15 @@ import { OtherMachine } from "../OtherMachine"; import { settingsRoute } from "../routes"; const history = createMemoryHistory(); -const App = buildRootComponent(OtherMachine, { - history, - basePath: "", - routes: [settingsRoute], - getPathName: () => "/settings", - getQueryString: () => "", +const App = buildRootComponent({ + machine: OtherMachine, + routing: { + history, + basePath: "", + routes: [settingsRoute], + getPathName: () => "/settings", + getQueryString: () => "", + }, }); describe("Selectors & canHandleEvent", () => { diff --git a/src/tests/actionsGetUpdatedSelectors.spec.tsx b/src/tests/actionsGetUpdatedSelectors.spec.tsx index 26b0bec..4243aad 100644 --- a/src/tests/actionsGetUpdatedSelectors.spec.tsx +++ b/src/tests/actionsGetUpdatedSelectors.spec.tsx @@ -28,8 +28,8 @@ describe("actions accessing selectors", () => { }, }); - const Root = buildRootComponent( - createXStateTreeMachine(machine, { + const Root = buildRootComponent({ + machine: createXStateTreeMachine(machine, { actions({ selectors, send }) { actionsCallCount++; return { @@ -43,8 +43,8 @@ describe("actions accessing selectors", () => { ); }, - }) - ); + }), + }); it("gets the most up to date selectors value without re-creating the action functions", async () => { const { getByRole, rerender } = render(); diff --git a/src/tests/asyncRouteRedirects.spec.tsx b/src/tests/asyncRouteRedirects.spec.tsx index 9908a53..f1d654c 100644 --- a/src/tests/asyncRouteRedirects.spec.tsx +++ b/src/tests/asyncRouteRedirects.spec.tsx @@ -70,16 +70,16 @@ describe("async route redirects", () => { }, }); - const Root = buildRootComponent( - createXStateTreeMachine(machine, { + const Root = buildRootComponent({ + machine: createXStateTreeMachine(machine, { View: ({ selectors }) =>

{selectors.bar}

, }), - { + routing: { basePath: "/", history: hist, routes: [parentRoute, redirectRoute, childRoute], - } - ); + }, + }); it("handles a top/middle/bottom route hierarchy where top and middle perform a redirect", async () => { const { queryByText } = render(); diff --git a/src/utils.ts b/src/utils.ts index ea5512a..603c4ab 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -13,6 +13,17 @@ export type OmitOptional = { ? P : never]: T[P]; }; + +export type EmptyKeys = keyof { + [K in keyof T as IsEmptyObject extends true ? K : never]: T[K]; +}; + +/** + * Marks any required property that can accept undefined as optional + */ +export type MarkOptionalLikePropertiesOptional = Omit> & + Partial>>; + export type IsEmptyObject< Obj, ExcludeOptional extends boolean = false @@ -24,6 +35,7 @@ export type IsEmptyObject< ? true : false; +export type IsUnknown = unknown extends T ? true : false; export function assertIsDefined( val: T, msg?: string diff --git a/src/xstateTree.spec.tsx b/src/xstateTree.spec.tsx index 0cda00f..7ab85c8 100644 --- a/src/xstateTree.spec.tsx +++ b/src/xstateTree.spec.tsx @@ -46,7 +46,7 @@ describe("xstate-tree", () => { return

Can swap: {selectors.canSwap}

; }, }); - const Root = buildRootComponent(xstateTreeMachine); + const Root = buildRootComponent({ machine: xstateTreeMachine }); render(); await delay(10); @@ -54,6 +54,35 @@ describe("xstate-tree", () => { }); }); + it("passes the supplied input through to the machine", async () => { + const machine = setup({ + types: { + context: {} as { foo: string }, + input: {} as { bar: string }, + }, + }).createMachine({ + initial: "a", + context: ({ input }) => ({ foo: input.bar }), + states: { + a: {}, + }, + }); + + const XstateTreeMachine = createXStateTreeMachine(machine, { + View: ({ selectors }) => { + return

{selectors.foo}

; + }, + }); + const Root = buildRootComponent({ + machine: XstateTreeMachine, + input: { bar: "foo" }, + }); + + const { getByText } = render(); + + getByText("foo"); + }); + describe("machines that don't have any visible change after initializing", () => { it("still renders the machines view", async () => { const renderCallback = jest.fn(); @@ -71,7 +100,7 @@ describe("xstate-tree", () => { return null; }, }); - const Root = buildRootComponent(XstateTreeMachine); + const Root = buildRootComponent({ machine: XstateTreeMachine }); render(); await delay(50); @@ -102,7 +131,7 @@ describe("xstate-tree", () => { return null; }, }); - const Root = buildRootComponent(XstateTreeMachine); + const Root = buildRootComponent({ machine: XstateTreeMachine }); const { rerender } = render(); await delay(10); @@ -138,7 +167,7 @@ describe("xstate-tree", () => { const XstateTreeMachine = createXStateTreeMachine(machine, { View: () => null, }); - const Root = buildRootComponent(XstateTreeMachine); + const Root = buildRootComponent({ machine: XstateTreeMachine }); render(); @@ -195,7 +224,7 @@ describe("xstate-tree", () => { const XstateTreeMachine = createXStateTreeMachine(machine, { View: () => null, }); - const Root = buildRootComponent(XstateTreeMachine); + const Root = buildRootComponent({ machine: XstateTreeMachine }); render(); @@ -226,7 +255,7 @@ describe("xstate-tree", () => { return

{selectors.foo}

; }, }); - const Root = buildRootComponent(XstateTreeMachine); + const Root = buildRootComponent({ machine: XstateTreeMachine }); const { findByText } = render(); @@ -235,7 +264,7 @@ describe("xstate-tree", () => { it("allows rendering nested roots", () => { const childRoot = viewToMachine(() =>

Child

); - const ChildRoot = buildRootComponent(childRoot); + const ChildRoot = buildRootComponent({ machine: childRoot }); const rootMachine = viewToMachine(() => { return ( <> @@ -244,7 +273,7 @@ describe("xstate-tree", () => { ); }); - const Root = buildRootComponent(rootMachine); + const Root = buildRootComponent({ machine: rootMachine }); const { getByText } = render(); getByText("Root"); @@ -297,10 +326,13 @@ describe("xstate-tree", () => { return

I am root

; }, }); - const Root = buildRootComponent(RootMachine, { - basePath: "/", - history: createMemoryHistory(), - routes: [], + const Root = buildRootComponent({ + machine: RootMachine, + routing: { + basePath: "/", + history: createMemoryHistory(), + routes: [], + }, }); const Root2Machine = createXStateTreeMachine(machine, { @@ -308,10 +340,13 @@ describe("xstate-tree", () => { return ; }, }); - const Root2 = buildRootComponent(Root2Machine, { - basePath: "/", - history: createMemoryHistory(), - routes: [], + const Root2 = buildRootComponent({ + machine: Root2Machine, + routing: { + basePath: "/", + history: createMemoryHistory(), + routes: [], + }, }); try { @@ -329,10 +364,10 @@ describe("xstate-tree", () => { it("does not throw an error if either or one are a routing root", async () => { const RootMachine = viewToMachine(() =>

I am root

); - const Root = buildRootComponent(RootMachine); + const Root = buildRootComponent({ machine: RootMachine }); const Root2Machine = viewToMachine(() => ); - const Root2 = buildRootComponent(Root2Machine); + const Root2 = buildRootComponent({ machine: Root2Machine }); const { rerender } = render(); rerender(); diff --git a/src/xstateTree.tsx b/src/xstateTree.tsx index a6ad4ef..65b5d70 100644 --- a/src/xstateTree.tsx +++ b/src/xstateTree.tsx @@ -14,6 +14,7 @@ import { ActorRefFrom, AnyEventObject, AnyActorRef, + InputFrom, } from "xstate"; import { @@ -29,7 +30,13 @@ import { GetSlotNames, Slot } from "./slots"; import { GlobalEvents, AnyXstateTreeMachine, XstateTreeHistory } from "./types"; import { useConstant } from "./useConstant"; import { useService } from "./useService"; -import { assertIsDefined, mergeMeta, toJSON } from "./utils"; +import { + assertIsDefined, + mergeMeta, + toJSON, + type IsUnknown, + type MarkOptionalLikePropertiesOptional, +} from "./utils"; export const emitter = new TinyEmitter(); @@ -269,6 +276,18 @@ export function recursivelySend(service: AnyActorRef, event: GlobalEvents) { children.forEach((child) => recursivelySend(child, event)); } +type RootOptions = { + routing: + | { + routes: AnyRoute[]; + history: XstateTreeHistory; + basePath: string; + getPathName?: () => string; + getQueryString?: () => string; + } + | undefined; + input: IsUnknown extends true ? undefined : TInput; +}; /** * @public * @@ -277,16 +296,14 @@ export function recursivelySend(service: AnyActorRef, event: GlobalEvents) { * @param machine - The root machine of the tree * @param routing - The routing configuration for the tree */ -export function buildRootComponent( - machine: AnyXstateTreeMachine, - routing?: { - routes: AnyRoute[]; - history: XstateTreeHistory; - basePath: string; - getPathName?: () => string; - getQueryString?: () => string; - } +export function buildRootComponent( + options: { machine: TMachine } & MarkOptionalLikePropertiesOptional< + RootOptions> + > ) { + const { input, machine, routing } = options as unknown as { + machine: TMachine; + } & RootOptions>; if (!machine._xstateTree) { throw new Error( "Root machine is not an xstate-tree machine, missing metadata" @@ -299,6 +316,7 @@ export function buildRootComponent( const RootComponent = function XstateTreeRootComponent() { const lastSnapshotsRef = useRef>({}); const [_, __, interpreter] = useActor(machine, { + input, inspect(event) { switch (event.type) { case "@xstate.actor": diff --git a/xstate-tree.api.md b/xstate-tree.api.md index 961ad9d..e081024 100644 --- a/xstate-tree.api.md +++ b/xstate-tree.api.md @@ -95,16 +95,15 @@ export function buildCreateRoute(history: () => XstateTreeHistory, basePath: str }) => Route, ResolveZodType>, ResolveZodType, TEvent_1, MergeRouteTypes, TMeta_1> & SharedMeta>; }; +// Warning: (ae-forgotten-export) The symbol "MarkOptionalLikePropertiesOptional" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "RootOptions" needs to be exported by the entry point index.d.ts +// // @public -export function buildRootComponent(machine: AnyXstateTreeMachine, routing?: { - routes: AnyRoute[]; - history: XstateTreeHistory; - basePath: string; - getPathName?: () => string; - getQueryString?: () => string; -}): { +export function buildRootComponent(options: { + machine: TMachine; +} & MarkOptionalLikePropertiesOptional>>): { (): JSX.Element; - rootMachine: AnyXstateTreeMachine; + rootMachine: TMachine; }; // @public @@ -243,10 +242,9 @@ export type Route = { // Warning: (ae-forgotten-export) The symbol "IsEmptyObject" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "EmptyRouteArguments" needs to be exported by the entry point index.d.ts -// Warning: (ae-forgotten-export) The symbol "MakeEmptyObjectPropertiesOptional" needs to be exported by the entry point index.d.ts // // @public (undocumented) -export type RouteArgumentFunctions> = IsEmptyObject extends true ? () => TReturn : keyof TArgs extends "meta" ? (args?: TArgs) => TReturn : EmptyRouteArguments extends true ? (args?: Partial) => TReturn : (args: MakeEmptyObjectPropertiesOptional) => TReturn; +export type RouteArgumentFunctions> = IsEmptyObject extends true ? () => TReturn : keyof TArgs extends "meta" ? (args?: TArgs) => TReturn : EmptyRouteArguments extends true ? (args?: Partial) => TReturn : (args: MarkOptionalLikePropertiesOptional) => TReturn; // @public (undocumented) export type RouteArguments = TParams extends undefined ? TQuery extends undefined ? TMeta extends undefined ? {} : { @@ -399,9 +397,9 @@ export type XstateTreeMachineStateSchemaV2