Skip to content

checkout() to the frontiers a detached doc is already at re-attaches it (1.13.6 → 1.13.7) #1048

Description

@bZichett

On 1.13.7, a detached doc that calls checkout(f) where f is both its current frontiers and HEAD comes back attached — isDetached() returns false. On 1.13.6 the same sequence stays detached.

Repro

import { LoroDoc } from 'loro-crdt';

const doc = new LoroDoc();
const t = doc.getText('t');
const cps = [];
for (const ch of ['a', 'b', 'c']) { t.insert(t.length, ch); doc.commit(); cps.push(doc.frontiers());
}

// scrub forward through every checkpoint, then back
const fwd = cps.map(f => (doc.checkout(f), doc.isDetached()));
const rev = [...cps].reverse().map(f => (doc.checkout(f), doc.isDetached()));
console.log('forward:', fwd, 'reverse:', rev);
1.13.6   forward: [true, true, true]    reverse: [true, true, true]
1.13.7   forward: [true, true, true]    reverse: [false, true, true]

The differing step is the first reverse one. The forward pass ends on cps[2], which is HEAD, so that call re-checks-out the position the doc already occupies.

Cause

b81abfc (#974, "harden diff calc for shallow text histories") added one line inside the pre-existing no-op early return in loro-internal/src/loro.rs:

+    self.set_detached(frontiers != &self.oplog_frontiers()); return Ok(()); }

Given that #974 mentions splitting shallow-root dependency checks from checkout semantics, this may well be intentional — so I'm asking rather than reporting a straight regression.

The part I'm unsure about

Only the no-op path derives the flag this way; the normal checkout path still sets detached unconditionally. So isDetached() at HEAD now depends on how you got there:

// both end sitting on HEAD, both detached beforehand
doc.checkout(mid); doc.checkout(head);   // -> isDetached() === true
doc.checkout(head);                       // (already there) -> isDetached() === false

Two readings, and I don't know which you intend:

  1. Detached means "position != HEAD", derived. Then the normal path should agree — checkout(head) from anywhere ought to report attached — and this is a breaking change worth a changelog line, since "detached while viewing HEAD" stops being
    representable.
  2. Detached stays an explicit mode, entered via checkout and left via checkoutToLatest. Then the no-op path shouldn't derive it.

For what it's worth on (2): on 1.13.6 a doc detached at HEAD could still re-attach normally via checkoutToLatest(), so this doesn't appear to have been fixing an unable-to-re-attach case.

Why I care

We drive a rich-text editor's read-only state off isDetached() while the user scrubs a history timeline. Dragging onto the newest entry when already parked there quietly makes the editor writable while the user is still in history view. It broke a scrubber regression test that walks the timeline forwards then backwards, which is how I found it.

Mostly I'd like to know which behavior to code against going forward. Happy to test a patch.

Environment

loro-crdt 1.13.7 (npm), Node 22, Linux. Same script on 1.13.6 for the comparison.

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