@onekeyfe/react-native-split-bundle-loader 3.0.63 → 3.0.64

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.
@@ -0,0 +1,51 @@
1
+ cmake_minimum_required(VERSION 3.13)
2
+
3
+ # Native (JNI) side of the SplitBundleLoader module.
4
+ #
5
+ # WHY THIS EXISTS:
6
+ # The Kotlin `loadSegment` previously called `ReactContext.registerSegment`,
7
+ # whose completion callback fires BEFORE the segment bytecode is evaluated
8
+ # into the runtime (the C++ ReactInstance::registerSegment only ENQUEUES the
9
+ # eval onto the RuntimeScheduler and returns). That races Metro's
10
+ # `import().then(() => __r(moduleId))` and can produce a fatal, uncatchable
11
+ # "Requiring unknown module".
12
+ #
13
+ # This library evaluates the segment OURSELVES on the JS thread via the
14
+ # bridgeless CallInvoker (which receives `jsi::Runtime&`) and signals
15
+ # completion in that SAME callback, so eval + resolve are one atomic unit —
16
+ # mirroring the iOS `callFunctionOnBufferedRuntimeExecutor:` fix.
17
+ project(splitbundleloader)
18
+
19
+ set(CMAKE_CXX_STANDARD 20)
20
+ set(CMAKE_CXX_STANDARD_REQUIRED ON)
21
+ set(CMAKE_VERBOSE_MAKEFILE ON)
22
+
23
+ add_library(
24
+ splitbundleloader
25
+ SHARED
26
+ src/main/cpp/SplitBundleLoaderJSI.cpp
27
+ )
28
+
29
+ target_include_directories(
30
+ splitbundleloader
31
+ PRIVATE
32
+ src/main/cpp
33
+ )
34
+
35
+ # jsi, reactnative (which carries CallInvoker / CallInvokerHolder headers and
36
+ # the merged libreactnative.so), and fbjni are all prefab targets exposed by
37
+ # the react-android AAR. See ReactAndroid build.gradle.kts prefab entries:
38
+ # - jsi (../ReactCommon/jsi/)
39
+ # - reactnative (turbomodule/ReactCommon/CallInvokerHolder.h,
40
+ # callinvoker/ReactCommon/CallInvoker.h, ...)
41
+ find_package(ReactAndroid REQUIRED CONFIG)
42
+ find_package(fbjni REQUIRED CONFIG)
43
+
44
+ target_link_libraries(
45
+ splitbundleloader
46
+ android
47
+ log
48
+ fbjni::fbjni
49
+ ReactAndroid::jsi
50
+ ReactAndroid::reactnative
51
+ )
@@ -33,10 +33,36 @@ android {
33
33
  defaultConfig {
34
34
  minSdkVersion getExtOrIntegerDefault("minSdkVersion")
35
35
  targetSdkVersion getExtOrIntegerDefault("targetSdkVersion")
36
+
37
+ externalNativeBuild {
38
+ cmake {
39
+ // C++20 to match React Native's prefab targets.
40
+ cppFlags "-O2", "-frtti", "-fexceptions", "-std=c++20"
41
+ // The react-android / hermestooling prefab targets are built against
42
+ // the shared STL; without this the default static STL is selected and
43
+ // CMake configure fails ("static STL but library requires a shared
44
+ // STL [//ReactAndroid/hermestooling]"). Matches react-native-background-thread.
45
+ arguments "-DANDROID_STL=c++_shared"
46
+ // Only build the ABIs the app ships.
47
+ abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
48
+ }
49
+ }
36
50
  }
37
51
 
38
52
  buildFeatures {
39
53
  buildConfig true
54
+ // Consume react-android / fbjni prefab packages (jsi, reactnative, fbjni)
55
+ // from the AAR for the native (JNI) side.
56
+ prefab true
57
+ }
58
+
59
+ // Native (JNI) side: evaluates split-bundle segments on the JS thread and
60
+ // resolves the promise strictly AFTER eval (fixes the "Requiring unknown
61
+ // module" race). See src/main/cpp/SplitBundleLoaderJSI.cpp.
62
+ externalNativeBuild {
63
+ cmake {
64
+ path "CMakeLists.txt"
65
+ }
40
66
  }
41
67
 
42
68
  buildTypes {
@@ -0,0 +1,239 @@
1
+ /*
2
+ * SplitBundleLoaderJSI.cpp
3
+ *
4
+ * JNI bridge that evaluates a Metro split-bundle segment into the CURRENT
5
+ * Hermes runtime and signals completion ONLY AFTER the segment's `__d(...)`
6
+ * module definitions have actually run.
7
+ *
8
+ * WHY THIS EXISTS — the "Requiring unknown module" race:
9
+ * The previous Kotlin implementation called `ReactContext.registerSegment`,
10
+ * which routes (bridgeless) through `ReactHostImpl.registerSegment` →
11
+ * `ReactInstance.registerSegment` → C++ `ReactInstance::registerSegment`, and
12
+ * that only does `runtimeScheduler_->scheduleWork([]{ evaluateJavaScript() })`
13
+ * — i.e. it ENQUEUES the eval and returns. `ReactHostImpl.registerSegment`
14
+ * then invokes the completion callback immediately on `Task.IMMEDIATE_EXECUTOR`,
15
+ * so the loadSegment promise resolves BEFORE the segment is evaluated. Metro's
16
+ * `import().then(() => __r(moduleId))` microtask can therefore run `__r` before
17
+ * the module table is populated → a fatal, uncatchable "Requiring unknown
18
+ * module" inside metroRequire.
19
+ *
20
+ * THE FIX (mirrors iOS callFunctionOnBufferedRuntimeExecutor:):
21
+ * We evaluate the segment OURSELVES on the JS thread via the bridgeless
22
+ * CallInvoker. `CallInvoker::invokeAsync(CallFunc&&)` schedules the callback
23
+ * onto the SAME RuntimeScheduler that registerSegment would have used, and the
24
+ * callback receives `jsi::Runtime&`. We read the segment file, call
25
+ * `runtime.evaluateJavaScript(...)`, and invoke the completion callback from
26
+ * INSIDE that same block, strictly AFTER eval. Eval + completion are one atomic
27
+ * unit of work on the JS thread, so any subsequent `__r(moduleId)` is
28
+ * guaranteed to find the module.
29
+ *
30
+ * SEGMENT FORMAT: OneKey segments are standalone-evaluatable Metro bundles
31
+ * (top-level `__d(moduleId, factory, deps)` calls; the `.seg.hbc` is just the
32
+ * Hermes-compiled form of the same `.seg.js`). C++ ReactInstance::registerSegment
33
+ * itself just calls `runtime.evaluateJavaScript(buffer, ...)` with no RAM/indexed
34
+ * segment manifest wiring, confirming these are evaluatable as plain scripts.
35
+ */
36
+
37
+ #include <fbjni/fbjni.h>
38
+ #include <jsi/jsi.h>
39
+
40
+ #include <ReactCommon/CallInvoker.h>
41
+ #include <ReactCommon/CallInvokerHolder.h>
42
+
43
+ #include <android/log.h>
44
+
45
+ #include <cstdio>
46
+ #include <memory>
47
+ #include <string>
48
+ #include <vector>
49
+
50
+ #define SBL_LOG_TAG "SplitBundleLoader"
51
+ #define SBL_LOGI(...) \
52
+ __android_log_print(ANDROID_LOG_INFO, SBL_LOG_TAG, __VA_ARGS__)
53
+ #define SBL_LOGW(...) \
54
+ __android_log_print(ANDROID_LOG_WARN, SBL_LOG_TAG, __VA_ARGS__)
55
+
56
+ namespace facebook::react::splitbundleloader {
57
+
58
+ namespace {
59
+
60
+ // Reads the whole file at `path` into a std::string. Returns false on failure.
61
+ bool readFileToString(const std::string& path, std::string& out) {
62
+ FILE* f = std::fopen(path.c_str(), "rb");
63
+ if (f == nullptr) {
64
+ return false;
65
+ }
66
+ if (std::fseek(f, 0, SEEK_END) != 0) {
67
+ std::fclose(f);
68
+ return false;
69
+ }
70
+ long size = std::ftell(f);
71
+ if (size < 0) {
72
+ std::fclose(f);
73
+ return false;
74
+ }
75
+ if (std::fseek(f, 0, SEEK_SET) != 0) {
76
+ std::fclose(f);
77
+ return false;
78
+ }
79
+ out.resize(static_cast<size_t>(size));
80
+ size_t read = (size == 0)
81
+ ? 0
82
+ : std::fread(&out[0], 1, static_cast<size_t>(size), f);
83
+ std::fclose(f);
84
+ return read == static_cast<size_t>(size);
85
+ }
86
+
87
+ } // namespace
88
+
89
+ // Java callback contract — implemented in Kotlin as
90
+ // SplitBundleLoaderModule.SegmentEvalCallback. `onComplete(null)` on success,
91
+ // `onComplete(errorMessage)` on failure. Invoked exactly once.
92
+ //
93
+ // Error-message prefix convention (read by the Kotlin side to pick the contract
94
+ // reject code): a message prefixed with "IO_ERROR:" is a segment file read/mmap
95
+ // failure → SPLIT_BUNDLE_IO_ERROR (non-retryable). Any other message is a
96
+ // segment JS/Hermes eval throw → SPLIT_BUNDLE_EVAL_ERROR (non-retryable).
97
+ struct JSegmentEvalCallback
98
+ : jni::JavaClass<JSegmentEvalCallback> {
99
+ static constexpr auto kJavaDescriptor =
100
+ "Lcom/splitbundleloader/SplitBundleLoaderModule$SegmentEvalCallback;";
101
+
102
+ // `error` empty → success (passes Java null); non-empty → failure (passes the
103
+ // message). We always pass null on success and a non-empty message on failure,
104
+ // so this mapping is unambiguous. `const` so it can be invoked through a
105
+ // (const) global_ref / alias_ref.
106
+ void onComplete(const std::string& error) const {
107
+ static const auto method =
108
+ javaClassStatic()->getMethod<void(jni::alias_ref<jni::JString>)>(
109
+ "onComplete");
110
+ jni::local_ref<jni::JString> arg =
111
+ error.empty() ? jni::local_ref<jni::JString>(nullptr)
112
+ : jni::make_jstring(error);
113
+ method(self(), arg);
114
+ }
115
+ };
116
+
117
+ class SplitBundleLoaderJSI
118
+ : public jni::JavaClass<SplitBundleLoaderJSI> {
119
+ public:
120
+ static constexpr auto kJavaDescriptor =
121
+ "Lcom/splitbundleloader/SplitBundleLoaderModule;";
122
+
123
+ // Schedules evaluation of the segment at `segmentPath` onto the JS thread via
124
+ // the bridgeless CallInvoker, then invokes `callback` from INSIDE the same JS
125
+ // thread block, strictly AFTER the segment has been evaluated. This is the
126
+ // ordering guarantee that fixes the "Requiring unknown module" race.
127
+ //
128
+ // Does NOT block the calling (native modules) thread — returns immediately
129
+ // after scheduling. Resolution happens later on the JS thread.
130
+ static void nativeEvaluateSegment(
131
+ jni::alias_ref<jclass> /* unused */,
132
+ jni::alias_ref<CallInvokerHolder::javaobject> callInvokerHolder,
133
+ jni::alias_ref<jstring> segmentPath,
134
+ jni::alias_ref<jstring> sourceURL,
135
+ jni::alias_ref<JSegmentEvalCallback> callback) {
136
+ // Capture everything we need as values / global refs because the callback
137
+ // runs later on a different thread.
138
+ auto globalCallback = jni::make_global(callback);
139
+
140
+ if (!callInvokerHolder) {
141
+ globalCallback->onComplete("CallInvokerHolder is null");
142
+ return;
143
+ }
144
+
145
+ std::shared_ptr<CallInvoker> callInvoker =
146
+ callInvokerHolder->cthis()->getCallInvoker();
147
+ if (!callInvoker) {
148
+ globalCallback->onComplete("CallInvoker is null");
149
+ return;
150
+ }
151
+
152
+ std::string path = segmentPath ? segmentPath->toStdString() : std::string();
153
+ std::string url =
154
+ sourceURL ? sourceURL->toStdString() : std::string("segment");
155
+
156
+ if (path.empty()) {
157
+ globalCallback->onComplete("Empty segment path");
158
+ return;
159
+ }
160
+
161
+ // F: Read the segment file HERE, on the calling (native module) thread,
162
+ // BEFORE invokeAsync. Doing the disk read inside the JS-thread callback
163
+ // would block the JS thread on I/O and race the Kotlin watchdog. We move
164
+ // the already-read buffer into the lambda so only evaluateJavaScript +
165
+ // completion run on the JS thread (mirrors iOS, which mmaps off-thread and
166
+ // only evaluates on the runtime thread). The read error is surfaced as a
167
+ // dedicated IO error so JS can classify it as NON-retryable.
168
+ std::string source;
169
+ bool ioOk = readFileToString(path, source);
170
+ if (!ioOk) {
171
+ globalCallback->onComplete("IO_ERROR:Failed to read segment file: " + path);
172
+ return;
173
+ }
174
+ if (source.empty()) {
175
+ globalCallback->onComplete("IO_ERROR:Empty segment file: " + path);
176
+ return;
177
+ }
178
+
179
+ // CallFunc = std::function<void(jsi::Runtime&)>; this runs on the JS thread
180
+ // on the SAME RuntimeScheduler the segment registration would have used.
181
+ callInvoker->invokeAsync([globalCallback,
182
+ source = std::move(source),
183
+ url = std::move(url)](jsi::Runtime& runtime) {
184
+ std::string error;
185
+ try {
186
+ {
187
+ SBL_LOGI(
188
+ "[SplitBundle] evaluating segment %s (%zu bytes)",
189
+ url.c_str(),
190
+ source.size());
191
+ auto buffer = std::make_shared<jsi::StringBuffer>(std::move(source));
192
+ // Evaluate the segment into the CURRENT runtime. evaluateJavaScript
193
+ // runs the segment's top-level __d(...) module definitions
194
+ // synchronously on this JS thread before returning.
195
+ runtime.evaluateJavaScript(std::move(buffer), url);
196
+ SBL_LOGI("[SplitBundle] segment %s evaluated", url.c_str());
197
+ }
198
+ } catch (const jsi::JSError& e) {
199
+ error = std::string("Segment evaluation JSError for ") + url + ": " +
200
+ e.getMessage();
201
+ SBL_LOGW("[SplitBundle] %s", error.c_str());
202
+ } catch (const std::exception& e) {
203
+ error = std::string("Segment evaluation failed for ") + url + ": " +
204
+ e.what();
205
+ SBL_LOGW("[SplitBundle] %s", error.c_str());
206
+ } catch (...) {
207
+ error = std::string("Segment evaluation failed for ") + url +
208
+ " (unknown C++ exception)";
209
+ SBL_LOGW("[SplitBundle] %s", error.c_str());
210
+ }
211
+
212
+ // Attach to the JVM for this (JS) thread before calling back into Java.
213
+ // The JS thread is a native (pthread) thread not implicitly attached to
214
+ // the JVM; ThreadScope ensures a valid JNIEnv for the callback.
215
+ jni::ThreadScope ts;
216
+ // Resolve/reject from INSIDE this same JS-thread block, strictly AFTER
217
+ // eval above — the ordering guarantee that fixes the race.
218
+ globalCallback->onComplete(error);
219
+ });
220
+ }
221
+
222
+ static void registerNatives() {
223
+ // Static JNI methods on a plain JavaClass (NOT a HybridClass): bind via
224
+ // the class's registerNatives, not registerHybrid.
225
+ javaClassStatic()->registerNatives({
226
+ makeNativeMethod(
227
+ "nativeEvaluateSegment",
228
+ SplitBundleLoaderJSI::nativeEvaluateSegment),
229
+ });
230
+ }
231
+ };
232
+
233
+ } // namespace facebook::react::splitbundleloader
234
+
235
+ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM* vm, void* /* reserved */) {
236
+ return facebook::jni::initialize(vm, [] {
237
+ facebook::react::splitbundleloader::SplitBundleLoaderJSI::registerNatives();
238
+ });
239
+ }
@@ -5,13 +5,18 @@ import android.content.res.AssetManager
5
5
  import com.facebook.react.bridge.Arguments
6
6
  import com.facebook.react.bridge.Promise
7
7
  import com.facebook.react.bridge.ReactApplicationContext
8
+ import com.facebook.react.common.annotations.FrameworkAPI
8
9
  import com.facebook.react.module.annotations.ReactModule
10
+ import com.facebook.react.turbomodule.core.CallInvokerHolderImpl
9
11
  import java.io.File
10
12
  import java.io.FileInputStream
11
13
  import java.io.FileOutputStream
12
14
  import java.io.IOException
13
15
  import java.security.MessageDigest
14
16
  import java.util.concurrent.Semaphore
17
+ import java.util.concurrent.atomic.AtomicBoolean
18
+ import android.os.Handler
19
+ import android.os.Looper
15
20
 
16
21
  /**
17
22
  * TurboModule entry point for SplitBundleLoader.
@@ -26,9 +31,65 @@ import java.util.concurrent.Semaphore
26
31
  class SplitBundleLoaderModule(reactContext: ReactApplicationContext) :
27
32
  NativeSplitBundleLoaderSpec(reactContext) {
28
33
 
34
+ /**
35
+ * Completion contract invoked by the native (JNI) side AFTER the segment
36
+ * has been evaluated into the runtime. Called from the JS thread.
37
+ *
38
+ * @param error null on success; a non-empty message on failure. A failure
39
+ * message prefixed with "IO_ERROR:" denotes a segment file read/mmap
40
+ * failure (mapped to SPLIT_BUNDLE_IO_ERROR); any other message denotes a
41
+ * segment JS/Hermes eval throw (mapped to SPLIT_BUNDLE_EVAL_ERROR).
42
+ */
43
+ fun interface SegmentEvalCallback {
44
+ fun onComplete(error: String?)
45
+ }
46
+
29
47
  companion object {
30
48
  const val NAME = "SplitBundleLoader"
31
49
  private const val BUILTIN_EXTRACT_DIR = "onekey-builtin-segments"
50
+
51
+ // Bounded watchdog: if the JS thread is wedged and the segment eval
52
+ // never runs, reject rather than leaving the JS promise pending forever.
53
+ // Generous because a cold JS thread under load can legitimately take a
54
+ // while to drain to our scheduled eval.
55
+ private const val SEGMENT_EVAL_TIMEOUT_MS = 30_000L
56
+
57
+ // Loads the JNI library that provides nativeEvaluateSegment. Wrapped so
58
+ // a missing/failed load is detectable: loadSegment then fail-closes with
59
+ // SPLIT_BUNDLE_NATIVE_UNAVAILABLE instead of crashing (we deliberately do
60
+ // NOT fall back to the legacy registerSegment path).
61
+ @JvmStatic
62
+ @Volatile
63
+ var nativeLibLoaded: Boolean = false
64
+ private set
65
+
66
+ init {
67
+ nativeLibLoaded = try {
68
+ System.loadLibrary("splitbundleloader")
69
+ true
70
+ } catch (e: Throwable) {
71
+ SBLLogger.warn("[SplitBundle] failed to load native lib 'splitbundleloader': ${e.message}")
72
+ false
73
+ }
74
+ }
75
+
76
+ /**
77
+ * JNI entry point. Schedules evaluation of the segment at [segmentPath]
78
+ * onto the JS thread via the bridgeless CallInvoker and invokes
79
+ * [callback] from inside that same JS-thread block, strictly AFTER the
80
+ * segment's `__d(...)` module definitions have run. This is the ordering
81
+ * guarantee that fixes the "Requiring unknown module" race.
82
+ *
83
+ * Returns immediately; [callback] fires later on the JS thread.
84
+ */
85
+ @JvmStatic
86
+ @OptIn(FrameworkAPI::class)
87
+ external fun nativeEvaluateSegment(
88
+ callInvokerHolder: CallInvokerHolderImpl,
89
+ segmentPath: String,
90
+ sourceURL: String,
91
+ callback: SegmentEvalCallback
92
+ )
32
93
  // #18: Limit concurrent asset extractions to avoid I/O contention
33
94
  private const val MAX_CONCURRENT_EXTRACTS = 2
34
95
  private val extractSemaphore = Semaphore(MAX_CONCURRENT_EXTRACTS)
@@ -197,6 +258,7 @@ class SplitBundleLoaderModule(reactContext: ReactApplicationContext) :
197
258
  // loadSegment
198
259
  // -----------------------------------------------------------------------
199
260
 
261
+ @OptIn(FrameworkAPI::class)
200
262
  override fun loadSegment(
201
263
  segmentId: Double,
202
264
  segmentKey: String,
@@ -229,15 +291,119 @@ class SplitBundleLoaderModule(reactContext: ReactApplicationContext) :
229
291
  return
230
292
  }
231
293
 
232
- // Use ReactContext.registerSegment which works in both bridge
233
- // and bridgeless modes. In bridge mode it delegates to
234
- // CatalystInstance; in bridgeless mode it delegates to ReactHost.
235
294
  val reactContext = reactApplicationContext
236
295
  val segStart = System.nanoTime()
237
- reactContext.registerSegment(segId, absolutePath) {
238
- val segMs = (System.nanoTime() - segStart) / 1_000_000.0
239
- SBLLogger.info("[SplitBundle] segment $segmentKey (id=$segId) registered in ${String.format("%.1f", segMs)}ms")
240
- promise.resolve(null)
296
+
297
+ // PRIMARY PATH (fixes the "Requiring unknown module" race):
298
+ // Evaluate the segment OURSELVES on the JS thread via the bridgeless
299
+ // CallInvoker and resolve ONLY after eval completes. We intentionally
300
+ // do NOT use ReactContext.registerSegment here: that resolves its
301
+ // callback immediately (on Task.IMMEDIATE_EXECUTOR) while the actual
302
+ // segment eval is merely ENQUEUED onto the RuntimeScheduler, so the
303
+ // promise resolves BEFORE the segment's __d(...) module definitions
304
+ // run — Metro's import().then(() => __r(moduleId)) can then hit a
305
+ // fatal "Requiring unknown module". nativeEvaluateSegment collapses
306
+ // eval + resolve into one JS-thread block (mirrors the iOS
307
+ // callFunctionOnBufferedRuntimeExecutor: fix).
308
+ val callInvokerHolder =
309
+ reactContext.jsCallInvokerHolder as? CallInvokerHolderImpl
310
+
311
+ if (nativeLibLoaded && callInvokerHolder != null) {
312
+ val sourceURL = File(absolutePath).name
313
+ // One-shot guard: native success/error AND the watchdog can each
314
+ // try to settle the promise; only the first wins.
315
+ val settled = AtomicBoolean(false)
316
+
317
+ // Bounded watchdog: if the JS thread never drains to our eval,
318
+ // reject instead of hanging the JS promise forever.
319
+ val watchdog = Handler(Looper.getMainLooper())
320
+ val timeoutRunnable = Runnable {
321
+ if (settled.compareAndSet(false, true)) {
322
+ SBLLogger.warn("[SplitBundle] segment $segmentKey (id=$segId) eval timed out after ${SEGMENT_EVAL_TIMEOUT_MS}ms")
323
+ // D: Reject with the RETRYABLE timeout code from the shared
324
+ // contract (NOT SPLIT_BUNDLE_EVAL_TIMEOUT). A wedged JS
325
+ // thread is a transient condition; using the retryable
326
+ // SPLIT_BUNDLE_TIMEOUT lets the JS loader re-attempt rather
327
+ // than caching this segment as a permanent failure.
328
+ promise.reject(
329
+ "SPLIT_BUNDLE_TIMEOUT",
330
+ "Segment eval timed out: $segmentKey (id=$segId)"
331
+ )
332
+ }
333
+ }
334
+ watchdog.postDelayed(timeoutRunnable, SEGMENT_EVAL_TIMEOUT_MS)
335
+
336
+ // Catch Throwable, NOT just Exception, around the native call.
337
+ // nativeEvaluateSegment is `external`: if the symbol is
338
+ // registered-but-broken (or the lib half-loaded) the JVM raises
339
+ // UnsatisfiedLinkError, which is a java.lang.Error — it would
340
+ // sail past the outer `catch (e: Exception)` and leave the JS
341
+ // promise unsettled forever (the watchdog would eventually fire,
342
+ // but only after a 30s hang). Settle exactly once here via the
343
+ // same AtomicBoolean one-shot guard and cancel the watchdog.
344
+ try {
345
+ nativeEvaluateSegment(
346
+ callInvokerHolder,
347
+ absolutePath,
348
+ sourceURL
349
+ ) { error ->
350
+ if (settled.compareAndSet(false, true)) {
351
+ watchdog.removeCallbacks(timeoutRunnable)
352
+ if (error == null) {
353
+ val segMs = (System.nanoTime() - segStart) / 1_000_000.0
354
+ SBLLogger.info("[SplitBundle] segment $segmentKey (id=$segId) evaluated in ${String.format("%.1f", segMs)}ms (eval-complete)")
355
+ promise.resolve(null)
356
+ } else {
357
+ // The native side prefixes I/O failures (file
358
+ // read/mmap) with "IO_ERROR:" so we can map them to
359
+ // SPLIT_BUNDLE_IO_ERROR (non-retryable). Everything
360
+ // else is a segment JS/Hermes eval throw →
361
+ // SPLIT_BUNDLE_EVAL_ERROR (also non-retryable, a real
362
+ // bug in the segment's own code).
363
+ if (error.startsWith("IO_ERROR:")) {
364
+ val msg = error.removePrefix("IO_ERROR:")
365
+ SBLLogger.warn("[SplitBundle] segment $segmentKey (id=$segId) IO failed: $msg")
366
+ promise.reject("SPLIT_BUNDLE_IO_ERROR", msg)
367
+ } else {
368
+ SBLLogger.warn("[SplitBundle] segment $segmentKey (id=$segId) eval failed: $error")
369
+ promise.reject("SPLIT_BUNDLE_EVAL_ERROR", error)
370
+ }
371
+ }
372
+ }
373
+ }
374
+ } catch (t: Throwable) {
375
+ // UnsatisfiedLinkError / any native dispatch failure. The
376
+ // callback never fired (the native side never got far enough
377
+ // to invoke it), so settle the promise ourselves — exactly
378
+ // once — as a fatal, non-retryable native fault.
379
+ if (settled.compareAndSet(false, true)) {
380
+ watchdog.removeCallbacks(timeoutRunnable)
381
+ SBLLogger.error("[SplitBundle] FATAL: nativeEvaluateSegment threw for $segmentKey (id=$segId): ${t.message}")
382
+ promise.reject(
383
+ "SPLIT_BUNDLE_NATIVE_UNAVAILABLE",
384
+ "Native segment eval threw: $segmentKey (id=$segId): ${t.message}"
385
+ )
386
+ }
387
+ }
388
+ } else {
389
+ // B: FAIL CLOSED. On this app (RN 0.81, NewArch / bridgeless),
390
+ // jsCallInvokerHolder is always a CallInvokerHolderImpl and the
391
+ // JNI lib ships in the AAR, so reaching here means a genuine
392
+ // native fault (loadLibrary failed or the holder cast failed) —
393
+ // NOT a benign legacy-bridge mode (this app never runs the old
394
+ // bridge). We deliberately do NOT fall back to the legacy
395
+ // ReactContext.registerSegment path: that resolves before eval
396
+ // and reintroduces the intermittent, uncatchable "Requiring
397
+ // unknown module" native crash. Failing closed (segment load
398
+ // unavailable → JS error boundary) is strictly better than an
399
+ // intermittent crash, so we reject with the dedicated
400
+ // NATIVE_UNAVAILABLE code (non-retryable; the JS loader will not
401
+ // hammer-retry a structurally broken native primitive).
402
+ SBLLogger.error("[SplitBundle] FATAL: native eval primitive unavailable (libLoaded=$nativeLibLoaded, holderCast=${callInvokerHolder != null}) for $segmentKey (id=$segId); failing closed instead of using the race-prone legacy registerSegment fallback")
403
+ promise.reject(
404
+ "SPLIT_BUNDLE_NATIVE_UNAVAILABLE",
405
+ "Native segment eval unavailable (libLoaded=$nativeLibLoaded, holderCast=${callInvokerHolder != null}): $segmentKey (id=$segId)"
406
+ )
241
407
  }
242
408
  } catch (e: Exception) {
243
409
  promise.reject("SPLIT_BUNDLE_LOAD_ERROR", e.message, e)
@@ -5,7 +5,105 @@
5
5
  #import <ReactCommon/RCTInstance.h>
6
6
  #import <objc/runtime.h>
7
7
  #import <CommonCrypto/CommonDigest.h>
8
+ #import <os/lock.h>
8
9
  #include <jsi/jsi.h>
10
+ #include <cstdint>
11
+
12
+ namespace {
13
+
14
+ // Zero-copy jsi::Buffer over an NSData (M4/M5).
15
+ //
16
+ // WHY: the previous code did `std::string(data.bytes, data.length)` inside a
17
+ // jsi::StringBuffer — a FULL second copy of the (potentially multi-MB) segment
18
+ // bytes on top of whatever the NSData read already cost. For the hot segment
19
+ // path that doubles peak memory and adds a memcpy on the JS thread.
20
+ //
21
+ // This buffer instead RETAINS the NSData and hands jsi the NSData's own bytes
22
+ // directly. Combined with NSDataReadingMappedIfSafe at the read site, the
23
+ // segment is mmap'd and Hermes parses straight from the mapped pages — no heap
24
+ // copy at all on the happy path. The retained NSData also makes the buffer's
25
+ // lifetime explicit and self-owned: the executor block runs ASYNCHRONOUSLY
26
+ // (it's buffered until the entry bundle finishes), so the NSData MUST outlive
27
+ // the originating scope. Holding it inside the Buffer (which jsi keeps alive
28
+ // via the shared_ptr for the duration of evaluateJavaScript) guarantees that.
29
+ class NSDataJSIBuffer : public facebook::jsi::Buffer {
30
+ public:
31
+ explicit NSDataJSIBuffer(NSData *data) : data_(data) {}
32
+ size_t size() const override { return data_.length; }
33
+ const uint8_t *data() const override {
34
+ return static_cast<const uint8_t *>(data_.bytes);
35
+ }
36
+
37
+ private:
38
+ NSData *data_; // strong retain (ARC) — keeps mmap/heap bytes alive for jsi.
39
+ };
40
+
41
+ } // namespace
42
+
43
+ // Exactly-once settle guard for the C1 watchdog. Wraps a BOOL behind an
44
+ // os_unfair_lock. WHY AN OBJECT (not a __block BOOL + manual free): the executor
45
+ // block and the watchdog dispatch_after BOTH capture it and BOTH may run
46
+ // (dispatch_after is not cancellable, and on a wedge the executor can run AFTER
47
+ // the watchdog). Tying the lock's lifetime to ARC — both blocks retain this
48
+ // object, it deallocs only after both release — eliminates the use-after-free a
49
+ // manual free() in either block would cause. `tryClaim` returns YES to exactly
50
+ // one caller; the loser does nothing.
51
+ @interface SBLSettleGuard : NSObject
52
+ - (BOOL)tryClaim;
53
+ @end
54
+
55
+ @implementation SBLSettleGuard {
56
+ os_unfair_lock _lock;
57
+ BOOL _settled;
58
+ }
59
+ - (instancetype)init {
60
+ if (self = [super init]) {
61
+ _lock = OS_UNFAIR_LOCK_INIT;
62
+ _settled = NO;
63
+ }
64
+ return self;
65
+ }
66
+ - (BOOL)tryClaim {
67
+ os_unfair_lock_lock(&_lock);
68
+ BOOL won = !_settled;
69
+ if (won) {
70
+ _settled = YES;
71
+ }
72
+ os_unfair_lock_unlock(&_lock);
73
+ return won;
74
+ }
75
+ @end
76
+
77
+ // Watchdog window for the buffered runtime executor (C1). Post-entry eval is
78
+ // sub-millisecond, so this only ever elapses on a genuine wedge (entry bundle
79
+ // never finished evaluating) — never on a healthy slow device.
80
+ //
81
+ // Fix G: 30s (matching Android and the bg-runtime watchdog). The executor stays
82
+ // buffered until the main ENTRY bundle finishes evaluating; on a slow/throttled
83
+ // cold start that entry eval can itself exceed 10s, which would falsely trip the
84
+ // watchdog on a segment load that was about to succeed. 30s keeps the
85
+ // genuine-wedge safety net while leaving headroom for slow cold starts.
86
+ static const NSTimeInterval kSegmentEvalWatchdogSeconds = 30.0;
87
+
88
+ // NSError `code` values produced by +evaluateSegmentAtPath:... . These are
89
+ // DISTINCT (L8) so loadSegment: can map each to its own JS reject code and JS
90
+ // can classify retryable-vs-fatal:
91
+ // - HostMissing / NilInstance → SPLIT_BUNDLE_NO_RUNTIME (retryable):
92
+ // the runtime/host simply wasn't ready yet; a later attempt may succeed.
93
+ // - IvarMissing → SPLIT_BUNDLE_NATIVE_UNAVAILABLE (NOT retryable): a renamed
94
+ // ivar is a structural/build defect that no retry can fix.
95
+ // - Timeout → SPLIT_BUNDLE_TIMEOUT (retryable): buffered executor never ran.
96
+ // - IORead → SPLIT_BUNDLE_IO_ERROR: file read/mmap failed.
97
+ // - EvalThrow → SPLIT_BUNDLE_EVAL_ERROR (NOT retryable): a real bug in the
98
+ // segment's own JS/Hermes code; retrying just re-throws.
99
+ typedef NS_ENUM(NSInteger, ESegmentEvalError) {
100
+ ESegmentEvalErrorHostMissing = 1,
101
+ ESegmentEvalErrorIvarMissing = 2,
102
+ ESegmentEvalErrorNilInstance = 3,
103
+ ESegmentEvalErrorIORead = 4,
104
+ ESegmentEvalErrorEvalThrow = 5,
105
+ ESegmentEvalErrorTimeout = 6,
106
+ };
9
107
 
10
108
  @implementation SplitBundleLoader
11
109
 
@@ -127,30 +225,200 @@
127
225
  return result;
128
226
  }
129
227
 
130
- // MARK: - Segment registration helper
228
+ // MARK: - RCTHost resolution helper
131
229
 
132
- /// Registers a segment with the current runtime via bridgeless (RCTHost) architecture (#13).
133
- ///
134
- /// Thread safety (#57): This method is called from the TurboModule (JS thread).
135
- /// No queue dispatch is needed.
136
- + (BOOL)registerSegment:(int)segmentId path:(NSString *)path error:(NSError **)outError
230
+ /// Resolves the bridgeless RCTHost via the AppDelegate's `reactHost` accessor
231
+ /// (New Architecture). Returns nil when the host is unavailable so callers can
232
+ /// reject gracefully. Extracted so both segment registration and the
233
+ /// evaluate-then-resolve path (#race) share one lookup.
234
+ + (nullable RCTHost *)currentReactHost
137
235
  {
138
- // Bridgeless (New Architecture): get RCTHost via AppDelegate
139
236
  id<UIApplicationDelegate> appDelegate = [UIApplication sharedApplication].delegate;
140
237
  if ([appDelegate respondsToSelector:NSSelectorFromString(@"reactHost")]) {
141
238
  RCTHost *host = [appDelegate performSelector:NSSelectorFromString(@"reactHost")];
142
- if (host && [host respondsToSelector:@selector(registerSegmentWithId:path:)]) {
143
- [host registerSegmentWithId:@(segmentId) path:path];
144
- return YES;
239
+ if (host) {
240
+ return host;
145
241
  }
146
242
  }
243
+ return nil;
244
+ }
245
+
246
+ // MARK: - Segment evaluation helper (resolve-after-eval, fixes lazy-segment race)
147
247
 
148
- if (outError) {
149
- *outError = [NSError errorWithDomain:@"SplitBundleLoader"
150
- code:1
151
- userInfo:@{NSLocalizedDescriptionKey: @"RCTHost not available for segment registration"}];
248
+ /// Evaluates a segment bundle into the CURRENT runtime and invokes `onEvaluated`
249
+ /// from INSIDE the same runtime-executor block, immediately after the segment's
250
+ /// `__d(...)` module definitions have run.
251
+ ///
252
+ /// WHY THIS EXISTS — the "Requiring unknown module" race (#race):
253
+ /// The previous implementation called `RCTHost registerSegmentWithId:path:`,
254
+ /// which routes to `ReactInstance::registerSegment` →
255
+ /// `runtimeScheduler_->scheduleWork([]{ runtime.evaluateJavaScript(segment) })`.
256
+ /// That only ENQUEUES the eval onto the runtime scheduler and returns; the
257
+ /// loadSegment promise was resolved IMMEDIATELY afterwards. Metro's
258
+ /// `import().then(() => __r(moduleId))` microtask could therefore run `__r`
259
+ /// BEFORE the scheduled eval populated the module table → a FATAL, uncatchable
260
+ /// "Requiring unknown module" inside metroRequire.
261
+ ///
262
+ /// We cannot fix this by merely scheduling `resolve` after `registerSegment` on
263
+ /// the same executor: `RuntimeScheduler_Modern::scheduleWork` pushes
264
+ /// ImmediatePriority tasks into a `std::priority_queue` keyed only on
265
+ /// `expirationTime` (RuntimeScheduler_Modern.cpp / Task.h `TaskPriorityComparer`).
266
+ /// `std::priority_queue` is NOT stable, so two same-tick tasks have UNDEFINED
267
+ /// relative order — FIFO is not guaranteed under the Modern scheduler.
268
+ ///
269
+ /// Instead we evaluate the segment OURSELVES inside a single
270
+ /// `callFunctionOnBufferedRuntimeExecutor:` block (exactly like
271
+ /// `loadEntryBundle:`) and signal completion in that SAME block. Eval and the
272
+ /// resolve are now one atomic unit of work — there is no cross-task ordering to
273
+ /// lose, so any subsequent `__r(moduleId)` is guaranteed to find the module.
274
+ ///
275
+ /// This is safe because OneKey segments are STANDALONE-EVALUATABLE Metro
276
+ /// bundles (the serializer emits `baseJSBundle`/`bundleToString` output — plain
277
+ /// top-level `__d(moduleId, factory, deps)` calls, NOT Hermes RAM/indexed
278
+ /// segments that would require the registerSegment manifest wiring). The paired
279
+ /// `.seg.hbc` is just the Hermes-compiled form of that same source, which
280
+ /// `evaluateJavaScript` runs identically to the entry bundle's `.hbc`.
281
+ ///
282
+ /// `onEvaluated` is invoked EXACTLY ONCE with nil on success or a populated
283
+ /// NSError on failure. The NSError `code` is meaningful and mapped by the caller
284
+ /// to a distinct JS reject code (see ESegmentEvalError below + loadSegment:):
285
+ /// callers use it to classify retryable (no-runtime / timeout) vs fatal
286
+ /// (eval-throw / IO) failures.
287
+ + (void)evaluateSegmentAtPath:(NSString *)bundlePath
288
+ segmentId:(int)segmentId
289
+ segmentKey:(NSString *)segmentKey
290
+ onEvaluated:(void (^)(NSError *_Nullable error))onEvaluated
291
+ {
292
+ RCTHost *host = [SplitBundleLoader currentReactHost];
293
+ if (!host) {
294
+ onEvaluated([NSError errorWithDomain:@"SplitBundleLoader"
295
+ code:ESegmentEvalErrorHostMissing
296
+ userInfo:@{NSLocalizedDescriptionKey: @"RCTHost not available for segment evaluation"}]);
297
+ return;
152
298
  }
153
- return NO;
299
+
300
+ // Reach the RCTInstance the same way loadEntryBundle: does, so we can use
301
+ // the buffered runtime executor primitive (callFunctionOnBufferedRuntimeExecutor:).
302
+ Ivar ivar = class_getInstanceVariable([host class], "_instance");
303
+ if (!ivar) {
304
+ // L7: a missing `_instance` ivar means a future RN bump renamed/removed
305
+ // the field our reflection depends on. That silently disables ALL
306
+ // segment loading, so log loudly (error, with the class name) instead of
307
+ // failing quietly — the next RN upgrade then surfaces visibly in logs.
308
+ [SBLLogger error:[NSString stringWithFormat:@"[SplitBundle] FATAL: _instance ivar not found on %@ — segment loading is DISABLED. An RN upgrade likely renamed this private field; SplitBundleLoader reflection must be updated.", [host class]]];
309
+ onEvaluated([NSError errorWithDomain:@"SplitBundleLoader"
310
+ code:ESegmentEvalErrorIvarMissing
311
+ userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"_instance ivar not found on %@", [host class]]}]);
312
+ return;
313
+ }
314
+
315
+ RCTInstance *instance = object_getIvar(host, ivar);
316
+ if (!instance) {
317
+ onEvaluated([NSError errorWithDomain:@"SplitBundleLoader"
318
+ code:ESegmentEvalErrorNilInstance
319
+ userInfo:@{NSLocalizedDescriptionKey: @"RCTInstance is nil"}]);
320
+ return;
321
+ }
322
+
323
+ // M4/M5: mmap the segment (NSDataReadingMappedIfSafe) instead of a full
324
+ // heap read, then wrap it zero-copy in NSDataJSIBuffer (which retains the
325
+ // NSData). Net effect: no second copy, and the bytes stay alive for the
326
+ // async executor block because the buffer owns the NSData.
327
+ NSError *readError = nil;
328
+ NSData *data = [NSData dataWithContentsOfFile:bundlePath
329
+ options:NSDataReadingMappedIfSafe
330
+ error:&readError];
331
+ if (!data || data.length == 0) {
332
+ onEvaluated([NSError errorWithDomain:@"SplitBundleLoader"
333
+ code:ESegmentEvalErrorIORead
334
+ userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Failed to read segment at %@%@", bundlePath, readError ? [NSString stringWithFormat:@": %@", readError.localizedDescription] : @""]}]);
335
+ return;
336
+ }
337
+
338
+ // M6: preserve a meaningful eval source URL so in-segment crash frames are
339
+ // symbolicated. RN's registerSegment used
340
+ // `JSExecutor::getSyntheticBundlePath(segmentId, segmentPath)`, which for a
341
+ // non-main segment yields `seg-<id>.js` (see cxxreact/JSExecutor.cpp). We
342
+ // replicate that exact form so Hermes/Metro attribute frames to the segment
343
+ // the same way the native path did — using `lastPathComponent` here would
344
+ // degrade symbolication.
345
+ NSString *sourceURL = [NSString stringWithFormat:@"seg-%d.js", segmentId];
346
+ CFAbsoluteTime dispatchStart = CFAbsoluteTimeGetCurrent();
347
+ [SBLLogger info:[NSString stringWithFormat:@"[SplitBundle] loadSegment: evaluating %@ (key=%@, %lu bytes)", sourceURL, segmentKey, (unsigned long)data.length]];
348
+
349
+ // C1: the executor block below is BUFFERED — callFunctionOnBufferedRuntimeExecutor
350
+ // does not run it until the main entry bundle finishes evaluating
351
+ // (RCTInstance/ReactInstance.cpp). If a segment load is requested before the
352
+ // entry completes (early startup, reload teardown, host swap) and the entry
353
+ // never completes, the block NEVER runs → onEvaluated would never fire →
354
+ // the JS promise hangs forever and inflightSegments wedges with no timeout.
355
+ //
356
+ // Guard: invoke `onEvaluated` EXACTLY ONCE. SBLSettleGuard wraps a BOOL
357
+ // behind an os_unfair_lock; whichever racer wins — the executor block (happy
358
+ // path) or the watchdog timer (genuine wedge) — gets YES from tryClaim and
359
+ // settles the promise; the loser does nothing. The guard is an ARC object
360
+ // captured (retained) by BOTH blocks, so its lock outlives both with no
361
+ // manual free() and therefore no use-after-free (both blocks can run, in
362
+ // either order, on a wedge).
363
+ SBLSettleGuard *settleGuard = [[SBLSettleGuard alloc] init];
364
+
365
+ [instance callFunctionOnBufferedRuntimeExecutor:^(facebook::jsi::Runtime &runtime) {
366
+ @autoreleasepool {
367
+ // If the watchdog already fired (entry took >30s then unwedged),
368
+ // the JS promise is already rejected — still evaluate the segment
369
+ // (the module table benefits) but don't double-settle.
370
+ BOOL won = [settleGuard tryClaim];
371
+ NSError *evalError = nil;
372
+ CFAbsoluteTime evalStart = CFAbsoluteTimeGetCurrent();
373
+ try {
374
+ auto buffer = std::make_shared<NSDataJSIBuffer>(data);
375
+ runtime.evaluateJavaScript(buffer, [sourceURL UTF8String]);
376
+ double evalMs = (CFAbsoluteTimeGetCurrent() - evalStart) * 1000.0;
377
+ [SBLLogger info:[NSString stringWithFormat:@"[SplitBundle] loadSegment: %@ evaluated in %.1fms", sourceURL, evalMs]];
378
+ } catch (const std::exception &e) {
379
+ // L8: a JS/Hermes eval throw is a REAL BUG in the segment's own
380
+ // code, not a transient runtime-readiness problem. Mapped to a
381
+ // NON-retryable code by the caller so JS caches it as failed.
382
+ evalError = [NSError errorWithDomain:@"SplitBundleLoader"
383
+ code:ESegmentEvalErrorEvalThrow
384
+ userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Segment evaluation failed for %@: %s", sourceURL, e.what()]}];
385
+ [SBLLogger warn:[NSString stringWithFormat:@"[SplitBundle] loadSegment: %@ evaluation threw: %s", sourceURL, e.what()]];
386
+ } catch (...) {
387
+ evalError = [NSError errorWithDomain:@"SplitBundleLoader"
388
+ code:ESegmentEvalErrorEvalThrow
389
+ userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Segment evaluation failed for %@ (unknown C++ exception)", sourceURL]}];
390
+ [SBLLogger warn:[NSString stringWithFormat:@"[SplitBundle] loadSegment: %@ evaluation threw an unknown exception", sourceURL]];
391
+ }
392
+ if (won) {
393
+ // Resolve/reject the JS promise from INSIDE this same block,
394
+ // strictly AFTER the segment eval above — the ordering guarantee
395
+ // that fixes the "Requiring unknown module" race (see method doc).
396
+ onEvaluated(evalError);
397
+ } else {
398
+ [SBLLogger warn:[NSString stringWithFormat:@"[SplitBundle] loadSegment: %@ evaluated AFTER watchdog already settled (entry was wedged >%.0fs)", sourceURL, kSegmentEvalWatchdogSeconds]];
399
+ }
400
+ }
401
+ }];
402
+
403
+ // C1 watchdog. Fires only on a genuine wedge: in steady state the entry
404
+ // bundle is long done and the buffered block runs sub-millisecond, so the
405
+ // guard is already claimed (tryClaim returns NO) long before this elapses.
406
+ // On a real wedge it settles the promise with a distinct, RETRYABLE timeout
407
+ // error so the JS loader can re-attempt instead of hanging inflightSegments
408
+ // forever. The block retains settleGuard, so the lock stays valid even if
409
+ // the executor block later runs (entry finally evaluates).
410
+ dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kSegmentEvalWatchdogSeconds * NSEC_PER_SEC)),
411
+ dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
412
+ if ([settleGuard tryClaim]) {
413
+ [SBLLogger error:[NSString stringWithFormat:@"[SplitBundle] loadSegment: %@ (key=%@) WATCHDOG fired after %.0fs — runtime executor never ran (entry bundle likely never finished evaluating). Rejecting as retryable timeout.", sourceURL, segmentKey, kSegmentEvalWatchdogSeconds]];
414
+ onEvaluated([NSError errorWithDomain:@"SplitBundleLoader"
415
+ code:ESegmentEvalErrorTimeout
416
+ userInfo:@{NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Segment %@ eval timed out after %.0fs (buffered runtime executor never ran)", segmentKey, kSegmentEvalWatchdogSeconds]}]);
417
+ }
418
+ });
419
+
420
+ double dispatchMs = (CFAbsoluteTimeGetCurrent() - dispatchStart) * 1000.0;
421
+ [SBLLogger info:[NSString stringWithFormat:@"[SplitBundle] loadSegment: %@ dispatched in %.1fms (resolve fires after eval; watchdog %.0fs)", sourceURL, dispatchMs, kSegmentEvalWatchdogSeconds]];
154
422
  }
155
423
 
156
424
  // MARK: - getRuntimeBundleContext
@@ -280,7 +548,12 @@
280
548
  return;
281
549
  }
282
550
 
283
- NSData *data = [NSData dataWithContentsOfFile:bundlePath];
551
+ // M5: mmap + zero-copy (NSDataJSIBuffer retains the NSData for the async block),
552
+ // mirroring the segment path. Entry bundle is the largest single read, so this
553
+ // saves the biggest single copy.
554
+ NSData *data = [NSData dataWithContentsOfFile:bundlePath
555
+ options:NSDataReadingMappedIfSafe
556
+ error:nil];
284
557
  if (!data || data.length == 0) {
285
558
  [SBLLogger warn:[NSString stringWithFormat:@"loadEntryBundle: failed to read bundle at %@", bundlePath]];
286
559
  return;
@@ -293,9 +566,8 @@
293
566
  [instance callFunctionOnBufferedRuntimeExecutor:^(facebook::jsi::Runtime &runtime) {
294
567
  @autoreleasepool {
295
568
  CFAbsoluteTime evalStart = CFAbsoluteTimeGetCurrent();
296
- auto buffer = std::make_shared<facebook::jsi::StringBuffer>(
297
- std::string(static_cast<const char *>(data.bytes), data.length));
298
- runtime.evaluateJavaScript(std::move(buffer), [sourceURL UTF8String]);
569
+ auto buffer = std::make_shared<NSDataJSIBuffer>(data);
570
+ runtime.evaluateJavaScript(buffer, [sourceURL UTF8String]);
299
571
  double evalMs = (CFAbsoluteTimeGetCurrent() - evalStart) * 1000.0;
300
572
  [SBLLogger info:[NSString stringWithFormat:@"[SplitBundle] loadEntryBundle: %@ evaluated in %.1fms", sourceURL, evalMs]];
301
573
  }
@@ -342,17 +614,62 @@
342
614
  return;
343
615
  }
344
616
 
345
- // Register segment (#13: supports both bridge and bridgeless)
346
- NSError *regError = nil;
347
- if ([SplitBundleLoader registerSegment:segId path:absolutePath error:&regError]) {
617
+ // Evaluate the segment into the current runtime and resolve ONLY after
618
+ // its module definitions have actually run (#race). We intentionally do
619
+ // NOT use registerSegmentWithId: + immediate resolve here: that resolves
620
+ // before the scheduler-enqueued eval completes, so Metro's
621
+ // `import().then(() => __r(moduleId))` can hit "Requiring unknown module"
622
+ // (a fatal, uncatchable crash). See +evaluateSegmentAtPath:... doc for
623
+ // the full ordering rationale (Modern scheduler priority_queue is not
624
+ // FIFO, so we collapse eval+resolve into one runtime-executor block).
625
+ // segId is threaded through for the synthetic eval source URL (M6) and
626
+ // for log parity with the previous register flow.
627
+ [SplitBundleLoader evaluateSegmentAtPath:absolutePath
628
+ segmentId:segId
629
+ segmentKey:segmentKey
630
+ onEvaluated:^(NSError *_Nullable evalError) {
631
+ if (evalError) {
632
+ // L8: map the helper's distinct NSError code to a distinct JS
633
+ // reject code so JS can classify retryable vs fatal (see
634
+ // ESegmentEvalError + installProdBundleLoader.ts H3).
635
+ NSString *rejectCode;
636
+ switch ((ESegmentEvalError)evalError.code) {
637
+ case ESegmentEvalErrorTimeout:
638
+ rejectCode = @"SPLIT_BUNDLE_TIMEOUT"; // retryable
639
+ break;
640
+ case ESegmentEvalErrorIORead:
641
+ rejectCode = @"SPLIT_BUNDLE_IO_ERROR"; // fatal
642
+ break;
643
+ case ESegmentEvalErrorEvalThrow:
644
+ rejectCode = @"SPLIT_BUNDLE_EVAL_ERROR"; // fatal (segment bug)
645
+ break;
646
+ case ESegmentEvalErrorIvarMissing:
647
+ // Fix 2: `_instance` ivar reflection failed — STRUCTURAL/
648
+ // PERMANENT. An RN version bump renamed/removed the
649
+ // private field our reflection depends on, so segment
650
+ // loading is disabled until the native code is updated.
651
+ // Retrying can NEVER recreate a renamed ivar, so this is
652
+ // fatal NATIVE_UNAVAILABLE — NOT retryable NO_RUNTIME. By
653
+ // contrast HostMissing / NilInstance below are genuinely
654
+ // transient (host/instance not up yet → a later attempt
655
+ // may succeed).
656
+ rejectCode = @"SPLIT_BUNDLE_NATIVE_UNAVAILABLE"; // fatal
657
+ break;
658
+ case ESegmentEvalErrorHostMissing:
659
+ case ESegmentEvalErrorNilInstance:
660
+ default:
661
+ rejectCode = @"SPLIT_BUNDLE_NO_RUNTIME"; // retryable
662
+ break;
663
+ }
664
+ reject(rejectCode,
665
+ evalError.localizedDescription ?: @"Segment evaluation failed",
666
+ evalError);
667
+ return;
668
+ }
348
669
  double segMs = (CFAbsoluteTimeGetCurrent() - segStart) * 1000.0;
349
- [SBLLogger info:[NSString stringWithFormat:@"[SplitBundle] Loaded segment %@ (id=%d) in %.1fms", segmentKey, segId, segMs]];
670
+ [SBLLogger info:[NSString stringWithFormat:@"[SplitBundle] Loaded segment %@ (id=%d) in %.1fms (eval-complete)", segmentKey, segId, segMs]];
350
671
  resolve(nil);
351
- } else {
352
- reject(@"SPLIT_BUNDLE_NO_RUNTIME",
353
- regError.localizedDescription ?: @"Runtime not available",
354
- regError);
355
- }
672
+ }];
356
673
  } @catch (NSException *exception) {
357
674
  reject(@"SPLIT_BUNDLE_LOAD_ERROR",
358
675
  [NSString stringWithFormat:@"Failed to load segment %@: %@", segmentKey, exception.reason],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onekeyfe/react-native-split-bundle-loader",
3
- "version": "3.0.63",
3
+ "version": "3.0.64",
4
4
  "description": "react-native-split-bundle-loader",
5
5
  "main": "./lib/module/index.js",
6
6
  "types": "./lib/typescript/src/index.d.ts",