@gonvex/react 0.1.31 → 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 +52 -35
- package/dist/index.js +281 -226
- 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,13 +16,22 @@ 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);
|
|
22
|
+
const [clientAuthError, setClientAuthError] = useState(null);
|
|
23
|
+
const authError = auth.authError ?? clientAuthError;
|
|
24
|
+
useEffect(() => props.client.onAuthError((message) => {
|
|
25
|
+
setClientAuthError(new Error(message || "Authentication failed"));
|
|
26
|
+
setTokenReady(false);
|
|
27
|
+
}), [props.client]);
|
|
24
28
|
useEffect(() => {
|
|
25
29
|
setTokenReady(false);
|
|
26
|
-
if (auth.isLoading || !auth.isAuthenticated || !auth.fetchAccessToken)
|
|
30
|
+
if (auth.isLoading || !auth.isAuthenticated || !auth.fetchAccessToken) {
|
|
31
|
+
if (!auth.isLoading && !auth.isAuthenticated)
|
|
32
|
+
setClientAuthError(null);
|
|
27
33
|
return;
|
|
34
|
+
}
|
|
28
35
|
const fetchAccessToken = auth.fetchAccessToken;
|
|
29
36
|
let cancelled = false;
|
|
30
37
|
void fetchAccessToken({ forceRefreshToken: false }).then((token) => {
|
|
@@ -33,6 +40,7 @@ export function ConvexProviderWithAuth(props) {
|
|
|
33
40
|
// on reconnect and force-refreshes on auth.error itself, instead of
|
|
34
41
|
// replaying this token verbatim after it expires.
|
|
35
42
|
props.client.setAuth({ token: token ?? undefined, fetchToken: fetchAccessToken });
|
|
43
|
+
setClientAuthError(null);
|
|
36
44
|
setTokenReady(Boolean(token));
|
|
37
45
|
}
|
|
38
46
|
}, (error) => {
|
|
@@ -53,18 +61,14 @@ export function ConvexProviderWithAuth(props) {
|
|
|
53
61
|
}, [auth.isLoading, auth.isAuthenticated, auth.fetchAccessToken, props.client]);
|
|
54
62
|
const authValue = useMemo(() => ({
|
|
55
63
|
...auth,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
64
|
+
authError,
|
|
65
|
+
isLoading: !authError && (auth.isLoading || (auth.isAuthenticated && !tokenReady)),
|
|
66
|
+
isAuthenticated: !authError && auth.isAuthenticated && tokenReady,
|
|
67
|
+
}), [auth, authError, tokenReady]);
|
|
68
|
+
const shouldHoldChildren = !authError && (auth.isLoading || (auth.isAuthenticated && !tokenReady));
|
|
60
69
|
return (_jsx(GonvexAuthContext.Provider, { value: authValue, children: _jsx(GonvexProvider, { client: props.client, children: shouldHoldChildren ? null : props.children }) }));
|
|
61
70
|
}
|
|
62
|
-
/**
|
|
63
|
-
* Native Gonvex authentication. The runtime performs the one centrally
|
|
64
|
-
* configured Google OAuth flow, while each app uses PKCE and receives a
|
|
65
|
-
* project-scoped Gonvex session. No Firebase or Google SDK is loaded in the
|
|
66
|
-
* browser.
|
|
67
|
-
*/
|
|
71
|
+
/** Native Gonvex authentication with password or a configured OAuth provider. */
|
|
68
72
|
// Dedupe callback bootstrap across React StrictMode remounts so the OAuth
|
|
69
73
|
// code+PKCE exchange runs once. Without this, the first effect's finally
|
|
70
74
|
// clears sessionStorage PKCE before the remount can finish verification.
|
|
@@ -85,12 +89,15 @@ export function GonvexAuthProvider(props) {
|
|
|
85
89
|
if (next) {
|
|
86
90
|
if (persist)
|
|
87
91
|
safeLocalStorageSet(storageKey, JSON.stringify(next));
|
|
88
|
-
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
|
+
});
|
|
89
96
|
}
|
|
90
97
|
else {
|
|
91
98
|
if (persist)
|
|
92
99
|
safeLocalStorageRemove(storageKey);
|
|
93
|
-
props.client.setAuth({ project: props.projectId, tenant: undefined, token: undefined });
|
|
100
|
+
props.client.setAuth({ project: props.projectId, tenant: undefined, token: undefined, identity: undefined });
|
|
94
101
|
}
|
|
95
102
|
setSession(next);
|
|
96
103
|
}, [props.client, props.projectId, storageKey]);
|
|
@@ -98,7 +105,7 @@ export function GonvexAuthProvider(props) {
|
|
|
98
105
|
let cancelled = false;
|
|
99
106
|
let bootstrap = authBootstrapPromises.get(storageKey);
|
|
100
107
|
if (!bootstrap) {
|
|
101
|
-
bootstrap = bootstrapGonvexAuth({ callbackPath, pkceStorageKey, projectId: props.projectId, runtimeUrl, storageKey })
|
|
108
|
+
bootstrap = bootstrapGonvexAuth({ callbackPath, client: props.client, pkceStorageKey, projectId: props.projectId, runtimeUrl, storageKey })
|
|
102
109
|
.finally(() => {
|
|
103
110
|
// Keep the resolved promise briefly so a StrictMode remount attaches
|
|
104
111
|
// to the same result instead of re-running a spent OAuth code.
|
|
@@ -116,7 +123,7 @@ export function GonvexAuthProvider(props) {
|
|
|
116
123
|
}).catch((cause) => {
|
|
117
124
|
if (!cancelled) {
|
|
118
125
|
installSession(null);
|
|
119
|
-
setError(cause instanceof Error ? cause.message : "
|
|
126
|
+
setError(cause instanceof Error ? cause.message : "Sign-in failed.");
|
|
120
127
|
}
|
|
121
128
|
}).finally(() => {
|
|
122
129
|
if (!cancelled)
|
|
@@ -135,12 +142,8 @@ export function GonvexAuthProvider(props) {
|
|
|
135
142
|
if (!force && current.expiresAt > Date.now() + 60_000)
|
|
136
143
|
return current;
|
|
137
144
|
attemptedRefreshToken = current.refreshToken;
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
project: props.projectId,
|
|
141
|
-
refreshToken: current.refreshToken,
|
|
142
|
-
tenant: current.activeTenantId,
|
|
143
|
-
});
|
|
145
|
+
const grant = await props.client.action(control.auth.refreshSession, { refreshToken: current.refreshToken });
|
|
146
|
+
const next = sessionFromNativeGrant(grant, current);
|
|
144
147
|
// Persist the rotated token before releasing the cross-tab lock. The
|
|
145
148
|
// next waiter must never read and reuse the just-consumed refresh token.
|
|
146
149
|
safeLocalStorageSet(storageKey, JSON.stringify(next));
|
|
@@ -181,7 +184,7 @@ export function GonvexAuthProvider(props) {
|
|
|
181
184
|
});
|
|
182
185
|
refreshRef.current = request;
|
|
183
186
|
return request;
|
|
184
|
-
}, [installSession, props.
|
|
187
|
+
}, [installSession, props.client, storageKey]);
|
|
185
188
|
useEffect(() => {
|
|
186
189
|
if (!session)
|
|
187
190
|
return;
|
|
@@ -190,6 +193,25 @@ export function GonvexAuthProvider(props) {
|
|
|
190
193
|
const timeout = window.setTimeout(() => { void refreshSession(); }, delay);
|
|
191
194
|
return () => window.clearTimeout(timeout);
|
|
192
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]);
|
|
193
215
|
useEffect(() => {
|
|
194
216
|
const onStorage = (event) => {
|
|
195
217
|
if (event.key !== storageKey)
|
|
@@ -199,7 +221,7 @@ export function GonvexAuthProvider(props) {
|
|
|
199
221
|
window.addEventListener("storage", onStorage);
|
|
200
222
|
return () => window.removeEventListener("storage", onStorage);
|
|
201
223
|
}, [installSession, storageKey]);
|
|
202
|
-
const
|
|
224
|
+
const signInWithProvider = useCallback(async (provider) => {
|
|
203
225
|
setError(null);
|
|
204
226
|
const verifier = randomBase64Url(64);
|
|
205
227
|
const state = randomBase64Url(32);
|
|
@@ -207,8 +229,8 @@ export function GonvexAuthProvider(props) {
|
|
|
207
229
|
const challenge = bytesToBase64Url(new Uint8Array(challengeBytes));
|
|
208
230
|
const redirectUri = new URL(callbackPath, window.location.origin).toString();
|
|
209
231
|
const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
|
210
|
-
safeSessionStorageSet(pkceStorageKey, JSON.stringify({ state, verifier, redirectUri, returnTo, createdAt: Date.now() }));
|
|
211
|
-
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`);
|
|
212
234
|
authorizeUrl.searchParams.set("project", props.projectId);
|
|
213
235
|
authorizeUrl.searchParams.set("redirect_uri", redirectUri);
|
|
214
236
|
authorizeUrl.searchParams.set("state", state);
|
|
@@ -216,18 +238,21 @@ export function GonvexAuthProvider(props) {
|
|
|
216
238
|
authorizeUrl.searchParams.set("code_challenge_method", "S256");
|
|
217
239
|
window.location.assign(authorizeUrl.toString());
|
|
218
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]);
|
|
219
248
|
const signOut = useCallback(async (options) => {
|
|
220
249
|
const current = sessionRef.current;
|
|
221
|
-
installSession(null);
|
|
222
250
|
setError(null);
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
body: JSON.stringify({ refreshToken: current.refreshToken, all: options?.allDevices === true }),
|
|
229
|
-
}).catch(() => undefined);
|
|
230
|
-
}, [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]);
|
|
231
256
|
const fetchAccessToken = useCallback(async (args) => {
|
|
232
257
|
const current = sessionRef.current;
|
|
233
258
|
if (!current)
|
|
@@ -248,82 +273,67 @@ export function GonvexAuthProvider(props) {
|
|
|
248
273
|
if (!token)
|
|
249
274
|
throw new Error("Sign in before loading tenant memberships.");
|
|
250
275
|
const current = sessionRef.current;
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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]);
|
|
260
286
|
const createTenant = useCallback(async (name) => {
|
|
261
287
|
const token = await fetchAccessToken({ forceRefreshToken: false });
|
|
262
288
|
if (!token)
|
|
263
289
|
throw new Error("Sign in before creating a tenant.");
|
|
264
|
-
const
|
|
265
|
-
method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
266
|
-
body: JSON.stringify({ name }),
|
|
267
|
-
});
|
|
268
|
-
const payload = await response.json().catch(() => ({}));
|
|
269
|
-
if (!response.ok || !payload.tenant)
|
|
270
|
-
throw new Error(payload.error ?? "Could not create the tenant.");
|
|
290
|
+
const tenant = await props.client.reducer(control.tenants.create, { name });
|
|
271
291
|
const current = sessionRef.current;
|
|
272
|
-
installSession({ ...current, tenants: [...current.tenants.filter((
|
|
273
|
-
return
|
|
274
|
-
}, [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]);
|
|
275
295
|
const inviteMember = useCallback(async (tenantId, email, options) => {
|
|
276
296
|
const token = await fetchAccessToken({ forceRefreshToken: false });
|
|
277
297
|
if (!token)
|
|
278
298
|
throw new Error("Sign in before inviting a member.");
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
if (!token)
|
|
290
|
-
throw new Error("Sign in before removing a member.");
|
|
291
|
-
const response = await fetch(`${runtimeUrl}/auth/tenants/${encodeURIComponent(tenantId)}/members/${encodeURIComponent(userId)}`, {
|
|
292
|
-
method: "DELETE", headers: { authorization: `Bearer ${token}` },
|
|
293
|
-
});
|
|
294
|
-
const payload = await response.json().catch(() => ({}));
|
|
295
|
-
if (!response.ok)
|
|
296
|
-
throw new Error(payload.error ?? "Could not remove the member.");
|
|
297
|
-
}, [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]);
|
|
298
309
|
const revokeInvitation = useCallback(async (tenantId, email) => {
|
|
299
310
|
const token = await fetchAccessToken({ forceRefreshToken: false });
|
|
300
311
|
if (!token)
|
|
301
312
|
throw new Error("Sign in before revoking an invitation.");
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
if (!response.ok)
|
|
307
|
-
throw new Error(payload.error ?? "Could not revoke the invitation.");
|
|
308
|
-
}, [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]);
|
|
309
317
|
const activeTenant = session?.tenants.find((tenant) => tenant.id === session.activeTenantId) ?? null;
|
|
310
318
|
const authValue = useMemo(() => ({
|
|
311
319
|
isLoading,
|
|
312
320
|
isAuthenticated: Boolean(session && session.refreshExpiresAt > Date.now()),
|
|
313
321
|
fetchAccessToken,
|
|
314
|
-
|
|
322
|
+
account: session?.account ?? null,
|
|
315
323
|
tenants: session?.tenants ?? [],
|
|
316
324
|
activeTenant,
|
|
317
325
|
error,
|
|
318
326
|
signIn,
|
|
327
|
+
signInWithProvider,
|
|
328
|
+
signInWithPassword,
|
|
319
329
|
signOut,
|
|
320
330
|
setActiveTenant,
|
|
321
331
|
refreshMemberships,
|
|
322
332
|
createTenant,
|
|
323
333
|
inviteMember,
|
|
334
|
+
acceptInvitation,
|
|
324
335
|
revokeInvitation,
|
|
325
|
-
|
|
326
|
-
}), [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]);
|
|
327
337
|
return (_jsx(ManagedAuthContext.Provider, { value: authValue, children: _jsx(GonvexAuthContext.Provider, { value: authValue, children: _jsx(GonvexProvider, { client: props.client, children: isLoading ? null : props.children }) }) }));
|
|
328
338
|
}
|
|
329
339
|
export function useGonvexAuth() {
|
|
@@ -332,6 +342,18 @@ export function useGonvexAuth() {
|
|
|
332
342
|
throw new Error("GonvexAuthProvider is required");
|
|
333
343
|
return value;
|
|
334
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
|
+
}
|
|
335
357
|
export function GonvexGoogleAuthButton(props) {
|
|
336
358
|
const { signOutLabel = "Sign out", children, disabled, onClick, ...buttonProps } = props;
|
|
337
359
|
const auth = useGonvexAuth();
|
|
@@ -375,10 +397,9 @@ async function bootstrapGonvexAuth(options) {
|
|
|
375
397
|
return latest;
|
|
376
398
|
if (latest.refreshExpiresAt <= Date.now())
|
|
377
399
|
throw new GonvexAuthRequestError("Your session expired. Please sign in again.", 401);
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
});
|
|
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);
|
|
382
403
|
safeLocalStorageSet(options.storageKey, JSON.stringify(next));
|
|
383
404
|
return next;
|
|
384
405
|
});
|
|
@@ -390,26 +411,34 @@ async function bootstrapGonvexAuth(options) {
|
|
|
390
411
|
}
|
|
391
412
|
}
|
|
392
413
|
const pkce = readPKCE(options.pkceStorageKey);
|
|
393
|
-
|
|
394
|
-
|
|
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.
|
|
395
417
|
if (callbackError) {
|
|
396
418
|
const messages = {
|
|
397
|
-
access_denied:
|
|
398
|
-
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.`,
|
|
399
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.",
|
|
400
423
|
membership_setup_failed: "Your account was verified, but its workspace could not be prepared. Please try again.",
|
|
401
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.",
|
|
402
427
|
invalid_google_identity: "Google identity verification failed. Please try again.",
|
|
403
|
-
|
|
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.`,
|
|
404
433
|
code_creation_failed: "Gonvex could not finish creating a sign-in code. Please try again.",
|
|
405
434
|
};
|
|
406
435
|
safeSessionStorageRemove(options.pkceStorageKey);
|
|
407
436
|
clearAuthCallbackParams(url, pkce?.returnTo);
|
|
408
|
-
throw new Error(messages[callbackError] ??
|
|
437
|
+
throw new Error(messages[callbackError] ?? `${providerLabel} sign-in failed (${callbackError}). Please try again.`);
|
|
409
438
|
}
|
|
410
439
|
if (!pkce || !returnedState || returnedState !== pkce.state || Date.now() - pkce.createdAt > 10 * 60 * 1000) {
|
|
411
440
|
clearAuthCallbackParams(url, pkce?.returnTo);
|
|
412
|
-
throw new Error(
|
|
441
|
+
throw new Error(`The ${providerLabel} sign-in response could not be verified. Please try again.`);
|
|
413
442
|
}
|
|
414
443
|
// Consume PKCE only after validation so a concurrent remount still sees it.
|
|
415
444
|
safeSessionStorageRemove(options.pkceStorageKey);
|
|
@@ -423,6 +452,16 @@ async function bootstrapGonvexAuth(options) {
|
|
|
423
452
|
safeLocalStorageSet(options.storageKey, JSON.stringify(session));
|
|
424
453
|
return session;
|
|
425
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
|
+
}
|
|
426
465
|
async function requestGonvexAuthToken(runtimeUrl, body) {
|
|
427
466
|
const controller = new AbortController();
|
|
428
467
|
const timeout = window.setTimeout(() => controller.abort(), 15_000);
|
|
@@ -450,7 +489,7 @@ function isFatalRefreshError(cause) {
|
|
|
450
489
|
}
|
|
451
490
|
function isGonvexAuthSession(value) {
|
|
452
491
|
return Boolean(value.accessToken && value.expiresAt && value.refreshToken && value.refreshExpiresAt
|
|
453
|
-
&& value.
|
|
492
|
+
&& value.account?.id && Array.isArray(value.tenants));
|
|
454
493
|
}
|
|
455
494
|
async function withBrowserAuthLock(name, action) {
|
|
456
495
|
const locks = typeof navigator === "undefined"
|
|
@@ -523,7 +562,7 @@ function readAuthSession(key) {
|
|
|
523
562
|
return null;
|
|
524
563
|
try {
|
|
525
564
|
const parsed = JSON.parse(localStorage.getItem(key) ?? "null");
|
|
526
|
-
if (!parsed?.accessToken || !parsed.refreshToken || !parsed.
|
|
565
|
+
if (!parsed?.accessToken || !parsed.refreshToken || !parsed.account?.id || !Array.isArray(parsed.tenants) || parsed.refreshExpiresAt <= Date.now()) {
|
|
527
566
|
safeLocalStorageRemove(key);
|
|
528
567
|
return null;
|
|
529
568
|
}
|
|
@@ -564,11 +603,7 @@ function bytesToBase64Url(bytes) {
|
|
|
564
603
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
565
604
|
}
|
|
566
605
|
const DEFAULT_LIVE_QUERY_SLOW_MS = 15_000;
|
|
567
|
-
/**
|
|
568
|
-
* Live query hook with an explicit status surface. Unlike `useQuery`, this
|
|
569
|
-
* distinguishes loading / success / error / timeout / disconnected, keeps the
|
|
570
|
-
* last good result while reconnecting, and exposes `retry()`.
|
|
571
|
-
*/
|
|
606
|
+
/** One-shot Query hook with explicit loading/error/timeout status and retry. */
|
|
572
607
|
export function useQueryResult(ref, args = {}, options = {}) {
|
|
573
608
|
const client = useGonvexClient();
|
|
574
609
|
const path = ref.path;
|
|
@@ -577,6 +612,7 @@ export function useQueryResult(ref, args = {}, options = {}) {
|
|
|
577
612
|
const argsKey = JSON.stringify(args);
|
|
578
613
|
const keepPreviousData = options.keepPreviousData !== false;
|
|
579
614
|
const timeoutMs = options.timeoutMs ?? DEFAULT_LIVE_QUERY_SLOW_MS;
|
|
615
|
+
const [requestGeneration, setRequestGeneration] = useState(0);
|
|
580
616
|
const [state, setState] = useState({
|
|
581
617
|
data: undefined,
|
|
582
618
|
status: args === "skip" ? "skip" : "loading",
|
|
@@ -608,147 +644,183 @@ export function useQueryResult(ref, args = {}, options = {}) {
|
|
|
608
644
|
setState({ data: undefined, status: "skip", error: null, isStale: false });
|
|
609
645
|
return;
|
|
610
646
|
}
|
|
611
|
-
setState(
|
|
647
|
+
setState((previous) => ({
|
|
648
|
+
data: keepPreviousData ? previous.data : undefined,
|
|
649
|
+
status: "loading",
|
|
650
|
+
error: null,
|
|
651
|
+
isStale: keepPreviousData && previous.data !== undefined,
|
|
652
|
+
}));
|
|
612
653
|
startSlowTimer();
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
});
|
|
617
|
-
const unsubscribeQuery = client.subscribeQuery(ref, args, (message) => {
|
|
618
|
-
if (message.type === "query.result") {
|
|
619
|
-
clearSlowTimer();
|
|
620
|
-
setState({ data: message.result, status: "success", error: null, isStale: false });
|
|
621
|
-
}
|
|
622
|
-
if (message.type === "query.error") {
|
|
623
|
-
clearSlowTimer();
|
|
624
|
-
const error = new GonvexClientError(message.error, { code: "server", path, operation: "query" });
|
|
625
|
-
setState((previous) => ({
|
|
626
|
-
data: keepPreviousData ? previous.data : undefined,
|
|
627
|
-
status: "error",
|
|
628
|
-
error,
|
|
629
|
-
isStale: keepPreviousData && previous.data !== undefined,
|
|
630
|
-
}));
|
|
631
|
-
}
|
|
632
|
-
});
|
|
633
|
-
const applyConnection = (connection) => {
|
|
634
|
-
if (!connection.isWebSocketConnected) {
|
|
635
|
-
clearSlowTimer();
|
|
636
|
-
setState((previous) => {
|
|
637
|
-
if (previous.status === "skip")
|
|
638
|
-
return previous;
|
|
639
|
-
return {
|
|
640
|
-
data: keepPreviousData ? previous.data : undefined,
|
|
641
|
-
status: "disconnected",
|
|
642
|
-
error: previous.error,
|
|
643
|
-
isStale: keepPreviousData && previous.data !== undefined,
|
|
644
|
-
};
|
|
645
|
-
});
|
|
654
|
+
let active = true;
|
|
655
|
+
void client.query(ref, args).then((data) => {
|
|
656
|
+
if (!active)
|
|
646
657
|
return;
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
})()
|
|
664
|
-
: 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
|
+
});
|
|
665
674
|
return () => {
|
|
675
|
+
active = false;
|
|
666
676
|
clearSlowTimer();
|
|
667
|
-
unsubscribeScope();
|
|
668
|
-
unsubscribeQuery();
|
|
669
|
-
unsubscribeConnection?.();
|
|
670
677
|
};
|
|
671
678
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
672
|
-
}, [client, kind, path, optimisticKey, argsKey, keepPreviousData, startSlowTimer, clearSlowTimer]);
|
|
679
|
+
}, [client, kind, path, optimisticKey, argsKey, keepPreviousData, requestGeneration, startSlowTimer, clearSlowTimer]);
|
|
673
680
|
const retry = useCallback(() => {
|
|
674
681
|
if (args === "skip")
|
|
675
682
|
return;
|
|
676
|
-
|
|
677
|
-
data: previous.data,
|
|
678
|
-
status: "loading",
|
|
679
|
-
error: null,
|
|
680
|
-
isStale: previous.data !== undefined,
|
|
681
|
-
}));
|
|
682
|
-
startSlowTimer();
|
|
683
|
-
if (typeof client.retryQuery === "function") {
|
|
684
|
-
client.retryQuery(ref, JSON.parse(argsKey));
|
|
685
|
-
}
|
|
683
|
+
setRequestGeneration((generation) => generation + 1);
|
|
686
684
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
687
|
-
}, [
|
|
685
|
+
}, [argsKey]);
|
|
688
686
|
return {
|
|
689
687
|
data: state.data,
|
|
690
688
|
status: state.status,
|
|
691
689
|
error: state.error,
|
|
692
690
|
isLoading: state.status === "loading",
|
|
693
|
-
isError: state.status === "error" || state.status === "timeout"
|
|
691
|
+
isError: state.status === "error" || state.status === "timeout",
|
|
694
692
|
isSuccess: state.status === "success",
|
|
695
693
|
isStale: state.isStale,
|
|
696
694
|
retry,
|
|
697
695
|
};
|
|
698
696
|
}
|
|
699
|
-
export function
|
|
697
|
+
export function useLiveQuery(ref, args = {}) {
|
|
700
698
|
const client = useGonvexClient();
|
|
701
|
-
|
|
702
|
-
|
|
699
|
+
if (ref.delivery !== "live" || !ref.live?.plan) {
|
|
700
|
+
throw new Error(`useLiveQuery requires a structured Live Query reference: ${ref.path}`);
|
|
701
|
+
}
|
|
703
702
|
const path = ref.path;
|
|
704
703
|
const kind = ref.kind;
|
|
705
704
|
const optimisticKey = JSON.stringify(ref.optimistic ?? null);
|
|
706
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);
|
|
707
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);
|
|
783
|
+
useEffect(() => {
|
|
784
|
+
let active = true;
|
|
708
785
|
if (args === "skip") {
|
|
709
786
|
setResult(undefined);
|
|
710
787
|
setError(null);
|
|
711
|
-
return;
|
|
788
|
+
return () => { active = false; };
|
|
712
789
|
}
|
|
713
790
|
setResult(undefined);
|
|
714
791
|
setError(null);
|
|
715
|
-
|
|
716
|
-
setResult(
|
|
717
|
-
setError(
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
setResult(message.result);
|
|
722
|
-
setError(null);
|
|
723
|
-
}
|
|
724
|
-
if (message.type === "query.error") {
|
|
725
|
-
setResult(undefined);
|
|
726
|
-
setError(new GonvexClientError(message.error, { code: "server", path, operation: "query" }));
|
|
727
|
-
}
|
|
728
|
-
});
|
|
729
|
-
return () => {
|
|
730
|
-
unsubscribeScope();
|
|
731
|
-
unsubscribeQuery();
|
|
732
|
-
};
|
|
733
|
-
}, [client, kind, path, optimisticKey, argsKey]);
|
|
734
|
-
// Convex-compatible: a failed query throws during render so error
|
|
735
|
-
// 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]);
|
|
736
798
|
if (error)
|
|
737
799
|
throw error;
|
|
738
800
|
return result;
|
|
739
801
|
}
|
|
740
|
-
export function
|
|
802
|
+
export function useReplicaCollection(ref, args = {}) {
|
|
741
803
|
const client = useGonvexClient();
|
|
742
804
|
const path = ref.path;
|
|
743
805
|
const kind = ref.kind;
|
|
744
806
|
const optimisticKey = JSON.stringify(ref.optimistic ?? null);
|
|
745
807
|
const argsKey = JSON.stringify(args);
|
|
746
|
-
const watch = useMemo(() => args === "skip" ? undefined : client.
|
|
808
|
+
const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args),
|
|
747
809
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
748
810
|
[client, kind, path, optimisticKey, argsKey]);
|
|
749
|
-
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);
|
|
750
822
|
}
|
|
751
|
-
export function
|
|
823
|
+
export function useReplicaSelector(ref, args, selector, isEqual = Object.is) {
|
|
752
824
|
const client = useGonvexClient();
|
|
753
825
|
const path = ref.path;
|
|
754
826
|
const kind = ref.kind;
|
|
@@ -758,7 +830,7 @@ export function useSyncSelector(ref, args, selector, isEqual = Object.is) {
|
|
|
758
830
|
const equalityRef = useRef(isEqual);
|
|
759
831
|
selectorRef.current = selector;
|
|
760
832
|
equalityRef.current = isEqual;
|
|
761
|
-
const watch = useMemo(() => args === "skip" ? undefined : client.
|
|
833
|
+
const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args),
|
|
762
834
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
763
835
|
[client, kind, path, optimisticKey, argsKey]);
|
|
764
836
|
const selectedRef = useRef({
|
|
@@ -769,7 +841,7 @@ export function useSyncSelector(ref, args, selector, isEqual = Object.is) {
|
|
|
769
841
|
selectedRef.current = { initialized: false, value: undefined };
|
|
770
842
|
}, [watch]);
|
|
771
843
|
const getSnapshot = useCallback(() => {
|
|
772
|
-
const rows = watch?.
|
|
844
|
+
const rows = watch?.localReplicaResult();
|
|
773
845
|
const next = rows === undefined ? undefined : selectorRef.current(rows);
|
|
774
846
|
if (!selectedRef.current.initialized
|
|
775
847
|
|| next === undefined
|
|
@@ -784,7 +856,7 @@ export function useSyncSelector(ref, args, selector, isEqual = Object.is) {
|
|
|
784
856
|
return () => undefined;
|
|
785
857
|
return watch.onUpdate(() => {
|
|
786
858
|
const previous = selectedRef.current.value;
|
|
787
|
-
const rows = watch.
|
|
859
|
+
const rows = watch.localReplicaResult();
|
|
788
860
|
const next = rows === undefined ? undefined : selectorRef.current(rows);
|
|
789
861
|
if (selectedRef.current.initialized
|
|
790
862
|
&& previous !== undefined
|
|
@@ -797,31 +869,25 @@ export function useSyncSelector(ref, args, selector, isEqual = Object.is) {
|
|
|
797
869
|
}, [watch]);
|
|
798
870
|
return useSyncExternalStore(subscribe, getSnapshot, () => undefined);
|
|
799
871
|
}
|
|
800
|
-
export function
|
|
872
|
+
export function useReducer(ref, options = {}) {
|
|
801
873
|
const client = useGonvexClient();
|
|
802
|
-
return (args = {}) => client.
|
|
874
|
+
return (args = {}) => client.reducer(ref, args, options);
|
|
803
875
|
}
|
|
804
876
|
export function useAction(ref, options = {}) {
|
|
805
877
|
const client = useGonvexClient();
|
|
806
878
|
return (args = {}) => client.action(ref, args, options);
|
|
807
879
|
}
|
|
808
|
-
export function useConvex() {
|
|
809
|
-
return useGonvexClient();
|
|
810
|
-
}
|
|
811
|
-
export function useConvexAuth() {
|
|
812
|
-
return useContext(GonvexAuthContext);
|
|
813
|
-
}
|
|
814
880
|
const FALLBACK_CONNECTION_STATE = {
|
|
815
881
|
isWebSocketConnected: false,
|
|
816
882
|
hasEverConnected: false,
|
|
817
883
|
connectionCount: 0,
|
|
818
884
|
connectionRetries: 0,
|
|
819
885
|
hasInflightRequests: false,
|
|
820
|
-
|
|
886
|
+
inflightReducers: 0,
|
|
821
887
|
inflightActions: 0,
|
|
822
888
|
inflightOneShotQueries: 0,
|
|
823
889
|
};
|
|
824
|
-
export function
|
|
890
|
+
export function useGonvexConnectionState() {
|
|
825
891
|
const client = useGonvexClient();
|
|
826
892
|
const [state, setState] = useState(() => (typeof client.connectionState === "function" ? client.connectionState() : FALLBACK_CONNECTION_STATE));
|
|
827
893
|
useEffect(() => {
|
|
@@ -832,18 +898,7 @@ export function useConvexConnectionState() {
|
|
|
832
898
|
}, [client]);
|
|
833
899
|
return state;
|
|
834
900
|
}
|
|
835
|
-
export function
|
|
836
|
-
const pageArgs = args === "skip" ? "skip" : { ...(isRecord(args) ? args : { args }), paginationOpts: { numItems: options.initialNumItems ?? 25, cursor: null } };
|
|
837
|
-
const result = useQuery(ref, pageArgs);
|
|
838
|
-
const page = Array.isArray(result) ? { page: result, isDone: true, continueCursor: null } : result;
|
|
839
|
-
return {
|
|
840
|
-
results: (page?.page ?? []),
|
|
841
|
-
status: args === "skip" ? "Exhausted" : result === undefined ? "LoadingFirstPage" : page?.isDone ? "Exhausted" : "CanLoadMore",
|
|
842
|
-
isLoading: args !== "skip" && result === undefined,
|
|
843
|
-
loadMore: (_numItems) => undefined,
|
|
844
|
-
};
|
|
845
|
-
}
|
|
846
|
-
function useGonvexClient() {
|
|
901
|
+
export function useGonvexClient() {
|
|
847
902
|
const client = useContext(GonvexContext);
|
|
848
903
|
if (!client)
|
|
849
904
|
throw new Error("GonvexProvider is required");
|