@hot-updater/react-native 0.30.7 → 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.
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,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> {