fix: cursor-jump and char-rollback during widget typing - #24
Merged
Conversation
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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
nums[i]→nums[])childDirtyboolean + CM6updateListeneras the authoritative "child has unsynced typing" sentinel, replacing the fragilerecentlyFlushed(200)timestamp heuristicchildDirty = truethrough the full composition windowchildDirtyhold time even ifcompositionendnever firesrecentlyFlushedgate and unneededsyncHandleplumbing; annotates self-originated child dispatches; armsSelfWriteSuppressionconsistently from fence-body writersBackground
See
.planning/debug/widget-cursor-jump-and-char-rollback.mdfor the full debug workflow record.Four root causes were identified:
DebouncedWriterresetspending = falsebefore the modify echo fires — bothhasPending()clobber gates open during the flush→echo gapsyncHandlegate is permanently dead code —this.syncHandleis never assigned in production; the optional-chain?.hasPending?.()evaluatesundefined !== truesilentlyreloadFromDiskfull-doc replace + line/col clamp — when typed chars don't yet exist on disk, the cursor clamps to a shorter line and typed chars vanishpushParentToChildcatch fallback sets cursor toprefixLen— when LCP starts at byte 0, cursor is slammed to widget position 0Commits A/B/C on
main(quick task260605-vny) closed the dominant clobber paths via narrow fixes. This branch replaces therecentlyFlushedheuristic at the heart of Fix C with the principledchildDirtyboolean and adds IME + sliding-timer robustness.Approach
Wave 1 — prework (3 commits)
refactor(widget): remove dead syncHandle gate and field— deletes the permanently-undefinedsyncHandlefield and the structurally-dead gate atliveModeViewPlugin.ts:159refactor(widget): annotate self-originated child dispatches— addsChildOriginAnnotationso parent-doc listeners can distinguish child-sourced transactions without a full hash comparisonrefactor(writers): arm SelfWriteSuppression for fence-body writers— ensures every fence-body write path arms suppression beforevault.process, closing a secondary echo-miss vectortest(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
childDirtyaccessor (1 commit)refactor(widget): introduce derived childDirty accessor— replaces therecentlyFlushed(200)boolean read-site with a singleget childDirty()accessor that encapsulates the sentinel logic; no behavioral change yet, but all call-sites now go through one placeWave 3 — stored boolean + IME + sliding timer (5 commits)
feat(widget): stored childDirty boolean + CM6 updateListener—childDirtybecomes a storedbooleanfield; a CM6updateListenersets ittrueon every non-self-originated child transaction and clears it whenmarkChildClean()is called after a successful flushfix(widget): markChildClean compares LIVE child hash, not snapshot— fixes a subtle race wheremarkChildCleanwas comparing the snapshot hash captured at flush-start rather than the live child doc, leavingchildDirty = falseeven when the user typed during the flush awaitfeat(widget): IME composition keeps childDirty true through compose window— installscompositionstart/compositionendDOM listeners on the childEditorView's DOM;isComposingflag suppressesmarkChildCleanduring the candidate-menu pausefeat(widget): sliding-window safety timer + hard-linked TTL— bounds maximumchildDirty = truehold toflushDebounceMs * 4(default ~2 s) via aclearTimeout/setTimeoutpair reset on each keystroke; prevents permanentchildDirtylock ifcompositionendis 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 comparisonWave 4 — cleanup (2 commits)
refactor(widget): remove obsolete recentlyFlushed(200) gate— deletes thelastFlushCompletedAtfield andrecentlyFlushed()method fromDebouncedWriternow thatchildDirtyis authoritative; removes therecentlyFlushed?.(200)call-site inmain.tstest(widget): rewrite parentToChildCursorPreservation against childDirty— updates the Plan-21-17-era cursor-preservation test to assert against thechildDirtysentinel rather than the removedrecentlyFlushedAPIdocs(planning): update PITFALLS.md and CLAUDE.md for Wave 3 design— records the IME composition ordering guarantee and themarkChildCleanlive-hash invariant as permanent pitfall entriesTest plan
npm run build)npm test)OBSIDIAN_DEV_VAULT_PROBE=1 npm testagainst real dev-vault, verify modify event fires (Pitfall 27)Reverts available
Each wave is a clean revert candidate. The 13 commits are organized so:
recentlyFlushedas a fallback while debuggingchildDirtyfeat(widget): sliding-window safety timer + hard-linked TTL) alone if the safety timer regresses IME behavior