DSH / Atlas
2026-08-29implementedbug-fixarchived 2026-09-04

The drill claim is published before the edit that re-enters tracking

The drill claim is published before the edit that re-enters tracking

A pointer descent in the `@` menu produced no breadcrumb, while the keyboard descent into the same directory produced one (#3310). Clicking a crumb — the gesture the breadcrumb exists for — dropped the header entirely instead of re-listing the step it named. Rows in a pointer-drilled listing also repeated the parent directory the header was supposed to carry. The three faults are one ordering defect in `InputTriggerC

English

Problem

A pointer descent in the @ menu produced no breadcrumb, while the keyboard descent into the same directory produced one (#3310). Clicking a crumb — the gesture the breadcrumb exists for — dropped the header entirely instead of re-listing the step it named. Rows in a pointer-drilled listing also repeated the parent directory the header was supposed to carry.

The three faults are one ordering defect in InputTriggerController.settle. The drill claim (drilled) was assigned after execute() returned, on the assumption that the input applies a descent edit and re-tracks later. That holds only for the keyboard: KEY_TAB_COMMAND handlers run inside a Lexical update, so SessionInputShell.applyEdit joins the enclosing update and the commit — with the track() call its update listener drives — lands after settle has returned. A pointer mousedown handler is outside any update, so applyEdit runs editor.update(fn, { discrete: true }), which sets _flushSync and commits synchronously; track() therefore re-enters the controller during execute(), and both readers of the claim — refreshHeaders and fetchCandidates — saw it still clear. Every existing test modeled the keyboard ordering: the fake insert listener returned true and the spec re-tracked afterwards by hand, so the pointer ordering was never exercised.

Decision

settle claims the drill before dispatching the edit, and withdraws the claim only when the edit is refused:

this.reduce({ type: 'close' })
this.drilled = action === 'drill'
if (!this.execute(outcome, hit.span)) this.drilled = false

The claim still follows reduce({ type: 'close' }), whose teardown clears it. Withdrawal remains exact because a refused edit mutates nothing and so drives no re-entrant track(): insertText fails its draftRev CAS before touching the editor, and $replaceDetectSpanWithText returns false from selectSpan ahead of $setSelection. The observable guarantee the breadcrumb decision states is unchanged — a header never names a directory nobody descended into — and both descent gestures now reach header and candidates as a drill.

Alternatives considered

Re-publish the header after execute returns. Rejected: it treats the visible half of one defect. fetchCandidates reads the same claim, so the candidate request would still report drilled: false and ui-reference would keep repeating the parent directory on every row of a pointer-drilled listing.

Defer execute to a microtask so the re-entrant track always lands after settle. Rejected: the edit carries hit.span for revision CAS, and postponing it past the current task lets an intervening keystroke invalidate the span, turning a working descent into a silently refused one.

Make applyEdit never flush synchronously. Rejected: discrete is what keeps a programmatic edit and the detect coordinates computed from it in one task; relaxing it to fix a menu flag would loosen the whole input machine's ordering for every caller.

Consequences

  • Tab, the row chevron, and a crumb reach one behavior, so the breadcrumb no longer depends on which gesture opened the listing.
  • Any future state a source reads through header or candidates must be published before execute, because the input can re-enter track() inside it. The claim is instance state on the controller, so the ordering is the only thing enforcing it.
  • Coverage: a controller spec whose insert listener re-tracks synchronously — the pointer ordering — asserts both readers, and reference-composer.e2e.ts asserts the breadcrumb and the trimmed rows after a chevron drill and walks a two-level trail back through a crumb click. The keyboard ordering keeps its existing spec, so a regression that fixes one gesture by breaking the other fails.

中文

Problem

在 @ 菜单里用指针进入目录不产生 breadcrumb,而用键盘进入同一个目录则会产生(#3310)。点击 crumb——breadcrumb 存在的意义所在——不但没有重新列出它所指的那一层,反而让整个 header 消失。指针进入的列表里,每一行还会重复 header 本应承担的父目录。

这三处故障是 InputTriggerController.settle 中的同一个顺序缺陷。drill 声明(drilled)过去在 execute() 返回之后才赋值,前提是输入层稍后才应用下钻编辑并重新 track。该前提只对键盘成立:KEY_TAB_COMMAND 的处理器运行在 Lexical update 内部,SessionInputShell.applyEdit 因此并入外层 update,提交——以及其 update listener 驱动的 track() 调用——落在 settle 返回之后。指针的 mousedown 处理器不在任何 update 内,applyEdit 于是执行 editor.update(fn, { discrete: true }),该选项置起 _flushSync 并同步提交;track() 因此在 execute() 执行期间重入控制器,而声明的两个读取方——refreshHeaders 与 fetchCandidates——看到的仍是未置位的值。既有测试全部按键盘顺序建模:伪造的 insert 监听器只返回 true,由用例事后手工重新 track,指针顺序从未被覆盖。

Decision

settle 在派发编辑之前声明 drill,并且只在编辑被拒绝时撤回:

this.reduce({ type: 'close' })
this.drilled = action === 'drill'
if (!this.execute(outcome, hit.span)) this.drilled = false

声明仍然排在 reduce({ type: 'close' }) 之后,因为后者的清理会把它清掉。撤回依然精确,原因是被拒绝的编辑不做任何变更,因而不会驱动重入的 track():insertText 在碰到编辑器之前就没通过 draftRev CAS,$replaceDetectSpanWithText 也在 $setSelection 之前就从 selectSpan 返回 false。breadcrumb 决策所声明的可观察保证不变——header 绝不会指向一个无人进入过的目录——而两种下钻手势现在都以 drill 的身份抵达 header 与 candidates。

Alternatives considered

在 execute 返回后重新发布 header。 否决:这只处理了缺陷中看得见的那一半。fetchCandidates 读取同一个声明,候选请求仍会报告 drilled: false,ui-reference 也就仍会在指针进入的列表中逐行重复父目录。

把 execute 推迟到 microtask,使重入的 track 必定落在 settle 之后。 否决:该编辑携带 hit.span 用于版本 CAS,把它推迟到当前任务之外,会让插入其间的按键作废该 span,把一次本可成功的下钻变成静默失败。

让 applyEdit 永不同步 flush。 否决:discrete 正是让一次程序化编辑与由它算出的 detect 坐标留在同一个任务内的机制;为了修一个菜单标志而放宽它,会为所有调用方松开整个输入机的顺序保证。

Consequences

  • Tab、行内 chevron 与 crumb 收敛到同一种行为,breadcrumb 不再取决于是哪种手势打开了列表。
  • 今后凡是 source 通过 header 或 candidates 读取的状态,都必须在 execute 之前发布,因为输入层可能在其内部重入 track()。该声明是控制器上的实例状态,顺序是唯一的约束手段。
  • 覆盖:一个 insert 监听器同步重新 track 的控制器用例——即指针顺序——断言两个读取方;reference-composer.e2e.ts 断言 chevron 下钻后的 breadcrumb 与精简后的行,并通过 crumb 点击走完两层路径的回退。键盘顺序保留原有用例,因此「修好一种手势却弄坏另一种」的回归会失败。