kikuta@dev:~$cat ./notes/spex-focus-async-ownership.md
·11 min·kikuta

Google Sheetsの選択・編集・フォーカスをどう見分けるか

非同期command完了が新しいユーザー操作のfocusを奪わないよう、selection・DOM focus・result UI ownershipを分離する。

Google Sheets上にキーボード操作を追加するChrome拡張Spexを作っています。

この種の拡張で意外と壊れやすいのが、DOM focusです。

コマンド自体は成功しているのに、その直後から文字入力できない。ダイアログを開いたのに、少し遅れてセルへfocusが戻る。マウスで別セルを触ったのに、古い非同期処理が元のセルへfocusを奪い返す。

どれも一見小さい不具合ですが、Spreadsheetのようにキーボード操作が連続するUIでは致命的です。

Spexでは最終的に、focusを単なる「処理後に戻す対象」ではなく、interactionごとに所有権を持つresourceとして扱うようにしました。

最初の実装:finallyでfocusを戻す

最初に考えやすいのは次の実装です。

const previous = document.activeElement;

try {
  await executeCommand();
} finally {
  if (previous instanceof HTMLElement) {
    previous.focus();
  }
}

単独の同期処理なら、これでも動きます。

問題はユーザー操作と非同期処理が並行するときです。

例えば、

A: セルの書式変更を開始
A: 結果確認を待つ
B: ユーザーが別のコマンドでダイアログを開く
B: ダイアログへfocus
A: 遅れてverification完了
A: finallyで古いセルへfocus() ← 事故

Aから見れば「元へ戻した」だけですが、ユーザーから見ると新しい操作を古い処理が破壊したことになります。

FocusとSelectionは別物だった

もう一つ混乱しやすかったのが、Google SheetsではDOM focusとSpreadsheet selectionが必ずしも一致しないことです。

  • セル選択は残っているが document.activeElement がBODYへ落ちる
  • ダイアログを開けば、selectionは残っていてもfocusは結果UIへ移る
  • ユーザーが別セルへ移動すれば、古いselection snapshotへ戻してはいけない

そのためSpexでは、

DOM Focus
Spreadsheet Selection
Result UI ownership

を分けて考えています。

「前のactiveElementへ戻す」では意味が足りません。

Commandごとにfocus semanticsを持たせる

コマンドには少なくとも3種類あります。

preserve-cell

Bold、Italic、行挿入など、操作後もSpreadsheetで連続入力したいもの。

execute -> verify -> cell keyboard contextへ戻る

transfer-result

ダイアログ、picker、sidebar、rename editorなど、結果として開いたUIを次に操作するもの。

execute -> result UI open -> result UIへownershipを渡す

leave-current

focusを積極的に操作しない方が安全な特殊ケース。

この区別をCommand contractとして持たせると、adapterの最後で何となく focus() を呼ぶ必要がなくなります。

Focus Lease

Spexでは、セル選択からinteractionを開始したとき、その時点のkeyboard contextを「Focus Lease」として扱います。

概念的には次の情報を持ちます。

type FocusLease = {
  interactionId: number;
  ownerDocument: Document;
  targetIdentity: unknown;
  captureReason: string;
  isStillValid: () => boolean;
};

重要なのはsnapshotそのものより、今もそのleaseを使ってよいかを検証することです。

復元前には少なくとも、

  • このleaseが最新interactionのものか
  • targetがまだconnectedか
  • selection/contextが変わっていないか
  • commandがresult UIへfocus transferしていないか
  • command実行中にユーザーが別操作を開始していないか

を確認します。

interactionIdで古い完了を無効化する

Focus Leaseとセットで使っているのが、単調増加する interactionId です。

新しい操作を開始するたびにgenerationを進めます。

interaction 41: Bold開始
interaction 42: Dialogを開く
interaction 41: verification完了

41が完了した時点で最新が42なら、41のcompletionはstaleです。

内部ログへ残すことはできますが、

  • focusを戻す
  • 最新Toastを書き換える
  • 新しいKeyTips stateを消す

といったユーザー向け副作用は禁止します。

この仕組みはfocusだけでなく、Toastやresult handoffのorderingにも使えます。

Pointer handoffも同じ所有権問題

keyboard-first UIを作っていても、ユーザーが最後までkeyboardだけを使うとは限りません。

典型的には、

AltでSpexを開始
-> native Sheets popupを開く
-> 最後はmouseで項目をclick

という操作があります。

このときSpexがpointerdownを「操作キャンセル」としてnative popupまで閉じると不自然です。

逆にSpexのoverlayがpointerを遮断すると、クリックできません。

そこで、

Keyboard -> Pointer
Keyboard -> native popup keyboard
Keyboard -> Escape

を正式なownership handoffとして扱います。

trusted pointerdown が来たら、Spex自身のnavigation stateとKeyTipsは解放する一方、Google Sheetsが開いたnative surfaceは残し、pointer eventをSheetsへ渡します。

「Spexのinteractionを終える」ことと「host UIを閉じる」ことを同一視しないのがポイントでした。

Overlayは原則focusableにしない

Focusのバグは、復元処理だけでなくSpex自身のUIからも起こります。

そのためCore overlayについては原則として、

  • autofocusしない
  • 表示だけの要素へ不要なtabindexを付けない
  • 表示のためだけにfocus()しない
  • overlay hostでpointer captureしない

という制約を置いています。

KeyTipsは見える必要がありますが、見えることとfocusを取ることは別です。

テストするのは「最終activeElement」だけではない

Focus preservationのテストでは、単純に最終的なactiveElementだけを見ると競合を取り逃します。

例えば次のシナリオを分けます。

  • cell mutation成功後はcell keyboard contextへ戻る
  • dialog command成功後はcellへ戻らない
  • command中にユーザーが別セルへ移動したら古いcellへ戻らない
  • pointer handoff後に古いasync completionがfocusを奪わない
  • failure後にresult UIがなければ安全にcell contextへ戻る

特に「古いcompletionが何もしない」ことをテストケースとして持つのが重要でした。

一般化すると「非同期UIに世代番号を持たせる」話だった

SpexではFocus Leaseと呼んでいますが、考え方自体はChrome拡張に限りません。

検索補完、非同期validation、editor extension、command paletteなどでも、

古い処理が新しいUI stateを上書きする

問題は起きます。

対策は「awaitのあとにとりあえずUIを更新する」のではなく、その結果がまだ現在のinteractionに属しているか確認することです。

focusは特に破壊力の大きい副作用なので、ownershipを明示しておく価値がありました。

Spexで実際に使っています

Spexでは、このfocus / interaction ownershipをKeyTips、command execution、native popup handoffなどで共通利用しています。

既存WebアプリへUIを差し込むextensionやuserscriptを作る場合、「どうfocusを戻すか」より先に「今focusを戻す権利が誰にあるか」を考えると設計しやすくなると思います。