会計サービス Chrome拡張:連携明細の重複表示バグ修正とUI磨き込み
朝、連携明細の事業者リストが3つずつ重複して並んでいるのを見て手が止まった。原因を追いかけてasync/awaitのインターリーブ問題にたどり着き、排他制御を入れるところから一日が始まった。その後、Sheets APIのautoResizeが沈黙する問題をねじ伏せ、UIのスタイルを統一し、推移表のBS/PLを1シートに結合するところまで進めた。
連携明細の3重表示バグ
症状
連携明細タブを開くと、同じ事業者が3回ずつDOMに追加されている。3事業者なら9行表示される。
原因特定のプロセス
最初はDOM操作の二重呼び出しを疑ったが、populateEntityServiceList() の呼び出し元を検索すると、3つのURL入力フィールドのauto-saveイベントが同時に発火していた。ページロード時にchrome.storageから値を復元する処理が3つのinputそれぞれのchangeイベントを発火させ、それぞれが populateEntityServiceList() を呼ぶ。
問題はこの関数がasyncだったこと。3つの呼び出しが同時に走り、for loop内のawaitでインターリーブが発生する。各呼び出しが独立にDOMへappendするため、結果が3倍になる。
呼出A: fetch事業者1 → append → fetch事業者2 → append → ...
呼出B: fetch事業者1 → append → fetch事業者2 → append → ...
呼出C: fetch事業者1 → append → fetch事業者2 → append → ...
console.logにタイムスタンプと呼び出し元IDを仕込んで、3つのPromiseが並走しているのを目視で確認した瞬間に原因が確定した。
修正: 排他制御と純粋関数の分離
2段階で修正した。
1. makeAsyncQueue による排他制御
// 同一関数の並列実行を直列化するキュー
const enqueue = makeAsyncQueue();
const populateEntityServiceList = () => enqueue(async () => { ... });
先行する呼び出しが完了するまで次の呼び出しをブロックする。これで3重実行が消える。
2. buildEntityServiceListItems 純粋関数への分離
DOM操作とデータ構築を分離した。純粋関数がHTML要素の配列を返し、呼び出し側が一度だけDOMをクリアしてappendする。DOMクリア→append が1箇所に集約されるため、仮にキューなしで2回呼ばれても重複しない(冪等になる)。
Google Sheets列幅自動調整
autoResizeDimensionsが動かない
エクスポート後のスプレッドシートで列幅がデフォルトのまま狭く、データが見切れる。Sheets APIの autoResizeDimensions リクエストを送っているのに変化がない。
APIリファレンスとStack Overflowを掘ると、autoResizeDimensions はデータ量が多いシートで正しく動作しない既知のバグだった。Googleのissue trackerにも2019年から報告が上がっていて、2026年現在も未修正。
回避策: calcColumnWidths純粋関数
API任せを諦めて、自前で列幅を計算する方針に切り替えた。
// 各列のデータを走査し、最大文字数からピクセル幅を算出
const calcColumnWidths = (rows, options) =>
rows[0].map((_, colIdx) => {
const maxLen = Math.max(...rows.map(row => String(row[colIdx] ?? '').length));
return Math.max(maxLen * options.charWidth + options.padding, options.minWidth);
});
計算結果を updateDimensionProperties で明示的に設定する。autoResizeに頼らない分、フォントサイズやpadding値を自分で決められる利点もある。
chrome.tabs.create失敗時のガード
症状
特定条件下でchrome.tabs.createが undefined を返し、直後の tab.id 参照でクラッシュ。sendResponse が呼ばれないまま関数が終了し、content.jsのメッセージリスナーが永久にハングする。
修正
const tab = await chrome.tabs.create({ url, active: false });
if (!tab?.id) {
sendResponse({ success: false, error: 'Tab creation failed' });
return;
}
sendResponse を確実に呼ぶガードを追加。content.js側はタイムアウトを持っていないため、このガードがないと一度の失敗でタブ全体がフリーズする。
連携明細UIの改善
チェックボックスの視覚的ノイズを減らした。
- チェックボックスを非表示にしてタイトルラベル化: 事業者名のチェックボックスは実質「全選択トグル」なので、チェックボックスを消してクリッカブルなタイトルラベルに変更。クリックで配下の全サービスが選択/解除される
- 全選択と個別選択の視覚的区別: 全選択はボールドの事業者名、個別選択は通常ウェイトのサービス名。インデントで親子関係を表現
スタイル統一: 全帳表・全年度・全選択ボタン
散らばっていた「全選択」系のUI要素のスタイルを統一した。
/* 全帳表・全年度・全選択ボタン共通 */
.select-all-btn {
border: none;
background: transparent;
color: #888;
cursor: pointer;
}
ボーダーなし、背景透明、グレー文字。主張しすぎない補助UIとして統一。以前はボタンごとにボーダーの有無やフォントサイズが異なっていた。
推移表のBS/PL統合
Before
BS(貸借対照表)とPL(損益計算書)がそれぞれ別シートに出力されていた。比較するたびにシートを切り替える必要がある。
After
1シートに縦結合。BSを上半分、PLを下半分に配置し、間に空行を1行挟む。ヘッダー行はBSとPLそれぞれに付与。
年度ヘッダーのスクレイピング問題
症状
推移表のスクレイピングで年度セルが空文字列になる。データ行は正常に取得できるのに、ヘッダーだけ取れない。
原因
DOMを確認すると、年度セルは <td> ではなく <th> で記述されていた。スクレイピングコードが querySelectorAll('td') で行内のセルを取得していたため、<th> がスキップされていた。
修正
querySelectorAll('td, th') に変更。テーブルのスクレイピングでは <td> だけ取るのはよくある落とし穴で、この会計サービスに限らず <th> がデータ行に混在するパターンは珍しくない。
振り返り
3重表示バグの原因を追いかけるうちに、「async関数を複数箇所から同時に呼ぶと何が起きるか」を手を動かして確認できた。ログにタイムスタンプを仕込んで3本のPromiseが並走する様子を眺めたとき、排他制御の必要性が腹落ちした。makeAsyncQueueと純粋関数分離の組み合わせは、同種の問題に再利用できるパターンとして手元に残った。
autoResizeDimensionsの件は、「APIが正しく動くはず」という前提で30分ほど設定パラメータを変えて試行錯誤した後、issue trackerを開いて既知バグだと知った。先にissue trackerを確認すべきだった。自前計算に切り替えた結果、列幅のコントロールが効くようになったのは怪我の功名。
年度ヘッダーの <th> vs <td> は、DevToolsでDOM構造を直接見た瞬間に解決した。推測でコードを直す前にDOMを見る、というのを徹底すればもっと早く片付いた。