Skip to content

Incomplete exports map causes dual-instance of @livekit/components-react under Metro, breaking RoomContext (regression in 2.10.x) #349

@kingh0730

Description

@kingh0730

Summary

The exports field added to package.json in 2.10.x only declares source / types / default conditions — no import, require, or react-native. Under Metro with unstable_enablePackageExports: true (default in Expo SDK 54), this forces every consumer onto the CJS build. If the app also imports @livekit/components-react directly, that import resolves to the ESM build (dist/index.mjs), and Metro ends up loading two parallel instances of @livekit/components-react, each with its own React.createContext(RoomContext).

The result is a silent context mismatch:

  • <SessionProvider> (imported directly from @livekit/components-react) provides Instance A's RoomContext.
  • useRoomContext re-exported from @livekit/react-native reads Instance B's RoomContext, which has no provider, and throws:
Error: tried to access room context outside of livekit room component

The same hazard hits any context-bearing re-export (useLocalParticipant, useParticipantTracks, useTracks, useTrackToggle, useSessionMessages, useVoiceAssistant, ParticipantContext, TrackRefContext, etc.) — the throw site just depends on which one runs first.

This is adjacent to #338 / #343 (which fixed the missing .js extension on the same field) but is a different problem: even with the extension fix, the lack of import / require / react-native conditions causes the dual-instance.

Why I think this is a regression

@livekit/react-native@2.9.5 had no exports field, only legacy main / module / react-native / source. Under Metro the react-native field pointed at src/index.tsx, which uses real ESM import syntax — Metro resolves that with the import condition and lands on @livekit/components-react's ESM build, the same instance a downstream consumer's direct import from @livekit/components-react gets. No dual-instance, everything works. The official agent-starter-react-native example pins @livekit/react-native@^2.9.5 and works for exactly this reason.

The exports map added in 2.10.x doesn't include import, require, or react-native conditions, so Metro falls through to default (CJS) regardless of how the consumer imports. The CJS file then require()s @livekit/components-react, which under the require condition lands on its CJS build — a different module instance from the ESM build that direct consumers get.

Repro

  • Expo SDK 54, RN 0.81.5, expo-router 6.0.23, React 19.1, Metro defaults.
  • @livekit/react-native@2.10.2, @livekit/components-react@2.9.20, livekit-client@2.18.8.
  • Standard Session-based pattern: <ConnectionProvider> (using useSession + <SessionProvider> from @livekit/components-react) wrapping a <Stack>. Inside a screen, call useRoomContext() imported from @livekit/react-native.
  • On the first re-render after disconnected → connecting, throws.

Switching the screen's import from @livekit/react-native to @livekit/components-react makes the error go away — same symbol name, different module instance. That's the smoking gun.

Suggested fix

Add explicit conditions to the exports field so Metro / Node / bundlers can pick the right build:

"exports": {
  ".": {
    "source": "./src/index.tsx",
    "types": "./lib/typescript/src/index.d.ts",
    "react-native": "./src/index.tsx",
    "import":  "./lib/module/index.js",
    "require": "./lib/commonjs/index.js",
    "default": "./lib/commonjs/index.js"
  }
}

The react-native condition restores the pre-2.10 behavior for Metro; import lets Node / bundlers reach the ESM build for tree-shaking; require and default keep CJS consumers working.

Workarounds today

  1. Import context-bearing symbols (useRoomContext, useLocalParticipant, useParticipantTracks, useTracks, useTrackToggle, useSessionMessages, VideoTrack, etc.) directly from @livekit/components-react. Only keep truly RN-specific exports (AudioSession, useIOSAudioManagement, registerGlobals, useTrackVolume, useMultibandTrackVolume, useE2EEManager) coming from @livekit/react-native.
  2. Force Metro onto a single condition in metro.config.js: config.resolver.unstable_conditionNames = ["react-native", "require"]. Collapses everything onto CJS at the cost of ESM tree-shaking.

Environment

  • @livekit/react-native: 2.10.2
  • @livekit/components-react: 2.9.20
  • livekit-client: 2.18.8
  • Expo SDK 54, expo-router 6.0.23, RN 0.81.5, React 19.1
  • Metro: unstable_enablePackageExports: true, unstable_conditionNames: [] (Expo defaults)

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