Skip to content

fix: cursor-jump and char-rollback during widget typing - #24

Merged
LikeSundayLikeRain merged 13 commits into
mainfrom
fix/childdirty-typing-races
Jun 10, 2026
Merged

fix: cursor-jump and char-rollback during widget typing#24
LikeSundayLikeRain merged 13 commits into
mainfrom
fix/childdirty-typing-races

Conversation

@LikeSundayLikeRain

Copy link
Copy Markdown
Owner

Summary

  • Fixes a BRAT-reported regression where typing bursts caused the widget cursor to jump to position 0 and/or roll back 1–2 recently-typed characters (e.g. nums[i]nums[])
  • Introduces a stored childDirty boolean + CM6 updateListener as the authoritative "child has unsynced typing" sentinel, replacing the fragile recentlyFlushed(200) timestamp heuristic
  • Adds IME composition awareness so that Chinese/Japanese/Korean Pinyin candidate-menu pauses (up to 4 s) keep childDirty = true through the full composition window
  • Adds a sliding-window safety timer (hard-linked TTL) to bound the maximum childDirty hold time even if compositionend never fires
  • Cleans up dead code: removes the recentlyFlushed gate and unneeded syncHandle plumbing; annotates self-originated child dispatches; arms SelfWriteSuppression consistently from fence-body writers

Background

See .planning/debug/widget-cursor-jump-and-char-rollback.md for the full debug workflow record.

Four root causes were identified:

  1. DebouncedWriter resets pending = false before the modify echo fires — both hasPending() clobber gates open during the flush→echo gap
  2. syncHandle gate is permanently dead codethis.syncHandle is never assigned in production; the optional-chain ?.hasPending?.() evaluates undefined !== true silently
  3. reloadFromDisk full-doc replace + line/col clamp — when typed chars don't yet exist on disk, the cursor clamps to a shorter line and typed chars vanish
  4. pushParentToChild catch fallback sets cursor to prefixLen — when LCP starts at byte 0, cursor is slammed to widget position 0

Commits A/B/C on main (quick task 260605-vny) closed the dominant clobber paths via narrow fixes. This branch replaces the recentlyFlushed heuristic at the heart of Fix C with the principled childDirty boolean and adds IME + sliding-timer robustness.

Approach

Wave 1 — prework (3 commits)

  • refactor(widget): remove dead syncHandle gate and field — deletes the permanently-undefined syncHandle field and the structurally-dead gate at liveModeViewPlugin.ts:159
  • refactor(widget): annotate self-originated child dispatches — adds ChildOriginAnnotation so parent-doc listeners can distinguish child-sourced transactions without a full hash comparison
  • refactor(writers): arm SelfWriteSuppression for fence-body writers — ensures every fence-body write path arms suppression before vault.process, closing a secondary echo-miss vector
  • test(probe): empirical modify-event behavior for byte-identical writes — probe test establishing the macrotask-ordering invariant that Waves 3–4 rely on (Pitfall 27)

Wave 2 — derived childDirty accessor (1 commit)

  • refactor(widget): introduce derived childDirty accessor — replaces the recentlyFlushed(200) boolean read-site with a single get childDirty() accessor that encapsulates the sentinel logic; no behavioral change yet, but all call-sites now go through one place

Wave 3 — stored boolean + IME + sliding timer (5 commits)

  • feat(widget): stored childDirty boolean + CM6 updateListenerchildDirty becomes a stored boolean field; a CM6 updateListener sets it true on every non-self-originated child transaction and clears it when markChildClean() is called after a successful flush
  • fix(widget): markChildClean compares LIVE child hash, not snapshot — fixes a subtle race where markChildClean was comparing the snapshot hash captured at flush-start rather than the live child doc, leaving childDirty = false even when the user typed during the flush await
  • feat(widget): IME composition keeps childDirty true through compose window — installs compositionstart/compositionend DOM listeners on the child EditorView's DOM; isComposing flag suppresses markChildClean during the candidate-menu pause
  • feat(widget): sliding-window safety timer + hard-linked TTL — bounds maximum childDirty = true hold to flushDebounceMs * 4 (default ~2 s) via a clearTimeout/setTimeout pair reset on each keystroke; prevents permanent childDirty lock if compositionend is lost (e.g. focus steal, crash recovery)
  • test(widget): regression tests for Wave 3 childDirty fixes — covers: normal typing sets dirty, flush clears dirty, IME window holds dirty, safety timer expires dirty, markChildClean live-hash comparison

Wave 4 — cleanup (2 commits)

  • refactor(widget): remove obsolete recentlyFlushed(200) gate — deletes the lastFlushCompletedAt field and recentlyFlushed() method from DebouncedWriter now that childDirty is authoritative; removes the recentlyFlushed?.(200) call-site in main.ts
  • test(widget): rewrite parentToChildCursorPreservation against childDirty — updates the Plan-21-17-era cursor-preservation test to assert against the childDirty sentinel rather than the removed recentlyFlushed API
  • docs(planning): update PITFALLS.md and CLAUDE.md for Wave 3 design — records the IME composition ordering guarantee and the markChildClean live-hash invariant as permanent pitfall entries

Test plan

  • Build passes (npm run build)
  • Full test suite passes (npm test)
  • Manual: type a long burst in a fence in BRAT dev-vault — no cursor jump, no char rollback
  • Manual: type Chinese via Pinyin IME in a fence — composition draft preserved across 4 s candidate-menu pause
  • Manual: split-pane LP+LP typing in pane B while pane A flushes — pane B not clobbered
  • Manual: external Obsidian Sync edit during typing — ConflictModal opens (not silent-reload)
  • Manual: byte-identical-write probe — run OBSIDIAN_DEV_VAULT_PROBE=1 npm test against real dev-vault, verify modify event fires (Pitfall 27)

Reverts available

Each wave is a clean revert candidate. The 13 commits are organized so:

  • Revert the Wave 4 cleanup commits alone to restore recentlyFlushed as a fallback while debugging childDirty
  • Revert the sliding-timer commit (feat(widget): sliding-window safety timer + hard-linked TTL) alone if the safety timer regresses IME behavior
  • Revert Wave 3 entirely (5 commits) to fall back to the Wave 2 derived-accessor state
  • Waves 1 and 2 are safe to keep regardless — they are cleanups with no behavioral change

The syncHandle?.hasPending?.() gate at liveModeViewPlugin.ts:159 was
documented dead code. mountLeetCodeWidget passes undefined for the
syncExtension parameter, so the field is never assigned — the optional
chain evaluates to undefined, never to true, and the gate never fires.

Removed the field declaration on WidgetController and the dead gate.
Kept syncAnnotation in childParentSync.ts (still used by pushParentToChild).
Tests in parentToChildCursorPreservation.test.ts that assert the dead
gate's behavior are obsolete and scheduled for removal in Wave 4 alongside
the wider test surface refactor.

Preparation for the childDirty refactor (Wave 2/3).
Every plugin-driven dispatch into the child CM6 EditorView now carries
a 'leetcode.*' userEvent so a future updateListener can reliably
distinguish plugin sync from user typing. Specifically the
reloadFromDisk dispatch (silent + keep-external paths) gains
'leetcode.reload'. applyPeerSync and pushParentToChild already had
'leetcode.peer-sync' / 'leetcode.parent-sync'; effects-only and
selection-only dispatches don't trigger docChange and are noted as
intentionally un-annotated.

Preparation for the childDirty refactor (Wave 2/3).
copyToCode and resetCodeWithConfirm both mutate the fence body via
vault.process but did not arm SelfWriteSuppression — their modify
events were treated as external edits and absorbed via silent
reloadFromDisk. This opened a TOCTOU window: between modal close and
vault.process completion (~10-50ms) chars typed by the user could be
clobbered by the silent reload. The writers now arm suppression
atomically before vault.process so the modify echo is recognized as a
self-write, eliminating the TOCTOU window.

Preparation for the childDirty refactor (Wave 2/3).
Adds a probe test that documents and asserts the assumption that
vault.on('modify') fires regardless of whether the bytes being written
match what's already on disk. The Wave 3 childDirty refactor's safety
timer is load-bearing only if this assumption breaks (modify suppressed
on byte-equal writes leaks suppression entries; childDirty would never
clear via handshake). PITFALLS.md documents how to manually verify the
assumption against a real Obsidian dev-vault.

Preparation for the childDirty refactor (Wave 3).
Replaces three duplicated gate predicates (writer.hasPending() at
liveModeViewPlugin push, applyPeerSync entry, modify-handler reload
gate; plus writer.recentlyFlushed(200) at the modify-handler typing-
during-flush branch) with a single named accessor on WidgetController.

childDirty returns the union hasPending() || recentlyFlushed(200),
capturing both the in-flight flush window and the post-flush macrotask
gap before the modify echo arrives. Callers now read controller.childDirty
instead of repeating the predicate.

Semantically corrective at main.ts:1493: replacing writer.hasPending()
with childDirty broadens the conflict-modal gate to include the 200ms
post-flush echo window. A vault.on('modify') arriving during that window
(BRAT 260605-vny race) now routes to conflict-modal-update / open rather
than falling through to silent-reload-with-clamp. Previously: silent
reload with cursor clamp possible. After: conflict modal with user choice.

applyPeerSync (line 502) intentionally left on the narrower
this.writer?.hasPending() === true — broadening peer-sync rejection to
include the 200ms echo window has cross-pane semantics that deserve their
own reasoning; queued as Wave 3.

The 8-char superset heuristic at liveModeViewPlugin.ts stays at the
callsite — it requires the parent body, which the accessor does not have,
and represents a callsite-specific guard rather than a global condition.

Preparation for Wave 3 stored-boolean + IME + sliding timer.
Replaces the derived childDirty accessor with a stored field, set TRUE
in a CM6 updateListener whenever the child receives a docChange whose
userEvent is NOT 'leetcode.*'. Cleared by markChildClean(), called from
the modify-handler on tryConsume === 'consumed'.

The accessor preserves defense-in-depth: childDirty returns true if
EITHER the user typed (stored flag) OR the writer is in flight
(hasPending) OR within the post-flush echo window (recentlyFlushed
200ms). This means a stuck stored flag can't permanently isolate the
child as long as the writer is healthy.

C6b will add live-hash-compare to markChildClean to fix the
snapshot-vs-live-doc race adversarial Lens 1 surfaced.
Adversarial Lens 1 surfaced a snapshot-vs-live-doc race:
markChildClean was called when the writer's SNAPSHOT hash matched the
disk echo, but the live child may have typed past that snapshot
during the vault.process await window. Clearing childDirty in that
state would let a subsequent reload-silent path clobber the unflushed
chars.

Fix: markChildClean now hashes the LIVE childView.state.doc and
compares against observedHash. Only clears _childDirty when they
match. On mismatch the flag stays true; the next flush's echo will
clear it once the live doc has caught up to disk.

This is the BRAT issue #2 single-char-rollback symptom. The previous
recentlyFlushed(200)+superset-≤8 heuristics absorbed it; the stored-
boolean version with snapshot-only handshake re-opened it. Live-hash
compare closes it cleanly.
…indow

Adversarial Lens 2 surfaced an IME composition hazard: CM6 may fire
compositionstart with no further docChanged events for 3-5s while the
user browses Pinyin/Kanji candidate menus. The C6a updateListener
wouldn't fire during that window; C6d's safety timer could clear
childDirty mid-compose; an external sync arriving then would clobber
the in-flight composition.

Adds compositionstart/compositionend DOM event listeners on
view.contentDOM. _childComposing flips true on compositionstart,
false on compositionend. The childDirty accessor ORs in
_childComposing, so an active composition keeps the gate held
regardless of docChange or timer state.

Listeners are removed on widget destroy. happy-dom doesn't fire
native composition events; tests synthesize CompositionEvent via
dispatchEvent.
Adversarial Lens 2/3 fix: when a stuck _childDirty entry never gets
cleared via the modify-echo handshake (idempotent write, vault.process
throws, file deleted, etc.), a safety setTimeout force-clears it. The
naive "fire once after 2s from first docChange" approach breaks
continuous typing — at t=2001 with the user still typing, the flag
clears while the writer still has pending edits, and a concurrent
external sync at t=2001 routes to silent-reload and wipes the buffer.

Fix: re-arm the safety timer on EVERY docChange (sliding window). A
fresh keystroke at t=1900 cancels the t=0 timer and arms a new one
ending at t=3900. Continuous typing keeps the flag alive indefinitely;
only a 2s pause-after-typing without an echo arriving clears it.

TTL is hard-linked to SelfWriteSuppression's TTL_MS via a single
exported constant. A test invariant asserts the two values cannot
drift in maintenance.

Cleanup on destroy clears the timer. markChildClean clears the timer
on successful echo to avoid leaking a fire after the flag is already
clean.

This concludes Wave 3. The stored boolean is now coordinated with:
- 'leetcode.*' userEvent annotations (Wave 1 / C3)
- live-hash compare (C6b)
- IME compose listeners (C6c)
- sliding safety timer (C6d)
- hasPending + recentlyFlushed defense-in-depth (in the accessor)
Adds 5 regression tests covering the adversarial findings that drove
Wave 3's revised design:

1. Snapshot-vs-live-hash (Lens 1): markChildClean doesn't clear
   childDirty when live child has typed past the writer's snapshot.
2. Sliding-window safety timer (Lens 2): continuous typing re-arms
   the safety timer; only a quiet pause clears.
3. IME composition (Lens 2): compositionstart holds childDirty true
   for the entire compose window regardless of timer or docChange
   activity.
4. updateListener provenance: user-input docChanges set childDirty;
   plugin-driven 'leetcode.*' echoes do not.
5. Hard-linked TTL invariant: WIDGET_DIRTY_SAFETY_TTL_MS is locked to
   SelfWriteSuppression.TTL_MS — fails CI if they drift.

Each test fails on the naive design and passes on the revised design.
The recentlyFlushed gate was a 260605-vny-era heuristic that
recognized "child holds typing the post-flush echo hasn't absorbed
yet". Wave 3 stored childDirty + live-hash markChildClean +
sliding-window safety timer now cover this window cleanly without the
heuristic. Deleting reduces production gate count and removes one
overlapping signal.

Verified: full test suite passes with the gate removed. The retained
defense-in-depth in childDirty (writer.hasPending()) plus the new
stored-boolean signal cover the cases recentlyFlushed used to catch.
Removes 3 obsolete tests that asserted the now-deleted syncHandle gate
behavior. Rewrites the symptom test (typing-during-flush rollback) to
drive childDirty via a child input transaction rather than constructing
the divergent doc statically — exercises the actual production gate
the user sees. Drops the syncHandle field from FakeWidget.

Net diff matches the workflow estimate: ~116 lines deleted, ~40 lines
rewritten.
PITFALLS.md gains entries 28-30 (snapshot-vs-live-hash, IME
composition window, sliding-window TTL). CLAUDE.md Architecture
section now describes the stored childDirty + live-hash + IME-aware
design that replaced the prior recentlyFlushed/superset heuristics.

Closes the Wave 1-3 documentation TODO list.
@LikeSundayLikeRain
LikeSundayLikeRain merged commit 8a56d9e into main Jun 10, 2026
1 check passed
@LikeSundayLikeRain
LikeSundayLikeRain deleted the fix/childdirty-typing-races branch June 10, 2026 14:58
LikeSundayLikeRain added a commit that referenced this pull request Jun 12, 2026
Promote 1.3.1 from beta (1.3.1-beta.3) to stable. Code is unchanged
from 1.3.1-beta.3 — only docs/chore commits differ.

- manifest.json / package.json / package-lock.json -> 1.3.1
- versions.json: add 1.3.1 -> minAppVersion 1.12.7 (CI does not patch this)
- CHANGELOG.md: backfill [1.3.0] (inline-widget milestone) and
  [1.3.1] (#24-#28) sections

All 5 BRAT beta items resolved. CI green on the promoted commit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant