Skip to content

createMapStore factories instantiate twice under pnpm hoisted + Turbopack — toolkit hooks read dead state #993

@downsDrew

Description

@downsDrew

Package: @accelint/map-toolkit@4.0.0 (shared/create-map-store)

Symptom

Cursor display sticks on --, --. Viewport sticks on -- x -- NM. No console errors. No thrown exceptions. MapEvents.hover events fire correctly on the bus singleton — verified by attaching a separate Broadcast.getInstance().on(MapEvents.hover, …) listener that logs every event. The toolkit's own useCursorCoordinates / useMapViewport hooks just don't see the updates.

Same hazard applies in principle to viewportStore, cameraStore, mapModeStore, and any other createMapStore-built singleton.

Empirical proof of dual-instance

import { cursorCoordinateStore } from '@accelint/map-toolkit/cursor-coordinates';

cursorCoordinateStore.set(BASE_MAP_ID, { coordinate: [-98.17, 26.30] });
// cursorCoordinateStore.get(BASE_MAP_ID) returns the new value ✓
// useCursorCoordinates(BASE_MAP_ID) consumer's formattedCoord stays "--, --" ✗

The package-boundary cursorCoordinateStore handle and the internal-relative one consumed by useCursorCoordinates are two different in-memory objects sharing one exported symbol. Their internal instances Maps are not the same Map.

@accelint/bus is unaffected — Broadcast.getInstance() is a true singleton (single class definition in the bundle) and de-dupes correctly. We have pnpm.overrides forcing one bus version.

Root cause

@accelint/map-toolkit/dist/shared/create-map-store.js:

function createMapStore(config) {
  const { defaultState, actions: createActions, bus, onCleanup } = config;
  const instances = new Map();           // module-private closure
  const pendingInitialState = new Map(); // module-private closure
  const subscriptionCache = new Map();   // module-private closure
  const snapshotCache = new Map();       // module-private closure
  // ...
  return { use, useSelector, get, set, subscribe, snapshot, ... };
}

The four Maps are created fresh every time createMapStore runs. The factory runs at module-init time of each store file (cursor-coordinates/store.js, etc.).

Under pnpm hoisted layouts, peer-dep nested node_modules/@accelint/... symlinks resolve to a different specifier path than the top-level node_modules/@accelint/... symlink, even though both target the same on-disk package. Turbopack treats those as distinct modules and re-runs initialization. Two parallel createMapStore invocations → two parallel sets of Maps → two parallel state containers, all returned under the same exported store symbol.

Suggested fix

Anchor the per-store Maps on globalThis keyed by an interned symbol so multiple evaluations converge on the same in-memory state container:

function createMapStore(config) {
  const STORE_KEY = Symbol.for(`@accelint/map-toolkit/${config.id}`);
  const globalState = globalThis[STORE_KEY] ??= {
    instances: new Map(),
    pendingInitialState: new Map(),
    subscriptionCache: new Map(),
    snapshotCache: new Map(),
  };
  const { instances, pendingInitialState, subscriptionCache, snapshotCache } = globalState;
  // ... rest of the factory unchanged, just close over the shared Maps
}

This converges all evaluations onto the same state container while preserving the public API. Symbol.for is intentional — interned symbols are equal across realms, which the bus singleton already relies on.

Happy to PR this if useful.

Consumer workaround

Subscribe directly to Broadcast.getInstance().on(MapEvents.hover, …) / MapEvents.viewport and feed the toolkit's pure formatters (formatDecimalDegrees, getViewportSize). Bypasses useCursorCoordinates / <ViewportSize> entirely. This works because the bus is a true singleton, but it diverges from the documented hook pattern.

Versions

  • @accelint/map-toolkit@4.0.0
  • @accelint/bus@4.0.0 (forced via pnpm.overrides to dedupe)
  • Next.js 16.1.6 (App Router)
  • React 19.1.0 with reactStrictMode: true
  • Turbopack dev mode
  • TypeScript 5.8
  • pnpm 10.9 hoisted node_modules

Severity

Medium — silent UI regression with no console errors. Took meaningful investigation to localize because the hook surface looks like it's working (no exceptions, types match, hover events demonstrably firing on the bus). Affects three downstream consumer apps; we shipped consumer-side workarounds to unblock demos.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions