Skip to content

Commit 6bfb1c6

Browse files
CAMOBAPclaude
andcommitted
feat: add Expo managed workflow (expo-dev-client) support for iOS
- Add runtime ObjC dispatch via objc_getClass/performSelector to derive the sandbox bundle URL from EXDevLauncherController.sourceUrl without a hard compile-time dependency on expo-dev-launcher headers - Add KVO on EXDevLauncherController.sourceUrl in SandboxReactNativeViewComponentView to retry sandbox load once the Expo host bundle URL becomes available (set asynchronously after deep-link network check, ~1-2s after launch) - Expose bundleURL as a public method on SandboxReactNativeDelegate - Add apps/expo-demo: a minimal Expo SDK 54 / RN 0.81.4 managed-workflow app that exercises the sandbox inside an expo-dev-client build - Remove orphaned apps/expo/ (abandoned WIP native build output, no package.json), replace with apps/expo-demo using managed workflow - Pin metro extraNodeModules.react to workspace root to avoid bun hoisting a stale react@19.0.0 into the app's local node_modules Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d558ae6 commit 6bfb1c6

42 files changed

Lines changed: 1470 additions & 1176 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/expo-demo/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ios/
2+
android/

apps/expo-demo/App.tsx

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import SandboxReactNativeView from '@callstack/react-native-sandbox'
2+
import React, {useState} from 'react'
3+
import {SafeAreaView, StyleSheet, Text, View} from 'react-native'
4+
5+
import CrashIfYouCanDemo from './CrashIfYouCanDemo'
6+
7+
const DemoApp: React.FC = () => {
8+
const [lastError, setLastError] = useState<string | null>(null)
9+
10+
return (
11+
<SafeAreaView style={styles.safeArea}>
12+
<View style={styles.container}>
13+
<View style={styles.column}>
14+
<Text style={styles.header}>Main App</Text>
15+
<CrashIfYouCanDemo />
16+
</View>
17+
<View style={[styles.column, styles.columnSandbox]}>
18+
<Text style={styles.header}>Sandboxed</Text>
19+
{lastError && <Text style={styles.errorText}>{lastError}</Text>}
20+
<SandboxReactNativeView
21+
style={styles.sandboxView}
22+
jsBundleSource={'sandbox'}
23+
componentName={'SandboxedDemo'}
24+
onError={error => {
25+
const message = `${error.isFatal ? '[fatal]' : '[warn]'} ${error.name}: ${error.message}`
26+
console.warn('Sandbox error:', message)
27+
setLastError(message)
28+
return false
29+
}}
30+
/>
31+
</View>
32+
</View>
33+
</SafeAreaView>
34+
)
35+
}
36+
37+
const styles = StyleSheet.create({
38+
safeArea: {
39+
flex: 1,
40+
},
41+
container: {
42+
flex: 1,
43+
flexDirection: 'row',
44+
padding: 16,
45+
},
46+
columnSandbox: {
47+
borderWidth: 1,
48+
borderColor: '#8232ff',
49+
borderRadius: 4,
50+
},
51+
column: {
52+
flex: 1,
53+
padding: 8,
54+
},
55+
header: {
56+
fontWeight: 'bold',
57+
fontSize: 16,
58+
marginBottom: 8,
59+
textAlign: 'center',
60+
},
61+
sandboxView: {
62+
flex: 1,
63+
},
64+
errorText: {
65+
color: 'red',
66+
fontSize: 11,
67+
marginBottom: 4,
68+
},
69+
})
70+
71+
export default DemoApp
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import React, {useState} from 'react'
2+
import {
3+
Button,
4+
LogBox,
5+
NativeModules,
6+
ScrollView,
7+
StyleSheet,
8+
View,
9+
} from 'react-native'
10+
11+
export default function CrashIfYouCanDemo() {
12+
const [counter, setCounter] = useState(0)
13+
14+
const triggerCrash = () => {
15+
// @ts-ignore
16+
global.nonExistentMethod() // Should crash the app
17+
}
18+
19+
const overwriteGlobal = () => {
20+
// Overwrite console.log to something harmful
21+
console.log = () => {
22+
throw new Error('console.log has been hijacked!')
23+
}
24+
console.log('This will now throw') // This will crash or break logs
25+
}
26+
27+
const accessBlockedTurboModule = () => {
28+
const FileReaderModule = NativeModules.FileReaderModule
29+
FileReaderModule.readAsText('/some/file.txt')
30+
.then((text: string) => console.log(text))
31+
.catch((err: any) => console.log(err.message))
32+
}
33+
34+
const infiniteLoop = () => {
35+
while (true) {}
36+
}
37+
38+
const incrementCounter = () => {
39+
setCounter(counter + 1)
40+
}
41+
42+
return (
43+
<ScrollView contentContainerStyle={styles.container}>
44+
<Button title="1. Crash App (undefined global)" onPress={triggerCrash} />
45+
<View style={styles.spacer} />
46+
<Button title="2. Overwrite Global (console.log)" onPress={overwriteGlobal} />
47+
<View style={styles.spacer} />
48+
<Button title="3. Access Blocked TurboModule" onPress={accessBlockedTurboModule} />
49+
<View style={styles.spacer} />
50+
<Button title="4. Infinite Loop" onPress={infiniteLoop} />
51+
<View style={styles.spacer} />
52+
<Button title={`Increment ${counter}`} onPress={incrementCounter} />
53+
</ScrollView>
54+
)
55+
}
56+
57+
LogBox.ignoreAllLogs()
58+
59+
const styles = StyleSheet.create({
60+
container: {
61+
padding: 16,
62+
justifyContent: 'center',
63+
},
64+
spacer: {
65+
height: 16,
66+
},
67+
})

apps/expo-demo/app.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"expo": {
3+
"name": "expo-demo",
4+
"slug": "expo-demo",
5+
"version": "1.0.0",
6+
"orientation": "portrait",
7+
"newArchEnabled": true,
8+
"ios": {
9+
"bundleIdentifier": "com.callstack.expodemo",
10+
"supportsTablet": true
11+
},
12+
"android": {
13+
"package": "com.callstack.expodemo"
14+
},
15+
"plugins": ["expo-dev-client"]
16+
}
17+
}

apps/expo-demo/babel.config.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module.exports = function (api) {
2+
api.cache(true)
3+
return {
4+
presets: ['babel-preset-expo'],
5+
}
6+
}

apps/expo-demo/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import {registerRootComponent} from 'expo'
2+
import App from './App'
3+
4+
registerRootComponent(App)

apps/expo-demo/metro.config.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
const {getDefaultConfig} = require('expo/metro-config')
2+
const path = require('path')
3+
4+
const projectRoot = __dirname
5+
const workspaceRoot = path.resolve(projectRoot, '../..')
6+
7+
const config = getDefaultConfig(projectRoot)
8+
9+
// Monorepo: watch workspace root so Metro can resolve packages/react-native-sandbox
10+
config.watchFolders = [workspaceRoot]
11+
12+
// Resolution order: app-local first, then workspace root.
13+
// disableHierarchicalLookup prevents accidentally climbing up beyond workspaceRoot.
14+
config.resolver.nodeModulesPaths = [
15+
path.resolve(projectRoot, 'node_modules'),
16+
path.resolve(workspaceRoot, 'node_modules'),
17+
]
18+
config.resolver.disableHierarchicalLookup = true
19+
20+
// Pin react and react-native to specific node_modules to avoid version
21+
// mismatch when the workspace root has a different RN version (e.g. apps/demo
22+
// uses 0.80.1 while we use 0.81.4).
23+
// react is pinned to workspaceRoot because bun may hoist an older transitive
24+
// version into apps/expo-demo/node_modules when deduplicating workspace deps.
25+
config.resolver.extraNodeModules = {
26+
react: path.resolve(workspaceRoot, 'node_modules/react'),
27+
'react-native': path.resolve(projectRoot, 'node_modules/react-native'),
28+
'react-dom': path.resolve(projectRoot, 'node_modules/react-dom'),
29+
}
30+
31+
module.exports = config

apps/expo-demo/package.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "@apps/expo-demo",
3+
"version": "1.0.0",
4+
"private": true,
5+
"main": "index.js",
6+
"scripts": {
7+
"start": "expo start",
8+
"android": "expo run:android",
9+
"ios": "expo run:ios",
10+
"prebuild": "expo prebuild --clean",
11+
"typecheck": "tsc --noEmit"
12+
},
13+
"dependencies": {
14+
"@callstack/react-native-sandbox": "workspace:*",
15+
"expo": "~54.0.17",
16+
"expo-dev-client": "~6.0.0",
17+
"react": "19.1.0",
18+
"react-native": "0.81.4"
19+
},
20+
"devDependencies": {
21+
"@babel/core": "^7.25.2",
22+
"@types/react": "~19.0.0",
23+
"babel-preset-expo": "~14.0.0",
24+
"typescript": "~5.3.3"
25+
}
26+
}

apps/expo-demo/sandbox.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import {AppRegistry, LogBox} from 'react-native'
2+
3+
import CrashIfYouCanDemo from './CrashIfYouCanDemo'
4+
5+
LogBox.uninstall()
6+
7+
AppRegistry.registerComponent('SandboxedDemo', () => CrashIfYouCanDemo)

apps/expo-demo/tsconfig.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"extends": "expo/tsconfig.base",
3+
"compilerOptions": {
4+
"strict": true
5+
}
6+
}

0 commit comments

Comments
 (0)