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
- 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.
- 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)
Summary
The
exportsfield added topackage.jsonin 2.10.x only declaressource/types/defaultconditions — noimport,require, orreact-native. Under Metro withunstable_enablePackageExports: true(default in Expo SDK 54), this forces every consumer onto the CJS build. If the app also imports@livekit/components-reactdirectly, 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 ownReact.createContext(RoomContext).The result is a silent context mismatch:
<SessionProvider>(imported directly from@livekit/components-react) provides Instance A'sRoomContext.useRoomContextre-exported from@livekit/react-nativereads Instance B'sRoomContext, which has no provider, and throws: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
.jsextension on the same field) but is a different problem: even with the extension fix, the lack ofimport/require/react-nativeconditions causes the dual-instance.Why I think this is a regression
@livekit/react-native@2.9.5had noexportsfield, only legacymain/module/react-native/source. Under Metro thereact-nativefield pointed atsrc/index.tsx, which uses real ESMimportsyntax — Metro resolves that with theimportcondition and lands on@livekit/components-react's ESM build, the same instance a downstream consumer's directimportfrom@livekit/components-reactgets. No dual-instance, everything works. The officialagent-starter-react-nativeexample pins@livekit/react-native@^2.9.5and works for exactly this reason.The
exportsmap added in 2.10.x doesn't includeimport,require, orreact-nativeconditions, so Metro falls through todefault(CJS) regardless of how the consumer imports. The CJS file thenrequire()s@livekit/components-react, which under therequirecondition lands on its CJS build — a different module instance from the ESM build that direct consumers get.Repro
@livekit/react-native@2.10.2,@livekit/components-react@2.9.20,livekit-client@2.18.8.<ConnectionProvider>(usinguseSession+<SessionProvider>from@livekit/components-react) wrapping a<Stack>. Inside a screen, calluseRoomContext()imported from@livekit/react-native.disconnected → connecting, throws.Switching the screen's import from
@livekit/react-nativeto@livekit/components-reactmakes the error go away — same symbol name, different module instance. That's the smoking gun.Suggested fix
Add explicit conditions to the
exportsfield so Metro / Node / bundlers can pick the right build:The
react-nativecondition restores the pre-2.10 behavior for Metro;importlets Node / bundlers reach the ESM build for tree-shaking;requireanddefaultkeep CJS consumers working.Workarounds today
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.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.20livekit-client: 2.18.8unstable_enablePackageExports: true,unstable_conditionNames: [](Expo defaults)