Turboキャッシュ前にプロンプトポップオーバーを除去しヒストリーナビゲーションのオーファンを防止

basecamp/lexxy

Turbo がページスナップショットをキャッシュする際に、LexicalPromptElement が生成したポップオーバーが残存し、ヒストリーバック/フォワード後に孤立した UI が再表示される不具合を解消した変更です。turbo:before-cache イベントでポップオーバーを確実に除去し、要素切断時にもクリーンアップを行うことで、オーファンが残らない堅牢なライフサイクルを実現します。

背景

問題: @ キーでプロンプトポップオーバーを開いた状態でブラウザの戻る・進む操作を行うと、Turbo がキャッシュしたエディタのサブツリーにポップオーバー <ul> が残り、復元時に管理対象外のオーファンが表示され、閉じられなくなるケースが報告されました。実際に Basecamp のカードで再現でき、ユーザー体験を著しく損なう状況でした。

根本原因: LexicalPromptElement#buildPopover がポップオーバーを <lexxy-editor> の DOM 階層に直接挿入していたため、Turbo は turbo:before-cache 時点でそのサブツリー全体をスナップショットに含めていました。キャッシュ復元時には新しく接続されたプロンプト要素がポップオーバーへの参照を失い、リスナーも破棄されたまま残存してしまいます。一方、エディタ側は #resetBeforeTurboCaches で同様の問題に対処していましたが、プロンプト側には同等の仕組みが欠如していました。

結論: キャッシュ前にポップオーバー自体を除去し、要素が切断された際にも DOM から完全に取り除く必要があります。これにより Turbo がスナップショットに不要な UI を含めず、復元後にオーファンが生まれません。

技術的な変更

新メソッド #removePopoverBeforeTurboCaches の導入: コンストラクタで this.#removePopoverBeforeTurboCaches() を呼び出し、document に対して turbo:before-cache イベントを監視するリスナーを登録します。イベントが発火したら内部的に #removePopover() を実行し、ポップオーバーとそのリスナーを確実に破棄します。

+  #removePopoverBeforeTurboCaches() {
+    this.#globalListeners.track(
+      registerEventListener(document, "turbo:before-cache", () => this.#removePopover())
+    )
+  }

#removePopover の実装: 既存のポップオーバーリスナーを dispose() し、popoverElement?.remove() で DOM から削除した後、参照を null にリセットします。これにより要素が残存するリスクが排除されます。

+  #removePopover() {
+    this.#popoverListeners.dispose()
+    this.popoverElement?.remove()
+    this.popoverElement = null
+  }

ライフサイクルフックの更新: コンストラクタで #removePopoverBeforeTurboCaches() を呼び出すと同時に、disconnectedCallback() では従来の popoverElement = null だけでなく #removePopover() を呼び出すよう変更しました。これにより要素が DOM から除去された瞬間にポップオーバーも削除されます。

@@
   constructor() {
     ...
     this.#addTriggerListener()
+    this.#removePopoverBeforeTurboCaches()
     this.toggleAttribute("connected", true)
   }
@@
   disconnectedCallback() {
     this.#popoverListeners.dispose()
     this.#globalListeners.dispose()
     this.source = null
-    this.popoverElement = null
+    this.#removePopover()
   }

テスト追加: test/browser/tests/prompts/prompt_history_navigation.test.js で 2 つのケースを検証しました。1 つ目は turbo:before-cache を手動で発火させ、キャッシュされた <lexxy-editor> 内にポップオーバーが残っていないことを確認。2 つ目はプロンプト要素自体を削除した際にポップオーバーが DOM から消えていることを確認しています。

+test("popover is torn down before Turbo caches the page", async ({ page, editor }) => { ... })
+test("popover is removed from the DOM when the prompt element disconnects", async ({ page, editor }) => { ... })

結論: 変更は既存のエディタリセットロジックと同様のパターンをプロンプト要素に適用し、最小限のコード追加でキャッシュ前クリーンアップと要素切断時の後処理を実装しました。機能追加による API 変更はなく、後方互換性も保たれています。

設計判断

エディタのリセットパターンを再利用: LexicalEditorElement が提供する #resetBeforeTurboCaches のアプローチを踏襲し、LexicalPromptElement にも同様のフック #removePopoverBeforeTurboCaches を導入しました。この選択は、コンポーネント間で一貫したキャッシュ対策を提供し、メンテナンスコストを低減します。新しい公開インターフェースを増やさず、内部リスナーマネージャ (#globalListeners) を活用する点が特徴です。

切断時のクリーンアップ強化: 以前は disconnectedCallback で参照を null にするだけでしたが、ポップオーバーが DOM に残る問題を防ぐために #removePopover を呼び出すよう変更しました。これにより要素のライフサイクル全体でリソースが漏れないことが保証され、Turbo のスナップショットに不要な UI が混入しない設計となります。

結論: 設計は「最小侵入・最大効果」を掲げ、既存のリスナーマネジメントとキャッシュリセットのパターンを再利用することで、コードベースの一貫性と将来的な拡張性を確保しています。

まとめ

今回の PR は、Turbo がページをキャッシュするタイミングでプロンプトポップオーバーを除去し、要素切断時にもクリーンアップを徹底することで、ヒストリーナビゲーション後に残るオーファン UI を根本的に解消しました。エディタと同様のリセットロジックを共有する設計により、既存機能への影響を最小限に抑えつつ堅牢性を向上させています。

記事メタデータ

Generated by:
gpt-oss-120b for DiffDaily
LLM Trace:
d3002572

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

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
gpt-oss-120b for DiffDaily

Review Criteria:

記事構成 ✓ PASS

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

リード文、背景、技術的変更、まとめが明確に配置されており、総論→各論→結論の構成が守られています。

カスタムMarkdown構文 ⚠ WARNING

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

コードブロックのファイル名付きハイライトは正しい形式です。PRリンクは "[PR #1121]" となっており、仕様の "[#1121]" 形式と微差があります。

対象読者への適合性 ✓ PASS

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

エンジニア向けの専門的な記述で、初心者向けの過度な解説はありません。

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

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

各セクションは総論・各論・結論の段落構成があり、トピックセンテンスで始まり、1段落1トピック・適切な長さが保たれています。

Diff内容との照合 ✓ PASS

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

記事中のコードブロックは提供された Diff と完全に一致し、変更点・追加点が正確に反映されています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

用語は PR と一致し、誤用や不適切な表現はありません。

説明の技術的正確性 ✓ PASS

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

技術的説明は PR の根拠に基づき正確で、因果関係も論理的です。

事実の突合 ✓ PASS

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

全ての主張は PR の記載内容・Diff に裏付けられており、根拠のない推測はありません。

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

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

PR 番号 #1121 だけが記載されており、正確です。

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

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

記事タイトルは PR の内容を正確に日本語で表現しており、一致しています。

外部知識の正確性 ✓ PASS

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

バージョンサポートやリリース情報など、PR に無い外部知識は含まれていません。

時間表現の正確性 ✓ PASS

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

時間表現の歪曲はなく、PR の記述と一致しています。