-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[@xstate/lit] Add Lit Controller #4775
base: main
Are you sure you want to change the base?
Changes from all commits
afc4616
8d5837c
dc3aa91
e6653d1
a4aae43
08e15e3
ea4fde5
83ab23c
7c0a38f
68c9ab4
259f072
a3a71bd
99dc400
5fed1a8
ff3a4d4
40b88a5
6b51fd6
fdddbda
d44d4a1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
# @xstate/lit |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,132 @@ | ||
# @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 | ||
<script src="https://unpkg.com/@xstate/lit/dist/xstate-lit.esm.js"></script> | ||
``` | ||
|
||
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<typeof toggleMachine>; | ||
|
||
constructor() { | ||
super(); | ||
this.toggleController = new UseMachine(this, { | ||
machine: toggleMachine, | ||
options: { inspect } | ||
}); | ||
} | ||
|
||
private get _turn() { | ||
return this.toggleController.snapshot.matches('inactive'); | ||
} | ||
|
||
render() { | ||
return html` | ||
<button @click=${() => this.toggleController.send({ type: 'TOGGLE' })}> | ||
${this._turn ? 'Turn on' : 'Turn off'} | ||
</button> | ||
`; | ||
} | ||
} | ||
``` | ||
|
||
## API | ||
|
||
### `new UseMachine(host, {machine, options?, callback?})` | ||
|
||
A class that creates an actor from the given machine and starts a service that runs for the lifetime of the component. | ||
|
||
#### Constructor Options: | ||
|
||
`host`: ReactiveControllerHost: The Lit component host. | ||
`machine`: AnyStateMachine: The XState machine to manage. | ||
`options?`: ActorOptions<TMachine>: Optional options for the actor. | ||
`callback?`: (snapshot: SnapshotFrom<TMachine>) => void: An optional subscription callback that listens to snapshot updates. | ||
|
||
#### Return Methods: | ||
|
||
`actor` - Returns the actor (state machine) instance. | ||
`snapshot` - Returns the current state snapshot. | ||
`send` - Send an event to the state machine. | ||
`unsubscribe` - Unsubscribes from state updates. | ||
|
||
## Matching States | ||
|
||
When using [hierarchical](https://xstate.js.org/docs/guides/hierarchical.html) and [parallel](https://xstate.js.org/docs/guides/parallel.html) machines, the state values will be objects, not strings. In this case, it is best to use [`state.matches(...)`](https://xstate.js.org/docs/guides/states.html#state-methods-and-properties). | ||
|
||
```js | ||
${this.myXStateController.snapshot.matches('idle')} | ||
// | ||
${this.myXStateController.snapshot.matches({ loading: 'user' })} | ||
// | ||
${this.myXStateController.snapshot.matches({ loading: 'friends' })} | ||
``` | ||
|
||
## Persisted and Rehydrated State | ||
|
||
You can persist and rehydrate state with `useMachine(...)` via `options.snapshot`: | ||
|
||
```js | ||
// Get the persisted state config object from somewhere, e.g. localStorage | ||
// highlight-start | ||
const persistedState = JSON.parse( | ||
localStorage.getItem('some-persisted-state-key'), | ||
); | ||
// highlight-end | ||
|
||
constructor() { | ||
super(); | ||
this.fetchController = new UseMachine(this, { | ||
machine: someMachine, | ||
options: { | ||
snapshot: this.persistedState | ||
} | ||
}); | ||
} | ||
|
||
// state will initially be that persisted state, not the machine’s initialState | ||
``` |
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this package should try to use this experimental option: preconstruct/preconstruct#586 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hi, I added 'type:module' and that fixed the error in "yarn typecheck". https://github.com/preconstruct/preconstruct/pull/586/checks#discussion_r1452789690 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it still should use that experimental option - otherwise, I'd expect us to run into problems with There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done! |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
{ | ||
"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 <[email protected]>", | ||
"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://[email protected]/statelyai/xstate.git" | ||
}, | ||
"bugs": { | ||
"url": "https://github.com/statelyai/xstate/issues" | ||
}, | ||
"peerDependencies": { | ||
"lit": "^3.1.2", | ||
"xstate": "^5.14.0" | ||
}, | ||
"devDependencies": { | ||
"@open-wc/testing-helpers": "^3.0.0", | ||
"@testing-library/dom": "^9.3.4", | ||
"@types/jest": "^29.5.10", | ||
"lit": "^3.1.2", | ||
"xstate": "5.14.0" | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,84 @@ | ||
import { ReactiveController, ReactiveControllerHost } from 'lit'; | ||
import { | ||
Actor, | ||
AnyStateMachine, | ||
ActorOptions, | ||
createActor, | ||
EventFrom, | ||
SnapshotFrom, | ||
Subscription | ||
} from 'xstate'; | ||
|
||
export class UseMachine<TMachine extends AnyStateMachine> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since this is a controller and not a React hook, I'd recommend against the "Use" name, and call this something like There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hi @justinfagnani There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As someone more planted in the Lit ecosystem, I think it'd be nice to match Lit naming too - which just follows the somewhat cross-language standard OO noun-phrase vs verb-phrase naming. The class should be the noun-phrase name of the category of thing that the instances are, while functions should be verb-phrases.
this.fetchController = new UseMachine(...); Because this is saying that This is why most reactive controllers are named {X}Controller. It's possible to vend a function that makes the class for you, so maybe that's better: this.fetchController = useMachine(...); But even then, in the Lit ecosystem I'd still want to shy away from the "use" name because people tend to think that it's a hook and has React-like "rule of hooks" semantics, when it doesn't. |
||
implements ReactiveController | ||
{ | ||
private host: ReactiveControllerHost; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd recommend prefixing private fields with Even better if you can, I'd use standard private fields. They have very good browser support these days, and even better tool support. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As with my previous comment, I followed the style used in xstate here. I reviewed some controllers in Lit, such as: And decided to maintain that style. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oops... those don't actually follow our code style, so I'll fix them. Thanks for pointing them out! One reason why we prefix all private fields is so that they can be renamed in a minifier like Terser with a simple rule. |
||
private machine: TMachine; | ||
private options?: ActorOptions<TMachine>; | ||
private callback?: (snapshot: SnapshotFrom<TMachine>) => void; | ||
private actorRef = {} as Actor<TMachine>; | ||
private subs: Subscription = { unsubscribe: () => {} }; | ||
private currentSnapshot: SnapshotFrom<TMachine>; | ||
|
||
constructor( | ||
host: ReactiveControllerHost, | ||
{ | ||
machine, | ||
options, | ||
callback | ||
}: { | ||
machine: TMachine; | ||
options?: ActorOptions<TMachine>; | ||
callback?: (snapshot: SnapshotFrom<TMachine>) => void; | ||
} | ||
) { | ||
this.machine = machine; | ||
this.options = options; | ||
this.callback = callback; | ||
this.currentSnapshot = this.snapshot; | ||
|
||
(this.host = host).addController(this); | ||
} | ||
|
||
get actor() { | ||
return this.actorRef; | ||
} | ||
|
||
get snapshot() { | ||
return this.actorRef?.getSnapshot?.(); | ||
} | ||
|
||
send(ev: EventFrom<TMachine>) { | ||
this.actorRef?.send(ev); | ||
} | ||
|
||
unsubscribe() { | ||
this.subs.unsubscribe(); | ||
} | ||
|
||
protected onNext = (snapshot: SnapshotFrom<TMachine>) => { | ||
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(); | ||
} | ||
oscarmarina marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
hostDisconnected() { | ||
this.stopService(); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { UseMachine } from './UseMachine.ts'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
import { html, LitElement } from 'lit'; | ||
import type { Snapshot } from 'xstate'; | ||
import { fromPromise } from 'xstate/actors'; | ||
import { fetchMachine } from './fetchMachine.ts'; | ||
import { UseMachine } from '../src/index.ts'; | ||
|
||
const onFetch = () => | ||
new Promise<string>((res) => { | ||
setTimeout(() => res('some data'), 50); | ||
}); | ||
|
||
const fMachine = fetchMachine.provide({ | ||
actors: { | ||
fetchData: fromPromise(onFetch) | ||
} | ||
}); | ||
|
||
export class UseActor extends LitElement { | ||
fetchController: UseMachine<typeof fetchMachine> = new UseMachine(this, { | ||
machine: fMachine, | ||
options: { | ||
snapshot: this.persistedState | ||
} | ||
}); | ||
|
||
get persistedState(): Snapshot<any> | undefined { | ||
return undefined; | ||
} | ||
|
||
override createRenderRoot() { | ||
return this; | ||
} | ||
|
||
override render() { | ||
return html` | ||
<slot></slot> | ||
<div> | ||
${this.fetchController.snapshot.matches('idle') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't know the XState API, but could the control offer a Something like: this.fetchController.render({
idle: () => html`...`,
loading: () => html`...`,
success: () => html`...`,
}); Could such an API be type safe wrt the the state names? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, I'm not quite sure if those two concepts can be combined. We would need @davidkpiano and @Andarist 's input on this. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Which two concepts? |
||
? html` | ||
<button | ||
@click=${() => this.fetchController.send({ type: 'FETCH' })} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This may be a super small DX improvement, but the controller could offer a helper that created an event handler for the user, instead of requiring an arrow function. It would also eliminate the closure creation on every render for a small perf gain. Something like html`
<button
@click=${this.fetchController.sendHandler({ type: 'FETCH' })}
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I’m not quite sure what you're suggesting here. Could you please clarify? Here is a repository where I created some demos before making the PR. If possible, you can add your suggestions there: https://github.com/oscarmarina/XstateController/blob/main/xstate-lit/src/UseMachine.ts There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So right now you need to use an arrow function in the click handler: html`
<button
@click=${() => this.fetchController.send({ type: 'FETCH' })}
>
Fetch
</button>
` This is fine, but it does allocate a new function object for this binding every render, and it's a slight bit of boilerplate. Instead you can have the controller allocate a function object once and reuse it every render. Using it would look like: html`
<button
@click=${this.fetchController.sendHandler({ type: 'FETCH' })}
>
Fetch
</button>
` and implementing it would look like: class MachineController {
#sendHandler;
/** Returns a callback that calls this.send() with the given arguments **/
sendHandler(...args) {
return this.#sendHandler ??= () => this.send(...args);
}
} There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hi, With this approach, so "null assignment" is a problem and caches the function: // the function is invoked as soon as the template is processed, rather than in response to a click event.
sendHandlerCache(ev: EventFrom<TMachine>) {
console.log('#sendHandler::', ev);
// the use of "nullish assignment (??=)" causes it to be assigned only once
return (this.#sendHandler ??= () => this.sendEventFrom(ev));
} Without the "nullish assignment", it works perfectly: // the function is invoked as soon as the template is processed, rather than in response to a click event.
sendHandler(ev: EventFrom<TMachine>) {
console.log('#sendHandlerNoNullish::', ev);
// In this way it works, but does it make sense to create a class field?
return (this.#sendHandlerNoNullish = () => this.sendEventFrom(ev));
} but it seems unnecessary when it can be done like this: // the function is invoked as soon as the template is processed, rather than in response to a click event.
send(ev: EventFrom<TMachine>) {
console.log('send - arrow function::', ev);
// It works, the question is does it improve the DX?
// And does it eliminate the closure creation on every render for a small performance gain?
return () => this.sendEventFrom(ev);
}
sendEventFrom(ev: EventFrom<TMachine>) {
console.log('click::', ev);
this.actorRef?.send(ev);
} Does this last approach eliminate the creation of closures on every render? It's true that, from a DX perspective, avoiding the need for arrow functions is better. |
||
> | ||
Fetch | ||
</button> | ||
` | ||
: ''} | ||
${this.fetchController.snapshot.matches('loading') | ||
? html` <div>Loading...</div> ` | ||
: ''} | ||
${this.fetchController.snapshot.matches('success') | ||
? html` | ||
<div> | ||
Success! Data: | ||
<div data-testid="data"> | ||
${this.fetchController.snapshot.context.data} | ||
</div> | ||
</div> | ||
` | ||
: ''} | ||
</div> | ||
`; | ||
} | ||
} | ||
|
||
window.customElements.define('use-actor', UseActor); | ||
|
||
declare global { | ||
interface HTMLElementTagNameMap { | ||
'use-actor': UseActor; | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why do we need this? does it turn off ESM->CJS transform?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It is necessary to use Jest with Lit.
You can see more information here.
jestjs/jest#11783 (comment)
===
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Jest still doesn't support modules??