Skip to content

fix: chevron language switch (highlighting + body swap + conflict-modal regression chain) - #25

Merged
LikeSundayLikeRain merged 8 commits into
mainfrom
fix/chevron-language-switch
Jun 10, 2026
Merged

fix: chevron language switch (highlighting + body swap + conflict-modal regression chain)#25
LikeSundayLikeRain merged 8 commits into
mainfrom
fix/chevron-language-switch

Conversation

@LikeSundayLikeRain

Copy link
Copy Markdown
Owner

Summary

User report: clicking the language chevron showed cosmetic changes (icon + lc-language frontmatter) but the fence body never updated, syntax highlighting stayed frozen on the old language, and a few iterations later the "External edit detected" ConflictModal started firing on every switch.

This PR is the full fix chain. Each commit addresses a distinct root cause uncovered as the previous one revealed the next layer:

  • 537f177 fix(widget): re-render semantic classes on language-facet changeobsidianSemanticClasses ViewPlugin only recomputed cm-keyword/cm-type/etc. on docChanged || viewportChanged || geometryChanged. A Compartment.reconfigure is effects-only, so cached OLD-language classes stuck to tokens until the next keystroke. Added update.startState.facet(language) !== update.state.facet(language) to the recompute predicate.

  • ddd77d6 feat(widget): restore v1.2 starter-snippet body swap on language change — v1.3 deliberately dropped v1.2's body swap; users perceived this as a regression. Added runLanguageSwitch (16-step helper, unit-testable), resolveStarterCode ({code, reason: 'ok' | 'network' | 'unavailable'}), applyAuthoritativeBodyAndFrontmatter (atomic body+fm primitive with multi-pane fan-out), dispatchAuthoritativeBodySwap on WidgetController (single CM6 transaction: body change + parser reconfigure + userEvent: 'leetcode.lang-switch').

  • 24469b5 refactor(widget): chevron language switch is always-overwrite (drop preserve-on-dirty gate) — original implementation used an instrumentation-first cleanness gate (childDirty + hasEverBeenDirtySinceMount + body-vs-oldStarter). User feedback after dogfooding: confusing — once any character was typed, switching showed a Notice instead of swapping. Dropped the gate per v1.2 semantics. Per-language buffer preservation is parked as a future enhancement.

  • 4633597 + ffc592d + dc58cdc (the conflict-modal hunt) — chevron switch started tripping the ConflictModal. Workflow-driven debug session with diagnostic logging identified two real issues:

    1. Disk write was silently skipped. dispatchAuthoritativeBodySwap carries userEvent: 'leetcode.lang-switch', which the childDirtyExtension filters — so _childDirty stayed false, DebouncedWriter had nothing to flush, and widget.flushNow() returned 1ms after dispatch without writing anything. processFrontMatter then wrote only frontmatter; the modify-handler read the OLD body (extLen=828 Java vs mineLen=304 new Python) and trip the modal at main.ts:1573.

    2. Hash domain mismatch. The helper armed suppression with sha1(newBody) but the modify-handler computed sha1(extractFenceBody(disk, fenceIndex)). Any whitespace / line-ending normalization in rewriteFenceBody made these diverge.

    Fix (dc58cdc): replace widget.flushNow() with explicit app.vault.process(file, current => rewriteFenceBody(current, fenceIndex, newBody))vault.process is now the load-bearing disk write. Hash via the same pipeline DebouncedWriter and the modify-handler use: sha1(extractFenceBody(rewriteFenceBody(disk, idx, newBody))). Both modify echoes (body write + frontmatter rewrite) now match the modify-handler's gates.

  • 6f3899e chore(widget): strip [lc-debug] diagnostics — clean shipping commit, removes the 7 lang-switch:* and 2 modify-handler diagnostic log lines + peekMapDebug accessor added during the hunt.

End-to-end behavior after this PR

  • Click chevron → starter snippet for the new language replaces the fence body in a single CM6 transaction (one undo step).
  • Highlighting follows the new parser via the language-facet detector.
  • Multi-pane: every editable peer widget on the same file receives the same dispatch directly (no peer reload-from-disk).
  • IME composition: switch defers to compositionend (no parser reconfigure mid-CJK).
  • Read-only / embed widgets: fm-only write, no body swap.
  • Same-slug: short-circuit no-op.
  • New language has no LC starter snippet OR offline with empty cache: differentiated Notice copy, fm-only write proceeds.

Test plan

  • npm run build — zero TS errors, esbuild bundle produced.
  • npx vitest run2943 passed, 0 failed, 8 skipped across 254 test files.
  • Dogfooded in interview-prep vault: confirmed body swap + highlighting follow + no conflict modal across multiple switches (Java ↔ Python ↔ C++).
  • New tests:
    • tests/main/childEditorSemanticClasses.languageReactivity.test.ts — pins the language-facet recompute predicate (fails without the fix).
    • tests/widget/languageSwitch.test.ts — 9 tests against real runLanguageSwitch (Pattern F, same-slug, read-only, body+parser swap, IME deferral, network/unavailable Notice differentiation, multi-pane fan-out).
    • tests/widget/applyAuthoritativeBodyAndFrontmatter.test.ts — 7 helper tests covering arm-before-dispatch ordering, peer fan-out, error-path suppression cleanup, currentDocHash pre-update.
    • tests/solve/resolveStarterCode.test.ts — 7 tests covering every reason branch.

obsidianSemanticClasses ViewPlugin only recomputed cm-keyword/cm-type/etc.
decorations on docChanged || viewportChanged || geometryChanged. A
languageCompartment.reconfigure(...) dispatch is effects-only and trips
none of those flags, so the cached OLD-language class assignments stuck
to tokens until the next keystroke and Obsidian-theme rules cascaded
the wrong colors.

Compare update.startState.facet(language) vs update.state.facet(language)
to detect parser swaps from a Compartment.reconfigure and force a
re-walk of the freshly-parsed tree. Idiomatic CM6 — same predicate the
@codemirror/language facet exists for.

Includes regression test that mounts a real EditorView with a Python
language Compartment, dispatches reconfigure(java()), and asserts the
keyword decoration count changes. Test fails without the predicate fix.

Closes Failure A in .planning/debug/language-switch-body-not-swapped.md.
Failure B (restore v1.2 starter-snippet body swap on language change)
is a separate behavior change tracked for follow-up.
The chevron-driven language switch in v1.3 only wrote `lc-language`
frontmatter. Users coming from v1.2 saw the chevron + frontmatter update
but their fence body stayed untouched — perceived as a regression even
though it was deliberate. With Failure A landed (537f177), highlighting
now follows the new parser, but the body itself still didn't swap.

Restore v1.2's swap-to-starter behavior with v1.3-shaped semantics:

- runLanguageSwitch (src/main/runLanguageSwitch.ts): the 16-step
  algorithm extracted as a pure helper so it can be unit-tested without
  instantiating LeetCodePlugin. switchLanguageFromWidget becomes a thin
  shim that binds deps and delegates.

- resolveStarterCode (src/solve/resolveStarterCode.ts): cache-first then
  live-fetch with a {code, reason: 'ok' | 'network' | 'unavailable'}
  contract. Differentiates "offline" from "LC has no starter for this
  language on this problem" so the Notice copy can be actionable.

- applyAuthoritativeBodyAndFrontmatter
  (src/widget/applyAuthoritativeBody.ts): the combined body+fm primitive.
  Arms SelfWriteSuppression with the originator's registryKey + sha1 of
  the new body BEFORE dispatching, fans the dispatch out to peer widgets
  via collectPeerWidgets, then runs processFrontMatter, then acknowledges
  on every widget. Throw path clears the suppression entry.

- dispatchAuthoritativeBodySwap (WidgetController): single CM6
  transaction carrying both `changes` (new body text) AND
  `effects: [Compartment.reconfigure(...)]` so body + parser swap
  atomically — no visible smear of new text under the old parser.
  Carries `userEvent: 'leetcode.lang-switch'` so the section-protection
  extension and DebouncedWriter recognize it as plugin-originated.

- hasEverBeenDirtySinceMount latch (WidgetController): sticky bit set on
  first markChildDirty since mount, cleared only on reloadFromDisk or
  acknowledgeAuthoritativeBody. Catches "user pasted the OLD starter"
  so byte-equal-to-old-starter alone never triggers a swap on a widget
  that has been edited.

UX rules:

- Clean fence + new starter available -> swap body + parser atomically,
  fan out to peer widgets.
- Clean fence + new starter unavailable -> fm-only write, Notice copy
  differentiated by reason ('offline' vs 'no LC starter').
- Dirty fence -> preserve user code, fm-only write, Notice with
  Cmd-Shift-P breadcrumb pointing at the LeetCode: Reset code command.
- IME composition active -> defer the entire switch to compositionend
  via a one-shot listener; never reconfigure the parser mid-CJK menu.
- Read-only / embed widget -> fm-only write, no body swap.

Tests:

- tests/widget/languageSwitch.test.ts (rewritten): 11 tests against the
  real runLanguageSwitch helper covering Pattern F flush-before-fm,
  same-slug short-circuit, read-only guard, clean swap, dirty preserve,
  manual-paste-of-old-starter, race-window typing during cache-miss,
  IME composition deferral with compositionend re-entry,
  network/unavailable Notice differentiation, multi-pane fan-out.

- tests/widget/applyAuthoritativeBodyAndFrontmatter.test.ts: arm-before-
  dispatch ordering, peer fan-out, error-path suppression cleanup.

- tests/solve/resolveStarterCode.test.ts: 7 reason-code branches.

Suite: 2945 passed, 0 failed, 8 skipped (254 test files).

Pitfalls 31-36 documented in .planning/research/PITFALLS.md.
CLAUDE.md Architecture section augmented with the language-switch flow.
Debug session resolved at .planning/debug/resolved/language-switch-body-not-swapped.md.
…reserve-on-dirty gate)

User feedback after dogfooding: the preserve-on-dirty path was confusing.
Once the user typed any character, switching language showed a Notice
("Cmd-Shift-P > LeetCode: Reset code...") instead of swapping the body —
they had to chain two actions to get the new starter. The v1.2 contract
was simpler: switching language always loads the new starter, full stop.

Drop the cleanness gate (childDirty + hasEverBeenDirtySinceMount + body-vs-
oldStarter comparison) and the DIRTY branch entirely. The chevron click is
a destructive action with the same semantics as Reset for the new language.

The hasEverBeenDirtySinceMount latch on WidgetController stays in place —
it's harmless when unused, and the planned per-language buffer feature
(switch-back-restores-typing) is a natural fit for it.

resolveStarterCode no longer needs to fetch the OLD starter (we never
compare against it); we resolve only the new language. extractFenceBody-
FromFullNote drops out of the LanguageSwitchDeps shape and the production
shim's import.

Tests: drop the three obsolete describe blocks (dirty branch, manual-paste-
of-old-starter, race-window typing). Remaining 9 tests still pin Pattern F
flush-before-fm, same-slug short-circuit, read-only guard, body+parser
swap, IME composition deferral with compositionend re-entry,
network/unavailable Notice differentiation, and multi-pane fan-out.
2942 passed, 0 failed, 8 skipped.
… language switch

User reported the ConflictModal ("External edit detected") firing during
the chevron language switch — the body swapped, but a moment later the
modal would pop up.

Root cause: applyAuthoritativeBodyAndFrontmatter does TWO disk writes —
(a) widget.flushNow() writes the new body, (b) processFrontMatter writes
the new lc-language frontmatter. Each fires a separate modify event in
the modify-handler. The first arm-before-dispatch consumed the body
write's echo, but the fm-write echo arrived with no entry left in the
suppression map and fell through to the conflict modal at branch (d).

The Pitfall P2 early-return (currentDocHash === observedHash) doesn't
catch this because acknowledgeAuthoritativeBody only updates
currentDocHash AFTER processFrontMatter resolves — the fm-write modify
fires while currentDocHash is still on the OLD body hash.

Fix: re-arm suppression with the same hash (the fence body is unchanged
across the fm-write — only frontmatter mutates) AFTER flushNow but
BEFORE processFrontMatter. Both modify echoes now land inside their own
suppression TTL window.

Test (7) in applyAuthoritativeBodyAndFrontmatter.test.ts pins the
re-arm contract: two arm calls in strict order arm → flushNow → arm →
processFrontMatter, both with the same hash + originator registryKey.
Updated tests (1) and the languageSwitch clean-swap + multi-pane fan-out
tests to reflect arm count = 2 instead of 1.

Suite: 2943 passed, 0 failed, 8 skipped. Build clean.
…oth modify events via Pitfall P2

User saw the ConflictModal still firing after the previous re-arm fix.

Root cause: applyAuthoritativeBodyAndFrontmatter fires TWO modify events
on the same path — one from widget.flushNow (body write) and one from
processFrontMatter (fm rewrite). The SelfWriteSuppression map is keyed
by path with single-entry-per-key semantics, so the first tryConsume
drops the entry; whichever modify event lands second falls through to
branch (d) and trips the conflict modal regardless of how many times we
arm.

Re-arming twice didn't fix it because Obsidian batches modify events
into the same microtask queue and the relative ordering of the body-
write echo vs the fm-write echo isn't guaranteed — whichever runs
second after both arms have been consumed loses.

The right gate is the modify-handler's existing Pitfall P2 early-return
at src/main.ts:1352-1358 — if currentDocHash === observedHash, the
fence body is unchanged from what the widget last knew about, so the
modify is treated as a self-write absorption regardless of suppression
map state. The fix sets currentDocHash to the post-write hash on every
widget BEFORE the writes start. The fence body is unchanged across both
writes (only frontmatter mutates between them) so both modify events
hit P2 absorption.

The single suppression.arm before dispatch stays in place — it's still
the canonical signal for the body-write echo and feeds the peer-sync
routing via peekOriginator. P2 is the belt-and-suspenders gate that
catches the fm-write modify when the suppression map has been consumed.

Test (7) in applyAuthoritativeBodyAndFrontmatter.test.ts pins the
contract: currentDocHash on originator + every peer is updated to
sha1(newBody) BEFORE dispatch / flushNow / processFrontMatter run.

Suite: 2943 passed, 0 failed, 8 skipped.
…as silently skipping disk write)

Diagnostic logs from a real switch revealed that the body was never
landing on disk: lang-switch:flushNow-resolved fires 1ms after dispatch
because dispatchAuthoritativeBodySwap carries userEvent
'leetcode.lang-switch' which the childDirtyExtension filters — so
_childDirty stays false and DebouncedWriter.flush has nothing to write.
processFrontMatter then writes only frontmatter; the modify-handler
reads the OLD body off disk; observedHash doesn't match currentDocHash
(304-byte new Python starter vs 828-byte old Java solution); P2 misses,
suppression misses, conflict modal opens.

Replace widget.flushNow() with an explicit app.vault.process call that
applies rewriteFenceBody atomically. The dispatch still drives the
visible CM6 swap (instant, single undo step); vault.process is now the
load-bearing disk write.

Also fix the hash domain so the modify-handler's gates match. The
modify-handler computes observedHash via
sha1(extractFenceBody(disk, fenceIndex)). The helper now hashes via the
same pipeline:
  futureFullText = rewriteFenceBody(currentDisk, fenceIndex, newBody)
  futureFenceBody = extractFenceBody(futureFullText, fenceIndex)
  expectedHash = sha1(futureFenceBody)
This matches DebouncedWriter.flush's arming exactly so any whitespace /
line-ending normalization rewriteFenceBody does is reflected in our
hash — closing the divergence that bypassed both the suppression match
AND P2 absorption when newBody had a quirky byte boundary.

Tests updated:
- FakeWidget gains fenceIndex
- FakeApp gains vault.read + vault.process fakes
- Test (4) ordering vault.process → processFrontMatter (was flushNow → processFrontMatter)
- Test (7) currentDocHash pre-update visible at vault.process site

Pitfall 37: chevron switch's body+fm sequence has TWO modify events;
the body write must use vault.process so the disk actually changes,
and both echo hashes must match the modify-handler's pipeline.

Suite: 2943 passed, 0 failed, 8 skipped.
…al hunt

The instrumentation served its purpose — runtime logs identified
applyAuthoritativeBodyAndFrontmatter's missing disk write and the hash-
domain mismatch (commit dc58cdc fixed both). Strip the seven lang-switch:*
log lines from applyAuthoritativeBody.ts, the modify:branch=p2-absorbed
+ modify:p2-miss + verbose mapKeys/mapHashes/mapAtModalOpen fields from
src/main.ts, and the peekMapDebug accessor from selfWriteSuppression.ts.

Restores the pre-debug log shape: [lc-debug] modify:enter,
[lc-debug] conflict:open with a tighter payload, and the bare
modify:branch=conflict-modal-open line. These existed before this
debug session and remain in place.
CI lint failures:
- src/main/runLanguageSwitch.ts:84 — `as string` assertion redundant
  after the `typeof === 'string'` narrowing on the previous line.
- tests/widget/languageSwitch.test.ts:433 — eslint-plugin-obsidianmd
  prefers window.setTimeout over global setTimeout for popout-window
  compatibility (project lint rule).
@LikeSundayLikeRain
LikeSundayLikeRain merged commit 344f553 into main Jun 10, 2026
1 check passed
@LikeSundayLikeRain
LikeSundayLikeRain deleted the fix/chevron-language-switch branch June 10, 2026 21:02
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