@rific/updater 0.2.4 → 0.3.1

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. Silently stages updates in the background when the app is foregrounded, 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 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.
4
4
 
5
5
  ---
6
6
 
@@ -24,12 +24,13 @@ import { useUpdater } from '@rific/updater'
24
24
  const { check, checking, updateReady } = useUpdater()
25
25
  ```
26
26
 
27
- Call `check()` from a "Check for Updates" button. The `updateReady` flag goes `true` after a silent background fetchuse it to show a badge on your settings icon.
27
+ By default this is enough — a foreground fetch that finds an update shows the confirm dialog on its own, no button needed. `check()` is there for an explicit "Check for Updates" button/menu item. Pair it with `autoPrompt: false` (see below) if you'd rather have foreground fetches stage silently and only prompt from that button then `updateReady` going `true` is your cue to show a badge on it.
28
28
 
29
- ### Settings screen
29
+ ### Settings screen with a persistent "update ready" badge
30
30
 
31
31
  ```tsx
32
32
  const { check, checking, updateReady } = useUpdater({
33
+ autoPrompt: false,
33
34
  onError: (msg) => toast(msg),
34
35
  })
35
36
 
@@ -41,6 +42,8 @@ const { check, checking, updateReady } = useUpdater({
41
42
  />
42
43
  ```
43
44
 
45
+ `autoPrompt: false` is what makes `updateReady` a useful, persistent badge signal here — foreground fetches stage silently instead of immediately consuming the manifest into a dialog, so it stays `true` until the user taps through `check()`.
46
+
44
47
  ### With a custom confirm dialog
45
48
 
46
49
  ```tsx
@@ -52,12 +55,22 @@ const { check, checking } = useUpdater({
52
55
  })
53
56
  ```
54
57
 
55
- ### Disable automatic foreground check
58
+ ### Disable automatic foreground check entirely
56
59
 
57
60
  ```tsx
58
61
  const { check, checking } = useUpdater({ autoCheck: false })
59
62
  ```
60
63
 
64
+ Fully manual — no `AppState` listener at all, `check()` always fetches fresh. `autoPrompt` is irrelevant here.
65
+
66
+ ### Silent background staging only (no auto-prompt)
67
+
68
+ ```tsx
69
+ const { check, checking, updateReady } = useUpdater({ autoPrompt: false })
70
+ ```
71
+
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.
73
+
61
74
  ---
62
75
 
63
76
  ## API
@@ -67,6 +80,7 @@ const { check, checking } = useUpdater({ autoCheck: false })
67
80
  ```ts
68
81
  interface UseUpdaterOptions {
69
82
  autoCheck?: boolean // default: true
83
+ autoPrompt?: boolean // default: true
70
84
  onConfirm?: (manifest: UpdateManifest) => Promise<boolean>
71
85
  onError?: (message: string) => void
72
86
  }
@@ -80,21 +94,22 @@ interface UseUpdaterReturn {
80
94
 
81
95
  | Option | Default | Description |
82
96
  |--------|---------|-------------|
83
- | `autoCheck` | `true` | Registers an `AppState` listener that silently fetches available updates whenever the app comes to the foreground. Disable for games or apps that want full manual control. |
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. |
84
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. |
85
100
  | `onError` | — | Called with an error message string if `check()` throws. Defaults to `Alert.alert`. |
86
101
 
87
102
  | Return | Description |
88
103
  |--------|-------------|
89
- | `check()` | Manual update check. Shows a dev/web guard alert if unsupported. If a background fetch already staged an update, uses that manifest directly (no extra network call). Clears `updateReady` on completion regardless of whether the user confirmed. |
90
- | `checking` | `true` while `check()` is in flight. Safe to drive a loading spinner. Concurrent calls are ignored via a ref guard. |
91
- | `updateReady` | `true` after the background fetch successfully staged an update. Cleared when `check()` completes. Use to show a badge on a settings button. |
104
+ | `check()` | Manual update check. Shows a dev/web guard alert if unsupported. If a background fetch already staged an update (`autoPrompt: false`), uses that manifest directly (no extra network call). Clears `updateReady` on completion regardless of whether the user confirmed. |
105
+ | `checking` | `true` while `check()` — or an `autoPrompt` auto-prompt — is in flight. Safe to drive a loading spinner. Concurrent calls are ignored via a ref guard. |
106
+ | `updateReady` | `true` once a fetch has staged an update. With the default `autoPrompt: true` this is transient (cleared as soon as the dialog resolves); with `autoPrompt: false` it persists until `check()` runs, so it's the useful signal for a settings badge there. |
92
107
 
93
108
  ---
94
109
 
95
110
  ## How updates work
96
111
 
97
- **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()` silently. The downloaded bundle sits on disk — no prompt, no restart. The **next cold launch** automatically runs it.
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.
98
113
 
99
114
  **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.
100
115
 
@@ -140,6 +155,7 @@ The path argument defaults to `src/constants/release.ts` if omitted.
140
155
 
141
156
  - Named `@rific/updater` (not `expo-updater`) to avoid confusion with the `expo-updates` peer dependency
142
157
  - `check()` uses a ref guard (`checkingRef`) rather than the `checking` state to prevent concurrent calls — state batching means a second call could see stale `false` before the first render commits
158
+ - `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
143
159
  - `updateReady` and the staged manifest ref are cleared in `finally` so they reset on both confirm and cancel
144
160
  - `onConfirm` replaces the default `Alert` entirely — useful in apps that have their own dialog primitive (e.g. a `select()` utility or bottom sheet)
145
161
  - No Provider or context required — the hook is self-contained
@@ -148,9 +164,12 @@ The path argument defaults to `src/constants/release.ts` if omitted.
148
164
 
149
165
  ## Consuming apps
150
166
 
151
- - **Lumber** (`../Lumber`)account screen, shows version + update badge
152
- - **CashierFu-Utility** (`../CashierFu-Utility`) — settings modal, uses `@rific/toaster` for `onError`
153
- - Games (Setter, Hangman, Crumby, HexFleet, etc.) use `autoCheck: true`, no manual check needed
167
+ > **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).
168
+
169
+ - **Lumber** (`../Lumber`) account screen, shows version + update badge. Root layout's bare `useUpdater()` will start auto-prompting on upgrade unless changed.
170
+ - **CashierFu-Utility** (`../CashierFu-Utility`) — settings modal, uses `@rific/toaster` for `onError`. Same root-layout caveat as Lumber.
171
+ - **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`.
154
173
 
155
174
  ### Local development (yalc)
156
175
 
package/dist/index.d.mts CHANGED
@@ -5,6 +5,7 @@ interface UpdateManifest {
5
5
 
6
6
  interface UseUpdaterOptions {
7
7
  autoCheck?: boolean;
8
+ autoPrompt?: boolean;
8
9
  onConfirm?: (manifest: UpdateManifest) => Promise<boolean>;
9
10
  onError?: (message: string) => void;
10
11
  }
package/dist/index.d.ts CHANGED
@@ -5,6 +5,7 @@ interface UpdateManifest {
5
5
 
6
6
  interface UseUpdaterOptions {
7
7
  autoCheck?: boolean;
8
+ autoPrompt?: boolean;
8
9
  onConfirm?: (manifest: UpdateManifest) => Promise<boolean>;
9
10
  onError?: (message: string) => void;
10
11
  }
package/dist/index.js CHANGED
@@ -58,20 +58,40 @@ Message: ${manifest.metadata.message}.`;
58
58
  // src/useUpdater.ts
59
59
  var isUnsupported = () => __DEV__ || import_react_native2.Platform.OS === "web";
60
60
  var useUpdater = (options = {}) => {
61
- const { autoCheck = true, onConfirm, onError } = options;
61
+ const { autoCheck = true, autoPrompt = true, onConfirm, onError } = options;
62
62
  const [checking, setChecking] = (0, import_react.useState)(false);
63
63
  const [updateReady, setUpdateReady] = (0, import_react.useState)(false);
64
64
  const checkingRef = (0, import_react.useRef)(false);
65
65
  const appState = (0, import_react.useRef)(import_react_native2.AppState.currentState);
66
66
  const stagedManifest = (0, import_react.useRef)(null);
67
+ const onConfirmRef = (0, import_react.useRef)(onConfirm);
68
+ const onErrorRef = (0, import_react.useRef)(onError);
69
+ onConfirmRef.current = onConfirm;
70
+ onErrorRef.current = onError;
67
71
  (0, import_react.useEffect)(() => {
68
72
  if (!autoCheck || isUnsupported()) return;
69
73
  const subscription = import_react_native2.AppState.addEventListener("change", (nextState) => {
70
74
  if (/inactive|background/.test(appState.current) && nextState === "active") {
71
- checkForUpdate().then((manifest) => {
72
- if (manifest) {
73
- stagedManifest.current = manifest;
74
- setUpdateReady(true);
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);
75
95
  }
76
96
  }).catch(() => {
77
97
  });
@@ -79,7 +99,7 @@ var useUpdater = (options = {}) => {
79
99
  appState.current = nextState;
80
100
  });
81
101
  return () => subscription.remove();
82
- }, [autoCheck]);
102
+ }, [autoCheck, autoPrompt]);
83
103
  const check = async () => {
84
104
  if (__DEV__) {
85
105
  import_react_native2.Alert.alert("Updates unavailable", "Update checks are disabled in development mode.");
package/dist/index.mjs CHANGED
@@ -32,20 +32,40 @@ Message: ${manifest.metadata.message}.`;
32
32
  // src/useUpdater.ts
33
33
  var isUnsupported = () => __DEV__ || Platform.OS === "web";
34
34
  var useUpdater = (options = {}) => {
35
- const { autoCheck = true, onConfirm, onError } = options;
35
+ const { autoCheck = true, autoPrompt = true, onConfirm, onError } = options;
36
36
  const [checking, setChecking] = useState(false);
37
37
  const [updateReady, setUpdateReady] = useState(false);
38
38
  const checkingRef = useRef(false);
39
39
  const appState = useRef(AppState.currentState);
40
40
  const stagedManifest = useRef(null);
41
+ const onConfirmRef = useRef(onConfirm);
42
+ const onErrorRef = useRef(onError);
43
+ onConfirmRef.current = onConfirm;
44
+ onErrorRef.current = onError;
41
45
  useEffect(() => {
42
46
  if (!autoCheck || isUnsupported()) return;
43
47
  const subscription = AppState.addEventListener("change", (nextState) => {
44
48
  if (/inactive|background/.test(appState.current) && nextState === "active") {
45
- checkForUpdate().then((manifest) => {
46
- if (manifest) {
47
- stagedManifest.current = manifest;
48
- setUpdateReady(true);
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);
49
69
  }
50
70
  }).catch(() => {
51
71
  });
@@ -53,7 +73,7 @@ var useUpdater = (options = {}) => {
53
73
  appState.current = nextState;
54
74
  });
55
75
  return () => subscription.remove();
56
- }, [autoCheck]);
76
+ }, [autoCheck, autoPrompt]);
57
77
  const check = async () => {
58
78
  if (__DEV__) {
59
79
  Alert2.alert("Updates unavailable", "Update checks are disabled in development mode.");
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rific/updater",
3
- "version": "0.2.4",
4
- "description": "OTA update hook for Expo apps silent background fetch on foreground, manual check with confirmation dialog",
3
+ "version": "0.3.1",
4
+ "description": "OTA update hook for Expo apps. Silent background fetch on foreground, manual check with confirmation dialog",
5
5
  "keywords": [
6
6
  "expo",
7
7
  "expo-updates",
File without changes
package/src/useUpdater.ts CHANGED
@@ -8,6 +8,7 @@ import { UpdateManifest } from './types'
8
8
 
9
9
  export interface UseUpdaterOptions {
10
10
  autoCheck?: boolean
11
+ autoPrompt?: boolean
11
12
  onConfirm?: (manifest: UpdateManifest) => Promise<boolean>
12
13
  onError?: (message: string) => void
13
14
  }
@@ -21,12 +22,18 @@ export interface UseUpdaterReturn {
21
22
  const isUnsupported = () => __DEV__ || Platform.OS === 'web'
22
23
 
23
24
  export const useUpdater = (options: UseUpdaterOptions = {}): UseUpdaterReturn => {
24
- const { autoCheck = true, onConfirm, onError } = options
25
+ const { autoCheck = true, autoPrompt = true, onConfirm, onError } = options
25
26
  const [checking, setChecking] = useState(false)
26
27
  const [updateReady, setUpdateReady] = useState(false)
27
28
  const checkingRef = useRef(false)
28
29
  const appState = useRef(AppState.currentState)
29
30
  const stagedManifest = useRef<UpdateManifest | null>(null)
31
+ // Read via refs inside the AppState listener so onConfirm/onError identity changes (e.g. an
32
+ // inline arrow function) don't tear down and re-subscribe the listener on every render.
33
+ const onConfirmRef = useRef(onConfirm)
34
+ const onErrorRef = useRef(onError)
35
+ onConfirmRef.current = onConfirm
36
+ onErrorRef.current = onError
30
37
 
31
38
  useEffect(() => {
32
39
  if (!autoCheck || isUnsupported()) return
@@ -34,10 +41,27 @@ export const useUpdater = (options: UseUpdaterOptions = {}): UseUpdaterReturn =>
34
41
  const subscription = AppState.addEventListener('change', (nextState: AppStateStatus) => {
35
42
  if (/inactive|background/.test(appState.current) && nextState === 'active') {
36
43
  checkForUpdate()
37
- .then((manifest) => {
38
- if (manifest) {
39
- stagedManifest.current = manifest
40
- setUpdateReady(true)
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)
41
65
  }
42
66
  })
43
67
  .catch(() => {})
@@ -46,7 +70,7 @@ export const useUpdater = (options: UseUpdaterOptions = {}): UseUpdaterReturn =>
46
70
  })
47
71
 
48
72
  return () => subscription.remove()
49
- }, [autoCheck])
73
+ }, [autoCheck, autoPrompt])
50
74
 
51
75
  const check = async (): Promise<void> => {
52
76
  if (__DEV__) {