Skip to content

Commit 457bc08

Browse files
CAMOBAPclaude
andcommitted
feat: add Expo module isolation via scoped AppContext per sandbox origin
Auto-detects expo-modules-core at build time so vanilla RN projects are unaffected: - iOS (podspec): File.exist? check sets RNS_HAS_EXPO_MODULES=1 via pod_target_xcconfig and pulls in ios/Expo/** sources + expo-modules-core/expo dependencies only when present - Android (build.gradle): rootProject.file("node_modules/expo-modules-core").exists() gates the src/expo/java source set, expo-modules-core dependency, and HAS_EXPO_MODULES BuildConfig field iOS isolation (gated by #if RNS_HAS_EXPO_MODULES): - SandboxExpoFactory.mm: creates ExpoReactNativeFactory instead of RCTReactNativeFactory, enabling Expo module registration inside the sandbox RCTHost - SandboxAppContextBridge.swift: Swift bridge that creates EXAppContext with a scoped AppContextConfig (documentDirectory/cacheDirectory under SandboxData/<sanitized-origin>/) - SandboxReactNativeDelegate.mm: injects the scoped EXAppContext via ExpoBridgeModule.initWithAppContext: in getModuleInstanceFromClass: so Expo modules (FileSystem, SecureStore, etc.) read/write origin-namespaced directories Android isolation: - SandboxContextWrapper already scopes getFilesDir/getCacheDir/getSharedPreferences to the origin; AppContext receives this wrapped context so Expo modules are automatically scoped - ExpoIntegration.kt provides a ModuleRegistryAdapter with the host project's Expo packages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 3deecec commit 457bc08

8 files changed

Lines changed: 152 additions & 5 deletions

File tree

packages/react-native-sandbox/React-Sandbox.podspec

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ require "json"
22

33
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
44

5+
has_expo = File.exist?(File.join(__dir__, "../../node_modules/expo-modules-core"))
6+
57
header_search_paths = [
68
"\"$(PODS_TARGET_SRCROOT)/ReactCommon\"",
79
"\"$(PODS_ROOT)/Headers/Private/React-Core\"",
@@ -18,12 +20,20 @@ Pod::Spec.new do |s|
1820
s.authors = { "Alex Babrykovich" => "aliaksandr.babrykovich@callstack.com" }
1921
s.platforms = { :ios => "12.4" }
2022
s.source = { :git => "https://github.com/callstackincubator/react-native-sandbox.git", :tag => "#{s.version}" }
21-
s.source_files = ["ios/**/*.{h,m,mm,cpp,swift}", "cxx/**/*.{h,cpp}"]
23+
s.source_files = ["ios/*.{h,m,mm,cpp}", "cxx/**/*.{h,cpp}"]
2224
install_modules_dependencies(s)
2325
s.dependency "fmt"
24-
s.pod_target_xcconfig = {
26+
27+
expo_xcconfig = {}
28+
if has_expo
29+
s.source_files += ["ios/Expo/**/*.{h,m,mm,swift}"]
30+
s.dependency "expo-modules-core"
31+
s.dependency "expo"
32+
expo_xcconfig = { "OTHER_CPLUSPLUSFLAGS" => "$(inherited) -DRNS_HAS_EXPO_MODULES=1" }
33+
end
34+
35+
s.pod_target_xcconfig = {
2536
"HEADER_SEARCH_PATHS" => header_search_paths + ["\"$(PODS_TARGET_SRCROOT)/cxx\""],
26-
# "OTHER_CPLUSPLUSFLAGS" => "-DFOLLY_NO_CONFIG -DFOLLY_MOBILE=1 -DFOLLY_USE_LIBCPP=1",
2737
"CLANG_CXX_LANGUAGE_STANDARD" => "c++17"
28-
}
38+
}.merge(expo_xcconfig)
2939
end

packages/react-native-sandbox/android/build.gradle

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ apply plugin: "com.android.library"
1616
apply plugin: "org.jetbrains.kotlin.android"
1717
apply plugin: "com.facebook.react"
1818

19+
def hasExpo = rootProject.file("node_modules/expo-modules-core").exists()
20+
1921
def safeExtGet(prop, fallback) {
2022
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
2123
}
@@ -27,6 +29,7 @@ android {
2729
defaultConfig {
2830
minSdkVersion safeExtGet("minSdkVersion", 24)
2931
targetSdkVersion safeExtGet("targetSdkVersion", 35)
32+
buildConfigField "boolean", "HAS_EXPO_MODULES", "${hasExpo}"
3033

3134
externalNativeBuild {
3235
cmake {
@@ -45,6 +48,7 @@ android {
4548
sourceSets {
4649
main {
4750
java.srcDirs += ["src/main/java"]
51+
if (hasExpo) java.srcDirs += ["src/expo/java"]
4852
}
4953
}
5054

@@ -59,6 +63,7 @@ android {
5963

6064
buildFeatures {
6165
prefab true
66+
buildConfig true
6267
}
6368
}
6469

@@ -69,4 +74,7 @@ repositories {
6974

7075
dependencies {
7176
implementation "com.facebook.react:react-android:+"
77+
if (hasExpo) {
78+
implementation "expo.modules:expo-modules-core:+"
79+
}
7280
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package io.callstack.rnsandbox
2+
3+
import com.facebook.react.ReactPackage
4+
import expo.modules.adapters.react.ModuleRegistryAdapter
5+
import expo.modules.adapters.react.ReactAdapterPackage
6+
import expo.modules.adapters.react.ReactModuleRegistryProvider
7+
import expo.modules.kotlin.ExpoModulesHelper
8+
9+
/**
10+
* Wires Expo modules into a sandbox React Native host.
11+
*
12+
* Each sandbox already wraps its ReactApplicationContext with SandboxContextWrapper,
13+
* which overrides getFilesDir(), getCacheDir(), and getSharedPreferences() to return
14+
* origin-namespaced paths. Since Expo's AppContext receives this wrapped context, all
15+
* Expo modules (FileSystem, SecureStore, etc.) automatically operate in scoped
16+
* directories without additional AppContext configuration.
17+
*/
18+
internal object ExpoIntegration {
19+
/**
20+
* Returns the ReactPackage instances required to expose Expo modules inside a
21+
* sandbox. Include the result in the sandbox's package list when
22+
* BuildConfig.HAS_EXPO_MODULES is true.
23+
*/
24+
fun createPackages(): List<ReactPackage> {
25+
val registryProvider = ReactModuleRegistryProvider(listOf(ReactAdapterPackage()), null)
26+
val modulesProvider = ExpoModulesHelper.modulesProvider
27+
return listOf(ModuleRegistryAdapter(registryProvider, modulesProvider))
28+
}
29+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright 2025-present Callstack. All rights reserved.
2+
3+
import ExpoModulesCore
4+
import Foundation
5+
6+
/// Bridges Swift-only Expo APIs to ObjC so the sandbox can create a scoped
7+
/// EXAppContext per origin without exposing Swift value types across the boundary.
8+
@objc(SandboxAppContextBridge)
9+
public class SandboxAppContextBridge: NSObject {
10+
/// Returns an EXAppContext whose document and cache directories are namespaced
11+
/// to the given sandbox origin. Callers own the returned object.
12+
@objc public static func createScopedAppContext(forOrigin origin: String) -> EXAppContext {
13+
let sanitized =
14+
origin
15+
.replacingOccurrences(of: "/", with: "-")
16+
.replacingOccurrences(of: "@", with: "-")
17+
.replacingOccurrences(of: ":", with: "-")
18+
19+
let fileManager = FileManager.default
20+
let docs = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first
21+
let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first
22+
23+
let docDir = docs?.appendingPathComponent("SandboxData/\(sanitized)")
24+
let cacheDir = caches?.appendingPathComponent("SandboxData/\(sanitized)")
25+
26+
let config = AppContextConfig(
27+
documentDirectory: docDir,
28+
cacheDirectory: cacheDir,
29+
appGroups: nil
30+
)
31+
return AppContext(config: config)
32+
}
33+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Copyright 2025-present Callstack. All rights reserved.
2+
//
3+
// Thin helper so Core ObjC++ files can create an ExpoReactNativeFactory without
4+
// directly importing the Expo Swift-generated header (which is only available
5+
// when expo-modules-core is present and RNS_HAS_EXPO_MODULES=1).
6+
7+
#import <React-RCTAppDelegate/RCTReactNativeFactory.h>
8+
9+
@protocol RCTReactNativeFactoryDelegate;
10+
11+
NS_ASSUME_NONNULL_BEGIN
12+
13+
/// Returns a new ExpoReactNativeFactory configured with the given delegate.
14+
/// Core files use this function behind a #if RNS_HAS_EXPO_MODULES guard so the
15+
/// Expo pod dependency is only pulled in when expo-modules-core is present.
16+
RCTReactNativeFactory *SandboxCreateExpoFactory(id<RCTReactNativeFactoryDelegate> delegate);
17+
18+
NS_ASSUME_NONNULL_END
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Copyright 2025-present Callstack. All rights reserved.
2+
3+
#import "SandboxExpoFactory.h"
4+
5+
// ExpoReactNativeFactory is a Swift class in the Expo pod. Its ObjC interface is
6+
// generated during build and available via the Swift compatibility header.
7+
#if __has_include(<Expo/Expo-Swift.h>)
8+
#import <Expo/Expo-Swift.h>
9+
#elif __has_include(<Expo/Swift.h>)
10+
#import <Expo/Swift.h>
11+
#else
12+
#import "Expo-Swift.h"
13+
#endif
14+
15+
RCTReactNativeFactory *SandboxCreateExpoFactory(id<RCTReactNativeFactoryDelegate> delegate)
16+
{
17+
return [[ExpoReactNativeFactory alloc] initWithDelegate:delegate];
18+
}

packages/react-native-sandbox/ios/SandboxReactNativeDelegate.mm

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@
3535
#include "SandboxRegistry.h"
3636
#import "StubTurboModuleCxx.h"
3737

38+
#if RNS_HAS_EXPO_MODULES
39+
#import <ExpoModulesCore/ExpoBridgeModule.h>
40+
#if __has_include(<React-Sandbox/React-Sandbox-Swift.h>)
41+
#import <React-Sandbox/React-Sandbox-Swift.h>
42+
#else
43+
#import "React-Sandbox-Swift.h"
44+
#endif
45+
#endif
46+
3847
namespace jsi = facebook::jsi;
3948
namespace TurboModuleConvertUtils = facebook::react::TurboModuleConvertUtils;
4049
using namespace facebook::react;
@@ -655,6 +664,20 @@ - (Class)getModuleClassFromName:(const char *)name
655664
}
656665
}
657666

667+
#if RNS_HAS_EXPO_MODULES
668+
// Scope the Expo modules bridge to this sandbox's origin. Each sandbox already
669+
// gets its own ExpoBridgeModule instance (via its own RCTHost), but the default
670+
// init creates an un-scoped EXAppContext. Inject a scoped one so Expo modules
671+
// (file system, secure store, etc.) write to origin-namespaced directories.
672+
if ([moduleClass isSubclassOfClass:[ExpoBridgeModule class]]) {
673+
NSString *originNS = [NSString stringWithUTF8String:_origin.c_str()];
674+
EXAppContext *ctx = [SandboxAppContextBridge createScopedAppContextForOrigin:originNS];
675+
ExpoBridgeModule *bridgeModule = [[moduleClass alloc] initWithAppContext:ctx];
676+
_substitutedModuleInstances[moduleName] = bridgeModule;
677+
return (id<RCTTurboModule>)bridgeModule;
678+
}
679+
#endif
680+
658681
if (!isSubstitutionTarget) {
659682
return nullptr;
660683
}

packages/react-native-sandbox/ios/SandboxReactNativeViewComponentView.mm

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
#import <ReactCommon/RCTHost.h>
1414

1515
#import "SandboxReactNativeDelegate.h"
16+
17+
#if RNS_HAS_EXPO_MODULES
18+
#import "SandboxExpoFactory.h"
19+
#endif
1620
#import <objc/runtime.h>
1721

1822
#include "SandboxRegistry.h"
@@ -225,7 +229,7 @@ - (void)scheduleReactViewLoad
225229
self.didScheduleLoad = YES;
226230

227231
dispatch_async(dispatch_get_main_queue(), ^{
228-
[self loadReactNativeView];
232+
[self loadReactNativeView];
229233
self.didScheduleLoad = NO;
230234
});
231235
}
@@ -345,7 +349,11 @@ - (void)acquireFactory
345349
}
346350
}
347351

352+
#if RNS_HAS_EXPO_MODULES
353+
self.reactNativeFactory = SandboxCreateExpoFactory(self.reactNativeDelegate);
354+
#else
348355
self.reactNativeFactory = [[RCTReactNativeFactory alloc] initWithDelegate:self.reactNativeDelegate];
356+
#endif
349357

350358
if (origin.length > 0) {
351359
SharedFactory *shared = [SharedFactory new];

0 commit comments

Comments
 (0)