@zmdb/react-native 1.0.0-beta.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/src/index.ts ADDED
@@ -0,0 +1,316 @@
1
+ import type { ClientRuntime } from '@zmdb/client';
2
+ import { createZmdbReact } from '@zmdb/react';
3
+ import type {
4
+ MutationRunner,
5
+ MutationState,
6
+ QueryLoader,
7
+ QueryState,
8
+ ZmdbClientProviderProps,
9
+ ZmdbReactBindings,
10
+ ZmdbReactRequestKind,
11
+ ZmdbReactRequestLifecycle,
12
+ } from '@zmdb/react';
13
+ import { createContext, createElement, useCallback, useContext, useEffect, useRef } from 'react';
14
+ import type { DependencyList, ReactElement } from 'react';
15
+
16
+ export type {
17
+ MutationRunner,
18
+ MutationState,
19
+ QueryLoader,
20
+ QueryState,
21
+ ZmdbClientProviderProps,
22
+ ZmdbReactBindings,
23
+ } from '@zmdb/react';
24
+
25
+ export type NativeBackgroundPolicy = 'abort' | 'abort-and-refresh' | 'continue';
26
+ export type NativeConnectivityState = 'offline' | 'online';
27
+ export type NativeOfflinePolicy = 'queue' | 'refuse';
28
+ export type NativeAppStateStatus = string | null | undefined;
29
+
30
+ export interface NativeSubscription {
31
+ remove(): void;
32
+ }
33
+
34
+ /**
35
+ * React Native's AppState object satisfies this shape directly. Tests and
36
+ * non-device renderers can supply the same two-member structural port.
37
+ */
38
+ export interface NativeAppState {
39
+ readonly currentState: NativeAppStateStatus;
40
+ addEventListener(type: 'change', listener: (state: NativeAppStateStatus) => void): NativeSubscription;
41
+ }
42
+
43
+ /**
44
+ * Application-owned bridge for NetInfo, another connectivity library, or a
45
+ * platform service. The adapter deliberately imports none of them.
46
+ */
47
+ export interface NativeConnectivity {
48
+ readonly currentState: NativeConnectivityState;
49
+ subscribe(listener: (state: NativeConnectivityState) => void): () => void;
50
+ }
51
+
52
+ /**
53
+ * Application-owned bridge for AsyncStorage, Keychain, SecureStore, or another
54
+ * native credential implementation. Writing null clears the selected value.
55
+ */
56
+ export interface NativeCredentialStore<Credential> {
57
+ read(): PromiseLike<Credential | null>;
58
+ write(value: Credential | null): PromiseLike<void>;
59
+ }
60
+
61
+ export interface ZmdbReactNativeOptions<Credential> {
62
+ readonly appState: NativeAppState;
63
+ readonly backgroundPolicy: NativeBackgroundPolicy;
64
+ readonly connectivity: NativeConnectivity;
65
+ readonly credentials: NativeCredentialStore<Credential>;
66
+ readonly offlinePolicy: NativeOfflinePolicy;
67
+ readonly bindingName?: string;
68
+ }
69
+
70
+ export interface ZmdbReactNativeBindings<Client extends object, Credential> extends ZmdbReactBindings<Client> {
71
+ useCredentialStore(): NativeCredentialStore<Credential>;
72
+ useConnectivity(): NativeConnectivity;
73
+ }
74
+
75
+ export class NativeOfflineError extends Error {
76
+ override readonly name = 'NativeOfflineError';
77
+
78
+ constructor(bindingName: string) {
79
+ super(`${bindingName} refused the request because native connectivity is offline`);
80
+ }
81
+ }
82
+
83
+ export class NativeBackgroundError extends Error {
84
+ override readonly name = 'NativeBackgroundError';
85
+ readonly state: NativeAppStateStatus;
86
+
87
+ constructor(bindingName: string, state: NativeAppStateStatus) {
88
+ super(`${bindingName} aborted the request because AppState changed to ${String(state)}`);
89
+ this.state = state;
90
+ }
91
+ }
92
+
93
+ interface NativeCoordinator<Credential> {
94
+ readonly connectivity: NativeConnectivity;
95
+ readonly credentials: NativeCredentialStore<Credential>;
96
+ readonly requestLifecycle: ZmdbReactRequestLifecycle;
97
+ activate(): () => void;
98
+ registerRefresh(refresh: () => Promise<void>): () => void;
99
+ }
100
+
101
+ function once(cleanup: () => void): () => void {
102
+ let complete = false;
103
+ return () => {
104
+ if (complete) return;
105
+ complete = true;
106
+ cleanup();
107
+ };
108
+ }
109
+
110
+ function isActive(state: NativeAppStateStatus): boolean {
111
+ return state === 'active';
112
+ }
113
+
114
+ function createCoordinator<Credential>(
115
+ options: ZmdbReactNativeOptions<Credential>,
116
+ bindingName: string,
117
+ ): NativeCoordinator<Credential> {
118
+ const queries = new Set<AbortController>();
119
+ const mutations = new Set<AbortController>();
120
+ const refreshes = new Set<() => Promise<void>>();
121
+ let currentState = options.appState.currentState;
122
+ let refreshOnForeground = false;
123
+
124
+ const abortRequests = (state: NativeAppStateStatus): void => {
125
+ if (options.backgroundPolicy === 'continue') return;
126
+ if (options.backgroundPolicy === 'abort-and-refresh' && refreshes.size > 0) {
127
+ refreshOnForeground = true;
128
+ }
129
+ const reason = new NativeBackgroundError(bindingName, state);
130
+ for (const controller of [...queries, ...mutations]) {
131
+ if (!controller.signal.aborted) controller.abort(reason);
132
+ }
133
+ };
134
+
135
+ const onAppState = (state: NativeAppStateStatus): void => {
136
+ const wasActive = isActive(currentState);
137
+ currentState = state;
138
+ const active = isActive(state);
139
+ if (wasActive && !active) abortRequests(state);
140
+ if (!wasActive && active && refreshOnForeground) {
141
+ refreshOnForeground = false;
142
+ for (const refresh of refreshes) void refresh().catch(() => undefined);
143
+ }
144
+ };
145
+
146
+ const requestLifecycle: ZmdbReactRequestLifecycle = {
147
+ register(kind: ZmdbReactRequestKind, controller: AbortController): () => void {
148
+ const selected = kind === 'query' ? queries : mutations;
149
+ selected.add(controller);
150
+ const release = once(() => {
151
+ selected.delete(controller);
152
+ controller.signal.removeEventListener('abort', release);
153
+ });
154
+ controller.signal.addEventListener('abort', release, { once: true });
155
+ if (controller.signal.aborted) release();
156
+
157
+ if (!isActive(currentState) && options.backgroundPolicy !== 'continue' && !controller.signal.aborted) {
158
+ if (kind === 'query' && options.backgroundPolicy === 'abort-and-refresh') refreshOnForeground = true;
159
+ controller.abort(new NativeBackgroundError(bindingName, currentState));
160
+ }
161
+ return release;
162
+ },
163
+ };
164
+
165
+ return {
166
+ connectivity: options.connectivity,
167
+ credentials: options.credentials,
168
+ requestLifecycle,
169
+ activate() {
170
+ const subscription = options.appState.addEventListener('change', onAppState);
171
+ const mountedState = options.appState.currentState;
172
+ if (mountedState !== currentState) onAppState(mountedState);
173
+ else if (!isActive(mountedState)) abortRequests(mountedState);
174
+ return once(() => {
175
+ subscription.remove();
176
+ });
177
+ },
178
+ registerRefresh(refresh) {
179
+ refreshes.add(refresh);
180
+ return once(() => {
181
+ refreshes.delete(refresh);
182
+ });
183
+ },
184
+ };
185
+ }
186
+
187
+ function aborted(signal: AbortSignal): Promise<never> {
188
+ return Promise.reject(signal.reason);
189
+ }
190
+
191
+ function waitForConnectivity(connectivity: NativeConnectivity, signal: AbortSignal): Promise<void> {
192
+ if (connectivity.currentState === 'online') return Promise.resolve();
193
+ if (signal.aborted) return aborted(signal);
194
+
195
+ return new Promise<void>((resolve, reject) => {
196
+ let complete = false;
197
+ let unsubscribe: (() => void) | undefined;
198
+ const finish = (action: () => void): void => {
199
+ if (complete) return;
200
+ complete = true;
201
+ signal.removeEventListener('abort', onAbort);
202
+ unsubscribe?.();
203
+ unsubscribe = undefined;
204
+ action();
205
+ };
206
+ const onAbort = (): void => {
207
+ finish(() => reject(signal.reason));
208
+ };
209
+ const onConnectivity = (state: NativeConnectivityState): void => {
210
+ if (state === 'online') finish(resolve);
211
+ };
212
+
213
+ signal.addEventListener('abort', onAbort, { once: true });
214
+ const selectedUnsubscribe = connectivity.subscribe(onConnectivity);
215
+ if (complete) selectedUnsubscribe();
216
+ else unsubscribe = selectedUnsubscribe;
217
+ if (signal.aborted) onAbort();
218
+ else if (connectivity.currentState === 'online') finish(resolve);
219
+ });
220
+ }
221
+
222
+ async function dispatchWhenAvailable<Output>(
223
+ connectivity: NativeConnectivity,
224
+ offlinePolicy: NativeOfflinePolicy,
225
+ bindingName: string,
226
+ signal: AbortSignal,
227
+ dispatch: () => PromiseLike<Output>,
228
+ ): Promise<Output> {
229
+ if (signal.aborted) return aborted(signal);
230
+ if (connectivity.currentState === 'offline') {
231
+ if (offlinePolicy === 'refuse') throw new NativeOfflineError(bindingName);
232
+ await waitForConnectivity(connectivity, signal);
233
+ }
234
+ if (signal.aborted) return aborted(signal);
235
+ return dispatch();
236
+ }
237
+
238
+ export function createZmdbReactNative<Client extends object = ClientRuntime, Credential = string>(
239
+ options: ZmdbReactNativeOptions<Credential>,
240
+ ): ZmdbReactNativeBindings<Client, Credential> {
241
+ const bindingName = options.bindingName ?? '@zmdb/react-native';
242
+ const react = createZmdbReact<Client>(bindingName);
243
+ const NativeContext = createContext<NativeCoordinator<Credential> | undefined>(undefined);
244
+
245
+ function useNativeContext(): NativeCoordinator<Credential> {
246
+ const coordinator = useContext(NativeContext);
247
+ if (coordinator !== undefined) return coordinator;
248
+ throw new Error(
249
+ `${bindingName} native services are unavailable; render this hook under ${bindingName}.ZmdbClientProvider`,
250
+ );
251
+ }
252
+
253
+ function ZmdbClientProvider(props: ZmdbClientProviderProps<Client>): ReactElement {
254
+ const coordinator = useRef<NativeCoordinator<Credential> | undefined>(undefined);
255
+ if (coordinator.current === undefined) coordinator.current = createCoordinator(options, bindingName);
256
+ const selectedCoordinator = coordinator.current;
257
+
258
+ useEffect(() => selectedCoordinator.activate(), [selectedCoordinator]);
259
+
260
+ return createElement(
261
+ NativeContext.Provider,
262
+ { value: selectedCoordinator },
263
+ createElement(
264
+ react.ZmdbClientProvider,
265
+ {
266
+ client: props.client,
267
+ requestLifecycle: selectedCoordinator.requestLifecycle,
268
+ },
269
+ props.children,
270
+ ),
271
+ );
272
+ }
273
+
274
+ function useZmdbQuery<Output>(load: QueryLoader<Client, Output>, dependencies: DependencyList): QueryState<Output> {
275
+ const coordinator = useNativeContext();
276
+ const guardedLoad = useCallback(
277
+ (client: Client, signal: AbortSignal) =>
278
+ dispatchWhenAvailable(coordinator.connectivity, options.offlinePolicy, bindingName, signal, () =>
279
+ load(client, signal),
280
+ ),
281
+ [coordinator, ...dependencies],
282
+ );
283
+ const query = react.useZmdbQuery(guardedLoad, [guardedLoad]);
284
+ useEffect(() => coordinator.registerRefresh(query.refresh), [coordinator, query.refresh]);
285
+ return query;
286
+ }
287
+
288
+ function useZmdbMutation<Input, Output>(run: MutationRunner<Client, Input, Output>): MutationState<Input, Output> {
289
+ const coordinator = useNativeContext();
290
+ const guardedRun = useCallback(
291
+ (client: Client, input: Input, signal: AbortSignal) =>
292
+ dispatchWhenAvailable(coordinator.connectivity, options.offlinePolicy, bindingName, signal, () =>
293
+ run(client, input, signal),
294
+ ),
295
+ [coordinator, run],
296
+ );
297
+ return react.useZmdbMutation(guardedRun);
298
+ }
299
+
300
+ function useCredentialStore(): NativeCredentialStore<Credential> {
301
+ return useNativeContext().credentials;
302
+ }
303
+
304
+ function useConnectivity(): NativeConnectivity {
305
+ return useNativeContext().connectivity;
306
+ }
307
+
308
+ return Object.freeze({
309
+ ZmdbClientProvider,
310
+ useZmdbClient: react.useZmdbClient,
311
+ useZmdbMutation,
312
+ useZmdbQuery,
313
+ useCredentialStore,
314
+ useConnectivity,
315
+ });
316
+ }