@rific/updater 0.2.3 → 0.3.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 +31 -12
- package/dist/index.d.mts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +26 -6
- package/dist/index.mjs +26 -6
- package/package.json +5 -4
- package/src/useUpdater.ts +30 -6
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @rific/updater
|
|
2
2
|
|
|
3
|
-
OTA update hook for Expo apps.
|
|
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
|
-
|
|
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
|
|
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`
|
|
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()`
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
-
|
|
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
package/dist/index.d.ts
CHANGED
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rific/updater",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"type": "commonjs",
|
|
24
24
|
"exports": {
|
|
25
25
|
".": {
|
|
26
|
-
"react-native": "./
|
|
26
|
+
"react-native": "./src/index.ts",
|
|
27
27
|
"types": "./dist/index.d.ts",
|
|
28
28
|
"import": "./dist/index.mjs",
|
|
29
29
|
"require": "./dist/index.js"
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"main": "dist/index.js",
|
|
33
33
|
"module": "dist/index.mjs",
|
|
34
|
+
"react-native": "src/index.ts",
|
|
34
35
|
"types": "dist/index.d.ts",
|
|
35
36
|
"bin": {
|
|
36
37
|
"rific-bump-ota": "./scripts/bump-ota-version.mjs"
|
|
@@ -80,8 +81,8 @@
|
|
|
80
81
|
"typescript-eslint": "^8.59.3"
|
|
81
82
|
},
|
|
82
83
|
"peerDependencies": {
|
|
83
|
-
"expo-updates": ">=0.
|
|
84
|
-
"react": ">=
|
|
84
|
+
"expo-updates": ">=57.0.0",
|
|
85
|
+
"react": ">=19.0.0",
|
|
85
86
|
"react-native": ">=0.76.0"
|
|
86
87
|
},
|
|
87
88
|
"publishConfig": {
|
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
|
-
|
|
40
|
-
|
|
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__) {
|