@hot-updater/react-native 0.27.1 → 0.29.0

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 (87) hide show
  1. package/android/build.gradle +12 -0
  2. package/android/src/main/AndroidManifest.xml +3 -0
  3. package/android/src/main/AndroidManifestNew.xml +3 -0
  4. package/android/src/main/cpp/CMakeLists.txt +9 -0
  5. package/android/src/main/cpp/HotUpdaterRecovery.cpp +143 -0
  6. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +325 -210
  7. package/android/src/main/java/com/hotupdater/BundleMetadata.kt +73 -16
  8. package/android/src/main/java/com/hotupdater/CohortService.kt +73 -0
  9. package/android/src/main/java/com/hotupdater/DecompressService.kt +28 -22
  10. package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +1 -1
  11. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +51 -13
  12. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +533 -0
  13. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryReceiver.kt +14 -0
  14. package/android/src/main/java/com/hotupdater/ReactNativeValueConverters.kt +55 -0
  15. package/android/src/main/java/com/hotupdater/TarBrDecompressionStrategy.kt +19 -7
  16. package/android/src/newarch/HotUpdaterModule.kt +16 -25
  17. package/android/src/oldarch/HotUpdaterModule.kt +20 -26
  18. package/android/src/oldarch/HotUpdaterSpec.kt +12 -2
  19. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +340 -232
  20. package/ios/HotUpdater/Internal/BundleMetadata.swift +61 -8
  21. package/ios/HotUpdater/Internal/CohortService.swift +63 -0
  22. package/ios/HotUpdater/Internal/DecompressService.swift +53 -30
  23. package/ios/HotUpdater/Internal/HotUpdater-Bridging-Header.h +9 -1
  24. package/ios/HotUpdater/Internal/HotUpdater.mm +376 -70
  25. package/ios/HotUpdater/Internal/HotUpdaterCrashHandler.h +7 -0
  26. package/ios/HotUpdater/Internal/HotUpdaterCrashHandler.mm +4 -0
  27. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +321 -9
  28. package/ios/HotUpdater/Internal/TarBrDecompressionStrategy.swift +24 -8
  29. package/lib/commonjs/DefaultResolver.js +3 -5
  30. package/lib/commonjs/DefaultResolver.js.map +1 -1
  31. package/lib/commonjs/checkForUpdate.js +2 -0
  32. package/lib/commonjs/checkForUpdate.js.map +1 -1
  33. package/lib/commonjs/index.js +13 -0
  34. package/lib/commonjs/index.js.map +1 -1
  35. package/lib/commonjs/native.js +211 -39
  36. package/lib/commonjs/native.js.map +1 -1
  37. package/lib/commonjs/native.spec.js +443 -0
  38. package/lib/commonjs/native.spec.js.map +1 -0
  39. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  40. package/lib/commonjs/types.js.map +1 -1
  41. package/lib/commonjs/wrap.js +4 -5
  42. package/lib/commonjs/wrap.js.map +1 -1
  43. package/lib/module/DefaultResolver.js +3 -5
  44. package/lib/module/DefaultResolver.js.map +1 -1
  45. package/lib/module/checkForUpdate.js +3 -1
  46. package/lib/module/checkForUpdate.js.map +1 -1
  47. package/lib/module/index.js +14 -1
  48. package/lib/module/index.js.map +1 -1
  49. package/lib/module/native.js +204 -34
  50. package/lib/module/native.js.map +1 -1
  51. package/lib/module/native.spec.js +442 -0
  52. package/lib/module/native.spec.js.map +1 -0
  53. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  54. package/lib/module/types.js.map +1 -1
  55. package/lib/module/wrap.js +5 -6
  56. package/lib/module/wrap.js.map +1 -1
  57. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/index.d.ts +14 -1
  59. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/native.d.ts +43 -23
  61. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +32 -8
  63. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/types.d.ts +6 -3
  65. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/wrap.d.ts +3 -6
  67. package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
  68. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  69. package/lib/typescript/module/index.d.ts +14 -1
  70. package/lib/typescript/module/index.d.ts.map +1 -1
  71. package/lib/typescript/module/native.d.ts +43 -23
  72. package/lib/typescript/module/native.d.ts.map +1 -1
  73. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +32 -8
  74. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  75. package/lib/typescript/module/types.d.ts +6 -3
  76. package/lib/typescript/module/types.d.ts.map +1 -1
  77. package/lib/typescript/module/wrap.d.ts +3 -6
  78. package/lib/typescript/module/wrap.d.ts.map +1 -1
  79. package/package.json +6 -6
  80. package/src/DefaultResolver.ts +4 -4
  81. package/src/checkForUpdate.ts +4 -0
  82. package/src/index.ts +21 -0
  83. package/src/native.spec.ts +480 -0
  84. package/src/native.ts +285 -39
  85. package/src/specs/NativeHotUpdater.ts +36 -6
  86. package/src/types.ts +7 -3
  87. package/src/wrap.tsx +8 -12
package/src/native.ts CHANGED
@@ -1,4 +1,9 @@
1
- import type { UpdateStatus } from "@hot-updater/core";
1
+ import {
2
+ INVALID_COHORT_ERROR_MESSAGE,
3
+ isValidCohort,
4
+ normalizeCohortValue,
5
+ type UpdateStatus,
6
+ } from "@hot-updater/core";
2
7
  import { NativeEventEmitter, Platform } from "react-native";
3
8
  import { HotUpdaterErrorCode, isHotUpdaterError } from "./error";
4
9
  import HotUpdaterNative, {
@@ -8,6 +13,30 @@ import HotUpdaterNative, {
8
13
  export { HotUpdaterErrorCode, isHotUpdaterError };
9
14
 
10
15
  const NIL_UUID = "00000000-0000-0000-0000-000000000000";
16
+ const normalizeAndValidateCohort = (cohort: string): string => {
17
+ const normalized = normalizeCohortValue(cohort);
18
+ if (!isValidCohort(normalized)) {
19
+ throw new Error(INVALID_COHORT_ERROR_MESSAGE);
20
+ }
21
+ return normalized;
22
+ };
23
+
24
+ export interface ManifestAsset {
25
+ fileHash: string;
26
+ }
27
+
28
+ export interface Manifest {
29
+ bundleId: string;
30
+ assets: Record<string, ManifestAsset>;
31
+ }
32
+
33
+ type ActiveBundleSnapshotCacheValues = {
34
+ bundleId?: string;
35
+ manifest?: Manifest;
36
+ baseURL?: string | null;
37
+ };
38
+
39
+ type ActiveBundleSnapshotCacheKey = keyof ActiveBundleSnapshotCacheValues;
11
40
 
12
41
  /**
13
42
  * Built-in reload behaviors used by `HotUpdater.reload()`.
@@ -34,17 +63,16 @@ export type CustomReloadHandler = () => void | Promise<void>;
34
63
  */
35
64
  export type ReloadBehaviorSetting = ReloadBehavior | "custom";
36
65
 
37
- declare const __HOT_UPDATER_BUNDLE_ID: string | undefined;
38
-
39
- export const HotUpdaterConstants = {
40
- HOT_UPDATER_BUNDLE_ID: __HOT_UPDATER_BUNDLE_ID || NIL_UUID,
41
- };
42
-
43
66
  class HotUpdaterSessionState {
44
67
  private readonly defaultChannel: string;
45
68
  private currentChannel: string;
69
+ private cachedCohort: string | undefined;
46
70
  private readonly inflightUpdates = new Map<string, Promise<boolean>>();
47
71
  private lastInstalledBundleId: string | null = null;
72
+ private readonly activeBundleSnapshotCache = new Map<
73
+ ActiveBundleSnapshotCacheKey,
74
+ ActiveBundleSnapshotCacheValues[ActiveBundleSnapshotCacheKey]
75
+ >();
48
76
 
49
77
  constructor() {
50
78
  const constants = HotUpdaterNative.getConstants();
@@ -85,12 +113,66 @@ class HotUpdaterSessionState {
85
113
  if (channel) {
86
114
  this.currentChannel = channel;
87
115
  }
116
+ this.clearActiveBundleSnapshotCache();
88
117
  }
89
118
 
90
119
  resetChannelState() {
91
120
  this.currentChannel = this.defaultChannel;
92
121
  this.lastInstalledBundleId = null;
93
122
  this.inflightUpdates.clear();
123
+ this.clearActiveBundleSnapshotCache();
124
+ }
125
+
126
+ getCachedBundleId(): string | undefined {
127
+ return this.getActiveBundleSnapshotValue("bundleId");
128
+ }
129
+
130
+ cacheBundleId(bundleId: string) {
131
+ this.setActiveBundleSnapshotValue("bundleId", bundleId);
132
+ }
133
+
134
+ getCachedManifest(): Manifest | undefined {
135
+ const manifest = this.getActiveBundleSnapshotValue("manifest");
136
+ return manifest ? cloneManifest(manifest) : undefined;
137
+ }
138
+
139
+ cacheManifest(manifest: Manifest) {
140
+ this.setActiveBundleSnapshotValue("manifest", cloneManifest(manifest));
141
+ }
142
+
143
+ getCachedBaseURL(): string | null | undefined {
144
+ return this.getActiveBundleSnapshotValue("baseURL");
145
+ }
146
+
147
+ cacheBaseURL(baseURL: string | null) {
148
+ this.setActiveBundleSnapshotValue("baseURL", baseURL);
149
+ }
150
+
151
+ getCachedCohort(): string | undefined {
152
+ return this.cachedCohort;
153
+ }
154
+
155
+ cacheCohort(cohort: string) {
156
+ this.cachedCohort = cohort;
157
+ }
158
+
159
+ private clearActiveBundleSnapshotCache() {
160
+ this.activeBundleSnapshotCache.clear();
161
+ }
162
+
163
+ private getActiveBundleSnapshotValue<K extends ActiveBundleSnapshotCacheKey>(
164
+ key: K,
165
+ ): ActiveBundleSnapshotCacheValues[K] | undefined {
166
+ return this.activeBundleSnapshotCache.get(key) as
167
+ | ActiveBundleSnapshotCacheValues[K]
168
+ | undefined;
169
+ }
170
+
171
+ private setActiveBundleSnapshotValue<K extends ActiveBundleSnapshotCacheKey>(
172
+ key: K,
173
+ value: ActiveBundleSnapshotCacheValues[K],
174
+ ) {
175
+ this.activeBundleSnapshotCache.set(key, value);
94
176
  }
95
177
  }
96
178
 
@@ -98,6 +180,16 @@ const sessionState = new HotUpdaterSessionState();
98
180
  let reloadBehavior: ReloadBehaviorSetting = "processRestart";
99
181
  let customReloadHandler: CustomReloadHandler | null = null;
100
182
 
183
+ const cloneManifest = (manifest: Manifest): Manifest => ({
184
+ bundleId: manifest.bundleId,
185
+ assets: Object.fromEntries(
186
+ Object.entries(manifest.assets).map(([key, asset]) => [
187
+ key,
188
+ { fileHash: asset.fileHash },
189
+ ]),
190
+ ),
191
+ });
192
+
101
193
  const getReloadProcess = (): (() => Promise<void>) | null => {
102
194
  const nativeModule = HotUpdaterNative as typeof HotUpdaterNative & {
103
195
  reloadProcess?: () => Promise<void>;
@@ -318,13 +410,72 @@ export const getMinBundleId = (): string => {
318
410
  /**
319
411
  * Fetches the current bundle version id.
320
412
  *
321
- * @async
322
- * @returns {string} Resolves with the current version id or null if not available.
413
+ * JS falls back to MIN_BUNDLE_ID only after native confirms there is no active
414
+ * downloaded bundle. When the native module does not expose `getBundleId()`,
415
+ * treat it as a JS/native SDK mismatch instead of silently reporting the
416
+ * built-in bundle.
417
+ *
418
+ * @returns {string} Resolves with the current version id.
323
419
  */
324
420
  export const getBundleId = (): string => {
325
- return HotUpdaterConstants.HOT_UPDATER_BUNDLE_ID === NIL_UUID
326
- ? getMinBundleId()
327
- : HotUpdaterConstants.HOT_UPDATER_BUNDLE_ID;
421
+ const cachedBundleId = sessionState.getCachedBundleId();
422
+ if (cachedBundleId !== undefined) {
423
+ return cachedBundleId;
424
+ }
425
+
426
+ const nativeModule = HotUpdaterNative as typeof HotUpdaterNative & {
427
+ getBundleId?: () => string | null;
428
+ };
429
+
430
+ if (typeof nativeModule.getBundleId !== "function") {
431
+ throw new Error(
432
+ "[HotUpdater] Native module is missing 'getBundleId()'. This JS bundle requires a newer native @hot-updater/react-native SDK. Rebuild and release a new app version before delivering this OTA update.",
433
+ );
434
+ }
435
+
436
+ const bundleId = nativeModule.getBundleId();
437
+
438
+ const resolvedBundleId =
439
+ !bundleId || bundleId === NIL_UUID ? getMinBundleId() : bundleId;
440
+
441
+ sessionState.cacheBundleId(resolvedBundleId);
442
+ return resolvedBundleId;
443
+ };
444
+
445
+ /**
446
+ * Fetches the current manifest for the active bundle.
447
+ *
448
+ * Returns a normalized manifest with empty assets when manifest.json is missing
449
+ * or invalid.
450
+ */
451
+ export const getManifest = (): Manifest => {
452
+ const cachedManifest = sessionState.getCachedManifest();
453
+ if (cachedManifest !== undefined) {
454
+ return cachedManifest;
455
+ }
456
+
457
+ const nativeModule = HotUpdaterNative as typeof HotUpdaterNative & {
458
+ getManifest?: () => Record<string, unknown> | string;
459
+ };
460
+ const manifest = nativeModule.getManifest?.();
461
+
462
+ let normalizedManifest: Manifest;
463
+
464
+ if (!manifest) {
465
+ normalizedManifest = createEmptyManifest();
466
+ } else if (typeof manifest === "string") {
467
+ try {
468
+ normalizedManifest = normalizeManifest(JSON.parse(manifest));
469
+ } catch {
470
+ normalizedManifest = createEmptyManifest();
471
+ }
472
+ } else {
473
+ normalizedManifest = normalizeManifest(manifest);
474
+ }
475
+
476
+ sessionState.cacheBundleId(normalizedManifest.bundleId);
477
+ sessionState.cacheManifest(normalizedManifest);
478
+ return cloneManifest(normalizedManifest);
328
479
  };
329
480
 
330
481
  /**
@@ -364,18 +515,16 @@ export const getFingerprintHash = (): string | null => {
364
515
  * Result returned by notifyAppReady()
365
516
  */
366
517
  export type NotifyAppReadyResult = {
367
- status: "PROMOTED" | "RECOVERED" | "STABLE";
518
+ status: "RECOVERED" | "STABLE";
368
519
  crashedBundleId?: string;
369
520
  };
370
521
 
371
522
  /**
372
- * Notifies the native side that the app has successfully started with the current bundle.
373
- * If the bundle matches the staging bundle, it promotes to stable.
523
+ * Reads the native launch report for the current process.
374
524
  *
375
- * This function is called automatically when the module loads.
525
+ * This function is called automatically after the app has rendered.
376
526
  *
377
527
  * @returns {NotifyAppReadyResult} Bundle state information
378
- * - `status: "PROMOTED"` - Staging bundle was promoted to stable (ACTIVE event)
379
528
  * - `status: "RECOVERED"` - App recovered from crash, rollback occurred (ROLLBACK event)
380
529
  * - `status: "STABLE"` - No changes, already stable
381
530
  * - `crashedBundleId` - Present only when status is "RECOVERED"
@@ -384,33 +533,97 @@ export type NotifyAppReadyResult = {
384
533
  * ```ts
385
534
  * const result = HotUpdater.notifyAppReady();
386
535
  *
387
- * switch (result.status) {
388
- * case "PROMOTED":
389
- * // Send ACTIVE analytics event
390
- * analytics.track('bundle_active', { bundleId: HotUpdater.getBundleId() });
391
- * break;
392
- * case "RECOVERED":
393
- * // Send ROLLBACK analytics event
394
- * analytics.track('bundle_rollback', { crashedBundleId: result.crashedBundleId });
395
- * break;
396
- * case "STABLE":
397
- * // No special action needed
398
- * break;
536
+ * if (result.status === "RECOVERED") {
537
+ * // Send ROLLBACK analytics event
538
+ * analytics.track("bundle_rollback", {
539
+ * crashedBundleId: result.crashedBundleId,
540
+ * });
399
541
  * }
400
542
  * ```
401
543
  */
402
544
  export const notifyAppReady = (): NotifyAppReadyResult => {
403
- const bundleId = getBundleId();
404
- const result = HotUpdaterNative.notifyAppReady({ bundleId });
405
- // Oldarch returns JSON string, newarch returns array
545
+ const result = HotUpdaterNative.notifyAppReady();
546
+ // Older Android old-arch implementations returned JSON strings.
406
547
  if (typeof result === "string") {
407
548
  try {
408
- return JSON.parse(result);
549
+ return normalizeNotifyAppReadyResult(JSON.parse(result));
409
550
  } catch {
410
551
  return { status: "STABLE" };
411
552
  }
412
553
  }
413
- return result;
554
+ return normalizeNotifyAppReadyResult(result);
555
+ };
556
+
557
+ const normalizeNotifyAppReadyResult = (
558
+ result: NotifyAppReadyResult | { status?: string; crashedBundleId?: string },
559
+ ): NotifyAppReadyResult => {
560
+ if (result.status === "RECOVERED") {
561
+ return {
562
+ status: "RECOVERED",
563
+ crashedBundleId: result.crashedBundleId,
564
+ };
565
+ }
566
+
567
+ return { status: "STABLE" };
568
+ };
569
+
570
+ const createEmptyManifest = (): Manifest => ({
571
+ bundleId: getBundleId(),
572
+ assets: {},
573
+ });
574
+
575
+ const normalizeManifest = (value: unknown): Manifest => {
576
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
577
+ return createEmptyManifest();
578
+ }
579
+
580
+ const bundleIdValue = (value as { bundleId?: unknown }).bundleId;
581
+ const bundleId =
582
+ typeof bundleIdValue === "string" && bundleIdValue.trim()
583
+ ? bundleIdValue.trim()
584
+ : getBundleId();
585
+
586
+ return {
587
+ bundleId,
588
+ assets: normalizeManifestAssets((value as { assets?: unknown }).assets),
589
+ };
590
+ };
591
+
592
+ const normalizeManifestAssets = (value: unknown): Manifest["assets"] => {
593
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
594
+ return {};
595
+ }
596
+
597
+ return Object.fromEntries(
598
+ Object.entries(value).flatMap(([key, entry]) => {
599
+ const trimmedKey = key.trim();
600
+
601
+ if (!trimmedKey) {
602
+ return [];
603
+ }
604
+
605
+ if (typeof entry === "string") {
606
+ const fileHash = entry.trim();
607
+
608
+ if (!fileHash) {
609
+ return [];
610
+ }
611
+
612
+ return [[trimmedKey, { fileHash }] as const];
613
+ }
614
+
615
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
616
+ return [];
617
+ }
618
+
619
+ const { fileHash } = entry as { fileHash?: unknown };
620
+ if (typeof fileHash !== "string" || !fileHash.trim()) {
621
+ return [];
622
+ }
623
+
624
+ return [[trimmedKey, { fileHash: fileHash.trim() }] as const];
625
+ }),
626
+ );
414
627
  };
415
628
 
416
629
  /**
@@ -421,7 +634,7 @@ export const notifyAppReady = (): NotifyAppReadyResult => {
421
634
  */
422
635
  export const getCrashHistory = (): string[] => {
423
636
  const result = HotUpdaterNative.getCrashHistory();
424
- // Oldarch returns JSON string, newarch returns array
637
+ // Older Android old-arch implementations returned JSON strings.
425
638
  if (typeof result === "string") {
426
639
  try {
427
640
  return JSON.parse(result);
@@ -450,11 +663,15 @@ export const clearCrashHistory = (): boolean => {
450
663
  * @returns {string | null} Base URL string (e.g., "file:///data/.../bundle-store/abc123") or null if not available
451
664
  */
452
665
  export const getBaseURL = (): string | null => {
453
- const result = HotUpdaterNative.getBaseURL();
454
- if (typeof result === "string" && result !== "") {
455
- return result;
666
+ const cachedBaseURL = sessionState.getCachedBaseURL();
667
+ if (cachedBaseURL !== undefined) {
668
+ return cachedBaseURL;
456
669
  }
457
- return null;
670
+
671
+ const result = HotUpdaterNative.getBaseURL();
672
+ const baseURL = typeof result === "string" && result !== "" ? result : null;
673
+ sessionState.cacheBaseURL(baseURL);
674
+ return baseURL;
458
675
  };
459
676
 
460
677
  /**
@@ -471,3 +688,32 @@ export const resetChannel = async (): Promise<boolean> => {
471
688
  }
472
689
  return ok;
473
690
  };
691
+
692
+ /**
693
+ * Sets the persisted cohort used for update checks.
694
+ *
695
+ * HotUpdater only derives a device-based cohort when nothing has been stored
696
+ * yet. If you need to restore that initial value later, read it with
697
+ * `getCohort()` before calling `setCohort()`, then store it yourself.
698
+ */
699
+ export const setCohort = (cohort: string): void => {
700
+ const normalized = normalizeAndValidateCohort(cohort);
701
+ HotUpdaterNative.setCohort(normalized);
702
+ sessionState.cacheCohort(normalized);
703
+ };
704
+
705
+ /**
706
+ * Gets the persisted cohort used for rollout calculations.
707
+ * If none has been stored yet, native derives the initial value once and
708
+ * persists it before returning.
709
+ */
710
+ export const getCohort = (): string => {
711
+ const cachedCohort = sessionState.getCachedCohort();
712
+ if (cachedCohort !== undefined) {
713
+ return cachedCohort;
714
+ }
715
+
716
+ const cohort = normalizeAndValidateCohort(HotUpdaterNative.getCohort());
717
+ sessionState.cacheCohort(cohort);
718
+ return cohort;
719
+ };
@@ -1,5 +1,6 @@
1
1
  import type { TurboModule } from "react-native";
2
2
  import { TurboModuleRegistry } from "react-native";
3
+ import type { UnsafeObject } from "react-native/Libraries/Types/CodegenTypes";
3
4
 
4
5
  export interface UpdateBundleParams {
5
6
  bundleId: string;
@@ -60,18 +61,16 @@ export interface Spec extends TurboModule {
60
61
  updateBundle(params: UpdateBundleParams): Promise<boolean>;
61
62
 
62
63
  /**
63
- * Notifies the native side that the app has successfully started with the given bundle.
64
- * If the bundle matches the staging bundle, it promotes to stable.
64
+ * Reads the launch report for the current process.
65
+ * This is a read-only API; native launch state has already been finalized.
65
66
  *
66
- * @param params - Parameters containing the bundle ID
67
67
  * @returns Object with status and optional crashedBundleId
68
- * - `status: "PROMOTED"` - Staging bundle was promoted to stable (ACTIVE event)
69
68
  * - `status: "RECOVERED"` - App recovered from crash, rollback occurred (ROLLBACK event)
70
69
  * - `status: "STABLE"` - No changes, already stable
71
70
  * - `crashedBundleId` - Present only when status is "RECOVERED"
72
71
  */
73
- notifyAppReady(params: { bundleId: string }): {
74
- status: "PROMOTED" | "RECOVERED" | "STABLE";
72
+ notifyAppReady(): {
73
+ status: "RECOVERED" | "STABLE";
75
74
  crashedBundleId?: string;
76
75
  };
77
76
 
@@ -107,6 +106,37 @@ export interface Spec extends TurboModule {
107
106
  */
108
107
  getBaseURL: () => string;
109
108
 
109
+ /**
110
+ * Gets the current active bundle ID from native bundle storage.
111
+ * Native reads the extracted bundle manifest first and falls back to the
112
+ * legacy BUNDLE_ID file when needed. Built-in bundle fallback is handled in JS.
113
+ *
114
+ * @returns Active bundle ID from bundle storage, or null when unavailable
115
+ */
116
+ getBundleId: () => string | null;
117
+
118
+ /**
119
+ * Gets the current manifest from native bundle storage.
120
+ * Returns an empty object when manifest.json is missing or invalid.
121
+ */
122
+ getManifest: () => UnsafeObject;
123
+
124
+ /**
125
+ * Sets the persisted cohort used for rollout calculations.
126
+ *
127
+ * Native only derives a device-based cohort when nothing has been stored
128
+ * yet. Call `getCohort()` first if the app needs to save that initial value
129
+ * for a later restore.
130
+ */
131
+ setCohort: (cohort: string) => void;
132
+
133
+ /**
134
+ * Gets the persisted cohort used for rollout calculations.
135
+ * If none has been stored yet, native derives the initial value once and
136
+ * persists it before returning.
137
+ */
138
+ getCohort: () => string;
139
+
110
140
  // EventEmitter
111
141
  addListener(eventName: string): void;
112
142
  removeListeners(count: number): void;
package/src/types.ts CHANGED
@@ -30,6 +30,11 @@ export interface ResolverCheckUpdateParams {
30
30
  */
31
31
  channel: string;
32
32
 
33
+ /**
34
+ * Cohort identifier used for server-side rollout decisions.
35
+ */
36
+ cohort: string;
37
+
33
38
  /**
34
39
  * Update strategy being used
35
40
  */
@@ -57,11 +62,10 @@ export interface ResolverCheckUpdateParams {
57
62
  export interface ResolverNotifyAppReadyParams {
58
63
  /**
59
64
  * The bundle state from native notifyAppReady
60
- * - "PROMOTED": Staging bundle was promoted to stable
61
65
  * - "RECOVERED": App recovered from crash, rollback occurred
62
66
  * - "STABLE": No changes, bundle is stable
63
67
  */
64
- status: "PROMOTED" | "RECOVERED" | "STABLE";
68
+ status: "RECOVERED" | "STABLE";
65
69
 
66
70
  /**
67
71
  * Present only when status is "RECOVERED"
@@ -111,7 +115,7 @@ export interface HotUpdaterResolver {
111
115
  /**
112
116
  * Custom implementation for notifying app ready.
113
117
  * When provided, this completely replaces the default notifyAppReady network flow.
114
- * Note: The native notifyAppReady for bundle promotion still happens automatically.
118
+ * Note: Native rollback/promotion semantics are already finalized before this callback runs.
115
119
  *
116
120
  * @param params - All parameters about the current app state
117
121
  * @returns Notification result
package/src/wrap.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useEffect, useLayoutEffect, useState } from "react";
1
+ import React, { useEffect, useState } from "react";
2
2
  import { checkForUpdate } from "./checkForUpdate";
3
3
  import type { HotUpdaterError } from "./error";
4
4
  import { useEventCallback } from "./hooks/useEventCallback";
@@ -39,12 +39,11 @@ interface CommonHotUpdaterOptions {
39
39
  requestTimeout?: number;
40
40
 
41
41
  /**
42
- * Callback invoked when the app is ready and bundle verification completes.
43
- * Provides information about bundle promotion, recovery from crashes, or stable state.
42
+ * Callback invoked when the app is ready and the native launch report is available.
43
+ * Provides information about rollback recovery or stable state.
44
44
  *
45
45
  * @param result - Bundle state information
46
46
  * @param result.status - Current bundle state:
47
- * - "PROMOTED": Staging bundle was promoted to stable (new update applied)
48
47
  * - "RECOVERED": App recovered from a crash, rollback occurred
49
48
  * - "STABLE": No changes, bundle is stable
50
49
  * @param result.crashedBundleId - Present only when status is "RECOVERED"
@@ -56,9 +55,7 @@ interface CommonHotUpdaterOptions {
56
55
  * updateMode: "manual",
57
56
  * onNotifyAppReady: ({ status, crashedBundleId }) => {
58
57
  * if (status === "RECOVERED") {
59
- * analytics.track('bundle_rollback', { crashedBundleId });
60
- * } else if (status === "PROMOTED") {
61
- * analytics.track('bundle_promoted');
58
+ * analytics.track("bundle_rollback", { crashedBundleId });
62
59
  * }
63
60
  * }
64
61
  * })(App);
@@ -220,7 +217,6 @@ const handleNotifyAppReady = async (options: {
220
217
  onNotifyAppReady?: (result: NotifyAppReadyResult) => void;
221
218
  }): Promise<void> => {
222
219
  try {
223
- // Always call native notifyAppReady for bundle promotion
224
220
  const nativeResult = nativeNotifyAppReady();
225
221
 
226
222
  // If resolver.notifyAppReady exists, call it with simplified params
@@ -249,7 +245,7 @@ export function wrap<P extends React.JSX.IntrinsicAttributes = object>(
249
245
  if (options.updateMode === "manual") {
250
246
  return (WrappedComponent: React.ComponentType<P>) => {
251
247
  const ManualHOC: React.FC<P> = (props: P) => {
252
- useLayoutEffect(() => {
248
+ useEffect(() => {
253
249
  void handleNotifyAppReady(options);
254
250
  }, []);
255
251
 
@@ -342,13 +338,13 @@ export function wrap<P extends React.JSX.IntrinsicAttributes = object>(
342
338
  restOptions.onProgress?.(progress);
343
339
  }, [progress]);
344
340
 
345
- // Notify native side that app is ready (JS bundle fully loaded)
346
- useLayoutEffect(() => {
341
+ // Read the native launch report after the first render commit.
342
+ useEffect(() => {
347
343
  void handleNotifyAppReady(restOptions);
348
344
  }, []);
349
345
 
350
346
  // Start update check
351
- useLayoutEffect(() => {
347
+ useEffect(() => {
352
348
  initHotUpdater();
353
349
  }, []);
354
350