@hot-updater/react-native 0.30.7 → 0.30.9

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,170 @@
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
+ });
103
+
104
+ expect(result).toBeUndefined();
105
+ expect(mocks.createDefaultResolver).toHaveBeenCalledWith(
106
+ "https://updates.example.com",
107
+ );
108
+ expect(mocks.init).toHaveBeenCalledWith({
109
+ requestHeaders: {
110
+ Authorization: "Bearer token",
111
+ },
112
+ requestTimeout: 1000,
113
+ resolver,
114
+ });
115
+ });
116
+
117
+ it("uses init configuration for later manual update checks", async () => {
118
+ const resolver = {
119
+ checkUpdate: vi.fn(),
120
+ notifyAppReady: vi.fn(),
121
+ };
122
+ mocks.createDefaultResolver.mockReturnValue(resolver);
123
+
124
+ const HotUpdater = await importHotUpdater();
125
+
126
+ HotUpdater.init({
127
+ baseURL: "https://updates.example.com",
128
+ requestHeaders: {
129
+ Authorization: "Bearer token",
130
+ },
131
+ requestTimeout: 1000,
132
+ });
133
+
134
+ await HotUpdater.checkForUpdate({
135
+ requestHeaders: {
136
+ "X-Runtime": "secondary",
137
+ },
138
+ updateStrategy: "appVersion",
139
+ });
140
+
141
+ expect(mocks.checkForUpdate).toHaveBeenCalledWith({
142
+ requestHeaders: {
143
+ Authorization: "Bearer token",
144
+ "X-Runtime": "secondary",
145
+ },
146
+ requestTimeout: 1000,
147
+ resolver,
148
+ updateStrategy: "appVersion",
149
+ });
150
+ });
151
+
152
+ it("points users to wrap or init when methods are called too early", async () => {
153
+ const HotUpdater = await importHotUpdater();
154
+
155
+ expect(() =>
156
+ HotUpdater.checkForUpdate({
157
+ updateStrategy: "appVersion",
158
+ }),
159
+ ).toThrow("requires HotUpdater.wrap() or HotUpdater.init() to be used");
160
+ });
161
+
162
+ it("requires baseURL for init", async () => {
163
+ const HotUpdater = await importHotUpdater();
164
+
165
+ expect(() => HotUpdater.init({} as never)).toThrow(
166
+ "baseURL must be provided",
167
+ );
168
+ expect(mocks.init).not.toHaveBeenCalled();
169
+ });
170
+ });
package/src/index.ts CHANGED
@@ -27,7 +27,14 @@ 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 HotUpdaterInitOptions,
32
+ type HotUpdaterOptions,
33
+ init,
34
+ type InternalInitOptions,
35
+ type InternalWrapOptions,
36
+ wrap,
37
+ } from "./wrap";
31
38
 
32
39
  export type {
33
40
  CustomReloadHandler,
@@ -47,7 +54,11 @@ export {
47
54
  type ResolverNotifyAppReadyParams,
48
55
  type SignatureVerificationFailure,
49
56
  } from "./types";
50
- export type { HotUpdaterOptions, RunUpdateProcessResponse } from "./wrap";
57
+ export type {
58
+ HotUpdaterInitOptions,
59
+ HotUpdaterOptions,
60
+ RunUpdateProcessResponse,
61
+ } from "./wrap";
51
62
 
52
63
  /**
53
64
  * Register getBaseURL to global objects for use without imports.
@@ -81,23 +92,109 @@ function createHotUpdaterClient() {
81
92
  resolver: null,
82
93
  };
83
94
 
95
+ const createMissingNetworkConfigError = (apiName: "init" | "wrap") => {
96
+ if (apiName === "init") {
97
+ return new Error(
98
+ `[HotUpdater] baseURL must be provided.\n\n` +
99
+ `Configure HotUpdater before calling update APIs:\n\n` +
100
+ ` HotUpdater.init({\n` +
101
+ ` baseURL: "<your-update-server-url>",\n` +
102
+ ` });\n\n` +
103
+ `For more information, visit: https://hot-updater.dev/docs/react-native-api/init`,
104
+ );
105
+ }
106
+
107
+ const baseURLExample =
108
+ ` export default HotUpdater.wrap({\n` +
109
+ ` baseURL: "<your-update-server-url>",\n` +
110
+ ` updateStrategy: "appVersion",\n` +
111
+ ` updateMode: "auto"\n` +
112
+ ` })(App);\n\n`;
113
+
114
+ const resolverExample =
115
+ ` export default HotUpdater.wrap({\n` +
116
+ ` resolver: {\n` +
117
+ ` checkUpdate: async (params) => { /* custom logic */ },\n` +
118
+ ` notifyAppReady: async (params) => { /* custom logic */ }\n` +
119
+ ` },\n` +
120
+ ` updateMode: "manual"\n` +
121
+ ` })(App);\n\n`;
122
+
123
+ return new Error(
124
+ `[HotUpdater] Either baseURL or resolver must be provided.\n\n` +
125
+ `Option 1: Using baseURL (recommended for most cases)\n` +
126
+ baseURLExample +
127
+ `Option 2: Using custom resolver (advanced)\n` +
128
+ resolverExample +
129
+ `For more information, visit: https://hot-updater.dev/docs/react-native-api/${apiName}`,
130
+ );
131
+ };
132
+
133
+ const normalizeOptions = (
134
+ options: HotUpdaterOptions,
135
+ ): InternalWrapOptions => {
136
+ if ("baseURL" in options && options.baseURL) {
137
+ const { baseURL, ...rest } = options;
138
+ return {
139
+ ...rest,
140
+ resolver: createDefaultResolver(baseURL),
141
+ };
142
+ }
143
+
144
+ if ("resolver" in options && options.resolver) {
145
+ return options;
146
+ }
147
+
148
+ throw createMissingNetworkConfigError("wrap");
149
+ };
150
+
151
+ const normalizeInitOptions = (
152
+ options: HotUpdaterInitOptions,
153
+ ): InternalInitOptions => {
154
+ const {
155
+ baseURL,
156
+ updateMode: _updateMode,
157
+ ...rest
158
+ } = options as HotUpdaterInitOptions & {
159
+ updateMode?: unknown;
160
+ };
161
+
162
+ if (baseURL) {
163
+ return {
164
+ ...rest,
165
+ resolver: createDefaultResolver(baseURL),
166
+ };
167
+ }
168
+
169
+ throw createMissingNetworkConfigError("init");
170
+ };
171
+
172
+ const configureGlobal = (
173
+ normalizedOptions: InternalInitOptions | InternalWrapOptions,
174
+ options: HotUpdaterOptions | HotUpdaterInitOptions,
175
+ ) => {
176
+ globalConfig.resolver = normalizedOptions.resolver;
177
+ globalConfig.requestHeaders = options.requestHeaders;
178
+ globalConfig.requestTimeout = options.requestTimeout;
179
+ };
180
+
84
181
  const ensureGlobalResolver = (methodName: string) => {
85
182
  if (!globalConfig.resolver) {
86
183
  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` +
184
+ `[HotUpdater] ${methodName} requires HotUpdater.wrap() or HotUpdater.init() to be used.\n\n` +
185
+ `To fix this issue, configure HotUpdater before calling ${methodName}:\n\n` +
186
+ `Option 1: With HotUpdater.wrap()\n` +
90
187
  ` export default HotUpdater.wrap({\n` +
91
188
  ` baseURL: "<your-update-server-url>",\n` +
92
189
  ` updateStrategy: "appVersion",\n` +
93
190
  ` updateMode: "auto"\n` +
94
191
  ` })(App);\n\n` +
95
- `Option 2: Manual updates only (custom flow)\n` +
96
- ` export default HotUpdater.wrap({\n` +
192
+ `Option 2: With HotUpdater.init() for custom runtimes\n` +
193
+ ` HotUpdater.init({\n` +
97
194
  ` baseURL: "<your-update-server-url>",\n` +
98
- ` updateMode: "manual"\n` +
99
- ` })(App);\n\n` +
100
- `For more information, visit: https://hot-updater.dev/docs/react-native-api/wrap`,
195
+ ` });\n\n` +
196
+ `For more information, visit: https://hot-updater.dev/docs/react-native-api/wrap ` +
197
+ `or https://hot-updater.dev/docs/react-native-api/init`,
101
198
  );
102
199
  }
103
200
  return globalConfig.resolver;
@@ -129,44 +226,36 @@ function createHotUpdaterClient() {
129
226
  * ```
130
227
  */
131
228
  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 {
143
- 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`,
160
- );
161
- }
162
-
163
- globalConfig.resolver = normalizedOptions.resolver;
164
- globalConfig.requestHeaders = options.requestHeaders;
165
- globalConfig.requestTimeout = options.requestTimeout;
229
+ const normalizedOptions = normalizeOptions(options);
230
+ configureGlobal(normalizedOptions, options);
166
231
 
167
232
  return wrap(normalizedOptions);
168
233
  },
169
234
 
235
+ /**
236
+ * Initializes HotUpdater without wrapping a React component.
237
+ *
238
+ * Use this for manual update flows in runtimes where a root component HOC
239
+ * is not convenient. It has the same initialization effect as
240
+ * `HotUpdater.wrap({ updateMode: "manual" })(App)`.
241
+ *
242
+ * @example
243
+ * ```tsx
244
+ * HotUpdater.init({
245
+ * baseURL: "<your-update-server-url>",
246
+ * });
247
+ *
248
+ * export default App;
249
+ * ```
250
+ */
251
+ init: (options: HotUpdaterInitOptions): void => {
252
+ const normalizedOptions = normalizeInitOptions(options);
253
+
254
+ configureGlobal(normalizedOptions, options);
255
+
256
+ init(normalizedOptions);
257
+ },
258
+
170
259
  /**
171
260
  * Reloads the app.
172
261
  */
@@ -0,0 +1,81 @@
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
+ });
62
+
63
+ expect(result).toBeUndefined();
64
+ expect(mocks.notifyAppReady).not.toHaveBeenCalled();
65
+ expect(resolver.notifyAppReady).not.toHaveBeenCalled();
66
+
67
+ expect(requestAnimationFrame).toHaveBeenCalled();
68
+
69
+ await vi.runOnlyPendingTimersAsync();
70
+
71
+ expect(mocks.notifyAppReady).toHaveBeenCalledWith();
72
+ expect(resolver.notifyAppReady).toHaveBeenCalledWith({
73
+ status: "STABLE",
74
+ crashedBundleId: undefined,
75
+ requestHeaders: {
76
+ Authorization: "Bearer token",
77
+ },
78
+ requestTimeout: 1000,
79
+ });
80
+ });
81
+ });
package/src/wrap.tsx CHANGED
@@ -173,6 +173,16 @@ export type ManualUpdateOptions = CommonHotUpdaterOptions &
173
173
  updateMode: "manual";
174
174
  };
175
175
 
176
+ type ManualBaseURLUpdateOptions = CommonHotUpdaterOptions &
177
+ BaseURLConfig & {
178
+ updateMode: "manual";
179
+ };
180
+
181
+ export type HotUpdaterInitOptions = Omit<
182
+ ManualBaseURLUpdateOptions,
183
+ "updateMode"
184
+ >;
185
+
176
186
  export type HotUpdaterOptions = AutoUpdateOptions | ManualUpdateOptions;
177
187
 
178
188
  /**
@@ -204,10 +214,30 @@ type InternalManualUpdateOptions = InternalCommonOptions & {
204
214
  updateMode: "manual";
205
215
  };
206
216
 
217
+ export type InternalInitOptions = InternalCommonOptions;
218
+
207
219
  export type InternalWrapOptions =
208
220
  | InternalAutoUpdateOptions
209
221
  | InternalManualUpdateOptions;
210
222
 
223
+ type RequestAnimationFrame = (callback: (timestamp: number) => void) => number;
224
+
225
+ const waitForNextFrame = () =>
226
+ new Promise<void>((resolve) => {
227
+ const requestAnimationFrame = (
228
+ globalThis as typeof globalThis & {
229
+ requestAnimationFrame?: RequestAnimationFrame;
230
+ }
231
+ )?.requestAnimationFrame;
232
+
233
+ if (requestAnimationFrame) {
234
+ requestAnimationFrame(() => resolve());
235
+ return;
236
+ }
237
+
238
+ void Promise.resolve().then(resolve);
239
+ });
240
+
211
241
  /**
212
242
  * Helper function to handle notifyAppReady flow
213
243
  */
@@ -217,6 +247,8 @@ const handleNotifyAppReady = async (options: {
217
247
  requestTimeout?: number;
218
248
  onNotifyAppReady?: (result: NotifyAppReadyResult) => void;
219
249
  }): Promise<void> => {
250
+ await waitForNextFrame();
251
+
220
252
  try {
221
253
  const nativeResult = nativeNotifyAppReady();
222
254
 
@@ -240,6 +272,10 @@ const handleNotifyAppReady = async (options: {
240
272
  }
241
273
  };
242
274
 
275
+ export function init(options: InternalInitOptions): void {
276
+ void handleNotifyAppReady(options);
277
+ }
278
+
243
279
  export function wrap<P extends React.JSX.IntrinsicAttributes = object>(
244
280
  options: InternalWrapOptions,
245
281
  ): (WrappedComponent: React.ComponentType<P>) => React.ComponentType<P> {