Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/expo-demo/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ios/
android/
71 changes: 71 additions & 0 deletions apps/expo-demo/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import SandboxReactNativeView from '@callstack/react-native-sandbox'
import React, {useState} from 'react'
import {SafeAreaView, StyleSheet, Text, View} from 'react-native'

import CrashIfYouCanDemo from './CrashIfYouCanDemo'

const DemoApp: React.FC = () => {
const [lastError, setLastError] = useState<string | null>(null)

return (
<SafeAreaView style={styles.safeArea}>
<View style={styles.container}>
<View style={styles.column}>
<Text style={styles.header}>Main App</Text>
<CrashIfYouCanDemo />
</View>
<View style={[styles.column, styles.columnSandbox]}>
<Text style={styles.header}>Sandboxed</Text>
{lastError && <Text style={styles.errorText}>{lastError}</Text>}
<SandboxReactNativeView
style={styles.sandboxView}
jsBundleSource={'sandbox'}
componentName={'SandboxedDemo'}
onError={error => {
const message = `${error.isFatal ? '[fatal]' : '[warn]'} ${error.name}: ${error.message}`
console.warn('Sandbox error:', message)
setLastError(message)
return false
}}
/>
</View>
</View>
</SafeAreaView>
)
}

const styles = StyleSheet.create({
safeArea: {
flex: 1,
},
container: {
flex: 1,
flexDirection: 'row',
padding: 16,
},
columnSandbox: {
borderWidth: 1,
borderColor: '#8232ff',
borderRadius: 4,
},
column: {
flex: 1,
padding: 8,
},
header: {
fontWeight: 'bold',
fontSize: 16,
marginBottom: 8,
textAlign: 'center',
},
sandboxView: {
flex: 1,
},
errorText: {
color: 'red',
fontSize: 11,
marginBottom: 4,
},
})

export default DemoApp
69 changes: 69 additions & 0 deletions apps/expo-demo/CrashIfYouCanDemo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import React, {useState} from 'react'
import {
Button,
LogBox,
NativeModules,
ScrollView,
StyleSheet,
View,
} from 'react-native'

export default function CrashIfYouCanDemo() {
const [counter, setCounter] = useState(0)

const triggerCrash = () => {
// @ts-ignore
global.nonExistentMethod() // Should crash the app
}

const overwriteGlobal = () => {
// Overwrite console.log to something harmful
console.log = () => {
throw new Error('console.log has been hijacked!')
}
console.log('This will now throw') // This will crash or break logs
}

const accessBlockedTurboModule = () => {
const FileReaderModule = NativeModules.FileReaderModule
FileReaderModule.readAsText('/some/file.txt')
.then((text: string) => console.log(text))
.catch((err: any) => console.log(err.message))
}

// Hangs the sandbox JS thread only — the host app stays responsive because
// each sandbox runs in its own Hermes instance and JS thread.
const infiniteLoop = () => {
while (true) {}
}

const incrementCounter = () => {
setCounter(counter + 1)
}

return (
<ScrollView contentContainerStyle={styles.container}>
<Button title="1. Crash App (undefined global)" onPress={triggerCrash} />
<View style={styles.spacer} />
<Button title="2. Overwrite Global (console.log)" onPress={overwriteGlobal} />
<View style={styles.spacer} />
<Button title="3. Access Blocked TurboModule" onPress={accessBlockedTurboModule} />
<View style={styles.spacer} />
<Button title="4. Infinite Loop" onPress={infiniteLoop} />
<View style={styles.spacer} />
<Button title={`Increment ${counter}`} onPress={incrementCounter} />
</ScrollView>
)
}

LogBox.ignoreAllLogs()

const styles = StyleSheet.create({
container: {
padding: 16,
justifyContent: 'center',
},
spacer: {
height: 16,
},
})
17 changes: 17 additions & 0 deletions apps/expo-demo/app.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"expo": {
"name": "expo-demo",
"slug": "expo-demo",
"version": "1.0.0",
"orientation": "portrait",
"newArchEnabled": true,
"ios": {
"bundleIdentifier": "com.callstack.expodemo",
"supportsTablet": true
},
"android": {
"package": "com.callstack.expodemo"
},
"plugins": ["expo-dev-client"]
}
}
6 changes: 6 additions & 0 deletions apps/expo-demo/babel.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = function (api) {
api.cache(true)
return {
presets: ['babel-preset-expo'],
}
}
4 changes: 4 additions & 0 deletions apps/expo-demo/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import {registerRootComponent} from 'expo'
import App from './App'

registerRootComponent(App)
31 changes: 31 additions & 0 deletions apps/expo-demo/metro.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
const {getDefaultConfig} = require('expo/metro-config')
const path = require('path')

const projectRoot = __dirname
const workspaceRoot = path.resolve(projectRoot, '../..')

const config = getDefaultConfig(projectRoot)

// Monorepo: watch workspace root so Metro can resolve packages/react-native-sandbox
config.watchFolders = [workspaceRoot]

// Resolution order: app-local first, then workspace root.
// disableHierarchicalLookup prevents accidentally climbing up beyond workspaceRoot.
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(workspaceRoot, 'node_modules'),
]
config.resolver.disableHierarchicalLookup = true

// Pin react and react-native to specific node_modules to avoid version
// mismatch when the workspace root has a different RN version (e.g. apps/demo
// uses 0.80.1 while we use 0.81.4).
// react is pinned to workspaceRoot because bun may hoist an older transitive
// version into apps/expo-demo/node_modules when deduplicating workspace deps.
config.resolver.extraNodeModules = {
react: path.resolve(workspaceRoot, 'node_modules/react'),
'react-native': path.resolve(projectRoot, 'node_modules/react-native'),
'react-dom': path.resolve(projectRoot, 'node_modules/react-dom'),
}

module.exports = config
26 changes: 26 additions & 0 deletions apps/expo-demo/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"name": "@apps/expo-demo",
"version": "1.0.0",
"private": true,
"main": "index.js",
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"prebuild": "expo prebuild --clean",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@callstack/react-native-sandbox": "workspace:*",
"expo": "~54.0.17",
"expo-dev-client": "~6.0.0",
"react": "19.1.0",
"react-native": "0.81.4"
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@types/react": "~19.0.0",
"babel-preset-expo": "~14.0.0",
"typescript": "~5.3.3"
}
}
7 changes: 7 additions & 0 deletions apps/expo-demo/sandbox.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import {AppRegistry, LogBox} from 'react-native'

import CrashIfYouCanDemo from './CrashIfYouCanDemo'

LogBox.uninstall()

AppRegistry.registerComponent('SandboxedDemo', () => CrashIfYouCanDemo)
6 changes: 6 additions & 0 deletions apps/expo-demo/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}

This file was deleted.

This file was deleted.

This file was deleted.

This file was deleted.

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
1 change: 0 additions & 1 deletion apps/expo/android/app/src/main/res/values-night/colors.xml

This file was deleted.

Loading