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