Introduction

One day, I noticed that annotations could no longer be created at all in a IIIF annotation editor deployed on Vercel. It worked correctly on the local development server, but in the production environment, drawing mode could not be entered. There were no console errors. The UI buttons switched correctly, but dragging on the image did nothing.

The cause was an automatic upgrade of Annotorious due to caret (^) specification in package.json, and a state management library migration in v3.7.13 that caused issues with webpack’s production build.

This article summarizes the investigation process, root cause identification, and lessons learned.

Environment

  • Framework: Next.js 15 (App Router)
  • Image viewer: OpenSeadragon 5
  • Annotation: Annotorious v3 (@annotorious/react + @annotorious/openseadragon)
  • Bundler: webpack (built into Next.js)
  • Deployment: Vercel

Symptoms

The following symptoms occurred in the production environment (Vercel).

  • Clicking the rectangle/polygon drawing tool buttons caused the UI state to switch correctly (React state updates worked normally)
  • However, Annotorious did not enter drawing mode – the cursor remained auto instead of changing to crosshair
  • Click & drag on the image did not create annotations
  • No errors were displayed in the console at all
  • The Annotorious annotation layer element (a9s-gl-canvas) was correctly rendered in the DOM

Reproduction was difficult since everything worked perfectly in local next dev.

Investigation Process

1. Automated Testing with Playwright

First, automated tests were run against the deployed site using Playwright.

ac}wo)ancr;DisoeRrttnteasuswpctruiaunlngretgeslw:.oimcr=n"oldadi=douecowtkac.ov(wug"e'amer[iet(idtnCefatoxitp.mpcaaqpea-guuctteettio.reeooeyddnlvSS=aet""llycruelreaceoctt(steoesa(rlhn(()ag)'.il.cre=au"">9r)]ss'{-o)gr;l;-canvas');

This confirmed that while button state changes were normal, the transition to drawing mode was not happening inside Annotorious.

2. First Hypothesis – OpenSeadragon WebGL Conflict

OpenSeadragon v5 uses a WebGL drawer by default. Since Annotorious also uses a WebGL overlay (a9s-gl-canvas), the initial suspicion was a WebGL context conflict between the two.

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

This change was deployed, but the problem was not resolved.

3. Comparison with Vercel Deployment History

To identify “when it broke,” Vercel’s deployment history was reviewed. The changes between the last working deployment and the broken deployment were commits for webpack migration (including re-running npm install).

4. Root Cause Found in package-lock.json Diff

The decisive clue was in the package-lock.json diff.

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

When npm install was run during the webpack migration commit, package-lock.json was regenerated, and the caret specification ("^3.7.4") caused Annotorious to be automatically upgraded.

PackageBeforeAfter
@annotorious/core3.7.43.7.19
@annotorious/annotorious3.7.43.7.19
@annotorious/openseadragon3.7.43.7.19
@annotorious/react3.7.43.7.19

Root Cause – v3.7.13 nanostores Migration

In Annotorious v3.7.13 (PR #582 “Remove Svelte Dependency from the Core”), the state management library was significantly changed.

v3.7.4 dependencies:

@@aannnnoottoorriioouuss//croeraectSzvueslttaendwritablestores

v3.7.19 dependencies:

@@aannnnoottoorriioouuss//croeraectnzaunsotsatnodrersemaotvoemdNREEWMOVED

While this change is a minor version bump in semver terms (not a patch), the internal state management behavior changed, causing a regression that broke drawing mode only in webpack production builds.

Technical Details

Why did it break only in production builds? Three factors combined.

Factor 1: Duplication of nanostores Module-Level Variables Due to Scope Hoisting

The nanostores atom implementation uses module-level mutable variables.

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

Since @annotorious/core, @annotorious/annotorious, and @annotorious/openseadragon each bundle nanostores, these variables exist as 3 independent copies.

  • Development mode: webpack keeps each module in an independent function scope – the 3 copies don’t interfere
  • Production mode: ModuleConcatenationPlugin (scope hoisting) merges ES module scopes – variables can collide

In the minified code after production build, each package’s listenerQueue is assigned different variable names.

PackageQueue VariableIndex Variable
@annotorious/coreI = []z = 0
@annotorious/annotoriousMe = []Oe = 0
@annotorious/openseadragonGe = []Ir = 0

Since atom notification queues are fragmented across packages, state changes fired in one package no longer reach subscribers in another package.

Factor 2: Selection State Subscription Chain

Annotorious’s drawing layer subscribes to the Selection atom to control enabling/disabling OpenSeadragon’s mouse navigation.

UserSDCMseraoelalulewlsecisectntigOesoSvnlDeta'noaystoteslorsmerdteueMaptocdeuhacsttedesNrdaacvwhEiannnaggbelteodo(lfalse)

If atom notifications don’t propagate correctly, OSD’s mouse navigation remains enabled, and all pointer events are captured by OSD before reaching the drawing tool.

Factor 3: Difference in Equality Comparison Behavior

v3.7.4 (Svelte writable)v3.7.19 (nanostores atom)
.set() guardObjects always notify (safe_not_equal returns true for any object)!== only (same reference = skip)
s}e,tli}n(efant$$neaaowoottsVllootaddmmolVV..ruaavneellaos)uulteeuia{eft=!yo==(m$=oanlitnedmoewVpmwVal.Valevalumalueelue)nuete)at{ionStrictreferencecomparisononly!

Svelte’s writable always notified subscribers on object .set(), but nanostores’ atom skips notification if the reference is the same. If there is a code path that mutates an object and passes the same reference to .set(), notifications are silently dropped.

Solution

All Annotorious package versions were pinned by removing the caret (^).

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

After pinning to version 3.7.4, drawing mode was confirmed to work correctly in the production environment on Vercel.

Additionally, Issue #599 was created in the Annotorious repository to report the production webpack build issue.

Lessons Learned

1. Semver Caret Specification Is Not “Safe”

"^3.7.4" means “the latest compatible version within 3.x.x,” but in reality, breaking changes can be introduced even in minor versions. Internal implementation changes (such as state management library migrations) in particular can cause unexpected issues through interaction with bundler optimizations, even when the API surface remains compatible.

Consider exact pinning (no caret) for critical libraries.

2. Investigation Methods for Bugs That Only Reproduce in Production Builds

For bugs that don’t reproduce in development, consider the following:

  • Scope hoisting: webpack’s ModuleConcatenationPlugin is only active in production. Affects libraries with module-level mutable state
  • Tree shaking: Code paths that exist during development may be removed
  • Minification: Variable name shortening makes debugging difficult
  • Dependency duplication: Multiple copies of the same library may be included in the bundle

3. Watch package-lock.json Diffs Closely

npm install updates caret-specified dependencies to the latest version when regenerating package-lock.json. Check package-lock.json diffs before and after major changes to verify no unintended upgrades have occurred.

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

4. Playwright Is Effective for Debugging Deployed Environments

Even for production environment bugs that are hard to reproduce with browser DevTools, Playwright allows programmatic verification of DOM state and styles, greatly aiding problem isolation.

Summary

ItemDetails
SymptomsAnnotorious drawing mode not working in production build
Causev3.7.13 nanostores migration + webpack scope hoisting
Scope of impactwebpack production build only (not reproducible in development mode)
SolutionPin version to 3.7.4
Reportannotorious/annotorious#599

Bugs that only reproduce in production builds are extremely difficult to investigate, but by identifying “when it broke” and carefully tracing the diff, you can reach the root cause. I hope this serves as a reference for anyone facing similar issues.