Skip to content
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

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ const { constants } = require('jest-config');
module.exports = {
prettierPath: null,
setupFilesAfterEnv: ['@xstate-repo/jest-utils/setup'],
transformIgnorePatterns: [
'node_modules/(?!(@open-wc|lit-html|lit-element|lit|@lit)/)'
],
Comment on lines +9 to +11
Copy link
Member

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?

Copy link
Author

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)

===
jest-and-lit

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??

transform: {
[constants.DEFAULT_JS_PATTERN]: 'babel-jest',
'^.+\\.vue$': '@vue/vue3-jest',
Expand Down
1 change: 1 addition & 0 deletions packages/xstate-lit/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# @xstate/lit
132 changes: 132 additions & 0 deletions packages/xstate-lit/README.md
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
```
65 changes: 65 additions & 0 deletions packages/xstate-lit/package.json
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this package should try to use this experimental option: preconstruct/preconstruct#586

Copy link
Author

Choose a reason for hiding this comment

The 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

Copy link
Member

Choose a reason for hiding this comment

The 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 preconstruct dev/validate/build

Copy link
Author

Choose a reason for hiding this comment

The 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"
}
}
84 changes: 84 additions & 0 deletions packages/xstate-lit/src/UseMachine.ts
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>

Choose a reason for hiding this comment

The 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 MachineController

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @justinfagnani
I use "Use" to follow the naming style as closely as possible to xstate. I took packages xstate-vue and xstate-svelte as references. Even though they are not related to React, they use similar nomenclature.
How do you see it, @Andarist?

Choose a reason for hiding this comment

The 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.

UseMachine is a verb-phrase, and I think it reads a bit weird to have statements like:

this.fetchController = new UseMachine(...);

Because this is saying that fetchController is a "UseMachine". But what is a "UseMachine"? It's a controller - an object with a verb-phrased name. But it sounds like a function.

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;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd recommend prefixing private fields with _ so that they're more obviously private even to plain JavaScript users.

Even better if you can, I'd use standard private fields. They have very good browser support these days, and even better tool support.

Copy link
Author

Choose a reason for hiding this comment

The 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.

Choose a reason for hiding this comment

The 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();
}
}
1 change: 1 addition & 0 deletions packages/xstate-lit/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { UseMachine } from './UseMachine.ts';
71 changes: 71 additions & 0 deletions packages/xstate-lit/test/UseActor.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')

Choose a reason for hiding this comment

The 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 @lit/task-like render helper that's a bit more declarative?

Something like:

this.fetchController.render({
  idle: () => html`...`,
  loading: () => html`...`,
  success: () => html`...`,
});

Could such an API be type safe wrt the the state names?

Copy link
Author

Choose a reason for hiding this comment

The 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.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which two concepts?

? html`
<button
@click=${() => this.fetchController.send({ type: 'FETCH' })}

Choose a reason for hiding this comment

The 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' })}

sendHandler() is probably a terrible name, but I couldn't think of something better right now.

Copy link
Author

Choose a reason for hiding this comment

The 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

Choose a reason for hiding this comment

The 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);
  }
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi,
I have a couple of doubts:

With this approach, <button @click=${this.fetchController.sendHandler({ type: 'FETCH' })}>Fetch</button>, the function is called on each render pass.

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;
}
}
Loading
Loading