@gonvex/react 0.1.32 → 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 +63 -45
- package/dist/index.d.ts +50 -35
- package/dist/index.js +265 -221
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
3
|
-
import {
|
|
3
|
+
import { GonvexClientError, control } from "@gonvex/client";
|
|
4
4
|
export { GonvexClientError } from "@gonvex/client";
|
|
5
5
|
const GonvexContext = createContext(null);
|
|
6
6
|
const GonvexAuthContext = createContext({ isLoading: false, isAuthenticated: true });
|
|
7
|
-
export const ConvexProvider = GonvexProvider;
|
|
8
7
|
export function GonvexProvider(props) {
|
|
9
8
|
return _jsx(GonvexContext.Provider, { value: props.client, children: props.children });
|
|
10
9
|
}
|
|
11
|
-
export { ConvexReactClient };
|
|
12
10
|
class GonvexAuthRequestError extends Error {
|
|
13
11
|
status;
|
|
14
12
|
constructor(message, status) {
|
|
@@ -18,7 +16,7 @@ class GonvexAuthRequestError extends Error {
|
|
|
18
16
|
}
|
|
19
17
|
}
|
|
20
18
|
const ManagedAuthContext = createContext(null);
|
|
21
|
-
export function
|
|
19
|
+
export function GonvexProviderWithAuth(props) {
|
|
22
20
|
const auth = props.useAuth();
|
|
23
21
|
const [tokenReady, setTokenReady] = useState(false);
|
|
24
22
|
const [clientAuthError, setClientAuthError] = useState(null);
|
|
@@ -70,12 +68,7 @@ export function ConvexProviderWithAuth(props) {
|
|
|
70
68
|
const shouldHoldChildren = !authError && (auth.isLoading || (auth.isAuthenticated && !tokenReady));
|
|
71
69
|
return (_jsx(GonvexAuthContext.Provider, { value: authValue, children: _jsx(GonvexProvider, { client: props.client, children: shouldHoldChildren ? null : props.children }) }));
|
|
72
70
|
}
|
|
73
|
-
/**
|
|
74
|
-
* Native Gonvex authentication. The runtime performs the one centrally
|
|
75
|
-
* configured Google OAuth flow, while each app uses PKCE and receives a
|
|
76
|
-
* project-scoped Gonvex session. No Firebase or Google SDK is loaded in the
|
|
77
|
-
* browser.
|
|
78
|
-
*/
|
|
71
|
+
/** Native Gonvex authentication with password or a configured OAuth provider. */
|
|
79
72
|
// Dedupe callback bootstrap across React StrictMode remounts so the OAuth
|
|
80
73
|
// code+PKCE exchange runs once. Without this, the first effect's finally
|
|
81
74
|
// clears sessionStorage PKCE before the remount can finish verification.
|
|
@@ -96,12 +89,15 @@ export function GonvexAuthProvider(props) {
|
|
|
96
89
|
if (next) {
|
|
97
90
|
if (persist)
|
|
98
91
|
safeLocalStorageSet(storageKey, JSON.stringify(next));
|
|
99
|
-
props.client.setAuth({
|
|
92
|
+
props.client.setAuth({
|
|
93
|
+
project: props.projectId, tenant: next.activeTenantId, token: next.accessToken,
|
|
94
|
+
identity: { sub: next.account.id, iss: props.projectId },
|
|
95
|
+
});
|
|
100
96
|
}
|
|
101
97
|
else {
|
|
102
98
|
if (persist)
|
|
103
99
|
safeLocalStorageRemove(storageKey);
|
|
104
|
-
props.client.setAuth({ project: props.projectId, tenant: undefined, token: undefined });
|
|
100
|
+
props.client.setAuth({ project: props.projectId, tenant: undefined, token: undefined, identity: undefined });
|
|
105
101
|
}
|
|
106
102
|
setSession(next);
|
|
107
103
|
}, [props.client, props.projectId, storageKey]);
|
|
@@ -109,7 +105,7 @@ export function GonvexAuthProvider(props) {
|
|
|
109
105
|
let cancelled = false;
|
|
110
106
|
let bootstrap = authBootstrapPromises.get(storageKey);
|
|
111
107
|
if (!bootstrap) {
|
|
112
|
-
bootstrap = bootstrapGonvexAuth({ callbackPath, pkceStorageKey, projectId: props.projectId, runtimeUrl, storageKey })
|
|
108
|
+
bootstrap = bootstrapGonvexAuth({ callbackPath, client: props.client, pkceStorageKey, projectId: props.projectId, runtimeUrl, storageKey })
|
|
113
109
|
.finally(() => {
|
|
114
110
|
// Keep the resolved promise briefly so a StrictMode remount attaches
|
|
115
111
|
// to the same result instead of re-running a spent OAuth code.
|
|
@@ -127,7 +123,7 @@ export function GonvexAuthProvider(props) {
|
|
|
127
123
|
}).catch((cause) => {
|
|
128
124
|
if (!cancelled) {
|
|
129
125
|
installSession(null);
|
|
130
|
-
setError(cause instanceof Error ? cause.message : "
|
|
126
|
+
setError(cause instanceof Error ? cause.message : "Sign-in failed.");
|
|
131
127
|
}
|
|
132
128
|
}).finally(() => {
|
|
133
129
|
if (!cancelled)
|
|
@@ -146,12 +142,8 @@ export function GonvexAuthProvider(props) {
|
|
|
146
142
|
if (!force && current.expiresAt > Date.now() + 60_000)
|
|
147
143
|
return current;
|
|
148
144
|
attemptedRefreshToken = current.refreshToken;
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
project: props.projectId,
|
|
152
|
-
refreshToken: current.refreshToken,
|
|
153
|
-
tenant: current.activeTenantId,
|
|
154
|
-
});
|
|
145
|
+
const grant = await props.client.action(control.auth.refreshSession, { refreshToken: current.refreshToken });
|
|
146
|
+
const next = sessionFromNativeGrant(grant, current);
|
|
155
147
|
// Persist the rotated token before releasing the cross-tab lock. The
|
|
156
148
|
// next waiter must never read and reuse the just-consumed refresh token.
|
|
157
149
|
safeLocalStorageSet(storageKey, JSON.stringify(next));
|
|
@@ -192,7 +184,7 @@ export function GonvexAuthProvider(props) {
|
|
|
192
184
|
});
|
|
193
185
|
refreshRef.current = request;
|
|
194
186
|
return request;
|
|
195
|
-
}, [installSession, props.
|
|
187
|
+
}, [installSession, props.client, storageKey]);
|
|
196
188
|
useEffect(() => {
|
|
197
189
|
if (!session)
|
|
198
190
|
return;
|
|
@@ -201,6 +193,25 @@ export function GonvexAuthProvider(props) {
|
|
|
201
193
|
const timeout = window.setTimeout(() => { void refreshSession(); }, delay);
|
|
202
194
|
return () => window.clearTimeout(timeout);
|
|
203
195
|
}, [refreshRetryAt, refreshSession, session]);
|
|
196
|
+
// Keep the account tenant directory authoritative without a reducer+manual
|
|
197
|
+
// refetch pair. The live Control Plane Query resumes on reconnect.
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
if (!sessionRef.current)
|
|
200
|
+
return;
|
|
201
|
+
const watch = props.client.watchControlQuery(control.tenants.mine, {});
|
|
202
|
+
return watch.onUpdate(() => {
|
|
203
|
+
const tenants = watch.getSnapshot().result;
|
|
204
|
+
const current = sessionRef.current;
|
|
205
|
+
if (!current || !tenants)
|
|
206
|
+
return;
|
|
207
|
+
const activeTenantId = tenants.some((tenant) => tenant.id === current.activeTenantId)
|
|
208
|
+
? current.activeTenantId
|
|
209
|
+
: tenants[0]?.id;
|
|
210
|
+
if (JSON.stringify(current.tenants) === JSON.stringify(tenants) && current.activeTenantId === activeTenantId)
|
|
211
|
+
return;
|
|
212
|
+
installSession({ ...current, tenants, activeTenantId });
|
|
213
|
+
});
|
|
214
|
+
}, [installSession, props.client, session?.account.id]);
|
|
204
215
|
useEffect(() => {
|
|
205
216
|
const onStorage = (event) => {
|
|
206
217
|
if (event.key !== storageKey)
|
|
@@ -210,7 +221,7 @@ export function GonvexAuthProvider(props) {
|
|
|
210
221
|
window.addEventListener("storage", onStorage);
|
|
211
222
|
return () => window.removeEventListener("storage", onStorage);
|
|
212
223
|
}, [installSession, storageKey]);
|
|
213
|
-
const
|
|
224
|
+
const signInWithProvider = useCallback(async (provider) => {
|
|
214
225
|
setError(null);
|
|
215
226
|
const verifier = randomBase64Url(64);
|
|
216
227
|
const state = randomBase64Url(32);
|
|
@@ -218,8 +229,8 @@ export function GonvexAuthProvider(props) {
|
|
|
218
229
|
const challenge = bytesToBase64Url(new Uint8Array(challengeBytes));
|
|
219
230
|
const redirectUri = new URL(callbackPath, window.location.origin).toString();
|
|
220
231
|
const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
|
221
|
-
safeSessionStorageSet(pkceStorageKey, JSON.stringify({ state, verifier, redirectUri, returnTo, createdAt: Date.now() }));
|
|
222
|
-
const authorizeUrl = new URL(`${runtimeUrl}/auth/
|
|
232
|
+
safeSessionStorageSet(pkceStorageKey, JSON.stringify({ state, verifier, redirectUri, returnTo, provider, createdAt: Date.now() }));
|
|
233
|
+
const authorizeUrl = new URL(`${runtimeUrl}/auth/${provider}/authorize`);
|
|
223
234
|
authorizeUrl.searchParams.set("project", props.projectId);
|
|
224
235
|
authorizeUrl.searchParams.set("redirect_uri", redirectUri);
|
|
225
236
|
authorizeUrl.searchParams.set("state", state);
|
|
@@ -227,18 +238,21 @@ export function GonvexAuthProvider(props) {
|
|
|
227
238
|
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
|
228
239
|
window.location.assign(authorizeUrl.toString());
|
|
229
240
|
}, [callbackPath, pkceStorageKey, props.projectId, runtimeUrl]);
|
|
241
|
+
const signIn = useCallback((provider = "google") => signInWithProvider(provider), [signInWithProvider]);
|
|
242
|
+
const signInWithPassword = useCallback(async (email, password) => {
|
|
243
|
+
setError(null);
|
|
244
|
+
const grant = await props.client.action(control.auth.passwordLogin, { email, password });
|
|
245
|
+
const next = sessionFromNativeGrant(grant, sessionRef.current ?? undefined);
|
|
246
|
+
installSession(next);
|
|
247
|
+
}, [installSession, props.client]);
|
|
230
248
|
const signOut = useCallback(async (options) => {
|
|
231
249
|
const current = sessionRef.current;
|
|
232
|
-
installSession(null);
|
|
233
250
|
setError(null);
|
|
234
|
-
if (
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
body: JSON.stringify({ refreshToken: current.refreshToken, all: options?.allDevices === true }),
|
|
240
|
-
}).catch(() => undefined);
|
|
241
|
-
}, [installSession, runtimeUrl]);
|
|
251
|
+
if (current) {
|
|
252
|
+
await props.client.reducer(control.auth.logout, { refreshToken: current.refreshToken, all: options?.allDevices === true }).catch(() => undefined);
|
|
253
|
+
}
|
|
254
|
+
installSession(null);
|
|
255
|
+
}, [installSession, props.client]);
|
|
242
256
|
const fetchAccessToken = useCallback(async (args) => {
|
|
243
257
|
const current = sessionRef.current;
|
|
244
258
|
if (!current)
|
|
@@ -259,82 +273,67 @@ export function GonvexAuthProvider(props) {
|
|
|
259
273
|
if (!token)
|
|
260
274
|
throw new Error("Sign in before loading tenant memberships.");
|
|
261
275
|
const current = sessionRef.current;
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
276
|
+
const [account, tenants] = await Promise.all([
|
|
277
|
+
props.client.query(control.accounts.me, {}),
|
|
278
|
+
props.client.query(control.tenants.mine, {}),
|
|
279
|
+
]);
|
|
280
|
+
const mappedAccount = { id: account.id, email: account.email, emailVerified: true, name: account.name, picture: account.avatarUrl, provider: current.account.provider };
|
|
281
|
+
const mappedTenants = tenants;
|
|
282
|
+
const activeTenantId = mappedTenants.some((tenant) => tenant.id === current.activeTenantId) ? current.activeTenantId : mappedTenants[0]?.id;
|
|
283
|
+
installSession({ ...current, account: mappedAccount, tenants: mappedTenants, activeTenantId });
|
|
284
|
+
return mappedTenants;
|
|
285
|
+
}, [fetchAccessToken, installSession, props.client]);
|
|
271
286
|
const createTenant = useCallback(async (name) => {
|
|
272
287
|
const token = await fetchAccessToken({ forceRefreshToken: false });
|
|
273
288
|
if (!token)
|
|
274
289
|
throw new Error("Sign in before creating a tenant.");
|
|
275
|
-
const
|
|
276
|
-
method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
277
|
-
body: JSON.stringify({ name }),
|
|
278
|
-
});
|
|
279
|
-
const payload = await response.json().catch(() => ({}));
|
|
280
|
-
if (!response.ok || !payload.tenant)
|
|
281
|
-
throw new Error(payload.error ?? "Could not create the tenant.");
|
|
290
|
+
const tenant = await props.client.reducer(control.tenants.create, { name });
|
|
282
291
|
const current = sessionRef.current;
|
|
283
|
-
installSession({ ...current, tenants: [...current.tenants.filter((
|
|
284
|
-
return
|
|
285
|
-
}, [fetchAccessToken, installSession,
|
|
292
|
+
installSession({ ...current, tenants: [...current.tenants.filter((item) => item.id !== tenant.id), tenant], activeTenantId: tenant.id });
|
|
293
|
+
return tenant;
|
|
294
|
+
}, [fetchAccessToken, installSession, props.client]);
|
|
286
295
|
const inviteMember = useCallback(async (tenantId, email, options) => {
|
|
287
296
|
const token = await fetchAccessToken({ forceRefreshToken: false });
|
|
288
297
|
if (!token)
|
|
289
298
|
throw new Error("Sign in before inviting a member.");
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if (!token)
|
|
301
|
-
throw new Error("Sign in before removing a member.");
|
|
302
|
-
const response = await fetch(`${runtimeUrl}/auth/tenants/${encodeURIComponent(tenantId)}/members/${encodeURIComponent(userId)}`, {
|
|
303
|
-
method: "DELETE", headers: { authorization: `Bearer ${token}` },
|
|
304
|
-
});
|
|
305
|
-
const payload = await response.json().catch(() => ({}));
|
|
306
|
-
if (!response.ok)
|
|
307
|
-
throw new Error(payload.error ?? "Could not remove the member.");
|
|
308
|
-
}, [fetchAccessToken, runtimeUrl]);
|
|
299
|
+
if (tenantId !== sessionRef.current?.activeTenantId)
|
|
300
|
+
throw new Error("Switch to the tenant before inviting a member.");
|
|
301
|
+
return props.client.reducer(control.invitations.create, { email, role: options?.role ?? "member", permissions: (options?.permissions ?? {}), teamIds: options?.teamIds ?? [], allowedAuthProviders: options?.allowedAuthProviders ?? [], payload: options?.payload ?? {} });
|
|
302
|
+
}, [fetchAccessToken, props.client]);
|
|
303
|
+
const acceptInvitation = useCallback(async (token) => {
|
|
304
|
+
const accessToken = await fetchAccessToken({ forceRefreshToken: false });
|
|
305
|
+
if (!accessToken)
|
|
306
|
+
throw new Error("Sign in before accepting an invitation.");
|
|
307
|
+
return props.client.reducer(control.invitations.accept, { token });
|
|
308
|
+
}, [fetchAccessToken, props.client]);
|
|
309
309
|
const revokeInvitation = useCallback(async (tenantId, email) => {
|
|
310
310
|
const token = await fetchAccessToken({ forceRefreshToken: false });
|
|
311
311
|
if (!token)
|
|
312
312
|
throw new Error("Sign in before revoking an invitation.");
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
if (!response.ok)
|
|
318
|
-
throw new Error(payload.error ?? "Could not revoke the invitation.");
|
|
319
|
-
}, [fetchAccessToken, runtimeUrl]);
|
|
313
|
+
if (tenantId !== sessionRef.current?.activeTenantId)
|
|
314
|
+
throw new Error("Switch to the tenant before revoking an invitation.");
|
|
315
|
+
await props.client.reducer(control.invitations.revoke, { id: "", email });
|
|
316
|
+
}, [fetchAccessToken, props.client]);
|
|
320
317
|
const activeTenant = session?.tenants.find((tenant) => tenant.id === session.activeTenantId) ?? null;
|
|
321
318
|
const authValue = useMemo(() => ({
|
|
322
319
|
isLoading,
|
|
323
320
|
isAuthenticated: Boolean(session && session.refreshExpiresAt > Date.now()),
|
|
324
321
|
fetchAccessToken,
|
|
325
|
-
|
|
322
|
+
account: session?.account ?? null,
|
|
326
323
|
tenants: session?.tenants ?? [],
|
|
327
324
|
activeTenant,
|
|
328
325
|
error,
|
|
329
326
|
signIn,
|
|
327
|
+
signInWithProvider,
|
|
328
|
+
signInWithPassword,
|
|
330
329
|
signOut,
|
|
331
330
|
setActiveTenant,
|
|
332
331
|
refreshMemberships,
|
|
333
332
|
createTenant,
|
|
334
333
|
inviteMember,
|
|
334
|
+
acceptInvitation,
|
|
335
335
|
revokeInvitation,
|
|
336
|
-
|
|
337
|
-
}), [activeTenant, createTenant, error, fetchAccessToken, inviteMember, isLoading, refreshMemberships, removeMember, revokeInvitation, session, setActiveTenant, signIn, signOut]);
|
|
336
|
+
}), [acceptInvitation, activeTenant, createTenant, error, fetchAccessToken, inviteMember, isLoading, refreshMemberships, revokeInvitation, session, setActiveTenant, signIn, signInWithPassword, signInWithProvider, signOut]);
|
|
338
337
|
return (_jsx(ManagedAuthContext.Provider, { value: authValue, children: _jsx(GonvexAuthContext.Provider, { value: authValue, children: _jsx(GonvexProvider, { client: props.client, children: isLoading ? null : props.children }) }) }));
|
|
339
338
|
}
|
|
340
339
|
export function useGonvexAuth() {
|
|
@@ -343,6 +342,18 @@ export function useGonvexAuth() {
|
|
|
343
342
|
throw new Error("GonvexAuthProvider is required");
|
|
344
343
|
return value;
|
|
345
344
|
}
|
|
345
|
+
/** Subscribed profile for the active tenant, reconciled by GonvexAuthProvider. */
|
|
346
|
+
export function useCurrentTenantProfile() {
|
|
347
|
+
return useGonvexAuth().activeTenant;
|
|
348
|
+
}
|
|
349
|
+
/** Live tenant-admin invitation list; reducer changes reconcile automatically. */
|
|
350
|
+
export function useInvitationList() {
|
|
351
|
+
return useControlQuery(control.invitations.list, {});
|
|
352
|
+
}
|
|
353
|
+
/** Read the auth state installed by either auth provider. */
|
|
354
|
+
export function useGonvexAuthState() {
|
|
355
|
+
return useContext(GonvexAuthContext);
|
|
356
|
+
}
|
|
346
357
|
export function GonvexGoogleAuthButton(props) {
|
|
347
358
|
const { signOutLabel = "Sign out", children, disabled, onClick, ...buttonProps } = props;
|
|
348
359
|
const auth = useGonvexAuth();
|
|
@@ -386,10 +397,9 @@ async function bootstrapGonvexAuth(options) {
|
|
|
386
397
|
return latest;
|
|
387
398
|
if (latest.refreshExpiresAt <= Date.now())
|
|
388
399
|
throw new GonvexAuthRequestError("Your session expired. Please sign in again.", 401);
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
});
|
|
400
|
+
options.client.setAuth({ project: options.projectId, token: latest.accessToken });
|
|
401
|
+
const grant = await options.client.action(control.auth.refreshSession, { refreshToken: latest.refreshToken });
|
|
402
|
+
const next = sessionFromNativeGrant(grant, latest);
|
|
393
403
|
safeLocalStorageSet(options.storageKey, JSON.stringify(next));
|
|
394
404
|
return next;
|
|
395
405
|
});
|
|
@@ -401,26 +411,34 @@ async function bootstrapGonvexAuth(options) {
|
|
|
401
411
|
}
|
|
402
412
|
}
|
|
403
413
|
const pkce = readPKCE(options.pkceStorageKey);
|
|
404
|
-
|
|
405
|
-
|
|
414
|
+
const provider = pkce?.provider ?? "google";
|
|
415
|
+
const providerLabel = provider[0].toUpperCase() + provider.slice(1);
|
|
416
|
+
// Surface the runtime error even when another tab already consumed PKCE.
|
|
406
417
|
if (callbackError) {
|
|
407
418
|
const messages = {
|
|
408
|
-
access_denied:
|
|
409
|
-
invitation_required:
|
|
419
|
+
access_denied: `${providerLabel} sign-in was cancelled.`,
|
|
420
|
+
invitation_required: `This app is invite-only. Ask an administrator to invite your verified ${providerLabel} email.`,
|
|
410
421
|
verified_google_email_required: "Google must provide a verified email address for this app.",
|
|
422
|
+
verified_microsoft_email_required: "Microsoft must provide a verified email address for this app.",
|
|
411
423
|
membership_setup_failed: "Your account was verified, but its workspace could not be prepared. Please try again.",
|
|
412
424
|
google_exchange_failed: "Google rejected the sign-in code exchange. Check GONVEX_GOOGLE_CLIENT_ID/SECRET and the broker callback URI.",
|
|
425
|
+
microsoft_exchange_failed: "Microsoft rejected the sign-in code exchange. Check the project's Microsoft realm configuration.",
|
|
426
|
+
apple_exchange_failed: "Apple rejected the sign-in code exchange. Check the project's Apple realm configuration.",
|
|
413
427
|
invalid_google_identity: "Google identity verification failed. Please try again.",
|
|
414
|
-
|
|
428
|
+
invalid_microsoft_identity: "Microsoft identity verification failed. Please try again.",
|
|
429
|
+
invalid_apple_identity: "Apple identity verification failed. Please try again.",
|
|
430
|
+
microsoft_not_configured: "Microsoft sign-in is not configured for this project.",
|
|
431
|
+
apple_not_configured: "Apple sign-in is not configured for this project.",
|
|
432
|
+
account_creation_failed: `Your ${providerLabel} account could not be linked. Please try again.`,
|
|
415
433
|
code_creation_failed: "Gonvex could not finish creating a sign-in code. Please try again.",
|
|
416
434
|
};
|
|
417
435
|
safeSessionStorageRemove(options.pkceStorageKey);
|
|
418
436
|
clearAuthCallbackParams(url, pkce?.returnTo);
|
|
419
|
-
throw new Error(messages[callbackError] ??
|
|
437
|
+
throw new Error(messages[callbackError] ?? `${providerLabel} sign-in failed (${callbackError}). Please try again.`);
|
|
420
438
|
}
|
|
421
439
|
if (!pkce || !returnedState || returnedState !== pkce.state || Date.now() - pkce.createdAt > 10 * 60 * 1000) {
|
|
422
440
|
clearAuthCallbackParams(url, pkce?.returnTo);
|
|
423
|
-
throw new Error(
|
|
441
|
+
throw new Error(`The ${providerLabel} sign-in response could not be verified. Please try again.`);
|
|
424
442
|
}
|
|
425
443
|
// Consume PKCE only after validation so a concurrent remount still sees it.
|
|
426
444
|
safeSessionStorageRemove(options.pkceStorageKey);
|
|
@@ -434,6 +452,16 @@ async function bootstrapGonvexAuth(options) {
|
|
|
434
452
|
safeLocalStorageSet(options.storageKey, JSON.stringify(session));
|
|
435
453
|
return session;
|
|
436
454
|
}
|
|
455
|
+
function sessionFromNativeGrant(value, previous) {
|
|
456
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || !isGonvexAuthSession(value)) {
|
|
457
|
+
throw new GonvexAuthRequestError("Gonvex returned an invalid native session.", 502);
|
|
458
|
+
}
|
|
459
|
+
const session = value;
|
|
460
|
+
const activeTenantId = previous && session.tenants.some((tenant) => tenant.id === previous.activeTenantId)
|
|
461
|
+
? previous.activeTenantId
|
|
462
|
+
: session.activeTenantId;
|
|
463
|
+
return { ...session, activeTenantId };
|
|
464
|
+
}
|
|
437
465
|
async function requestGonvexAuthToken(runtimeUrl, body) {
|
|
438
466
|
const controller = new AbortController();
|
|
439
467
|
const timeout = window.setTimeout(() => controller.abort(), 15_000);
|
|
@@ -461,7 +489,7 @@ function isFatalRefreshError(cause) {
|
|
|
461
489
|
}
|
|
462
490
|
function isGonvexAuthSession(value) {
|
|
463
491
|
return Boolean(value.accessToken && value.expiresAt && value.refreshToken && value.refreshExpiresAt
|
|
464
|
-
&& value.
|
|
492
|
+
&& value.account?.id && Array.isArray(value.tenants));
|
|
465
493
|
}
|
|
466
494
|
async function withBrowserAuthLock(name, action) {
|
|
467
495
|
const locks = typeof navigator === "undefined"
|
|
@@ -534,7 +562,7 @@ function readAuthSession(key) {
|
|
|
534
562
|
return null;
|
|
535
563
|
try {
|
|
536
564
|
const parsed = JSON.parse(localStorage.getItem(key) ?? "null");
|
|
537
|
-
if (!parsed?.accessToken || !parsed.refreshToken || !parsed.
|
|
565
|
+
if (!parsed?.accessToken || !parsed.refreshToken || !parsed.account?.id || !Array.isArray(parsed.tenants) || parsed.refreshExpiresAt <= Date.now()) {
|
|
538
566
|
safeLocalStorageRemove(key);
|
|
539
567
|
return null;
|
|
540
568
|
}
|
|
@@ -575,11 +603,7 @@ function bytesToBase64Url(bytes) {
|
|
|
575
603
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
576
604
|
}
|
|
577
605
|
const DEFAULT_LIVE_QUERY_SLOW_MS = 15_000;
|
|
578
|
-
/**
|
|
579
|
-
* Live query hook with an explicit status surface. Unlike `useQuery`, this
|
|
580
|
-
* distinguishes loading / success / error / timeout / disconnected, keeps the
|
|
581
|
-
* last good result while reconnecting, and exposes `retry()`.
|
|
582
|
-
*/
|
|
606
|
+
/** One-shot Query hook with explicit loading/error/timeout status and retry. */
|
|
583
607
|
export function useQueryResult(ref, args = {}, options = {}) {
|
|
584
608
|
const client = useGonvexClient();
|
|
585
609
|
const path = ref.path;
|
|
@@ -588,6 +612,7 @@ export function useQueryResult(ref, args = {}, options = {}) {
|
|
|
588
612
|
const argsKey = JSON.stringify(args);
|
|
589
613
|
const keepPreviousData = options.keepPreviousData !== false;
|
|
590
614
|
const timeoutMs = options.timeoutMs ?? DEFAULT_LIVE_QUERY_SLOW_MS;
|
|
615
|
+
const [requestGeneration, setRequestGeneration] = useState(0);
|
|
591
616
|
const [state, setState] = useState({
|
|
592
617
|
data: undefined,
|
|
593
618
|
status: args === "skip" ? "skip" : "loading",
|
|
@@ -619,147 +644,183 @@ export function useQueryResult(ref, args = {}, options = {}) {
|
|
|
619
644
|
setState({ data: undefined, status: "skip", error: null, isStale: false });
|
|
620
645
|
return;
|
|
621
646
|
}
|
|
622
|
-
setState(
|
|
647
|
+
setState((previous) => ({
|
|
648
|
+
data: keepPreviousData ? previous.data : undefined,
|
|
649
|
+
status: "loading",
|
|
650
|
+
error: null,
|
|
651
|
+
isStale: keepPreviousData && previous.data !== undefined,
|
|
652
|
+
}));
|
|
623
653
|
startSlowTimer();
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
});
|
|
628
|
-
const unsubscribeQuery = client.subscribeQuery(ref, args, (message) => {
|
|
629
|
-
if (message.type === "query.result") {
|
|
630
|
-
clearSlowTimer();
|
|
631
|
-
setState({ data: message.result, status: "success", error: null, isStale: false });
|
|
632
|
-
}
|
|
633
|
-
if (message.type === "query.error") {
|
|
634
|
-
clearSlowTimer();
|
|
635
|
-
const error = new GonvexClientError(message.error, { code: "server", path, operation: "query" });
|
|
636
|
-
setState((previous) => ({
|
|
637
|
-
data: keepPreviousData ? previous.data : undefined,
|
|
638
|
-
status: "error",
|
|
639
|
-
error,
|
|
640
|
-
isStale: keepPreviousData && previous.data !== undefined,
|
|
641
|
-
}));
|
|
642
|
-
}
|
|
643
|
-
});
|
|
644
|
-
const applyConnection = (connection) => {
|
|
645
|
-
if (!connection.isWebSocketConnected) {
|
|
646
|
-
clearSlowTimer();
|
|
647
|
-
setState((previous) => {
|
|
648
|
-
if (previous.status === "skip")
|
|
649
|
-
return previous;
|
|
650
|
-
return {
|
|
651
|
-
data: keepPreviousData ? previous.data : undefined,
|
|
652
|
-
status: "disconnected",
|
|
653
|
-
error: previous.error,
|
|
654
|
-
isStale: keepPreviousData && previous.data !== undefined,
|
|
655
|
-
};
|
|
656
|
-
});
|
|
654
|
+
let active = true;
|
|
655
|
+
void client.query(ref, args).then((data) => {
|
|
656
|
+
if (!active)
|
|
657
657
|
return;
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
})()
|
|
675
|
-
: undefined;
|
|
658
|
+
clearSlowTimer();
|
|
659
|
+
setState({ data, status: "success", error: null, isStale: false });
|
|
660
|
+
}, (failure) => {
|
|
661
|
+
if (!active)
|
|
662
|
+
return;
|
|
663
|
+
clearSlowTimer();
|
|
664
|
+
const error = failure instanceof Error
|
|
665
|
+
? failure
|
|
666
|
+
: new GonvexClientError(String(failure), { code: "server", path, operation: "query" });
|
|
667
|
+
setState((previous) => ({
|
|
668
|
+
data: keepPreviousData ? previous.data : undefined,
|
|
669
|
+
status: "error",
|
|
670
|
+
error,
|
|
671
|
+
isStale: keepPreviousData && previous.data !== undefined,
|
|
672
|
+
}));
|
|
673
|
+
});
|
|
676
674
|
return () => {
|
|
675
|
+
active = false;
|
|
677
676
|
clearSlowTimer();
|
|
678
|
-
unsubscribeScope();
|
|
679
|
-
unsubscribeQuery();
|
|
680
|
-
unsubscribeConnection?.();
|
|
681
677
|
};
|
|
682
678
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
683
|
-
}, [client, kind, path, optimisticKey, argsKey, keepPreviousData, startSlowTimer, clearSlowTimer]);
|
|
679
|
+
}, [client, kind, path, optimisticKey, argsKey, keepPreviousData, requestGeneration, startSlowTimer, clearSlowTimer]);
|
|
684
680
|
const retry = useCallback(() => {
|
|
685
681
|
if (args === "skip")
|
|
686
682
|
return;
|
|
687
|
-
|
|
688
|
-
data: previous.data,
|
|
689
|
-
status: "loading",
|
|
690
|
-
error: null,
|
|
691
|
-
isStale: previous.data !== undefined,
|
|
692
|
-
}));
|
|
693
|
-
startSlowTimer();
|
|
694
|
-
if (typeof client.retryQuery === "function") {
|
|
695
|
-
client.retryQuery(ref, JSON.parse(argsKey));
|
|
696
|
-
}
|
|
683
|
+
setRequestGeneration((generation) => generation + 1);
|
|
697
684
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
698
|
-
}, [
|
|
685
|
+
}, [argsKey]);
|
|
699
686
|
return {
|
|
700
687
|
data: state.data,
|
|
701
688
|
status: state.status,
|
|
702
689
|
error: state.error,
|
|
703
690
|
isLoading: state.status === "loading",
|
|
704
|
-
isError: state.status === "error" || state.status === "timeout"
|
|
691
|
+
isError: state.status === "error" || state.status === "timeout",
|
|
705
692
|
isSuccess: state.status === "success",
|
|
706
693
|
isStale: state.isStale,
|
|
707
694
|
retry,
|
|
708
695
|
};
|
|
709
696
|
}
|
|
710
|
-
export function
|
|
697
|
+
export function useLiveQuery(ref, args = {}) {
|
|
711
698
|
const client = useGonvexClient();
|
|
712
|
-
|
|
713
|
-
|
|
699
|
+
if (ref.delivery !== "live" || !ref.live?.plan) {
|
|
700
|
+
throw new Error(`useLiveQuery requires a structured Live Query reference: ${ref.path}`);
|
|
701
|
+
}
|
|
714
702
|
const path = ref.path;
|
|
715
703
|
const kind = ref.kind;
|
|
716
704
|
const optimisticKey = JSON.stringify(ref.optimistic ?? null);
|
|
717
705
|
const argsKey = JSON.stringify(args);
|
|
706
|
+
const liveKey = JSON.stringify(ref.live ?? null);
|
|
707
|
+
const liveWatch = useMemo(() => args === "skip" ? undefined : client.watchLiveQuery(ref, args),
|
|
708
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
709
|
+
[client, kind, path, optimisticKey, argsKey, liveKey]);
|
|
710
|
+
const liveResult = useSyncExternalStore(useCallback((notify) => liveWatch?.onUpdate(notify) ?? (() => undefined), [liveWatch]), useCallback(() => liveWatch?.localLiveQueryResult(), [liveWatch]), () => undefined);
|
|
711
|
+
return liveResult;
|
|
712
|
+
}
|
|
713
|
+
/** Subscribe to a host-owned Control Plane Query on the existing Gonvex connection. */
|
|
714
|
+
export function useControlQuery(ref, args = {}) {
|
|
715
|
+
const client = useGonvexClient();
|
|
716
|
+
const argsKey = JSON.stringify(args);
|
|
717
|
+
const watch = useMemo(() => args === "skip" ? undefined : client.watchControlQuery(ref, args),
|
|
718
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
719
|
+
[client, ref.kind, ref.path, argsKey]);
|
|
720
|
+
const snapshot = useSyncExternalStore(useCallback((notify) => watch?.onUpdate(notify) ?? (() => undefined), [watch]), useCallback(() => watch?.getSnapshot(), [watch]), () => undefined);
|
|
721
|
+
return snapshot?.result;
|
|
722
|
+
}
|
|
723
|
+
/** Read one normalized entity from the single Gonvex Local Replica. */
|
|
724
|
+
export function useEntity(entity, id) {
|
|
725
|
+
const client = useGonvexClient();
|
|
726
|
+
useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), useCallback(() => client.localReplica.version(), [client]), () => 0);
|
|
727
|
+
return client.localReplica.entity(entity, id);
|
|
728
|
+
}
|
|
729
|
+
/** Resolve an ordered entity batch with one Local Replica subscription. */
|
|
730
|
+
export function useReplicaEntities(entity, ids) {
|
|
731
|
+
const client = useGonvexClient();
|
|
732
|
+
const idsKey = JSON.stringify(ids);
|
|
733
|
+
const version = useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), useCallback(() => client.localReplica.version(), [client]), () => 0);
|
|
734
|
+
return useMemo(() => client.replicaEntities(entity, ids), [client, entity, idsKey, version]);
|
|
735
|
+
}
|
|
736
|
+
/** Read a persisted Live Query window without opening another server subscription. */
|
|
737
|
+
export function useRetainedLiveQuery(signatureOrReference, args = {}) {
|
|
738
|
+
const client = useGonvexClient();
|
|
739
|
+
const argsKey = JSON.stringify(args);
|
|
740
|
+
const signature = typeof signatureOrReference === "string"
|
|
741
|
+
? signatureOrReference
|
|
742
|
+
: client.replicaSignature(signatureOrReference, args);
|
|
743
|
+
const version = useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), useCallback(() => client.localReplica.version(), [client]), () => 0);
|
|
744
|
+
return useMemo(() => client.retainedLiveQuery(signature), [client, signature, argsKey, version]);
|
|
745
|
+
}
|
|
746
|
+
/** Structured Live Query state backed by normalized Local Replica entities. */
|
|
747
|
+
export function useLiveQueryState(ref, args = {}) {
|
|
748
|
+
const client = useGonvexClient();
|
|
749
|
+
const argsKey = JSON.stringify(args);
|
|
750
|
+
const signature = args === "skip" ? "" : client.replicaSignature(ref, args);
|
|
751
|
+
useEffect(() => {
|
|
752
|
+
if (args === "skip")
|
|
753
|
+
return;
|
|
754
|
+
return client.subscribeLiveQuery(ref, args, () => undefined);
|
|
755
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
756
|
+
}, [client, ref.kind, ref.path, argsKey]);
|
|
757
|
+
useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), useCallback(() => client.localReplica.version(), [client]), () => 0);
|
|
758
|
+
if (args !== "skip" && client.localReplica.freshness() === "offline") {
|
|
759
|
+
const offline = client.offlineLiveQuery(ref, args);
|
|
760
|
+
return {
|
|
761
|
+
rows: offline.rows,
|
|
762
|
+
ids: offline.rows.map((row) => String(row.id ?? row._id ?? "")).filter(Boolean),
|
|
763
|
+
...(offline.total === undefined ? {} : { total: offline.total }),
|
|
764
|
+
...(offline.offset === undefined ? {} : { offset: offline.offset }),
|
|
765
|
+
...(offline.limit === undefined ? {} : { limit: offline.limit }),
|
|
766
|
+
source: "cache",
|
|
767
|
+
completeness: offline.completeness,
|
|
768
|
+
freshness: "offline",
|
|
769
|
+
supported: offline.supported,
|
|
770
|
+
...(offline.unsupportedOperator ? { unsupportedOperator: offline.unsupportedOperator } : {}),
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
return signature
|
|
774
|
+
? client.localReplica.liveQuery(signature)
|
|
775
|
+
: { rows: [], ids: [], source: "cache", completeness: "partial", freshness: client.localReplica.freshness() };
|
|
776
|
+
}
|
|
777
|
+
/** Execute a read-only Query once. Queries never subscribe or rerun. */
|
|
778
|
+
export function useQuery(ref, args = {}) {
|
|
779
|
+
const client = useGonvexClient();
|
|
780
|
+
const [result, setResult] = useState();
|
|
781
|
+
const [error, setError] = useState(null);
|
|
782
|
+
const argsKey = JSON.stringify(args);
|
|
718
783
|
useEffect(() => {
|
|
784
|
+
let active = true;
|
|
719
785
|
if (args === "skip") {
|
|
720
786
|
setResult(undefined);
|
|
721
787
|
setError(null);
|
|
722
|
-
return;
|
|
788
|
+
return () => { active = false; };
|
|
723
789
|
}
|
|
724
790
|
setResult(undefined);
|
|
725
791
|
setError(null);
|
|
726
|
-
|
|
727
|
-
setResult(
|
|
728
|
-
setError(
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
setResult(message.result);
|
|
733
|
-
setError(null);
|
|
734
|
-
}
|
|
735
|
-
if (message.type === "query.error") {
|
|
736
|
-
setResult(undefined);
|
|
737
|
-
setError(new GonvexClientError(message.error, { code: "server", path, operation: "query" }));
|
|
738
|
-
}
|
|
739
|
-
});
|
|
740
|
-
return () => {
|
|
741
|
-
unsubscribeScope();
|
|
742
|
-
unsubscribeQuery();
|
|
743
|
-
};
|
|
744
|
-
}, [client, kind, path, optimisticKey, argsKey]);
|
|
745
|
-
// Convex-compatible: a failed query throws during render so error
|
|
746
|
-
// boundaries can catch it, instead of being indistinguishable from loading.
|
|
792
|
+
void client.query(ref, args).then((value) => { if (active)
|
|
793
|
+
setResult(value); }, (failure) => { if (active)
|
|
794
|
+
setError(failure instanceof Error ? failure : new Error(String(failure))); });
|
|
795
|
+
return () => { active = false; };
|
|
796
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
797
|
+
}, [client, ref.kind, ref.path, argsKey]);
|
|
747
798
|
if (error)
|
|
748
799
|
throw error;
|
|
749
800
|
return result;
|
|
750
801
|
}
|
|
751
|
-
export function
|
|
802
|
+
export function useReplicaCollection(ref, args = {}) {
|
|
752
803
|
const client = useGonvexClient();
|
|
753
804
|
const path = ref.path;
|
|
754
805
|
const kind = ref.kind;
|
|
755
806
|
const optimisticKey = JSON.stringify(ref.optimistic ?? null);
|
|
756
807
|
const argsKey = JSON.stringify(args);
|
|
757
|
-
const watch = useMemo(() => args === "skip" ? undefined : client.
|
|
808
|
+
const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args),
|
|
758
809
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
759
810
|
[client, kind, path, optimisticKey, argsKey]);
|
|
760
|
-
return useSyncExternalStore(useCallback((onStoreChange) => watch?.onUpdate(onStoreChange) ?? (() => undefined), [watch]), useCallback(() => watch?.
|
|
811
|
+
return useSyncExternalStore(useCallback((onStoreChange) => watch?.onUpdate(onStoreChange) ?? (() => undefined), [watch]), useCallback(() => watch?.localReplicaResult(), [watch]), () => undefined);
|
|
812
|
+
}
|
|
813
|
+
/** Replica rows plus authoritative completeness, truncation, and freshness metadata. */
|
|
814
|
+
export function useReplicaCollectionState(ref, args = {}) {
|
|
815
|
+
const client = useGonvexClient();
|
|
816
|
+
const path = ref.path;
|
|
817
|
+
const argsKey = JSON.stringify(args);
|
|
818
|
+
const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args),
|
|
819
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
820
|
+
[client, ref.kind, path, argsKey]);
|
|
821
|
+
return useSyncExternalStore(useCallback((onStoreChange) => watch?.onUpdate(onStoreChange) ?? (() => undefined), [watch]), useCallback(() => watch?.localReplicaState(), [watch]), () => undefined);
|
|
761
822
|
}
|
|
762
|
-
export function
|
|
823
|
+
export function useReplicaSelector(ref, args, selector, isEqual = Object.is) {
|
|
763
824
|
const client = useGonvexClient();
|
|
764
825
|
const path = ref.path;
|
|
765
826
|
const kind = ref.kind;
|
|
@@ -769,7 +830,7 @@ export function useSyncSelector(ref, args, selector, isEqual = Object.is) {
|
|
|
769
830
|
const equalityRef = useRef(isEqual);
|
|
770
831
|
selectorRef.current = selector;
|
|
771
832
|
equalityRef.current = isEqual;
|
|
772
|
-
const watch = useMemo(() => args === "skip" ? undefined : client.
|
|
833
|
+
const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args),
|
|
773
834
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
774
835
|
[client, kind, path, optimisticKey, argsKey]);
|
|
775
836
|
const selectedRef = useRef({
|
|
@@ -780,7 +841,7 @@ export function useSyncSelector(ref, args, selector, isEqual = Object.is) {
|
|
|
780
841
|
selectedRef.current = { initialized: false, value: undefined };
|
|
781
842
|
}, [watch]);
|
|
782
843
|
const getSnapshot = useCallback(() => {
|
|
783
|
-
const rows = watch?.
|
|
844
|
+
const rows = watch?.localReplicaResult();
|
|
784
845
|
const next = rows === undefined ? undefined : selectorRef.current(rows);
|
|
785
846
|
if (!selectedRef.current.initialized
|
|
786
847
|
|| next === undefined
|
|
@@ -795,7 +856,7 @@ export function useSyncSelector(ref, args, selector, isEqual = Object.is) {
|
|
|
795
856
|
return () => undefined;
|
|
796
857
|
return watch.onUpdate(() => {
|
|
797
858
|
const previous = selectedRef.current.value;
|
|
798
|
-
const rows = watch.
|
|
859
|
+
const rows = watch.localReplicaResult();
|
|
799
860
|
const next = rows === undefined ? undefined : selectorRef.current(rows);
|
|
800
861
|
if (selectedRef.current.initialized
|
|
801
862
|
&& previous !== undefined
|
|
@@ -808,31 +869,25 @@ export function useSyncSelector(ref, args, selector, isEqual = Object.is) {
|
|
|
808
869
|
}, [watch]);
|
|
809
870
|
return useSyncExternalStore(subscribe, getSnapshot, () => undefined);
|
|
810
871
|
}
|
|
811
|
-
export function
|
|
872
|
+
export function useReducer(ref, options = {}) {
|
|
812
873
|
const client = useGonvexClient();
|
|
813
|
-
return (args = {}) => client.
|
|
874
|
+
return (args = {}) => client.reducer(ref, args, options);
|
|
814
875
|
}
|
|
815
876
|
export function useAction(ref, options = {}) {
|
|
816
877
|
const client = useGonvexClient();
|
|
817
878
|
return (args = {}) => client.action(ref, args, options);
|
|
818
879
|
}
|
|
819
|
-
export function useConvex() {
|
|
820
|
-
return useGonvexClient();
|
|
821
|
-
}
|
|
822
|
-
export function useConvexAuth() {
|
|
823
|
-
return useContext(GonvexAuthContext);
|
|
824
|
-
}
|
|
825
880
|
const FALLBACK_CONNECTION_STATE = {
|
|
826
881
|
isWebSocketConnected: false,
|
|
827
882
|
hasEverConnected: false,
|
|
828
883
|
connectionCount: 0,
|
|
829
884
|
connectionRetries: 0,
|
|
830
885
|
hasInflightRequests: false,
|
|
831
|
-
|
|
886
|
+
inflightReducers: 0,
|
|
832
887
|
inflightActions: 0,
|
|
833
888
|
inflightOneShotQueries: 0,
|
|
834
889
|
};
|
|
835
|
-
export function
|
|
890
|
+
export function useGonvexConnectionState() {
|
|
836
891
|
const client = useGonvexClient();
|
|
837
892
|
const [state, setState] = useState(() => (typeof client.connectionState === "function" ? client.connectionState() : FALLBACK_CONNECTION_STATE));
|
|
838
893
|
useEffect(() => {
|
|
@@ -843,18 +898,7 @@ export function useConvexConnectionState() {
|
|
|
843
898
|
}, [client]);
|
|
844
899
|
return state;
|
|
845
900
|
}
|
|
846
|
-
export function
|
|
847
|
-
const pageArgs = args === "skip" ? "skip" : { ...(isRecord(args) ? args : { args }), paginationOpts: { numItems: options.initialNumItems ?? 25, cursor: null } };
|
|
848
|
-
const result = useQuery(ref, pageArgs);
|
|
849
|
-
const page = Array.isArray(result) ? { page: result, isDone: true, continueCursor: null } : result;
|
|
850
|
-
return {
|
|
851
|
-
results: (page?.page ?? []),
|
|
852
|
-
status: args === "skip" ? "Exhausted" : result === undefined ? "LoadingFirstPage" : page?.isDone ? "Exhausted" : "CanLoadMore",
|
|
853
|
-
isLoading: args !== "skip" && result === undefined,
|
|
854
|
-
loadMore: (_numItems) => undefined,
|
|
855
|
-
};
|
|
856
|
-
}
|
|
857
|
-
function useGonvexClient() {
|
|
901
|
+
export function useGonvexClient() {
|
|
858
902
|
const client = useContext(GonvexContext);
|
|
859
903
|
if (!client)
|
|
860
904
|
throw new Error("GonvexProvider is required");
|