詳細画面でボタンを押しているとき、遅れて届いた関連情報を表示しただけで、キーボードの操作位置が失われることがあります。画面には同じボタンが残っているように見えるため、画像だけでは気づきにくい不具合です。
この記事は、JavaScriptでHTMLを組み立てる画面を保守する人向けに、非同期応答とDOM更新の境界を見直した例を紹介します。結論は、応答で変わる部分を小さく区切り、操作中の要素を置き換えないことです。フォーカスを無条件に戻す処理は追加しません。
何が起きていたか
具体例は、カードの詳細から「デッキ」という組み合わせにカードを追加できるWebアプリです。カードの基本情報と、関連するデッキのレシピ一覧は別々に取得します。基本情報が先に届けば、レシピの読み込み中でも追加操作ができます。
変更前は、レシピの初回取得が終わると詳細全体を描き直していました。次の順序で操作すると問題が現れます。
- カード詳細を開く。レシピの応答はまだ届かない。
- 「追加」ボタンをキーボードで押す。
- レシピの応答が届き、詳細全体を再生成する。
- フォーカスされていたボタンがDOMから外れ、操作位置が失われる。
変更前の遅延応答テストでは、操作中のボタンからページ本体へフォーカスが移ることを再現しています。これは利用者調査で報告された頻度や、すべてのブラウザに共通する結果を示すものではありません。
見た目が同じでも、同じ要素ではない
DOMは、ブラウザが管理する要素の木構造です。innerHTMLへの代入は、その要素の子孫を新しいHTMLから作った要素に置き換えます。同じラベルやIDのボタンを作り直しても、以前のボタンと同一のDOMノードにはなりません。MDN: innerHTML
そのため、「更新後もボタンがある」という確認では不十分です。表示する情報が増えただけなのに、操作中のボタンまで更新対象へ含めていたことが問題でした。
更新範囲を次のように分けます。これは構造を説明する図で、実画面の再現ではありません。
詳細パネル
├─ 追加ボタン ← 維持する
├─ 現在の枚数・進捗 ← 維持する
├─ デッキを見るボタン ← 維持する
└─ レシピ導線の領域 ← レシピ応答で更新する
応答時に更新するのは関連情報の領域だけ
以下は実装変更のpublic/index.htmlから、レシピ取得後の処理を抜粋したものです。単独で実行できるサンプルではありません。取得したレシピ一覧を保存し、現在選択中のカードに対応する導線だけを作り直します。
fetch('data/recipes.json')
.then(r => r.json())
.then(data => {
RECIPES = Array.isArray(data) ? data : [];
// レシピ導線だけ更新し、操作中のボタンやフォーカスを維持する。
if (selectedCard && !document.getElementById('mobileDetail').classList.contains('hidden')) {
const c = CARDS.find(x => x.id === selectedCard);
const recipeSlot = document.getElementById('detailRecipeButton');
if (c && recipeSlot) {
recipeSlot.innerHTML = recipeButtonHTML(c);
recipeSlot.hidden = !RECIPES.length;
}
}
})
.catch(() => { RECIPES = []; });
詳細を組み立てる側には、次の領域を追加しています。こちらはJavaScriptのテンプレート文字列内の抜粋です。
<div id="detailRecipeButton" ${RECIPES.length ? '' : 'hidden'}>${recipeButtonHTML(c)}</div>
導線のHTMLを組み立てる部分では、そのカードに対応するレシピを案内する既存の関数を呼び出しています。追加ボタンと進捗表示はこの領域の外側に置きます。この実装はinnerHTMLを使わなくする変更ではなく、置き換える子孫の範囲を狭める変更です。HTMLを作る関数へ外部入力を渡す際の安全性は別途必要であり、単なる文字表示ならtextContentなどを検討します。
条件にも意味があります。
| 応答までに起きたこと | 反映時の判断 |
|---|---|
| 同じカードを見ている | そのカードのレシピ導線だけ更新 |
| 別のカードへ切り替えた | 応答時点で選択中のカードを使う |
| 詳細を閉じた | 非表示の詳細を更新しない |
| 別の内容を表示している | 専用の更新領域がなければ何もしない |
| 空の配列が届いた | レシピ導線を非表示にする |
| 取得に失敗した | この処理から詳細全体は描き直さない |
ここで取得しているのはカードごとの個別応答ではなく、共通のレシピ一覧です。そのため、応答時点のカードを参照できます。カードA専用の応答とカードB専用の応答が前後する設計なら、リクエストIDや選択IDの照合なども必要です。この分岐だけで、あらゆる非同期競合を解決できるわけではありません。
更新後に追加ボタンへ操作位置を戻せばよいのでは?
応答後に追加ボタンへ強制的にフォーカスを戻す方法では、応答を待つ間に利用者が別のボタンへ移動した場合、その操作位置を奪ってしまいます。
この変更では、追加ボタンへ戻すことを成功条件にしません。応答が届く直前に利用者が操作していた要素を維持することを確認します。追加ボタンにいた場合だけでなく、「デッキを見る」ボタンへ移動していた場合も対象です。
もちろん、詳細を開いた直後や閉じた直後など、画面遷移に伴ってフォーカス移動が必要な場面はあります。今回扱うのは、同じ詳細に関連情報が後から補充される場面です。
また、この方針でも更新領域の内側にフォーカスがあると、その要素を置き換えてしまいます。今回は初回取得で現れるレシピ導線を対象にしています。定期更新や再取得を追加するなら、導線自身のノードを維持して文言だけ更新する設計も検討対象になります。
応答の前後を制御して検証する
回線が偶然遅くなるのを待つと再現が不安定です。検証ではPlaywrightのリクエスト制御でレシピ応答を保留し、操作を済ませてから解放しました。Playwrightのネットワーク制御
検査する順序は次のとおりです。
応答を保留 → 詳細を開く → 追加する/別ボタンへ移動する
→ フォーカスと保存内容を記録 → 応答を解放
→ 同じDOMノードにフォーカスがあるか、保存内容が変わっていないか確認
document.activeElementで現在のフォーカス先を取得できます。実際の検証では応答前の要素への参照を保持し、応答後も同じ要素にフォーカスがあるか比較しています。ボタンのラベルが同じという検査より、今回の原因に直接対応します。MDN: activeElement
記録されている検証結果は、次のように範囲を分けて読めます。
| 検証 | 確認範囲 |
|---|---|
| 修正前のローカル再現 | 遅延応答でフォーカスが失われること |
| 修正後のローカル24ケース | PC・モバイル幅×ライト・ダーク×追加・別ボタンへの移動・カード切替・詳細終了・空応答・取得失敗 |
| 公開本番を使った保存済み8ケース | 390px・1440px幅×ライト表示×追加・別ボタンへの移動・カード切替・詳細終了 |
| 記事作成時のChromeでの確認 | 再読み込み後の本番詳細に、更新領域と進捗表示が存在すること |
保存された実際の検証ログ
公開本番を使った8ケースはすべて成功しています。次は、そのうち幅390pxで追加操作をしたケースの結果JSONから、今回の説明に必要な項目を抜粋したものです。値は変更せず、保存内容とレシピ導線などの項目を省略しています。テストの成功と、応答前後で操作位置・選択・枚数・進捗が保たれたことを読めます。
{
"width": 390,
"theme": "light",
"scenario": "add",
"passed": true,
"before": {
"selected": "dm01-100",
"total": 1,
"progress": "このカード:1枚 / デッキ合計:1 / 40"
},
"after": {
"sameFocus": true,
"selected": "dm01-100",
"total": 1,
"progress": "このカード:1枚 / デッキ合計:1 / 40"
}
}
ログの「成功」はテストの判定結果です。HTTPのレスポンスコードやCI全体の成功を表すものではありません。この記録は、以下の隔離条件で実行したブラウザ試験の証拠です。
本番8ケースでは、一時ブラウザの保存領域を使い、Service Workerを無効にし、別オリジンへの通信を遮断しています。応答前後でフォーカスの同一性、保存内容、選択中のカード、合計枚数、進捗表示が保たれることを検査しました。既存の個人データを使った試験ではありません。モバイル幅もPC上のブラウザ検証であり、実機スマートフォンの検証ではありません。
記事作成時の通常Chromeでは、初回表示に更新領域が見当たらず、再読み込み後には確認できました。原因は特定していません。通常表示の確認を、遅延応答テストの再実行や、更新経路全体の保証としては扱いません。保存済み本番テストもService Workerを無効にしているため、キャッシュを含む更新経路を保証するものではありません。
これらは保存済みの実装・検証記録に基づきます。スクリーンリーダーでの通知、ブラウザ間の差、実利用での操作成功率は未測定です。フォーカスのテストが通ることと、アクセシビリティ全体の適合や使いやすさの向上は区別しています。
まとめ
非同期処理が終わったとき、表示したい情報と一緒に操作中の要素まで作り直していないかを確認します。今回の修正では、関連情報を更新する領域を分け、現在の選択状態を参照し、詳細が閉じていれば更新を控えました。
テストでは「ボタンが表示されている」だけでなく、「応答前のフォーカス先がそのまま残る」「利用者が移動した先から戻されない」「保存内容が変わらない」を確認します。更新範囲の設計と検証条件をそろえると、見た目だけでは分かりにくい不具合を捉えやすくなります。
実装・検証・原稿整理にはAIエージェントを使用しています。利用者の体験談や著者本人の実測として記述したものではありません。