Bug: Recreating a machine with children after getPersistedSnapshot results in an inability to persist it again
XState version
XState version 5
Description
Sequence of events:
- Create a machine with children (either one or multiple of the same type)
- Create an actor and initialize the children machines through events of their parent
- Call
getPersistsedSnapshot - Try to create new actor from that snapshot
- If you reuse the same object, actor works fine and you can call
getPersistedSnapshoton a new actor - If you don't reuse it, and instead go through
JSON.parse(JSON.serialize(...)),getPersistedSnapshotcrashes when calling on a new actor.
Expected result
Restored actors continue to be persistable
Actual result
Restored actors fail to persist their children
Reproduction
https://codesandbox.io/p/devbox/gracious-jerry-njrg8n
Additional context
xstate 5.18.1 (latest)
Have you found a workaround for this @iamquoz ?
Unfortunately this makes spawning actors impossible when there's persistence involved, which seems like a high priority bug to me.
@reinoute Haven't found a workaround that lets me recreate from a snapshot, however you could save every event your machine recieves and apply them to a blank actor.
@iamquoz I'm using XState in a serverless (stateless) backend, so on each request I need to restore the statemachine from the persisted state. I don't think replaying previous events is realistic for that use case, but I could be wrong...
I think I have a workaround, but it might be a PITA depending on your scenario: the problem is that the child machine src is the statemachine object, and it doesn't get properly serialized in getPersistedState, so when it rehydrates the sub actor the logic property is just a POJO. if you can inspect your serialized children tree before restoring your parent actor and get all your children state machine definitions, you can patch them in:
(the salient bit is in the loadMachineState function)
import {
setup,
createMachine,
assign,
spawnChild,
sendTo,
StateMachine,
} from "xstate";
import { createActor } from "xstate";
import fs from "fs";
import path from "path";
// Simple file-based persistence
const PERSISTENCE_FILE = path.join(process.cwd(), "machine-state.json");
// Child machine definition
const childMachineDefinition = createMachine({
id: "childMachine",
initial: "idle",
context: {
count: 0,
},
states: {
idle: {
on: {
INCREMENT: {
actions: assign({
count: ({ context }) => context.count + 1,
}),
},
NEXT: "active",
},
},
active: {
on: {
INCREMENT: {
actions: assign({
count: ({ context }) => context.count + 1,
}),
},
BACK: "idle",
},
},
},
});
// Parent machine definition
const parentMachine = setup({
types: {
context: {}, // Remove TypeScript type assertion
},
}).createMachine({
id: "parentMachine",
initial: "noChild",
context: {
childRef: null,
},
states: {
noChild: {
on: {
SPAWN: {
target: "hasChild",
actions: assign({
childRef: ({ spawn }) =>
spawn(childMachineDefinition, { id: "child" }) &&
false,
}),
},
},
},
hasChild: {
on: {
// Forward events to child
SEND_TO_CHILD: {
actions: sendTo("child", ({ event }) => event.childEvent),
},
// Stop child
STOP_CHILD: {
target: "noChild",
actions: assign({
childRef: null,
}),
},
},
},
},
});
// Save machine state to file
function persistMachineState(actor) {
try {
// Use getSnapshot instead of getPersistedSnapshot which might not be available
const snapshot = actor.getPersistedSnapshot();
console.log("writing snapshot", snapshot);
fs.writeFileSync(PERSISTENCE_FILE, JSON.stringify(snapshot, null, 2));
console.log("Machine state persisted successfully");
return true;
} catch (error) {
console.error("Error persisting machine state:", error);
return false;
}
}
// Load machine state from file
function loadMachineState() {
try {
if (fs.existsSync(PERSISTENCE_FILE)) {
const data = fs.readFileSync(PERSISTENCE_FILE, "utf8");
const snap = JSON.parse(data);
// WORKAROUND - turn src back into the actual child machine definition
snap.children = snap.children
? Object.fromEntries(
Object.entries(snap.children).map(([key, child]) => [
key,
{
...child,
src: childMachineDefinition,
},
])
)
: snap.children;
return snap;
}
return null;
} catch (error) {
console.error("Error loading machine state:", error);
return null;
}
}
// Delete persisted state
function clearPersistedState() {
if (fs.existsSync(PERSISTENCE_FILE)) {
fs.unlinkSync(PERSISTENCE_FILE);
console.log("Persisted state cleared");
}
}
// Demo function
async function runDemo() {
console.log("\n--- PERSISTENCE DEMO WITH SPAWNED CHILD ---\n");
// Clear any existing state
clearPersistedState();
// STEP 1: Create a new parent machine and spawn a child
console.log("STEP 1: Creating parent machine and spawning child");
const parentActor = createActor(parentMachine);
parentActor.start();
// Spawn a child
parentActor.send({ type: "SPAWN" });
// Send events to the child to change its state
parentActor.send({
type: "SEND_TO_CHILD",
childEvent: { type: "INCREMENT" },
});
parentActor.send({
type: "SEND_TO_CHILD",
childEvent: { type: "NEXT" },
});
parentActor.send({
type: "SEND_TO_CHILD",
childEvent: { type: "INCREMENT" },
});
// Log the current state
const snapshot1 = parentActor.getSnapshot();
console.log("Parent state:", snapshot1.value);
console.log("Child exists:", !!snapshot1.children.child);
if (snapshot1.children.child) {
const childSnapshot = snapshot1.children.child.getSnapshot();
console.log("Child state:", childSnapshot.value);
console.log("Child count:", childSnapshot.context.count);
}
// STEP 2: Persist the machine state
console.log("\nSTEP 2: Persisting machine state");
const persisted = persistMachineState(parentActor);
if (!persisted) {
console.error("Failed to persist state, aborting demo");
return;
}
// Stop the actor
parentActor.stop();
console.log("Original actor stopped");
// STEP 3: Restore from persistence
console.log("\nSTEP 3: Restoring machine from persistence");
const persistedState = loadMachineState();
if (!persistedState) {
console.error("Failed to load persisted state, aborting demo");
return;
}
// Create a new actor with the persisted state
const restoredActor = createActor(parentMachine, {
snapshot: persistedState,
});
restoredActor.start();
// Log the restored state
const snapshot2 = restoredActor.getSnapshot();
console.log("Restored parent state:", snapshot2.value);
console.log("Restored child exists:", !!snapshot2.children.child);
if (snapshot2.children.child) {
const childSnapshot = snapshot2.children.child.getSnapshot();
console.log("Restored child state:", childSnapshot.value);
console.log("Restored child count:", childSnapshot.context.count);
} else {
console.error("Child was not restored properly!");
}
// STEP 4: Verify we can interact with the restored child
console.log("\nSTEP 4: Interacting with restored child");
if (snapshot2.children.child) {
// Send more events to the child
restoredActor.send({
type: "SEND_TO_CHILD",
childEvent: { type: "INCREMENT" },
});
// Log the updated state
const snapshot3 = restoredActor.getSnapshot();
const childSnapshot = snapshot3.children.child.getSnapshot();
console.log("Child state after increment:", childSnapshot.value);
console.log(
"Child count after increment:",
childSnapshot.context.count
);
// STEP 5: Re-persist the state with the updated child
console.log("\nSTEP 5: Re-persisting state with updated child");
const rePersisted = persistMachineState(restoredActor);
if (rePersisted) {
console.log("Successfully re-persisted state with child");
} else {
console.log("Failed to re-persist state with child");
throw new Error("FAILED");
}
} else {
console.error("Cannot interact with child as it was not restored");
}
// STEP 6: Restore again from the re-persisted state
console.log("\nSTEP 6: Restoring from re-persisted state");
const rePersistState = loadMachineState();
if (!rePersistState) {
console.error("Failed to load re-persisted state");
return;
}
const finalActor = createActor(parentMachine, {
snapshot: rePersistState,
});
finalActor.start();
// Log the final restored state
const finalSnapshot = finalActor.getSnapshot();
console.log("Final parent state:", finalSnapshot.value);
console.log("Final child exists:", !!finalSnapshot.children.child);
if (finalSnapshot.children.child) {
const childSnapshot = finalSnapshot.children.child.getSnapshot();
console.log("Final child state:", childSnapshot.value);
console.log("Final child count:", childSnapshot.context.count);
console.log(
"\nDEMO SUCCESSFUL: Child was properly persisted and restored twice"
);
} else {
console.error(
"\nDEMO FAILED: Child was not properly restored in the final step"
);
}
// Clean up
finalActor.stop();
clearPersistedState();
}
// Run the demo
runDemo().catch((error) => {
console.error("Demo failed with error:", error);
});
I am facing the exact same issue.
Here's my minimal reproduction: (I am using Deno)
import { assign, createActor, fromCallback, setup } from "npm:xstate@5";
const myMachine = setup({
actions: {
logTaskAEntry: () => console.log("Entering processing.taskA"),
logTaskBEntry: () => console.log("Entering processing.taskB"),
},
actors: {
cb: fromCallback(({ input: { task, user } }) => {
console.log("CALLBACK FOR task and user", task, user);
return () => {};
}),
},
}).createMachine(
{
id: "myMachine",
initial: "idle",
context: ({ input }) => ({
count: input.count,
user: input.user,
nested: null,
}),
states: {
idle: {
on: { START: "processing.taskA" },
},
processing: {
initial: "taskA",
invoke: {
src: "cb",
input: ({ context }) => ({ task: "processing", user: context.user }),
},
states: {
taskA: {
entry: [() => console.log("test taskA"), { type: "logTaskAEntry" }],
on: { NEXT: "taskB" },
},
taskB: {
entry: [
{ type: "logTaskBEntry" },
assign({
nested: ({ spawn }) =>
spawn(myMachine, {
input: {
count: 1,
user: { id: "user3", name: "Jerome" },
},
}),
}),
({ context }) => context.nested.send({ type: "START" }),
],
on: { FINISH: "#myMachine.finished" },
},
},
},
finished: {
type: "final",
},
},
},
);
const actor = createActor(myMachine, {
input: {
count: 1,
user: { id: "user2", name: "Bob" },
},
});
// Subscribe to state changes (optional, for demonstration)
actor.subscribe((snapshot) => {
console.log("[Actor 1] Current state:", snapshot.value);
// console.log('(2)Current context:', snapshot.context);
});
actor.start();
actor.send({ type: "START" });
actor.send({ type: "NEXT" });
Deno.writeFileSync(
"state-test.json",
new TextEncoder().encode(JSON.stringify(actor.getPersistedSnapshot())),
);
console.log("wrote snapshot");
const snapshot = JSON.parse(
new TextDecoder().decode(Deno.readFileSync("state-test.json")),
);
console.log("-------------------");
// Create the actor, providing your custom initial state configuration
const actor2 = createActor(myMachine, {
snapshot, //: actor.getPersistedSnapshot()
});
// Subscribe to state changes (optional, for demonstration)
actor2.subscribe((snapshot) => {
console.log("[Actor 2] Current state:", snapshot.value);
// console.log('Current context:', snapshot.context);
});
// Start the actor
actor2.start();
I get the logs:
❯ deno -A snap-test.js
[Actor 1] Current state: idle
test taskA
Entering processing.taskA
CALLBACK FOR task and user processing { id: "user2", name: "Bob" }
[Actor 1] Current state: { processing: "taskA" }
Entering processing.taskB
test taskA
Entering processing.taskA
CALLBACK FOR task and user processing { id: "user3", name: "Jerome" }
[Actor 1] Current state: { processing: "taskB" }
wrote snapshot
-------------------
CALLBACK FOR task and user processing { id: "user2", name: "Bob" }
[Actor 2] Current state: { processing: "taskB" }
I am missing CALLBACK FOR task and user processing { id: "user3", name: "Jerome" }
If I use the non-serialized snapshot, I get the correct logs:
❯ deno -A snap-test.js
[Actor 1] Current state: idle
test taskA
Entering processing.taskA
CALLBACK FOR task and user processing { id: "user2", name: "Bob" }
[Actor 1] Current state: { processing: "taskA" }
Entering processing.taskB
test taskA
Entering processing.taskA
CALLBACK FOR task and user processing { id: "user3", name: "Jerome" }
[Actor 1] Current state: { processing: "taskB" }
wrote snapshot
-------------------
CALLBACK FOR task and user processing { id: "user2", name: "Bob" }
CALLBACK FOR task and user processing { id: "user3", name: "Jerome" }
[Actor 2] Current state: { processing: "taskB" }
Is there an update to this? I thought https://github.com/statelyai/xstate/pull/5269 would fix this problem, but it doesn't seem so as of v5.20
@LuccaPassos it appears to have solved it for me. I am on 5.20.1