Skip to content

Bug: radix-ui umbrella Avatar component pulls in CJS require("react") shim — crashes in ESM-only browser environments #3856

@fs-projects

Description

@fs-projects

Package: radix-ui (umbrella package, e.g. ^1.4.x)
Affected component: Avatar (via use-sync-external-store)
Environment: Vite 8 / Rolldown in library mode (build.lib), ESM output (format: 'es'), React/react-dom externalized


Summary

When building a Vite component library in ESM library mode with react externalized, importing Avatar from the radix-ui umbrella package causes the bundler to include a CJS interop shim for use-sync-external-store. That shim unconditionally calls require("react") at module evaluation time, crashing any browser that loads the ESM bundle.


Error

Uncaught Error: Calling `require` for "react" in an environment that doesn't expose the `require` function.
    at avatar-XXXXXXXX.esm.js:89

The crash happens immediately when the avatar chunk is evaluated — before any component renders.


Reproduction

Library (component package) — Vite config

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { glob } from "glob";
import path from "path";

export default defineConfig({
  plugins: [react(),....],
  server: {
    port: 3002,
  },
  resolve: {
    dedupe: ['react', 'react-dom'],
  },
  build: {
    lib: {
      entry: resolve(__dirname, 'src/main.ts'),
      formats: ['es'],
    },
    rolldownOptions: {
      external: [/^react($|\/)/, /^react-dom($|\/)/],
      input: Object.fromEntries(
        glob

          .sync('src/**/*.{ts,tsx}', {
            ignore: ['src/**/*.d.ts', 'src/_playground/**'],
          })

          .map((file) => {
            return [
              relative(
                'src',
                file.slice(0, file.length - extname(file).length),
              ),
              fileURLToPath(new URL(file, import.meta.url)),
            ];
          }),
      ),
      output: {
        entryFileNames: '[name].esm.js',
        assetFileNames: 'assets/[name][extname]',
        chunkFileNames: 'chunks/[name]-[hash].esm.js',
      },
    },
  },
});

Component source — avatar.tsx

// src/components/ui/avatar.tsx
import { Avatar as AvatarPrimitive } from "radix-ui"; // ← umbrella import

function Avatar(props: React.ComponentProps<typeof AvatarPrimitive.Root>) {
  return <AvatarPrimitive.Root {...props} />;
}

function AvatarImage(
  props: React.ComponentProps<typeof AvatarPrimitive.Image>,
) {
  return <AvatarPrimitive.Image {...props} />;
}

...
export { Avatar, AvatarImage, .... };

Barrel export — main.ts

// src/main.ts
export * from "./components/ui/avatar";
export * from "./components/ui/dropdown-menu";
// ...

Consumer app — any file importing from the library

// Any file in the consumer app:
import { Button } from "@your-org/ui";
// ↑ Because avatar is in the barrel, avatar.esm.js is eagerly evaluated,
//   triggering the require("react") call even if Avatar is never used.

What the bundler emits

The built avatar-XXXXXXXX.esm.js chunk contains roughly:

// avatar-XXXXXXXX.esm.js  (simplified)
import { createRequire } from "module";
const require = createRequire(import.meta.url); // works in Node
// — or the Rolldown/Rollup guard version: —
function o(mod) {
  if (typeof require !== "undefined") return require(mod);
  throw new Error(
    `Calling \`require\` for "${mod}" in an environment that doesn't expose the \`require\` function.`,
  );
}

// use-sync-external-store CJS shim calls it immediately:
var ReactDOM = o("react"); // ← throws in browser

The shared helper chunk (dist-XXXXXXXX.esm.js) defines the o() guard and is imported by both avatar and dropdown-menu chunks — but only avatar exercises the call path via use-sync-external-store.


Potential root cause

@radix-ui/react-avatar depends on use-sync-external-store/shim. That shim ships both a CJS and an ESM variant. When the umbrella radix-ui package re-exports Avatar, some resolution paths (particularly in Vite/Rolldown with preserveModules: true) resolve the shim to its CJS variant. The CJS variant unconditionally calls require("react") during module initialization, which is incompatible with pure-ESM browser environments.

Other radix primitives that do not depend on use-sync-external-store (e.g. DropdownMenu, Button/Slot) are not affected — their chunks import the shared guard helper but never invoke it.


Impact

Any consumer app (React SPA, Next.js client component, etc.) that:

  1. Installs a component library built in Vite ESM library mode, and
  2. Imports anything from that library's barrel entry

…will crash immediately on page load, even if they never use <Avatar>.


What I have tried

Use scoped package instead of umbrella

npm install @radix-ui/react-avatar
// Before:
import { Avatar as AvatarPrimitive } from "radix-ui";

// After:
import * as AvatarPrimitive from "@radix-ui/react-avatar";

Didn't work, same issue encountered.

Expected behaviour

The umbrella radix-ui package's Avatar re-export should resolve (or force Rolldown to pick) the ESM variant of use-sync-external-store/shim, preventing any require() call from appearing in ESM output.


Environment

radix-ui ^1.4.3
vite ^8.0.x
Bundler Rolldown (Vite 8 default)
react ^19.x
Node 22.x
Build format es (ESM library mode)
react externalized Yes

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions