Skip to content

fix: fixes for sandbox communication (shared origins and permissions)#36

Open
ryan-karn wants to merge 2 commits into
callstackincubator:mainfrom
ryan-karn:shared-origin-msg-fix
Open

fix: fixes for sandbox communication (shared origins and permissions)#36
ryan-karn wants to merge 2 commits into
callstackincubator:mainfrom
ryan-karn:shared-origin-msg-fix

Conversation

@ryan-karn
Copy link
Copy Markdown
Contributor

Note this PR is based on changes proposed in #34

Summary

This change fixes cross-origin messaging, error broadcasting, and access control for sandboxes sharing a Hermes VM via origin pooling.

#29

In order to achieve this behaviour, additional changes to the API/approach for sending and handling messages within a bundle were needed. (See details below).

These changes are:

  • Only needed if bundle is to be used in a shared origin
  • Can be done with or withing taking a dependency on rn-sandbox (to install a custom hook)
  • Are not a strict dependency. meaning nothing with break if a bundle does not have these changes, but messaging and TTL may not work as expected.

useSurfaceMessaging hook

Why

When multiple sandboxes share an origin (and therefore a single Hermes VM), there is only one globalThis.postMessage and one globalThis.setOnMessage for the entire runtime. The native side can install these globals, but it cannot know which surface a JS call originates from without cooperation from the JS layer.

The fundamental problem: postMessage({type: 'hello'}) arrives at the native host function, but the native side has no way to determine which of the N surfaces sharing this VM made the call. Similarly, when a message arrives from the host, the native side can invoke the JS callback, but if all surfaces registered via the same setOnMessage, only the last one wins.

The solution requires the JS side to include a routing hint (__sandboxDelegateId) in outgoing messages and to register listeners keyed by delegate ID. This is inherently a JS-side concern because:

  1. The delegate ID is injected as an initialProperty — it's only available to the JS component as a prop.
  2. The native postMessage host function receives a generic jsi::Value — it can't inspect the JS call stack to determine which React component invoked it.
  3. Per-surface listener registration requires the JS side to pass the delegate ID to setOnMessage so the native side can maintain a map of callbacks.

The hook (or the equivalent convention-based pattern) bridges this gap by attaching the surface identity to messages at the JS layer, enabling the native layer to route correctly.


Break down of changes included


iOS Fixes (SandboxReactNativeDelegate.mm)

1. setOnMessage argument count

The native setOnMessage JSI function strictly required 1 argument, but the JS useSurfaceMessaging hook passes an optional delegateId as the second argument. Updated to accept 1 or 2 arguments.

2. JSI global re-installation on warm start

When a same-origin sandbox warm-starts, it needs to swap postMessage/setOnMessage to point to the new delegate. The original code used defineReadOnlyGlobal with configurable: false, making the properties permanently locked. Replaced with Object.defineProperty using configurable: true so warm-start re-installations succeed.

3. Per-surface setOnMessage dispatch

Previously, _onMessageSandbox was a single shared callback (last-writer-wins). Added _surfaceMessageCallbacks map keyed by delegate ID so each surface gets its own listener, matching Android behavior.

4. Cross-origin message routing

Changed routeMessage:toSandbox: to use findAll() (deliver to all delegates for the target origin) and reordered checks: existence first, then permissions. This ensures SandboxRoutingError fires when the target doesn't exist, and AccessDeniedError only fires when the target exists but the sender lacks permission.

5. Error broadcasting

Updated setupErrorHandler to broadcast errors to all delegates registered for the origin via registry.findAll(), matching Android. The error handler captures origin by value and uses a weak reference to self to prevent crashes if the delegate is deallocated.

6. Error handler idempotency

setupErrorHandler is now idempotent — it sets a flag (__sandboxErrorHandlerInstalled) on ErrorUtils and skips re-installation on warm start. The cold-start handler remains active and broadcasts via the registry.

7. Self-targeting removal

Removed the guard that blocked a sandbox from sending messages to its own origin, matching Android behavior.

8. Registry unregistration fix

Changed dealloc and setOrigin to use unregisterDelegate instead of unregister, which was nuking all delegates for the entire origin when only one should be removed.


Android Fixes (SandboxJSIInstaller.cpp, SandboxReactNativeDelegate.kt, SandboxReactNativeViewManager.kt)

1. Object mutation in postMessage

The __sandboxDelegateId property was stripped from the caller's message object without being restored. Now the property is restored after serialization to avoid surprising the caller.

2. Thread-safe delegate map

delegateById changed from mutableMapOf (non-thread-safe) to ConcurrentHashMap since it's accessed from both the JS and UI threads.

3. Atomic delegate ID generation

nextDelegateId changed from a plain Long to AtomicLong to prevent duplicate IDs under concurrent access.

4. allowedOrigins registration

Android was always registering sandboxes with empty allowedOrigins in the C++ SandboxRegistry. Both registration paths (installSandboxJSIBindings and nativeRegisterDelegate) now read the allowedOrigins field from the Kotlin delegate via JNI at registration time. Added nativeUpdateAllowedOrigins JNI method and call it from the view manager when the prop changes.

5. Cross-origin routing order

Aligned with iOS: check target existence first (SandboxRoutingError), then permissions (AccessDeniedError). Previously Android checked permissions first, which produced misleading errors when the target didn't exist.


Shared C++ (SandboxRegistry.cpp, SandboxRegistry.h)

#35

1. registerSandbox always updates allowedOrigins

The duplicate-delegate check previously returned early without updating allowedOrigins_[origin]. Now it skips adding the delegate but always updates the allowed origins map.

2. isPermittedFrom semantics

Updated to check the target's allowedOrigins (receiver-side access control): "does the target accept messages from this source?"

3. updateAllowedOrigins method

New method that updates allowed origins for an origin without requiring a delegate reference. Used by Android's JNI bridge.


Cleanup function

setOnMessage now returns a cleanup function that replaces the callback with a no-op. Consumers should use it in useEffect cleanup to prevent stale callbacks when a surface unmounts while the VM stays alive.


Demo App (apps/origin-pooling)

Convention-based messaging (SandboxAppConvention.tsx)

New component demonstrating messaging without importing @callstack/react-native-sandbox. Uses globalThis.postMessage/setOnMessage directly with the __sandboxDelegateId convention.

Dual-approach wiring

  • Alpha sandboxes use SandboxAppConvention (convention-based, no library import)
  • Beta and isolated sandboxes use SandboxApp (library-based, useSurfaceMessaging)

Access control demo

  • Isolated sandboxes now each get a unique origin (own VM)
  • Alpha and beta only accept messages from each other and isolated-1
  • Other isolated sandboxes get AccessDeniedError when trying to reach alpha/beta

Demo App (apps/p2p-counter)

  • Minor app updateds to reflect the nuances for the allowedOrigin fixes

…allstackincubator#30, callstackincubator#28)

- Add iOS support for shared-origin factory pooling (same-origin sandboxes
  reuse a single RCTReactNativeFactory)
- Add idleTTL prop to defer ReactHost/factory cleanup after last surface
  unmounts, enabling warm starts for same-origin remounts
- Add origin-pooling demo app demonstrating both features on iOS and Android

Ref: callstackincubator#28
Ref: callstackincubator#30
@ryan-karn ryan-karn changed the title Fixes for sandbox communication fix: fixes for sandbox communication (shared origins and permissions) May 14, 2026
@ryan-karn ryan-karn force-pushed the shared-origin-msg-fix branch from da093f5 to 5eb1133 Compare May 14, 2026 21:41
…ittedFrom callstackincubator#35)

Fixing issues for handling messages and errors when sandboxes use
the same origin.  Namely, this approach defines and passes an id
that is passed to the sandbox, and provides a hook that allows
sandboxes to use this for messaging.  This is to distinguish views
on the same origin that share the same runtime and globals.

This also addresses an issue there allowOrigin checks where checking
sender side allows rather than receiver side.

ref: callstackincubator#29
ref: callstackincubator#35
@ryan-karn ryan-karn force-pushed the shared-origin-msg-fix branch from 5eb1133 to 13a98dd Compare May 14, 2026 21:43
@ryan-karn
Copy link
Copy Markdown
Contributor Author

Detailing some the of the behaviour seen prior to this fix;

(1) Sandbox → Host messaging

iss29_bug1.mp4
  • Add multiple sandboxes to the same origin
  • Host receives rendered notification message as coming from the first sandbox, rather the new instantiated one ❌
  • Same behaviour for heartbeat messages ❌
  • If we remove the sandboxes such that there are 0 for that VM origin, then add more within the TTL (warm start), no messages for rendered are received by the host app ❌

(2) Sandbox→Sandbox messaging

iss29_bug3.mp4
  • Add multiple sandboxes to multiple origins
  • Attempting to send a message to a specified origin, it only gets pick up by the last sandbox added to that origin ❌
  • If we remove said last sandbox, ping messages are then dropped entirely ❌

(3) Error Callbacks

iss29_bug5.mp4
  • Add multiple sandboxes to the same origin
  • If a sandbox throws an uncaught error, it propagates to the host from the first sandbox regardless of what sandbox caused the error ❌
  • If we remove the first sandbox, error callbacks to the host are dropped entirely ❌

@ryan-karn
Copy link
Copy Markdown
Contributor Author

Demonstration of post-fix behaviour:

(1) Sandbox → Host messaging

iss29_fix1.mp4
  • Add multiple sandboxes to the same origin
  • Host receives rendered notification message as coming from the newly added sandbox, as expected ✅
  • Same behaviour for heartbeat messages, they are from the intended sandbox ✅
  • If we remove the sandboxes such that there are 0 for that VM origin, then add more within the TTL (warm start), notifications still work ✅

(2) Sandbox→Sandbox messaging

iss29_fix2.mp4
  • Add multiple sandboxes to multiple origins
  • Attempting to send a message to a specified origin, it is received by all sandboxes in that origin, as they share the same runtime ✅
  • If we remove said last sandbox, ping messages continue to work ✅

(3) Error Callbacks

iss29_fix3.mp4
  • Add multiple sandboxes to the same origin
  • If a sandbox throws an uncaught error, it propagates to the host from all sandboxes in the origin ✅
    • _ NOTE this is because its a uncaught error on the runtime, we can’ be sure what sandbox caused the issue. for our demo, we are adding the instance ID, but that is from the bundle explicitly for the demo sake. This is trade-off with the design approach taken here for the problem_
  • If we remove the first sandbox, error callbacks still work on other sandboxes ✅

(4) Origin permission checks

iss29_fix4.mp4
  • Add multiple sandboxes, add other sandboxes that are marked as allowedOrigins and ones that are excluded
  • Permitting sandboxes can to all origins as expected ✅
  • Permitted sandbox is not allowed ✅
  • If no sandboxes in origin for message send, message received that not sandboxes found ✅

@ryan-karn
Copy link
Copy Markdown
Contributor Author

Also, demonstration of the p2p counter demo post this change -

Simulator.Screen.Recording.-.iPhone.17.Pro.-.2026-05-14.at.14.53.12.mov

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant