Summary
In a pnpm monorepo, @proofkit/webviewer can be installed as two physical copies via peer-dependency fan-out. Because the fetch-callback registry (cbs) and window.handleFmWVFetchCallback are module-level state in main.js, two copies split the registry: one copy registers the callback while the other owns the global handler. Every fmFetch/WebViewerAdapter response then logs Callback is missing for fetchId: … and the promise never resolves (eternal spinner). The failure is silent and points the developer at FileMaker/the bridge rather than the bundler.
Root cause
window.handleFmWVFetchCallback is overwritten on each module load (last loaded wins), and it closes over that copy's cbs. With ≥2 copies, the callback fired by FileMaker lands in the wrong registry. In our repo the fork came from a varlock peer mismatch (1.3.0 vs 1.4.0); next's optional @babel/core peer is a second fan-out axis.
Repro
- Monorepo where one workspace pins a peer (e.g.
varlock) at a different version than another.
- App A imports
@proofkit/webviewer directly; App A also consumes a workspace package that imports @proofkit/webviewer/adapter.
find node_modules/.pnpm -path "*@proofkit+webviewer*/dist/esm/main.js" → more than one copy.
- Any
fmFetch → Callback is missing for fetchId, promise hangs.
Example: the only difference between the two resolved instances in our lockfile was the varlock peer:
@proofkit/webviewer@3.1.0(next@16.2.6(@babel/core@7.29.7)...(varlock@1.3.0))(react@19.2.6)
@proofkit/webviewer@3.1.0(next@16.2.6...(varlock@1.4.0))(react@19.2.6)
Suggested fixes (library side)
- Hoist the registry + global handler to a shared realm-global (e.g.
globalThis.__proofkitWV ??= { cbs: {}, … }) so all copies share one registry instead of clobbering each other.
- And/or emit a one-time
console.warn when window.handleFmWVFetchCallback is reassigned by a different module instance, to surface the dupe loudly.
- Document the
resolve.dedupe / single-funnel workaround.
Workaround (consumer side)
Funnel all webviewer access through one workspace package and add resolve.dedupe: ["@proofkit/webviewer"] to the Vite config.
Versions: @proofkit/webviewer@3.1.0, pnpm 9.12.3, Vite (single-file build), React 19.
Summary
In a pnpm monorepo,
@proofkit/webviewercan be installed as two physical copies via peer-dependency fan-out. Because the fetch-callback registry (cbs) andwindow.handleFmWVFetchCallbackare module-level state inmain.js, two copies split the registry: one copy registers the callback while the other owns the global handler. EveryfmFetch/WebViewerAdapterresponse then logsCallback is missing for fetchId: …and the promise never resolves (eternal spinner). The failure is silent and points the developer at FileMaker/the bridge rather than the bundler.Root cause
window.handleFmWVFetchCallbackis overwritten on each module load (last loaded wins), and it closes over that copy'scbs. With ≥2 copies, the callback fired by FileMaker lands in the wrong registry. In our repo the fork came from avarlockpeer mismatch (1.3.0vs1.4.0);next's optional@babel/corepeer is a second fan-out axis.Repro
varlock) at a different version than another.@proofkit/webviewerdirectly; App A also consumes a workspace package that imports@proofkit/webviewer/adapter.find node_modules/.pnpm -path "*@proofkit+webviewer*/dist/esm/main.js"→ more than one copy.fmFetch→Callback is missing for fetchId, promise hangs.Example: the only difference between the two resolved instances in our lockfile was the
varlockpeer:Suggested fixes (library side)
globalThis.__proofkitWV ??= { cbs: {}, … }) so all copies share one registry instead of clobbering each other.console.warnwhenwindow.handleFmWVFetchCallbackis reassigned by a different module instance, to surface the dupe loudly.resolve.dedupe/ single-funnel workaround.Workaround (consumer side)
Funnel all webviewer access through one workspace package and add
resolve.dedupe: ["@proofkit/webviewer"]to the Vite config.Versions:
@proofkit/webviewer@3.1.0, pnpm 9.12.3, Vite (single-file build), React 19.