From 15ea62642f88d2d9375db8e6f6194a38f2458852 Mon Sep 17 00:00:00 2001 From: Kenny Udovic Date: Sat, 10 Jan 2026 09:29:07 -0500 Subject: [PATCH 1/5] chore: reduce casts --- packages/core/src/machine.ts | 92 ++++++++++++++++++------------------ packages/core/src/types.ts | 12 ++++- 2 files changed, 58 insertions(+), 46 deletions(-) diff --git a/packages/core/src/machine.ts b/packages/core/src/machine.ts index 617d10a..a00007f 100644 --- a/packages/core/src/machine.ts +++ b/packages/core/src/machine.ts @@ -295,6 +295,18 @@ function createActor< ): MachineActor { const runtime = options?.runtime; const childSnapshots = options?.childSnapshots; + + // Helper to run an Effect with optional runtime - consolidates runtime branching casts + const runForkEffect = (eff: Effect.Effect): Fiber.RuntimeFiber => + runtime + ? Runtime.runFork(runtime)(eff) + : Effect.runFork(eff as Effect.Effect); + + const runPromiseExitEffect = (eff: Effect.Effect): Promise> => + runtime + ? Runtime.runPromiseExit(runtime)(eff) + : Effect.runPromiseExit(eff as Effect.Effect); + // Mutable state - use provided snapshot or initial let snapshot: MachineSnapshot = options?.snapshot ?? machine.initialSnapshot; let stopped = false; @@ -890,26 +902,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 +934,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 +956,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)) { @@ -979,7 +988,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 +999,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 +1046,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 +1057,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 +1069,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 +1080,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 +1143,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 +1180,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 +1212,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)); @@ -1310,7 +1312,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 +1355,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 +1400,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 +1417,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) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ec595c6..b66e9ee 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; From 276f861a28eff5b8b24c1fcb6093ce1698ec56fd Mon Sep 17 00:00:00 2001 From: Kenny Udovic Date: Sat, 10 Jan 2026 09:40:13 -0500 Subject: [PATCH 2/5] refactor: remove object compatibility with context - require Schema - better R channel --- README.md | 4 +- .../docs/getting-started/introduction.mdx | 4 +- apps/docs/src/content/docs/index.mdx | 6 +- packages/core/src/machine.ts | 41 +++++------ packages/core/src/serialization.ts | 65 +++++------------ packages/core/src/types.ts | 73 +++++-------------- 6 files changed, 62 insertions(+), 131 deletions(-) diff --git a/README.md b/README.md index 92d00c4..92e3014 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ - **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? @@ -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/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..8d30c10 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? diff --git a/packages/core/src/machine.ts b/packages/core/src/machine.ts index a00007f..19cba97 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; }; } 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 b66e9ee..6546efa 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -838,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, @@ -848,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) // ============================================================================ @@ -914,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; } // ============================================================================ @@ -946,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, @@ -963,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; From c44558ffb8ab9df9adc3a4091c16b7845857ff83 Mon Sep 17 00:00:00 2001 From: Kenny Udovic Date: Sat, 10 Jan 2026 10:09:38 -0500 Subject: [PATCH 3/5] chore: wip on runtime requirements changes --- README.md | 4 +- .../docs/getting-started/comparison.mdx | 53 ++++---- apps/docs/src/content/docs/index.mdx | 4 +- packages/core/bench/machine.bench.ts | 113 +++++++++++++----- packages/core/src/index.ts | 2 +- packages/core/src/machine.ts | 67 ++--------- packages/core/tests/activities.test.ts | 27 +++-- packages/core/tests/actors.test.ts | 25 ++-- packages/core/tests/advanced.test.ts | 17 +-- packages/core/tests/communication.test.ts | 33 ++--- packages/core/tests/core.test.ts | 17 +-- packages/core/tests/invoke.test.ts | 51 ++++---- packages/core/tests/test-utils.ts | 86 +++++++++++++ packages/core/tests/timing.test.ts | 35 +++--- packages/core/tests/transitions.test.ts | 19 +-- 15 files changed, 333 insertions(+), 220 deletions(-) create mode 100644 packages/core/tests/test-utils.ts diff --git a/README.md b/README.md index 92e3014..c319266 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,8 @@ | Metric | effstate | XState | |--------|----------|--------| | **Bundle size (gzip)** | **~3.9 kB** | 13.7 kB | -| Event processing | **30x faster** | - | -| With subscribers | **14x faster** | - | +| Event processing | **24x faster** | - | +| Realistic app lifecycle | **4.5x faster** | - | [See full comparison →](https://handfish.github.io/effstate/getting-started/comparison/) diff --git a/apps/docs/src/content/docs/getting-started/comparison.mdx b/apps/docs/src/content/docs/getting-started/comparison.mdx index 1afe0d0..fb8d108 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** | **14,316,058** | **0.070** | +| XState | 340,392 | 2.938 | -effstate is **~50x faster** at machine creation. +effstate is **~42x 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 | 55,228 | 18.107 | +| **XState** | **335,239** | **2.983** | -XState is ~1.5x faster at actor lifecycle (their createActor is more optimized). +XState is ~6x faster at actor lifecycle. This is the cost of effstate's Effect-first architecture - actor creation goes through Effect's runtime for proper dependency injection and scope management. However, this is typically a one-time cost per actor. ### 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** | **14,877** | **67.2** | +| XState | 621 | 1609.3 | -effstate is **~30x faster** at event processing. +effstate is **~24x 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** | **42,998** | **23.3** | +| XState | 4,769 | 209.7 | -effstate is **~14x faster** with subscribers. +effstate is **~9x 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** | **51,633** | **19.4** | +| XState | 11,444 | 87.4 | -effstate is **~1.8x faster** for full lifecycle operations. +effstate is **~4.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 | 42x faster | +| Actor Lifecycle | XState | 6x faster | +| Event Sending | effstate | 24x faster | +| With Subscribers | effstate | 9x faster | +| Realistic Lifecycle | effstate | 4.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 +### What Matters Most? + +Actor creation happens once, but events are sent many times. In real applications: +- You create an actor when the app/component mounts +- You send dozens or hundreds of events during the actor's lifetime +- effstate's 24x faster event processing dominates the overall performance + ## Running the Benchmarks You can run the benchmarks yourself: diff --git a/apps/docs/src/content/docs/index.mdx b/apps/docs/src/content/docs/index.mdx index 8d30c10..462abb7 100644 --- a/apps/docs/src/content/docs/index.mdx +++ b/apps/docs/src/content/docs/index.mdx @@ -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..a7aa8cc 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,36 @@ import { assign as xstateAssign, } from "xstate"; +// ============================================================================ +// Benchmark Runtime Setup +// ============================================================================ + +// Pre-create a shared scope for benchmarks (actors are manually stopped) +const benchScope = Effect.runSync(Scope.make()); + +/** + * Benchmark-optimized actor creation. + * Uses a pre-created scope to avoid per-iteration overhead. + * Since we call actor.stop() manually, we don't need individual scopes. + */ +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 +190,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 +213,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 +232,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 +327,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 +361,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 +403,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 +448,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 +511,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 +545,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/src/index.ts b/packages/core/src/index.ts index 975c7a5..0143b67 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, 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 19cba97..d2b8d24 100644 --- a/packages/core/src/machine.ts +++ b/packages/core/src/machine.ts @@ -267,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, @@ -279,28 +279,23 @@ 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 optional runtime - consolidates runtime branching casts + // Helper to run an Effect with the captured runtime const runForkEffect = (eff: Effect.Effect): Fiber.RuntimeFiber => - runtime - ? Runtime.runFork(runtime)(eff) - : Effect.runFork(eff as Effect.Effect); + Runtime.runFork(runtime)(eff); const runPromiseExitEffect = (eff: Effect.Effect): Promise> => - runtime - ? Runtime.runPromiseExit(runtime)(eff) - : Effect.runPromiseExit(eff as Effect.Effect); + Runtime.runPromiseExit(runtime)(eff); // Mutable state - use provided snapshot or initial let snapshot: MachineSnapshot = options?.snapshot ?? machine.initialSnapshot; @@ -966,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); @@ -1222,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", @@ -1498,41 +1486,6 @@ export const interpret = < return actor; }); -/** - * Synchronously interpret a machine without Effect context. - * - * This is the escape hatch for: - * - React components that manage lifecycle themselves - * - Simple use cases that don't need services - * - Backwards compatibility - * - * Note: Effect actions that require services (R !== never) will fail at runtime. - * - * @example - * ```ts - * const actor = interpretSync(machine) - * actor.send(new MyEvent()) - * // Don't forget to clean up! - * actor.stop() - * ``` - */ -export function interpretSync< - TId extends string, - TStateValue extends string, - TContext extends MachineContext, - TEvent extends MachineEvent, - R, - E, - TContextEncoded, ->( - machine: MachineDefinition, - options?: { - parent?: MachineActor; - }, -): MachineActor { - return createActor(machine, options); -} - // ============================================================================ // Internal Helpers // ============================================================================ 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 From 071188519bf33f0bbb6ed5a6611eac34ae2ce8f9 Mon Sep 17 00:00:00 2001 From: Kenny Udovic Date: Sat, 10 Jan 2026 10:39:40 -0500 Subject: [PATCH 4/5] feat: advanced demo chore: fix lint --- README.md | 4 +- apps/demo-advanced/.eslintrc.cjs | 16 + apps/demo-advanced/README.md | 102 +++++++ apps/demo-advanced/index.html | 13 + apps/demo-advanced/package.json | 40 +++ apps/demo-advanced/postcss.config.js | 6 + apps/demo-advanced/src/App.tsx | 56 ++++ .../src/components/ManualLifecycleDemo.tsx | 148 +++++++++ .../src/components/ui/button.tsx | 56 ++++ .../src/data-access/manual-actor.ts | 280 ++++++++++++++++++ apps/demo-advanced/src/index.css | 65 ++++ apps/demo-advanced/src/lib/utils.ts | 6 + apps/demo-advanced/src/main.tsx | 10 + apps/demo-advanced/tailwind.config.js | 57 ++++ apps/demo-advanced/tsconfig.json | 28 ++ apps/demo-advanced/vite.config.ts | 12 + apps/demo/src/app.tsx | 4 +- .../docs/getting-started/comparison.mdx | 50 ++-- apps/docs/src/content/docs/index.mdx | 4 +- packages/core/bench/machine.bench.ts | 11 +- packages/core/src/index.ts | 2 +- packages/core/src/machine.ts | 67 ++++- pnpm-lock.yaml | 73 +++++ 23 files changed, 1057 insertions(+), 53 deletions(-) create mode 100644 apps/demo-advanced/.eslintrc.cjs create mode 100644 apps/demo-advanced/README.md create mode 100644 apps/demo-advanced/index.html create mode 100644 apps/demo-advanced/package.json create mode 100644 apps/demo-advanced/postcss.config.js create mode 100644 apps/demo-advanced/src/App.tsx create mode 100644 apps/demo-advanced/src/components/ManualLifecycleDemo.tsx create mode 100644 apps/demo-advanced/src/components/ui/button.tsx create mode 100644 apps/demo-advanced/src/data-access/manual-actor.ts create mode 100644 apps/demo-advanced/src/index.css create mode 100644 apps/demo-advanced/src/lib/utils.ts create mode 100644 apps/demo-advanced/src/main.tsx create mode 100644 apps/demo-advanced/tailwind.config.js create mode 100644 apps/demo-advanced/tsconfig.json create mode 100644 apps/demo-advanced/vite.config.ts diff --git a/README.md b/README.md index c319266..d45f922 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,8 @@ | Metric | effstate | XState | |--------|----------|--------| | **Bundle size (gzip)** | **~3.9 kB** | 13.7 kB | -| Event processing | **24x faster** | - | -| Realistic app lifecycle | **4.5x faster** | - | +| Event processing | **25x faster** | - | +| Realistic app lifecycle | **5x faster** | - | [See full comparison →](https://handfish.github.io/effstate/getting-started/comparison/) 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 fb8d108..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,316,058** | **0.070** | -| XState | 340,392 | 2.938 | +| **effstate** | **13,723,258** | **0.073** | +| XState | 268,803 | 3.720 | -effstate is **~42x 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 | 55,228 | 18.107 | -| **XState** | **335,239** | **2.983** | +| effstate | 57,627 | 17.353 | +| **XState** | **307,461** | **3.252** | -XState is ~6x faster at actor lifecycle. This is the cost of effstate's Effect-first architecture - actor creation goes through Effect's runtime for proper dependency injection and scope management. However, this is typically a one-time cost per actor. +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** | **14,877** | **67.2** | -| XState | 621 | 1609.3 | +| **effstate** | **15,166** | **65.9** | +| XState | 610 | 1640.7 | -effstate is **~24x faster** at event processing. +effstate is **~25x faster** at event processing. ### With Subscribers (5 subscribers, 100 events) @@ -61,10 +61,10 @@ Processing events with multiple subscribers attached. | Library | ops/sec | Mean (μs) | |---------|---------|-----------| -| **effstate** | **42,998** | **23.3** | -| XState | 4,769 | 209.7 | +| **effstate** | **44,536** | **22.5** | +| XState | 5,728 | 174.6 | -effstate is **~9x faster** with subscribers. +effstate is **~8x faster** with subscribers. ### Realistic App Lifecycle @@ -72,20 +72,20 @@ Complete workflow simulating real app usage: create actor → subscribe (like Re | Library | ops/sec | Mean (μs) | |---------|---------|-----------| -| **effstate** | **51,633** | **19.4** | -| XState | 11,444 | 87.4 | +| **effstate** | **54,743** | **18.3** | +| XState | 11,323 | 88.3 | -effstate is **~4.5x faster** for realistic app lifecycles. +effstate is **~5x faster** for realistic app lifecycles. ## Summary | Benchmark | Winner | Factor | |-----------|--------|--------| -| Machine Creation | effstate | 42x faster | -| Actor Lifecycle | XState | 6x faster | -| Event Sending | effstate | 24x faster | -| With Subscribers | effstate | 9x faster | -| Realistic Lifecycle | effstate | 4.5x 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** @@ -102,12 +102,12 @@ effstate is **~4.5x faster** for realistic app lifecycles. - Effect ecosystem integration - Hierarchical parent/child actor communication -### What Matters Most? +### Performance Tips -Actor creation happens once, but events are sent many times. In real applications: -- You create an actor when the app/component mounts -- You send dozens or hundreds of events during the actor's lifetime -- effstate's 24x faster event processing dominates the overall performance +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 diff --git a/apps/docs/src/content/docs/index.mdx b/apps/docs/src/content/docs/index.mdx index 462abb7..4d3ee4c 100644 --- a/apps/docs/src/content/docs/index.mdx +++ b/apps/docs/src/content/docs/index.mdx @@ -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 a7aa8cc..357e9c5 100644 --- a/packages/core/bench/machine.bench.ts +++ b/packages/core/bench/machine.bench.ts @@ -17,13 +17,12 @@ import { // Benchmark Runtime Setup // ============================================================================ -// Pre-create a shared scope for benchmarks (actors are manually stopped) +// Shared scope for benchmarks - actors are manually stopped so we reuse one scope const benchScope = Effect.runSync(Scope.make()); /** - * Benchmark-optimized actor creation. - * Uses a pre-created scope to avoid per-iteration overhead. - * Since we call actor.stop() manually, we don't need individual scopes. + * Benchmark actor creation using the standard interpret() API. + * This is what we recommend in docs/demos - honest benchmarking. */ function benchActor< TId extends string, @@ -37,9 +36,7 @@ function benchActor< machine: MachineDefinition, ): MachineActor { return Effect.runSync( - interpret(machine).pipe( - Effect.provideService(Scope.Scope, benchScope), - ), + interpret(machine).pipe(Effect.provideService(Scope.Scope, benchScope)) ); } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0143b67..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, 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 d2b8d24..a82c6a5 100644 --- a/packages/core/src/machine.ts +++ b/packages/core/src/machine.ts @@ -1471,20 +1471,61 @@ 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())); + 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, + ); + }, + ); - return actor; - }); +/** + * Interpret a machine without automatic cleanup. + * + * 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). + * + * **Performance**: ~1.6x faster than `interpret()` due to skipping finalizer overhead. + * + * @example + * ```ts + * const program = Effect.gen(function* () { + * const actor = yield* interpretManual(machine); + * + * actor.send(new MyEvent()); + * + * // YOU must stop the actor manually + * actor.stop(); + * }); + * ``` + */ +export function interpretManual< + TId extends string, + TStateValue extends string, + TContext extends MachineContext, + TEvent extends MachineEvent, + R, + E, + TContextEncoded, +>( + machine: MachineDefinition, + options?: { + parent?: MachineActor; + /** Initial snapshot to restore from (for persistence) */ + snapshot?: MachineSnapshot; + /** Child snapshots to restore (keyed by child ID) */ + childSnapshots?: ReadonlyMap>; + }, +): Effect.Effect, never, R> { + return Effect.map( + Effect.runtime(), + (runtime) => createActor(machine, { ...options, runtime }), + ); +} // ============================================================================ // Internal Helpers 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': From adf6495f28fc599c0a7e7b377b8561904b308abb Mon Sep 17 00:00:00 2001 From: Kenny Udovic Date: Sun, 11 Jan 2026 09:35:50 -0500 Subject: [PATCH 5/5] chore: changeset related updates chore: bump version numbers --- .changeset/config.json | 2 +- .changeset/free-jars-fly.md | 6 ++++++ packages/core/package.json | 2 +- packages/react/package.json | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 .changeset/free-jars-fly.md 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/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/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",