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

Conversation

oscarmarina
Copy link

@oscarmarina oscarmarina commented Feb 28, 2024

This pull request adds compatibility for linking XState with Lit.

Motivation

XState's state machines offer a structured and predictable approach to handle complex logic, while Lit facilitates reactive UI updates in response to state changes.

Changes

It follows the established structure of the xstate repository, including:

📂 packages/xstate-lit/src/

Adds xstate-lit to packages with code, tests, and documentation.

UseMachine.ts

Implements the @xstate/lit controller, referencing other packages for guidance as much as possible.
@xstate/svelte, @xstate/vue and Lifecycle: reactive controller adapters for other frameworks

  • Provides get actor, get snapshot, and send(ev: EventFrom<TMachine>) method for interacting with the XState actor.
  • Exposes an unsubscribe method
  • Option to pass a reactive property for use in the actor's subscribe method.
  • Documentation of the API in the README file.
this.toggleController = new UseMachine(this, {
  machine: toggleMachine,
  options: { inspect },
  subscriptionProperty: '_xstate',
});

// ...

updated(props: Map<string, unknown>) {
  super.updated && super.updated(props);
  if (props.has('_xstate')) {
    const { value } = this._xstate;
    const toggleEvent = new CustomEvent('togglechange', {
      detail: value,
    });
    this.dispatchEvent(toggleEvent);
  }
}

// ...

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

useActorRef.ts

Creates and returns the XState actor without Lit-specific dependencies (handled in UseMachine.js).

index.ts:

Exports only UseMachine.ts.

It is a structure more in line with the working approach used by Lit's controllers.

📂 packages/xstate-lit/test

Leverages @open-wc/testing-helpers for unit testing components, drawing inspiration from existing tests in Svelte and Vue integrations.

useActor.test.ts

it('should be able to spawn an actor from actor logic', async () => {
    const el: UseActorWithTransitionLogic = await fixture(
      html`<use-actor-with-transition-logic></use-actor-with-transition-logic>`
    );
    const buttonEl = getByTestId(el, 'count');
    await waitFor(() => expect(buttonEl.textContent?.trim()).toEqual('0'));
    await fireEvent.click(buttonEl);
    await el.updateComplete;
    await waitFor(() => expect(buttonEl.textContent?.trim()).toEqual('1'));
  });

useActorRef.test.ts

it('observer should be called with next state', async () => {
    const el: UseActorRef = await fixture(
      html`<use-actor-ref></use-actor-ref>`
    );
    const buttonEl = getByTestId(el, 'button');
    await waitFor(() => expect(buttonEl.textContent?.trim()).toBe('Turn on'));
    await fireEvent.click(buttonEl);
    await el.updateComplete;
    await waitFor(() => expect(buttonEl.textContent?.trim()).toBe('Turn off'));
  });

📂 templates/lit-ts/

Adds examples and documentation.

Usage:

npm i && npm start

Provides two demos:

  • <lit-ts> & feedbackMachine: Equal to existing templates in other packages.
  • <lit-ts-counter> & counterMachine: Illustrates using a reactive property and the inspect API to listen for events that caused transitions and reset reactive property.
<lit-ts> <lit-ts-counter>

Update before "publish"

import { UseMachine } from '../../../packages/xstate-lit/src/index.js';
// import { UseMachine } from '@xstate/lit';
  • templates/lit-ts/src/LitTs.ts (line 4 and 5)
  • templates/lit-ts/src/LitTsCounter.ts (line 5 and 6)

Other Modified Files:

▨ jest.config.js

Add transformIgnorePatterns to accommodate Lit and Open-WC.

transformIgnorePatterns: [
  'node_modules/(?!(@open-wc|lit-html|lit-element|lit|@lit)/)'
],

📂 scripts/jest-utils/

▨ setup.js

Filters out Lit's "Lit is in dev mode..." console logs during tests.


Copy link

changeset-bot bot commented Feb 28, 2024

⚠️ No Changeset found

Latest commit: d44d4a1

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Copy link

codesandbox-ci bot commented Feb 28, 2024

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

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!

Comment on lines +8 to +11
transformIgnorePatterns: [
'node_modules/(?!(@open-wc|lit-html|lit-element|lit|@lit)/)'
],
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??

@oscarmarina
Copy link
Author

Hi, @davidkpiano, @Andarist
I have uploaded to stackblitz "a version of the demo located in the template folder" if you want to see how it works.

The folder xstat-lit corresponds to what is in the folder packages/xstate-lit

Comment on lines 3 to 9
export const useActorRef = <TMachine extends AnyActorLogic>(
logic: TMachine,
options?: ActorOptions<TMachine>
): Actor<TMachine> => {
const actorRef = createActor(logic, options);
return actorRef;
};
Copy link
Member

Choose a reason for hiding this comment

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

it's just a wrapper around createActor - do you even need it here? couldn't u just call createActor directly in ur UseMachine?

Copy link
Author

Choose a reason for hiding this comment

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

Completely agree, I did it like this following the 'format' of the other packages, but it's much better to use 'createActor' directly.
Done.

Comment on lines 19 to 21
fetchController: UseMachine<typeof fetchMachine> = {} as UseMachine<
typeof fetchMachine
>;
Copy link
Member

Choose a reason for hiding this comment

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

wouldn't it be possible to call new UseMachine(...) here? declaring a property like this would be much easier for consumers

Copy link
Author

Choose a reason for hiding this comment

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

Yes, updated.

@oscarmarina
Copy link
Author

Hi @Andarist
I'm thinking that if the "PR" is correct, I would need to decline this PR and create two new ones, one for @xstate/lit and another for the templates/lit-ts.

This way, I can add the dependency to the templates/lit-ts package file and also update the "import(s)".

Here are the steps I would take:

  • Decline the current PR.
  • Create a new PR for "@xstate/lit".
  • Create a new PR for the "templates/lit-ts".
    • In the "templates/lit-ts" package file, add the dependency on "@xstate/lit".
    • In the "templates/lit-ts" update the "import(s)" to use "@xstate/lit".
  • Submit the two new PRs for review.

Does this approach sound reasonable?

@Andarist
Copy link
Member

Andarist commented Mar 8, 2024

I'd keep this PR open but remove templates from it. Then you can have a branch with templates on it, and point us to it so we can see how it's used in practice (although tests in this PR here might/should be enough too). Once we land this PR that introduces @xstate/lit then you'll be able to open a new one with the templates.

Also, please note that I'm aware of this PR and I plan to review it thoroughly (so far I didn't really do a proper review - just a driveby one). It might take some time before I properly get to it because at the moment I'm focusing on something else. We really appreciate your contribution!

@oscarmarina
Copy link
Author

Perfect, I'll leave it as it is for now to not drive you crazy, and if it eventually makes sense and gets approved, I'll make the necessary changes.

Thank you for taking the time to review the PR.

@oscarmarina
Copy link
Author

Hi @Andarist, just checking in to see if there's been any progress on reviewing the PR or if you need me to make any changes. Thanks!

@christophe-g
Copy link

christophe-g commented Apr 12, 2024

For those interested, this is an alternate lit-controller: https://github.com/lit-apps/lit-app/tree/dev/packages/actor

It leverages https://github.com/lit-apps/lit-app/tree/dev/packages/state and wraps xstate actors so that lit element re-renders when the snapshot changes.

example usage:

const actor = new Actor(workflow)
 
export default class fsmTest extends LitElement {
  // bind actor state to fsmTest element, so it will re-render when actor snapshot changes
  bindActor = new StateController(this, actor)
  
  override render() {

   const send = () => actor.send({ 
    type: 'NewPatientEvent', name: 'John', condition: 'healthy' 
   })
   
   return html`
    <div>
     <div>status: ${actor.status}</div>
     <div>any context value: ${actor.context.anyValue}</div>
     <div>value: ${JSON.stringify(actor.value)}</div>
    </div>
    <button @click=${send}>NewPatientEvent</button>
   `;
  }
}

Copy link

@justinfagnani justinfagnani left a comment

Choose a reason for hiding this comment

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

This is great! Hope a drive-by review is welcome!

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.

export class UseMachine<TMachine extends AnyStateMachine>
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.

packages/xstate-lit/src/UseMachine.ts Outdated Show resolved Hide resolved
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?

${this.fetchController.snapshot.matches('idle')
? 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.

oscarmarina and others added 11 commits June 26, 2024 14:36
1. state renamed to snapshot;
2. transition event should in an object.
* Support for parameterized `enqueueActions`

* add missing context
* Add basic event emitter

* Remove id and delay

* Fix types

* Rename

* Add machine types

* Add TEmitted type... everywhere

* Avoid upsetting devs who rely on order of ActorLogic<…> generics

* Same for ActorScope<…>

* Update packages/core/src/actions/emit.ts

Co-authored-by: Mateusz Burzyński <[email protected]>

* Update packages/core/src/actions/emit.ts

Co-authored-by: Mateusz Burzyński <[email protected]>

* Update packages/core/src/State.ts

Co-authored-by: Mateusz Burzyński <[email protected]>

* Update packages/core/src/actions/emit.ts

Co-authored-by: Mateusz Burzyński <[email protected]>

* Update packages/core/src/actions/emit.ts

Co-authored-by: Mateusz Burzyński <[email protected]>

* Update packages/core/src/actions/emit.ts

Co-authored-by: Mateusz Burzyński <[email protected]>

* Update packages/core/test/types.test.ts

Co-authored-by: Mateusz Burzyński <[email protected]>

* Update packages/core/src/createMachine.ts

Co-authored-by: Mateusz Burzyński <[email protected]>

* Update packages/core/src/actions/emit.ts

Co-authored-by: Mateusz Burzyński <[email protected]>

* Fix TS error

* Add emit to enqueueActions

* Add default

* Wrap handler

* Check for errors

* Add changeset

* Types

* small tweaks

* fix types

* tweak things

* fix small issues around listeners management

* rename stuff

* tighten up one default

* remove unused type

* fixed `MachineImplementationsActions`

* No need for defer

* Add test

* rewrite test to make it fail correctly

* defer again

* Add jsdocs

* Update packages/core/src/actions/emit.ts

---------

Co-authored-by: Mateusz Burzyński <[email protected]>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants