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
autoinstead of changing tocrosshair - 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.
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.
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.
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.
| Package | Before | After |
|---|---|---|
@annotorious/core | 3.7.4 | 3.7.19 |
@annotorious/annotorious | 3.7.4 | 3.7.19 |
@annotorious/openseadragon | 3.7.4 | 3.7.19 |
@annotorious/react | 3.7.4 | 3.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:
v3.7.19 dependencies:
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.
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.
| Package | Queue Variable | Index Variable |
|---|---|---|
@annotorious/core | I = [] | z = 0 |
@annotorious/annotorious | Me = [] | Oe = 0 |
@annotorious/openseadragon | Ge = [] | 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.
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() guard | Objects always notify (safe_not_equal returns true for any object) | !== only (same reference = skip) |
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 (^).
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
ModuleConcatenationPluginis 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.
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
| Item | Details |
|---|---|
| Symptoms | Annotorious drawing mode not working in production build |
| Cause | v3.7.13 nanostores migration + webpack scope hoisting |
| Scope of impact | webpack production build only (not reproducible in development mode) |
| Solution | Pin version to 3.7.4 |
| Report | annotorious/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.