@remit/web-client 0.0.62 → 0.0.63

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.62",
3
+ "version": "0.0.63",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The full-window blocking screen an update takes over.
3
+ *
4
+ * Once consent is given the server is genuinely going away, so a quiet spinner
5
+ * over a mailbox that cannot load would let a broken system look healthy. This
6
+ * mounts at the app root and owns the window for the whole apply, and for the
7
+ * "server never came back" verdict — from any route, so a reload mid-apply or a
8
+ * second tab resumes straight into it.
9
+ */
10
+ import {
11
+ SelfUpdateProgressOverlay,
12
+ SelfUpdateUnreachableScreen,
13
+ } from "@remit/ui";
14
+ import { useSelfUpdate } from "@/hooks/use-system-update";
15
+
16
+ export function SelfUpdateOverlay() {
17
+ const { surface, onRetryConnection } = useSelfUpdate();
18
+
19
+ if (surface.status !== "ready") return null;
20
+ const { overlay } = surface;
21
+
22
+ if (overlay.kind === "applying") {
23
+ return (
24
+ <SelfUpdateProgressOverlay
25
+ target={overlay.target}
26
+ phase={overlay.phase}
27
+ elapsedSeconds={overlay.elapsedSeconds}
28
+ />
29
+ );
30
+ }
31
+
32
+ if (overlay.kind === "neverCameBack") {
33
+ return (
34
+ <SelfUpdateUnreachableScreen
35
+ attemptedVersion={overlay.attemptedVersion}
36
+ previousVersion={overlay.previousVersion}
37
+ elapsedSeconds={overlay.elapsedSeconds}
38
+ logsCommand={overlay.logsCommand}
39
+ onRetryConnection={onRetryConnection}
40
+ />
41
+ );
42
+ }
43
+
44
+ return null;
45
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The Advanced nav icon, with the update dot.
3
+ *
4
+ * A dot on the way to settings is the entire notification budget an available
5
+ * update gets outside the pane — no count, no copy, no action. Everywhere else
6
+ * stays silent.
7
+ */
8
+ import { UpdateAvailableDot } from "@remit/ui";
9
+ import { Wrench } from "lucide-react";
10
+ import { useOptionalSelfUpdate } from "@/hooks/use-system-update";
11
+
12
+ export function AdvancedNavIcon() {
13
+ const selfUpdate = useOptionalSelfUpdate();
14
+ const updateAvailable =
15
+ selfUpdate?.surface.status === "ready" &&
16
+ selfUpdate.surface.section.status === "available";
17
+
18
+ return (
19
+ <UpdateAvailableDot show={updateAvailable}>
20
+ <Wrench className="size-4" />
21
+ </UpdateAvailableDot>
22
+ );
23
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The self-update surface in Settings › Advanced — the only place that talks
3
+ * about updates. Renders nothing when the surface is absent (no manifest URL,
4
+ * not the owner, not signed in), so there is no entry point at all in that case.
5
+ */
6
+ import { SelfUpdateConfirmDialog, SelfUpdateSection } from "@remit/ui";
7
+ import { useState } from "react";
8
+ import { useSelfUpdate } from "@/hooks/use-system-update";
9
+
10
+ export function SelfUpdatePanel() {
11
+ const {
12
+ surface,
13
+ release,
14
+ currentVersion,
15
+ appliesSchemaMigration,
16
+ onCheck,
17
+ install,
18
+ onDismissResult,
19
+ } = useSelfUpdate();
20
+ const [confirming, setConfirming] = useState(false);
21
+
22
+ if (surface.status !== "ready") return null;
23
+
24
+ return (
25
+ <>
26
+ <SelfUpdateSection
27
+ state={surface.section}
28
+ onCheck={onCheck}
29
+ onInstall={() => setConfirming(true)}
30
+ onDismissResult={onDismissResult}
31
+ />
32
+ {release && currentVersion && (
33
+ <SelfUpdateConfirmDialog
34
+ open={confirming}
35
+ currentVersion={currentVersion}
36
+ release={release}
37
+ appliesSchemaMigration={appliesSchemaMigration}
38
+ onClose={() => setConfirming(false)}
39
+ onConfirm={() => {
40
+ setConfirming(false);
41
+ install(release.version);
42
+ }}
43
+ />
44
+ )}
45
+ </>
46
+ );
47
+ }
@@ -0,0 +1,342 @@
1
+ /**
2
+ * The self-update hook, wired end to end against the generated client through a
3
+ * mocked fetch. The pure state machine is covered in `lib/self-update-state.test.ts`;
4
+ * these exercise the parts only the live hook has: resuming a persisted run
5
+ * across a request that fails, the poll that survives a dead server, and the
6
+ * POST that persists a run id the reload will pick up.
7
+ */
8
+ import assert from "node:assert/strict";
9
+ import { afterEach, beforeEach, describe, test } from "node:test";
10
+ import { systemOperationsGetSystemUpdateQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
11
+ import type {
12
+ RemitImapSystemUpdateResponse,
13
+ RemitImapSystemUpdateRun,
14
+ } from "@remit/api-http-client/types.gen.ts";
15
+ import { act, createElement, type ReactNode } from "react";
16
+ import { SelfUpdateOverlay } from "../components/self-update/SelfUpdateOverlay";
17
+ import { AdvancedNavIcon } from "../components/settings/AdvancedNavIcon";
18
+ import { SelfUpdatePanel } from "../components/settings/SelfUpdatePanel";
19
+ import {
20
+ loadHeldRun,
21
+ SELF_UPDATE_RUN_KEY,
22
+ saveHeldRun,
23
+ } from "../lib/self-update-state";
24
+ import { createDomHarness, type DomHarness } from "../test-support/dom";
25
+ import { type HttpMock, httpError, mockFetch } from "../test-support/http";
26
+ import {
27
+ type SelfUpdateApi,
28
+ SelfUpdateProvider,
29
+ useSelfUpdate,
30
+ } from "./use-system-update";
31
+
32
+ let harness: DomHarness | undefined;
33
+ let http: HttpMock | undefined;
34
+
35
+ function installMemoryStorage(): void {
36
+ const store = new Map<string, string>();
37
+ globalThis.localStorage = {
38
+ getItem: (k: string) => store.get(k) ?? null,
39
+ setItem: (k: string, v: string) => void store.set(k, v),
40
+ removeItem: (k: string) => void store.delete(k),
41
+ clear: () => store.clear(),
42
+ key: () => null,
43
+ length: 0,
44
+ } as Storage;
45
+ }
46
+
47
+ beforeEach(installMemoryStorage);
48
+
49
+ afterEach(() => {
50
+ http?.restore();
51
+ http = undefined;
52
+ harness?.close();
53
+ harness = undefined;
54
+ });
55
+
56
+ function run(
57
+ overrides: Partial<RemitImapSystemUpdateRun> = {},
58
+ ): RemitImapSystemUpdateRun {
59
+ return {
60
+ runId: "upd_1",
61
+ fromVersion: "0.9.3",
62
+ targetVersion: "0.9.4",
63
+ phase: "starting",
64
+ outcome: null,
65
+ startedAt: "2026-07-20T11:59:30.000Z",
66
+ updatedAt: "2026-07-20T11:59:45.000Z",
67
+ message: "Restarting Remit on 0.9.4.",
68
+ logCommand: "remit logs --since 10m",
69
+ ...overrides,
70
+ };
71
+ }
72
+
73
+ const available: RemitImapSystemUpdateResponse = {
74
+ currentVersion: "0.9.3",
75
+ check: {
76
+ status: "ok",
77
+ updateAvailable: true,
78
+ latestVersion: "0.9.4",
79
+ publishedAt: "2026-07-14T09:00:00.000Z",
80
+ summary: "Faster first sync.",
81
+ releaseNotesUrl: "https://example.test/notes",
82
+ },
83
+ run: null,
84
+ };
85
+
86
+ const updateKey = systemOperationsGetSystemUpdateQueryKey();
87
+
88
+ async function settle(dom: DomHarness): Promise<void> {
89
+ for (let attempt = 0; attempt < 40; attempt += 1) {
90
+ await dom.flush();
91
+ const state = dom.queryClient.getQueryState(updateKey);
92
+ const done =
93
+ state &&
94
+ state.fetchStatus === "idle" &&
95
+ (state.data !== undefined || state.error != null);
96
+ if (done) {
97
+ // The cache has settled; give React the turns to commit the render it
98
+ // scheduled off that settle before the assert reads the DOM.
99
+ await dom.flush();
100
+ await dom.wait(1);
101
+ await dom.flush();
102
+ return;
103
+ }
104
+ await dom.wait(1);
105
+ }
106
+ }
107
+
108
+ async function renderSurface(
109
+ getResponse: () => unknown,
110
+ children: ReactNode,
111
+ ): Promise<DomHarness> {
112
+ http = mockFetch((call) => {
113
+ if (call.path.endsWith("/system/update") && call.method === "GET") {
114
+ return getResponse();
115
+ }
116
+ return {};
117
+ });
118
+ harness = createDomHarness();
119
+ harness.renderApp(createElement(SelfUpdateProvider, null, children));
120
+ await settle(harness);
121
+ return harness;
122
+ }
123
+
124
+ describe("SelfUpdatePanel — the surface in Advanced", () => {
125
+ test("a 404 renders no entry point at all", async () => {
126
+ const dom = await renderSurface(
127
+ () => httpError(404),
128
+ createElement(SelfUpdatePanel),
129
+ );
130
+ assert.equal(dom.html(), "");
131
+ });
132
+
133
+ test("an available update shows the install action", async () => {
134
+ const dom = await renderSurface(
135
+ () => available,
136
+ createElement(SelfUpdatePanel),
137
+ );
138
+ assert.match(dom.html(), /Install 0\.9\.4/);
139
+ });
140
+
141
+ test("a rolled-back run shows the reason and command verbatim", async () => {
142
+ const dom = await renderSurface(
143
+ () => ({
144
+ currentVersion: "0.9.3",
145
+ check: { status: "ok", updateAvailable: false },
146
+ run: run({
147
+ outcome: "rolledBack",
148
+ message: "migration 0042 failed and was reverted",
149
+ logCommand: "remit logs --since 30m",
150
+ }),
151
+ }),
152
+ createElement(SelfUpdatePanel),
153
+ );
154
+ assert.match(dom.html(), /migration 0042 failed and was reverted/);
155
+ assert.match(dom.html(), /remit logs --since 30m/);
156
+ });
157
+
158
+ test("a rollback that failed is shown verbatim and needs a shell", async () => {
159
+ const dom = await renderSurface(
160
+ () => ({
161
+ currentVersion: "0.9.3",
162
+ check: { status: "ok", updateAvailable: false },
163
+ run: run({
164
+ outcome: "rollbackFailed",
165
+ message: "snapshot restore errored: database is locked",
166
+ logCommand: "remit logs --since 1h",
167
+ }),
168
+ }),
169
+ createElement(SelfUpdatePanel),
170
+ );
171
+ assert.match(dom.html(), /snapshot restore errored: database is locked/);
172
+ assert.match(dom.html(), /remit logs --since 1h/);
173
+ assert.match(dom.html(), /needs you at a shell/);
174
+ });
175
+ });
176
+
177
+ describe("AdvancedNavIcon — the dot", () => {
178
+ test("shows the update dot when one is available", async () => {
179
+ const dom = await renderSurface(
180
+ () => available,
181
+ createElement(AdvancedNavIcon),
182
+ );
183
+ assert.match(dom.html(), /Update available/);
184
+ });
185
+
186
+ test("shows no dot when up to date", async () => {
187
+ const dom = await renderSurface(
188
+ () => ({
189
+ currentVersion: "0.9.3",
190
+ check: { status: "ok", updateAvailable: false },
191
+ run: null,
192
+ }),
193
+ createElement(AdvancedNavIcon),
194
+ );
195
+ assert.doesNotMatch(dom.html(), /Update available/);
196
+ });
197
+ });
198
+
199
+ describe("SelfUpdateOverlay — the blocking screen", () => {
200
+ test("a run this client never started still renders", async () => {
201
+ const dom = await renderSurface(
202
+ () => ({
203
+ currentVersion: "0.9.3",
204
+ check: { status: "ok", updateAvailable: false },
205
+ run: run({ outcome: null }),
206
+ }),
207
+ createElement(SelfUpdateOverlay),
208
+ );
209
+ assert.match(dom.html(), /Installing Remit 0\.9\.4/);
210
+ });
211
+
212
+ test("a held run resumes into applying when the request fails", async () => {
213
+ saveHeldRun({
214
+ runId: "upd_1",
215
+ attemptedVersion: "0.9.4",
216
+ previousVersion: "0.9.3",
217
+ startedAt: Date.now() - 20_000,
218
+ });
219
+ const dom = await renderSurface(() => {
220
+ throw new Error("connection refused");
221
+ }, createElement(SelfUpdateOverlay));
222
+ assert.match(dom.html(), /Installing Remit 0\.9\.4/);
223
+ assert.doesNotMatch(dom.html(), /has not answered since the restart/);
224
+ });
225
+
226
+ test("the budget elapsing flips a held run to never-came-back", async () => {
227
+ saveHeldRun({
228
+ runId: "upd_1",
229
+ attemptedVersion: "0.9.4",
230
+ previousVersion: "0.9.3",
231
+ startedAt: Date.now() - 60 * 60_000,
232
+ });
233
+ const dom = await renderSurface(() => {
234
+ throw new Error("connection refused");
235
+ }, createElement(SelfUpdateOverlay));
236
+ assert.match(dom.html(), /has not answered since the restart/);
237
+ assert.match(dom.html(), /remit logs/);
238
+ });
239
+ });
240
+
241
+ describe("useSystemUpdate — actions", () => {
242
+ function mountApi(getResponse: () => unknown): {
243
+ dom: DomHarness;
244
+ api: () => SelfUpdateApi;
245
+ } {
246
+ http = mockFetch((call) => {
247
+ if (call.path.endsWith("/system/update") && call.method === "GET") {
248
+ return getResponse();
249
+ }
250
+ return {
251
+ currentVersion: "0.9.3",
252
+ check: { status: "ok", updateAvailable: true, latestVersion: "0.9.4" },
253
+ run: run({ outcome: null }),
254
+ };
255
+ });
256
+ let captured: SelfUpdateApi | undefined;
257
+ const Probe = () => {
258
+ captured = useSelfUpdate();
259
+ return null;
260
+ };
261
+ harness = createDomHarness();
262
+ harness.renderApp(
263
+ createElement(SelfUpdateProvider, null, createElement(Probe)),
264
+ );
265
+ return {
266
+ dom: harness,
267
+ api: () => {
268
+ if (!captured) throw new Error("hook not mounted");
269
+ return captured;
270
+ },
271
+ };
272
+ }
273
+
274
+ test("install persists the run id the surface returns", async () => {
275
+ const { dom, api } = mountApi(() => available);
276
+ await settle(dom);
277
+
278
+ await act(async () => {
279
+ api().install("0.9.4");
280
+ await dom.flush();
281
+ await dom.wait(1);
282
+ await dom.flush();
283
+ });
284
+
285
+ const held = loadHeldRun();
286
+ assert.equal(held?.runId, "upd_1");
287
+ assert.equal(localStorage.getItem(SELF_UPDATE_RUN_KEY) !== null, true);
288
+ const surface = api().surface;
289
+ assert.equal(
290
+ surface.status === "ready" && surface.overlay.kind,
291
+ "applying",
292
+ );
293
+ });
294
+
295
+ test("dismissing a finished result clears the pane", async () => {
296
+ const { dom, api } = mountApi(() => ({
297
+ currentVersion: "0.9.4",
298
+ check: { status: "ok", updateAvailable: false },
299
+ run: run({
300
+ runId: "upd_9",
301
+ outcome: "succeeded",
302
+ targetVersion: "0.9.4",
303
+ }),
304
+ }));
305
+ await settle(dom);
306
+
307
+ const before = api().surface;
308
+ assert.equal(
309
+ before.status === "ready" && before.section.status,
310
+ "succeeded",
311
+ );
312
+
313
+ await act(async () => {
314
+ api().onDismissResult();
315
+ await dom.flush();
316
+ });
317
+
318
+ const after = api().surface;
319
+ assert.equal(after.status === "ready" && after.section.status, "upToDate");
320
+ });
321
+
322
+ test("checking and retrying re-poll the surface", async () => {
323
+ let calls = 0;
324
+ const { dom, api } = mountApi(() => {
325
+ calls += 1;
326
+ return available;
327
+ });
328
+ await settle(dom);
329
+ const afterMount = calls;
330
+
331
+ await act(async () => {
332
+ api().onCheck();
333
+ await dom.flush();
334
+ });
335
+ await act(async () => {
336
+ api().onRetryConnection();
337
+ await dom.flush();
338
+ });
339
+
340
+ assert.equal(calls > afterMount, true);
341
+ });
342
+ });
@@ -0,0 +1,215 @@
1
+ /**
2
+ * The self-update surface, wired to the generated client.
3
+ *
4
+ * A single instance owns the query, the mutation, and the run id the client
5
+ * persists across a restart, and hands the folded `SelfUpdateState` plus the
6
+ * blocking-overlay state to every consumer through context — so the Advanced
7
+ * pane, the root overlay, and the nav dot all read one source of truth. The
8
+ * state machine itself lives in `lib/self-update-state.ts`.
9
+ *
10
+ * Polling follows the run: every 30 seconds while idle, every 5 seconds while a
11
+ * run is in flight or a persisted run is being resumed.
12
+ */
13
+ import {
14
+ systemOperationsApplySystemUpdateMutation,
15
+ systemOperationsGetSystemUpdateOptions,
16
+ systemOperationsGetSystemUpdateQueryKey,
17
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
18
+ import type {
19
+ RemitImapSystemUpdateResponse,
20
+ RemitImapSystemUpdateRun,
21
+ } from "@remit/api-http-client/types.gen.ts";
22
+ import type { ReleaseInfo } from "@remit/ui";
23
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
24
+ import {
25
+ createContext,
26
+ createElement,
27
+ type ReactNode,
28
+ useCallback,
29
+ useContext,
30
+ useEffect,
31
+ useRef,
32
+ useState,
33
+ } from "react";
34
+ import {
35
+ appliesSchemaMigration,
36
+ clearStoredRun,
37
+ deriveUpdateSurface,
38
+ type HeldRun,
39
+ isSurfaceAbsent,
40
+ loadHeldRun,
41
+ releaseFromCheck,
42
+ saveHeldRun,
43
+ type UpdateSurface,
44
+ } from "@/lib/self-update-state";
45
+
46
+ const IDLE_POLL_MS = 30_000;
47
+ const RUN_POLL_MS = 5_000;
48
+
49
+ export interface SelfUpdateApi {
50
+ surface: UpdateSurface;
51
+ /** Whether the pending release runs a schema migration during the window. */
52
+ appliesSchemaMigration: boolean;
53
+ currentVersion: string | undefined;
54
+ /** The available release, for the consent dialog. */
55
+ release: ReleaseInfo | undefined;
56
+ /** Refetch the surface, showing a `checking` pane until it settles. */
57
+ onCheck: () => void;
58
+ /** Request a specific release — consent has been given. */
59
+ install: (targetVersion: string) => void;
60
+ /** Clear a finished or given-up result from the pane. */
61
+ onDismissResult: () => void;
62
+ /** Re-poll from the "server never came back" screen. */
63
+ onRetryConnection: () => void;
64
+ }
65
+
66
+ function pollInterval(
67
+ error: unknown,
68
+ run: RemitImapSystemUpdateRun | null,
69
+ hasHeldRun: boolean,
70
+ ): number | false {
71
+ if (isSurfaceAbsent(error) && !hasHeldRun) return false;
72
+ const inFlight = run !== null && run.outcome === null;
73
+ if (hasHeldRun || inFlight) return RUN_POLL_MS;
74
+ return IDLE_POLL_MS;
75
+ }
76
+
77
+ export function useSystemUpdate(): SelfUpdateApi {
78
+ const queryClient = useQueryClient();
79
+ const [held, setHeld] = useState<HeldRun | null>(() => loadHeldRun());
80
+ const [dismissedRunId, setDismissedRunId] = useState<string | null>(null);
81
+ const [checkRequested, setCheckRequested] = useState(false);
82
+
83
+ const heldRef = useRef(held);
84
+ heldRef.current = held;
85
+
86
+ const query = useQuery({
87
+ ...systemOperationsGetSystemUpdateOptions(),
88
+ retry: false,
89
+ meta: { softError: true },
90
+ refetchInterval: (query) =>
91
+ pollInterval(
92
+ query.state.error,
93
+ query.state.data?.run ?? null,
94
+ heldRef.current !== null,
95
+ ),
96
+ });
97
+
98
+ const derived = deriveUpdateSurface({
99
+ data: query.data,
100
+ isError: query.isError,
101
+ error: query.error,
102
+ isFetching: query.isFetching,
103
+ held,
104
+ dismissedRunId,
105
+ checkRequested,
106
+ now: Date.now(),
107
+ });
108
+
109
+ const shownRunIdRef = useRef<string | null>(null);
110
+ shownRunIdRef.current =
111
+ derived.surface.status === "ready" && "runId" in derived.surface.section
112
+ ? derived.surface.section.runId
113
+ : null;
114
+
115
+ const { clearStoredRun: shouldClearStored, releaseHeld } = derived;
116
+
117
+ useEffect(() => {
118
+ if (shouldClearStored) clearStoredRun();
119
+ }, [shouldClearStored]);
120
+
121
+ useEffect(() => {
122
+ if (releaseHeld) setHeld((current) => (current === null ? current : null));
123
+ }, [releaseHeld]);
124
+
125
+ useEffect(() => {
126
+ if (checkRequested && !query.isFetching) setCheckRequested(false);
127
+ }, [checkRequested, query.isFetching]);
128
+
129
+ const { refetch } = query;
130
+
131
+ const onCheck = useCallback(() => {
132
+ setCheckRequested(true);
133
+ void refetch();
134
+ }, [refetch]);
135
+
136
+ const onRetryConnection = useCallback(() => {
137
+ void refetch();
138
+ }, [refetch]);
139
+
140
+ const mutation = useMutation({
141
+ ...systemOperationsApplySystemUpdateMutation(),
142
+ meta: { softError: true },
143
+ onSuccess: (response: RemitImapSystemUpdateResponse) => {
144
+ const run = response.run;
145
+ if (run !== null) {
146
+ const record: HeldRun = {
147
+ runId: run.runId,
148
+ attemptedVersion: run.targetVersion,
149
+ previousVersion: run.fromVersion,
150
+ startedAt: Date.now(),
151
+ };
152
+ saveHeldRun(record);
153
+ setHeld(record);
154
+ setDismissedRunId(null);
155
+ }
156
+ queryClient.setQueryData(
157
+ systemOperationsGetSystemUpdateQueryKey(),
158
+ response,
159
+ );
160
+ },
161
+ onError: () => {
162
+ void refetch();
163
+ },
164
+ });
165
+
166
+ const { mutate } = mutation;
167
+ const install = useCallback(
168
+ (targetVersion: string) => {
169
+ mutate({ body: { targetVersion } });
170
+ },
171
+ [mutate],
172
+ );
173
+
174
+ const onDismissResult = useCallback(() => {
175
+ if (shownRunIdRef.current !== null)
176
+ setDismissedRunId(shownRunIdRef.current);
177
+ clearStoredRun();
178
+ setHeld(null);
179
+ }, []);
180
+
181
+ return {
182
+ surface: derived.surface,
183
+ appliesSchemaMigration: appliesSchemaMigration(query.data),
184
+ currentVersion: query.data?.currentVersion,
185
+ release: releaseFromCheck(query.data, Date.now()),
186
+ onCheck,
187
+ install,
188
+ onDismissResult,
189
+ onRetryConnection,
190
+ };
191
+ }
192
+
193
+ const SelfUpdateContext = createContext<SelfUpdateApi | null>(null);
194
+
195
+ export function SelfUpdateProvider({ children }: { children: ReactNode }) {
196
+ const value = useSystemUpdate();
197
+ return createElement(SelfUpdateContext.Provider, { value }, children);
198
+ }
199
+
200
+ export function useSelfUpdate(): SelfUpdateApi {
201
+ const value = useContext(SelfUpdateContext);
202
+ if (value === null) {
203
+ throw new Error("useSelfUpdate must be used within a SelfUpdateProvider");
204
+ }
205
+ return value;
206
+ }
207
+
208
+ /**
209
+ * The surface when a provider is present, or null when it is not. For the nav
210
+ * dot, which renders in settings trees that some tests mount without the
211
+ * provider — no provider means no update to hint at.
212
+ */
213
+ export function useOptionalSelfUpdate(): SelfUpdateApi | null {
214
+ return useContext(SelfUpdateContext);
215
+ }