@rific/updater 0.3.1 → 0.4.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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @rific/updater
2
2
 
3
- OTA update hook for Expo apps. Checks for updates in the background when the app is foregrounded and prompts to restart as soon as one's found, and exposes a manual `check()` for settings screens. No surprise restarts — the user always confirms before the app reloads.
3
+ OTA update hook for Expo apps. Checks for updates on launch and again whenever the app is foregrounded, prompting to restart as soon as one's found, and exposes a manual `check()` for settings screens. No surprise restarts — the user always confirms before the app reloads.
4
4
 
5
5
  ---
6
6
 
@@ -55,13 +55,23 @@ const { check, checking } = useUpdater({
55
55
  })
56
56
  ```
57
57
 
58
- ### Disable automatic foreground check entirely
58
+ ### With custom info alerts
59
+
60
+ ```tsx
61
+ const { check, checking } = useUpdater({
62
+ onInfo: (title, message) => myInfoDialog(title, message),
63
+ })
64
+ ```
65
+
66
+ Covers `check()`'s three purely-informational cases — dev-mode disabled, web unsupported, and "you're already up to date" — none of which need a confirm/cancel choice, just something to acknowledge. Defaults to `Alert.alert` like `onError`.
67
+
68
+ ### Disable automatic mount/foreground checks entirely
59
69
 
60
70
  ```tsx
61
71
  const { check, checking } = useUpdater({ autoCheck: false })
62
72
  ```
63
73
 
64
- Fully manual — no `AppState` listener at all, `check()` always fetches fresh. `autoPrompt` is irrelevant here.
74
+ Fully manual — no mount-time fetch, no `AppState` listener, `check()` always fetches fresh. `autoPrompt` is irrelevant here.
65
75
 
66
76
  ### Silent background staging only (no auto-prompt)
67
77
 
@@ -69,7 +79,7 @@ Fully manual — no `AppState` listener at all, `check()` always fetches fresh.
69
79
  const { check, checking, updateReady } = useUpdater({ autoPrompt: false })
70
80
  ```
71
81
 
72
- Foreground fetches still run and stage the update (`updateReady` flips `true`), but the confirm dialog only shows up via a manual `check()` — same as the settings-screen example above. Good for games or anything where you don't want a dialog interrupting the user; the staged bundle still applies on the next cold launch even if `check()` is never called.
82
+ Mount and foreground fetches still run and stage the update (`updateReady` flips `true`), but the confirm dialog only shows up via a manual `check()` — same as the settings-screen example above. Good for games or anything where you don't want a dialog interrupting the user.
73
83
 
74
84
  ---
75
85
 
@@ -83,6 +93,7 @@ interface UseUpdaterOptions {
83
93
  autoPrompt?: boolean // default: true
84
94
  onConfirm?: (manifest: UpdateManifest) => Promise<boolean>
85
95
  onError?: (message: string) => void
96
+ onInfo?: (title: string, message: string) => void
86
97
  }
87
98
 
88
99
  interface UseUpdaterReturn {
@@ -94,10 +105,11 @@ interface UseUpdaterReturn {
94
105
 
95
106
  | Option | Default | Description |
96
107
  |--------|---------|-------------|
97
- | `autoCheck` | `true` | Registers an `AppState` listener that fetches available updates whenever the app comes to the foreground. Disable for apps that want full manual control. |
98
- | `autoPrompt` | `true` | When a foreground `autoCheck` fetch finds an update, run the confirmation dialog (and reload on confirm) immediately. Set `false` to fall back to the old behavior — silently stage it for a manual `check()` or the next cold launch instead. Ignored if `autoCheck` is `false`. A manual `check()` call and an auto-prompt won't run concurrently — whichever is in flight blocks the other. |
99
- | `onConfirm` | — | Custom confirmation dialog. Receives the update manifest, must return `Promise<boolean>` — `true` to reload, `false` to cancel. Defaults to a native `Alert` showing the release date and metadata message. |
108
+ | `autoCheck` | `true` | Fetches available updates once on mount and again via an `AppState` listener whenever the app comes to the foreground. Disable for apps that want full manual control. |
109
+ | `autoPrompt` | `true` | When a mount or foreground `autoCheck` fetch finds an update, run the confirmation dialog (and reload on confirm) immediately. Set `false` to fall back to the old behavior — silently stage it for a manual `check()` or the next cold launch instead. Ignored if `autoCheck` is `false`. A manual `check()` call and an auto-prompt won't run concurrently — whichever is in flight blocks the other. |
110
+ | `onConfirm` | — | Custom confirmation dialog. Receives the update manifest, must return `Promise<boolean>` — `true` to reload, `false` to cancel. Defaults to a native `Alert` showing the release date. |
100
111
  | `onError` | — | Called with an error message string if `check()` throws. Defaults to `Alert.alert`. |
112
+ | `onInfo` | — | Called with `(title, message)` for `check()`'s purely-informational cases (dev-mode disabled, web unsupported, no update found) — no confirm/cancel choice, just an acknowledgeable message. Defaults to `Alert.alert`. |
101
113
 
102
114
  | Return | Description |
103
115
  |--------|-------------|
@@ -109,7 +121,7 @@ interface UseUpdaterReturn {
109
121
 
110
122
  ## How updates work
111
123
 
112
- **Automatic (foreground):** When `autoCheck` is `true`, the hook registers an `AppState` listener. Each time the app returns from background/inactive to active, it calls `checkForUpdateAsync()` + `fetchUpdateAsync()`. By default (`autoPrompt: true`) a found update goes straight into the confirmation dialog and `reloadAsync()` on confirm — no tap required. With `autoPrompt: false`, the downloaded bundle just sits on disk instead — no prompt, no restart — until a manual `check()` or the **next cold launch**, which automatically runs it.
124
+ **Automatic (mount + foreground):** When `autoCheck` is `true`, the hook fetches once on mount (covering cold launch) and also registers an `AppState` listener that re-fetches each time the app returns from background/inactive to active. Both paths call `checkForUpdateAsync()` + `fetchUpdateAsync()` and share the same confirm/reload flow. By default (`autoPrompt: true`) a found update goes straight into the confirmation dialog and `reloadAsync()` on confirm — no tap required. With `autoPrompt: false`, the downloaded bundle just sits on disk instead — no prompt, no restart — until a manual `check()` is called.
113
125
 
114
126
  **Manual (`check()`):** Runs the full flow — check (or reuse staged manifest) → confirmation dialog → `reloadAsync()`. The user sees what was released and chooses whether to restart now.
115
127
 
@@ -158,6 +170,7 @@ The path argument defaults to `src/constants/release.ts` if omitted.
158
170
  - `autoPrompt`'s foreground flow shares that same `checkingRef` guard with `check()`, so a manual check and an auto-prompt can't both be mid-confirm at once
159
171
  - `updateReady` and the staged manifest ref are cleared in `finally` so they reset on both confirm and cancel
160
172
  - `onConfirm` replaces the default `Alert` entirely — useful in apps that have their own dialog primitive (e.g. a `select()` utility or bottom sheet)
173
+ - `onInfo` is the same idea for `check()`'s three non-choice messages (dev-mode disabled, web unsupported, no update found) — separate from `onConfirm` because there's nothing to confirm, just something to show
161
174
  - No Provider or context required — the hook is self-contained
162
175
 
163
176
  ---
@@ -165,11 +178,13 @@ The path argument defaults to `src/constants/release.ts` if omitted.
165
178
  ## Consuming apps
166
179
 
167
180
  > **0.3.0 changed the default:** `autoPrompt` now defaults to `true`, so a bare `useUpdater()` prompts on its own the moment a foreground fetch finds something — it no longer just stages silently for next launch. Every app below was written against the old silent-by-default behavior; pass `autoPrompt: false` explicitly if that's still what you want (this is what Lumber's and CashierFu-Utility's manual-check hooks already do via `autoCheck: false`, so they're unaffected — it's the bare root-layout `useUpdater()` calls and the games that actually change behavior on upgrade).
181
+ >
182
+ > **Next release adds a mount-time check:** `autoCheck` now also fetches once on mount, in addition to the existing foreground `AppState` listener — covering cold launch, which previously only got an update via native `expo-updates` (`checkAutomatically`), silently and outside this hook's confirm/reload flow. Any app with a bare root-layout `useUpdater()` (default `autoPrompt: true`) will now show the confirm dialog on cold launch too, not just on foreground return.
168
183
 
169
184
  - **Lumber** (`../Lumber`) — account screen, shows version + update badge. Root layout's bare `useUpdater()` will start auto-prompting on upgrade unless changed.
170
185
  - **CashierFu-Utility** (`../CashierFu-Utility`) — settings modal, uses `@rific/toaster` for `onError`. Same root-layout caveat as Lumber.
171
186
  - **Swirlio** (`../Swirlio`) — top sheet; now just relies on the `autoPrompt` default rather than passing it explicitly.
172
- - Games (Setter, Hangman, Crumby, HexFleet, etc.) — call `useUpdater()` with no options, relying on the old silent-only default. Will start prompting on foreground return (not during active play — the listener only fires on a background→active transition) unless given `autoPrompt: false`.
187
+ - Games (Setter, Hangman, Crumby, HexFleet, etc.) — call `useUpdater()` with no options, relying on the old silent-only default. Will start prompting on cold launch and on foreground return (not during active play — the listener only fires on a background→active transition) unless given `autoPrompt: false`.
173
188
 
174
189
  ### Local development (yalc)
175
190
 
package/dist/index.d.mts CHANGED
@@ -1,6 +1,5 @@
1
1
  interface UpdateManifest {
2
2
  createdAt: string;
3
- metadata?: Record<string, string | undefined>;
4
3
  }
5
4
 
6
5
  interface UseUpdaterOptions {
@@ -8,6 +7,7 @@ interface UseUpdaterOptions {
8
7
  autoPrompt?: boolean;
9
8
  onConfirm?: (manifest: UpdateManifest) => Promise<boolean>;
10
9
  onError?: (message: string) => void;
10
+ onInfo?: (title: string, message: string) => void;
11
11
  }
12
12
  interface UseUpdaterReturn {
13
13
  check: () => Promise<void>;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  interface UpdateManifest {
2
2
  createdAt: string;
3
- metadata?: Record<string, string | undefined>;
4
3
  }
5
4
 
6
5
  interface UseUpdaterOptions {
@@ -8,6 +7,7 @@ interface UseUpdaterOptions {
8
7
  autoPrompt?: boolean;
9
8
  onConfirm?: (manifest: UpdateManifest) => Promise<boolean>;
10
9
  onError?: (message: string) => void;
10
+ onInfo?: (title: string, message: string) => void;
11
11
  }
12
12
  interface UseUpdaterReturn {
13
13
  check: () => Promise<void>;
package/dist/index.js CHANGED
@@ -42,11 +42,9 @@ var checkForUpdate = async () => {
42
42
  var import_react_native = require("react-native");
43
43
  var getUpdateConfirmation = (manifest) => {
44
44
  const date = new Date(manifest.createdAt);
45
- let info = `A new update was released on ${date.toLocaleDateString()} at ${date.toLocaleTimeString()}.`;
46
- if (manifest.metadata?.message) info += `
45
+ const info = `A new update was released on ${date.toLocaleDateString()} at ${date.toLocaleTimeString()}.
47
46
 
48
- Message: ${manifest.metadata.message}.`;
49
- info += "\n\nRestart app to update.";
47
+ Restart app to update.`;
50
48
  return new Promise((resolve) => {
51
49
  import_react_native.Alert.alert("Update available", info, [
52
50
  { text: "Cancel", style: "cancel", onPress: () => resolve(false) },
@@ -58,43 +56,54 @@ Message: ${manifest.metadata.message}.`;
58
56
  // src/useUpdater.ts
59
57
  var isUnsupported = () => __DEV__ || import_react_native2.Platform.OS === "web";
60
58
  var useUpdater = (options = {}) => {
61
- const { autoCheck = true, autoPrompt = true, onConfirm, onError } = options;
59
+ const { autoCheck = true, autoPrompt = true, onConfirm, onError, onInfo } = options;
62
60
  const [checking, setChecking] = (0, import_react.useState)(false);
63
61
  const [updateReady, setUpdateReady] = (0, import_react.useState)(false);
64
62
  const checkingRef = (0, import_react.useRef)(false);
63
+ const autoCheckPendingRef = (0, import_react.useRef)(false);
65
64
  const appState = (0, import_react.useRef)(import_react_native2.AppState.currentState);
66
65
  const stagedManifest = (0, import_react.useRef)(null);
67
66
  const onConfirmRef = (0, import_react.useRef)(onConfirm);
68
67
  const onErrorRef = (0, import_react.useRef)(onError);
69
- onConfirmRef.current = onConfirm;
70
- onErrorRef.current = onError;
68
+ (0, import_react.useEffect)(() => {
69
+ onConfirmRef.current = onConfirm;
70
+ onErrorRef.current = onError;
71
+ });
71
72
  (0, import_react.useEffect)(() => {
72
73
  if (!autoCheck || isUnsupported()) return;
74
+ const runAutoCheck = () => {
75
+ if (autoCheckPendingRef.current) return;
76
+ autoCheckPendingRef.current = true;
77
+ checkForUpdate().then(async (manifest) => {
78
+ if (!manifest) return;
79
+ stagedManifest.current = manifest;
80
+ setUpdateReady(true);
81
+ if (!autoPrompt || checkingRef.current) return;
82
+ checkingRef.current = true;
83
+ setChecking(true);
84
+ try {
85
+ const confirmFn = onConfirmRef.current ?? getUpdateConfirmation;
86
+ const confirmed = await confirmFn(manifest);
87
+ if (confirmed) await (0, import_expo_updates2.reloadAsync)();
88
+ } catch (err) {
89
+ const message = err instanceof Error ? err.message : "Could not check for updates.";
90
+ if (onErrorRef.current) onErrorRef.current(message);
91
+ else import_react_native2.Alert.alert("Update error", message);
92
+ } finally {
93
+ stagedManifest.current = null;
94
+ setUpdateReady(false);
95
+ checkingRef.current = false;
96
+ setChecking(false);
97
+ }
98
+ }).catch(() => {
99
+ }).finally(() => {
100
+ autoCheckPendingRef.current = false;
101
+ });
102
+ };
103
+ runAutoCheck();
73
104
  const subscription = import_react_native2.AppState.addEventListener("change", (nextState) => {
74
105
  if (/inactive|background/.test(appState.current) && nextState === "active") {
75
- checkForUpdate().then(async (manifest) => {
76
- if (!manifest) return;
77
- stagedManifest.current = manifest;
78
- setUpdateReady(true);
79
- if (!autoPrompt || checkingRef.current) return;
80
- checkingRef.current = true;
81
- setChecking(true);
82
- try {
83
- const confirmFn = onConfirmRef.current ?? getUpdateConfirmation;
84
- const confirmed = await confirmFn(manifest);
85
- if (confirmed) await (0, import_expo_updates2.reloadAsync)();
86
- } catch (err) {
87
- const message = err instanceof Error ? err.message : "Could not check for updates.";
88
- if (onErrorRef.current) onErrorRef.current(message);
89
- else import_react_native2.Alert.alert("Update error", message);
90
- } finally {
91
- stagedManifest.current = null;
92
- setUpdateReady(false);
93
- checkingRef.current = false;
94
- setChecking(false);
95
- }
96
- }).catch(() => {
97
- });
106
+ runAutoCheck();
98
107
  }
99
108
  appState.current = nextState;
100
109
  });
@@ -102,11 +111,15 @@ var useUpdater = (options = {}) => {
102
111
  }, [autoCheck, autoPrompt]);
103
112
  const check = async () => {
104
113
  if (__DEV__) {
105
- import_react_native2.Alert.alert("Updates unavailable", "Update checks are disabled in development mode.");
114
+ const message = "Update checks are disabled in development mode.";
115
+ if (onInfo) onInfo("Updates unavailable", message);
116
+ else import_react_native2.Alert.alert("Updates unavailable", message);
106
117
  return;
107
118
  }
108
119
  if (import_react_native2.Platform.OS === "web") {
109
- import_react_native2.Alert.alert("Updates unavailable", "Update checks are not supported on web.");
120
+ const message = "Update checks are not supported on web.";
121
+ if (onInfo) onInfo("Updates unavailable", message);
122
+ else import_react_native2.Alert.alert("Updates unavailable", message);
110
123
  return;
111
124
  }
112
125
  if (checkingRef.current) return;
@@ -115,7 +128,9 @@ var useUpdater = (options = {}) => {
115
128
  try {
116
129
  const manifest = stagedManifest.current ?? await checkForUpdate();
117
130
  if (!manifest) {
118
- import_react_native2.Alert.alert("No update", "You are on the most recent version.");
131
+ const message = "You are on the most recent version.";
132
+ if (onInfo) onInfo("No update", message);
133
+ else import_react_native2.Alert.alert("No update", message);
119
134
  return;
120
135
  }
121
136
  const confirmFn = onConfirm ?? getUpdateConfirmation;
package/dist/index.mjs CHANGED
@@ -16,11 +16,9 @@ var checkForUpdate = async () => {
16
16
  import { Alert } from "react-native";
17
17
  var getUpdateConfirmation = (manifest) => {
18
18
  const date = new Date(manifest.createdAt);
19
- let info = `A new update was released on ${date.toLocaleDateString()} at ${date.toLocaleTimeString()}.`;
20
- if (manifest.metadata?.message) info += `
19
+ const info = `A new update was released on ${date.toLocaleDateString()} at ${date.toLocaleTimeString()}.
21
20
 
22
- Message: ${manifest.metadata.message}.`;
23
- info += "\n\nRestart app to update.";
21
+ Restart app to update.`;
24
22
  return new Promise((resolve) => {
25
23
  Alert.alert("Update available", info, [
26
24
  { text: "Cancel", style: "cancel", onPress: () => resolve(false) },
@@ -32,43 +30,54 @@ Message: ${manifest.metadata.message}.`;
32
30
  // src/useUpdater.ts
33
31
  var isUnsupported = () => __DEV__ || Platform.OS === "web";
34
32
  var useUpdater = (options = {}) => {
35
- const { autoCheck = true, autoPrompt = true, onConfirm, onError } = options;
33
+ const { autoCheck = true, autoPrompt = true, onConfirm, onError, onInfo } = options;
36
34
  const [checking, setChecking] = useState(false);
37
35
  const [updateReady, setUpdateReady] = useState(false);
38
36
  const checkingRef = useRef(false);
37
+ const autoCheckPendingRef = useRef(false);
39
38
  const appState = useRef(AppState.currentState);
40
39
  const stagedManifest = useRef(null);
41
40
  const onConfirmRef = useRef(onConfirm);
42
41
  const onErrorRef = useRef(onError);
43
- onConfirmRef.current = onConfirm;
44
- onErrorRef.current = onError;
42
+ useEffect(() => {
43
+ onConfirmRef.current = onConfirm;
44
+ onErrorRef.current = onError;
45
+ });
45
46
  useEffect(() => {
46
47
  if (!autoCheck || isUnsupported()) return;
48
+ const runAutoCheck = () => {
49
+ if (autoCheckPendingRef.current) return;
50
+ autoCheckPendingRef.current = true;
51
+ checkForUpdate().then(async (manifest) => {
52
+ if (!manifest) return;
53
+ stagedManifest.current = manifest;
54
+ setUpdateReady(true);
55
+ if (!autoPrompt || checkingRef.current) return;
56
+ checkingRef.current = true;
57
+ setChecking(true);
58
+ try {
59
+ const confirmFn = onConfirmRef.current ?? getUpdateConfirmation;
60
+ const confirmed = await confirmFn(manifest);
61
+ if (confirmed) await reloadAsync();
62
+ } catch (err) {
63
+ const message = err instanceof Error ? err.message : "Could not check for updates.";
64
+ if (onErrorRef.current) onErrorRef.current(message);
65
+ else Alert2.alert("Update error", message);
66
+ } finally {
67
+ stagedManifest.current = null;
68
+ setUpdateReady(false);
69
+ checkingRef.current = false;
70
+ setChecking(false);
71
+ }
72
+ }).catch(() => {
73
+ }).finally(() => {
74
+ autoCheckPendingRef.current = false;
75
+ });
76
+ };
77
+ runAutoCheck();
47
78
  const subscription = AppState.addEventListener("change", (nextState) => {
48
79
  if (/inactive|background/.test(appState.current) && nextState === "active") {
49
- checkForUpdate().then(async (manifest) => {
50
- if (!manifest) return;
51
- stagedManifest.current = manifest;
52
- setUpdateReady(true);
53
- if (!autoPrompt || checkingRef.current) return;
54
- checkingRef.current = true;
55
- setChecking(true);
56
- try {
57
- const confirmFn = onConfirmRef.current ?? getUpdateConfirmation;
58
- const confirmed = await confirmFn(manifest);
59
- if (confirmed) await reloadAsync();
60
- } catch (err) {
61
- const message = err instanceof Error ? err.message : "Could not check for updates.";
62
- if (onErrorRef.current) onErrorRef.current(message);
63
- else Alert2.alert("Update error", message);
64
- } finally {
65
- stagedManifest.current = null;
66
- setUpdateReady(false);
67
- checkingRef.current = false;
68
- setChecking(false);
69
- }
70
- }).catch(() => {
71
- });
80
+ runAutoCheck();
72
81
  }
73
82
  appState.current = nextState;
74
83
  });
@@ -76,11 +85,15 @@ var useUpdater = (options = {}) => {
76
85
  }, [autoCheck, autoPrompt]);
77
86
  const check = async () => {
78
87
  if (__DEV__) {
79
- Alert2.alert("Updates unavailable", "Update checks are disabled in development mode.");
88
+ const message = "Update checks are disabled in development mode.";
89
+ if (onInfo) onInfo("Updates unavailable", message);
90
+ else Alert2.alert("Updates unavailable", message);
80
91
  return;
81
92
  }
82
93
  if (Platform.OS === "web") {
83
- Alert2.alert("Updates unavailable", "Update checks are not supported on web.");
94
+ const message = "Update checks are not supported on web.";
95
+ if (onInfo) onInfo("Updates unavailable", message);
96
+ else Alert2.alert("Updates unavailable", message);
84
97
  return;
85
98
  }
86
99
  if (checkingRef.current) return;
@@ -89,7 +102,9 @@ var useUpdater = (options = {}) => {
89
102
  try {
90
103
  const manifest = stagedManifest.current ?? await checkForUpdate();
91
104
  if (!manifest) {
92
- Alert2.alert("No update", "You are on the most recent version.");
105
+ const message = "You are on the most recent version.";
106
+ if (onInfo) onInfo("No update", message);
107
+ else Alert2.alert("No update", message);
93
108
  return;
94
109
  }
95
110
  const confirmFn = onConfirm ?? getUpdateConfirmation;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rific/updater",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "OTA update hook for Expo apps. Silent background fetch on foreground, manual check with confirmation dialog",
5
5
  "keywords": [
6
6
  "expo",
@@ -67,6 +67,7 @@
67
67
  "eslint-config-prettier": "^10.1.8",
68
68
  "eslint-plugin-package-json": "^1.0.0",
69
69
  "eslint-plugin-prettier": "^5.5.5",
70
+ "eslint-plugin-react-hooks": "^7.1.1",
70
71
  "eslint-plugin-react-native": "^5.0.0",
71
72
  "eslint-plugin-simple-import-sort": "^13.0.0",
72
73
  "expo-updates": ">=0.25.0",
@@ -4,9 +4,7 @@ import { UpdateManifest } from './types'
4
4
 
5
5
  export const getUpdateConfirmation = (manifest: UpdateManifest): Promise<boolean> => {
6
6
  const date = new Date(manifest.createdAt)
7
- let info = `A new update was released on ${date.toLocaleDateString()} at ${date.toLocaleTimeString()}.`
8
- if (manifest.metadata?.message) info += `\n\nMessage: ${manifest.metadata.message}.`
9
- info += '\n\nRestart app to update.'
7
+ const info = `A new update was released on ${date.toLocaleDateString()} at ${date.toLocaleTimeString()}.\n\nRestart app to update.`
10
8
 
11
9
  return new Promise((resolve) => {
12
10
  Alert.alert('Update available', info, [
package/src/types.ts CHANGED
@@ -1,4 +1,3 @@
1
1
  export interface UpdateManifest {
2
2
  createdAt: string
3
- metadata?: Record<string, string | undefined>
4
3
  }
package/src/useUpdater.ts CHANGED
@@ -11,6 +11,11 @@ export interface UseUpdaterOptions {
11
11
  autoPrompt?: boolean
12
12
  onConfirm?: (manifest: UpdateManifest) => Promise<boolean>
13
13
  onError?: (message: string) => void
14
+ // Purely informational — no confirm/cancel choice, just something to acknowledge. Covers the
15
+ // three plain Alert.alert calls inside check() below (dev-mode disabled, web unsupported, and
16
+ // "you're already up to date") — none of them go through onConfirm, since there's no decision
17
+ // to make.
18
+ onInfo?: (title: string, message: string) => void
14
19
  }
15
20
 
16
21
  export interface UseUpdaterReturn {
@@ -22,49 +27,70 @@ export interface UseUpdaterReturn {
22
27
  const isUnsupported = () => __DEV__ || Platform.OS === 'web'
23
28
 
24
29
  export const useUpdater = (options: UseUpdaterOptions = {}): UseUpdaterReturn => {
25
- const { autoCheck = true, autoPrompt = true, onConfirm, onError } = options
30
+ const { autoCheck = true, autoPrompt = true, onConfirm, onError, onInfo } = options
26
31
  const [checking, setChecking] = useState(false)
27
32
  const [updateReady, setUpdateReady] = useState(false)
28
33
  const checkingRef = useRef(false)
34
+ const autoCheckPendingRef = useRef(false)
29
35
  const appState = useRef(AppState.currentState)
30
36
  const stagedManifest = useRef<UpdateManifest | null>(null)
31
37
  // Read via refs inside the AppState listener so onConfirm/onError identity changes (e.g. an
32
38
  // inline arrow function) don't tear down and re-subscribe the listener on every render.
33
39
  const onConfirmRef = useRef(onConfirm)
34
40
  const onErrorRef = useRef(onError)
35
- onConfirmRef.current = onConfirm
36
- onErrorRef.current = onError
41
+
42
+ useEffect(() => {
43
+ onConfirmRef.current = onConfirm
44
+ onErrorRef.current = onError
45
+ })
37
46
 
38
47
  useEffect(() => {
39
48
  if (!autoCheck || isUnsupported()) return
40
49
 
50
+ const runAutoCheck = () => {
51
+ // Guards against overlapping fetches if AppState fires again (e.g. rapid app-switcher
52
+ // transitions) before a prior auto-check has resolved. Independent of checkingRef, which
53
+ // only guards the confirmation prompt and covers manual check() calls too.
54
+ if (autoCheckPendingRef.current) return
55
+ autoCheckPendingRef.current = true
56
+
57
+ checkForUpdate()
58
+ .then(async (manifest) => {
59
+ if (!manifest) return
60
+ stagedManifest.current = manifest
61
+ setUpdateReady(true)
62
+ if (!autoPrompt || checkingRef.current) return
63
+
64
+ checkingRef.current = true
65
+ setChecking(true)
66
+ try {
67
+ const confirmFn = onConfirmRef.current ?? getUpdateConfirmation
68
+ const confirmed = await confirmFn(manifest)
69
+ if (confirmed) await reloadAsync()
70
+ } catch (err) {
71
+ const message = err instanceof Error ? err.message : 'Could not check for updates.'
72
+ if (onErrorRef.current) onErrorRef.current(message)
73
+ else Alert.alert('Update error', message)
74
+ } finally {
75
+ stagedManifest.current = null
76
+ setUpdateReady(false)
77
+ checkingRef.current = false
78
+ setChecking(false)
79
+ }
80
+ })
81
+ .catch(() => {})
82
+ .finally(() => {
83
+ autoCheckPendingRef.current = false
84
+ })
85
+ }
86
+
87
+ // Cold launch: run the same check+prompt flow as a foreground resume so update
88
+ // discovery is consistent regardless of how the app was started.
89
+ runAutoCheck()
90
+
41
91
  const subscription = AppState.addEventListener('change', (nextState: AppStateStatus) => {
42
92
  if (/inactive|background/.test(appState.current) && nextState === 'active') {
43
- checkForUpdate()
44
- .then(async (manifest) => {
45
- if (!manifest) return
46
- stagedManifest.current = manifest
47
- setUpdateReady(true)
48
- if (!autoPrompt || checkingRef.current) return
49
-
50
- checkingRef.current = true
51
- setChecking(true)
52
- try {
53
- const confirmFn = onConfirmRef.current ?? getUpdateConfirmation
54
- const confirmed = await confirmFn(manifest)
55
- if (confirmed) await reloadAsync()
56
- } catch (err) {
57
- const message = err instanceof Error ? err.message : 'Could not check for updates.'
58
- if (onErrorRef.current) onErrorRef.current(message)
59
- else Alert.alert('Update error', message)
60
- } finally {
61
- stagedManifest.current = null
62
- setUpdateReady(false)
63
- checkingRef.current = false
64
- setChecking(false)
65
- }
66
- })
67
- .catch(() => {})
93
+ runAutoCheck()
68
94
  }
69
95
  appState.current = nextState
70
96
  })
@@ -74,11 +100,15 @@ export const useUpdater = (options: UseUpdaterOptions = {}): UseUpdaterReturn =>
74
100
 
75
101
  const check = async (): Promise<void> => {
76
102
  if (__DEV__) {
77
- Alert.alert('Updates unavailable', 'Update checks are disabled in development mode.')
103
+ const message = 'Update checks are disabled in development mode.'
104
+ if (onInfo) onInfo('Updates unavailable', message)
105
+ else Alert.alert('Updates unavailable', message)
78
106
  return
79
107
  }
80
108
  if (Platform.OS === 'web') {
81
- Alert.alert('Updates unavailable', 'Update checks are not supported on web.')
109
+ const message = 'Update checks are not supported on web.'
110
+ if (onInfo) onInfo('Updates unavailable', message)
111
+ else Alert.alert('Updates unavailable', message)
82
112
  return
83
113
  }
84
114
  if (checkingRef.current) return
@@ -87,7 +117,9 @@ export const useUpdater = (options: UseUpdaterOptions = {}): UseUpdaterReturn =>
87
117
  try {
88
118
  const manifest = stagedManifest.current ?? (await checkForUpdate())
89
119
  if (!manifest) {
90
- Alert.alert('No update', 'You are on the most recent version.')
120
+ const message = 'You are on the most recent version.'
121
+ if (onInfo) onInfo('No update', message)
122
+ else Alert.alert('No update', message)
91
123
  return
92
124
  }
93
125
  const confirmFn = onConfirm ?? getUpdateConfirmation