はじめに

ある日、Vercelにデプロイした IIIF アノテーションエディタで、アノテーションが一切付与できなくなっていることに気づきました。ローカルの開発サーバーでは正常に動作するのに、本番環境でだけ描画モードに入れない。コンソールエラーも出ない。UIのボタンは正しく切り替わるのに、画像上でドラッグしても何も起きない——。

原因は、package.json のキャレット指定(^)による Annotorious の自動アップグレードと、v3.7.13 での状態管理ライブラリ移行が webpack の production build で引き起こす不具合でした。

この記事では、調査過程から根本原因の特定、そして得られた教訓までをまとめます。

環境

  • フレームワーク : Next.js 15 (App Router)
  • 画像ビューア : OpenSeadragon 5
  • アノテーション : Annotorious v3 (@annotorious/react + @annotorious/openseadragon)
  • バンドラ : webpack(Next.js 内蔵)
  • デプロイ先 : Vercel

症状

本番環境(Vercel)で以下の症状が発生しました。

  • 矩形・ポリゴンの描画ツールボタンをクリックすると、UIの状態は正しく切り替わる (React の state 更新は正常)
  • しかし Annotorious が描画モードに入らない ——カーソルが crosshair に変わらず auto のまま
  • 画像上でクリック&ドラッグしてもアノテーションが作成されない
  • コンソールにエラーは一切表示されない
  • Annotorious のアノテーションレイヤー要素(a9s-gl-canvas)自体は DOM 上に正しく描画されている

ローカルの next dev では完全に正常動作するため、再現が困難な状況でした。

調査過程

1. Playwright による自動テスト

まず Playwright を使って、デプロイ済みサイトに対する自動テストを実施しました。

ac}wo)ancr;isoettntsu:pctraun"greaeslwu.oitcr=nold"i=docowkac.(wug'ame[ietdtnC"atoctp.mraaqpo-guusteetso.rehoeydalvSSi=aetr"lly"rueleacectt(teoea(rln(()g)'.l.ce=au">9r]ss'{-o)gr;l;-canvas');

これにより、ボタンの状態変更は正常だが Annotorious 内部で描画モードへの遷移が起きていないことが確認できました。

2. 最初の仮説——OpenSeadragon WebGL 競合

OpenSeadragon v5 はデフォルトで WebGL ドローアを使用します。Annotorious も WebGL オーバーレイ(a9s-gl-canvas)を使うため、当初は両者の WebGL コンテキスト競合を疑いました。

c}o;ndsrtawveire:we"rcOapntviaosn"s,={WebGLCanvas2D

この変更をデプロイしましたが、問題は解決しませんでした

3. Vercel デプロイ履歴との比較

「いつから壊れたのか」を特定するため、Vercel のデプロイ履歴を確認しました。正常に動作していた最後のデプロイと、壊れたデプロイの間にある変更は、webpack 移行のためのコミット(npm install の再実行を含む)でした。

4. package-lock.json の差分で原因特定

決定的な手がかりは package-lock.json の差分にありました。

gitdiff119370a..851abfepackage-lock.json|grep-A10-B2"annotorious"

webpack 移行コミットで npm install を実行した際、package-lock.json が再生成され、キャレット指定("^3.7.4")により Annotorious が自動的にアップグレードされていました。

パッケージ変更前変更後
@annotorious/core3.7.43.7.19
@annotorious/annotorious3.7.43.7.19
@annotorious/openseadragon3.7.43.7.19
@annotorious/react3.7.43.7.19

原因特定——v3.7.13 の nanostores 移行

Annotorious v3.7.13(PR #582 “Remove Svelte Dependency from the Core”)で、状態管理ライブラリが大きく変更されていました。

v3.7.4 の依存関係:

@@aannnnoottoorriioouuss//croeraectSzvueslttaendwritablestores

v3.7.19 の依存関係:

@@aannnnoottoorriioouuss//croeraectnzaunsotsatnodresatomRENMEOWVED

この変更は semver 上は minor version bump(パッチではなくマイナー)ですが、内部の状態管理の挙動が変わり、webpack の production build でのみ 描画モードが壊れるリグレッションを引き起こしていました。

技術的詳細

なぜ production build でだけ壊れるのか。3つの要因が重なっていました。

要因1: Scope Hoisting による nanostores のモジュールレベル変数の重複

nanostores の atom 実装は、モジュールレベルの可変変数を使用します。

llceeeoxttnpnsoalltrniqtosIQstnUltedEeoneUtrexEer_esQ=Ip/uToae0EctuMhoeSm_=/=PiE0n[Rd]_eLxI.SjTsENER=4

@annotorious/core@annotorious/annotorious@annotorious/openseadragon はそれぞれ nanostores をバンドルしているため、この変数が3つの独立したコピー として存在します。

  • 開発モード : webpack は各モジュールを独立した関数スコープに保持 → 3つのコピーは干渉しない
  • 本番モード : ModuleConcatenationPlugin(scope hoisting)が ES モジュールのスコープを結合 → 変数が衝突する可能性

production build 後の minified コードでは、各パッケージの listenerQueue が異なる変数名に割り当てられます。

パッケージQueue 変数Index 変数
@annotorious/coreI = []z = 0
@annotorious/annotoriousMe = []Oe = 0
@annotorious/openseadragonGe = []Ir = 0

パッケージ間で atom の通知キューが分断されるため、あるパッケージで発火した状態変更が別のパッケージのサブスクライバーに届かなくなります。

要因2: Selection state のサブスクリプションチェーン

Annotorious の描画レイヤーは Selection の atom をサブスクライブして、OpenSeadragon のマウスナビゲーション有効/無効を制御しています。

SOeSlDectisoentMaotuosmeNavEnabled(false)

atom の通知が正しく伝播しないと、OSD のマウスナビゲーションが有効なまま残り、すべてのポインタイベントが描画ツールに届く前に OSD に奪われます。

要因3: 等値比較の挙動の違い

v3.7.4 (Svelte writable)v3.7.19 (nanostores atom)
.set() のガードオブジェクトは常に通知safe_not_equal は任意のオブジェクトに true を返す)!== のみ(同じ参照 = スキップ
s}e,tli}n(efant$$neaaowoottsVllootaddmmolVV..ruaavneellaos)uulteeui{ef=!ya==(t$=ooanlmtnedoewVmwVa.Valvalualuelue)uee){

Svelte の writable はオブジェクトの .set() では常にサブスクライバーに通知していましたが、nanostores の atom同じ参照なら通知をスキップ します。もしコード内でオブジェクトをミューテーションして同じ参照を .set() に渡すパスがあれば、通知がサイレントにドロップされます。

解決策

すべての Annotorious パッケージのバージョンを、キャレット(^)を外して固定しました。

{}"}de"""p@@@eaaannnndnnneooontttcoooirrreiiisooo"uuu:sss//{arnpeneaonctstoe"ra:idor"ua3sg."o7:n."4":"3."73..47".,4",

バージョンを 3.7.4 に固定した後、Vercel 上の本番環境で描画モードが正常に動作することを確認しました。

また、Annotorious リポジトリに Issue #599 を作成し、production webpack build での不具合を報告しています。

教訓

1. semver のキャレット指定は「安全」ではない

"^3.7.4" は「3.x.x の範囲で互換性のある最新版」を意味しますが、現実にはマイナーバージョンでも破壊的な変更が入ることがあります。特に内部実装の変更(状態管理ライブラリの移行など)は、API の表面上は互換性があっても、バンドラの最適化との相互作用で予期しない不具合を引き起こす可能性があります。

重要なライブラリは完全固定 (キャレットなし)を検討しましょう。

2. Production build でのみ再現するバグの調査手法

開発環境で再現しないバグは、以下の点を疑うべきです。

  • Scope hoisting : webpack の ModuleConcatenationPlugin は production でのみ有効。モジュールレベルの可変状態を持つライブラリに影響する
  • Tree shaking : 未使用コードの除去により、開発時には存在するコードパスが消える
  • Minification : 変数名の短縮により、デバッグが困難になる
  • 依存関係の重複 : 同じライブラリの複数コピーがバンドルに含まれる

3. package-lock.json の差分を注視する

npm installpackage-lock.json を再生成する際、キャレット指定の依存関係を最新版に更新します。大きな変更の前後で package-lock.json の差分を確認し、意図しないアップグレードがないかチェックしましょう。

gitdiffHEAD~1package-lock.json|grep-B2-A10"your-package-name"

4. Playwright はデプロイ環境のデバッグに有効

ブラウザの DevTools では再現しにくい production 環境のバグでも、Playwright を使えばプログラマティックに DOM の状態やスタイルを検証でき、問題の切り分けに大いに役立ちます。

まとめ

項目内容
症状Production build で Annotorious の描画モードが動作しない
原因v3.7.13 の nanostores 移行 + webpack scope hoisting
影響範囲webpack production build のみ(開発モードでは再現しない)
解決策バージョンを 3.7.4 に固定
報告annotorious/annotorious#599

Production build でのみ再現するバグは調査が非常に困難ですが、「いつから壊れたか」を特定し、差分を丹念に追うことで原因にたどり着くことができます。同様の問題で困っている方の参考になれば幸いです。