@scalebun/react-native 1.0.1 → 1.0.3

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.
Files changed (111) hide show
  1. package/android/src/main/java/com/scalebun/rn/ota/BundleDownloader.kt +170 -0
  2. package/android/src/main/java/com/scalebun/rn/ota/ScaleBunOtaModule.kt +160 -12
  3. package/android/src/main/java/com/scalebun/rn/ota/SlotManager.kt +32 -6
  4. package/android/src/oldarch/java/com/scalebun/rn/ota/ScaleBunOtaSpec.kt +7 -1
  5. package/ios/Ota/BundleDownloader.swift +182 -0
  6. package/ios/Ota/OtaSlotManager.swift +35 -9
  7. package/ios/Ota/ScaleBunOtaBridge.mm +9 -0
  8. package/ios/Ota/ScaleBunOtaEventsModule.swift +61 -0
  9. package/ios/Ota/ScaleBunOtaModule.swift +58 -5
  10. package/lib/commonjs/bootstrap/SDKBootstrapper.js +22 -0
  11. package/lib/commonjs/bootstrap/SDKBootstrapper.js.map +1 -1
  12. package/lib/commonjs/features/crash/CrashFeature.js +18 -0
  13. package/lib/commonjs/features/crash/CrashFeature.js.map +1 -1
  14. package/lib/commonjs/features/crash/rejectionHandler.js +123 -0
  15. package/lib/commonjs/features/crash/rejectionHandler.js.map +1 -0
  16. package/lib/commonjs/features/engage/EngagePromptProvider.js +4 -2
  17. package/lib/commonjs/features/engage/EngagePromptProvider.js.map +1 -1
  18. package/lib/commonjs/features/engage/engageThrottle.js +41 -10
  19. package/lib/commonjs/features/engage/engageThrottle.js.map +1 -1
  20. package/lib/commonjs/features/engage/engageTypes.js.map +1 -1
  21. package/lib/commonjs/features/navigation/AutoScreenDetector.js +53 -10
  22. package/lib/commonjs/features/navigation/AutoScreenDetector.js.map +1 -1
  23. package/lib/commonjs/features/ota/OtaOrchestrator.js +515 -20
  24. package/lib/commonjs/features/ota/OtaOrchestrator.js.map +1 -1
  25. package/lib/commonjs/features/ota/environment.js +172 -0
  26. package/lib/commonjs/features/ota/environment.js.map +1 -0
  27. package/lib/commonjs/features/ota/retry.js +96 -0
  28. package/lib/commonjs/features/ota/retry.js.map +1 -0
  29. package/lib/commonjs/features/ota/signature.js +115 -0
  30. package/lib/commonjs/features/ota/signature.js.map +1 -0
  31. package/lib/commonjs/features/session/BackendSessionAdapter.js +231 -26
  32. package/lib/commonjs/features/session/BackendSessionAdapter.js.map +1 -1
  33. package/lib/commonjs/index.js +7 -0
  34. package/lib/commonjs/index.js.map +1 -1
  35. package/lib/commonjs/public/ScaleBunErrorBoundary.js +79 -0
  36. package/lib/commonjs/public/ScaleBunErrorBoundary.js.map +1 -0
  37. package/lib/commonjs/specs/NativeScaleBunOta.js.map +1 -1
  38. package/lib/module/bootstrap/SDKBootstrapper.js +22 -0
  39. package/lib/module/bootstrap/SDKBootstrapper.js.map +1 -1
  40. package/lib/module/features/crash/CrashFeature.js +18 -0
  41. package/lib/module/features/crash/CrashFeature.js.map +1 -1
  42. package/lib/module/features/crash/rejectionHandler.js +116 -0
  43. package/lib/module/features/crash/rejectionHandler.js.map +1 -0
  44. package/lib/module/features/engage/EngagePromptProvider.js +4 -2
  45. package/lib/module/features/engage/EngagePromptProvider.js.map +1 -1
  46. package/lib/module/features/engage/engageThrottle.js +41 -10
  47. package/lib/module/features/engage/engageThrottle.js.map +1 -1
  48. package/lib/module/features/engage/engageTypes.js.map +1 -1
  49. package/lib/module/features/navigation/AutoScreenDetector.js +53 -10
  50. package/lib/module/features/navigation/AutoScreenDetector.js.map +1 -1
  51. package/lib/module/features/ota/OtaOrchestrator.js +516 -21
  52. package/lib/module/features/ota/OtaOrchestrator.js.map +1 -1
  53. package/lib/module/features/ota/environment.js +165 -0
  54. package/lib/module/features/ota/environment.js.map +1 -0
  55. package/lib/module/features/ota/retry.js +87 -0
  56. package/lib/module/features/ota/retry.js.map +1 -0
  57. package/lib/module/features/ota/signature.js +109 -0
  58. package/lib/module/features/ota/signature.js.map +1 -0
  59. package/lib/module/features/session/BackendSessionAdapter.js +230 -26
  60. package/lib/module/features/session/BackendSessionAdapter.js.map +1 -1
  61. package/lib/module/index.js +6 -0
  62. package/lib/module/index.js.map +1 -1
  63. package/lib/module/public/ScaleBunErrorBoundary.js +70 -0
  64. package/lib/module/public/ScaleBunErrorBoundary.js.map +1 -0
  65. package/lib/module/specs/NativeScaleBunOta.js.map +1 -1
  66. package/lib/typescript/bootstrap/SDKBootstrapper.d.ts.map +1 -1
  67. package/lib/typescript/features/crash/CrashFeature.d.ts.map +1 -1
  68. package/lib/typescript/features/crash/rejectionHandler.d.ts +46 -0
  69. package/lib/typescript/features/crash/rejectionHandler.d.ts.map +1 -0
  70. package/lib/typescript/features/engage/EngagePromptProvider.d.ts.map +1 -1
  71. package/lib/typescript/features/engage/engageThrottle.d.ts +24 -4
  72. package/lib/typescript/features/engage/engageThrottle.d.ts.map +1 -1
  73. package/lib/typescript/features/engage/engageTypes.d.ts +6 -0
  74. package/lib/typescript/features/engage/engageTypes.d.ts.map +1 -1
  75. package/lib/typescript/features/navigation/AutoScreenDetector.d.ts +9 -0
  76. package/lib/typescript/features/navigation/AutoScreenDetector.d.ts.map +1 -1
  77. package/lib/typescript/features/ota/OtaOrchestrator.d.ts +67 -1
  78. package/lib/typescript/features/ota/OtaOrchestrator.d.ts.map +1 -1
  79. package/lib/typescript/features/ota/OtaTypes.d.ts +42 -0
  80. package/lib/typescript/features/ota/OtaTypes.d.ts.map +1 -1
  81. package/lib/typescript/features/ota/environment.d.ts +99 -0
  82. package/lib/typescript/features/ota/environment.d.ts.map +1 -0
  83. package/lib/typescript/features/ota/retry.d.ts +53 -0
  84. package/lib/typescript/features/ota/retry.d.ts.map +1 -0
  85. package/lib/typescript/features/ota/signature.d.ts +66 -0
  86. package/lib/typescript/features/ota/signature.d.ts.map +1 -0
  87. package/lib/typescript/features/session/BackendSessionAdapter.d.ts +93 -2
  88. package/lib/typescript/features/session/BackendSessionAdapter.d.ts.map +1 -1
  89. package/lib/typescript/index.d.ts +6 -0
  90. package/lib/typescript/index.d.ts.map +1 -1
  91. package/lib/typescript/public/ScaleBunErrorBoundary.d.ts +44 -0
  92. package/lib/typescript/public/ScaleBunErrorBoundary.d.ts.map +1 -0
  93. package/lib/typescript/specs/NativeScaleBunOta.d.ts +11 -0
  94. package/lib/typescript/specs/NativeScaleBunOta.d.ts.map +1 -1
  95. package/package.json +3 -3
  96. package/src/bootstrap/SDKBootstrapper.ts +24 -0
  97. package/src/features/crash/CrashFeature.ts +19 -0
  98. package/src/features/crash/rejectionHandler.ts +134 -0
  99. package/src/features/engage/EngagePromptProvider.tsx +4 -2
  100. package/src/features/engage/engageThrottle.ts +44 -6
  101. package/src/features/engage/engageTypes.ts +6 -0
  102. package/src/features/navigation/AutoScreenDetector.ts +59 -4
  103. package/src/features/ota/OtaOrchestrator.ts +594 -23
  104. package/src/features/ota/OtaTypes.ts +56 -0
  105. package/src/features/ota/environment.ts +199 -0
  106. package/src/features/ota/retry.ts +123 -0
  107. package/src/features/ota/signature.ts +123 -0
  108. package/src/features/session/BackendSessionAdapter.ts +233 -25
  109. package/src/index.ts +6 -0
  110. package/src/public/ScaleBunErrorBoundary.tsx +79 -0
  111. package/src/specs/NativeScaleBunOta.ts +12 -0
@@ -31,6 +31,10 @@ import { bridgeAdapter } from '../replay/bridge/adapters/bridgeAdapter';
31
31
  import { getReplaySdkNative } from '../replay/bridge/nativeModule';
32
32
  import type { ReplaySdkNativeModule } from '../replay/bridge/nativeModule';
33
33
  import type { StorageBackend } from '../../storage/StorageBackend';
34
+ // Reused rather than reimplemented: `chunkEvents` is generic, order-preserving, and already carries
35
+ // a contract test proving it drops and duplicates nothing (analytics/__tests__/batchChunking.test.ts).
36
+ // Its internal ceiling (MAX_BATCH_EVENTS = 500) is well above the 100-frame cap passed here.
37
+ import { chunkEvents } from '../../analytics/batching';
34
38
  import type {
35
39
  SessionTransport,
36
40
  Session,
@@ -110,8 +114,31 @@ export class BackendSessionAdapter implements SessionTransport {
110
114
  private readonly _frameStore: StorageBackend | null;
111
115
  /** Storage key for the snapshotted unflushed frame buffer (see persistPendingFrames). */
112
116
  private static readonly PENDING_FRAMES_KEY = 'scalebun_replay_pending_frames';
113
- /** Cap on frames persisted per snapshot — bounds KV footprint (keeps the most recent). */
114
- private static readonly MAX_PERSIST_FRAMES = 10;
117
+ /**
118
+ * Byte budget for the cross-kill frame snapshot (see `_selectFramesToPersist`).
119
+ *
120
+ * Replaces a flat 10-frame cap. A count could not bound the KV write at all — these frames carry
121
+ * base64 screenshots, so ten of them is anywhere from ~200 KB to several MB depending on device
122
+ * resolution, and an oversized value is exactly what fails on the background transition where
123
+ * this write happens.
124
+ *
125
+ * 2 MB is deliberately modest: this is a last-resort snapshot for frames that never reached the
126
+ * network, not a mirror of the session. Enough for a keyframe plus a run of deltas on any device;
127
+ * small enough that writing it cannot itself be the reason backgrounding stalls.
128
+ */
129
+ private static readonly MAX_PERSIST_BYTES = 2 * 1024 * 1024;
130
+ /**
131
+ * Frames the backend accepts in ONE request — `'Max 100 frames per batch'`
132
+ * (`IngestionService.uploadFrames`). Exceeding it is a 4xx, which this SDK classifies as
133
+ * permanent.
134
+ *
135
+ * This has to exist alongside the byte budget, not instead of it. The budget alone cannot bound
136
+ * the COUNT, and the count is what the server checks: measured on real traffic, mobile keyframes
137
+ * average ~16 KB, so a 2 MB snapshot is around 128 frames — over the cap by construction. The old
138
+ * flat 10-frame rule was under it by construction, which is why this failure could not happen
139
+ * before and can now.
140
+ */
141
+ private static readonly MAX_FRAMES_PER_BATCH = 100;
115
142
  // Per-session toggle: cleared to false if the backend reports storage disabled.
116
143
  private objectStorageActive: boolean;
117
144
  // True when telemetry should divert to the durable native outbox (both flags on).
@@ -139,6 +166,14 @@ export class BackendSessionAdapter implements SessionTransport {
139
166
  private pendingLogs: LogQueueItem[] = [];
140
167
  private flushTimer: ReturnType<typeof setInterval> | null = null;
141
168
  private destroyed = false;
169
+ /**
170
+ * Latch for `sdk_replay_capture_failed` — reported once per adapter, not once per rejected batch.
171
+ *
172
+ * A session whose row was never created rejects EVERY frame batch, so an unlatched report would
173
+ * emit one analytics event per flush for as long as the app stays open, on the same lane it is
174
+ * trying to report through.
175
+ */
176
+ private _captureFailureReported = false;
142
177
 
143
178
  // ── Analytics lane (always-on, foreground-scoped; isolated from recording) ──
144
179
  // Fully separate from currentSessionId/pendingEvents/_flushEvents, which serve
@@ -344,6 +379,11 @@ export class BackendSessionAdapter implements SessionTransport {
344
379
  }): Promise<void> {
345
380
  if (this.destroyed) return;
346
381
  this._analyticsSessionId = session.sessionId;
382
+ // A new session gets its own capture-failure report. The latch bounds reports WITHIN a
383
+ // session; left standing across sessions it silenced every session after the first in the
384
+ // app process, which is the one place this differs from the web SDK — that pipeline is
385
+ // constructed per page and its session id is readonly, so its latch cannot outlive a session.
386
+ this._captureFailureReported = false;
347
387
  this._startAnalyticsFlushTimer();
348
388
 
349
389
  // Replay pre-ready buffered events now that the id exists. sessionId is
@@ -838,21 +878,109 @@ export class BackendSessionAdapter implements SessionTransport {
838
878
  const sessionId = this.currentSessionId;
839
879
 
840
880
  const batch = this.pendingFrames.splice(0, this.config.batchSize);
881
+ /**
882
+ * Chunked, because `config.batchSize` is HOST-CONFIGURABLE and the server's 100-frame cap is
883
+ * not. A host raising it past 100 previously turned every frame flush into a permanent 4xx —
884
+ * silent, total replay loss for that app, from a config value that looks like a tuning knob.
885
+ *
886
+ * A permanent rejection is reported and abandoned inside `_postFramesChunked`; what comes
887
+ * back is only what is still worth another attempt.
888
+ */
889
+ const remaining = await this._postFramesChunked(sessionId, batch);
890
+ if (remaining.length) {
891
+ logger.warn(`[replay.sdk.error] backend frame upload failure`, { count: remaining.length });
892
+ this.pendingFrames.unshift(...remaining);
893
+ return;
894
+ }
895
+ logger.debug(`[BackendSessionAdapter] Flushed ${batch.length} frame(s)`);
896
+ // Delivered — the durable snapshot (if any) is now redundant. Clear it once
897
+ // the in-memory buffer is fully drained so a later kill can't replay
898
+ // already-uploaded frames.
899
+ if (this.pendingFrames.length === 0) this._clearPersistedFrames();
900
+ }
901
+
902
+ /**
903
+ * Report frames the SDK gave up on, on the analytics lane.
904
+ *
905
+ * SAME EVENT NAME AS THE WEB SDK (`sdk_replay_capture_failed`) with platform-specific `reason`
906
+ * values, so one dashboard breakdown reads both platforms without a concept-map alias — the two
907
+ * SDKs share no event vocabulary otherwise (`$pageview` vs `screen_viewed`), and adding a second
908
+ * name here would need an entry in `event-concepts.ts` for no benefit.
909
+ *
910
+ * Latched to once per session: a session whose row was never created rejects every batch, and an
911
+ * unlatched report would emit one event per flush for as long as the app is open.
912
+ *
913
+ * Best-effort and never throws — a diagnostic must not be able to break the capture path it
914
+ * describes.
915
+ */
916
+ private _reportCaptureFailure(stage: string, err: unknown, frameCount: number): void {
917
+ if (this._captureFailureReported) return;
918
+ this._captureFailureReported = true;
841
919
  try {
842
- await this._postFrameBatch(sessionId, batch);
843
- logger.debug(`[BackendSessionAdapter] Flushed ${batch.length} frame(s)`);
844
- // Delivered — the durable snapshot (if any) is now redundant. Clear it once
845
- // the in-memory buffer is fully drained so a later kill can't replay
846
- // already-uploaded frames.
847
- if (this.pendingFrames.length === 0) this._clearPersistedFrames();
848
- } catch (err) {
849
- if (BackendSessionAdapter._isPermanentHttpError(err)) return;
850
- logger.warn(`[replay.sdk.error] backend frame upload failure`, { count: batch.length });
851
- // Re-queue on failure
852
- this.pendingFrames.unshift(...batch);
920
+ if (!this._analyticsSessionId) return; // no lane open — nothing to attach it to
921
+ const status = (err as { status?: number })?.status;
922
+ this.trackEvent({
923
+ eventId: this._clientId(),
924
+ sessionId: this._analyticsSessionId,
925
+ ts: Date.now(),
926
+ type: 'sdk_replay_capture_failed',
927
+ source: 'sdk',
928
+ payload: {
929
+ stage,
930
+ // Platform-specific vocabulary. The HTTP status is the honest reason on this path:
931
+ // RN posts to the per-lane endpoints, which surface a real status — unlike the web
932
+ // batch fan-out, whose 200 says nothing about what it stored.
933
+ reason: status ? `http_${status}` : 'upload_failed',
934
+ frames: frameCount,
935
+ platform: 'react-native',
936
+ },
937
+ } as unknown as JourneyEvent);
938
+ } catch {
939
+ // Diagnostics must never break the capture path.
853
940
  }
854
941
  }
855
942
 
943
+ /**
944
+ * Post frames in BACKEND-SAFE CHUNKS, and return the ones that were not durably accepted.
945
+ *
946
+ * WHY THIS EXISTS. `_postFrameBatch` issues exactly one request for whatever array it is handed,
947
+ * and both callers could hand it more than the server's 100-frame cap — the live flush via
948
+ * `config.batchSize`, and cross-kill recovery via the 2 MB persistence budget (~128 frames at the
949
+ * measured 16 KB average). The result was a 4xx, classified permanent, and on the recovery path
950
+ * that meant `_clearPersistedFrames()` — the ENTIRE cross-kill snapshot deleted because it was
951
+ * one frame too big to send in a single request it never needed to be sent in.
952
+ *
953
+ * THE RETURN VALUE IS THE POINT. The caller must be able to delete exactly what landed and keep
954
+ * exactly what did not, so this reports the survivors rather than throwing:
955
+ * - chunk accepted → dropped from the result (it is durably stored)
956
+ * - chunk PERMANENTLY refused → dropped, and reported once. Re-sending identical bytes cannot
957
+ * succeed, and holding it would wedge every future launch on the
958
+ * same poison chunk. Only that chunk is abandoned; the rest still
959
+ * gets its chance, which is the whole difference from before.
960
+ * - chunk TRANSIENTLY failed → that chunk AND every chunk after it are kept, in order. We stop
961
+ * on the first transient failure rather than pressing on, because
962
+ * the usual cause is the network being gone and the rest would
963
+ * fail identically.
964
+ */
965
+ private async _postFramesChunked(
966
+ sessionId: string,
967
+ items: Array<{ frame: ReplayFrame; imageData: string }>,
968
+ ): Promise<Array<{ frame: ReplayFrame; imageData: string }>> {
969
+ const chunks = chunkEvents(items, BackendSessionAdapter.MAX_FRAMES_PER_BATCH);
970
+ for (let i = 0; i < chunks.length; i++) {
971
+ try {
972
+ await this._postFrameBatch(sessionId, chunks[i]);
973
+ } catch (err) {
974
+ if (BackendSessionAdapter._isPermanentHttpError(err)) {
975
+ this._reportCaptureFailure('upload-rejected', err, chunks[i].length);
976
+ continue;
977
+ }
978
+ return chunks.slice(i).flat();
979
+ }
980
+ }
981
+ return [];
982
+ }
983
+
856
984
  /**
857
985
  * POST one frame batch under an EXPLICIT sessionId (object-storage path when
858
986
  * active, else legacy inline base64). Used by both the live flush and cross-kill
@@ -905,7 +1033,8 @@ export class BackendSessionAdapter implements SessionTransport {
905
1033
  if (!this._frameStore || this.destroyed || !this.currentSessionId) return;
906
1034
  if (this.pendingFrames.length === 0) return;
907
1035
  try {
908
- const items = this.pendingFrames.slice(-BackendSessionAdapter.MAX_PERSIST_FRAMES);
1036
+ const items = BackendSessionAdapter._selectFramesToPersist(this.pendingFrames);
1037
+ if (items.length === 0) return;
909
1038
  this._frameStore.set(
910
1039
  BackendSessionAdapter.PENDING_FRAMES_KEY,
911
1040
  JSON.stringify({ sessionId: this.currentSessionId, items }),
@@ -915,6 +1044,70 @@ export class BackendSessionAdapter implements SessionTransport {
915
1044
  }
916
1045
  }
917
1046
 
1047
+ /**
1048
+ * Choose which unflushed frames survive a process kill.
1049
+ *
1050
+ * WHY THIS IS NOT `slice(-10)`.
1051
+ *
1052
+ * The old rule kept the ten most RECENT frames, which is the wrong end of the buffer for replay.
1053
+ * A delta reconstructs against its base keyframe, so ten trailing deltas whose keyframe was
1054
+ * dropped are unplayable on their own — they arrive as `orphaned`, the state the dashboard flags
1055
+ * as a capture defect. Keeping the keyframe and fewer deltas yields a SHORTER replay; keeping the
1056
+ * deltas and no keyframe yields NO replay. That asymmetry is the whole decision.
1057
+ *
1058
+ * So: every keyframe is kept, then the most recent deltas fill whatever byte budget is left. This
1059
+ * mirrors the web SDK's `capBytes` policy (which sheds deltas first and never the keyframe), so
1060
+ * the two platforms lose the same thing under pressure instead of two different things.
1061
+ *
1062
+ * BYTES, not a frame count. A count is a poor proxy here: these carry base64 screenshots, so ten
1063
+ * frames may be 200 KB or 4 MB depending on device resolution — and this write goes to a KV store
1064
+ * on the background transition, where an oversized value is the thing most likely to fail and
1065
+ * lose the snapshot entirely. `imageData.length` is the base64 length, which is within ~1% of the
1066
+ * serialized cost and needs no extra pass.
1067
+ */
1068
+ private static _selectFramesToPersist(
1069
+ pending: Array<{ frame: ReplayFrame; imageData: string }>,
1070
+ ): Array<{ frame: ReplayFrame; imageData: string }> {
1071
+ const isKeyframe = (b: { frame: ReplayFrame }) => b.frame.frameType !== 'delta';
1072
+ const size = (b: { imageData: string }) => b.imageData?.length ?? 0;
1073
+
1074
+ const keep: Array<{ frame: ReplayFrame; imageData: string }> = [];
1075
+ let budget = BackendSessionAdapter.MAX_PERSIST_BYTES;
1076
+ /**
1077
+ * A COUNT cap as well as a byte budget.
1078
+ *
1079
+ * The byte budget is the right bound for the KV write; it is the wrong bound for the upload,
1080
+ * because the server checks a count. Recovery now chunks, so exceeding this is no longer
1081
+ * destructive — but persisting more than can ever be sent in one pass just guarantees leftover
1082
+ * work on every launch, and the frames past this point are the least valuable ones anyway.
1083
+ */
1084
+ const maxCount = BackendSessionAdapter.MAX_FRAMES_PER_BATCH;
1085
+
1086
+ // Keyframes first, newest back to oldest — a later keyframe supersedes an earlier one, so if
1087
+ // only one fits it should be the one closest to what the user last saw.
1088
+ for (let i = pending.length - 1; i >= 0; i--) {
1089
+ const b = pending[i];
1090
+ if (!isKeyframe(b)) continue;
1091
+ const cost = size(b);
1092
+ if (cost > budget || keep.length >= maxCount) continue;
1093
+ budget -= cost;
1094
+ keep.push(b);
1095
+ }
1096
+ // Then the most recent deltas with what remains.
1097
+ for (let i = pending.length - 1; i >= 0; i--) {
1098
+ const b = pending[i];
1099
+ if (isKeyframe(b)) continue;
1100
+ const cost = size(b);
1101
+ if (cost > budget || keep.length >= maxCount) continue;
1102
+ budget -= cost;
1103
+ keep.push(b);
1104
+ }
1105
+
1106
+ // Restore capture order: the backend orders frames by `sequenceNumber`, and the recovery POST
1107
+ // re-sends this array as-is.
1108
+ return keep.sort((a, b) => (a.frame.sequenceNumber ?? 0) - (b.frame.sequenceNumber ?? 0));
1109
+ }
1110
+
918
1111
  /** Drop the persisted frame snapshot (after successful delivery). Never throws. */
919
1112
  private _clearPersistedFrames(): void {
920
1113
  if (!this._frameStore) return;
@@ -958,18 +1151,33 @@ export class BackendSessionAdapter implements SessionTransport {
958
1151
  return;
959
1152
  }
960
1153
 
961
- try {
962
- logger.info(`[replay.sdk.frame] recovering ${items.length} frame(s) for prior session ${sessionId}`);
963
- await this._postFrameBatch(sessionId, items);
1154
+ logger.info(`[replay.sdk.frame] recovering ${items.length} frame(s) for prior session ${sessionId}`);
1155
+ /**
1156
+ * DELETE ONLY WHAT WAS ACCEPTED.
1157
+ *
1158
+ * This posted every recovered frame in ONE request and, on any permanent rejection, deleted
1159
+ * the whole snapshot. Since the persistence budget is 2 MB of ~16 KB frames, that request was
1160
+ * routinely over the server's 100-frame cap — so the common outcome was a 4xx followed by the
1161
+ * deletion of a cross-kill recovery snapshot in which every frame was individually fine. The
1162
+ * one thing a recovery buffer must never do.
1163
+ *
1164
+ * Now the send is chunked and only the unaccepted remainder survives. A chunk the server will
1165
+ * never take is abandoned inside `_postFramesChunked` (and reported), so a single poison chunk
1166
+ * cannot wedge every future launch — but a transient failure keeps its frames for next time.
1167
+ */
1168
+ const remaining = await this._postFramesChunked(sessionId, items);
1169
+ if (remaining.length === 0) {
964
1170
  this._clearPersistedFrames();
965
- } catch (err) {
966
- if (BackendSessionAdapter._isPermanentHttpError(err)) {
967
- // Server rejected permanently (e.g. session gone) don't retry forever.
968
- this._clearPersistedFrames();
969
- return;
970
- }
971
- // Transient keep the snapshot for the next launch.
972
- logger.warn(`[replay.sdk.frame] prior-session frame recovery deferred (transient)`);
1171
+ return;
1172
+ }
1173
+ logger.warn(`[replay.sdk.frame] prior-session frame recovery deferred`, { kept: remaining.length });
1174
+ try {
1175
+ this._frameStore.set(
1176
+ BackendSessionAdapter.PENDING_FRAMES_KEY,
1177
+ JSON.stringify({ sessionId, items: remaining }),
1178
+ );
1179
+ } catch {
1180
+ // Re-writing the trimmed snapshot is best-effort; the original is still on disk.
973
1181
  }
974
1182
  }
975
1183
 
package/src/index.ts CHANGED
@@ -16,6 +16,12 @@
16
16
 
17
17
  export { default } from './public/ScaleBunFacade';
18
18
  export { ScaleBunProvider } from './public/ScaleBunProvider';
19
+ /**
20
+ * Render-error boundary. Named and shaped to match `@scalebun/web-react`'s export so the same code
21
+ * works on both platforms — see the note in ScaleBunErrorBoundary.tsx for why a host boundary
22
+ * without this silently swallowed every React render error.
23
+ */
24
+ export { ScaleBunErrorBoundary, type ScaleBunErrorBoundaryProps } from './public/ScaleBunErrorBoundary';
19
25
 
20
26
  // ─── React Components & Hooks (consumer-facing) ────────────────────────────
21
27
  // These are needed by app developers to wrap their app and track screens.
@@ -0,0 +1,79 @@
1
+ /**
2
+ * ScaleBunErrorBoundary — React render errors, forwarded to the SDK.
3
+ *
4
+ * THE GAP THIS CLOSES
5
+ * `ErrorUtils.setGlobalHandler` catches a render error only when NO boundary intercepts it first.
6
+ * The moment a host wraps its tree in its own boundary — which most production apps do — that
7
+ * boundary's `componentDidCatch` consumes the error, it never reaches `ErrorUtils`, and the SDK
8
+ * recorded nothing at all. The screen went blank or fell back, and the crash list stayed empty.
9
+ *
10
+ * The web SDK has shipped `ScaleBunErrorBoundary` from `@scalebun/web-react` for exactly this reason;
11
+ * React Native had no equivalent and no `componentDidCatch` hook a host could forward through. This
12
+ * is the counterpart, and the two are deliberately the same component with the same prop names so a
13
+ * team working across both platforms writes the same thing twice.
14
+ *
15
+ * Use it INSTEAD of your own boundary, or nest it inside one — `onError` runs after capture so an
16
+ * existing boundary's logging keeps working.
17
+ *
18
+ * ```tsx
19
+ * <ScaleBunErrorBoundary fallback={(e) => <Text>Something broke: {e.message}</Text>}>
20
+ * <App />
21
+ * </ScaleBunErrorBoundary>
22
+ * ```
23
+ */
24
+ import { Component, type ErrorInfo, type ReactNode } from 'react';
25
+ import ScaleBun from './ScaleBunFacade';
26
+
27
+ export interface ScaleBunErrorBoundaryProps {
28
+ children: ReactNode;
29
+ /** Rendered after a caught error. A function receives the error; a node is rendered as-is. */
30
+ fallback?: ReactNode | ((error: Error) => ReactNode);
31
+ /** Extra hook after the error is captured (e.g. custom logging). */
32
+ onError?: (error: Error, info: ErrorInfo) => void;
33
+ /** Screen name to attribute the error to, when the host knows it. */
34
+ screenName?: string;
35
+ }
36
+
37
+ interface State {
38
+ error: Error | null;
39
+ }
40
+
41
+ export class ScaleBunErrorBoundary extends Component<ScaleBunErrorBoundaryProps, State> {
42
+ state: State = { error: null };
43
+
44
+ static getDerivedStateFromError(error: Error): State {
45
+ return { error };
46
+ }
47
+
48
+ componentDidCatch(error: Error, info: ErrorInfo): void {
49
+ try {
50
+ /**
51
+ * NOT fatal, matching the web boundary.
52
+ *
53
+ * The app is still running — that is the entire point of a boundary — so this is reported
54
+ * as a handled error rather than a crash. Marking it fatal would put an event the user
55
+ * experienced as a fallback screen into the same bucket as an app that actually died, and
56
+ * the crash rate is a number people act on.
57
+ *
58
+ * `componentStack` travels in `metadata` because that is the shape RN's `captureError`
59
+ * takes; the web SDK accepts it as a top-level context key. Same information, and the
60
+ * difference is in the two facades, not here.
61
+ */
62
+ ScaleBun.captureError(error, {
63
+ screenName: this.props.screenName,
64
+ metadata: { componentStack: info.componentStack ?? undefined },
65
+ });
66
+ } catch {
67
+ /* never let capture break the boundary */
68
+ }
69
+ this.props.onError?.(error, info);
70
+ }
71
+
72
+ render(): ReactNode {
73
+ if (this.state.error) {
74
+ const { fallback } = this.props;
75
+ return typeof fallback === 'function' ? fallback(this.state.error) : (fallback ?? null);
76
+ }
77
+ return this.props.children;
78
+ }
79
+ }
@@ -73,6 +73,18 @@ export interface Spec extends TurboModule {
73
73
  * at cold start, BEFORE JS loads.
74
74
  */
75
75
  revertToPrevious(): Promise<boolean>;
76
+
77
+ /**
78
+ * Restart the React Native runtime so the applied bundle loads NOW.
79
+ *
80
+ * Exists because `DevSettings.reload()` is a silent no-op in RELEASE builds
81
+ * (verified live: "Triggering app restart…" logged, nothing happened, same
82
+ * process, old bundle still running). That made IMMEDIATE / ON_NEXT_RESUME /
83
+ * `autoRestart` / the CodePush shim's restart all fiction in production.
84
+ * Android: `ReactHost.reload()` (bridgeless) or
85
+ * `recreateReactContextInBackground()` (bridge). iOS: RCTReloadCommand.
86
+ */
87
+ restartApp(): void;
76
88
  }
77
89
 
78
90
  /**