`#showPopover()` の非同期処理にアボートガードを追加してエディタフリーズを修正
# を入力後、遅延ソースの読み込み完了前にスペースを押すとエディタが操作不能になるレースコンディションを修正しました。#showPopover() の各 await 後にアボートチェックを挿入することで、ポップオーバーが非表示になった後も処理が継続する問題を解消しています。
背景
# トリガーを持つプロンプトが遅延ソース(src 属性によるリモートフェッチ)を使用する場合、#showPopover() の処理中にユーザーがスペースを押すと致命的な問題が発生していました。Markdownの見出しショートカットが # をh1に変換してテキストを消費しますが、#showPopover() はその非同期処理を継続し、最終的に COMMAND_PRIORITY_CRITICAL でキーリスナーを登録してしまいます。これにより、以降のすべてのキー入力がブロックされ、エディタが操作不能になっていました。
問題の根本は、非同期の #showPopover() が #hidePopover() の呼び出しを検知する手段を持っていなかった点にあります。await をまたぐとその間に状態が変化しても、処理を継続するか中断するかを判断できませんでした。
技術的な変更
世代カウンタによるアボートガードを #showPopover() と #hidePopover() に追加しました。
LexicalPromptElement のコンストラクタに showPopoverId フィールド(初期値 0)を追加し、#showPopover() が呼ばれるたびにカウンタをインクリメントしてその値をキャプチャします。その後、各 await の直後でキャプチャした値と現在のカウンタを比較し、一致しなければ処理を中断します。
async #showPopover() {
const showId = ++this.showPopoverId
this.popoverElement ??= await this.#buildPopover()
if (this.showPopoverId !== showId) return
this.#resetPopoverPosition()
await this.#filterOptions()
if (this.showPopoverId !== showId) return
this.popoverElement.classList.toggle("lexxy-prompt-menu--visible", true)
this.#selectFirstOption()
// ...
}
#hidePopover() は呼ばれると showPopoverId をインクリメントするだけです。これにより、進行中の #showPopover() は次の await 後のチェックでキャプチャした showId との不一致を検知し、キーリスナーの登録など後続の処理を実行せずにリターンします。
async #hidePopover() {
this.showPopoverId++
this.#clearSelection()
// ...
}
さらに、トリガーチェックの await 後にもガードを追加しています。#showFilteredOptions() の完了後、Markdownショートカットなどによってトリガー文字列がすでに消費されていれば #hidePopover() を呼んで早期リターンします。
if (this.#editorContents.containsTextBackUntil(this.trigger)) {
await this.#showFilteredOptions()
// Re-check after async operation — the trigger may have been consumed
// (e.g. markdown heading shortcut converted "# " to h1 during the fetch)
if (!this.#editorContents.containsTextBackUntil(this.trigger)) {
this.#hidePopover()
return
}
await nextFrame()
this.#positionPopover()
}
設計判断
世代カウンタ(generation counter)パターンが採用されています。キャンセル可能な非同期処理には AbortController を使う手法もありますが、ここでは整数カウンタによる比較という軽量なアプローチが選ばれています。#showPopover() の呼び出しを重ねた場合(例: 素早い再トリガー)も、最新の呼び出しだけが有効な showId を持つため、古い呼び出しは自然に無効化されます。
キャンセルのシグナルを渡す代わりに、#hidePopover() がカウンタをインクリメントするだけで済む点は、#showPopover() の呼び出し元を変更せずに済むという利点があります。既存のインターフェースをそのままに、await をはさむ各チェックポイントで状態確認を挿入するだけという、最小限の変更で修正が完結しています。
新設されたテストは test/browser/tests/prompts/ ディレクトリに配置され、遅延フェッチのある設定(markdown_heading_shortcut.test.js)とリモートフィルタリングありの設定(markdown_heading_remote_filter_prompt.test.js)の両方をPlaywrightでカバーしています。テストでは page.route() によるネットワークインターセプトで意図的に50〜75msの遅延を挿入し、ユーザーが先にスペースを押してもエディタが引き続き正常に入力を受け付けることを検証しています。
まとめ
非同期処理の「キャンセル後も継続する」という古典的なレースコンディションを、世代カウンタという軽量なパターンで解消した変更です。各 await の直後に1行のチェックを加えるだけで、ポップオーバーの状態遷移と非同期処理のライフサイクルが一致するようになり、エディタの入力不能という致命的な症状が防がれます。