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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions