【ITニュース解説】A Stream Retry Needs a Fresh Announcement, Not a Rewritten Transcript
2026年09月25日に「Dev.to」が公開したITニュース「A Stream Retry Needs a Fresh Announcement, Not a Rewritten Transcript」について初心者にもわかりやすく解説しています。
ITニュース概要
ストリーミングチャットの再試行時、スクリーンリーダーがキャンセルされた内容と新しい内容を結合して読み上げたり、ボタン名が古いままになるアクセシビリティ問題が発生した。これは、表示と読み上げに同じ要素を使い、状態更新が遅れることが原因。読み上げ用要素を分離し、ボタンの状態を確実に更新し、ストリームの世代管理を行うことでこの不具合を解消した。
ITニュース解説
Webアプリケーション開発において、ユーザー体験の向上は常に重要な目標だ。特に最近では、ChatGPTのようなAIチャットが普及し、リアルタイムでテキストが生成される「ストリーミングチャット」機能を目にする機会が増えている。このような機能は、情報が段階的に表示されるため、ユーザーがすぐに内容を把握できる利点がある一方で、視覚だけでなく、音声で情報を読み上げる「スクリーンリーダー」などの支援技術を利用するユーザーにとっては、予期せぬ問題を引き起こすことがある。この記事は、まさにその支援技術とストリーミングチャットの組み合わせで発生したバグと、その解決策について解説している。システムエンジニアを目指す皆さんが、将来アクセシビリティを考慮した開発を行う上で非常に役立つ知見が得られるだろう。
この問題は、開発者がテスト中に発見したものだ。ストリーミングチャットでAIの回答が途中で止まってしまい、ユーザーが「キャンセル」ボタンを押して生成を中断し、その後「再試行(Retry)」ボタンを押して新しい回答の生成を試みた。画面上では、キャンセルされた古いテキストが消え、新しいテキストが生成され始めるように見えた。しかし、スクリーンリーダーが読み上げた内容は全く違った。スクリーンリーダーは、キャンセルされた古いテキストの断片と、新しく生成され始めたテキストをあたかも一つの文章であるかのように続けて読み上げてしまったのだ。つまり、視覚的には問題なく更新されているように見えても、音声では古い情報が混ざって伝えられてしまうという、視覚と聴覚の間に不一致が生じていた。
さらに、もう一つの問題も発生していた。チャットの生成が中断された後、画面上のボタンは「再試行」と表示されているにもかかわらず、キーボードのEnterキーを押すと、なぜか「停止(Stop)」のアクションが実行されてしまうというものだった。ユーザーが期待するのは「再試行」なのに、実際には「停止」が機能してしまう。これは、ボタンにフォーカスが当たった状態で、ボタンの「表示されている名前」と「実際に実行されるアクション」が食い違っていることを意味する。このような状況は、キーボード操作でウェブサイトを利用するユーザーにとって、非常に混乱を招く。なぜなら、彼らは画面に表示されている情報と、その情報がもたらすはずの動作を信頼して操作するからだ。
これらの問題の根本原因は、ウェブページのコンテンツ構造と、支援技術への情報の伝え方にあった。特に「ライブリージョン」と呼ばれる、動的に内容が変化する部分をスクリーンリーダーに適切に伝えるための仕組みの理解が重要になる。ライブリージョンは、通常のテキストエディタのように内容を書き換えるだけでなく、「新しいメッセージが届いた」ということをスクリーンリーダーに明確に伝える必要がある。開発者は、チャットのテキスト表示領域をライブリージョンとして設定し、再試行時にそのテキストを一度クリアしてから新しいテキストを書き込んでいた。しかし、スクリーンリーダーによっては、この「クリアして書き込む」という操作を「テキストが書き換えられた」とは認識せず、「以前のテキストに新しいテキストが追加された」と解釈してしまう場合があるのだ。これは、スクリーンリーダーがテキストの内容だけでなく、DOM(Document Object Model)ツリー上の「ノードの同一性」を判断基準としているためだった。同じノードを使い回して内容を更新するだけでは、スクリーンリーダーはそれを「新しいメッセージ」とは認識しないことがあったのだ。
ボタンの問題も同様に、DOMとレンダリングのタイミングに起因していた。ボタンの表示テキスト(ラベル)が「再試行」に更新されるタイミングと、ボタンが実際に持つべきアクション(イベントハンドラ)が「再試行」に変更されるタイミングがずれていたのだ。ユーザーがキャンセルした直後、フォーカスはボタンに残っていたが、その時点ではまだボタンのアクションは古い「停止」のままだった。画面が再描画(レンダリング)され、ボタンのテキストとアクションが同期される前に、ユーザーがキーボードでEnterを押してしまうと、意図しない「停止」が実行されてしまうという状況だった。
開発者はこれらの問題を解決するために、まず闇雲にコードを修正するのではなく、現在のアプリケーションの状態と、各操作によって状態がどう変化すべきかを明確にするための「状態遷移表」を作成した。これは、バグの原因を推測するのではなく、システムがどのような状態にあるべきか、そしてどのようなアナウンスをすべきかを客観的に定義する非常に有効な手段だ。例えば、「アイドル」「ストリーミング中」「キャンセル済み」「失敗」といった状態を定義し、それぞれの状態におけるボタンの表示名、フォーカス位置、そしてスクリーンリーダーが何をアナウンスすべきかを整理した。
この状態管理の中で、「世代(generation)」という新しい概念が導入された。これは、各チャットの試行(回答生成の試み)にユニークな番号を割り当てるものだ。再試行が行われるたびに、この世代番号をインクリメントする。もし、ネットワークの遅延などによって、キャンセルされた古い世代の回答の一部が遅れて届いたとしても、現在の世代番号と一致しない場合はその情報を無視するようにすることで、スクリーンリーダーが古い情報を読み上げてしまう問題を根本から解決する狙いがあった。
根本原因を特定するために、開発者は三つの確認を行った。一つ目は、再試行後にアナウンサー(スクリーンリーダーが読み上げるための領域)の参照が変更されていないことを確認した。これにより、支援技術が新しいメッセージとして認識すべき新しいDOMノードが存在しないことが判明した。二つ目は、キャンセル操作を行った直後のボタンテキストを確認し、それがまだ「停止」と表示されていることを確認した。これにより、ボタンの名前更新が遅れていることが明らかになった。三つ目は、aria-atomic属性を試したが、これはむしろ重複読み上げを強調してしまい、解決策ではないことがわかった。これらの確認から、問題はスクリーンリーダーの「癖」だけでなく、開発者自身の構築したDOM構造と、支援技術への情報の伝え方に根本的な原因があることが明確になった。
最終的な解決策は、「視覚的なトランスクリプト」と「専用のアナウンサー領域」を明確に分離することだった。
具体的には、チャットの回答テキストが表示される領域は、単なる視覚的な表示領域として扱い、aria-live="off"を設定してスクリーンリーダーがその内容の変化を自動的に読み上げないようにした。そして、スクリーンリーダーに特定のメッセージを読み上げてほしいときのために、もう一つ別の、専用の「アナウンサー」用のDOMノードを用意した。このアナウンサーノードはaria-live="polite"(丁寧に読み上げる)とaria-atomic="true"(内容全体を読み上げる)を設定し、状態が変化するたび(例えば「キャンセルされました」や「新しい回答を生成中」など)に、このアナウンサーノードを新しく作成し、古いアナウンサーノードと置き換えるようにした。
このようにすることで、スクリーンリーダーは毎回「新しい」アナウンサーノードを受け取ることになり、前のメッセージの続きとして解釈するのではなく、完全に新しい独立したメッセージとして読み上げるようになった。これにより、古いテキストと新しいテキストが混ざって読み上げられる問題が解決された。
ボタンの表示とアクションの不一致の問題については、アプリケーションの状態が変化するタイミングで、ボタンのテキスト(ラベル)と、ボタンが実行するアクションの両方を同時に更新するように修正した。これにより、ユーザーがボタンにフォーカスを当てた状態で操作しても、画面に表示されている名前と実際に実行される機能が常に一致するようになった。
さらに、非同期でデータが流れてくるストリーミングの特性に対応するため、「世代ガード」が導入された。これは、ネットワークの遅延によって、キャンセル済みや古い世代のデータが遅れて届いた場合でも、現在のアプリケーションの「世代」と一致しないデータは無視するというものだ。これにより、ユーザーがキャンセルしてすぐに再試行した場合でも、古いデータが誤って表示されたり、スクリーンリーダーが読み上げたりするのを防ぐことができる。
この一連の修正は、HTML構造の変更と、JavaScriptによる状態管理およびDOM操作の改善によって実現された。HTMLでは、<div id="transcript" aria-live="off"></div>で視覚的な内容を、<div id="announcer" aria-live="polite" aria-atomic="true"></div>で音声によるアナウンスを分離した。JavaScript側では、アプリケーションの状態を管理する関数(reduce)と、UIを更新する関数(render)、そしてスクリーンリーダーにメッセージを伝える関数(announce)を明確に役割分担した。特にannounce関数では、新しいメッセージを伝える際に古いアナウンサーノードをクローンし、新しいメッセージを書き込んだノードで置き換えることで、スクリーンリーダーに「新鮮なアナウンス」を確実に伝えるようにしている。
この一連の修正は、単に目の前のバグを直すだけでなく、アクセシビリティを考慮した開発のベストプラクティスを示している。特に、動的なコンテンツを扱うウェブアプリケーションにおいて、視覚的な情報だけでなく、支援技術を通じて提供される情報の一貫性と正確性を確保することの重要性を浮き彫りにした。開発者は、単に画面が正しく表示されるだけでなく、スクリーンリーダーが何をどのように読み上げるか、キーボード操作でどのような挙動をするかといった点まで注意を払う必要がある。
この経験は、プロダクト開発において、目に見えるUIとアクセシビリティツリー(支援技術が利用する情報構造)の間に乖離が生じることがいかに深刻な問題を引き起こすかを示している。そして、その問題を解決するためには、状態管理を徹底し、DOMノードのライフサイクルと、支援技術が情報を解釈する方法を深く理解することが不可欠であることを教えてくれる。システムエンジニアを目指す皆さんは、将来開発を行う際に、単に機能を実現するだけでなく、多様なユーザーが快適に利用できるような、アクセシブルな設計を常に心がけるべきだ。それは、より高品質で、より多くの人に価値を届けられるプロダクトを作るための重要なステップとなるだろう。