Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/commit-state-before-effects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect-machine": patch
---

Commit an entered state before its state Effect starts.
10 changes: 0 additions & 10 deletions src/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -909,15 +909,6 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* <
timestamp,
}));
});
const onInitialSpawnEffects: RuntimeLifecycleHooks<S, E>["onInitialSpawnEffects"] = (state) =>
emitWithTimestamp(inspectorValue, (timestamp) => ({
type: "@machine.effect",
actorId: id,
generation: runtimeGeneration,
effectType: "spawn",
state,
timestamp,
}));
return {
onEvent,
onStateChange: (result, event) =>
Expand Down Expand Up @@ -964,7 +955,6 @@ export const createActor = Effect.fn("effect-machine.actor.spawn")(function* <
}));
}
}),
onInitialSpawnEffects,
};
};

Expand Down
73 changes: 34 additions & 39 deletions src/internal/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import type { Machine, MachineRef } from "../machine.js";
import type { ActorRef, ActorSystemService, TransitionInfo } from "../actor.js";
import { ActorSystem as ActorSystemTag } from "../actor.js";
import type { ProcessEventHooks, ProcessEventResult } from "./transition.js";
import { processEventCoreImmediate, runSpawnEffects, shouldPostpone } from "./transition.js";
import { enterState, processEventCoreImmediate, shouldPostpone } from "./transition.js";
import { makeEventAdvancement } from "./event-advancement.js";
import { ActorStoppedError, NoReplyError } from "../errors.js";
import { INTERNAL_INIT_EVENT, isEffect } from "./utils.js";
Expand Down Expand Up @@ -159,8 +159,6 @@ export interface RuntimeLifecycleHooks<S, E> {
readonly onFinal?: (state: S) => Effect.Effect<void>;
/** Before stop resource cleanup — actor emits @machine.stop, settles pending replies */
readonly onShutdown?: () => Effect.Effect<void>;
/** Before initial spawn effects — actor emits @machine.effect inspection */
readonly onInitialSpawnEffects?: (state: S) => Effect.Effect<void>;
}

// ============================================================================
Expand Down Expand Up @@ -324,12 +322,8 @@ export const createRuntime = Effect.fn("effect-machine.runtime.create")(function
machine,
initialState,
initEvent,
self,
stateScopeRef,
system,
actorId,
{ ...hooks, onSpawnDefect: initialSpawnDefectSignal },
generation,
);
let initialResult: ProcessEventResult<S, E>;
if (isEffect(initialProcessing)) {
Expand Down Expand Up @@ -388,36 +382,29 @@ export const createRuntime = Effect.fn("effect-machine.runtime.create")(function
// Run initial spawn effects — catch defects, tag as initial-spawn, and propagate.
// For unsupervised actors this fails createActor (correct: don't register dead actors).
// For supervised actors (Step 3), the supervision loop will catch and restart.
if (!initialResult.lifecycleRan && lifecycle?.onInitialSpawnEffects !== undefined) {
yield* lifecycle.onInitialSpawnEffects(stableInitialState);
}
// Note: onSpawnDefect for initial spawn fibers that defect asynchronously (after forking).
// If they defect later, this signals through exitDeferred and interrupts the loop.
if (!initialResult.lifecycleRan) {
yield* runSpawnEffects(
machine,
stableInitialState,
initEvent,
self,
stateScopeRef.current,
system,
actorId,
hooks?.onError,
initialSpawnDefectSignal,
generation,
).pipe(
Effect.catchCause((cause) =>
// Tag as initial-spawn defect, set exit, clean up, then propagate
Effect.gen(function* () {
yield* Ref.set(stoppedRef, true);
yield* Scope.close(stateScopeRef.current, Exit.void);
yield* Scope.close(actorScope, Exit.void);
yield* Deferred.succeed(exitDeferred, RuntimeExit.Defect(cause, "initial-spawn"));
return yield* Effect.failCause(cause);
}),
),
);
}
yield* enterState(
machine,
stableInitialState,
self,
stateScopeRef.current,
system,
actorId,
{ ...hooks, onSpawnDefect: initialSpawnDefectSignal },
generation,
).pipe(
Effect.catchCause((cause) =>
// Tag as initial-spawn defect, set exit, clean up, then propagate
Effect.gen(function* () {
yield* Ref.set(stoppedRef, true);
yield* Scope.close(stateScopeRef.current, Exit.void);
yield* Scope.close(actorScope, Exit.void);
yield* Deferred.succeed(exitDeferred, RuntimeExit.Defect(cause, "initial-spawn"));
return yield* Effect.failCause(cause);
}),
),
);

// Check if initial state is final — if so, clean up and signal done
if (machine._isFinal(stableInitialState._tag)) {
Expand Down Expand Up @@ -741,12 +728,8 @@ const runtimeEventLoop = Effect.fn("effect-machine.runtime.eventLoop")(function*
machine,
currentState,
event,
self,
stateScopeRef,
system,
actorId,
hooks,
generation,
);
let result: ProcessEventResult<S, E>;
if (isEffect(processing)) {
Expand All @@ -766,6 +749,18 @@ const runtimeEventLoop = Effect.fn("effect-machine.runtime.eventLoop")(function*
event: latest.event,
});
}
if (result.lifecycleRan) {
yield* enterState(
machine,
result.newState,
self,
stateScopeRef.current,
system,
actorId,
hooks,
generation,
);
}
}

// Lifecycle: onStateChange (actor notifies listeners and saves durability)
Expand Down
108 changes: 50 additions & 58 deletions src/internal/transition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,19 +408,24 @@ export const processEventCore = <
generation = 0,
) =>
Effect.suspend(() => {
const processed = processEventCoreImmediate(
machine,
currentState,
event,
self,
stateScopeRef,
system,
actorId,
hooks,
generation,
);
if (isEffect(processed)) return processed;
return Effect.succeed(processed);
const processed = processEventCoreImmediate(machine, currentState, event, stateScopeRef, hooks);
return Effect.gen(function* () {
let result: ProcessEventResult<S, E>;
if (isEffect(processed)) result = yield* processed;
else result = processed;
if (!result.lifecycleRan) return result;
yield* enterState(
machine,
result.newState,
self,
stateScopeRef.current,
system,
actorId,
hooks,
generation,
);
return result;
});
});

const completeProcessedEvent = <
Expand All @@ -431,14 +436,9 @@ const completeProcessedEvent = <
// eslint-disable-next-line @typescript-eslint/no-explicit-any
machine: Machine<S, E, R, any, any, any, any>,
currentState: S,
event: E,
result: ExecutedTransition<S, E>,
self: MachineRef<E, S>,
stateScopeRef: { current: Scope.Closeable },
system: ActorSystemService,
actorId: string,
hooks?: ProcessEventHooks<S, E>,
generation = 0,
):
| ProcessEventResult<S, E>
| Effect.Effect<ProcessEventResult<S, E>, never, Exclude<R, Scope.Scope>> => {
Expand Down Expand Up @@ -497,25 +497,6 @@ const completeProcessedEvent = <
);
}

// Hook: about to run spawn effects
if (hooks?.onSpawnEffect !== undefined) {
yield* hooks.onSpawnEffect(newState);
}

// Run spawn effects for new state
const enterEvent = { _tag: INTERNAL_ENTER_EVENT } as E;
yield* runSpawnEffects(
machine,
newState,
enterEvent,
self,
stateScopeRef.current,
system,
actorId,
hooks?.onError,
hooks?.onSpawnDefect,
generation,
);
return processed;
});
};
Expand All @@ -530,29 +511,14 @@ export const processEventCoreImmediate = <
machine: Machine<S, E, R, any, any, any, any>,
currentState: S,
event: E,
self: MachineRef<E, S>,
stateScopeRef: { current: Scope.Closeable },
system: ActorSystemService,
actorId: string,
hooks?: ProcessEventHooks<S, E>,
generation = 0,
) => {
const execution = executeTransitionImmediate(machine, currentState, event, hooks);
const complete = (result: ExecutedTransition<S, E>) => {
const immediateCandidates = machine._findImmediateTransitions(result.newState._tag);
if (immediateCandidates.length === 0) {
return completeProcessedEvent(
machine,
currentState,
event,
result,
self,
stateScopeRef,
system,
actorId,
hooks,
generation,
);
return completeProcessedEvent(machine, currentState, result, stateScopeRef, hooks);
}

return Effect.gen(function* () {
Expand Down Expand Up @@ -591,7 +557,6 @@ export const processEventCoreImmediate = <
const processed = completeProcessedEvent(
machine,
currentState,
event,
{
...result,
newState: stableState,
Expand All @@ -600,12 +565,8 @@ export const processEventCoreImmediate = <
transition: steps.at(-1)?.transition,
steps,
},
self,
stateScopeRef,
system,
actorId,
hooks,
generation,
);
if (isEffect(processed)) return yield* processed;
return processed;
Expand Down Expand Up @@ -698,6 +659,37 @@ export const runSpawnEffects = Effect.fn("effect-machine.runSpawnEffects")(funct
}
});

/** @internal */
export const enterState = Effect.fn("effect-machine.enterState")(function* <
S extends { readonly _tag: string },
E extends { readonly _tag: string },
R,
>(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
machine: Machine<S, E, R, any, any, any, any>,
state: S,
self: MachineRef<E, S>,
stateScope: Scope.Closeable,
system: ActorSystemService,
actorId: string,
hooks?: ProcessEventHooks<S, E>,
generation = 0,
) {
if (hooks?.onSpawnEffect !== undefined) yield* hooks.onSpawnEffect(state);
yield* runSpawnEffects(
machine,
state,
{ _tag: INTERNAL_ENTER_EVENT } as E,
self,
stateScope,
system,
actorId,
hooks?.onError,
hooks?.onSpawnDefect,
generation,
);
});

/**
* Resolve which transition should fire for a given state and event.
* Uses indexed O(1) lookup. First matching transition wins.
Expand Down
Loading
Loading