From 07a30def7fd32a56b5406de08bbaddb9975c60b3 Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Mon, 27 Jul 2026 22:05:35 -0300 Subject: [PATCH 1/4] feat(sandbox): resolve bundle URL from host script Derives the sandbox's dev bundle URL in JS from the host's own resolved script URL (NativeModules.SourceCode.scriptURL) instead of guessing it natively. Works the same regardless of which launcher stood up the host (RN CLI, Expo dev-client, bare), so Expo managed workflow works out of the box with no native changes. --- jest.config.js | 1 + packages/react-native-sandbox/README.md | 6 ++ packages/react-native-sandbox/jest.config.js | 15 +++++ packages/react-native-sandbox/src/index.tsx | 17 ++++-- .../__tests__/resolveBundleSource.test.ts | 53 +++++++++++++++++ .../react-native-sandbox/src/library/index.ts | 1 + .../src/library/resolveBundleSource.ts | 57 +++++++++++++++++++ 7 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 packages/react-native-sandbox/jest.config.js create mode 100644 packages/react-native-sandbox/src/library/__tests__/resolveBundleSource.test.ts create mode 100644 packages/react-native-sandbox/src/library/index.ts create mode 100644 packages/react-native-sandbox/src/library/resolveBundleSource.ts diff --git a/jest.config.js b/jest.config.js index 2b0c481..d2d3d42 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,6 +2,7 @@ module.exports = { projects: [ '/apps/*/jest.config.js', // This will find all jest.config.js files in app directories + '/packages/*/jest.config.js', ], // Common settings for all projects transformIgnorePatterns: [ diff --git a/packages/react-native-sandbox/README.md b/packages/react-native-sandbox/README.md index cc1888e..02bd94c 100644 --- a/packages/react-native-sandbox/README.md +++ b/packages/react-native-sandbox/README.md @@ -28,6 +28,12 @@ yarn add @callstack/react-native-sandbox The package uses **autolinking** and supports the **React Native New Architecture** - no manual configuration required. +### Expo support + +No extra setup needed. This works out of the box in Expo (managed workflow with `expo-dev-client`, or bare) on both iOS and Android, in dev and release builds alike. + +In dev, the sandbox's bundle URL is derived in JS from the host's own resolved bundle URL (`NativeModules.SourceCode.scriptURL`) instead of guessing it natively. That value only reflects wherever the host bundle actually loaded from, regardless of which launcher (RN CLI, Expo's dev-client, etc.) resolved it, so there's no dependency on Expo internals, no runtime class probing, and nothing that can silently break across Expo SDK upgrades. + ## 🎯 Basic Usage > For complete examples with both host and sandbox code, see the [project examples](https://github.com/callstackincubator/react-native-sandbox#-api-example). diff --git a/packages/react-native-sandbox/jest.config.js b/packages/react-native-sandbox/jest.config.js new file mode 100644 index 0000000..b727414 --- /dev/null +++ b/packages/react-native-sandbox/jest.config.js @@ -0,0 +1,15 @@ +module.exports = { + testEnvironment: 'node', + testMatch: ['/src/**/__tests__/**/*.test.ts'], + transform: { + '^.+\\.tsx?$': [ + 'babel-jest', + { + presets: [ + ['@babel/preset-env', {targets: {node: 'current'}}], + '@babel/preset-typescript', + ], + }, + ], + }, +} diff --git a/packages/react-native-sandbox/src/index.tsx b/packages/react-native-sandbox/src/index.tsx index 0e6bf07..790e5e7 100644 --- a/packages/react-native-sandbox/src/index.tsx +++ b/packages/react-native-sandbox/src/index.tsx @@ -7,13 +7,21 @@ import { useRef, } from 'react' import type {NativeSyntheticEvent} from 'react-native' -import {StyleProp, StyleSheet, View, ViewProps, ViewStyle} from 'react-native' +import { + NativeModules, + StyleProp, + StyleSheet, + View, + ViewProps, + ViewStyle, +} from 'react-native' import type {NativeSandboxReactNativeViewComponentType} from '../specs/NativeSandboxReactNativeView' import NativeSandboxReactNativeView, { Commands, ErrorEvent, } from '../specs/NativeSandboxReactNativeView' +import {resolveJsBundleSource} from './library' const SANDBOX_TURBOMODULES_WHITELIST = [ 'NativeDOMCxx', @@ -347,10 +355,9 @@ const SandboxReactNativeView = forwardRef< }, [componentName, moduleName]) const _jsBundleSource = useMemo(() => { - if (jsBundleSource) { - return jsBundleSource - } - return 'index' + const source = jsBundleSource || 'index' + const hostScriptURL = NativeModules.SourceCode?.scriptURL + return resolveJsBundleSource(source, hostScriptURL, __DEV__) }, [jsBundleSource]) return ( diff --git a/packages/react-native-sandbox/src/library/__tests__/resolveBundleSource.test.ts b/packages/react-native-sandbox/src/library/__tests__/resolveBundleSource.test.ts new file mode 100644 index 0000000..d1534e2 --- /dev/null +++ b/packages/react-native-sandbox/src/library/__tests__/resolveBundleSource.test.ts @@ -0,0 +1,53 @@ +import {resolveJsBundleSource} from '../resolveBundleSource' + +describe('resolveJsBundleSource', () => { + it('passes through an already-absolute URL untouched', () => { + expect( + resolveJsBundleSource( + 'https://cdn.example.com/sandbox.bundle', + 'http://localhost:8081/index.bundle?platform=ios&dev=true', + true + ) + ).toBe('https://cdn.example.com/sandbox.bundle') + }) + + it('passes through the bare name in release builds', () => { + expect(resolveJsBundleSource('sandbox', undefined, false)).toBe('sandbox') + }) + + it('passes through the bare name when the host script URL is unavailable', () => { + expect(resolveJsBundleSource('sandbox', undefined, true)).toBe('sandbox') + }) + + it('derives a sibling bundle URL from the host script URL in dev', () => { + expect( + resolveJsBundleSource( + 'sandbox', + 'http://localhost:8081/apps/demo/index.bundle?platform=ios&dev=true&hot=false&minify=false', + true + ) + ).toBe( + 'http://localhost:8081/apps/demo/sandbox.bundle?platform=ios&dev=true&hot=false&minify=false' + ) + }) + + it('strips launcher-specific params (e.g. Expo Router transform params)', () => { + expect( + resolveJsBundleSource( + 'sandbox', + 'http://192.168.1.10:8081/index.bundle?platform=ios&dev=true&transform.routerRoot=app&transform.bytecode=1', + true + ) + ).toBe('http://192.168.1.10:8081/sandbox.bundle?platform=ios&dev=true') + }) + + it('normalizes a bundle name that already carries an extension', () => { + expect( + resolveJsBundleSource( + 'sandbox.bundle', + 'http://localhost:8081/index.bundle?platform=android&dev=true', + true + ) + ).toBe('http://localhost:8081/sandbox.bundle?platform=android&dev=true') + }) +}) diff --git a/packages/react-native-sandbox/src/library/index.ts b/packages/react-native-sandbox/src/library/index.ts new file mode 100644 index 0000000..7d0cc0e --- /dev/null +++ b/packages/react-native-sandbox/src/library/index.ts @@ -0,0 +1 @@ +export * from './resolveBundleSource' diff --git a/packages/react-native-sandbox/src/library/resolveBundleSource.ts b/packages/react-native-sandbox/src/library/resolveBundleSource.ts new file mode 100644 index 0000000..0adc225 --- /dev/null +++ b/packages/react-native-sandbox/src/library/resolveBundleSource.ts @@ -0,0 +1,57 @@ +// Query params that only affect how a bundle is transformed/served by Metro +// (platform, dev/hot/minify flags, etc). Anything else, like Expo Router's +// `transform.routerRoot`, describes the host bundle's own setup and breaks +// Metro when forwarded to an unrelated sibling bundle, so it gets dropped. +const FORWARDABLE_QUERY_PARAMS = [ + 'platform', + 'dev', + 'hot', + 'minify', + 'runModule', + 'excludeSource', + 'inlineSourceMap', + 'lazy', +] + +const ABSOLUTE_URL_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//i + +/** + * Resolves the final `jsBundleSource` to hand to native. + * + * In dev, Metro serves the host bundle from a URL that native's own launcher + * (RN CLI, or a wrapper like Expo's dev-client) resolved. `hostScriptURL` is + * that same URL, read back from the already-running host JS (see the + * SourceCode native module). Because it reflects wherever the host bundle + * actually loaded from, deriving the sandbox's bundle URL from it works the + * same way no matter which launcher stood up the host, with no native-side + * knowledge of Expo (or any other launcher) required. + */ +export function resolveJsBundleSource( + jsBundleSource: string, + hostScriptURL: string | null | undefined, + isDev: boolean +): string { + if (ABSOLUTE_URL_PATTERN.test(jsBundleSource)) { + return jsBundleSource + } + + if (!isDev || !hostScriptURL) { + return jsBundleSource + } + + const [base, query = ''] = hostScriptURL.split('?') + const lastSlash = base.lastIndexOf('/') + if (lastSlash === -1) { + return jsBundleSource + } + + const dir = base.substring(0, lastSlash) + const bundleName = jsBundleSource.replace(/\.(bundle|jsbundle)$/, '') + + const forwardedQuery = query + .split('&') + .filter(pair => FORWARDABLE_QUERY_PARAMS.includes(pair.split('=')[0])) + .join('&') + + return `${dir}/${bundleName}.bundle${forwardedQuery ? `?${forwardedQuery}` : ''}` +} From e27dd9fe59e6860ccb8dd27e7816dc037ef2eff5 Mon Sep 17 00:00:00 2001 From: eumaninho54 Date: Mon, 27 Jul 2026 22:05:41 -0300 Subject: [PATCH 2/4] feat(demo): add expo-demo app Pulled apps/expo-demo in from upstream PR #42 (Expo managed workflow demo with expo-dev-client). Bumped @types/react to match the workspace version and fix a duplicate-types JSX error. --- apps/expo-demo/.gitignore | 2 + apps/expo-demo/App.tsx | 71 ++ apps/expo-demo/CrashIfYouCanDemo.tsx | 75 ++ apps/expo-demo/app.json | 17 + apps/expo-demo/babel.config.js | 6 + apps/expo-demo/index.js | 5 + apps/expo-demo/metro.config.js | 31 + apps/expo-demo/package.json | 26 + apps/expo-demo/sandbox.js | 7 + apps/expo-demo/tsconfig.json | 6 + bun.lock | 1131 ++++++++++++++++++++++++-- 11 files changed, 1325 insertions(+), 52 deletions(-) create mode 100644 apps/expo-demo/.gitignore create mode 100644 apps/expo-demo/App.tsx create mode 100644 apps/expo-demo/CrashIfYouCanDemo.tsx create mode 100644 apps/expo-demo/app.json create mode 100644 apps/expo-demo/babel.config.js create mode 100644 apps/expo-demo/index.js create mode 100644 apps/expo-demo/metro.config.js create mode 100644 apps/expo-demo/package.json create mode 100644 apps/expo-demo/sandbox.js create mode 100644 apps/expo-demo/tsconfig.json diff --git a/apps/expo-demo/.gitignore b/apps/expo-demo/.gitignore new file mode 100644 index 0000000..8c0a260 --- /dev/null +++ b/apps/expo-demo/.gitignore @@ -0,0 +1,2 @@ +ios/ +android/ diff --git a/apps/expo-demo/App.tsx b/apps/expo-demo/App.tsx new file mode 100644 index 0000000..4339566 --- /dev/null +++ b/apps/expo-demo/App.tsx @@ -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(null) + + return ( + + + + Main App + + + + Sandboxed + {lastError && {lastError}} + { + const message = `${error.isFatal ? '[fatal]' : '[warn]'} ${error.name}: ${error.message}` + console.warn('Sandbox error:', message) + setLastError(message) + return false + }} + /> + + + + ) +} + +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 diff --git a/apps/expo-demo/CrashIfYouCanDemo.tsx b/apps/expo-demo/CrashIfYouCanDemo.tsx new file mode 100644 index 0000000..96a1b7f --- /dev/null +++ b/apps/expo-demo/CrashIfYouCanDemo.tsx @@ -0,0 +1,75 @@ +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 ( + +