Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .changeset/effect-4-0-5.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@typeonce/effect-machine": minor
"@typeonce/effect-machine-react": minor
"@typeonce/effect-machine-devtools": minor
---

Require Effect 4.0.5 or newer. The `effect`, `@effect/atom-react`, and `@effect/platform-*` requirements now accept `^4.0.5`, which includes Effect's fix for repeated rebuilds of derived atoms.

Upgrade `effect` and any installed `@effect/*` packages to matching 4.0.5 or newer versions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,10 @@
},
"devDependencies": {
"@changesets/cli": "2.31.0",
"@effect/vitest": "4.0.0",
"@effect/vitest": "4.0.5",
"@types/node": "25.7.0",
"dprint": "0.55.2",
"effect": "4.0.0",
"effect": "4.0.5",
"pagefind": "1.5.2",
"tinybench": "2.9.0",
"tstyche": "7.2.1",
Expand Down
4 changes: 2 additions & 2 deletions packages/devtools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ The package is experimental and pre-1.0. Minor releases may change its command o
Core and devtools always release at the same version. Install matching versions:

```sh
pnpm add @typeonce/effect-machine@latest effect@4.0.0
pnpm add @typeonce/effect-machine@latest effect@4.0.5
pnpm add --save-dev @typeonce/effect-machine-devtools@latest
```

When pinning a release instead of using `latest`, use the same explicit version for both packages.

The current release requires Node.js 22.19 or newer and stable Effect `^4.0.0`.
The current release requires Node.js 22.19 or newer and stable Effect `^4.0.5`.

## Run the visualizer

Expand Down
10 changes: 5 additions & 5 deletions packages/devtools/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,21 +32,21 @@
"dev": "tsx src/bin.ts"
},
"dependencies": {
"@effect/platform-browser": "^4.0.0",
"@effect/platform-node": "^4.0.0",
"@effect/platform-node-shared": "^4.0.0",
"@effect/platform-browser": "^4.0.5",
"@effect/platform-node": "^4.0.5",
"@effect/platform-node-shared": "^4.0.5",
"@typeonce/effect-machine": "workspace:^",
"chokidar": "4.0.3",
"elkjs": "0.12.0",
"typescript": "6.0.3",
"vite": "8.1.5"
},
"peerDependencies": {
"effect": "^4.0.0"
"effect": "^4.0.5"
},
"devDependencies": {
"@types/node": "25.7.0",
"effect": "4.0.0",
"effect": "4.0.5",
"tsx": "4.21.0"
},
"files": [
Expand Down
3 changes: 2 additions & 1 deletion packages/effect-machine-react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ without an `input` prop.

`useMachineAtom(() => makeMachine(input))` remains available for custom owners.
Use `AtomMachine.select`, `selectSnapshot`, and `matches` for custom granular
subscriptions. Read `.result` for the whole logical snapshot or `.snapshot` for
subscriptions. Derive narrower projections with `Atom.family`, `Atom.mapResult`,
and `Atom.withEquality` rather than hook options. Read `.result` for the whole logical snapshot or `.snapshot` for
runtime status; the bridge has no separate `.state` projection.

See the [React guide](../effect-machine/docs/effect-atom-react.md) for events,
Expand Down
8 changes: 4 additions & 4 deletions packages/effect-machine-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,17 @@
"@typeonce/effect-machine": "workspace:^"
},
"peerDependencies": {
"@effect/atom-react": "^4.0.0",
"effect": "^4.0.0",
"@effect/atom-react": "^4.0.5",
"effect": "^4.0.5",
"react": ">=19.0.0 <20.0.0",
"scheduler": ">=0.25.0 <0.28.0"
},
"devDependencies": {
"@effect/atom-react": "4.0.0",
"@effect/atom-react": "4.0.5",
"@testing-library/react": "16.3.0",
"@types/react": "19.2.16",
"@types/react-dom": "19.2.3",
"effect": "4.0.0",
"effect": "4.0.5",
"jsdom": "27.2.0",
"react": "19.2.7",
"react-dom": "19.2.7",
Expand Down
54 changes: 52 additions & 2 deletions packages/effect-machine-react/test/MachineAtom.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// @vitest-environment jsdom
import { RegistryContext, useAtomSuspense } from "@effect/atom-react"
import { act, cleanup, render, screen, waitFor } from "@testing-library/react"
import { Effect, Option, Schema } from "effect"
import { AtomRegistry } from "effect/reactivity"
import { Effect, Equal, Option, Schema } from "effect"
import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"
import * as React from "react"
import { renderToString } from "react-dom/server"
import { afterEach, assert, describe, it } from "vitest"
Expand Down Expand Up @@ -82,6 +82,56 @@ describe("useMachineAtom", () => {
view.unmount()
registry.dispose()
})
it("rerenders a field projection only when the projected value changes", async () => {
const machine = trackedMachine(() => {})
const registry = AtomRegistry.make({ defaultIdleTTL: 1000 })
const makeOwned = () => AtomMachine.make(machine, 0)
const reachedThreshold = Atom.family((owned: ReturnType<typeof makeOwned>) =>
Atom.mapResult(AtomMachine.select(owned, "Active"), Option.map(({ value }) => value >= 2)).pipe(
Atom.withEquality(Equal.equals)
)
)
let current: ReturnType<typeof makeOwned> | undefined
let readerRenders = 0
function Reader({ owned }: {
readonly owned: ReturnType<typeof makeOwned>
}) {
readerRenders++
const reached = useAtomSuspense(reachedThreshold(owned)).value
return <span data-testid="reached">{String(Option.getOrThrow(reached))}</span>
}
function Owner() {
const owned = useMachineAtom(makeOwned)
current = owned
return (
<React.Suspense fallback="starting">
<Reader owned={owned} />
</React.Suspense>
)
}
const view = render(
<RegistryContext.Provider value={registry}>
<Owner />
</RegistryContext.Provider>
)
await waitFor(() => assert.strictEqual(screen.getByTestId("reached").textContent, "false"))
assert.strictEqual(reachedThreshold(current!), reachedThreshold(current!))
const initialReaderRenders = readerRenders
await act(() => {
registry.set(current!.send, new Increment({}))
})
const active = registry.get(AtomMachine.select(current!, "Active"))
assert.ok(AsyncResult.isSuccess(active))
assert.strictEqual(Option.getOrThrow(active.value).value, 1)
assert.strictEqual(readerRenders, initialReaderRenders)
await act(() => {
registry.set(current!.send, new Increment({}))
})
await waitFor(() => assert.strictEqual(screen.getByTestId("reached").textContent, "true"))
assert.ok(readerRenders > initialReaderRenders)
view.unmount()
registry.dispose()
})
it("owns one committed machine without making startup input reactive", async () => {
let starts = 0
const machine = trackedMachine(() => {
Expand Down
4 changes: 2 additions & 2 deletions packages/effect-machine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ Cluster and are exposed only through explicit integration boundaries.
## Install

```sh
pnpm add @typeonce/effect-machine effect@4.0.0
pnpm add @typeonce/effect-machine effect@4.0.5
```

`effect` is a peer dependency requiring stable `^4.0.0`. Keep any installed
`effect` is a peer dependency requiring stable `^4.0.5`. Keep any installed
`@effect/*` packages on the same version as `effect`.

Development and benchmarks use exact versions. CI checks both the pinned
Expand Down
40 changes: 35 additions & 5 deletions packages/effect-machine/docs/effect-atom-react.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,11 +220,41 @@ function AuthScreen() {
}
```

That component rerenders for every result change. Current
`@effect/atom-react` does not select from the successful value in
`useAtomSuspense`. Until it does, use typed path selectors for state-owned UI,
or declare a custom derived atom once in a strongly owned scope. Do not create
a fresh derived atom on every render.
That component rerenders for every result change. Prefer typed path selectors
for state-owned UI.

Selection and equality belong to atoms, not to React hooks. To subscribe to
part of a selected value, derive an atom from the path selector and give it the
equality that defines a meaningful change. Declare the derivation once with
`Atom.family`, keyed by the machine atom, so every render reuses the same atom:

```tsx
import { useAtomSuspense } from "@effect/atom-react"
import { Equal, Option } from "effect"
import { Atom } from "effect/reactivity"

type AuthMachineAtom = ReturnType<typeof useAuthMachine>

const editingEmail = Atom.family((machine: AuthMachineAtom) =>
Atom.mapResult(
AtomMachine.select(machine, "Editing"),
Option.map(({ email }) => email)
).pipe(Atom.withEquality(Equal.equals))
)

function EmailField() {
const machine = useAuthMachine()
const email = useAtomSuspense(editingEmail(machine)).value

return Option.match(email, {
onNone: () => null,
onSome: (email) => <input value={email} />
})
}
```

The component rerenders only when the projected email changes, not when other
`Editing` fields change. Do not create a fresh derived atom on every render.

## Share a keyed machine outside one React owner

Expand Down
6 changes: 3 additions & 3 deletions packages/effect-machine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,12 @@
"test:types": "tstyche"
},
"peerDependencies": {
"effect": "^4.0.0"
"effect": "^4.0.5"
},
"devDependencies": {
"@effect/vitest": "4.0.0",
"@effect/vitest": "4.0.5",
"@types/node": "25.7.0",
"effect": "4.0.0",
"effect": "4.0.5",
"tinybench": "2.9.0",
"tstyche": "7.2.1",
"typescript": "6.0.3",
Expand Down
Loading
Loading