A plugin registry populated during startup is empty when the application reads it. A configuration set once is missing at the point of use. Nothing throws, and the reported symptom is unhelpfully vague:

Error: No renderer registered for "markdown"
    at render (/app/node_modules/@scope/engine/dist/index.cjs:88:11)

The renderer was registered — into the other copy of the module. When a dual-format package is loaded through both its import and require conditions in one process, everything at module scope exists twice.


Root Cause

Node.js keeps separate caches for ESM and CommonJS, and the dual-package hazard is what happens when both load the same package: two module instances, each with its own top-level variables. Any state held in a module-scoped Map, Set, object or class static is duplicated with it.

The state can be brought back together without eliminating the duplication, because JavaScript has one thing that is genuinely process-wide: the registered symbol table reachable through Symbol.for. A value stored on globalThis under a registered symbol is visible to every copy of every module in the process, which is exactly the scope a singleton needs.


Minimal Reproduction

// src/registry.ts — module-scoped state, duplicated with the module
const renderers = new Map<string, Renderer>();

export function register(name: string, r: Renderer): void { renderers.set(name, r); }
export function get(name: string): Renderer | undefined { return renderers.get(name); }
// app.mjs
import { register } from "@scope/engine";           // loads the ESM copy
import { renderMarkdown } from "./markdown.js";
register("markdown", renderMarkdown);

const { get } = require("@scope/engine");           // loads the CJS copy
console.log(get("markdown"));                       // undefined

Two caches, two Map instances, one confusing bug report.


Where the State Lives

Module-scoped versus process-scoped state With module-scoped state each copy of the package holds its own map. With state stored on globalThis under a registered symbol, both copies read and write the same object. Same two copies — different place to keep the data module-scoped ESM copy Map A CJS copy Map B writes to one are invisible to the other, permanently the default, and the bug process-scoped ESM copy CJS copy globalThis[Symbol.for(...)] one object, both copies see it

Step-by-Step Fix

1. Move the state behind a registered symbol

// src/state.ts
const STATE_KEY = Symbol.for("@scope/engine:v2:state");

export interface EngineState {
  renderers: Map<string, Renderer>;
  config: { strict: boolean };
}

export function getState(): EngineState {
  const g = globalThis as Record<symbol, unknown>;
  if (!g[STATE_KEY]) {
    g[STATE_KEY] = { renderers: new Map(), config: { strict: false } } satisfies EngineState;
  }
  return g[STATE_KEY] as EngineState;
}

Symbol.for is doing the work: it looks up a process-wide table by string, so both copies resolve the identical symbol. The v2 segment in the key isolates major versions, which matters because two majors of your package can legitimately coexist in one dependency tree with incompatible state shapes.

2. Route every stateful export through it

- const renderers = new Map<string, Renderer>();
-
- export function register(name: string, r: Renderer): void { renderers.set(name, r); }
- export function get(name: string): Renderer | undefined { return renderers.get(name); }
+ import { getState } from "./state.js";
+
+ export function register(name: string, r: Renderer): void { getState().renderers.set(name, r); }
+ export function get(name: string): Renderer | undefined { return getState().renderers.get(name); }
node --input-type=module -e "
  const { register } = await import('@scope/engine');
  const { createRequire } = await import('node:module');
  const { get } = createRequire(import.meta.url)('@scope/engine');
  register('markdown', () => 'ok');
  console.log('shared:', typeof get('markdown'));
"

Expected output: shared: function.

3. Replace identity checks with structural ones

Shared state does not merge the class objects, so instanceof still fails across copies. Export a type guard instead of asking consumers to use instanceof:

const BRAND = Symbol.for("@scope/engine:v2:renderer");

export class Renderer {
  readonly [BRAND] = true;
  constructor(readonly name: string) {}
}

export function isRenderer(value: unknown): value is Renderer {
  return typeof value === "object" && value !== null && BRAND in value;
}
- if (value instanceof Renderer) { /* ... */ }
+ if (isRenderer(value)) { /* ... */ }
Identity versus structural checks across copies An object created by one copy of the module fails an instanceof test performed by the other copy, because each defines its own class. A branded property check keyed on a registered symbol succeeds in both. Shared state is not shared identity value instanceof Renderer each copy defines its own class cross-copy check returns false fails silently, in production isRenderer(value) checks a registered-symbol brand true across every copy export it, and document it

4. Warn when duplication is detected

const COPIES = Symbol.for("@scope/engine:v2:copies");
const g = globalThis as Record<symbol, unknown>;
const copies = ((g[COPIES] ??= new Set<symbol>()) as Set<symbol>);
copies.add(Symbol("instance"));

if (copies.size > 1 && process.env.NODE_ENV !== "production") {
  console.warn(
    `[@scope/engine] ${copies.size} copies loaded in one process. ` +
    "State is shared, but instanceof checks across copies will fail."
  );
}

HAZARD PREVENTION

Symptom: State is shared correctly, and a consumer still reports that objects from your package are rejected by your own API.

Root cause: An internal instanceof check remains somewhere in the codebase, so a value created by the other copy is rejected by a guard you did not convert.

Fix: Grep for instanceof across src/ and convert every check against an exported class. A lint rule banning instanceof for your own types keeps it converted.


Verification

// test/dual-load.test.ts
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";

describe("dual-format state", () => {
  it("shares state between the ESM and CommonJS copies", async () => {
    const esm = await import("@scope/engine");
    const cjs = createRequire(import.meta.url)("@scope/engine");

    esm.register("markdown", () => "rendered");
    expect(cjs.get("markdown")).toBeTypeOf("function");
  });

  it("recognises values created by the other copy", async () => {
    const esm = await import("@scope/engine");
    const cjs = createRequire(import.meta.url)("@scope/engine");
    expect(cjs.isRenderer(new esm.Renderer("x"))).toBe(true);
  });
});

Expected: both tests pass, and they fail the day someone adds a second entry point that reintroduces module-scoped state.


Edge Cases / Gotchas

  • Worker threads have separate globals. The registered symbol table is per-realm, so a worker gets its own state — usually correct, occasionally surprising for a registry expected to be process-wide.
  • Bundlers may inline Symbol.for calls unchanged, which is fine. What breaks is a build that replaces Symbol.for with a plain Symbol through an over-eager optimisation; verify the emitted output contains Symbol.for.
  • Version-keyed state means two majors do not interoperate. That is the intended trade-off, and worth documenting so a consumer with both majors installed understands why registrations do not cross.
  • Serialised state cannot hold symbols. If any part of the state is persisted or sent across a boundary, the brand does not survive; re-brand on the way back in.
  • Test runners can load both formats unintentionally. A test suite that imports the ESM build while a helper requires the CommonJS one is the ideal place for the duplication test to live, because the condition is already present.

Frequently Asked Questions

Why does Symbol.for work when a plain Symbol does not?

Symbol.for looks up a process-wide registry keyed by string, so both copies resolve the identical symbol. A plain Symbol() creates a fresh value per evaluation, so each copy gets its own key.

Does sharing state fix instanceof checks as well?

No. Class objects remain distinct per copy, so an object created by one fails an instanceof test in the other. Structural checks are the accompanying change.

Is putting state on globalThis safe?

With a registered symbol key, yes — it cannot collide with another library’s state and does not appear in ordinary enumeration. A string key is a genuine namespace risk.

Should the state be versioned?

Yes, if the shape can change between majors, since two majors can coexist in one process and would otherwise find the same key.

Does this replace fixing the duplication itself?

No. It tolerates duplication; a thin CommonJS wrapper or an ESM-only build removes the second copy entirely and is better where achievable.



↑ Back to Navigating the Dual-Package Hazard