• #Chrome拡張機能
  • #クラウド会計
  • #排他制御
  • #Google Sheets API
  • #UI改善
開発misc-devメモ

会計サービス 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を見る、というのを徹底すればもっと早く片付いた。