Turboキャッシュ前にプロンプトポップオーバーを除去しヒストリーナビゲーションのオーファンを防止
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 を根本的に解消しました。エディタと同様のリセットロジックを共有する設計により、既存機能への影響を最小限に抑えつつ堅牢性を向上させています。