`#showPopover()` の非同期処理にアボートガードを追加してエディタフリーズを修正

basecamp/lexxy

# を入力後、遅延ソースの読み込み完了前にスペースを押すとエディタが操作不能になるレースコンディションを修正しました。#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行のチェックを加えるだけで、ポップオーバーの状態遷移と非同期処理のライフサイクルが一致するようになり、エディタの入力不能という致命的な症状が防がれます。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
65fe4fca

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文(総論)、背景・技術詳細・設計判断(各論)、まとめ(結論)という3部構成が明確に守られています。各セクションの目的も明確で、読者が内容を段階的に理解しやすい構成です。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きシンタックスハイライト(```javascript:src/elements/prompt.js)の形式、PR番号のリンク記法([PR #881](URL))が正しく使用されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

「レースコンディション」「アボートガード」「世代カウンタ」といった専門用語を適切に使用し、専門知識を持つエンジニア向けに簡潔かつ的確な内容となっています。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクション、各段落がトピックセンテンスで始まり、1段落1トピックの原則が守られています。見出しと各段落の1文目を読むだけで概要が把握できる、非常に可読性の高い文章構成です。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内で引用されているコードブロックは、`src/elements/prompt.js` に追加された `showPopoverId` を利用したアボートガードのロジックを正確に抜粋しており、Diff内容と完全に一致しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「世代カウンタ(generation counter)」「レースコンディション」「アボートガード」などの技術用語が、PRの文脈と一般の用法に沿って正確に使用されています。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

エディタがフリーズする問題の原因(非同期処理の継続)と、世代カウンタを用いた解決策についての説明は、PR DescriptionとDiffのコード変更によって完全に裏付けられており、技術的に正確です。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事内のすべての主張は、PRのDescription、Diffで追加されたコード(本体およびテストコード)の内容に基づいており、PR情報にない推測や創作は見られません。「設計判断」セクションの解説もコードから読み取れる内容で適切です。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号「#881」が正確に記載されています。テストコード内の遅延時間(50-75ms)に関する言及もDiffと一致しています。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事のタイトルはPRの主題「# prompt freezeの修正」をより具体的に「アボートガードの追加」という解決策まで含めて表現しており、内容と完全に一致しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

記事はPRで提供された情報(Title, Description, Diff)の範囲内に留まっており、バージョンのサポート状況やリリース日といった外部知識の追記はありません。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

記事内での時間表現に問題はなく、PRの内容を正確に反映しています。