-
-
Notifications
You must be signed in to change notification settings - Fork 123
Description
Calling doc.forkAt() inside a subscription callback that fires during a checkout event corrupts the checkout state. The document ends up in an inconsistent state where frontiers() returns the checkout target but toJSON() returns the pre-checkout state.
Minimal Reproduction
import { LoroDoc } from "loro-crdt"
const doc = new LoroDoc()
doc.setPeerId("1")
// Make some changes
doc.getText("text").insert(0, "Hello")
doc.commit()
const frontier1 = doc.frontiers() // [{ peer: "1", counter: 4 }]
doc.getText("text").insert(5, " World")
doc.commit()
const frontier2 = doc.frontiers() // [{ peer: "1", counter: 10 }]
// Subscribe and call forkAt inside the callback
doc.subscribe((event) => {
if (event.by === "checkout") {
// BUG: This corrupts the checkout state
const fork = doc.forkAt(doc.frontiers())
console.log("Fork toJSON:", fork.toJSON())
}
})
// Checkout to earlier state
doc.checkout(frontier1)
// EXPECTED: doc.toJSON() should return { text: "Hello" }
// ACTUAL: doc.toJSON() returns { text: "Hello World" } (pre-checkout state)
console.log("After checkout - frontiers:", doc.frontiers()) // Shows frontier1 ✓
console.log("After checkout - toJSON:", doc.toJSON()) // Shows "Hello World" ✗Expected Behavior
After checkout(frontier1):
doc.frontiers()should returnfrontier1doc.toJSON()should return the state atfrontier1(i.e.,{ text: "Hello" })
Actual Behavior
After checkout(frontier1) when forkAt is called inside the subscription:
doc.frontiers()correctly returnsfrontier1doc.toJSON()incorrectly returns the state from before checkout (i.e.,{ text: "Hello World" })
The document is in an inconsistent state where the frontiers don't match the actual state.
Workaround
Skip forkAt calls when event.by === "checkout":
doc.subscribe((event) => {
if (event.by === "checkout") {
// Don't call forkAt during checkout events
return
}
// Safe to call forkAt for "local" and "import" events
const fork = doc.forkAt(doc.frontiers())
})Additional Context
This bug was discovered while implementing a time travel debugging feature. The pattern of calling forkAt inside a subscription is useful for creating snapshots of state transitions (before/after states for reactors in LEA, a TEA-like architecture).
The bug only occurs when:
- There's an active subscription on the document
- The subscription callback calls
forkAt - The subscription fires due to a
checkoutevent (notlocalorimport)
Calling forkAt during local or import events works correctly.
Environment
- loro-crdt version: 1.10.3
- Platform: macOS, Node.js / Browser