何が起きていたか
PWAとして動作している管理画面で、「投稿する」ボタンや情報検索ボタンが一切クリックに反応しなくなった。 画面は正常に表示されており、要素も存在している。なのにタップしても何も起こらない。
スクリーンショットを見ると、現在のコードには存在しないはずのアイコンやチェックボックスが表示されていた。つまりブラウザが古いHTMLを表示していた。
原因の特定
Service Workerによるキャッシュ問題
このPWAはService Workerを使ってオフライン対応している。以前のバージョンでは、HTMLページもService Workerにキャッシュさせていた時期があった(のちにネットワーク優先に修正済み)。その古いSWがまだ一部のブラウザに残存していた。
- 古いSW: HTMLページをキャッシュしていた(修正前のバージョン)
- 新しいJS: 最新ビルドのバンドルが取得される(コンテンツハッシュ付きURLのためキャッシュミス → 新鮮取得)
- 結果: 古いHTMLで存在しない要素を新しいJSが参照 → TypeError
TypeScriptの ! 非nullアサーションが仇になった
問題の核心はJavaScript側の実装にもあった。最新の機能追加(リンクカードプレビュー)で追加した要素への参照に、TypeScriptの ! 非nullアサーションを使っていた。
// 変更前: 要素が存在しない場合にTypeErrorが発生する
const linkPreviewCard = document.getElementById('link-preview-card')!;
! はTypeScriptの型システムに「この値はnullではない」と伝えるもので、ランタイムには何の保護もない。古いHTMLではこの要素が存在しないため、null が返る。
その後、null.classList.add('visible') を実行しようとして Uncaught TypeError が発生。Javascriptはこの時点でクラッシュし、以降に書かれているすべての addEventListener が実行されない。
つまり「投稿する」ボタンはHTMLに存在しているのに、イベントリスナーが一切登録されていないため、タップしても何も起きない状態になっていた。
修正内容
1. Service Workerのキャッシュバージョンを更新
キャッシュ識別子のバージョンを上げることで、Service Workerの activate イベントで旧バージョンのキャッシュが自動削除される。これにより古いHTMLキャッシュが一掃される。
// 変更前
const CACHE_NAME = 'webapp-admin-v4';
// 変更後
const CACHE_NAME = 'webapp-admin-v5';
2. null安全な実装に変更(防御的修正)
SWバージョン更新だけでは今後の同種問題に対して無防備なため、JS側も堅牢化した。
要素参照の変更(! を削除して null 許容に):
// 変更後: null を許容する型に変更
const linkPreviewCard = document.getElementById('link-preview-card');
const linkCardOgimageWrap = document.getElementById('link-card-ogimage-wrap');
// ...
関数内に存在チェックを追加:
function renderLinkPreview(link: typeof selectedLink): void {
if (!link) { clearLinkPreview(); return; }
// 要素が存在しない場合はDOM操作をスキップする
if (!linkPreviewCard || !linkCardOgimageWrap || !linkCardTitleEl || !linkCardDescEl || !linkCardDomainEl) return;
// ... 以降のDOM操作
}
function clearLinkPreview(): void {
selectedLink = null;
isLinkAutoDetected = false;
lastFetchedLinkUrl = null;
if (!linkPreviewCard) return; // 要素がなければスキップ
linkPreviewCard.classList.remove('visible');
// ...
}
addEventListener にオプショナルチェーンを適用:
// 変更前
linkCardRemoveBtn.addEventListener('click', () => { clearLinkPreview(); });
imageBtn.addEventListener('click', () => imageInput.click());
// 変更後
linkCardRemoveBtn?.addEventListener('click', () => { clearLinkPreview(); });
imageBtn?.addEventListener('click', () => imageInput?.click());
プレビューコンテナを使う関数にも早期リターンを追加:
function updateYouTubePreview(text: string): void {
if (!youtubePreviewContainer) return; // 要素なし時はスキップ
// ... 以下は変更なし
}
学び
! 非nullアサーションの危険性
TypeScriptの ! は型チェックをパスさせるための便宜的な記法で、ランタイムでは何も保証しない。document.getElementById が返す HTMLElement | null を ! でnullではないと「宣言」しても、実際にnullが返ってきた場合は普通にTypeErrorになる。
ページのJSが単一のモジュールとして実行されている場合、途中でuncaught Typeエラーが発生するとそれ以降のコードがすべて実行されない。DOMに要素が存在しているのにボタンが反応しないという「症状だけ見ると謎な現象」はこうして起きる。
Service Workerのバージョン管理
HTMLをキャッシュする設計から「ネットワーク優先」に変更しても、古いService Workerが残存しているブラウザでは変更前の動作が続く。
SWのキャッシュ識別子(CACHE_NAME)をバンプするだけで、次回の activate イベント時に旧バージョンのキャッシュが全削除される。PWAの設計変更後は必ずバージョンを上げることが重要。
一方の修正だけでは不完全
- SWバージョン更新だけ → 過去の問題は解決するが、将来の「HTML/JSバージョン不一致」には無力
- null安全化だけ → 現在の問題は緩和されるが、古いキャッシュを持つユーザーは引き続き古いUIを見る
両方をセットで修正することで、今回の問題解決と将来の予防が達成できる。