Skip to content

forkAt inside subscription during checkout event corrupts document state #907

@canadaduane

Description

@canadaduane

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 return frontier1
  • doc.toJSON() should return the state at frontier1 (i.e., { text: "Hello" })

Actual Behavior

After checkout(frontier1) when forkAt is called inside the subscription:

  • doc.frontiers() correctly returns frontier1
  • doc.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:

  1. There's an active subscription on the document
  2. The subscription callback calls forkAt
  3. The subscription fires due to a checkout event (not local or import)

Calling forkAt during local or import events works correctly.

Environment

  • loro-crdt version: 1.10.3
  • Platform: macOS, Node.js / Browser

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions