アップロード完了時のフォーカス奪取を防ぐ非同期更新タグの制御
Lexicalエディタのアップロードライフサイクルメソッドが非同期に完了した際、ユーザーが別のフィールドにフォーカスを移していても、エディタにフォーカスが戻ってしまう問題を修正しました。SKIP_DOM_SELECTION_TAG の条件付き適用により、エディタがフォーカスを持たない状態での不要なDOM選択の復元を防ぎます。
背景
ActionTextAttachmentUploadNode のアップロードライフサイクルメソッド(進捗更新・完了・エラー処理)は非同期で実行されるため、ユーザーがアップロード中にタイトルフィールドなど別の入力欄へフォーカスを移した後に完了する可能性があります。この状況でLexicalのリコンサイラが動作すると、DOM選択がエディタ内に戻されてしまい、ユーザーの作業を中断させていました。
具体的には、Lexicalの更新処理に SKIP_DOM_SELECTION_TAG を付与しない場合、リコンサイラはDOM選択をエディタ内の状態に同期しようとします。アップロード完了時にノードの置換と選択転送が行われると、エディタ外にあったフォーカスがエディタに引き戻されるという副作用が生じていました。
従来の実装では、アップロード関連の更新すべてに SILENT_UPDATE_TAGS が使われていましたが、このタグにはDOM選択の同期をスキップする効果が含まれておらず、フォーカス奪取を防ぐには不十分でした。
技術的な変更
#backgroundUpdateTags ゲッターを新設し、エディタのフォーカス状態に応じてLexical更新タグを動的に切り替える仕組みを導入しました。
ソースファイル src/nodes/action_text_attachment_upload_node.js の変更内容は以下の通りです。まず lexical からの SKIP_DOM_SELECTION_TAG のインポートが追加されました:
// 変更前
import { $setSelection } from "lexical"
// 変更後
import { $setSelection, SKIP_DOM_SELECTION_TAG } from "lexical"
次に #backgroundUpdateTags ゲッターが追加されました:
// Upload lifecycle methods (progress, completion, errors) run asynchronously and may
// fire while the user is focused on another element (e.g., a title field). Without
// SKIP_DOM_SELECTION_TAG, Lexical's reconciler would move the DOM selection back into
// the editor, stealing focus from wherever the user is currently typing.
get #backgroundUpdateTags() {
if (this.#editorHasFocus) {
return SILENT_UPDATE_TAGS
} else {
return [SILENT_UPDATE_TAGS, SKIP_DOM_SELECTION_TAG].flat()
}
}
すべてのライフサイクルメソッドで SILENT_UPDATE_TAGS を直接渡していた箇所を this.#backgroundUpdateTags に置き換えることで、エディタがフォーカスを持たない場合に限り SKIP_DOM_SELECTION_TAG が追加されます。
#showUploadedAttachment では、ノード選択の転送もエディタのフォーカス状態で制御するよう変更されています。editor.update() コールバック内でフォーカス状態を参照すると更新時点のスナップショットと異なる可能性があるため、コールバックの外でフォーカス状態を事前に取得しています:
// 変更前
#showUploadedAttachment(blob) {
this.editor.update(() => {
const shouldTransferNodeSelection = this.isSelected()
// ...
}, { tag: SILENT_UPDATE_TAGS })
}
// 変更後
#showUploadedAttachment(blob) {
const editorHasFocus = this.#editorHasFocus
this.editor.update(() => {
const shouldTransferNodeSelection = editorHasFocus && this.isSelected()
// ...
}, { tag: this.#backgroundUpdateTags })
}
動作のフローを整理すると以下のようになります:
システムテスト test/system/upload_focus_test.rb も追加されています。XHRインターセプトによってアップロードの完了タイミングを制御し、アップロード中にタイトルフィールドへフォーカスを移した後でアップロードを完了させ、フォーカスが保持されることを検証しています。
設計判断
エディタのフォーカス状態に応じてタグを動的に選択するアプローチが採用されました。
フォーカスがある場合は従来通り SILENT_UPDATE_TAGS のみを使い、ない場合にのみ SKIP_DOM_SELECTION_TAG を追加する設計です。これにより、エディタにフォーカスがある通常のアップロード完了時の挙動(ノードが選択された状態になる)を維持しつつ、フォーカスが外れた場合の副作用だけを除去しています。
editor.update() コールバックの外でフォーカス状態をキャプチャしている点も注目です。Lexicalの更新はバッチ処理されることがあるため、コールバック内でフォーカス状態を参照すると実際の更新タイミングとずれが生じる可能性があります。const editorHasFocus = this.#editorHasFocus を先に評価することで、更新タグの決定と一貫した状態を参照しています。
まとめ
本PRは、非同期処理とUIフォーカス管理という典型的な競合状態を、LexicalのDOM選択制御タグを活用して解決した変更です。「エディタがフォーカスを持つ場合は通常動作、持たない場合はDOM選択を変更しない」という明確な条件分岐を1つのゲッターに集約することで、各ライフサイクルメソッドの変更を最小限に抑えつつ問題を根本から解消しています。