From 95607d95dc1840483f85bfbde31730500f1352ee Mon Sep 17 00:00:00 2001 From: oscar marina Date: Sun, 27 Jul 2025 21:16:05 +0200 Subject: [PATCH 1/2] [@xstate/lit] Add Lit Controller - packages --- .changeset/strong-birds-refuse.md | 11 +++ packages/xstate-lit/CHANGELOG.md | 1 + packages/xstate-lit/README.md | 75 +++++++++++++++ packages/xstate-lit/package.json | 66 +++++++++++++ packages/xstate-lit/setup-file.mts | 1 + packages/xstate-lit/src/UseMachine.ts | 94 +++++++++++++++++++ packages/xstate-lit/src/index.ts | 1 + packages/xstate-lit/test/UseActor.ts | 76 +++++++++++++++ packages/xstate-lit/test/UseActorRef.ts | 52 ++++++++++ .../test/UseActorRehydratedState.ts | 18 ++++ .../test/UseActorRehydratedStateConfig.ts | 17 ++++ .../test/UseActorWithTransitionLogic.ts | 43 +++++++++ packages/xstate-lit/test/fetchMachine.ts | 39 ++++++++ packages/xstate-lit/test/package.json | 3 + packages/xstate-lit/test/persistedMachine.ts | 26 +++++ packages/xstate-lit/test/tsconfig.json | 7 ++ packages/xstate-lit/test/useActor.test.ts | 67 +++++++++++++ packages/xstate-lit/test/useActorRef.test.ts | 14 +++ packages/xstate-lit/vitest.config.mts | 14 +++ 19 files changed, 625 insertions(+) create mode 100644 .changeset/strong-birds-refuse.md create mode 100644 packages/xstate-lit/CHANGELOG.md create mode 100644 packages/xstate-lit/README.md create mode 100644 packages/xstate-lit/package.json create mode 100644 packages/xstate-lit/setup-file.mts create mode 100644 packages/xstate-lit/src/UseMachine.ts create mode 100644 packages/xstate-lit/src/index.ts create mode 100644 packages/xstate-lit/test/UseActor.ts create mode 100644 packages/xstate-lit/test/UseActorRef.ts create mode 100644 packages/xstate-lit/test/UseActorRehydratedState.ts create mode 100644 packages/xstate-lit/test/UseActorRehydratedStateConfig.ts create mode 100644 packages/xstate-lit/test/UseActorWithTransitionLogic.ts create mode 100644 packages/xstate-lit/test/fetchMachine.ts create mode 100644 packages/xstate-lit/test/package.json create mode 100644 packages/xstate-lit/test/persistedMachine.ts create mode 100644 packages/xstate-lit/test/tsconfig.json create mode 100644 packages/xstate-lit/test/useActor.test.ts create mode 100644 packages/xstate-lit/test/useActorRef.test.ts create mode 100644 packages/xstate-lit/vitest.config.mts diff --git a/.changeset/strong-birds-refuse.md b/.changeset/strong-birds-refuse.md new file mode 100644 index 0000000000..2553bd5bfc --- /dev/null +++ b/.changeset/strong-birds-refuse.md @@ -0,0 +1,11 @@ +--- +'@xstate/lit': major +'xstate': minor +'@xstate/react': minor +'@xstate/solid': minor +'@xstate/store': minor +'@xstate/svelte': minor +'@xstate/vue': minor +--- + +Add Lit Controller diff --git a/packages/xstate-lit/CHANGELOG.md b/packages/xstate-lit/CHANGELOG.md new file mode 100644 index 0000000000..9620536e4e --- /dev/null +++ b/packages/xstate-lit/CHANGELOG.md @@ -0,0 +1 @@ +# @xstate/lit diff --git a/packages/xstate-lit/README.md b/packages/xstate-lit/README.md new file mode 100644 index 0000000000..57bc490c8d --- /dev/null +++ b/packages/xstate-lit/README.md @@ -0,0 +1,75 @@ +# @xstate/lit + +The [@xstate/lit](https://github.com/lit/lit) package contains a [Reactive Controller](https://lit.dev/docs/composition/controllers/) for using XState with Lit. + +- [Read the full documentation in the XState docs](https://stately.ai/docs/xstate-lit/). +- [Read our contribution guidelines](https://github.com/statelyai/xstate/blob/main/CONTRIBUTING.md). + +## Quick Start + +1. Install `xstate` and `@xstate/lit`: + +```bash +npm i xstate @xstate/lit +``` + +**Via CDN** + +```html + +``` + +2. Import the `UseMachine` Lit controller: + +**`new UseMachine(this, {machine, options?, callback?})`** + +```js +import { html, LitElement } from 'lit'; +import { customElement } from 'lit/decorators.js'; +import { createBrowserInspector } from '@statelyai/inspect'; +import { createMachine } from 'xstate'; +import { UseMachine } from '@xstate/lit'; + +const { inspect } = createBrowserInspector({ + // Comment out the line below to start the inspector + autoStart: false, +}); + +const toggleMachine = createMachine({ + id: 'toggle', + initial: 'inactive', + states: { + inactive: { + on: { TOGGLE: 'active' }, + }, + active: { + on: { TOGGLE: 'inactive' }, + }, + }, +}); + +@customElement('toggle-component') +export class ToggleComponent extends LitElement { + toggleController: UseMachine; + +constructor() { + super(); + this.toggleController = new UseMachine(this, { + machine: toggleMachine, + options: { inspect } + }); + } + + private get turn() { + return this.toggleController.snapshot.matches('inactive'); + } + + render() { + return html` + + `; + } +} +``` diff --git a/packages/xstate-lit/package.json b/packages/xstate-lit/package.json new file mode 100644 index 0000000000..5627c93606 --- /dev/null +++ b/packages/xstate-lit/package.json @@ -0,0 +1,66 @@ +{ + "name": "@xstate/lit", + "version": "1.0.0", + "description": "XState tools for Lit", + "keywords": [ + "state", + "machine", + "statechart", + "scxml", + "state", + "graph", + "store", + "lit", + "reactive controller", + "web components" + ], + "author": "David Khourshid ", + "homepage": "https://github.com/statelyai/xstate/tree/main/packages/xstate-lit#readme", + "license": "MIT", + "main": "dist/xstate-lit.cjs.js", + "module": "dist/xstate-lit.esm.js", + "type": "module", + "preconstruct": { + "exports": true, + "___experimentalFlags_WILL_CHANGE_IN_PATCH": { + "typeModule": true, + "distInRoot": true, + "importsConditions": true + } + }, + "exports": { + ".": { + "types": { + "import": "./dist/xstate-lit.cjs.mjs", + "default": "./dist/xstate-lit.cjs.js" + }, + "module": "./dist/xstate-lit.esm.js", + "import": "./dist/xstate-lit.cjs.mjs", + "default": "./dist/xstate-lit.cjs.js" + }, + "./package.json": "./package.json" + }, + "sideEffects": false, + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "git+ssh://git@github.com/statelyai/xstate.git" + }, + "bugs": { + "url": "https://github.com/statelyai/xstate/issues" + }, + "peerDependencies": { + "lit": "^3.3.1", + "xstate": "workspace:^" + }, + "devDependencies": { + "@vitest/browser": "^3.2.2", + "lit": "^3.3.1", + "playwright": "^1.54.1", + "vitest": "^3.2.2", + "vitest-browser-lit": "^0.1.0", + "xstate": "workspace:^" + } +} diff --git a/packages/xstate-lit/setup-file.mts b/packages/xstate-lit/setup-file.mts new file mode 100644 index 0000000000..0e1d6ebe8b --- /dev/null +++ b/packages/xstate-lit/setup-file.mts @@ -0,0 +1 @@ +import 'vitest-browser-lit' \ No newline at end of file diff --git a/packages/xstate-lit/src/UseMachine.ts b/packages/xstate-lit/src/UseMachine.ts new file mode 100644 index 0000000000..317468aea9 --- /dev/null +++ b/packages/xstate-lit/src/UseMachine.ts @@ -0,0 +1,94 @@ +import { ReactiveController, ReactiveControllerHost } from 'lit'; +import { + Actor, + AnyStateMachine, + ActorOptions, + createActor, + EventFrom, + Subscription, + SnapshotFrom, +} from 'xstate'; + +export class UseMachine + implements ReactiveController +{ + private host: ReactiveControllerHost; + private machine: TMachine; + private options?: ActorOptions; + private callback?: (snapshot: SnapshotFrom) => void; + private actorRef = {} as Actor; + private subs: Subscription = { unsubscribe: () => {} }; + private currentSnapshot: SnapshotFrom; + + constructor( + host: ReactiveControllerHost, + { + machine, + options, + callback, + }: { + machine: TMachine; + options?: ActorOptions; + callback?: (snapshot: SnapshotFrom) => void; + } + ) { + this.machine = machine; + this.options = options; + this.callback = callback; + this.currentSnapshot = this.snapshot; + + (this.host = host).addController(this); + } + + /** + * The underlying ActorRef from XState + */ + get actor() { + return this.actorRef; + } + + /** + * The latest snapshot of the actor's state + */ + get snapshot() { + return this.actorRef?.getSnapshot?.(); + } + + /** + * Send an event to the actor service + * @param {import('xstate').EventFrom} ev + */ + send(ev: EventFrom) { + this.actorRef?.send(ev); + } + + unsubscribe() { + this.subs.unsubscribe(); + } + + protected onNext = (snapshot: SnapshotFrom) => { + if (this.currentSnapshot !== snapshot) { + this.currentSnapshot = snapshot; + this.callback?.(snapshot); + this.host.requestUpdate(); + } + }; + + private startService() { + this.actorRef = createActor(this.machine, this.options); + this.subs = this.actorRef?.subscribe(this.onNext); + this.actorRef?.start(); + } + + private stopService() { + this.actorRef?.stop(); + } + + hostConnected() { + this.startService(); + } + + hostDisconnected() { + this.stopService(); + } +} diff --git a/packages/xstate-lit/src/index.ts b/packages/xstate-lit/src/index.ts new file mode 100644 index 0000000000..5ab5cf5953 --- /dev/null +++ b/packages/xstate-lit/src/index.ts @@ -0,0 +1 @@ +export { UseMachine } from './UseMachine.ts'; diff --git a/packages/xstate-lit/test/UseActor.ts b/packages/xstate-lit/test/UseActor.ts new file mode 100644 index 0000000000..5b7351d7aa --- /dev/null +++ b/packages/xstate-lit/test/UseActor.ts @@ -0,0 +1,76 @@ +import { html, LitElement } from 'lit'; +import type { AnyMachineSnapshot } from 'xstate'; +import { fromPromise } from 'xstate/actors'; +import { fetchMachine } from './fetchMachine.ts'; +import { UseMachine } from '../src/index.ts'; + +const onFetch = () => + new Promise((res) => { + setTimeout(() => res('some data'), 50); + }); + +const fMachine = fetchMachine.provide({ + actors: { + fetchData: fromPromise(onFetch) + } +}); + +export class UseActor extends LitElement { + fetchController: UseMachine = {} as UseMachine< + typeof fetchMachine + >; + persistedState: AnyMachineSnapshot | undefined = undefined; + + static properties = { + persistedState: { attribute: false } + }; + + override connectedCallback() { + super.connectedCallback?.(); + + this.fetchController = new UseMachine(this, { + machine: fMachine, + options: { + snapshot: this.persistedState + } + }); + } + + override render() { + return html` + +
+ ${this.fetchController.snapshot.matches('idle') + ? html` + + ` + : ''} + ${this.fetchController.snapshot.matches('loading') + ? html`
Loading...
` + : ''} + ${this.fetchController.snapshot.matches('success') + ? html` +
+ Success! Data: +
+ ${this.fetchController.snapshot.context.data} +
+
+ ` + : ''} +
+ `; + } +} + +window.customElements.define('use-actor', UseActor); + +declare global { + interface HTMLElementTagNameMap { + 'use-actor': UseActor; + } +} diff --git a/packages/xstate-lit/test/UseActorRef.ts b/packages/xstate-lit/test/UseActorRef.ts new file mode 100644 index 0000000000..beabd1ba7f --- /dev/null +++ b/packages/xstate-lit/test/UseActorRef.ts @@ -0,0 +1,52 @@ +import { html, LitElement } from 'lit'; +import { createMachine } from 'xstate'; +import { UseMachine } from '../src/index.ts'; + +const machine = createMachine({ + initial: 'inactive', + states: { + inactive: { + on: { + TOGGLE: 'active' + } + }, + active: { + on: { + TOGGLE: 'inactive' + } + } + } +}); + +export class UseActorRef extends LitElement { + machineController: UseMachine = {} as UseMachine< + typeof machine + >; + + constructor() { + super(); + this.machineController = new UseMachine(this, { + machine: machine + }); + } + + private get turn() { + return this.machineController.snapshot.matches('inactive'); + } + + override render() { + return html` + + `; + } +} + +window.customElements.define('use-actor-ref', UseActorRef); + +declare global { + interface HTMLElementTagNameMap { + 'use-actor-ref': UseActorRef; + } +} diff --git a/packages/xstate-lit/test/UseActorRehydratedState.ts b/packages/xstate-lit/test/UseActorRehydratedState.ts new file mode 100644 index 0000000000..9b87d3ff17 --- /dev/null +++ b/packages/xstate-lit/test/UseActorRehydratedState.ts @@ -0,0 +1,18 @@ +import { UseActor } from './UseActor.ts'; +import type { AnyMachineSnapshot } from 'xstate'; +import { persistedFetchState } from './persistedMachine.ts'; + +export class UseActorRehydratedState extends UseActor { + persistedState: AnyMachineSnapshot | undefined = persistedFetchState as AnyMachineSnapshot | undefined; +} + +window.customElements.define( + 'use-actor-rehydrated-state', + UseActorRehydratedState +); + +declare global { + interface HTMLElementTagNameMap { + 'use-actor-rehydrated-state': UseActorRehydratedState; + } +} diff --git a/packages/xstate-lit/test/UseActorRehydratedStateConfig.ts b/packages/xstate-lit/test/UseActorRehydratedStateConfig.ts new file mode 100644 index 0000000000..704d4cd18a --- /dev/null +++ b/packages/xstate-lit/test/UseActorRehydratedStateConfig.ts @@ -0,0 +1,17 @@ +import { UseActor } from './UseActor.ts'; +import { persistedFetchStateConfig } from './persistedMachine.ts'; + +export class UseActorRehydratedStateConfig extends UseActor { + persistedState = persistedFetchStateConfig; +} + +window.customElements.define( + 'use-actor-rehydrated-state-config', + UseActorRehydratedStateConfig +); + +declare global { + interface HTMLElementTagNameMap { + 'use-actor-rehydrated-state-config': UseActorRehydratedStateConfig; + } +} diff --git a/packages/xstate-lit/test/UseActorWithTransitionLogic.ts b/packages/xstate-lit/test/UseActorWithTransitionLogic.ts new file mode 100644 index 0000000000..214389d496 --- /dev/null +++ b/packages/xstate-lit/test/UseActorWithTransitionLogic.ts @@ -0,0 +1,43 @@ +import { html, LitElement } from 'lit'; +import type { AnyStateMachine } from 'xstate'; +import { fromTransition } from 'xstate/actors'; +import { UseMachine } from '../src/index.ts'; + +const reducer = (state: number, event: { type: 'INC' }): number => { + if (event.type === 'INC') { + return state + 1; + } + return state; +}; + +const logic = fromTransition(reducer, 0); + +export class UseActorWithTransitionLogic extends LitElement { + fromTransitionLogicController: UseMachine; + constructor() { + super(); + this.fromTransitionLogicController = new UseMachine(this, { + machine: logic as unknown as AnyStateMachine + }); + } + + override render() { + return html` `; + } +} + +window.customElements.define( + 'use-actor-with-transition-logic', + UseActorWithTransitionLogic +); + +declare global { + interface HTMLElementTagNameMap { + 'use-actor-with-transition-logic': UseActorWithTransitionLogic; + } +} diff --git a/packages/xstate-lit/test/fetchMachine.ts b/packages/xstate-lit/test/fetchMachine.ts new file mode 100644 index 0000000000..cab7c73823 --- /dev/null +++ b/packages/xstate-lit/test/fetchMachine.ts @@ -0,0 +1,39 @@ +import { createMachine, assign, type ActorLogicFrom } from 'xstate'; + +const context = { + data: undefined as string | undefined +}; + +export const fetchMachine = createMachine({ + id: 'fetch', + types: {} as { + context: typeof context; + actors: { + src: 'fetchData'; + logic: ActorLogicFrom>; + }; + }, + initial: 'idle', + context, + states: { + idle: { + on: { FETCH: 'loading' } + }, + loading: { + invoke: { + id: 'fetchData', + src: 'fetchData', + onDone: { + target: 'success', + actions: assign({ + data: ({ event }) => event.output + }), + guard: ({ event }) => !!event.output.length + } + } + }, + success: { + type: 'final' + } + } +}); diff --git a/packages/xstate-lit/test/package.json b/packages/xstate-lit/test/package.json new file mode 100644 index 0000000000..3dbc1ca591 --- /dev/null +++ b/packages/xstate-lit/test/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/packages/xstate-lit/test/persistedMachine.ts b/packages/xstate-lit/test/persistedMachine.ts new file mode 100644 index 0000000000..92681e48d5 --- /dev/null +++ b/packages/xstate-lit/test/persistedMachine.ts @@ -0,0 +1,26 @@ +import { createActor, createMachine } from 'xstate'; +import { fetchMachine } from './fetchMachine.ts'; + +const actorRef = createActor( + fetchMachine.provide({ + actors: { + fetchData: createMachine({ + initial: 'done', + states: { + done: { + type: 'final' + } + }, + output: 'persisted data' + }) as any + } + }) +).start(); + +actorRef.send({ type: 'FETCH' }); + +export const persistedFetchState = actorRef.getPersistedSnapshot(); + +export const persistedFetchStateConfig = JSON.parse( + JSON.stringify(persistedFetchState) +); diff --git a/packages/xstate-lit/test/tsconfig.json b/packages/xstate-lit/test/tsconfig.json new file mode 100644 index 0000000000..420309c479 --- /dev/null +++ b/packages/xstate-lit/test/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "experimentalDecorators": true, + "noEmit": true + } +} diff --git a/packages/xstate-lit/test/useActor.test.ts b/packages/xstate-lit/test/useActor.test.ts new file mode 100644 index 0000000000..9786b885cb --- /dev/null +++ b/packages/xstate-lit/test/useActor.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { userEvent } from '@vitest/browser/context'; +import { render } from 'vitest-browser-lit'; +import { html } from 'lit'; +import { createActor, createMachine } from 'xstate'; +import { fetchMachine } from './fetchMachine.ts'; +import './UseActor.ts'; +import './UseActorWithTransitionLogic.ts'; + +const actorRef = createActor( + fetchMachine.provide({ + actors: { + fetchData: createMachine({ + initial: 'done', + states: { + done: { + type: 'final' + } + }, + output: 'persisted data' + }) as any + } + }) +).start(); +actorRef.send({ type: 'FETCH' }); + +const persistedFetchState = actorRef.getPersistedSnapshot(); + +const persistedFetchStateConfig = JSON.parse( + JSON.stringify(persistedFetchState) +); + +describe('useActor', () => { + it('should work with a component', async () => { + const screen = render(html``); + await expect.element(screen.getByText('Fetch')).toBeVisible(); + await userEvent.click(screen.getByRole('button', { name: 'Fetch' })); + await expect.element(screen.getByText('Loading...')).toBeVisible(); + await expect.element(screen.getByText('Success')).toBeVisible(); + await expect.element(screen.getByText('some data')).toBeVisible(); + }); + + it('should work with a component with rehydrated state', async () => { + const screen = render( + html`` + ); + await expect.element(screen.getByText('Success')).toBeVisible(); + await expect.element(screen.getByText('persisted data')).toBeVisible(); + }); + + it('should work with a component with rehydrated state config', async () => { + const screen = render( + html`` + ); + await expect.element(screen.getByText('Success')).toBeVisible(); + await expect.element(screen.getByText('persisted data')).toBeVisible(); + }); + + it('should be able to spawn an actor from actor logic', async () => { + const screen = render( + html`` + ); + await expect.element(screen.getByText('0')).toBeVisible(); + await userEvent.click(screen.getByRole('button', { name: '0' })); + await expect.element(screen.getByText('1')).toBeVisible(); + }); +}); diff --git a/packages/xstate-lit/test/useActorRef.test.ts b/packages/xstate-lit/test/useActorRef.test.ts new file mode 100644 index 0000000000..a2bd5f5a4a --- /dev/null +++ b/packages/xstate-lit/test/useActorRef.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from 'vitest'; +import { userEvent } from '@vitest/browser/context'; +import { render } from 'vitest-browser-lit'; +import { html } from 'lit'; +import './UseActorRef.ts'; + +describe('useActorRef', () => { + it('observer should be called with next state', async () => { + const screen = render(html``); + await expect.element(screen.getByText('Turn on')).toBeVisible(); + await userEvent.click(screen.getByRole('button', { name: 'Turn on' })); + await expect.element(screen.getByText('Turn off')).toBeVisible(); + }); +}); diff --git a/packages/xstate-lit/vitest.config.mts b/packages/xstate-lit/vitest.config.mts new file mode 100644 index 0000000000..4eb93843f6 --- /dev/null +++ b/packages/xstate-lit/vitest.config.mts @@ -0,0 +1,14 @@ +import { defineProject } from 'vitest/config'; + +export default defineProject({ + test: { + globals: true, + setupFiles: ['./setup-file.mts'], + browser: { + enabled: true, + provider: 'playwright', + headless: true, + instances: [{ browser: 'chromium' }] + } + } +}); From 567c7f74b02a37e0a0999813bbe065b195122729 Mon Sep 17 00:00:00 2001 From: oscar marina Date: Tue, 29 Jul 2025 16:31:41 +0200 Subject: [PATCH 2/2] [@xstate/lit] Rename turn to turnedOff for clarity - packages --- packages/xstate-lit/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/xstate-lit/README.md b/packages/xstate-lit/README.md index 57bc490c8d..30e715f030 100644 --- a/packages/xstate-lit/README.md +++ b/packages/xstate-lit/README.md @@ -60,14 +60,14 @@ constructor() { }); } - private get turn() { + private get turnedOff() { return this.toggleController.snapshot.matches('inactive'); } render() { return html` `; }