@hot-updater/react-native 0.30.6 → 0.30.8

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 (42) hide show
  1. package/lib/commonjs/index.js +41 -19
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/index.spec.js +155 -0
  4. package/lib/commonjs/index.spec.js.map +1 -0
  5. package/lib/commonjs/store.js +3 -0
  6. package/lib/commonjs/store.js.map +1 -1
  7. package/lib/commonjs/store.spec.js +59 -0
  8. package/lib/commonjs/store.spec.js.map +1 -0
  9. package/lib/commonjs/wrap.js +14 -0
  10. package/lib/commonjs/wrap.js.map +1 -1
  11. package/lib/commonjs/wrap.spec.js +76 -0
  12. package/lib/commonjs/wrap.spec.js.map +1 -0
  13. package/lib/module/index.js +42 -20
  14. package/lib/module/index.js.map +1 -1
  15. package/lib/module/index.spec.js +154 -0
  16. package/lib/module/index.spec.js.map +1 -0
  17. package/lib/module/store.js +3 -0
  18. package/lib/module/store.js.map +1 -1
  19. package/lib/module/store.spec.js +58 -0
  20. package/lib/module/store.spec.js.map +1 -0
  21. package/lib/module/wrap.js +13 -0
  22. package/lib/module/wrap.js.map +1 -1
  23. package/lib/module/wrap.spec.js +75 -0
  24. package/lib/module/wrap.spec.js.map +1 -0
  25. package/lib/typescript/commonjs/index.d.ts +10 -1
  26. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  27. package/lib/typescript/commonjs/store.d.ts.map +1 -1
  28. package/lib/typescript/commonjs/wrap.d.ts +2 -0
  29. package/lib/typescript/commonjs/wrap.d.ts.map +1 -1
  30. package/lib/typescript/module/index.d.ts +10 -1
  31. package/lib/typescript/module/index.d.ts.map +1 -1
  32. package/lib/typescript/module/store.d.ts.map +1 -1
  33. package/lib/typescript/module/wrap.d.ts +2 -0
  34. package/lib/typescript/module/wrap.d.ts.map +1 -1
  35. package/package.json +6 -6
  36. package/plugin/build/withHotUpdater.js +14 -9
  37. package/src/index.spec.ts +177 -0
  38. package/src/index.ts +100 -39
  39. package/src/store.spec.ts +57 -0
  40. package/src/store.ts +7 -0
  41. package/src/wrap.spec.ts +82 -0
  42. package/src/wrap.tsx +26 -0
@@ -5,18 +5,19 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  const promises_1 = require("node:fs/promises");
7
7
  const path_1 = __importDefault(require("path"));
8
- const cli_tools_1 = require("@hot-updater/cli-tools");
9
8
  const config_plugins_1 = require("expo/config-plugins");
10
- const hot_updater_1 = require("hot-updater");
11
9
  const package_json_1 = __importDefault(require("../../package.json"));
12
10
  const transformers_1 = require("./transformers");
11
+ const loadCliTools = () => import("@hot-updater/cli-tools");
12
+ const loadHotUpdater = () => import("hot-updater");
13
13
  let fingerprintCache = null;
14
14
  const getFingerprint = async () => {
15
15
  if (fingerprintCache) {
16
16
  return fingerprintCache;
17
17
  }
18
- fingerprintCache = await (0, hot_updater_1.generateFingerprints)();
19
- await (0, hot_updater_1.createFingerprintJSON)(fingerprintCache);
18
+ const { createFingerprintJSON, generateFingerprints } = await loadHotUpdater();
19
+ fingerprintCache = await generateFingerprints();
20
+ await createFingerprintJSON(fingerprintCache);
20
21
  return fingerprintCache;
21
22
  };
22
23
  /**
@@ -36,7 +37,8 @@ const getPublicKeyFromConfig = async (signingConfig) => {
36
37
  const envPrivateKey = process.env.HOT_UPDATER_PRIVATE_KEY;
37
38
  if (envPrivateKey) {
38
39
  try {
39
- const publicKeyPEM = (0, hot_updater_1.getPublicKeyFromPrivate)(envPrivateKey);
40
+ const { getPublicKeyFromPrivate } = await loadHotUpdater();
41
+ const publicKeyPEM = getPublicKeyFromPrivate(envPrivateKey);
40
42
  console.log("[hot-updater] Using public key extracted from HOT_UPDATER_PRIVATE_KEY environment variable");
41
43
  return publicKeyPEM.trim();
42
44
  }
@@ -59,8 +61,9 @@ const getPublicKeyFromConfig = async (signingConfig) => {
59
61
  const publicKeyPath = privateKeyPath.replace(/private-key\.pem$/, "public-key.pem");
60
62
  try {
61
63
  // Priority 2: Private key file (existing method)
62
- const privateKeyPEM = await (0, hot_updater_1.loadPrivateKey)(privateKeyPath);
63
- const publicKeyPEM = (0, hot_updater_1.getPublicKeyFromPrivate)(privateKeyPEM);
64
+ const { getPublicKeyFromPrivate, loadPrivateKey } = await loadHotUpdater();
65
+ const privateKeyPEM = await loadPrivateKey(privateKeyPath);
66
+ const publicKeyPEM = getPublicKeyFromPrivate(privateKeyPEM);
64
67
  console.log(`[hot-updater] Extracted public key from ${privateKeyPath}`);
65
68
  return publicKeyPEM.trim();
66
69
  }
@@ -118,7 +121,8 @@ const withHotUpdaterConfigAsync = (props) => (config) => {
118
121
  // === iOS: Add channel and fingerprint to Info.plist ===
119
122
  modifiedConfig = (0, config_plugins_1.withInfoPlist)(modifiedConfig, async (cfg) => {
120
123
  let fingerprintHash = null;
121
- const config = await (0, cli_tools_1.loadConfig)(null);
124
+ const { loadConfig } = await loadCliTools();
125
+ const config = await loadConfig(null);
122
126
  if (config.updateStrategy !== "appVersion") {
123
127
  const fingerprint = await getFingerprint();
124
128
  fingerprintHash = fingerprint.ios.hash;
@@ -137,7 +141,8 @@ const withHotUpdaterConfigAsync = (props) => (config) => {
137
141
  // === Android: Add channel and fingerprint to strings.xml ===
138
142
  modifiedConfig = (0, config_plugins_1.withStringsXml)(modifiedConfig, async (cfg) => {
139
143
  let fingerprintHash = null;
140
- const config = await (0, cli_tools_1.loadConfig)(null);
144
+ const { loadConfig } = await loadCliTools();
145
+ const config = await loadConfig(null);
141
146
  if (config.updateStrategy !== "appVersion") {
142
147
  const fingerprint = await getFingerprint();
143
148
  fingerprintHash = fingerprint.android.hash;
@@ -0,0 +1,177 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mocks = vi.hoisted(() => ({
4
+ addListener: vi.fn(() => () => {}),
5
+ checkForUpdate: vi.fn(),
6
+ clearCrashHistory: vi.fn(() => true),
7
+ createDefaultResolver: vi.fn(),
8
+ getAppVersion: vi.fn(() => "1.0.0"),
9
+ getBaseURL: vi.fn(() => null),
10
+ getBundleId: vi.fn(() => "bundle-id"),
11
+ getChannel: vi.fn(() => "production"),
12
+ getCohort: vi.fn(() => "123"),
13
+ getCrashHistory: vi.fn(() => []),
14
+ getDefaultChannel: vi.fn(() => "production"),
15
+ getFingerprintHash: vi.fn(() => null),
16
+ getManifest: vi.fn(() => null),
17
+ getMinBundleId: vi.fn(() => "min-bundle-id"),
18
+ init: vi.fn(),
19
+ isChannelSwitched: vi.fn(() => false),
20
+ reload: vi.fn(),
21
+ resetChannel: vi.fn(),
22
+ setCohort: vi.fn(),
23
+ setReloadBehavior: vi.fn(),
24
+ updateBundle: vi.fn(),
25
+ wrap: vi.fn(),
26
+ }));
27
+
28
+ vi.mock("./DefaultResolver", () => ({
29
+ createDefaultResolver: mocks.createDefaultResolver,
30
+ }));
31
+
32
+ vi.mock("./checkForUpdate", () => ({
33
+ checkForUpdate: mocks.checkForUpdate,
34
+ }));
35
+
36
+ vi.mock("./native", () => ({
37
+ addListener: mocks.addListener,
38
+ clearCrashHistory: mocks.clearCrashHistory,
39
+ getAppVersion: mocks.getAppVersion,
40
+ getBaseURL: mocks.getBaseURL,
41
+ getBundleId: mocks.getBundleId,
42
+ getChannel: mocks.getChannel,
43
+ getCohort: mocks.getCohort,
44
+ getCrashHistory: mocks.getCrashHistory,
45
+ getDefaultChannel: mocks.getDefaultChannel,
46
+ getFingerprintHash: mocks.getFingerprintHash,
47
+ getManifest: mocks.getManifest,
48
+ getMinBundleId: mocks.getMinBundleId,
49
+ isChannelSwitched: mocks.isChannelSwitched,
50
+ reload: mocks.reload,
51
+ resetChannel: mocks.resetChannel,
52
+ setCohort: mocks.setCohort,
53
+ setReloadBehavior: mocks.setReloadBehavior,
54
+ updateBundle: mocks.updateBundle,
55
+ }));
56
+
57
+ vi.mock("./wrap", () => ({
58
+ init: mocks.init,
59
+ wrap: mocks.wrap,
60
+ }));
61
+
62
+ const importHotUpdater = async () => {
63
+ const { HotUpdater } = await import("./index");
64
+ return HotUpdater;
65
+ };
66
+
67
+ describe("HotUpdater client initialization", () => {
68
+ beforeEach(() => {
69
+ vi.resetModules();
70
+
71
+ for (const mock of Object.values(mocks)) {
72
+ mock.mockReset();
73
+ }
74
+
75
+ mocks.addListener.mockReturnValue(() => {});
76
+ mocks.checkForUpdate.mockResolvedValue(null);
77
+ mocks.createDefaultResolver.mockImplementation((baseURL: string) => ({
78
+ baseURL,
79
+ checkUpdate: vi.fn(),
80
+ notifyAppReady: vi.fn(),
81
+ }));
82
+ mocks.getBaseURL.mockReturnValue(null);
83
+ mocks.init.mockReturnValue(undefined);
84
+ mocks.wrap.mockReturnValue((Component: unknown) => Component);
85
+ });
86
+
87
+ it("initializes manual update flows without wrapping a component", async () => {
88
+ const resolver = {
89
+ checkUpdate: vi.fn(),
90
+ notifyAppReady: vi.fn(),
91
+ };
92
+ mocks.createDefaultResolver.mockReturnValue(resolver);
93
+
94
+ const HotUpdater = await importHotUpdater();
95
+
96
+ const result = HotUpdater.init({
97
+ baseURL: "https://updates.example.com",
98
+ requestHeaders: {
99
+ Authorization: "Bearer token",
100
+ },
101
+ requestTimeout: 1000,
102
+ updateMode: "manual",
103
+ });
104
+
105
+ expect(result).toBeUndefined();
106
+ expect(mocks.createDefaultResolver).toHaveBeenCalledWith(
107
+ "https://updates.example.com",
108
+ );
109
+ expect(mocks.init).toHaveBeenCalledWith({
110
+ requestHeaders: {
111
+ Authorization: "Bearer token",
112
+ },
113
+ requestTimeout: 1000,
114
+ resolver,
115
+ updateMode: "manual",
116
+ });
117
+ });
118
+
119
+ it("uses init configuration for later manual update checks", async () => {
120
+ const resolver = {
121
+ checkUpdate: vi.fn(),
122
+ notifyAppReady: vi.fn(),
123
+ };
124
+ mocks.createDefaultResolver.mockReturnValue(resolver);
125
+
126
+ const HotUpdater = await importHotUpdater();
127
+
128
+ HotUpdater.init({
129
+ baseURL: "https://updates.example.com",
130
+ requestHeaders: {
131
+ Authorization: "Bearer token",
132
+ },
133
+ requestTimeout: 1000,
134
+ updateMode: "manual",
135
+ });
136
+
137
+ await HotUpdater.checkForUpdate({
138
+ requestHeaders: {
139
+ "X-Runtime": "secondary",
140
+ },
141
+ updateStrategy: "appVersion",
142
+ });
143
+
144
+ expect(mocks.checkForUpdate).toHaveBeenCalledWith({
145
+ requestHeaders: {
146
+ Authorization: "Bearer token",
147
+ "X-Runtime": "secondary",
148
+ },
149
+ requestTimeout: 1000,
150
+ resolver,
151
+ updateStrategy: "appVersion",
152
+ });
153
+ });
154
+
155
+ it("points users to wrap or init when methods are called too early", async () => {
156
+ const HotUpdater = await importHotUpdater();
157
+
158
+ expect(() =>
159
+ HotUpdater.checkForUpdate({
160
+ updateStrategy: "appVersion",
161
+ }),
162
+ ).toThrow("requires HotUpdater.wrap() or HotUpdater.init() to be used");
163
+ });
164
+
165
+ it("rejects automatic update mode for init at runtime", async () => {
166
+ const HotUpdater = await importHotUpdater();
167
+
168
+ expect(() =>
169
+ HotUpdater.init({
170
+ baseURL: "https://updates.example.com",
171
+ updateMode: "auto",
172
+ updateStrategy: "appVersion",
173
+ } as never),
174
+ ).toThrow('HotUpdater.init() only supports updateMode: "manual"');
175
+ expect(mocks.init).not.toHaveBeenCalled();
176
+ });
177
+ });
package/src/index.ts CHANGED
@@ -27,7 +27,13 @@ import {
27
27
  } from "./native";
28
28
  import { hotUpdaterStore } from "./store";
29
29
  import type { HotUpdaterResolver } from "./types";
30
- import { type HotUpdaterOptions, type InternalWrapOptions, wrap } from "./wrap";
30
+ import {
31
+ type HotUpdaterOptions,
32
+ init,
33
+ type InternalWrapOptions,
34
+ type ManualUpdateOptions,
35
+ wrap,
36
+ } from "./wrap";
31
37
 
32
38
  export type {
33
39
  CustomReloadHandler,
@@ -48,6 +54,7 @@ export {
48
54
  type SignatureVerificationFailure,
49
55
  } from "./types";
50
56
  export type { HotUpdaterOptions, RunUpdateProcessResponse } from "./wrap";
57
+ export type { ManualUpdateOptions as HotUpdaterInitOptions } from "./wrap";
51
58
 
52
59
  /**
53
60
  * Register getBaseURL to global objects for use without imports.
@@ -81,23 +88,88 @@ function createHotUpdaterClient() {
81
88
  resolver: null,
82
89
  };
83
90
 
91
+ const normalizeOptions = (
92
+ options: HotUpdaterOptions,
93
+ apiName: "init" | "wrap",
94
+ ): InternalWrapOptions => {
95
+ if ("baseURL" in options && options.baseURL) {
96
+ const { baseURL, ...rest } = options;
97
+ return {
98
+ ...rest,
99
+ resolver: createDefaultResolver(baseURL),
100
+ };
101
+ }
102
+
103
+ if ("resolver" in options && options.resolver) {
104
+ return options;
105
+ }
106
+
107
+ const baseURLExample =
108
+ apiName === "wrap"
109
+ ? ` export default HotUpdater.wrap({\n` +
110
+ ` baseURL: "<your-update-server-url>",\n` +
111
+ ` updateStrategy: "appVersion",\n` +
112
+ ` updateMode: "auto"\n` +
113
+ ` })(App);\n\n`
114
+ : ` HotUpdater.init({\n` +
115
+ ` baseURL: "<your-update-server-url>",\n` +
116
+ ` updateMode: "manual"\n` +
117
+ ` });\n\n`;
118
+
119
+ const resolverExample =
120
+ apiName === "wrap"
121
+ ? ` export default HotUpdater.wrap({\n` +
122
+ ` resolver: {\n` +
123
+ ` checkUpdate: async (params) => { /* custom logic */ },\n` +
124
+ ` notifyAppReady: async (params) => { /* custom logic */ }\n` +
125
+ ` },\n` +
126
+ ` updateMode: "manual"\n` +
127
+ ` })(App);\n\n`
128
+ : ` HotUpdater.init({\n` +
129
+ ` resolver: {\n` +
130
+ ` checkUpdate: async (params) => { /* custom logic */ },\n` +
131
+ ` notifyAppReady: async (params) => { /* custom logic */ }\n` +
132
+ ` },\n` +
133
+ ` updateMode: "manual"\n` +
134
+ ` });\n\n`;
135
+
136
+ throw new Error(
137
+ `[HotUpdater] Either baseURL or resolver must be provided.\n\n` +
138
+ `Option 1: Using baseURL (recommended for most cases)\n` +
139
+ baseURLExample +
140
+ `Option 2: Using custom resolver (advanced)\n` +
141
+ resolverExample +
142
+ `For more information, visit: https://hot-updater.dev/docs/react-native-api/${apiName}`,
143
+ );
144
+ };
145
+
146
+ const configureGlobal = (
147
+ normalizedOptions: InternalWrapOptions,
148
+ options: HotUpdaterOptions,
149
+ ) => {
150
+ globalConfig.resolver = normalizedOptions.resolver;
151
+ globalConfig.requestHeaders = options.requestHeaders;
152
+ globalConfig.requestTimeout = options.requestTimeout;
153
+ };
154
+
84
155
  const ensureGlobalResolver = (methodName: string) => {
85
156
  if (!globalConfig.resolver) {
86
157
  throw new Error(
87
- `[HotUpdater] ${methodName} requires HotUpdater.wrap() to be used.\n\n` +
88
- `To fix this issue, wrap your root component with HotUpdater.wrap():\n\n` +
89
- `Option 1: With automatic updates\n` +
158
+ `[HotUpdater] ${methodName} requires HotUpdater.wrap() or HotUpdater.init() to be used.\n\n` +
159
+ `To fix this issue, configure HotUpdater before calling ${methodName}:\n\n` +
160
+ `Option 1: With HotUpdater.wrap()\n` +
90
161
  ` export default HotUpdater.wrap({\n` +
91
162
  ` baseURL: "<your-update-server-url>",\n` +
92
163
  ` updateStrategy: "appVersion",\n` +
93
164
  ` updateMode: "auto"\n` +
94
165
  ` })(App);\n\n` +
95
- `Option 2: Manual updates only (custom flow)\n` +
96
- ` export default HotUpdater.wrap({\n` +
166
+ `Option 2: With HotUpdater.init() for custom runtimes\n` +
167
+ ` HotUpdater.init({\n` +
97
168
  ` baseURL: "<your-update-server-url>",\n` +
98
169
  ` updateMode: "manual"\n` +
99
- ` })(App);\n\n` +
100
- `For more information, visit: https://hot-updater.dev/docs/react-native-api/wrap`,
170
+ ` });\n\n` +
171
+ `For more information, visit: https://hot-updater.dev/docs/react-native-api/wrap ` +
172
+ `or https://hot-updater.dev/docs/react-native-api/init`,
101
173
  );
102
174
  }
103
175
  return globalConfig.resolver;
@@ -129,42 +201,31 @@ function createHotUpdaterClient() {
129
201
  * ```
130
202
  */
131
203
  wrap: (options: HotUpdaterOptions) => {
132
- let normalizedOptions: InternalWrapOptions;
133
-
134
- if ("baseURL" in options && options.baseURL) {
135
- const { baseURL, ...rest } = options;
136
- normalizedOptions = {
137
- ...rest,
138
- resolver: createDefaultResolver(baseURL),
139
- };
140
- } else if ("resolver" in options && options.resolver) {
141
- normalizedOptions = options;
142
- } else {
204
+ const normalizedOptions = normalizeOptions(options, "wrap");
205
+ configureGlobal(normalizedOptions, options);
206
+
207
+ return wrap(normalizedOptions);
208
+ },
209
+
210
+ /**
211
+ * Initializes HotUpdater without wrapping a React component.
212
+ *
213
+ * Use this for manual update flows in runtimes where a root component HOC
214
+ * is not convenient. It has the same initialization effect as
215
+ * `HotUpdater.wrap({ updateMode: "manual" })(App)`.
216
+ */
217
+ init: (options: ManualUpdateOptions): void => {
218
+ const normalizedOptions = normalizeOptions(options, "init");
219
+ if (normalizedOptions.updateMode !== "manual") {
143
220
  throw new Error(
144
- `[HotUpdater] Either baseURL or resolver must be provided.\n\n` +
145
- `Option 1: Using baseURL (recommended for most cases)\n` +
146
- ` export default HotUpdater.wrap({\n` +
147
- ` baseURL: "<your-update-server-url>",\n` +
148
- ` updateStrategy: "appVersion",\n` +
149
- ` updateMode: "auto"\n` +
150
- ` })(App);\n\n` +
151
- `Option 2: Using custom resolver (advanced)\n` +
152
- ` export default HotUpdater.wrap({\n` +
153
- ` resolver: {\n` +
154
- ` checkUpdate: async (params) => { /* custom logic */ },\n` +
155
- ` notifyAppReady: async (params) => { /* custom logic */ }\n` +
156
- ` },\n` +
157
- ` updateMode: "manual"\n` +
158
- ` })(App);\n\n` +
159
- `For more information, visit: https://hot-updater.dev/docs/react-native-api/wrap`,
221
+ `[HotUpdater] HotUpdater.init() only supports updateMode: "manual". ` +
222
+ `Use HotUpdater.wrap() for automatic updates.`,
160
223
  );
161
224
  }
162
225
 
163
- globalConfig.resolver = normalizedOptions.resolver;
164
- globalConfig.requestHeaders = options.requestHeaders;
165
- globalConfig.requestTimeout = options.requestTimeout;
226
+ configureGlobal(normalizedOptions, options);
166
227
 
167
- return wrap(normalizedOptions);
228
+ init(normalizedOptions);
168
229
  },
169
230
 
170
231
  /**
@@ -0,0 +1,57 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const addListenerMock = vi.hoisted(() => vi.fn());
4
+
5
+ vi.mock("./native", () => ({
6
+ addListener: addListenerMock,
7
+ }));
8
+
9
+ const importStore = async () => {
10
+ const { hotUpdaterStore } = await import("./store");
11
+ return hotUpdaterStore;
12
+ };
13
+
14
+ describe("hotUpdaterStore", () => {
15
+ beforeEach(() => {
16
+ vi.resetModules();
17
+ addListenerMock.mockReset();
18
+ });
19
+
20
+ it("does not notify subscribers when state values are unchanged", async () => {
21
+ const store = await importStore();
22
+ const listener = vi.fn();
23
+
24
+ const unsubscribe = store.subscribe(listener);
25
+
26
+ store.setState({});
27
+ store.setState({ progress: 0 });
28
+ store.setState({ isUpdateDownloaded: false });
29
+
30
+ expect(listener).not.toHaveBeenCalled();
31
+
32
+ unsubscribe();
33
+ });
34
+
35
+ it("notifies subscribers only when progress events change the snapshot", async () => {
36
+ const store = await importStore();
37
+ const listener = vi.fn();
38
+ const progressListener = addListenerMock.mock.calls[0][1] as (state: {
39
+ progress: number;
40
+ }) => void;
41
+
42
+ const unsubscribe = store.subscribe(listener);
43
+
44
+ progressListener({ progress: 0.5 });
45
+ progressListener({ progress: 0.5 });
46
+ progressListener({ progress: 1 });
47
+ store.setState({ isUpdateDownloaded: true });
48
+
49
+ expect(listener).toHaveBeenCalledTimes(2);
50
+ expect(store.getSnapshot()).toEqual({
51
+ isUpdateDownloaded: true,
52
+ progress: 1,
53
+ });
54
+
55
+ unsubscribe();
56
+ });
57
+ });
package/src/store.ts CHANGED
@@ -46,6 +46,13 @@ const createHotUpdaterStore = () => {
46
46
  nextState.isUpdateDownloaded = newState.isUpdateDownloaded;
47
47
  }
48
48
 
49
+ if (
50
+ state.progress === nextState.progress &&
51
+ state.isUpdateDownloaded === nextState.isUpdateDownloaded
52
+ ) {
53
+ return;
54
+ }
55
+
49
56
  state = nextState;
50
57
  emitChange();
51
58
  };
@@ -0,0 +1,82 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mocks = vi.hoisted(() => ({
4
+ addListener: vi.fn(() => () => {}),
5
+ checkForUpdate: vi.fn(),
6
+ getBundleId: vi.fn(() => "bundle-id"),
7
+ notifyAppReady: vi.fn(() => ({ status: "STABLE" })),
8
+ reload: vi.fn(),
9
+ }));
10
+
11
+ vi.mock("./checkForUpdate", () => ({
12
+ checkForUpdate: mocks.checkForUpdate,
13
+ }));
14
+
15
+ vi.mock("./native", () => ({
16
+ addListener: mocks.addListener,
17
+ getBundleId: mocks.getBundleId,
18
+ notifyAppReady: mocks.notifyAppReady,
19
+ reload: mocks.reload,
20
+ }));
21
+
22
+ describe("HotUpdater wrap initialization", () => {
23
+ beforeEach(() => {
24
+ vi.resetModules();
25
+ vi.unstubAllGlobals();
26
+ vi.useRealTimers();
27
+
28
+ for (const mock of Object.values(mocks)) {
29
+ mock.mockReset();
30
+ }
31
+
32
+ mocks.checkForUpdate.mockResolvedValue(null);
33
+ mocks.addListener.mockReturnValue(() => {});
34
+ mocks.getBundleId.mockReturnValue("bundle-id");
35
+ mocks.notifyAppReady.mockReturnValue({ status: "STABLE" });
36
+ });
37
+
38
+ it("returns void from init and defers notifyAppReady to the next frame", async () => {
39
+ vi.useFakeTimers();
40
+
41
+ const requestAnimationFrame = vi.fn(
42
+ (callback: (timestamp: number) => void) => {
43
+ setTimeout(() => callback(0), 0);
44
+ return 1;
45
+ },
46
+ );
47
+ vi.stubGlobal("requestAnimationFrame", requestAnimationFrame);
48
+
49
+ const resolver = {
50
+ checkUpdate: vi.fn(),
51
+ notifyAppReady: vi.fn().mockResolvedValue(undefined),
52
+ };
53
+ const { init } = await import("./wrap");
54
+
55
+ const result = init({
56
+ resolver,
57
+ requestHeaders: {
58
+ Authorization: "Bearer token",
59
+ },
60
+ requestTimeout: 1000,
61
+ updateMode: "manual",
62
+ });
63
+
64
+ expect(result).toBeUndefined();
65
+ expect(mocks.notifyAppReady).not.toHaveBeenCalled();
66
+ expect(resolver.notifyAppReady).not.toHaveBeenCalled();
67
+
68
+ expect(requestAnimationFrame).toHaveBeenCalled();
69
+
70
+ await vi.runOnlyPendingTimersAsync();
71
+
72
+ expect(mocks.notifyAppReady).toHaveBeenCalledWith();
73
+ expect(resolver.notifyAppReady).toHaveBeenCalledWith({
74
+ status: "STABLE",
75
+ crashedBundleId: undefined,
76
+ requestHeaders: {
77
+ Authorization: "Bearer token",
78
+ },
79
+ requestTimeout: 1000,
80
+ });
81
+ });
82
+ });
package/src/wrap.tsx CHANGED
@@ -204,10 +204,30 @@ type InternalManualUpdateOptions = InternalCommonOptions & {
204
204
  updateMode: "manual";
205
205
  };
206
206
 
207
+ export type InternalInitOptions = InternalManualUpdateOptions;
208
+
207
209
  export type InternalWrapOptions =
208
210
  | InternalAutoUpdateOptions
209
211
  | InternalManualUpdateOptions;
210
212
 
213
+ type RequestAnimationFrame = (callback: (timestamp: number) => void) => number;
214
+
215
+ const waitForNextFrame = () =>
216
+ new Promise<void>((resolve) => {
217
+ const requestAnimationFrame = (
218
+ globalThis as typeof globalThis & {
219
+ requestAnimationFrame?: RequestAnimationFrame;
220
+ }
221
+ )?.requestAnimationFrame;
222
+
223
+ if (requestAnimationFrame) {
224
+ requestAnimationFrame(() => resolve());
225
+ return;
226
+ }
227
+
228
+ void Promise.resolve().then(resolve);
229
+ });
230
+
211
231
  /**
212
232
  * Helper function to handle notifyAppReady flow
213
233
  */
@@ -217,6 +237,8 @@ const handleNotifyAppReady = async (options: {
217
237
  requestTimeout?: number;
218
238
  onNotifyAppReady?: (result: NotifyAppReadyResult) => void;
219
239
  }): Promise<void> => {
240
+ await waitForNextFrame();
241
+
220
242
  try {
221
243
  const nativeResult = nativeNotifyAppReady();
222
244
 
@@ -240,6 +262,10 @@ const handleNotifyAppReady = async (options: {
240
262
  }
241
263
  };
242
264
 
265
+ export function init(options: InternalInitOptions): void {
266
+ void handleNotifyAppReady(options);
267
+ }
268
+
243
269
  export function wrap<P extends React.JSX.IntrinsicAttributes = object>(
244
270
  options: InternalWrapOptions,
245
271
  ): (WrappedComponent: React.ComponentType<P>) => React.ComponentType<P> {