expo-modules-jsi 56.0.4 → 56.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,10 @@
10
10
 
11
11
  ### 💡 Others
12
12
 
13
+ ## 56.0.5 — 2026-05-15
14
+
15
+ _This version does not introduce any user-facing changes._
16
+
13
17
  ## 56.0.4 — 2026-05-13
14
18
 
15
19
  ### 🐛 Bug fixes
@@ -43,6 +43,17 @@ open class JavaScriptRuntime: Equatable, @unchecked Sendable {
43
43
  internal let runtimePointee: facebook.jsi.Runtime
44
44
  internal let scheduler: expo.RuntimeScheduler
45
45
 
46
+ /**
47
+ Thread ID of the JavaScript thread, captured at construction time. Used by `isOnJavaScriptThread()`
48
+ for a fast integer comparison instead of `Thread.current.name == "..."`.
49
+ Assumes runtime initializers always run on the JS thread.
50
+ */
51
+ private let jsThreadID: UInt64 = {
52
+ var id: UInt64 = 0
53
+ pthread_threadid_np(nil, &id)
54
+ return id
55
+ }()
56
+
46
57
  /**
47
58
  Actor for running runtime work.
48
59
  */
@@ -362,7 +373,7 @@ open class JavaScriptRuntime: Equatable, @unchecked Sendable {
362
373
  let argumentsRef = arguments.copy().ref()
363
374
 
364
375
  // Switch to asynchronous context.
365
- self.schedule(taskName: "[JS] Async function \(name)") {
376
+ self.schedule {
366
377
  // Invoke the asynchronous function and resolve/reject the promise.
367
378
  do {
368
379
  let result = try await function(this, argumentsRef.take())
@@ -399,11 +410,10 @@ open class JavaScriptRuntime: Equatable, @unchecked Sendable {
399
410
 
400
411
  public func schedule(
401
412
  priority: SchedulerPriority = .normal,
402
- taskName: String? = "[JS] runtime.schedule (\(#function))",
403
413
  @_implicitSelfCapture _ closure: @escaping @JavaScriptActor () async throws -> Void
404
414
  ) -> Void {
405
415
  schedule(priority: priority) {
406
- Task.immediate_polyfill(name: taskName) {
416
+ Task.immediate_polyfill {
407
417
  try await closure()
408
418
  }
409
419
  }
@@ -414,8 +424,14 @@ open class JavaScriptRuntime: Equatable, @unchecked Sendable {
414
424
  Not available in async contexts to prevent blocking the cooperative thread pool.
415
425
  */
416
426
  @available(*, noasync)
427
+ @discardableResult
417
428
  public func execute<R: Sendable>(@_implicitSelfCapture _ closure: @escaping @JavaScriptActor () throws -> R) throws -> sending R {
429
+ if isOnJavaScriptThread() {
430
+ return try JavaScriptActor.assumeIsolated(closure)
431
+ }
432
+
418
433
  var result: Result<R, any Error>!
434
+ nonisolated(unsafe) let callerRunLoop = CFRunLoopGetCurrent()
419
435
 
420
436
  scheduler.scheduleTask(.ImmediatePriority) {
421
437
  do {
@@ -423,12 +439,23 @@ open class JavaScriptRuntime: Equatable, @unchecked Sendable {
423
439
  } catch {
424
440
  result = .failure(error)
425
441
  }
442
+ // Wake the caller's run loop so its `CFRunLoopRunInMode(...)` returns immediately
443
+ // instead of waiting out the timeout backstop.
444
+ CFRunLoopPerformBlock(callerRunLoop, CFRunLoopMode.commonModes.rawValue) {}
445
+ CFRunLoopWakeUp(callerRunLoop)
426
446
  }
427
447
 
428
- // Use RunLoop to wait for the task to finish. As opposed to DispatchSemaphore or DispatchGroup,
429
- // this solution lets the current run loop to process other events in the meantime.
448
+ // Pump the caller's run loop until the task finishes. As opposed to DispatchSemaphore
449
+ // or DispatchGroup, this lets the run loop continue to process other events in the meantime,
450
+ // and the spin is also faster than a real kernel-mediated context switch when the JS work
451
+ // is short (the common case). The 100ms timeout is a backstop in case the wakeup is missed;
452
+ // the common path is woken by `CFRunLoopWakeUp` from the scheduled block above.
453
+ //
454
+ // `CFRunLoopRunInMode` is the C API rather than `RunLoop.current.run(mode:before:)` to
455
+ // avoid the per-iteration `+[NSRunLoop currentRunLoop]` autorelease push and `Date()`
456
+ // allocation that dominated the caller-thread profile otherwise.
430
457
  while result == nil {
431
- RunLoop.current.run(mode: .common, before: Date().addingTimeInterval(0.001))
458
+ CFRunLoopRunInMode(.commonModes, 0.1, false)
432
459
  }
433
460
  return try result.get()
434
461
  }
@@ -438,26 +465,40 @@ open class JavaScriptRuntime: Equatable, @unchecked Sendable {
438
465
  Not available in async contexts to prevent blocking the cooperative thread pool.
439
466
  */
440
467
  @available(*, noasync)
468
+ @discardableResult
441
469
  public func execute<R: Sendable>(
442
- taskName: String? = "[JS] runtime.execute (\(#function))",
443
470
  @_implicitSelfCapture _ closure: @escaping @JavaScriptActor () async throws -> R
444
471
  ) throws -> sending R {
445
472
  let result = NonisolatedUnsafeVar<Result<R, any Error>>()
473
+ let runInline = isOnJavaScriptThread()
474
+ // Wrapped in `NonisolatedUnsafeVar` instead of `nonisolated(unsafe) let`
475
+ // to work around a Swift 6.2.3 compiler bug.
476
+ let callerRunLoop = NonisolatedUnsafeVar(CFRunLoopGetCurrent())
446
477
 
447
- scheduler.scheduleTask(.ImmediatePriority) {
448
- Task.immediate_polyfill(name: taskName, priority: .high) {
478
+ func body() -> Void {
479
+ Task.immediate_polyfill(priority: .high) {
449
480
  do {
450
481
  result.value = .success(try await closure())
451
482
  } catch {
452
483
  result.value = .failure(error)
453
484
  }
485
+ // Wake the caller's run loop so its `CFRunLoopRunInMode(...)` returns immediately
486
+ // instead of waiting out the timeout backstop.
487
+ CFRunLoopPerformBlock(callerRunLoop.value, CFRunLoopMode.commonModes.rawValue) {}
488
+ CFRunLoopWakeUp(callerRunLoop.value)
454
489
  }
455
490
  }
491
+ if runInline {
492
+ body()
493
+ } else {
494
+ scheduler.scheduleTask(.ImmediatePriority, body)
495
+ }
456
496
 
457
- // Use RunLoop to wait for the task to finish. As opposed to DispatchSemaphore or DispatchGroup,
458
- // this solution lets the current run loop to process other events in the meantime.
497
+ // Pump the caller's run loop until the task finishes. See the sync overload above for
498
+ // the rationale on `CFRunLoopRunInMode` vs. `RunLoop.current.run(...)` and on pumping
499
+ // the run loop instead of blocking on a semaphore.
459
500
  while result.value == nil {
460
- RunLoop.current.run(mode: .common, before: Date().addingTimeInterval(0.001))
501
+ CFRunLoopRunInMode(.commonModes, 0.1, false)
461
502
  }
462
503
  return try result.value.get()
463
504
  }
@@ -465,9 +506,13 @@ open class JavaScriptRuntime: Equatable, @unchecked Sendable {
465
506
  /**
466
507
  Asynchronously executes a sync closure on the JavaScript runtime thread, awaiting its completion without blocking.
467
508
  */
509
+ @discardableResult
468
510
  public func execute<R: Sendable>(
469
511
  @_implicitSelfCapture _ closure: @escaping @JavaScriptActor () throws -> R
470
512
  ) async throws -> sending R {
513
+ if isOnJavaScriptThread() {
514
+ return try JavaScriptActor.assumeIsolated(closure)
515
+ }
471
516
  return try await withUnsafeThrowingContinuation { continuation in
472
517
  scheduler.scheduleTask(.ImmediatePriority) {
473
518
  do {
@@ -482,13 +527,16 @@ open class JavaScriptRuntime: Equatable, @unchecked Sendable {
482
527
  /**
483
528
  Asynchronously executes an async closure on the JavaScript runtime thread, awaiting its completion without blocking.
484
529
  */
530
+ @discardableResult
485
531
  public func execute<R: Sendable>(
486
- taskName: String? = "[JS] runtime.execute (async \(#function))",
487
532
  @_implicitSelfCapture _ closure: @escaping @JavaScriptActor () async throws -> R
488
533
  ) async throws -> sending R {
534
+ if isOnJavaScriptThread() {
535
+ return try await Task.immediate_polyfill(priority: .high, operation: closure).value
536
+ }
489
537
  return try await withUnsafeThrowingContinuation { continuation in
490
538
  scheduler.scheduleTask(.ImmediatePriority) {
491
- Task.immediate_polyfill(name: taskName, priority: .high) { @JavaScriptActor in
539
+ Task.immediate_polyfill(priority: .high) { @JavaScriptActor in
492
540
  do {
493
541
  continuation.resume(returning: try await closure())
494
542
  } catch {
@@ -502,14 +550,18 @@ open class JavaScriptRuntime: Equatable, @unchecked Sendable {
502
550
  /**
503
551
  Checks whether the function is called on the JavaScript thread.
504
552
  */
505
- public func isOnJavaScriptThread() -> Bool {
506
- return Thread.current.name == "com.facebook.react.runtime.JavaScript"
553
+ @inline(__always)
554
+ public final func isOnJavaScriptThread() -> Bool {
555
+ var current: UInt64 = 0
556
+ pthread_threadid_np(nil, &current)
557
+ return current == jsThreadID
507
558
  }
508
559
 
509
560
  /**
510
561
  Asserts whether we are on the JavaScript thread. Helpful for debugging threading issues.
511
562
  */
512
- public func assertThread(file: String = #file, function: String = #function, line: Int = #line) {
563
+ @inline(__always)
564
+ public final func assertThread(file: String = #file, function: String = #function, line: Int = #line) {
513
565
  assert(isOnJavaScriptThread(), "Function '\(function)' is not run on the JavaScript thread (\(file):\(line))")
514
566
  }
515
567
 
@@ -1,5 +1,6 @@
1
1
  import Testing
2
2
  import ExpoModulesJSI
3
+ import Foundation
3
4
 
4
5
  @Suite
5
6
  @JavaScriptActor
@@ -118,6 +119,49 @@ struct JavaScriptRuntimeTests {
118
119
  #expect(result == 100)
119
120
  }
120
121
 
122
+ // The execute<R> overloads have a same-thread fast path and a cross-thread path that
123
+ // schedules the closure onto the JS thread and pumps the caller's run loop until it
124
+ // completes. The tests above run on `@JavaScriptActor` (the JS thread), so they only
125
+ // exercise the fast path. The next three hop off the JS thread first to cover the
126
+ // cross-thread scheduling + run-loop pump. The sync overloads of `execute` are
127
+ // `@available(*, noasync)`, so the caller must be a real synchronous thread — wrapping
128
+ // in `Task.detached` would stay on the cooperative pool and trip the noasync diagnostic.
129
+
130
+ @Test
131
+ func `execute sync from off-thread caller`() async throws {
132
+ let runtime = self.runtime
133
+ let result = try await onSyncOffThread {
134
+ try runtime.execute { @JavaScriptActor in
135
+ return runtime.global().hasProperty("Object") ? 1 : 0
136
+ }
137
+ }
138
+ #expect(result == 1)
139
+ }
140
+
141
+ @Test
142
+ func `execute blocking-async from off-thread caller`() async throws {
143
+ let runtime = self.runtime
144
+ let result = try await onSyncOffThread {
145
+ try runtime.execute { @JavaScriptActor () async in
146
+ await Task.yield()
147
+ return runtime.global().hasProperty("Object") ? 1 : 0
148
+ }
149
+ }
150
+ #expect(result == 1)
151
+ }
152
+
153
+ @Test
154
+ func `execute sync rethrows from off-thread caller`() async throws {
155
+ let runtime = self.runtime
156
+ await #expect(throws: ScriptEvaluationError.self) {
157
+ try await onSyncOffThread {
158
+ try runtime.execute { @JavaScriptActor in
159
+ try runtime.eval("invalid syntax +++")
160
+ }
161
+ }
162
+ }
163
+ }
164
+
121
165
  // MARK: - Host objects
122
166
 
123
167
  @Test
@@ -698,3 +742,19 @@ struct JavaScriptRuntimeTests {
698
742
  #expect(result.getInt() == 3)
699
743
  }
700
744
  }
745
+
746
+ /// Runs `body` on a freshly spawned synchronous thread and bridges the result back into the
747
+ /// async test. The thread has a real run loop, which the cross-thread `execute` path pumps.
748
+ private func onSyncOffThread<R: Sendable>(
749
+ _ body: @escaping @Sendable () throws -> R
750
+ ) async throws -> R {
751
+ return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<R, any Error>) in
752
+ Thread.detachNewThread {
753
+ do {
754
+ continuation.resume(returning: try body())
755
+ } catch {
756
+ continuation.resume(throwing: error)
757
+ }
758
+ }
759
+ }
760
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-modules-jsi",
3
- "version": "56.0.4",
3
+ "version": "56.0.5",
4
4
  "description": "The JavaScript Interface for Expo Modules",
5
5
  "main": "index.js",
6
6
  "sideEffects": [],
@@ -41,7 +41,7 @@
41
41
  "./apple/scripts/test.sh"
42
42
  ]
43
43
  },
44
- "gitHead": "51c27fce31a5b3a877a4b05d832dabf4a99db5e1",
44
+ "gitHead": "f26be3dd9396bf7c399a1d607865d0fabdbc0d64",
45
45
  "scripts": {
46
46
  "build": "apple/scripts/build-xcframework.sh",
47
47
  "test": "apple/scripts/test.sh"