Skip to content
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
13 changes: 13 additions & 0 deletions .changeset/state-machine-solid2-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@solid-primitives/state-machine": major
---

Migrate to Solid.js v2.0 (beta.13)

## Breaking Changes

**Peer dependencies**: `solid-js@^2.0.0-beta.13` is now required.

- Signal writes are now batched by default; call `flush()` from `solid-js` after calling `state.to.*()` before reading reactive state in tests or synchronous non-reactive code.
- `@solid-primitives/utils` is now a runtime dependency (used for `INTERNAL_OPTIONS` / `ownedWrite` signal option).
- State callbacks called during machine initialization that immediately transition to another state will now require a `flush()` before reading the resulting state.
6 changes: 6 additions & 0 deletions packages/state-machine/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# @solid-primitives/state-machine

## 0.2.0

### Major Changes

- Migrate to Solid.js v2.0 (beta.13)

## 0.1.1

### Patch Changes
Expand Down
16 changes: 13 additions & 3 deletions packages/state-machine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,7 @@ v.value; // "foo"

if (v.type === "idle") {
v.to.loading(1000);

v.type; // "loading"
v.value; // "bar"
// state is now "loading" after the next reactive flush
}
```

Expand All @@ -93,10 +91,22 @@ if (state.type === "idle") {
}
```

> **Note:** Transitions via `state.to.*()` are batched and applied asynchronously (microtask). In reactive contexts such as JSX or effects, the updated state is reflected automatically. In tests or imperative code, call `flush()` from `solid-js` after a transition to read the updated state synchronously.
>
> ```ts
> import { flush } from "solid-js";
>
> state.to.loading(1000);
> flush();
> state.type; // "loading"
> ```

### Lifecycle

`createMachine` is implemented using `createMemo`, which reruns when the state is changed. This means that any reactive computations can be used inside the state callbacks and they will be disposed when the state changes. (owner context will be available in the callbacks)

State callbacks that immediately call `next.*()` (inline transitions to another state) are resolved synchronously within the same reactive computation — no flush is needed to see the final settled state.

```tsx
const state = createMachine({
initial: "counter",
Expand Down
17 changes: 6 additions & 11 deletions packages/state-machine/dev/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { type Component, For, type JSX, onMount } from "solid-js";
import { type Component, createStore, For, type JSX, onSettled } from "solid-js";
import { type MachineStates, createMachine } from "../src/index.js";
import { createStore } from "solid-js/store";

type Todo = {
title: string;
Expand Down Expand Up @@ -60,7 +59,7 @@ const todo_states: MachineStates<{
<input
ref={el => {
input = el;
onMount(() => el.focus());
onSettled(() => el.focus());
}}
type="text"
value={todo.title}
Expand Down Expand Up @@ -95,20 +94,16 @@ const App: Component = () => {
]);

function addTodo(title: string) {
setTodos(todos.length, { title, done: false });
setTodos(t => { t.push({ title, done: false }); });
}
function removeTodo(index: number) {
setTodos(p => {
const copy = p.slice();
copy.splice(index, 1);
return copy;
});
setTodos(t => { t.splice(index, 1); });
}
function toggleTodo(index: number) {
setTodos(index, "done", p => !p);
setTodos(t => { t[index]!.done = !t[index]!.done; });
}
function editTodo(index: number, title: string) {
setTodos(index, "title", title);
setTodos(t => { t[index]!.title = title; });
}

let input!: HTMLInputElement;
Expand Down
7 changes: 5 additions & 2 deletions packages/state-machine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,13 @@
"test": "pnpm run vitest",
"test:ssr": "pnpm run vitest --mode ssr"
},
"dependencies": {
"@solid-primitives/utils": "workspace:^"
},
"peerDependencies": {
"solid-js": "^1.7.0"
"solid-js": "^2.0.0-beta.13"
},
"devDependencies": {
"solid-js": "^1.9.7"
"solid-js": "2.0.0-beta.13"
}
}
33 changes: 28 additions & 5 deletions packages/state-machine/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createMemo, createSignal, type Accessor, untrack } from "solid-js";
import { INTERNAL_OPTIONS } from "@solid-primitives/utils";

/**
* Used for restricting the user type input in {@link createMachine} generic.
Expand Down Expand Up @@ -123,7 +124,7 @@ export type MachineNext<T extends StatesBase<keyof T>, TKey extends keyof T> = {
...args: T[K] extends { input: infer Input } ? [to: K, input: Input] : [to: K, input?: undefined]
) => void);

const EQUALS_OPTIONS = { equals: (a: { type: any }, b: { type: any }) => a.type === b.type };
const STATE_OPTIONS = { ...INTERNAL_OPTIONS, equals: (a: any, b: any) => a.type === b.type };

/**
* Creates a reactive state machine.
Expand Down Expand Up @@ -172,17 +173,39 @@ export function createMachine<T extends StatesBase<keyof T>>(
typeof initial === "object"
? { type: initial.type, value: initial.input as any, to }
: { type: initial as keyof T, value: undefined, to },
EQUALS_OPTIONS,
STATE_OPTIONS,
);

for (const key of Object.keys(states)) {
to[key as any] = (input: any) => to(key, input);
}

const memo = createMemo(() => {
const next = payload();
next.value = untrack(() => states[next.type](next.value, to));
return next;
let type = payload().type as keyof T;
let value: any = payload().value;
let pendingType: keyof T | undefined;
let pendingValue: any;

const next: any = (nextType: keyof T, nextValue?: any) => {
if (nextType !== type) {
pendingType = nextType;
pendingValue = nextValue;
}
};
for (const key of Object.keys(states)) {
next[key as any] = (input: any) => next(key, input);
}

// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (true) {
pendingType = undefined;
value = untrack(() => (states as any)[type](value, next));
if (pendingType === undefined) break; // eslint-disable-line @typescript-eslint/no-unnecessary-condition
type = pendingType;
value = pendingValue;
}

return { type, value, to };
}) as any;

Object.defineProperties(memo, {
Expand Down
11 changes: 10 additions & 1 deletion packages/state-machine/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, test, expect, vi } from "vitest";
import { createMemo, createRoot, onCleanup, untrack } from "solid-js";
import { createMemo, createRoot, flush, onCleanup, untrack } from "solid-js";
import { createMachine } from "../src/index.js";

describe("createMachine", () => {
Expand Down Expand Up @@ -28,6 +28,7 @@ describe("createMachine", () => {

// @ts-expect-error need to check if state is idle
state.to.loading();
flush();

expect(state.type).toBe("loading");
expect(state.value).toBe("bar");
Expand All @@ -40,6 +41,7 @@ describe("createMachine", () => {

state.to.idle();
}
flush();

expect(state.type).toBe("idle");
expect(state.value).toBe("foo");
Expand Down Expand Up @@ -69,6 +71,7 @@ describe("createMachine", () => {
if (state.type === "idle") {
state.to.loading(1);
}
flush();

expect(state.type).toBe("loading");
expect(state.value).toBe(1);
Expand All @@ -77,6 +80,7 @@ describe("createMachine", () => {
state.to.idle("a");
state.to.idle("b");
}
flush();

expect(state.type).toBe("idle");
expect(state.value).toBe("a");
Expand All @@ -99,12 +103,15 @@ describe("createMachine", () => {
},
});

flush();

expect(state.type).toBe("loading");
expect(state.value).toBe("bar");

if (state.type === "loading") {
state.to.idle();
}
flush();

expect(state.type).toBe("loading");
expect(state.value).toBe("bar");
Expand Down Expand Up @@ -140,6 +147,7 @@ describe("createMachine", () => {
if (state.type === "idle") {
state.to.loading();
}
flush();

expect(state.type).toBe("loading");
expect(cleanups.idle).toHaveBeenCalledOnce();
Expand All @@ -148,6 +156,7 @@ describe("createMachine", () => {
if (state.type === "loading") {
state.to.idle();
}
flush();

expect(state.type).toBe("idle");
expect(cleanups.idle).toHaveBeenCalledOnce();
Expand Down
6 changes: 5 additions & 1 deletion packages/state-machine/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
"outDir": "dist",
"rootDir": "src"
},
"references": [],
"references": [
{
"path": "../utils"
}
],
"include": [
"src"
]
Expand Down
12 changes: 8 additions & 4 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.