diff --git a/.changeset/config.json b/.changeset/config.json
index fd15433..71e16db 100644
--- a/.changeset/config.json
+++ b/.changeset/config.json
@@ -7,5 +7,5 @@
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
- "ignore": ["demo", "docs"]
+ "ignore": ["demo", "demo-advanced", "docs"]
}
diff --git a/.changeset/free-jars-fly.md b/.changeset/free-jars-fly.md
new file mode 100644
index 0000000..df24508
--- /dev/null
+++ b/.changeset/free-jars-fly.md
@@ -0,0 +1,6 @@
+---
+"effstate": patch
+"@effstate/react": patch
+---
+
+Require schema and runtime, better api more typesafe
diff --git a/README.md b/README.md
index 92d00c4..d45f922 100644
--- a/README.md
+++ b/README.md
@@ -28,15 +28,15 @@
- **Invocations**: Async operations with automatic result handling
- **Parent-child machines**: Spawn child machines and communicate via events
- **Cross-tab sync**: Built-in support for synchronizing state across browser tabs
-- **Schema validation**: Optional Effect Schema integration for context validation
+- **Schema-first**: Required Effect Schema for context - enables serialization, cross-tab sync, and validation
## Why effstate over XState?
| Metric | effstate | XState |
|--------|----------|--------|
| **Bundle size (gzip)** | **~3.9 kB** | 13.7 kB |
-| Event processing | **30x faster** | - |
-| With subscribers | **14x faster** | - |
+| Event processing | **25x faster** | - |
+| Realistic app lifecycle | **5x faster** | - |
[See full comparison →](https://handfish.github.io/effstate/getting-started/comparison/)
@@ -80,7 +80,7 @@ class Retry extends Data.TaggedClass("RETRY")<{}> {}
type ConnectionEvent = Connect | Disconnect | Retry;
// =============================================================================
-// 2. Define context schema (optional but recommended)
+// 2. Define context schema (required for all machines)
// =============================================================================
const ConnectionContextSchema = Schema.Struct({
diff --git a/apps/demo-advanced/.eslintrc.cjs b/apps/demo-advanced/.eslintrc.cjs
new file mode 100644
index 0000000..de7d613
--- /dev/null
+++ b/apps/demo-advanced/.eslintrc.cjs
@@ -0,0 +1,16 @@
+module.exports = {
+ root: true,
+ env: { browser: true, es2020: true },
+ extends: [
+ 'eslint:recommended',
+ 'plugin:@typescript-eslint/recommended',
+ 'plugin:react-hooks/recommended',
+ ],
+ ignorePatterns: ['dist', '.eslintrc.cjs'],
+ parser: '@typescript-eslint/parser',
+ plugins: ['react-refresh'],
+ rules: {
+ 'react-refresh/only-export-components': 'off',
+ '@typescript-eslint/ban-types': 'off',
+ },
+}
diff --git a/apps/demo-advanced/README.md b/apps/demo-advanced/README.md
new file mode 100644
index 0000000..a9ddc81
--- /dev/null
+++ b/apps/demo-advanced/README.md
@@ -0,0 +1,102 @@
+# Advanced Demo: `interpretManual()`
+
+> ⚠️ **WARNING: This demo shows an advanced pattern that is NOT RECOMMENDED for most applications.**
+
+## What is this?
+
+This demo demonstrates `interpretManual()`, an alternative to `interpret()` that provides slightly faster actor creation at the cost of **manual lifecycle management**.
+
+## Should I use `interpretManual()`?
+
+**Almost certainly not.**
+
+| Question | If Yes | If No |
+|----------|--------|-------|
+| Are you creating thousands of actors per second? | Maybe consider it | Use `interpret()` |
+| Have you profiled and confirmed actor creation is a bottleneck? | Maybe consider it | Use `interpret()` |
+| Are you comfortable managing cleanup manually? | Maybe consider it | Use `interpret()` |
+| Is the 1.6x speedup significant for your use case? | Maybe consider it | Use `interpret()` |
+
+## Performance Comparison
+
+| Metric | `interpret()` | `interpretManual()` |
+|--------|--------------|---------------------|
+| Actor creation speed | Baseline | ~1.6x faster |
+| Cleanup | Automatic (via Scope) | **Manual** (you call `stop()`) |
+| Memory leak risk | None | **High if you forget cleanup** |
+| Code complexity | Simple | Complex |
+| Recommended | ✅ Yes | ❌ No (usually) |
+
+## The Problem with `interpretManual()`
+
+```typescript
+// With interpret() - cleanup is automatic
+const actor = yield* interpret(machine);
+// When Scope closes → finalizer runs → actor.stop() called automatically
+
+// With interpretManual() - YOU must cleanup
+const actor = Effect.runSync(interpretManual(machine));
+// If you forget to call actor.stop(), the actor LEAKS:
+// - Activities keep running forever
+// - Timers keep firing
+// - Memory is never freed
+```
+
+## Required Cleanup Pattern
+
+If you DO use `interpretManual()`, you MUST handle cleanup:
+
+```tsx
+// In React:
+useEffect(() => {
+ const actor = Effect.runSync(interpretManual(machine));
+
+ return () => {
+ actor.stop(); // CRITICAL! Without this, you leak!
+ };
+}, []);
+```
+
+## Why does `interpretManual()` exist?
+
+For rare cases where:
+1. You're creating many short-lived actors
+2. Actor creation overhead is a measured bottleneck
+3. You're managing lifecycle manually anyway
+4. The ~1.6x speedup matters for your use case
+
+## Running This Demo
+
+```bash
+pnpm --filter demo-advanced dev
+```
+
+Watch the lifecycle log to see:
+- When actors are created
+- When cleanup happens (or doesn't!)
+- What gets logged when you stop/restart
+
+## Files in This Demo
+
+- `src/data-access/manual-actor.ts` - The complex lifecycle management code
+- `src/components/ManualLifecycleDemo.tsx` - UI showing the pattern
+- `src/App.tsx` - Entry point with cleanup in useEffect
+
+## Compare to the Main Demo
+
+The main demo (`apps/demo`) uses `interpret()` with Effect-Atom for a much simpler pattern:
+
+```typescript
+// Main demo approach - simple and safe
+const actorAtom = appRuntime
+ .atom(interpret(machine))
+ .pipe(Atom.keepAlive);
+
+// That's it! No manual cleanup needed.
+```
+
+## Conclusion
+
+**Use `interpret()` unless you have a very specific, measured need for `interpretManual()`.**
+
+The complexity and risk of memory leaks almost never justifies the small performance gain.
diff --git a/apps/demo-advanced/index.html b/apps/demo-advanced/index.html
new file mode 100644
index 0000000..19d0708
--- /dev/null
+++ b/apps/demo-advanced/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ effstate - Advanced Demo (interpretManual)
+
+
+
+
+
+
diff --git a/apps/demo-advanced/package.json b/apps/demo-advanced/package.json
new file mode 100644
index 0000000..5dc309f
--- /dev/null
+++ b/apps/demo-advanced/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "demo-advanced",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
+ "preview": "vite preview",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "effect": "^3.19.12",
+ "effstate": "workspace:*",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "clsx": "^2.1.1",
+ "tailwind-merge": "^2.5.4",
+ "tailwindcss-animate": "^1.0.7",
+ "class-variance-authority": "^0.7.0",
+ "@radix-ui/react-slot": "^1.1.0"
+ },
+ "devDependencies": {
+ "@types/node": "^22.9.0",
+ "@types/react": "^18.3.3",
+ "@types/react-dom": "^18.3.0",
+ "@typescript-eslint/eslint-plugin": "^7.15.0",
+ "@typescript-eslint/parser": "^7.15.0",
+ "@vitejs/plugin-react-swc": "^3.5.0",
+ "autoprefixer": "^10.4.20",
+ "eslint": "^8.57.0",
+ "eslint-plugin-react-hooks": "^4.6.2",
+ "eslint-plugin-react-refresh": "^0.4.7",
+ "postcss": "^8.4.49",
+ "tailwindcss": "^3.4.14",
+ "typescript": "^5.2.2",
+ "vite": "^5.3.4"
+ }
+}
diff --git a/apps/demo-advanced/postcss.config.js b/apps/demo-advanced/postcss.config.js
new file mode 100644
index 0000000..2e7af2b
--- /dev/null
+++ b/apps/demo-advanced/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/apps/demo-advanced/src/App.tsx b/apps/demo-advanced/src/App.tsx
new file mode 100644
index 0000000..ebec28b
--- /dev/null
+++ b/apps/demo-advanced/src/App.tsx
@@ -0,0 +1,56 @@
+/**
+ * Advanced Demo: interpretManual() Lifecycle Management
+ *
+ * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+ * !! WARNING: This demo shows an ADVANCED pattern that is NOT RECOMMENDED !!
+ * !! for most applications. Use interpret() instead for automatic cleanup. !!
+ * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+ *
+ * This demo exists to:
+ * 1. Show HOW interpretManual() works
+ * 2. Demonstrate the cleanup complexity required
+ * 3. Explain the (small) performance gains
+ *
+ * Performance gains: ~1.6x faster actor creation
+ * Complexity cost: Manual lifecycle management, risk of memory leaks
+ *
+ * RECOMMENDATION: Use interpret() unless you have measured a performance
+ * bottleneck in actor creation AND you're creating thousands of actors.
+ */
+
+import { useEffect } from "react";
+import { ManualLifecycleDemo } from "./components/ManualLifecycleDemo";
+import {
+ initializeActor,
+ cleanupActor,
+} from "./data-access/manual-actor";
+
+function App() {
+ // CRITICAL: This is the cleanup pattern required for interpretManual()
+ // Without this useEffect, the actor would LEAK when the component unmounts
+ useEffect(() => {
+ initializeActor();
+ return () => {
+ cleanupActor();
+ };
+ }, []);
+
+ return (
+
+ {/* Warning banner */}
+
+
+ ⚠️ ADVANCED DEMO - NOT RECOMMENDED FOR PRODUCTION ⚠️
+
+
+ This demonstrates interpretManual() which requires manual cleanup.
+ Use interpret() instead for automatic lifecycle management.
+
+
+
+
+
+ );
+}
+
+export default App;
diff --git a/apps/demo-advanced/src/components/ManualLifecycleDemo.tsx b/apps/demo-advanced/src/components/ManualLifecycleDemo.tsx
new file mode 100644
index 0000000..cca2ea1
--- /dev/null
+++ b/apps/demo-advanced/src/components/ManualLifecycleDemo.tsx
@@ -0,0 +1,148 @@
+/**
+ * Manual Lifecycle Demo Component
+ *
+ * This demonstrates the complexity required when using interpretManual().
+ * Compare this to the simplicity of using interpret() with atoms!
+ */
+
+import { Button } from "./ui/button";
+import { useManualActor, useLifecycleLogs, clearLogs, type LifecycleLog } from "../data-access/manual-actor";
+import { cn } from "../lib/utils";
+
+const LogPanel = ({ logs }: { logs: LifecycleLog[] }) => (
+
+
+ Lifecycle Log
+
+
+
+ {logs.length === 0 ? (
+
Logs will appear here...
+ ) : (
+ logs.map((log, i) => (
+
+
+ {log.timestamp.toLocaleTimeString()}
+
+ {log.message}
+
+ ))
+ )}
+
+
+);
+
+export const ManualLifecycleDemo = () => {
+ const { count, tickCount, isStopped, increment, decrement, stop, restart } = useManualActor();
+ const logs = useLifecycleLogs();
+
+ return (
+
+
+ interpretManual() Demo
+
+
+ This counter has an activity that ticks every 100ms. Watch what happens when you stop/restart.
+
+
+ {/* Counter Display */}
+
+ {isStopped && (
+
+ ACTOR STOPPED - Activity interrupted!
+
+ )}
+
+
{count}
+
+ Ticks: {tickCount} {!isStopped && (counting...)}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Comparison Box */}
+
+
+
✅ interpret() (Recommended)
+
+ - • Automatic cleanup via Scope
+ - • No risk of memory leaks
+ - • Works great with atoms
+ - • Simpler code
+
+
+
+
⚠️ interpretManual() (This demo)
+
+ - • ~1.6x faster actor creation
+ - • YOU must call actor.stop()
+ - • Risk of memory leaks
+ - • Complex lifecycle code
+
+
+
+
+ {/* Lifecycle Log */}
+
+
+ {/* Code Example */}
+
+
Required Cleanup Pattern:
+
+{`// In your React component:
+useEffect(() => {
+ initializeActor(); // Create with interpretManual()
+ return () => {
+ cleanupActor(); // MUST call actor.stop() here!
+ };
+}, []);
+
+// If you forget cleanupActor(), the actor LEAKS:
+// - Activities keep running
+// - Timers keep firing
+// - Memory never freed`}
+
+
+
+ );
+};
diff --git a/apps/demo-advanced/src/components/ui/button.tsx b/apps/demo-advanced/src/components/ui/button.tsx
new file mode 100644
index 0000000..36496a2
--- /dev/null
+++ b/apps/demo-advanced/src/components/ui/button.tsx
@@ -0,0 +1,56 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-destructive-foreground hover:bg-destructive/90",
+ outline:
+ "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost: "hover:bg-accent hover:text-accent-foreground",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-10 px-4 py-2",
+ sm: "h-9 rounded-md px-3",
+ lg: "h-11 rounded-md px-8",
+ icon: "h-10 w-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button"
+ return (
+
+ )
+ }
+)
+Button.displayName = "Button"
+
+export { Button, buttonVariants }
diff --git a/apps/demo-advanced/src/data-access/manual-actor.ts b/apps/demo-advanced/src/data-access/manual-actor.ts
new file mode 100644
index 0000000..4e66217
--- /dev/null
+++ b/apps/demo-advanced/src/data-access/manual-actor.ts
@@ -0,0 +1,280 @@
+/**
+ * Manual Actor Lifecycle Management
+ *
+ * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+ * !! This file demonstrates interpretManual() - an ADVANCED pattern. !!
+ * !! DO NOT use this pattern unless you have a specific performance need. !!
+ * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+ *
+ * Key differences from interpret():
+ * - No Scope.Scope required
+ * - No automatic finalizer registration
+ * - YOU must call actor.stop() or you WILL leak memory
+ *
+ * Performance benefit: ~1.6x faster actor creation
+ * Complexity cost: All the code in this file that manages lifecycle manually
+ */
+
+import {
+ assign,
+ createMachine,
+ interpretManual,
+ type MachineSnapshot,
+ type MachineActor,
+} from "effstate";
+import { Data, Duration, Effect, Schedule, Schema, Stream } from "effect";
+import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
+
+// ============================================================================
+// Simple Counter Machine (for demonstration)
+// ============================================================================
+
+const CounterContextSchema = Schema.Struct({
+ count: Schema.Number,
+ tickCount: Schema.Number,
+});
+
+class Increment extends Data.TaggedClass("INCREMENT")<{}> {}
+class Decrement extends Data.TaggedClass("DECREMENT")<{}> {}
+class Tick extends Data.TaggedClass("TICK")<{}> {}
+
+type CounterEvent = Increment | Decrement | Tick;
+type CounterState = "idle" | "counting";
+type CounterContext = typeof CounterContextSchema.Type;
+type CounterSnapshot = MachineSnapshot;
+
+const initialSnapshot: CounterSnapshot = {
+ value: "counting",
+ context: { count: 0, tickCount: 0 },
+ event: null,
+};
+
+const counterMachine = createMachine({
+ id: "manual-counter",
+ initial: "counting",
+ context: CounterContextSchema,
+ initialContext: { count: 0, tickCount: 0 },
+ states: {
+ idle: {
+ on: {
+ INCREMENT: { actions: [assign(({ context }) => ({ count: context.count + 1 }))] },
+ DECREMENT: { actions: [assign(({ context }) => ({ count: context.count - 1 }))] },
+ },
+ },
+ counting: {
+ // This activity demonstrates why cleanup matters - it runs forever until stopped
+ activities: [
+ {
+ id: "ticker",
+ src: ({ send }) =>
+ Stream.fromSchedule(Schedule.spaced(Duration.millis(100))).pipe(
+ Stream.runForEach(() => Effect.sync(() => send(new Tick()))),
+ ),
+ },
+ ],
+ on: {
+ INCREMENT: { actions: [assign(({ context }) => ({ count: context.count + 1 }))] },
+ DECREMENT: { actions: [assign(({ context }) => ({ count: context.count - 1 }))] },
+ TICK: { actions: [assign(({ context }) => ({ tickCount: context.tickCount + 1 }))] },
+ },
+ },
+ },
+});
+
+// ============================================================================
+// Lifecycle Logging (to demonstrate what's happening)
+// ============================================================================
+
+export type LifecycleLog = { timestamp: Date; message: string; type: "info" | "warning" | "error" | "success" };
+let lifecycleLogs: LifecycleLog[] = [];
+const lifecycleLogSubscribers: Set<() => void> = new Set();
+
+const addLog = (message: string, type: LifecycleLog["type"] = "info") => {
+ const log = { timestamp: new Date(), message, type };
+ lifecycleLogs = [...lifecycleLogs.slice(-29), log];
+ console.log(`[${type.toUpperCase()}] ${message}`);
+ lifecycleLogSubscribers.forEach((cb) => cb());
+};
+
+export const clearLogs = () => {
+ lifecycleLogs = [];
+ lifecycleLogSubscribers.forEach((cb) => cb());
+};
+
+// ============================================================================
+// Actor Store (External Store Pattern for React)
+// ============================================================================
+
+type ActorStore = {
+ actor: MachineActor | null;
+ snapshot: CounterSnapshot;
+ isStopped: boolean;
+};
+
+let store: ActorStore = {
+ actor: null,
+ snapshot: initialSnapshot,
+ isStopped: true,
+};
+
+const storeSubscribers: Set<() => void> = new Set();
+
+const notifyChange = () => storeSubscribers.forEach((cb) => cb());
+
+// ============================================================================
+// Actor Lifecycle Management
+// ============================================================================
+
+/**
+ * Create a new actor using interpretManual().
+ *
+ * IMPORTANT: This returns an Effect that does NOT require Scope.
+ * The trade-off is that YOU must call actor.stop() when done.
+ */
+const createActor = () => {
+ addLog("Creating actor with interpretManual()...", "info");
+ addLog(" → No Scope.Scope in Effect type", "info");
+ addLog(" → No finalizer registered", "warning");
+ addLog(" → YOU must call actor.stop()!", "warning");
+
+ // interpretManual returns Effect - no Scope!
+ const actor = Effect.runSync(interpretManual(counterMachine));
+
+ addLog(`Actor created! Initial state: ${actor.getSnapshot().value}`, "success");
+ addLog("Activity 'ticker' is now running (check tickCount)", "info");
+
+ // Subscribe to state changes
+ actor.subscribe((snapshot) => {
+ store = { ...store, snapshot };
+ notifyChange();
+ });
+
+ store = {
+ actor,
+ snapshot: actor.getSnapshot(),
+ isStopped: false,
+ };
+
+ notifyChange();
+ return actor;
+};
+
+/**
+ * Stop the actor - THIS IS THE CRITICAL CLEANUP.
+ *
+ * If you forget to call this, the actor keeps running forever:
+ * - Activities continue consuming resources
+ * - Timers keep firing
+ * - Memory is never freed
+ *
+ * With interpret() + Scope, this happens automatically.
+ * With interpretManual(), YOU must do it.
+ */
+export const stopActor = () => {
+ if (!store.actor || store.isStopped) {
+ addLog("No actor to stop", "warning");
+ return;
+ }
+
+ addLog("=== MANUAL CLEANUP ===", "warning");
+ addLog("Calling actor.stop()...", "info");
+ addLog(" → Activities will be interrupted", "info");
+ addLog(" → Timers will be cancelled", "info");
+
+ // THIS IS THE KEY LINE - manual cleanup!
+ store.actor.stop();
+
+ addLog("Actor stopped successfully!", "success");
+ addLog("(With interpret(), this happens automatically via Scope)", "info");
+
+ store = { ...store, isStopped: true, snapshot: initialSnapshot };
+ notifyChange();
+};
+
+/**
+ * Restart the actor (stop + create new).
+ */
+export const restartActor = () => {
+ if (store.actor && !store.isStopped) {
+ addLog("=== RESTART: Stopping old actor first ===", "warning");
+ stopActor();
+ }
+ addLog("=== RESTART: Creating new actor ===", "info");
+ createActor();
+};
+
+/**
+ * Initialize actor - called when component mounts.
+ */
+let initialized = false;
+export const initializeActor = () => {
+ if (!initialized) {
+ initialized = true;
+ addLog("=== COMPONENT MOUNTED ===", "info");
+ addLog("Initializing actor...", "info");
+ createActor();
+ }
+};
+
+/**
+ * Cleanup actor - called when component unmounts.
+ *
+ * THIS IS CRITICAL! Without this, the actor would leak.
+ */
+export const cleanupActor = () => {
+ addLog("=== COMPONENT UNMOUNTING ===", "warning");
+ addLog("Must cleanup to prevent leak!", "warning");
+
+ if (store.actor && !store.isStopped) {
+ store.actor.stop();
+ addLog("Actor stopped - no leak!", "success");
+ }
+
+ initialized = false;
+ store = { actor: null, snapshot: initialSnapshot, isStopped: true };
+};
+
+// ============================================================================
+// React Hooks
+// ============================================================================
+
+export const useManualActor = () => {
+ const state = useSyncExternalStore(
+ (cb) => { storeSubscribers.add(cb); return () => storeSubscribers.delete(cb); },
+ () => store
+ );
+
+ const increment = useCallback(() => {
+ if (state.actor && !state.isStopped) {
+ state.actor.send(new Increment());
+ }
+ }, [state.actor, state.isStopped]);
+
+ const decrement = useCallback(() => {
+ if (state.actor && !state.isStopped) {
+ state.actor.send(new Decrement());
+ }
+ }, [state.actor, state.isStopped]);
+
+ return {
+ count: state.snapshot.context.count,
+ tickCount: state.snapshot.context.tickCount,
+ isStopped: state.isStopped,
+ increment,
+ decrement,
+ stop: stopActor,
+ restart: restartActor,
+ };
+};
+
+export const useLifecycleLogs = () => {
+ const [logs, setLogs] = useState(lifecycleLogs);
+
+ useEffect(() => {
+ const update = () => setLogs([...lifecycleLogs]);
+ lifecycleLogSubscribers.add(update);
+ return () => { lifecycleLogSubscribers.delete(update); };
+ }, []);
+
+ return logs;
+};
diff --git a/apps/demo-advanced/src/index.css b/apps/demo-advanced/src/index.css
new file mode 100644
index 0000000..6d499fe
--- /dev/null
+++ b/apps/demo-advanced/src/index.css
@@ -0,0 +1,65 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+@layer base {
+ :root {
+ --background: 20 14.3% 4.1%;
+ --foreground: 60 9.1% 97.8%;
+ --card: 20 14.3% 4.1%;
+ --card-foreground: 60 9.1% 97.8%;
+ --popover: 20 14.3% 4.1%;
+ --popover-foreground: 60 9.1% 97.8%;
+ --primary: 60 9.1% 97.8%;
+ --primary-foreground: 24 9.8% 10%;
+ --secondary: 12 6.5% 15.1%;
+ --secondary-foreground: 60 9.1% 97.8%;
+ --muted: 12 6.5% 15.1%;
+ --muted-foreground: 24 5.4% 63.9%;
+ --accent: 12 6.5% 15.1%;
+ --accent-foreground: 60 9.1% 97.8%;
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 60 9.1% 97.8%;
+ --border: 12 6.5% 15.1%;
+ --input: 12 6.5% 15.1%;
+ --ring: 24 5.7% 82.9%;
+ --chart-1: 220 70% 50%;
+ --chart-2: 160 60% 45%;
+ --chart-3: 30 80% 55%;
+ --chart-4: 280 65% 60%;
+ --chart-5: 340 75% 55%;
+ }
+ .dark {
+ --background: 20 14.3% 4.1%;
+ --foreground: 60 9.1% 97.8%;
+ --card: 20 14.3% 4.1%;
+ --card-foreground: 60 9.1% 97.8%;
+ --popover: 20 14.3% 4.1%;
+ --popover-foreground: 60 9.1% 97.8%;
+ --primary: 60 9.1% 97.8%;
+ --primary-foreground: 24 9.8% 10%;
+ --secondary: 12 6.5% 15.1%;
+ --secondary-foreground: 60 9.1% 97.8%;
+ --muted: 12 6.5% 15.1%;
+ --muted-foreground: 24 5.4% 63.9%;
+ --accent: 12 6.5% 15.1%;
+ --accent-foreground: 60 9.1% 97.8%;
+ --destructive: 0 62.8% 30.6%;
+ --destructive-foreground: 60 9.1% 97.8%;
+ --border: 12 6.5% 15.1%;
+ --input: 12 6.5% 15.1%;
+ --ring: 24 5.7% 82.9%;
+ --chart-1: 220 70% 50%;
+ --chart-2: 160 60% 45%;
+ --chart-3: 30 80% 55%;
+ --chart-4: 280 65% 60%;
+ --chart-5: 340 75% 55%;
+ }
+}
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
diff --git a/apps/demo-advanced/src/lib/utils.ts b/apps/demo-advanced/src/lib/utils.ts
new file mode 100644
index 0000000..37295e1
--- /dev/null
+++ b/apps/demo-advanced/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: Parameters) {
+ return twMerge(clsx(inputs));
+}
diff --git a/apps/demo-advanced/src/main.tsx b/apps/demo-advanced/src/main.tsx
new file mode 100644
index 0000000..9b67590
--- /dev/null
+++ b/apps/demo-advanced/src/main.tsx
@@ -0,0 +1,10 @@
+import React from "react";
+import ReactDOM from "react-dom/client";
+import App from "./App";
+import "./index.css";
+
+ReactDOM.createRoot(document.getElementById("root")!).render(
+
+
+
+);
diff --git a/apps/demo-advanced/tailwind.config.js b/apps/demo-advanced/tailwind.config.js
new file mode 100644
index 0000000..2487604
--- /dev/null
+++ b/apps/demo-advanced/tailwind.config.js
@@ -0,0 +1,57 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ darkMode: ["class"],
+ content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"],
+ theme: {
+ extend: {
+ borderRadius: {
+ lg: "var(--radius)",
+ md: "calc(var(--radius) - 2px)",
+ sm: "calc(var(--radius) - 4px)",
+ },
+ colors: {
+ background: "hsl(var(--background))",
+ foreground: "hsl(var(--foreground))",
+ card: {
+ DEFAULT: "hsl(var(--card))",
+ foreground: "hsl(var(--card-foreground))",
+ },
+ popover: {
+ DEFAULT: "hsl(var(--popover))",
+ foreground: "hsl(var(--popover-foreground))",
+ },
+ primary: {
+ DEFAULT: "hsl(var(--primary))",
+ foreground: "hsl(var(--primary-foreground))",
+ },
+ secondary: {
+ DEFAULT: "hsl(var(--secondary))",
+ foreground: "hsl(var(--secondary-foreground))",
+ },
+ muted: {
+ DEFAULT: "hsl(var(--muted))",
+ foreground: "hsl(var(--muted-foreground))",
+ },
+ accent: {
+ DEFAULT: "hsl(var(--accent))",
+ foreground: "hsl(var(--accent-foreground))",
+ },
+ destructive: {
+ DEFAULT: "hsl(var(--destructive))",
+ foreground: "hsl(var(--destructive-foreground))",
+ },
+ border: "hsl(var(--border))",
+ input: "hsl(var(--input))",
+ ring: "hsl(var(--ring))",
+ chart: {
+ 1: "hsl(var(--chart-1))",
+ 2: "hsl(var(--chart-2))",
+ 3: "hsl(var(--chart-3))",
+ 4: "hsl(var(--chart-4))",
+ 5: "hsl(var(--chart-5))",
+ },
+ },
+ },
+ },
+ plugins: [require("tailwindcss-animate")],
+};
diff --git a/apps/demo-advanced/tsconfig.json b/apps/demo-advanced/tsconfig.json
new file mode 100644
index 0000000..4145e33
--- /dev/null
+++ b/apps/demo-advanced/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "exactOptionalPropertyTypes": true,
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "baseUrl": "./",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/apps/demo-advanced/vite.config.ts b/apps/demo-advanced/vite.config.ts
new file mode 100644
index 0000000..112c417
--- /dev/null
+++ b/apps/demo-advanced/vite.config.ts
@@ -0,0 +1,12 @@
+import path from "path";
+import react from "@vitejs/plugin-react-swc";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
+});
diff --git a/apps/demo/src/app.tsx b/apps/demo/src/app.tsx
index c58d134..e680efa 100644
--- a/apps/demo/src/app.tsx
+++ b/apps/demo/src/app.tsx
@@ -1,9 +1,7 @@
import { HamsterWheel } from "@/components/hamster-wheel/hamster-wheel";
function App() {
- return (
-
- );
+ return ;
}
export default App;
diff --git a/apps/docs/src/content/docs/getting-started/comparison.mdx b/apps/docs/src/content/docs/getting-started/comparison.mdx
index 1afe0d0..4a172af 100644
--- a/apps/docs/src/content/docs/getting-started/comparison.mdx
+++ b/apps/docs/src/content/docs/getting-started/comparison.mdx
@@ -28,10 +28,10 @@ Creating a new state machine definition.
| Library | ops/sec | Mean (μs) |
|---------|---------|-----------|
-| **effstate** | **14,613,480** | **0.068** |
-| XState | 292,807 | 3.415 |
+| **effstate** | **13,723,258** | **0.073** |
+| XState | 268,803 | 3.720 |
-effstate is **~50x faster** at machine creation.
+effstate is **~51x faster** at machine creation.
### Actor Lifecycle
@@ -39,10 +39,10 @@ Creating, starting, and stopping an actor.
| Library | ops/sec | Mean (μs) |
|---------|---------|-----------|
-| effstate | 221,334 | 4.518 |
-| **XState** | **343,190** | **2.914** |
+| effstate | 57,627 | 17.353 |
+| **XState** | **307,461** | **3.252** |
-XState is ~1.5x faster at actor lifecycle (their createActor is more optimized).
+XState is ~5x faster at actor lifecycle. This is the cost of effstate's Effect-first architecture - actor creation captures the runtime for dependency injection. Use `interpretManual()` if you need maximum performance and manage cleanup yourself.
### Event Sending (1000 events)
@@ -50,10 +50,10 @@ Sending 1000 events to a running actor.
| Library | ops/sec | Mean (μs) |
|---------|---------|-----------|
-| **effstate** | **18,143** | **55.1** |
-| XState | 611 | 1636.6 |
+| **effstate** | **15,166** | **65.9** |
+| XState | 610 | 1640.7 |
-effstate is **~30x faster** at event processing.
+effstate is **~25x faster** at event processing.
### With Subscribers (5 subscribers, 100 events)
@@ -61,31 +61,31 @@ Processing events with multiple subscribers attached.
| Library | ops/sec | Mean (μs) |
|---------|---------|-----------|
-| **effstate** | **80,975** | **12.4** |
-| XState | 5,877 | 170.2 |
+| **effstate** | **44,536** | **22.5** |
+| XState | 5,728 | 174.6 |
-effstate is **~14x faster** with subscribers.
+effstate is **~8x faster** with subscribers.
-### Full Lifecycle
+### Realistic App Lifecycle
-Complete workflow: create actor → send events → get snapshot → stop.
+Complete workflow simulating real app usage: create actor → subscribe (like React component) → 50 user interactions → unsubscribe → stop.
| Library | ops/sec | Mean (μs) |
|---------|---------|-----------|
-| **effstate** | **204,283** | **4.9** |
-| XState | 113,129 | 8.8 |
+| **effstate** | **54,743** | **18.3** |
+| XState | 11,323 | 88.3 |
-effstate is **~1.8x faster** for full lifecycle operations.
+effstate is **~5x faster** for realistic app lifecycles.
## Summary
| Benchmark | Winner | Factor |
|-----------|--------|--------|
-| Machine Creation | effstate | 50x faster |
-| Actor Lifecycle | XState | 1.5x faster |
-| Event Sending | effstate | 30x faster |
-| With Subscribers | effstate | 14x faster |
-| Full Lifecycle | effstate | 1.8x faster |
+| Machine Creation | effstate | 51x faster |
+| Actor Lifecycle | XState | 5x faster |
+| Event Sending | effstate | 25x faster |
+| With Subscribers | effstate | 8x faster |
+| Realistic Lifecycle | effstate | 5x faster |
**Final Score: effstate 4 - 1 XState**
@@ -97,11 +97,18 @@ effstate is **~1.8x faster** for full lifecycle operations.
- Full Observable protocol (next/error/complete)
**effstate is optimized for:**
-- Minimal runtime overhead
+- Minimal runtime overhead for event processing
- Simple callbacks with error isolation
- Effect ecosystem integration
- Hierarchical parent/child actor communication
+### Performance Tips
+
+For maximum performance:
+- Use `interpretManual()` instead of `interpret()` when you manage actor lifecycle manually
+- Pre-create event instances instead of creating new ones each time
+- effstate's event processing is 25x faster, so focus on minimizing actor creation if needed
+
## Running the Benchmarks
You can run the benchmarks yourself:
diff --git a/apps/docs/src/content/docs/getting-started/introduction.mdx b/apps/docs/src/content/docs/getting-started/introduction.mdx
index fb65efb..ea5ab8a 100644
--- a/apps/docs/src/content/docs/getting-started/introduction.mdx
+++ b/apps/docs/src/content/docs/getting-started/introduction.mdx
@@ -95,8 +95,8 @@ export class ConnectionMachine extends Effect.Service()(
### Effect-First Architecture
Built on Effect for composable, type-safe side effects with proper error handling.
-### Schema-Based Context
-Use Effect Schema for automatic serialization/deserialization - perfect for cross-tab sync or persistence.
+### Schema-First Context
+Effect Schema is required for context - enabling automatic serialization, cross-tab sync, and validation out of the box.
### Actor Model
Parent-child machine composition with proper lifecycle management. Spawn, communicate with, and stop child machines.
diff --git a/apps/docs/src/content/docs/index.mdx b/apps/docs/src/content/docs/index.mdx
index 8234a05..4d3ee4c 100644
--- a/apps/docs/src/content/docs/index.mdx
+++ b/apps/docs/src/content/docs/index.mdx
@@ -29,15 +29,15 @@ import { Card, CardGrid, LinkCard } from '@astrojs/starlight/components';
Built on Effect for composable, type-safe side effects and resource management.
+
+ Required Effect Schema for context enables serialization, cross-tab sync, and validation.
+
Full TypeScript support with compile-time guarantees for events, states, and context.
Parent-child machine composition with proper lifecycle management.
-
- First-class React integration via @effstate/react with hooks and atoms.
-
## Why effstate?
@@ -49,8 +49,8 @@ import { Card, CardGrid, LinkCard } from '@astrojs/starlight/components';
href="./getting-started/comparison/"
/>
diff --git a/packages/core/bench/machine.bench.ts b/packages/core/bench/machine.bench.ts
index 9a294ed..357e9c5 100644
--- a/packages/core/bench/machine.bench.ts
+++ b/packages/core/bench/machine.bench.ts
@@ -1,8 +1,10 @@
import { Bench, type Task } from "tinybench";
-import { Data, Schema } from "effect";
+import { Data, Effect, Schema, Scope } from "effect";
// Our Effect-first state machine
-import { createMachine, interpretSync, assign } from "../src/index.js";
+import { createMachine, interpret, assign } from "../src/index.js";
+import type { MachineActor } from "../src/machine.js";
+import type { MachineContext, MachineDefinition, MachineEvent } from "../src/types.js";
// XState
import {
@@ -11,6 +13,33 @@ import {
assign as xstateAssign,
} from "xstate";
+// ============================================================================
+// Benchmark Runtime Setup
+// ============================================================================
+
+// Shared scope for benchmarks - actors are manually stopped so we reuse one scope
+const benchScope = Effect.runSync(Scope.make());
+
+/**
+ * Benchmark actor creation using the standard interpret() API.
+ * This is what we recommend in docs/demos - honest benchmarking.
+ */
+function benchActor<
+ TId extends string,
+ TStateValue extends string,
+ TContext extends MachineContext,
+ TEvent extends MachineEvent,
+ R,
+ E,
+ TContextEncoded,
+>(
+ machine: MachineDefinition,
+): MachineActor {
+ return Effect.runSync(
+ interpret(machine).pipe(Effect.provideService(Scope.Scope, benchScope))
+ );
+}
+
// ============================================================================
// Define equivalent machines in both libraries
// ============================================================================
@@ -158,7 +187,7 @@ function verifyImplementations() {
// Test 1: Context updates work
{
- const effectActor = interpretSync(effectMachine);
+ const effectActor = benchActor(effectMachine);
effectActor.send(incrementEvent);
effectActor.send(incrementEvent);
effectActor.send(decrementEvent);
@@ -181,7 +210,7 @@ function verifyImplementations() {
let effectCalls = 0;
let xstateCalls = 0;
- const effectActor = interpretSync(effectMachine);
+ const effectActor = benchActor(effectMachine);
effectActor.subscribe(() => effectCalls++);
effectActor.send(incrementEvent);
effectActor.send(incrementEvent);
@@ -200,7 +229,7 @@ function verifyImplementations() {
// Test 3: State transitions work
{
- const effectActor = interpretSync(effectMachine);
+ const effectActor = benchActor(effectMachine);
const effectState1 = effectActor.getSnapshot().value;
effectActor.send(incrementEvent);
const effectState2 = effectActor.getSnapshot().value;
@@ -295,7 +324,7 @@ async function main() {
const lifecycleBench = new Bench({ time: 200, warmupTime: 50 });
lifecycleBench.add("Effect: interpret + stop", () => {
- const actor = interpretSync(effectMachine);
+ const actor = benchActor(effectMachine);
actor.stop();
});
@@ -329,7 +358,7 @@ async function main() {
const eventBench = new Bench({ time: 200, warmupTime: 50 });
eventBench.add("Effect: send 1000 events", () => {
- const actor = interpretSync(effectMachine);
+ const actor = benchActor(effectMachine);
for (let i = 0; i < 500; i++) {
actor.send(incrementEvent);
actor.send(decrementEvent);
@@ -371,7 +400,7 @@ async function main() {
const subscriberBench = new Bench({ time: 200, warmupTime: 50 });
subscriberBench.add("Effect: with 5 subscribers", () => {
- const actor = interpretSync(effectMachine);
+ const actor = benchActor(effectMachine);
const unsubs: Array<() => void> = [];
for (let i = 0; i < 5; i++) {
unsubs.push(actor.subscribe(() => {}));
@@ -416,28 +445,55 @@ async function main() {
);
// -------------------------------------------------------------------------
- // Benchmark Group 5: Full Realistic Lifecycle
+ // Benchmark Group 5: Realistic App Lifecycle
// -------------------------------------------------------------------------
- console.log("\n\n🔄 FULL LIFECYCLE (create → events → snapshot → stop)\n");
+ console.log("\n\n🔄 REALISTIC APP LIFECYCLE\n");
+ console.log(" Simulates: create → subscribe → 50 user interactions → unsubscribe → stop\n");
const fullBench = new Bench({ time: 200, warmupTime: 50 });
- fullBench.add("Effect: full lifecycle", () => {
- const actor = interpretSync(effectMachine);
- actor.send(incrementEvent);
- actor.send(incrementEvent);
- actor.send(decrementEvent);
- actor.getSnapshot();
+ fullBench.add("Effect: realistic lifecycle", () => {
+ // Create actor (like app init)
+ const actor = benchActor(effectMachine);
+
+ // Subscribe (like React component mounting)
+ let lastSnapshot = actor.getSnapshot();
+ const unsub = actor.subscribe((s) => { lastSnapshot = s; });
+
+ // User interactions over time (50 events)
+ for (let i = 0; i < 25; i++) {
+ actor.send(incrementEvent);
+ actor.send(decrementEvent);
+ }
+
+ // Check state (like re-render)
+ void lastSnapshot.context.count;
+
+ // Cleanup (like component unmounting)
+ unsub();
actor.stop();
});
- fullBench.add("XState: full lifecycle", () => {
+ fullBench.add("XState: realistic lifecycle", () => {
+ // Create actor (like app init)
const actor = createActor(xstateMachine);
actor.start();
- actor.send({ type: "INCREMENT" });
- actor.send({ type: "INCREMENT" });
- actor.send({ type: "DECREMENT" });
- actor.getSnapshot();
+
+ // Subscribe (like React component mounting)
+ let lastSnapshot = actor.getSnapshot();
+ const sub = actor.subscribe((s) => { lastSnapshot = s; });
+
+ // User interactions over time (50 events)
+ for (let i = 0; i < 25; i++) {
+ actor.send({ type: "INCREMENT" });
+ actor.send({ type: "DECREMENT" });
+ }
+
+ // Check state (like re-render)
+ void lastSnapshot.context.count;
+
+ // Cleanup (like component unmounting)
+ sub.unsubscribe();
actor.stop();
});
@@ -452,9 +508,9 @@ async function main() {
);
printComparison(
- "full lifecycle",
- fullBench.getTask("Effect: full lifecycle"),
- fullBench.getTask("XState: full lifecycle"),
+ "realistic lifecycle",
+ fullBench.getTask("Effect: realistic lifecycle"),
+ fullBench.getTask("XState: realistic lifecycle"),
);
// -------------------------------------------------------------------------
@@ -486,9 +542,9 @@ async function main() {
xstate: subscriberBench.getTask("XState: with 5 subscribers"),
},
{
- label: "full lifecycle",
- effect: fullBench.getTask("Effect: full lifecycle"),
- xstate: fullBench.getTask("XState: full lifecycle"),
+ label: "realistic lifecycle",
+ effect: fullBench.getTask("Effect: realistic lifecycle"),
+ xstate: fullBench.getTask("XState: realistic lifecycle"),
},
];
diff --git a/packages/core/package.json b/packages/core/package.json
index 29ad26e..9b4a89e 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "effstate",
- "version": "0.0.2",
+ "version": "0.0.3",
"description": "Effect-first state machine library",
"type": "module",
"main": "./dist/index.cjs",
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 975c7a5..735766d 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -52,7 +52,7 @@ export {
} from "./types.js";
// Machine creation
-export { createMachine, interpret, interpretSync, withRequirements, type MachineActor } from "./machine.js";
+export { createMachine, interpret, interpretManual, withRequirements, type MachineActor } from "./machine.js";
// Actions
export { assign, assignOnDefect, assignOnFailure, assignOnSuccess, cancel, effect, emit, enqueueActions, forwardTo, invoke, log, raise, sendParent, sendTo, spawnChild, stopChild } from "./actions.js";
diff --git a/packages/core/src/machine.ts b/packages/core/src/machine.ts
index 617d10a..a82c6a5 100644
--- a/packages/core/src/machine.ts
+++ b/packages/core/src/machine.ts
@@ -64,18 +64,15 @@ import {
* Type parameters:
* - TStateValue: The state literal union (e.g., "idle" | "loading" | "done")
* - TEvent: The event union type
- * - TContextSchema: Use `typeof YourContextSchema`
+ * - TContextSchema: The Schema type for context (use `typeof YourSchema`)
*
* @example
* ```ts
- * const machine = createMachine<
- * "idle" | "loading" | "done",
- * MyEvent,
- * typeof MyContextSchema
- * >({
+ * const machine = createMachine({
* id: "myMachine",
* initial: "idle",
* context: MyContextSchema,
+ * initialContext: { count: 0 },
* states: { idle: {}, loading: {}, done: {} },
* });
* ```
@@ -99,29 +96,26 @@ export function createMachine<
TEvent,
R,
E,
- import("effect").Schema.Schema.Encoded
+ import("effect").Schema.Schema.Encoded,
+ import("effect").Schema.Schema.Context
> {
- // Cast necessary: TypeScript can't unify TContextSchema (Schema)
- // with Schema, Encoded, never> due to the third type parameter (Context/R).
- // Fixing this would require exposing Schema's R parameter throughout the API.
- type Def = MachineDefinition<
- string, TStateValue, import("effect").Schema.Schema.Type,
- TEvent, R, E, import("effect").Schema.Schema.Encoded
- >;
-
- const definition = {
+ // Type aliases for cleaner code
+ type TContext = import("effect").Schema.Schema.Type;
+ type TContextEncoded = import("effect").Schema.Schema.Encoded;
+ type TSchemaR = import("effect").Schema.Schema.Context;
+ type Def = MachineDefinition;
+
+ return {
_tag: "MachineDefinition" as const,
id: config.id,
- config: config as unknown as Def["config"],
+ config: config as Def["config"],
initialSnapshot: {
value: config.initial,
context: config.initialContext,
event: null,
},
- contextSchema: config.context as unknown as Def["contextSchema"],
+ contextSchema: config.context as Def["contextSchema"],
};
-
- return definition;
}
/**
@@ -155,11 +149,12 @@ export function withRequirements() {
_R,
E,
TContextEncoded,
+ TSchemaR,
>(
- machine: MachineDefinition,
- ): MachineDefinition => {
+ machine: MachineDefinition,
+ ): MachineDefinition => {
// Type-only operation - the machine is returned unchanged at runtime
- return machine as unknown as MachineDefinition;
+ return machine as unknown as MachineDefinition;
};
}
@@ -272,7 +267,7 @@ export interface MachineActor<
// ============================================================================
/**
- * Internal actor creation - used by both interpret and interpretSync
+ * Internal actor creation - used by interpret()
*/
function createActor<
TId extends string,
@@ -284,17 +279,24 @@ function createActor<
TContextEncoded,
>(
machine: MachineDefinition,
- options?: {
+ options: {
parent?: MachineActor;
- runtime?: Runtime.Runtime;
+ runtime: Runtime.Runtime;
/** Initial snapshot to restore from (for persistence) */
snapshot?: MachineSnapshot;
/** Child snapshots to restore (keyed by child ID) */
childSnapshots?: ReadonlyMap>;
},
): MachineActor {
- const runtime = options?.runtime;
- const childSnapshots = options?.childSnapshots;
+ const { runtime, childSnapshots } = options;
+
+ // Helper to run an Effect with the captured runtime
+ const runForkEffect = (eff: Effect.Effect): Fiber.RuntimeFiber =>
+ Runtime.runFork(runtime)(eff);
+
+ const runPromiseExitEffect = (eff: Effect.Effect): Promise> =>
+ Runtime.runPromiseExit(runtime)(eff);
+
// Mutable state - use provided snapshot or initial
let snapshot: MachineSnapshot = options?.snapshot ?? machine.initialSnapshot;
let stopped = false;
@@ -890,26 +892,23 @@ function createActor<
const runActionsSync = (
actions: ReadonlyArray>,
context: TContext,
- event: TEvent,
+ event: ProcessableEvent,
): TContext => {
let ctx = context;
+ // Cast once: actions are typed with TEvent but we accept ProcessableEvent (includes internal events)
+ const userEvent = event as TEvent;
for (const action of actions) {
switch (action._tag) {
case "assign": {
- const updates = action.fn({ context: ctx, event });
+ const updates = action.fn({ context: ctx, event: userEvent });
ctx = { ...ctx, ...updates };
break;
}
case "effect": {
// Defer effect - run async with Exit-based error handling
- const eff = action.fn({ context: ctx, event });
+ const eff = action.fn({ context: ctx, event: userEvent });
deferredEffects.push(() => {
- // Use runtime if available (from interpret), otherwise run directly (interpretSync)
- const runEffect = runtime
- ? Runtime.runPromiseExit(runtime)(eff as Effect.Effect)
- : Effect.runPromiseExit(eff as Effect.Effect);
-
- runEffect.then((exit) => {
+ runPromiseExitEffect(eff).then((exit) => {
Exit.match(exit, {
onFailure: (cause) => {
emitError(new EffectActionError({
@@ -925,21 +924,21 @@ function createActor<
}
case "raise": {
const raisedEvent = typeof action.event === "function"
- ? action.event({ context: ctx, event })
+ ? action.event({ context: ctx, event: userEvent })
: action.event;
mailbox.enqueue(raisedEvent as TEvent);
break;
}
case "cancel": {
const id = typeof action.sendId === "function"
- ? action.sendId({ context: ctx, event })
+ ? action.sendId({ context: ctx, event: userEvent })
: action.sendId;
cancelDelay(id);
break;
}
case "emit": {
const emitted = typeof action.event === "function"
- ? action.event({ context: ctx, event })
+ ? action.event({ context: ctx, event: userEvent })
: action.event;
emitEvent(emitted);
break;
@@ -947,13 +946,13 @@ function createActor<
case "enqueueActions": {
const queue: Array> = [];
const enqueue = createActionEnqueuer(queue);
- action.collect({ context: ctx, event, enqueue });
+ action.collect({ context: ctx, event: userEvent, enqueue });
ctx = runActionsSync(queue, ctx, event);
break;
}
case "spawnChild": {
const childId = typeof action.id === "function"
- ? action.id({ context: ctx, event })
+ ? action.id({ context: ctx, event: userEvent })
: action.id;
// Only spawn if child doesn't already exist (idempotent)
if (!childrenRef.has(childId)) {
@@ -962,15 +961,15 @@ function createActor<
const childMachine = action.src as unknown as MachineDefinition;
// Check if we have a saved snapshot for this child
const childSnapshot = childSnapshots?.get(childId);
- // Build options conditionally to satisfy exactOptionalPropertyTypes
+ // Build options - runtime is always available, snapshot is conditional
const childOptions: {
parent: MachineActor;
- runtime?: Runtime.Runtime;
+ runtime: Runtime.Runtime;
snapshot?: MachineSnapshot;
} = {
parent: actor as unknown as MachineActor,
+ runtime: runtime as Runtime.Runtime,
};
- if (runtime) childOptions.runtime = runtime as Runtime.Runtime;
if (childSnapshot) childOptions.snapshot = childSnapshot;
const childActor = createActor(childMachine, childOptions);
childrenRef.set(childId, childActor);
@@ -979,7 +978,7 @@ function createActor<
}
case "stopChild": {
const childId = typeof action.childId === "function"
- ? action.childId({ context: ctx, event })
+ ? action.childId({ context: ctx, event: userEvent })
: action.childId;
const child = childrenRef.get(childId);
if (child) {
@@ -990,26 +989,26 @@ function createActor<
}
case "sendTo": {
const targetId = typeof action.target === "function"
- ? action.target({ context: ctx, event })
+ ? action.target({ context: ctx, event: userEvent })
: action.target;
const targetEvent = typeof action.event === "function"
- ? action.event({ context: ctx, event })
+ ? action.event({ context: ctx, event: userEvent })
: action.event;
sendToChild(targetId, targetEvent);
break;
}
case "sendParent": {
const parentEvent = typeof action.event === "function"
- ? action.event({ context: ctx, event })
+ ? action.event({ context: ctx, event: userEvent })
: action.event;
sendToParent(parentEvent);
break;
}
case "forwardTo": {
const targetId = typeof action.target === "function"
- ? action.target({ context: ctx, event })
+ ? action.target({ context: ctx, event: userEvent })
: action.target;
- sendToChild(targetId, event);
+ sendToChild(targetId, userEvent);
break;
}
}
@@ -1037,8 +1036,10 @@ function createActor<
readonly src: (params: { context: TContext; event: TEvent; send: (event: TEvent) => void }) => Effect.Effect;
}>,
context: TContext,
- event: TEvent,
+ event: ProcessableEvent,
) => {
+ // Cast once: activity callbacks are typed with TEvent but we accept ProcessableEvent
+ const userEvent = event as TEvent;
for (const activity of activities) {
const send = (e: TEvent) => {
if (!stopped) mailbox.enqueue(e);
@@ -1046,7 +1047,7 @@ function createActor<
// Fork the activity and store the fiber for interruption
const activityId = activity.id;
- const activityEffect = activity.src({ context, event, send }).pipe(
+ const activityEffect = activity.src({ context, event: userEvent, send }).pipe(
// catchAllCause handles both typed errors and defects
Effect.catchAllCause((cause) => {
emitError(new ActivityError({
@@ -1058,10 +1059,7 @@ function createActor<
}),
);
- // Use runtime if available, otherwise run directly
- const fiber = runtime
- ? Runtime.runFork(runtime)(activityEffect as Effect.Effect)
- : Effect.runFork(activityEffect as Effect.Effect);
+ const fiber = runForkEffect(activityEffect as Effect.Effect);
activityCleanups.set(activity.id, () => {
Effect.runFork(Fiber.interrupt(fiber));
@@ -1072,11 +1070,13 @@ function createActor<
const startInvoke = (
invoke: InvokeConfigInternal,
context: TContext,
- event: TEvent,
+ event: ProcessableEvent,
) => {
+ // Cast once: invoke callbacks are typed with TEvent but we accept ProcessableEvent
+ const userEvent = event as TEvent;
const invokeId = invoke.id ?? `invoke-${Date.now()}`;
- const invokeEffect = invoke.src({ context, event }).pipe(
+ const invokeEffect = invoke.src({ context, event: userEvent }).pipe(
Effect.matchCauseEffect({
onSuccess: (output) => {
if (!stopped) {
@@ -1133,10 +1133,7 @@ function createActor<
}),
);
- // Fork the invoke effect
- const fiber = runtime
- ? Runtime.runFork(runtime)(invokeEffect as Effect.Effect)
- : Effect.runFork(invokeEffect as Effect.Effect);
+ const fiber = runForkEffect(invokeEffect as Effect.Effect);
invokeCleanups.set(invokeId, () => {
Effect.runFork(Fiber.interrupt(fiber));
@@ -1173,9 +1170,7 @@ function createActor<
),
);
- const fiber = runtime
- ? Runtime.runFork(runtime)(delayEffect as Effect.Effect)
- : Effect.runFork(delayEffect);
+ const fiber = runForkEffect(delayEffect as Effect.Effect);
cleanupMap.set(cleanupId, () => {
Effect.runFork(Fiber.interrupt(fiber));
@@ -1207,10 +1202,7 @@ function createActor<
),
);
- // Cast needed for else branch: user is warned via console.warn if using Effect delays without runtime
- const fiber = runtime
- ? Runtime.runFork(runtime)(delayEffect as Effect.Effect)
- : Effect.runFork(delayEffect as Effect.Effect);
+ const fiber = runForkEffect(delayEffect);
cleanupMap.set(cleanupId, () => {
Effect.runFork(Fiber.interrupt(fiber));
@@ -1225,13 +1217,6 @@ function createActor<
// Check if delay is an Effect or Duration
if (Effect.isEffect(after.delay)) {
- if (!runtime) {
- console.warn(
- "[effstate] Effect-based delays require interpret() with a runtime. " +
- "Using interpretSync() with Effect delays that require services will fail. " +
- "Consider using Duration-based delays or switch to interpret()."
- );
- }
scheduleDelayEffect(
after.delay as Effect.Effect,
"$effect",
@@ -1310,7 +1295,7 @@ function createActor<
const resumeActivities = () => {
const currentState = machine.config.states[snapshot.value];
if (currentState?.activities) {
- startActivities(currentState.activities, snapshot.context, { _tag: "$resume" } as TEvent);
+ startActivities(currentState.activities, snapshot.context, { _tag: "$resume" });
}
if (currentState?.after) {
scheduleAfterTransition(currentState.after);
@@ -1353,7 +1338,7 @@ function createActor<
if (stateChanged) {
const currentState = machine.config.states[snapshot.value];
if (currentState?.activities) {
- startActivities(currentState.activities, snapshot.context, { _tag: "$sync" } as TEvent);
+ startActivities(currentState.activities, snapshot.context, { _tag: "$sync" });
}
if (currentState?.after) {
scheduleAfterTransition(currentState.after);
@@ -1398,7 +1383,7 @@ function createActor<
if (restoringToNonInitialState && initialState?.entry) {
const spawnActions = initialState.entry.filter((a) => a._tag === "spawnChild");
if (spawnActions.length > 0) {
- runActionsSync(spawnActions, snapshot.context, { _tag: "$init" } as TEvent);
+ runActionsSync(spawnActions, snapshot.context, { _tag: "$init" });
}
}
@@ -1415,19 +1400,19 @@ function createActor<
if (actions.length > 0) {
snapshot = {
...snapshot,
- context: runActionsSync(actions, snapshot.context, { _tag: "$init" } as TEvent),
+ context: runActionsSync(actions, snapshot.context, { _tag: "$init" }),
};
}
}
// Start activities for current state (always needed, even when restoring)
if (currentState?.activities) {
- startActivities(currentState.activities, snapshot.context, { _tag: "$init" } as TEvent);
+ startActivities(currentState.activities, snapshot.context, { _tag: "$init" });
}
// Start invoke for current state (always needed, even when restoring)
if (currentState?.invoke) {
- startInvoke(asInvokeConfig(currentState.invoke), snapshot.context, { _tag: "$init" } as TEvent);
+ startInvoke(asInvokeConfig(currentState.invoke), snapshot.context, { _tag: "$init" });
}
// Handle delayed transitions for current state (always needed, even when restoring)
@@ -1486,40 +1471,39 @@ export const interpret = <
childSnapshots?: ReadonlyMap>;
},
): Effect.Effect, never, R | Scope.Scope> =>
- Effect.gen(function* () {
- // Capture runtime to run effects with the current context (services)
- const runtime = yield* Effect.runtime();
-
- const actor = createActor(machine, {
- ...options,
- runtime,
- });
-
- // Register cleanup when scope closes
- yield* Effect.addFinalizer(() => Effect.sync(() => actor.stop()));
-
- return actor;
- });
+ Effect.flatMap(
+ Effect.runtime(),
+ (runtime) => {
+ const actor = createActor(machine, { ...options, runtime });
+ // Register cleanup when scope closes
+ return Effect.as(
+ Effect.addFinalizer(() => Effect.sync(() => actor.stop())),
+ actor,
+ );
+ },
+ );
/**
- * Synchronously interpret a machine without Effect context.
+ * Interpret a machine without automatic cleanup.
*
- * This is the escape hatch for:
- * - React components that manage lifecycle themselves
- * - Simple use cases that don't need services
- * - Backwards compatibility
+ * This is a faster alternative to `interpret()` that skips finalizer registration.
+ * Use this when you manage actor lifecycle manually (e.g., calling actor.stop() yourself).
*
- * Note: Effect actions that require services (R !== never) will fail at runtime.
+ * **Performance**: ~1.6x faster than `interpret()` due to skipping finalizer overhead.
*
* @example
* ```ts
- * const actor = interpretSync(machine)
- * actor.send(new MyEvent())
- * // Don't forget to clean up!
- * actor.stop()
+ * const program = Effect.gen(function* () {
+ * const actor = yield* interpretManual(machine);
+ *
+ * actor.send(new MyEvent());
+ *
+ * // YOU must stop the actor manually
+ * actor.stop();
+ * });
* ```
*/
-export function interpretSync<
+export function interpretManual<
TId extends string,
TStateValue extends string,
TContext extends MachineContext,
@@ -1531,9 +1515,16 @@ export function interpretSync<
machine: MachineDefinition,
options?: {
parent?: MachineActor;
+ /** Initial snapshot to restore from (for persistence) */
+ snapshot?: MachineSnapshot;
+ /** Child snapshots to restore (keyed by child ID) */
+ childSnapshots?: ReadonlyMap>;
},
-): MachineActor {
- return createActor(machine, options);
+): Effect.Effect, never, R> {
+ return Effect.map(
+ Effect.runtime(),
+ (runtime) => createActor(machine, { ...options, runtime }),
+ );
}
// ============================================================================
diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts
index c32750a..206baef 100644
--- a/packages/core/src/serialization.ts
+++ b/packages/core/src/serialization.ts
@@ -30,21 +30,18 @@ export const encodeSnapshot = <
TStateValue extends string,
TContext extends MachineContext,
TContextEncoded,
+ TSchemaR,
>(
- machine: MachineDefinition,
+ machine: MachineDefinition,
snapshot: MachineSnapshot,
-): Effect.Effect, ParseResult.ParseError> => {
- if (!machine.contextSchema) {
- return Effect.die(new Error("Machine does not have a context schema for serialization"));
- }
- return Effect.map(
+): Effect.Effect, ParseResult.ParseError, TSchemaR> =>
+ Effect.map(
Schema.encode(machine.contextSchema)(snapshot.context),
(context) => ({
value: snapshot.value,
context,
}),
);
-};
/**
* Encode a snapshot to a JSON-safe format (sync, throws on error).
@@ -56,15 +53,10 @@ export const encodeSnapshotSync = <
>(
machine: MachineDefinition,
snapshot: MachineSnapshot,
-): EncodedSnapshot => {
- if (!machine.contextSchema) {
- throw new Error("Machine does not have a context schema for serialization");
- }
- return {
- value: snapshot.value,
- context: Schema.encodeSync(machine.contextSchema)(snapshot.context),
- };
-};
+): EncodedSnapshot => ({
+ value: snapshot.value,
+ context: Schema.encodeSync(machine.contextSchema)(snapshot.context),
+});
/**
* Decode a snapshot from a JSON-safe format.
@@ -80,14 +72,12 @@ export const decodeSnapshot = <
TStateValue extends string,
TContext extends MachineContext,
TContextEncoded,
+ TSchemaR,
>(
- machine: MachineDefinition,
+ machine: MachineDefinition,
encoded: EncodedSnapshot,
-): Effect.Effect, ParseResult.ParseError> => {
- if (!machine.contextSchema) {
- return Effect.die(new Error("Machine does not have a context schema for deserialization"));
- }
- return Effect.map(
+): Effect.Effect, ParseResult.ParseError, TSchemaR> =>
+ Effect.map(
Schema.decode(machine.contextSchema)(encoded.context),
(context) => ({
value: encoded.value,
@@ -95,7 +85,6 @@ export const decodeSnapshot = <
event: null,
}),
);
-};
/**
* Decode a snapshot from a JSON-safe format (sync, throws on error).
@@ -107,16 +96,11 @@ export const decodeSnapshotSync = <
>(
machine: MachineDefinition,
encoded: EncodedSnapshot,
-): MachineSnapshot => {
- if (!machine.contextSchema) {
- throw new Error("Machine does not have a context schema for deserialization");
- }
- return {
- value: encoded.value,
- context: Schema.decodeSync(machine.contextSchema)(encoded.context),
- event: null,
- };
-};
+): MachineSnapshot => ({
+ value: encoded.value,
+ context: Schema.decodeSync(machine.contextSchema)(encoded.context),
+ event: null,
+});
// ============================================================================
// Snapshot Schema Builder (for advanced use)
@@ -124,24 +108,18 @@ export const decodeSnapshotSync = <
/**
* Get the context schema from a machine definition.
- * Throws if the machine does not have a context schema.
*/
export const getContextSchema = <
TContext extends MachineContext,
TContextEncoded,
+ TSchemaR,
>(
- machine: MachineDefinition,
-): Schema.Schema => {
- if (!machine.contextSchema) {
- throw new Error("Machine does not have a context schema");
- }
- return machine.contextSchema;
-};
+ machine: MachineDefinition,
+): Schema.Schema => machine.contextSchema;
/**
* Create a Schema for the encoded snapshot format.
* Useful for validation when loading from external sources.
- * Throws if the machine does not have a context schema.
*
* @example
* ```ts
@@ -159,9 +137,6 @@ export const createSnapshotSchema = <
MachineSnapshot,
EncodedSnapshot
> => {
- if (!machine.contextSchema) {
- throw new Error("Machine does not have a context schema");
- }
const contextSchema = machine.contextSchema;
const encodedContextSchema = Schema.encodedSchema(contextSchema);
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index ec595c6..6546efa 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -380,6 +380,15 @@ export interface AfterEvent {
readonly target?: TStateValue;
}
+/**
+ * Synthetic events used internally for lifecycle operations.
+ * These provide context to activities/invokes about why they were started.
+ */
+export type SyntheticEvent =
+ | { readonly _tag: "$init" }
+ | { readonly _tag: "$resume" }
+ | { readonly _tag: "$sync" };
+
/**
* Union of all internal events synthesized by the machine.
* These are not part of the user's TEvent union but are processed internally.
@@ -389,7 +398,8 @@ export type InternalEvent =
| InvokeFailureEvent
| InvokeDefectEvent
| InvokeInterruptEvent
- | AfterEvent;
+ | AfterEvent
+ | SyntheticEvent;
/** @deprecated Use InvokeSuccessEvent instead */
export type InvokeDoneEvent = InvokeSuccessEvent;
@@ -828,9 +838,11 @@ export interface StateNodeConfig<
* ```
*/
/**
- * Schema-based machine config (with serialization support)
+ * Machine config with Schema-based context (required for serialization).
+ *
+ * TSchemaR captures the Schema's requirements channel, which is merged into the machine's R.
*/
-export interface MachineConfigSchema<
+export interface MachineConfig<
TId extends string,
TStateValue extends string,
TContext extends MachineContext,
@@ -838,60 +850,17 @@ export interface MachineConfigSchema<
R = never,
E = never,
TContextEncoded = unknown,
+ TSchemaR = never,
> {
readonly id: TId;
readonly initial: TStateValue;
/** Schema for context validation and serialization */
- readonly context: Schema.Schema;
+ readonly context: Schema.Schema;
/** Initial context value */
readonly initialContext: TContext;
readonly states: Record>;
}
-/**
- * Plain machine config (backwards compatible, no serialization)
- */
-export interface MachineConfigPlain<
- TId extends string,
- TStateValue extends string,
- TContext extends MachineContext,
- TEvent extends MachineEvent,
- R = never,
- E = never,
-> {
- readonly id: TId;
- readonly initial: TStateValue;
- /** Plain context object */
- readonly context: TContext;
- readonly states: Record>;
-}
-
-/**
- * Union of machine config types
- */
-export type MachineConfig<
- TId extends string,
- TStateValue extends string,
- TContext extends MachineContext,
- TEvent extends MachineEvent,
- R = never,
- E = never,
- TContextEncoded = unknown,
-> = MachineConfigSchema
- | MachineConfigPlain;
-
-/**
- * Type guard to check if a value is a Schema
- */
-export function isSchema(value: unknown): value is Schema.Schema {
- return (
- typeof value === "object" &&
- value !== null &&
- "_tag" in value &&
- (value as { _tag: unknown })._tag === "Schema"
- );
-}
-
// ============================================================================
// Machine Definition (output of createMachine)
// ============================================================================
@@ -904,14 +873,14 @@ export interface MachineDefinition<
R = never,
E = never,
TContextEncoded = unknown,
+ TSchemaR = never,
> {
readonly _tag: "MachineDefinition";
readonly id: TId;
- readonly config: MachineConfigSchema
- | MachineConfigPlain;
+ readonly config: MachineConfig;
readonly initialSnapshot: MachineSnapshot;
- /** Schema for context serialization (only present for Schema-based configs) */
- readonly contextSchema?: Schema.Schema;
+ /** Schema for context serialization */
+ readonly contextSchema: Schema.Schema;
}
// ============================================================================
@@ -936,15 +905,16 @@ export interface AnyMachineDefinition {
readonly id: string;
readonly initial: string;
readonly states: Record>;
- readonly context?: Schema.Schema.Any | MachineContext;
- readonly initialContext?: MachineContext;
+ readonly context: Schema.Schema.Any;
+ readonly initialContext: MachineContext;
};
readonly initialSnapshot: MachineSnapshot;
- readonly contextSchema?: Schema.Schema.Any;
+ readonly contextSchema: Schema.Schema.Any;
}
/**
* Extract the R channel from a MachineDefinition type.
+ * Includes both the machine's R and the Schema's R (TSchemaR).
*/
export type MachineDefinitionR = T extends MachineDefinition<
string,
@@ -953,9 +923,10 @@ export type MachineDefinitionR = T extends MachineDefinition<
MachineEvent,
infer R,
unknown,
- unknown
+ unknown,
+ infer TSchemaR
>
- ? R
+ ? R | TSchemaR
: T extends AnyMachineDefinition
? R
: never;
diff --git a/packages/core/tests/activities.test.ts b/packages/core/tests/activities.test.ts
index 0c50f32..5c7121d 100644
--- a/packages/core/tests/activities.test.ts
+++ b/packages/core/tests/activities.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import { Data, Effect, Ref, Schema } from "effect";
-import { createMachine, interpretSync } from "../src/machine.js";
+import { createMachine } from "../src/machine.js";
+import { testActorSync } from "./test-utils.js";
import { assign } from "../src/actions.js";
import { guard, and, or, not } from "../src/guards.js";
@@ -58,7 +59,7 @@ describe("activities", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
// Activity should not be started yet
let started = yield* Ref.get(activityStarted);
@@ -110,7 +111,7 @@ describe("activities", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
// Start running
actor.send(new Toggle());
@@ -172,7 +173,7 @@ describe("activities", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("50 millis");
@@ -212,7 +213,7 @@ describe("guards", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("10 millis");
@@ -245,7 +246,7 @@ describe("guards", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("10 millis");
@@ -278,7 +279,7 @@ describe("guards", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
// Value too low - should not transition
actor.send(new SetValue({ value: 30 }));
@@ -327,7 +328,7 @@ describe("guard combinators", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("10 millis");
@@ -363,7 +364,7 @@ describe("guard combinators", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("10 millis");
@@ -399,7 +400,7 @@ describe("guard combinators", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("10 millis");
@@ -435,7 +436,7 @@ describe("guard combinators", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("10 millis");
@@ -468,7 +469,7 @@ describe("guard combinators", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("10 millis");
@@ -501,7 +502,7 @@ describe("guard combinators", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("10 millis");
diff --git a/packages/core/tests/actors.test.ts b/packages/core/tests/actors.test.ts
index 735074c..62bb194 100644
--- a/packages/core/tests/actors.test.ts
+++ b/packages/core/tests/actors.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import { Data, Effect, Ref, Schema } from "effect";
-import { createMachine, interpretSync } from "../src/machine.js";
+import { createMachine } from "../src/machine.js";
+import { testActorSync } from "./test-utils.js";
import { assign, effect, raise, enqueueActions, spawnChild, stopChild } from "../src/actions.js";
// ============================================================================
@@ -97,7 +98,7 @@ describe("enqueueActions (dynamic action queuing)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("20 millis");
@@ -142,7 +143,7 @@ describe("enqueueActions (dynamic action queuing)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("20 millis");
@@ -180,7 +181,7 @@ describe("enqueueActions (dynamic action queuing)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("20 millis");
@@ -225,7 +226,7 @@ describe("enqueueActions (dynamic action queuing)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("30 millis");
@@ -265,7 +266,7 @@ describe("enqueueActions (dynamic action queuing)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("20 millis");
@@ -302,7 +303,7 @@ describe("enqueueActions (dynamic action queuing)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new SetValue({ value: 25 }));
yield* Effect.sleep("20 millis");
@@ -347,7 +348,7 @@ describe("spawnChild / stopChild (actor hierarchy)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("20 millis");
@@ -390,7 +391,7 @@ describe("spawnChild / stopChild (actor hierarchy)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("20 millis");
@@ -433,7 +434,7 @@ describe("spawnChild / stopChild (actor hierarchy)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("20 millis");
@@ -483,7 +484,7 @@ describe("spawnChild / stopChild (actor hierarchy)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("20 millis");
@@ -526,7 +527,7 @@ describe("spawnChild / stopChild (actor hierarchy)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("20 millis");
diff --git a/packages/core/tests/advanced.test.ts b/packages/core/tests/advanced.test.ts
index d5077f5..8837f85 100644
--- a/packages/core/tests/advanced.test.ts
+++ b/packages/core/tests/advanced.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import { Context, Data, Effect, Exit, Ref, Schema, Scope } from "effect";
-import { createMachine, interpret, interpretSync } from "../src/machine.js";
+import { createMachine, interpret } from "../src/machine.js";
+import { testActorSync } from "./test-utils.js";
import { assign, effect, spawnChild, sendTo } from "../src/actions.js";
import {
createSnapshotSchema,
@@ -356,7 +357,7 @@ describe("Type Safety", () => {
void _validProgram;
});
- it("interpretSync does not require service provision", () => {
+ it("testActorSync does not require service provision at type level", () => {
// Machine that normally requires services
const machineWithService = createMachine<
"test",
@@ -374,9 +375,9 @@ describe("Type Safety", () => {
},
});
- // interpretSync compiles without providing services
- // (though the effect would fail at runtime if triggered)
- const _actor = interpretSync(machineWithService);
+ // testActorSync compiles without providing services
+ // (though effect actions requiring services would fail at runtime if triggered)
+ const _actor = testActorSync(machineWithService);
_actor.stop();
});
});
@@ -446,7 +447,7 @@ describe("Schema Context", () => {
},
});
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
const snapshot = actor.getSnapshot();
const encoded = encodeSnapshotSync(machine, snapshot);
@@ -514,7 +515,7 @@ describe("Schema Context", () => {
},
});
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Increment());
const original = actor.getSnapshot();
@@ -542,7 +543,7 @@ describe("Schema Context", () => {
// Schema context is now required - contextSchema should always be defined
expect(machine.contextSchema).toBeDefined();
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
expect(actor.getSnapshot().context.count).toBe(0);
actor.stop();
});
diff --git a/packages/core/tests/communication.test.ts b/packages/core/tests/communication.test.ts
index 303a06a..de380c6 100644
--- a/packages/core/tests/communication.test.ts
+++ b/packages/core/tests/communication.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import { Data, Effect, Schema } from "effect";
-import { createMachine, interpretSync } from "../src/machine.js";
+import { createMachine } from "../src/machine.js";
+import { testActorSync } from "./test-utils.js";
import { assign, effect, emit, spawnChild, sendTo, sendParent, forwardTo } from "../src/actions.js";
// ============================================================================
@@ -115,7 +116,7 @@ describe("emit (external listeners)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
// Register listener
actor.on("notification", (event) => {
@@ -165,7 +166,7 @@ describe("emit (external listeners)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.on("notification", (event) => received1.push(event as TestEmittedEvent));
actor.on("notification", (event) => received2.push(event as TestEmittedEvent));
@@ -221,7 +222,7 @@ describe("emit (external listeners)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
const unsubscribe = actor.on("notification", (event) => received.push(event as TestEmittedEvent));
@@ -269,7 +270,7 @@ describe("emit (external listeners)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.on("countChanged", (event) => received.push(event as TestEmittedEvent));
@@ -310,7 +311,7 @@ describe("emit (external listeners)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
// No listeners registered
actor.send(new Toggle());
@@ -359,7 +360,7 @@ describe("sendTo (send events to child actors)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("20 millis");
@@ -417,7 +418,7 @@ describe("sendTo (send events to child actors)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("20 millis");
@@ -466,7 +467,7 @@ describe("sendTo (send events to child actors)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("20 millis");
@@ -503,7 +504,7 @@ describe("sendTo (send events to child actors)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("20 millis");
@@ -578,7 +579,7 @@ describe("sendParent (send events to parent actor)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
// Spawn child
actor.send(new Toggle());
@@ -656,7 +657,7 @@ describe("sendParent (send events to parent actor)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("20 millis");
@@ -695,7 +696,7 @@ describe("sendParent (send events to parent actor)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("20 millis");
@@ -759,7 +760,7 @@ describe("forwardTo (forward current event to another actor)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("20 millis");
@@ -827,7 +828,7 @@ describe("forwardTo (forward current event to another actor)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("20 millis");
@@ -865,7 +866,7 @@ describe("forwardTo (forward current event to another actor)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("20 millis");
diff --git a/packages/core/tests/core.test.ts b/packages/core/tests/core.test.ts
index bf8ef2b..7c477b6 100644
--- a/packages/core/tests/core.test.ts
+++ b/packages/core/tests/core.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import { Data, Effect, Schema } from "effect";
-import { createMachine, interpretSync } from "../src/machine.js";
+import { createMachine } from "../src/machine.js";
+import { testActorSync } from "./test-utils.js";
import { assign } from "../src/actions.js";
// ============================================================================
@@ -80,7 +81,7 @@ describe("subscribe()", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.subscribe((snapshot) => {
snapshots.push({ value: snapshot.value, count: snapshot.context.count });
@@ -119,7 +120,7 @@ describe("subscribe()", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.subscribe((snapshot) => {
snapshots.push({ value: snapshot.value, count: snapshot.context.count });
@@ -157,7 +158,7 @@ describe("subscribe()", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.subscribe(() => sub1Calls++);
actor.subscribe(() => sub2Calls++);
@@ -191,7 +192,7 @@ describe("subscribe()", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
const unsub = actor.subscribe(() => calls.push(1));
@@ -235,7 +236,7 @@ describe("assign()", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("10 millis");
@@ -268,7 +269,7 @@ describe("assign()", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("10 millis");
@@ -301,7 +302,7 @@ describe("assign()", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new SetValue({ value: 123 }));
yield* Effect.sleep("10 millis");
diff --git a/packages/core/tests/invoke.test.ts b/packages/core/tests/invoke.test.ts
index 6320135..6f1eebe 100644
--- a/packages/core/tests/invoke.test.ts
+++ b/packages/core/tests/invoke.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import { Data, Effect, Fiber, Ref, Schema } from "effect";
-import { createMachine, interpretSync } from "../src/machine.js";
+import { createMachine } from "../src/machine.js";
+import { testActorSync } from "./test-utils.js";
import { assign, effect } from "../src/actions.js";
// ============================================================================
@@ -38,7 +39,7 @@ describe("onError (error handling)", () => {
},
});
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
// First observer throws
actor.subscribe(() => {
@@ -84,7 +85,7 @@ describe("onError (error handling)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.onError((error) => {
errors.push({ _tag: error._tag, message: error.message });
@@ -131,7 +132,7 @@ describe("onError (error handling)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
const unsub = actor.onError((error) => {
errors.push(error._tag);
@@ -173,7 +174,7 @@ describe("waitFor (Effect-based state waiting)", () => {
await Effect.runPromise(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
// Condition already met (count >= 5)
const result = yield* actor.waitFor((s) => s.context.count >= 5);
@@ -199,7 +200,7 @@ describe("waitFor (Effect-based state waiting)", () => {
await Effect.runPromise(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
// Start waiting in background
const waitFiber = yield* Effect.fork(
@@ -240,7 +241,7 @@ describe("waitFor (Effect-based state waiting)", () => {
await Effect.runPromise(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
// Start waiting for count to reach 3
const waitFiber = yield* Effect.fork(
@@ -278,7 +279,7 @@ describe("waitFor (Effect-based state waiting)", () => {
await Effect.runPromise(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
// Wait for a state that will never happen, with timeout
const result = yield* actor
@@ -309,7 +310,7 @@ describe("waitFor (Effect-based state waiting)", () => {
await Effect.runPromise(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
// Start waiting
const fiber = yield* Effect.fork(
@@ -362,7 +363,7 @@ describe("invoke (async operations)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
// Should start in loading
expect(actor.getSnapshot().value).toBe("loading");
@@ -409,7 +410,7 @@ describe("invoke (async operations)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
expect(actor.getSnapshot().value).toBe("loading");
@@ -457,7 +458,7 @@ describe("invoke (async operations)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
expect(actor.getSnapshot().value).toBe("loading");
@@ -503,7 +504,7 @@ describe("invoke (async operations)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
yield* Effect.sleep("20 millis");
const snapshot = actor.getSnapshot();
@@ -540,7 +541,7 @@ describe("invoke (async operations)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
yield* Effect.sleep("20 millis");
const snapshot = actor.getSnapshot();
@@ -575,7 +576,7 @@ describe("invoke (async operations)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
yield* Effect.sleep("30 millis");
// Guard blocks transition (3 > 5 is false)
@@ -611,7 +612,7 @@ describe("invoke (async operations)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
yield* Effect.sleep("20 millis");
const snapshot = actor.getSnapshot();
@@ -650,7 +651,7 @@ describe("invoke (async operations)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
yield* Effect.sleep("20 millis");
actor.stop();
@@ -706,7 +707,7 @@ describe("invoke (async operations)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("30 millis");
@@ -753,7 +754,7 @@ describe("invoke (async operations)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(networkMachine);
+ const actor = testActorSync(networkMachine);
yield* Effect.sleep("20 millis");
expect(actor.getSnapshot().value).toBe("retry");
@@ -789,7 +790,7 @@ describe("invoke (async operations)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(validationMachine);
+ const actor = testActorSync(validationMachine);
yield* Effect.sleep("20 millis");
expect(actor.getSnapshot().value).toBe("invalid");
@@ -829,7 +830,7 @@ describe("invoke (async operations)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
yield* Effect.sleep("20 millis");
expect(actor.getSnapshot().value).toBe("error");
@@ -866,7 +867,7 @@ describe("invoke (async operations)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
yield* Effect.sleep("20 millis");
expect(actor.getSnapshot().value).toBe("crashed");
@@ -906,7 +907,7 @@ describe("invoke (async operations)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
// Transition away, which should interrupt the invoke
yield* Effect.sleep("20 millis");
@@ -948,7 +949,7 @@ describe("invoke (async operations)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
yield* Effect.sleep("20 millis");
expect(actor.getSnapshot().value).toBe("done");
@@ -981,7 +982,7 @@ describe("invoke (async operations)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
yield* Effect.sleep("20 millis");
expect(actor.getSnapshot().value).toBe("failed");
diff --git a/packages/core/tests/test-utils.ts b/packages/core/tests/test-utils.ts
new file mode 100644
index 0000000..5b01bf7
--- /dev/null
+++ b/packages/core/tests/test-utils.ts
@@ -0,0 +1,86 @@
+/**
+ * Test utilities for effstate tests.
+ *
+ * Provides helpers that wrap the Effect-based interpret() for easier testing.
+ */
+
+import { Effect, Scope } from "effect";
+import { interpret, type MachineActor } from "../src/machine.js";
+import type { MachineContext, MachineDefinition, MachineEvent, MachineSnapshot } from "../src/types.js";
+
+/**
+ * Create a test actor from a machine definition.
+ *
+ * This is a test helper that wraps interpret() for synchronous-style testing.
+ * It creates a managed scope and returns the actor.
+ *
+ * @example
+ * ```ts
+ * const actor = await testActor(machine);
+ * actor.send(new MyEvent());
+ * expect(actor.getSnapshot().value).toBe("done");
+ * ```
+ */
+export async function testActor<
+ TId extends string,
+ TStateValue extends string,
+ TContext extends MachineContext,
+ TEvent extends MachineEvent,
+ R,
+ E,
+ TContextEncoded,
+>(
+ machine: MachineDefinition,
+ options?: {
+ /** Initial snapshot to restore from */
+ snapshot?: MachineSnapshot;
+ /** Child snapshots to restore (keyed by child ID) */
+ childSnapshots?: ReadonlyMap>;
+ },
+): Promise> {
+ // Create a scope that lives for the duration of the test
+ const scope = Effect.runSync(Scope.make());
+
+ const actor = await Effect.runPromise(
+ interpret(machine, options).pipe(
+ Effect.provideService(Scope.Scope, scope),
+ ),
+ );
+
+ return actor;
+}
+
+/**
+ * Create a test actor synchronously (for tests that don't need async).
+ *
+ * Note: This still uses Effect internally but blocks on the result.
+ * Use testActor() for async tests.
+ */
+export function testActorSync<
+ TId extends string,
+ TStateValue extends string,
+ TContext extends MachineContext,
+ TEvent extends MachineEvent,
+ R,
+ E,
+ TContextEncoded,
+>(
+ machine: MachineDefinition,
+ options?: {
+ /** Initial snapshot to restore from */
+ snapshot?: MachineSnapshot;
+ /** Child snapshots to restore (keyed by child ID) */
+ childSnapshots?: ReadonlyMap>;
+ },
+): MachineActor {
+ // Create a scope that lives for the duration of the test
+ const scope = Effect.runSync(Scope.make());
+
+ const actor = Effect.runSync(
+ interpret(machine, options).pipe(
+ Effect.provideService(Scope.Scope, scope),
+ ),
+ );
+
+ return actor;
+}
diff --git a/packages/core/tests/timing.test.ts b/packages/core/tests/timing.test.ts
index a666bed..f63171b 100644
--- a/packages/core/tests/timing.test.ts
+++ b/packages/core/tests/timing.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import { Data, Effect, Ref, Schema } from "effect";
-import { createMachine, interpretSync } from "../src/machine.js";
+import { createMachine } from "../src/machine.js";
+import { testActorSync } from "./test-utils.js";
import { assign, effect, cancel } from "../src/actions.js";
// ============================================================================
@@ -43,7 +44,7 @@ describe("after (delayed transitions)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
// Should still be waiting
let snapshot = actor.getSnapshot();
@@ -82,7 +83,7 @@ describe("after (delayed transitions)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
// Wait for delay
yield* Effect.sleep("50 millis");
@@ -119,7 +120,7 @@ describe("after (delayed transitions)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
yield* Effect.sleep("50 millis");
@@ -165,7 +166,7 @@ describe("cancel (delayed events)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
// Cancel before timeout fires
yield* Effect.sleep("30 millis");
@@ -212,7 +213,7 @@ describe("cancel (delayed events)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
yield* Effect.sleep("30 millis");
actor.send(new SetValue({ value: 100 })); // Cancel "delay-100"
@@ -251,7 +252,7 @@ describe("cancel (delayed events)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("10 millis");
@@ -296,7 +297,7 @@ describe("cancel (delayed events)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
// Quickly transition to partial (before short timeout)
yield* Effect.sleep("20 millis");
@@ -349,7 +350,7 @@ describe("cancel (delayed events)", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
// Start in stopping, delay scheduled for 100ms
expect(actor.getSnapshot().value).toBe("stopping");
@@ -406,7 +407,7 @@ describe("persistent delays", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
expect(actor.getSnapshot().value).toBe("a");
@@ -455,7 +456,7 @@ describe("persistent delays", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
yield* Effect.sleep("30 millis");
actor.send(new Toggle()); // a -> b, cancels the delay
@@ -498,7 +499,7 @@ describe("persistent delays", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
yield* Effect.sleep("30 millis");
actor.stop(); // Stop the actor
@@ -539,7 +540,7 @@ describe("Effect-based delays", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
expect(actor.getSnapshot().value).toBe("waiting");
@@ -575,7 +576,7 @@ describe("Effect-based delays", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
yield* Effect.sleep("30 millis");
actor.send(new Toggle()); // a -> b
@@ -620,7 +621,7 @@ describe("Effect-based delays", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
yield* Effect.sleep("30 millis");
actor.send(new Toggle()); // a -> b, interrupts the delay Effect
@@ -662,7 +663,7 @@ describe("Effect-based delays", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
yield* Effect.sleep("70 millis");
@@ -700,7 +701,7 @@ describe("Effect-based delays", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
yield* Effect.sleep("30 millis");
actor.send(new Toggle()); // a -> b
diff --git a/packages/core/tests/transitions.test.ts b/packages/core/tests/transitions.test.ts
index 19aed05..6e01cae 100644
--- a/packages/core/tests/transitions.test.ts
+++ b/packages/core/tests/transitions.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import { Data, Effect, Ref, Schema } from "effect";
-import { createMachine, interpretSync } from "../src/machine.js";
+import { createMachine } from "../src/machine.js";
+import { testActorSync } from "./test-utils.js";
import { assign, effect, raise } from "../src/actions.js";
// ============================================================================
@@ -56,7 +57,7 @@ describe("raise()", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("20 millis");
@@ -100,7 +101,7 @@ describe("raise()", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("20 millis");
@@ -143,7 +144,7 @@ describe("entry/exit actions", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("10 millis");
@@ -178,7 +179,7 @@ describe("entry/exit actions", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("10 millis");
@@ -211,7 +212,7 @@ describe("entry/exit actions", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
actor.send(new Toggle());
yield* Effect.sleep("10 millis");
@@ -242,7 +243,7 @@ describe("entry/exit actions", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- interpretSync(machine);
+ testActorSync(machine);
yield* Effect.sleep("10 millis");
const log = yield* Ref.get(entryLog);
@@ -288,7 +289,7 @@ describe("self-transitions", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
yield* Effect.sleep("10 millis");
// Clear the log after initial entry
@@ -334,7 +335,7 @@ describe("self-transitions", () => {
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
- const actor = interpretSync(machine);
+ const actor = testActorSync(machine);
yield* Effect.sleep("10 millis");
yield* Ref.set(actionLog, []); // Clear after initial entry
diff --git a/packages/react/package.json b/packages/react/package.json
index 1807dda..96c5818 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -1,6 +1,6 @@
{
"name": "@effstate/react",
- "version": "0.0.2",
+ "version": "0.0.3",
"description": "React integration for effstate",
"type": "module",
"main": "./dist/index.cjs",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1006449..eceb22f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -133,6 +133,79 @@ importers:
specifier: ^4.0.16
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@22.19.3)(jiti@1.21.7)(jsdom@27.4.0)(tsx@4.21.0)(yaml@2.8.2)
+ apps/demo-advanced:
+ dependencies:
+ '@radix-ui/react-slot':
+ specifier: ^1.1.0
+ version: 1.2.4(@types/react@18.3.27)(react@18.3.1)
+ class-variance-authority:
+ specifier: ^0.7.0
+ version: 0.7.1
+ clsx:
+ specifier: ^2.1.1
+ version: 2.1.1
+ effect:
+ specifier: ^3.19.12
+ version: 3.19.14
+ effstate:
+ specifier: workspace:*
+ version: link:../../packages/core
+ react:
+ specifier: ^18.3.1
+ version: 18.3.1
+ react-dom:
+ specifier: ^18.3.1
+ version: 18.3.1(react@18.3.1)
+ tailwind-merge:
+ specifier: ^2.5.4
+ version: 2.6.0
+ tailwindcss-animate:
+ specifier: ^1.0.7
+ version: 1.0.7(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))
+ devDependencies:
+ '@types/node':
+ specifier: ^22.9.0
+ version: 22.19.3
+ '@types/react':
+ specifier: ^18.3.3
+ version: 18.3.27
+ '@types/react-dom':
+ specifier: ^18.3.0
+ version: 18.3.7(@types/react@18.3.27)
+ '@typescript-eslint/eslint-plugin':
+ specifier: ^7.15.0
+ version: 7.18.0(@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)
+ '@typescript-eslint/parser':
+ specifier: ^7.15.0
+ version: 7.18.0(eslint@8.57.1)(typescript@5.9.3)
+ '@vitejs/plugin-react-swc':
+ specifier: ^3.5.0
+ version: 3.11.0(@swc/helpers@0.5.18)(vite@5.4.21(@types/node@22.19.3))
+ autoprefixer:
+ specifier: ^10.4.20
+ version: 10.4.23(postcss@8.5.6)
+ eslint:
+ specifier: ^8.57.0
+ version: 8.57.1
+ eslint-plugin-react-hooks:
+ specifier: ^4.6.2
+ version: 4.6.2(eslint@8.57.1)
+ eslint-plugin-react-refresh:
+ specifier: ^0.4.7
+ version: 0.4.26(eslint@8.57.1)
+ postcss:
+ specifier: ^8.4.49
+ version: 8.5.6
+ tailwindcss:
+ specifier: ^3.4.14
+ version: 3.4.19(tsx@4.21.0)(yaml@2.8.2)
+ typescript:
+ specifier: ^5.2.2
+ version: 5.9.3
+ vite:
+ specifier: ^5.3.4
+ version: 5.4.21(@types/node@22.19.3)
+
apps/docs:
dependencies:
'@astrojs/react':