@gonvex/react 0.5.2-staging.2 → 0.5.2-staging.21

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/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
3
- import { GonvexClientError, control } from "@gonvex/client";
2
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore as useReactSyncExternalStore } from "react";
3
+ import { usePaintExternalStore as useSyncExternalStore } from "./paint-external-store.js";
4
+ import { GonvexClientError, control, entityStatusFromIntents } from "@gonvex/client";
4
5
  export { GonvexClientError } from "@gonvex/client";
5
6
  export { createFirebaseAuthAdapter } from "@gonvex/client";
6
7
  const GonvexContext = createContext(null);
@@ -94,8 +95,13 @@ export function GonvexProviderWithAuth(props) {
94
95
  // code+PKCE exchange runs once. Without this, the first effect's finally
95
96
  // clears sessionStorage PKCE before the remount can finish verification.
96
97
  const authBootstrapPromises = new Map();
98
+ // Creating a tenant provisions its database and runs every tenant migration,
99
+ // which routinely takes longer than the default reducer timeout. Timing out
100
+ // made onboarding report a failure for a tenant that was still being created.
101
+ const TENANT_CREATE_TIMEOUT_MS = 120_000;
97
102
  export function GonvexAuthProvider(props) {
98
103
  const runtimeUrl = props.runtimeUrl.replace(/\/+$/, "");
104
+ const hasExplicitInitialTenant = props.initialTenantId !== undefined;
99
105
  const initialTenantId = props.initialTenantId?.trim() || undefined;
100
106
  const callbackPath = normalizeCallbackPath(props.callbackPath ?? "/");
101
107
  const storageKey = `gonvex-auth:${encodeURIComponent(runtimeUrl)}:${props.projectId}`;
@@ -103,23 +109,55 @@ export function GonvexAuthProvider(props) {
103
109
  const initialAuthRef = useRef(null);
104
110
  if (!initialAuthRef.current) {
105
111
  const persisted = readAuthSession(storageKey);
112
+ const warmSession = props.externalAuth
113
+ ? reusableExternalAuthSession(persisted, initialTenantId, props.externalAuth.provider, hasExplicitInitialTenant)
114
+ : null;
115
+ // A reload keeps developer mode only while this tab's stored grant
116
+ // belongs to the same account and project and has not expired.
117
+ const candidate = warmSession ?? persisted;
118
+ const storedDeveloper = candidate
119
+ ? readDeveloperGrant(storageKey, props.projectId, candidate.account.id)
120
+ : null;
121
+ clearDeveloperGrants(storageKey, storedDeveloper?.accountId);
106
122
  initialAuthRef.current = {
107
123
  session: persisted,
108
- warmSession: props.externalAuth
109
- ? reusableExternalAuthSession(persisted, initialTenantId, props.externalAuth.provider)
124
+ warmSession,
125
+ developer: storedDeveloper
126
+ ? {
127
+ active: true,
128
+ tenantId: storedDeveloper.tenantId,
129
+ grantId: storedDeveloper.grantId,
130
+ expiresAt: storedDeveloper.expiresAt,
131
+ accountId: storedDeveloper.accountId,
132
+ originalTenantId: storedDeveloper.originalTenantId,
133
+ clientInstalled: false,
134
+ restoring: true,
135
+ }
110
136
  : null,
137
+ developerToken: storedDeveloper?.token ?? null,
111
138
  };
112
139
  }
113
140
  const initialAuth = initialAuthRef.current;
114
- const [session, setSession] = useState(initialAuth.session);
141
+ // The session exposed to React must be the same scoped session installed on
142
+ // the client. On an explicit landlord origin, warmSession intentionally
143
+ // clears a persisted tenant selection. Keeping the unscoped persisted value
144
+ // here makes the tenant-directory effect see a different auth scope and skip
145
+ // its authoritative Control Query, leaving deleted tenants in the UI.
146
+ const initialSession = initialAuth.warmSession ?? initialAuth.session;
147
+ const [session, setSession] = useState(initialSession);
115
148
  const [isLoading, setIsLoading] = useState(!initialAuth.warmSession);
116
149
  const [sessionState, setSessionState] = useState(initialAuth.warmSession ? "reconnecting" : "loading");
117
150
  const [error, setError] = useState(null);
118
151
  const [refreshRetryAt, setRefreshRetryAt] = useState(0);
119
- const [developerMode, setDeveloperMode] = useState({ active: false });
152
+ const [canonicalRefreshRequested, setCanonicalRefreshRequested] = useState(false);
153
+ const [developerMode, setDeveloperMode] = useState(() => initialAuth.developer ? publicDeveloperMode(initialAuth.developer) : { active: false });
120
154
  const sessionRef = useRef(session);
121
155
  const refreshRef = useRef(null);
122
- const developerModeRef = useRef(null);
156
+ const developerModeRef = useRef(initialAuth.developer);
157
+ // The newest grant credential lives beside, never inside, the developer
158
+ // state so it cannot leak through the public auth value.
159
+ const developerTokenRef = useRef(initialAuth.developerToken);
160
+ const pendingDeveloperTokenRef = useRef(null);
123
161
  // A persisted session is only a candidate until bootstrap/external auth has
124
162
  // installed its complete project + tenant + account scope on the client.
125
163
  // Never let the token-refresh effect authenticate an account-only socket in
@@ -130,28 +168,51 @@ export function GonvexAuthProvider(props) {
130
168
  const installedWarmSessionRef = useRef(false);
131
169
  if (initialAuth.warmSession && !installedWarmSessionRef.current) {
132
170
  installedWarmSessionRef.current = true;
133
- installClientSession(props.client, props.projectId, initialAuth.warmSession);
171
+ const developer = developerModeRef.current;
172
+ const developerToken = developerTokenRef.current;
173
+ if (developer && developerToken) {
174
+ installDeveloperClientAuth(props.client, props.projectId, developer, developerToken);
175
+ developer.clientInstalled = true;
176
+ }
177
+ else {
178
+ installClientSession(props.client, props.projectId, initialAuth.warmSession);
179
+ }
134
180
  }
135
181
  const installSession = useCallback((next, persist = true, installClientAuth = true) => {
136
182
  sessionRef.current = next;
183
+ const developer = developerModeRef.current;
184
+ if (developer && next?.account.id !== developer.accountId) {
185
+ // Signing out or switching accounts ends developer mode. The grant was
186
+ // issued to the previous account and must not survive into this one.
187
+ developerModeRef.current = null;
188
+ developerTokenRef.current = null;
189
+ setDeveloperMode({ active: false });
190
+ clearDeveloperGrants(storageKey);
191
+ }
192
+ const activeDeveloper = developerModeRef.current;
137
193
  if (next) {
138
194
  if (persist)
139
195
  safeLocalStorageSet(storageKey, JSON.stringify(next));
140
- if (!developerModeRef.current && installClientAuth) {
141
- installClientSession(props.client, props.projectId, next);
196
+ if (!activeDeveloper) {
197
+ if (installClientAuth)
198
+ installClientSession(props.client, props.projectId, next);
199
+ installedClientScopeRef.current = authSessionScope(props.projectId, next);
142
200
  }
143
- if (!developerModeRef.current) {
201
+ else if (!activeDeveloper.clientInstalled && installClientAuth && developerTokenRef.current) {
202
+ // A grant restored after reload waits for its account session to be
203
+ // confirmed, then connects with the grant instead of that session.
204
+ installDeveloperClientAuth(props.client, props.projectId, activeDeveloper, developerTokenRef.current);
205
+ activeDeveloper.clientInstalled = true;
144
206
  installedClientScopeRef.current = authSessionScope(props.projectId, next);
145
207
  }
146
208
  }
147
209
  else {
148
210
  if (persist)
149
211
  safeLocalStorageRemove(storageKey);
150
- if (!developerModeRef.current && installClientAuth) {
212
+ if (installClientAuth) {
151
213
  props.client.setAuth({ project: props.projectId, tenant: undefined, token: undefined, identity: undefined });
152
214
  }
153
- if (!developerModeRef.current)
154
- installedClientScopeRef.current = null;
215
+ installedClientScopeRef.current = null;
155
216
  }
156
217
  setSession(next);
157
218
  }, [props.client, props.projectId, storageKey]);
@@ -183,7 +244,7 @@ export function GonvexAuthProvider(props) {
183
244
  || message.targetOrigin !== window.location.origin)
184
245
  return;
185
246
  const candidate = message.session ?? null;
186
- const reusable = reusableExternalAuthSession(candidate, initialTenantId, props.externalAuth?.provider ?? candidate?.account.provider ?? "");
247
+ const reusable = reusableExternalAuthSession(candidate, initialTenantId, props.externalAuth?.provider ?? candidate?.account.provider ?? "", hasExplicitInitialTenant);
187
248
  if (!reusable)
188
249
  return;
189
250
  installSession(reusable);
@@ -194,25 +255,46 @@ export function GonvexAuthProvider(props) {
194
255
  window.addEventListener("message", onMessage);
195
256
  window.parent.postMessage({ type: SESSION_HANDOFF_READY, nonce }, parentOrigin);
196
257
  return () => window.removeEventListener("message", onMessage);
197
- }, [initialTenantId, installSession, props.crossOriginHandoff, props.externalAuth?.provider, props.projectId, runtimeUrl]);
258
+ }, [hasExplicitInitialTenant, initialTenantId, installSession, props.crossOriginHandoff, props.externalAuth?.provider, props.projectId, runtimeUrl]);
198
259
  const restoreAccountSession = useCallback(() => {
199
260
  const developer = developerModeRef.current;
200
261
  if (!developer)
201
262
  return;
202
263
  developerModeRef.current = null;
264
+ developerTokenRef.current = null;
265
+ clearDeveloperGrants(storageKey);
203
266
  setDeveloperMode({ active: false });
204
267
  const current = sessionRef.current;
205
268
  const activeTenantId = current?.tenants.some((tenant) => tenant.id === developer.originalTenantId)
206
269
  ? developer.originalTenantId
207
270
  : current?.activeTenantId;
208
271
  installSession(current ? { ...current, activeTenantId } : null);
209
- }, [installSession]);
272
+ }, [installSession, storageKey]);
210
273
  useEffect(() => props.client.onAuthError((message) => {
211
- if (!developerModeRef.current)
274
+ const developer = developerModeRef.current;
275
+ if (!developer)
212
276
  return;
213
277
  restoreAccountSession();
214
- setError(message || "Developer mode ended because its authorization is no longer valid.");
278
+ // A grant restored after a reload may already be gone (exited in another
279
+ // tab, revoked, or expired server-side). Falling back to the account
280
+ // session is the expected outcome there, not an error to surface.
281
+ setError(developer.restoring ? null : message || "Developer mode ended because its authorization is no longer valid.");
215
282
  }), [props.client, restoreAccountSession]);
283
+ useEffect(() => props.client.onDeveloperCredential?.((token) => {
284
+ // The runtime consumed the previous credential and issued a single-use
285
+ // successor. Keep only the newest one so a reload can resume the grant.
286
+ const pending = pendingDeveloperTokenRef.current;
287
+ if (pending) {
288
+ pending.token = token;
289
+ return;
290
+ }
291
+ const developer = developerModeRef.current;
292
+ if (!developer?.clientInstalled)
293
+ return;
294
+ developer.restoring = false;
295
+ developerTokenRef.current = token;
296
+ writeDeveloperGrant(storageKey, props.projectId, developer, token);
297
+ }), [props.client, props.projectId, storageKey]);
216
298
  useEffect(() => {
217
299
  if (!developerMode.active || !developerMode.expiresAt)
218
300
  return;
@@ -273,8 +355,9 @@ export function GonvexAuthProvider(props) {
273
355
  // Explicit signOut() clears both systems, and its localStorage removal
274
356
  // propagates to every tab through the storage listener below.
275
357
  const canonical = readAuthSession(storageKey) ?? sessionRef.current;
276
- const reusableCanonical = reusableExternalAuthSession(canonical, initialTenantId, adapter.provider);
358
+ const reusableCanonical = reusableExternalAuthSession(canonical, initialTenantId, adapter.provider, hasExplicitInitialTenant);
277
359
  if (reusableCanonical) {
360
+ setCanonicalRefreshRequested(false);
278
361
  if (!sameInstalledAuthSession(sessionRef.current, reusableCanonical)) {
279
362
  installSession(reusableCanonical, false);
280
363
  }
@@ -282,15 +365,18 @@ export function GonvexAuthProvider(props) {
282
365
  setIsLoading(false);
283
366
  }
284
367
  else if (canonical) {
285
- // Keep a refreshable but expired session private until the external
286
- // provider supplies a current identity token. Its tenant Replica is
287
- // not safe to expose under an expired access token.
368
+ // Firebase persistence is origin-scoped, while the canonical Gonvex
369
+ // session is deliberately handed across tenant subdomains. Refresh
370
+ // that canonical session directly instead of waiting forever for a
371
+ // Firebase user that may never exist on this sibling origin.
288
372
  sessionRef.current = canonical;
289
373
  setSession(canonical);
290
374
  setSessionState("loading");
291
375
  setIsLoading(true);
376
+ setCanonicalRefreshRequested(true);
292
377
  }
293
378
  else {
379
+ setCanonicalRefreshRequested(false);
294
380
  installSession(null, false);
295
381
  setSessionState("signedOut");
296
382
  setIsLoading(false);
@@ -298,12 +384,13 @@ export function GonvexAuthProvider(props) {
298
384
  setError(null);
299
385
  return;
300
386
  }
387
+ setCanonicalRefreshRequested(false);
301
388
  // A valid canonical Gonvex session remains usable while Firebase rotates
302
389
  // its identity token. Keep the application mounted and perform that
303
390
  // exchange in the background; unmounting the entire tree here destroys
304
391
  // open dialogs and other local UI state in every existing tab.
305
392
  const canonicalSession = readAuthSession(storageKey) ?? sessionRef.current;
306
- const reusableCanonicalSession = reusableExternalAuthSession(canonicalSession, initialTenantId, adapter.provider);
393
+ const reusableCanonicalSession = reusableExternalAuthSession(canonicalSession, initialTenantId, adapter.provider, hasExplicitInitialTenant);
307
394
  if (reusableCanonicalSession) {
308
395
  // A reload already has a valid canonical Gonvex session. Install its
309
396
  // complete tenant scope before releasing application children; the
@@ -326,7 +413,8 @@ export function GonvexAuthProvider(props) {
326
413
  // is waiting for the cross-tab exchange lock. Read the shared winner
327
414
  // inside the lock instead of replaying this tab's stale in-memory
328
415
  // refresh token.
329
- const current = readAuthSession(storageKey) ?? sessionRef.current;
416
+ const storedCurrent = readAuthSession(storageKey) ?? sessionRef.current;
417
+ const current = scopeSessionForInitialTenant(storedCurrent, initialTenantId, hasExplicitInitialTenant) ?? storedCurrent;
330
418
  attemptedRefreshToken = current?.refreshToken ?? "";
331
419
  if (!current) {
332
420
  props.client.setAuth({
@@ -334,7 +422,9 @@ export function GonvexAuthProvider(props) {
334
422
  identity: { sub: identity.uid, iss: identity.issuer ?? adapter.provider },
335
423
  });
336
424
  }
337
- const tenantId = initialTenantId ?? current?.activeTenantId;
425
+ const tenantId = hasExplicitInitialTenant
426
+ ? initialTenantId
427
+ : current?.activeTenantId;
338
428
  const grant = await props.client.action(control.auth.exchangeExternalToken, {
339
429
  provider: adapter.provider,
340
430
  token,
@@ -345,7 +435,13 @@ export function GonvexAuthProvider(props) {
345
435
  }).then((next) => {
346
436
  if (cancelled || currentGeneration !== generation)
347
437
  return;
348
- installSession(next);
438
+ // A user can select a different tenant while the provider exchange is
439
+ // still in flight. The returned access token is account-wide, but its
440
+ // activeTenantId reflects the scope captured when the exchange began.
441
+ // Preserve the latest locally accepted tenant selection so a stale
442
+ // background exchange cannot bounce a cross-origin handoff back to the
443
+ // previously active tenant.
444
+ installSession(preserveActiveTenant(next, sessionRef.current ?? undefined));
349
445
  setSessionState("current");
350
446
  setError(null);
351
447
  }).catch((cause) => {
@@ -384,7 +480,7 @@ export function GonvexAuthProvider(props) {
384
480
  generation += 1;
385
481
  unsubscribe();
386
482
  };
387
- }, [initialTenantId, installSession, props.client, props.externalAuth, props.projectId, storageKey]);
483
+ }, [hasExplicitInitialTenant, initialTenantId, installSession, props.client, props.externalAuth, props.projectId, storageKey]);
388
484
  const refreshSession = useCallback(async (force = false) => {
389
485
  if (refreshRef.current)
390
486
  return refreshRef.current;
@@ -393,35 +489,36 @@ export function GonvexAuthProvider(props) {
393
489
  ? `${storageKey}:external-session`
394
490
  : `${storageKey}:refresh`;
395
491
  const request = withBrowserAuthLock(lockName, async () => {
396
- const current = readAuthSession(storageKey) ?? sessionRef.current;
492
+ const storedCurrent = readAuthSession(storageKey) ?? sessionRef.current;
493
+ const current = scopeSessionForInitialTenant(storedCurrent, initialTenantId, hasExplicitInitialTenant) ?? storedCurrent;
397
494
  if (!current)
398
495
  return null;
399
496
  if (!force && current.expiresAt > Date.now() + 60_000)
400
497
  return current;
498
+ if (current.refreshExpiresAt <= Date.now())
499
+ return null;
401
500
  if (props.externalAuth) {
501
+ if (current.account.provider !== props.externalAuth.provider)
502
+ return null;
402
503
  attemptedRefreshToken = current.refreshToken;
403
504
  const token = await props.externalAuth.getIdToken(force);
404
- if (!token) {
405
- // External providers such as Firebase can briefly report no current
406
- // user while a new tab hydrates IndexedDB. That is not an explicit
407
- // sign-out and must not erase the still-valid canonical Gonvex
408
- // session shared by every tab. The provider callback will retry the
409
- // exchange once its identity is ready.
410
- setRefreshRetryAt(Date.now() + 1_000);
411
- return current;
412
- }
413
- const grant = await props.client.action(control.auth.exchangeExternalToken, {
414
- provider: props.externalAuth.provider,
415
- token,
416
- ...(current.activeTenantId ? { tenantId: current.activeTenantId } : {}),
417
- previousRefreshToken: current.refreshToken,
418
- });
419
- const next = sessionFromNativeGrant(grant, current);
505
+ const grant = token
506
+ ? await props.client.action(control.auth.exchangeExternalToken, {
507
+ provider: props.externalAuth.provider,
508
+ token,
509
+ ...(current.activeTenantId ? { tenantId: current.activeTenantId } : {}),
510
+ previousRefreshToken: current.refreshToken,
511
+ })
512
+ : await props.client.action(control.auth.refreshSession, {
513
+ refreshToken: current.refreshToken,
514
+ });
515
+ const refreshed = sessionFromNativeGrant(grant, current);
516
+ const next = scopeSessionForInitialTenant(refreshed, initialTenantId, hasExplicitInitialTenant);
517
+ if (!next)
518
+ return null;
420
519
  safeLocalStorageSet(storageKey, JSON.stringify(next));
421
520
  return next;
422
521
  }
423
- if (current.refreshExpiresAt <= Date.now())
424
- return null;
425
522
  attemptedRefreshToken = current.refreshToken;
426
523
  const grant = await props.client.action(control.auth.refreshSession, { refreshToken: current.refreshToken });
427
524
  const next = sessionFromNativeGrant(grant, current);
@@ -435,8 +532,13 @@ export function GonvexAuthProvider(props) {
435
532
  if (next) {
436
533
  setRefreshRetryAt(0);
437
534
  setError(null);
535
+ setSessionState("current");
536
+ }
537
+ else {
538
+ setSessionState("signedOut");
438
539
  }
439
540
  installSession(next);
541
+ setIsLoading(false);
440
542
  return next;
441
543
  }).catch((cause) => {
442
544
  const latest = readAuthSession(storageKey);
@@ -451,6 +553,8 @@ export function GonvexAuthProvider(props) {
451
553
  if (isFatalRefreshError(cause)) {
452
554
  installSession(null);
453
555
  setRefreshRetryAt(0);
556
+ setSessionState("signedOut");
557
+ setIsLoading(false);
454
558
  setError(cause instanceof Error ? cause.message : "Your session expired. Please sign in again.");
455
559
  return null;
456
560
  }
@@ -458,6 +562,8 @@ export function GonvexAuthProvider(props) {
458
562
  // destroy a valid refresh credential. Keep it and retry shortly.
459
563
  if (sessionRef.current)
460
564
  setRefreshRetryAt(Date.now() + 5_000);
565
+ setSessionState("degraded");
566
+ setIsLoading(false);
461
567
  setError("Gonvex could not refresh your session. Retrying shortly…");
462
568
  return null;
463
569
  }).finally(() => {
@@ -465,7 +571,19 @@ export function GonvexAuthProvider(props) {
465
571
  });
466
572
  refreshRef.current = request;
467
573
  return request;
468
- }, [installSession, props.client, props.externalAuth, storageKey]);
574
+ }, [hasExplicitInitialTenant, initialTenantId, installSession, props.client, props.externalAuth, storageKey]);
575
+ useEffect(() => {
576
+ if (!canonicalRefreshRequested)
577
+ return;
578
+ let cancelled = false;
579
+ void refreshSession().finally(() => {
580
+ if (!cancelled)
581
+ setCanonicalRefreshRequested(false);
582
+ });
583
+ return () => {
584
+ cancelled = true;
585
+ };
586
+ }, [canonicalRefreshRequested, refreshSession]);
469
587
  useEffect(() => {
470
588
  if (!session)
471
589
  return;
@@ -557,9 +675,14 @@ export function GonvexAuthProvider(props) {
557
675
  const revokeSession = current
558
676
  ? props.client.reducer(control.auth.logout, { refreshToken: current.refreshToken, all: options?.allDevices === true }).catch(() => undefined)
559
677
  : Promise.resolve();
678
+ const developer = developerModeRef.current;
679
+ const revokeDeveloper = developer
680
+ ? props.client.reducer(control.developer.exit, { grantId: developer.grantId }).catch(() => undefined)
681
+ : Promise.resolve();
682
+ // installSession(null) also ends developer mode and clears its stored grant.
560
683
  installSession(null);
561
684
  const signOutExternal = props.externalAuth?.signOut?.() ?? Promise.resolve();
562
- await Promise.all([revokeSession, signOutExternal]);
685
+ await Promise.all([revokeSession, revokeDeveloper, signOutExternal]);
563
686
  }, [installSession, props.client, props.externalAuth]);
564
687
  const fetchAccessToken = useCallback(async (args) => {
565
688
  const current = sessionRef.current;
@@ -626,7 +749,7 @@ export function GonvexAuthProvider(props) {
626
749
  const tenant = await props.client.reducer(control.tenants.create, {
627
750
  name,
628
751
  ...(options?.domain ? { domain: options.domain } : {}),
629
- });
752
+ }, { timeoutMs: TENANT_CREATE_TIMEOUT_MS });
630
753
  const current = sessionRef.current;
631
754
  installSession({ ...current, tenants: [...current.tenants.filter((item) => item.id !== tenant.id), tenant], activeTenantId: tenant.id });
632
755
  return tenant;
@@ -649,7 +772,20 @@ export function GonvexAuthProvider(props) {
649
772
  const frame = document.createElement("iframe");
650
773
  frame.hidden = true;
651
774
  frame.setAttribute("aria-hidden", "true");
652
- frame.src = receiver.toString();
775
+ const receiverMethod = handoff.receiverMethod ?? "get";
776
+ let receiverForm = null;
777
+ if (receiverMethod === "get") {
778
+ frame.src = receiver.toString();
779
+ }
780
+ else {
781
+ const frameName = `gonvex-session-handoff-${nonce}`;
782
+ frame.name = frameName;
783
+ receiverForm = document.createElement("form");
784
+ receiverForm.hidden = true;
785
+ receiverForm.method = "post";
786
+ receiverForm.action = receiver.toString();
787
+ receiverForm.target = frameName;
788
+ }
653
789
  await new Promise((resolve, reject) => {
654
790
  const timeout = window.setTimeout(() => {
655
791
  cleanup();
@@ -658,6 +794,7 @@ export function GonvexAuthProvider(props) {
658
794
  const cleanup = () => {
659
795
  window.clearTimeout(timeout);
660
796
  window.removeEventListener("message", onMessage);
797
+ receiverForm?.remove();
661
798
  frame.remove();
662
799
  };
663
800
  const onMessage = (event) => {
@@ -683,6 +820,10 @@ export function GonvexAuthProvider(props) {
683
820
  };
684
821
  window.addEventListener("message", onMessage);
685
822
  document.body.append(frame);
823
+ if (receiverForm) {
824
+ document.body.append(receiverForm);
825
+ receiverForm.submit();
826
+ }
686
827
  });
687
828
  }, [props.crossOriginHandoff, props.projectId, runtimeUrl]);
688
829
  const inviteMember = useCallback(async (tenantId, email, options) => {
@@ -719,6 +860,10 @@ export function GonvexAuthProvider(props) {
719
860
  if (!grant.id || !grant.token || !Number.isFinite(Date.parse(expiresAt))) {
720
861
  throw new Error("Gonvex returned an invalid developer grant.");
721
862
  }
863
+ // The activation token is consumed by this authentication; the runtime
864
+ // answers with the reconnect credential that a reload must use instead.
865
+ const pending = { token: grant.token };
866
+ pendingDeveloperTokenRef.current = pending;
722
867
  try {
723
868
  await props.client.authenticate({
724
869
  project: props.projectId,
@@ -738,10 +883,25 @@ export function GonvexAuthProvider(props) {
738
883
  });
739
884
  throw cause;
740
885
  }
741
- const next = { active: true, tenantId, grantId: grant.id, expiresAt, originalTenantId: current.activeTenantId };
886
+ finally {
887
+ if (pendingDeveloperTokenRef.current === pending)
888
+ pendingDeveloperTokenRef.current = null;
889
+ }
890
+ const next = {
891
+ active: true,
892
+ tenantId,
893
+ grantId: grant.id,
894
+ expiresAt,
895
+ accountId: current.account.id,
896
+ originalTenantId: current.activeTenantId,
897
+ clientInstalled: true,
898
+ restoring: false,
899
+ };
742
900
  developerModeRef.current = next;
743
- setDeveloperMode({ active: true, tenantId, grantId: grant.id, expiresAt });
744
- }, [props.client, props.projectId]);
901
+ developerTokenRef.current = pending.token;
902
+ writeDeveloperGrant(storageKey, props.projectId, next, pending.token);
903
+ setDeveloperMode(publicDeveloperMode(next));
904
+ }, [props.client, props.projectId, storageKey]);
745
905
  const exitDeveloperMode = useCallback(async () => {
746
906
  const developer = developerModeRef.current;
747
907
  if (!developer?.grantId)
@@ -756,7 +916,9 @@ export function GonvexAuthProvider(props) {
756
916
  const activeTenant = session?.tenants.find((tenant) => tenant.id === visibleTenantId) ?? null;
757
917
  const authValue = useMemo(() => ({
758
918
  isLoading,
759
- isAuthenticated: Boolean(session && session.refreshExpiresAt > Date.now()),
919
+ isAuthenticated: Boolean(session
920
+ && session.expiresAt > Date.now()
921
+ && session.refreshExpiresAt > Date.now()),
760
922
  sessionState,
761
923
  fetchAccessToken,
762
924
  account: session?.account ?? null,
@@ -778,7 +940,7 @@ export function GonvexAuthProvider(props) {
778
940
  enterDeveloperMode,
779
941
  exitDeveloperMode,
780
942
  }), [acceptInvitation, activeTenant, createTenant, developerMode, enterDeveloperMode, error, exitDeveloperMode, fetchAccessToken, handoffSessionTo, inviteMember, isLoading, refreshMemberships, revokeInvitation, session, sessionState, setActiveTenant, signIn, signInWithPassword, signInWithProvider, signOut]);
781
- return (_jsx(ManagedAuthContext.Provider, { value: authValue, children: _jsx(GonvexAuthContext.Provider, { value: authValue, children: _jsx(GonvexProvider, { client: props.client, children: isLoading ? null : props.children }) }) }));
943
+ return (_jsx(ManagedAuthContext.Provider, { value: authValue, children: _jsx(GonvexAuthContext.Provider, { value: authValue, children: _jsx(GonvexProvider, { client: props.client, children: isLoading ? (props.loadingFallback ?? null) : props.children }) }) }));
782
944
  }
783
945
  export function useGonvexAuth() {
784
946
  const value = useContext(ManagedAuthContext);
@@ -901,10 +1063,17 @@ function sessionFromNativeGrant(value, previous) {
901
1063
  throw new GonvexAuthRequestError("Gonvex returned an invalid native session.", 502);
902
1064
  }
903
1065
  const session = value;
904
- const activeTenantId = previous && session.tenants.some((tenant) => tenant.id === previous.activeTenantId)
1066
+ return preserveActiveTenant(session, previous);
1067
+ }
1068
+ function preserveActiveTenant(session, previous) {
1069
+ const activeTenantId = previous
1070
+ && previous.account.id === session.account.id
1071
+ && session.tenants.some((tenant) => tenant.id === previous.activeTenantId)
905
1072
  ? previous.activeTenantId
906
1073
  : session.activeTenantId;
907
- return { ...session, activeTenantId };
1074
+ return activeTenantId === session.activeTenantId
1075
+ ? session
1076
+ : { ...session, activeTenantId };
908
1077
  }
909
1078
  async function requestGonvexAuthToken(runtimeUrl, body) {
910
1079
  const controller = new AbortController();
@@ -929,7 +1098,16 @@ async function requestGonvexAuthToken(runtimeUrl, body) {
929
1098
  }
930
1099
  }
931
1100
  function isFatalRefreshError(cause) {
932
- return cause instanceof GonvexAuthRequestError && (cause.status === 400 || cause.status === 401 || cause.status === 403);
1101
+ if (cause instanceof GonvexAuthRequestError) {
1102
+ return cause.status === 400 || cause.status === 401 || cause.status === 403;
1103
+ }
1104
+ if (!(cause instanceof GonvexClientError))
1105
+ return false;
1106
+ if (cause.code === "auth")
1107
+ return true;
1108
+ if (cause.code !== "server")
1109
+ return false;
1110
+ return /invalid or expired refresh token|refresh token (?:reuse detected|was already rotated)|login was revoked/i.test(cause.message);
933
1111
  }
934
1112
  function isFatalExternalExchangeError(cause) {
935
1113
  if (cause instanceof GonvexAuthRequestError) {
@@ -1017,6 +1195,87 @@ const WARM_SESSION_ACCESS_SAFETY_MS = 30_000;
1017
1195
  function authSessionScope(projectId, session) {
1018
1196
  return `${projectId}\u0000${session.activeTenantId ?? ""}\u0000${session.account.id}`;
1019
1197
  }
1198
+ const DEVELOPER_GRANT_RESTORE_MARGIN_MS = 5_000;
1199
+ function developerGrantStoragePrefix(storageKey) {
1200
+ return `${storageKey}:developer:`;
1201
+ }
1202
+ function developerGrantStorageKey(storageKey, accountId) {
1203
+ return `${developerGrantStoragePrefix(storageKey)}${encodeURIComponent(accountId)}`;
1204
+ }
1205
+ function publicDeveloperMode(developer) {
1206
+ return { active: true, tenantId: developer.tenantId, grantId: developer.grantId, expiresAt: developer.expiresAt };
1207
+ }
1208
+ /**
1209
+ * Developer grants live in sessionStorage: they survive a reload of the same
1210
+ * tab but never reach other tabs or outlive the browser session.
1211
+ */
1212
+ function readDeveloperGrant(storageKey, projectId, accountId) {
1213
+ if (typeof window === "undefined")
1214
+ return null;
1215
+ const key = developerGrantStorageKey(storageKey, accountId);
1216
+ let parsed = null;
1217
+ try {
1218
+ parsed = JSON.parse(sessionStorage.getItem(key) ?? "null");
1219
+ }
1220
+ catch {
1221
+ parsed = null;
1222
+ }
1223
+ if (!parsed) {
1224
+ safeSessionStorageRemove(key);
1225
+ return null;
1226
+ }
1227
+ const expiresAt = typeof parsed.expiresAt === "string" ? Date.parse(parsed.expiresAt) : Number.NaN;
1228
+ if (parsed.projectId !== projectId
1229
+ || parsed.accountId !== accountId
1230
+ || typeof parsed.tenantId !== "string" || !parsed.tenantId
1231
+ || typeof parsed.grantId !== "string" || !parsed.grantId
1232
+ || typeof parsed.token !== "string" || !(parsed.token.startsWith("gvx_imp_") || parsed.token.startsWith("gvx_dev_"))
1233
+ || !Number.isFinite(expiresAt)
1234
+ || expiresAt <= Date.now() + DEVELOPER_GRANT_RESTORE_MARGIN_MS) {
1235
+ safeSessionStorageRemove(key);
1236
+ return null;
1237
+ }
1238
+ return parsed;
1239
+ }
1240
+ function writeDeveloperGrant(storageKey, projectId, developer, token) {
1241
+ const grant = {
1242
+ projectId,
1243
+ accountId: developer.accountId,
1244
+ tenantId: developer.tenantId,
1245
+ grantId: developer.grantId,
1246
+ token,
1247
+ expiresAt: developer.expiresAt,
1248
+ ...(developer.originalTenantId ? { originalTenantId: developer.originalTenantId } : {}),
1249
+ };
1250
+ safeSessionStorageSet(developerGrantStorageKey(storageKey, developer.accountId), JSON.stringify(grant));
1251
+ }
1252
+ /** Removes every stored developer grant for this project, except `keepAccountId`'s. */
1253
+ function clearDeveloperGrants(storageKey, keepAccountId) {
1254
+ if (typeof window === "undefined")
1255
+ return;
1256
+ const prefix = developerGrantStoragePrefix(storageKey);
1257
+ const keep = keepAccountId ? developerGrantStorageKey(storageKey, keepAccountId) : undefined;
1258
+ try {
1259
+ const keys = [];
1260
+ for (let index = 0; index < sessionStorage.length; index += 1) {
1261
+ const key = sessionStorage.key(index);
1262
+ if (key?.startsWith(prefix) && key !== keep)
1263
+ keys.push(key);
1264
+ }
1265
+ for (const key of keys)
1266
+ sessionStorage.removeItem(key);
1267
+ }
1268
+ catch { /* storage can be unavailable in hardened browsers */ }
1269
+ }
1270
+ function installDeveloperClientAuth(client, projectId, developer, token) {
1271
+ client.setAuth({
1272
+ project: projectId,
1273
+ tenant: developer.tenantId,
1274
+ token,
1275
+ fetchToken: undefined,
1276
+ identity: { sub: developer.accountId, iss: projectId },
1277
+ });
1278
+ }
1020
1279
  function installClientSession(client, projectId, session) {
1021
1280
  client.setAuth({
1022
1281
  project: projectId,
@@ -1037,21 +1296,44 @@ function sameInstalledAuthSession(current, next) {
1037
1296
  * has a current access token. A refresh credential alone is not authorization
1038
1297
  * to expose cached tenant data.
1039
1298
  */
1040
- function reusableExternalAuthSession(session, initialTenantId, provider, now = Date.now()) {
1299
+ function reusableExternalAuthSession(session, initialTenantId, provider, hasExplicitInitialTenant = initialTenantId !== undefined, now = Date.now()) {
1041
1300
  if (!session || session.account.provider !== provider)
1042
1301
  return null;
1043
1302
  if (!Number.isFinite(session.expiresAt) || session.expiresAt <= now + WARM_SESSION_ACCESS_SAFETY_MS) {
1044
1303
  return null;
1045
1304
  }
1046
- if (initialTenantId !== undefined
1047
- && !session.tenants.some((tenant) => (tenant.id === session.activeTenantId
1305
+ const scoped = scopeSessionForInitialTenant(session, initialTenantId, hasExplicitInitialTenant);
1306
+ if (!scoped)
1307
+ return null;
1308
+ if (hasExplicitInitialTenant
1309
+ && initialTenantId !== undefined
1310
+ && !scoped.tenants.some((tenant) => (tenant.id === scoped.activeTenantId
1048
1311
  && (tenant.id === initialTenantId || tenant.domain.toLowerCase() === initialTenantId.toLowerCase()))))
1049
1312
  return null;
1050
- if (session.activeTenantId !== undefined
1051
- && !session.tenants.some((tenant) => tenant.id === session.activeTenantId)) {
1313
+ if (scoped.activeTenantId !== undefined
1314
+ && !scoped.tenants.some((tenant) => tenant.id === scoped.activeTenantId)) {
1052
1315
  return null;
1053
1316
  }
1054
- return session;
1317
+ return scoped;
1318
+ }
1319
+ /** Restrict a persisted session to the tenant context selected by the host. */
1320
+ function scopeSessionForInitialTenant(session, initialTenantId, hasExplicitInitialTenant) {
1321
+ if (!session || !hasExplicitInitialTenant)
1322
+ return session;
1323
+ // The landlord is account-scoped. Reuse the valid token and tenant directory,
1324
+ // but never carry a tenant Replica scope onto that origin.
1325
+ if (initialTenantId === undefined) {
1326
+ return session.activeTenantId === undefined
1327
+ ? session
1328
+ : { ...session, activeTenantId: undefined };
1329
+ }
1330
+ const tenant = session.tenants.find((candidate) => (candidate.id === initialTenantId
1331
+ || candidate.domain.toLowerCase() === initialTenantId.toLowerCase()));
1332
+ if (!tenant)
1333
+ return null;
1334
+ return session.activeTenantId === tenant.id
1335
+ ? session
1336
+ : { ...session, activeTenantId: tenant.id };
1055
1337
  }
1056
1338
  function readAuthSession(key) {
1057
1339
  if (typeof window === "undefined")
@@ -1124,7 +1406,7 @@ export function useQueryResult(ref, args = {}, options = {}) {
1124
1406
  const sessionScopeGeneration = useSessionScopeGeneration(client);
1125
1407
  const path = ref.path;
1126
1408
  const kind = ref.kind;
1127
- const optimisticKey = JSON.stringify(ref.optimistic ?? null);
1409
+ const optimisticKey = useMemo(() => JSON.stringify(ref.optimistic ?? null), [ref.optimistic]);
1128
1410
  const argsKey = JSON.stringify(args);
1129
1411
  const keepPreviousData = options.keepPreviousData !== false;
1130
1412
  const timeoutMs = options.timeoutMs ?? DEFAULT_LIVE_QUERY_SLOW_MS;
@@ -1219,10 +1501,10 @@ export function useLiveQuery(ref, args = {}) {
1219
1501
  }
1220
1502
  const path = ref.path;
1221
1503
  const kind = ref.kind;
1222
- const optimisticKey = JSON.stringify(ref.optimistic ?? null);
1504
+ const optimisticKey = useMemo(() => JSON.stringify(ref.optimistic ?? null), [ref.optimistic]);
1223
1505
  const argsKey = JSON.stringify(args);
1224
- const liveKey = JSON.stringify(ref.live ?? null);
1225
- const liveWatch = useMemo(() => args === "skip" ? undefined : client.watchLiveQuery(ref, args),
1506
+ const liveKey = useMemo(() => JSON.stringify(ref.live ?? null), [ref.live]);
1507
+ const liveWatch = useMemo(() => args === "skip" ? undefined : client.watchLiveQuery(ref, args, { deferStart: true }),
1226
1508
  // eslint-disable-next-line react-hooks/exhaustive-deps
1227
1509
  [client, kind, path, optimisticKey, argsKey, liveKey]);
1228
1510
  const liveResult = useSyncExternalStore(useCallback((notify) => liveWatch?.onUpdate(notify) ?? (() => undefined), [liveWatch]), useCallback(() => liveWatch?.localLiveQueryResult(), [liveWatch]), () => undefined);
@@ -1235,21 +1517,58 @@ export function useControlQuery(ref, args = {}) {
1235
1517
  const watch = useMemo(() => args === "skip" ? undefined : client.watchControlQuery(ref, args),
1236
1518
  // eslint-disable-next-line react-hooks/exhaustive-deps
1237
1519
  [client, ref.kind, ref.path, argsKey]);
1238
- const snapshot = useSyncExternalStore(useCallback((notify) => watch?.onUpdate(notify) ?? (() => undefined), [watch]), useCallback(() => watch?.getSnapshot(), [watch]), () => undefined);
1520
+ const snapshot = useSyncExternalStore(useCallback((notify) => watch?.onUpdate(notify) ?? (() => undefined), [watch]), useCallback(() => watch?.getSnapshot(), [watch]), () => undefined,
1521
+ // Batch cold control-query delivery, but publish later control changes
1522
+ // before their Reducer promise settles and follow-up UI handlers run.
1523
+ true);
1239
1524
  return snapshot?.result;
1240
1525
  }
1241
1526
  /** Read one normalized entity from the single Gonvex Local Replica. */
1242
1527
  export function useEntity(entity, id) {
1243
1528
  const client = useGonvexClient();
1244
- useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), useCallback(() => client.localReplica.version(), [client]), () => 0);
1245
- return client.localReplica.entity(entity, id);
1529
+ useEffect(() => client.retainReplicaEntities(entity, [id]), [client, entity, id]);
1530
+ const version = useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), useCallback(() => client.localReplica.entityVersion(entity, id), [client, entity, id]), () => 0);
1531
+ return useMemo(() => client.localReplica.entity(entity, id), [client, entity, id, version]);
1246
1532
  }
1247
1533
  /** Resolve an ordered entity batch with one Local Replica subscription. */
1248
1534
  export function useReplicaEntities(entity, ids) {
1249
1535
  const client = useGonvexClient();
1250
1536
  const idsKey = JSON.stringify(ids);
1251
- const version = useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), useCallback(() => client.localReplica.version(), [client]), () => 0);
1252
- return useMemo(() => client.replicaEntities(entity, ids), [client, entity, idsKey, version]);
1537
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1538
+ useEffect(() => client.retainReplicaEntities(entity, ids), [client, entity, idsKey]);
1539
+ const cache = useMemo(() => new Map(), [client, entity]);
1540
+ const getVersion = useMemo(() => {
1541
+ let tableVersion;
1542
+ let snapshot = "";
1543
+ return () => {
1544
+ const next = client.localReplica.entityVersion(entity);
1545
+ // Metadata, presence and other tables publish through this subscription
1546
+ // too. They cannot change this batch, so avoid scanning its IDs again.
1547
+ if (next !== tableVersion) {
1548
+ tableVersion = next;
1549
+ snapshot = ids.map(id => client.localReplica.entityVersion(entity, id)).join(",");
1550
+ }
1551
+ return snapshot;
1552
+ };
1553
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1554
+ }, [client, entity, idsKey]);
1555
+ const version = useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), getVersion, () => "");
1556
+ return useMemo(() => {
1557
+ const retained = new Set(ids);
1558
+ for (const id of cache.keys())
1559
+ if (!retained.has(id))
1560
+ cache.delete(id);
1561
+ return ids.map(id => {
1562
+ const rowVersion = client.localReplica.entityVersion(entity, id);
1563
+ const prior = cache.get(id);
1564
+ if (prior?.version === rowVersion)
1565
+ return prior.row;
1566
+ const row = client.localReplica.entity(entity, id);
1567
+ cache.set(id, { version: rowVersion, row });
1568
+ return row;
1569
+ });
1570
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1571
+ }, [client, entity, idsKey, version, cache]);
1253
1572
  }
1254
1573
  /** Read a persisted Live Query window without opening another server subscription. */
1255
1574
  export function useRetainedLiveQuery(signatureOrReference, args = {}) {
@@ -1258,9 +1577,10 @@ export function useRetainedLiveQuery(signatureOrReference, args = {}) {
1258
1577
  const signature = typeof signatureOrReference === "string"
1259
1578
  ? signatureOrReference
1260
1579
  : client.replicaSignature(signatureOrReference, args);
1261
- const version = useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), useCallback(() => client.localReplica.version(), [client]), () => 0);
1262
- return useMemo(() => client.retainedLiveQuery(signature), [client, signature, argsKey, version]);
1580
+ useEffect(() => client.retainReplicaWindow(signature), [client, signature]);
1581
+ return useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), useCallback(() => client.localReplica.liveQuerySnapshot(signature), [client, signature, argsKey]), () => emptyLiveQuery);
1263
1582
  }
1583
+ const emptyLiveQuery = { rows: [], ids: [], source: 'cache', completeness: 'partial', freshness: 'verifying' };
1264
1584
  /** Structured Live Query state backed by normalized Local Replica entities. */
1265
1585
  export function useLiveQueryState(ref, args = {}) {
1266
1586
  const client = useGonvexClient();
@@ -1269,28 +1589,41 @@ export function useLiveQueryState(ref, args = {}) {
1269
1589
  useEffect(() => {
1270
1590
  if (args === "skip")
1271
1591
  return;
1272
- return client.subscribeLiveQuery(ref, args, () => undefined);
1592
+ const release = client.retainReplicaWindow(signature);
1593
+ const unsubscribe = client.subscribeLiveQuery(ref, args, () => undefined);
1594
+ return () => { unsubscribe(); release(); };
1273
1595
  // eslint-disable-next-line react-hooks/exhaustive-deps
1274
1596
  }, [client, ref.kind, ref.path, argsKey]);
1275
- useSyncExternalStore(useCallback((notify) => client.localReplica.subscribe(notify), [client]), useCallback(() => client.localReplica.version(), [client]), () => 0);
1276
- if (args !== "skip" && client.localReplica.freshness() === "offline") {
1277
- const offline = client.offlineLiveQuery(ref, args);
1278
- return {
1279
- rows: offline.rows,
1280
- ids: offline.rows.map((row) => String(row.id ?? row._id ?? "")).filter(Boolean),
1281
- ...(offline.total === undefined ? {} : { total: offline.total }),
1282
- ...(offline.offset === undefined ? {} : { offset: offline.offset }),
1283
- ...(offline.limit === undefined ? {} : { limit: offline.limit }),
1284
- source: "cache",
1285
- completeness: offline.completeness,
1286
- freshness: "offline",
1287
- supported: offline.supported,
1288
- ...(offline.unsupportedOperator ? { unsupportedOperator: offline.unsupportedOperator } : {}),
1597
+ const getSnapshot = useMemo(() => {
1598
+ let offlineVersion = -1;
1599
+ let offlineResult;
1600
+ return () => {
1601
+ if (args === 'skip')
1602
+ return emptyLiveQuery;
1603
+ if (client.localReplica.freshness() !== 'offline')
1604
+ return client.localReplica.liveQuerySnapshot(signature);
1605
+ const version = client.localReplica.version();
1606
+ if (offlineVersion === version && offlineResult)
1607
+ return offlineResult;
1608
+ const offline = client.offlineLiveQuery(ref, args);
1609
+ offlineResult = {
1610
+ rows: offline.rows,
1611
+ ids: offline.rows.map((row) => String(row.id ?? row._id ?? "")).filter(Boolean),
1612
+ ...(offline.total === undefined ? {} : { total: offline.total }),
1613
+ ...(offline.offset === undefined ? {} : { offset: offline.offset }),
1614
+ ...(offline.limit === undefined ? {} : { limit: offline.limit }),
1615
+ source: "cache",
1616
+ completeness: offline.completeness,
1617
+ freshness: "offline",
1618
+ supported: offline.supported,
1619
+ ...(offline.unsupportedOperator ? { unsupportedOperator: offline.unsupportedOperator } : {}),
1620
+ };
1621
+ offlineVersion = version;
1622
+ return offlineResult;
1289
1623
  };
1290
- }
1291
- return signature
1292
- ? client.localReplica.liveQuery(signature)
1293
- : { rows: [], ids: [], source: "cache", completeness: "partial", freshness: client.localReplica.freshness() };
1624
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1625
+ }, [client, ref.kind, ref.path, signature, argsKey]);
1626
+ return useSyncExternalStore(useCallback(notify => signature ? client.localReplica.subscribe(notify) : () => undefined, [client, signature]), getSnapshot, () => emptyLiveQuery);
1294
1627
  }
1295
1628
  /** Execute a read-only Query once. Queries never subscribe or rerun. */
1296
1629
  export function useQuery(ref, args = {}) {
@@ -1325,9 +1658,9 @@ export function useReplicaCollection(ref, args = {}) {
1325
1658
  const client = useGonvexClient();
1326
1659
  const path = ref.path;
1327
1660
  const kind = ref.kind;
1328
- const optimisticKey = JSON.stringify(ref.optimistic ?? null);
1661
+ const optimisticKey = useMemo(() => JSON.stringify(ref.optimistic ?? null), [ref.optimistic]);
1329
1662
  const argsKey = JSON.stringify(args);
1330
- const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args),
1663
+ const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args, { deferStart: true }),
1331
1664
  // eslint-disable-next-line react-hooks/exhaustive-deps
1332
1665
  [client, kind, path, optimisticKey, argsKey]);
1333
1666
  return useSyncExternalStore(useCallback((onStoreChange) => watch?.onUpdate(onStoreChange) ?? (() => undefined), [watch]), useCallback(() => watch?.localReplicaResult(), [watch]), () => undefined);
@@ -1337,22 +1670,75 @@ export function useReplicaCollectionState(ref, args = {}) {
1337
1670
  const client = useGonvexClient();
1338
1671
  const path = ref.path;
1339
1672
  const argsKey = JSON.stringify(args);
1340
- const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args),
1673
+ const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args, { deferStart: true }),
1341
1674
  // eslint-disable-next-line react-hooks/exhaustive-deps
1342
1675
  [client, ref.kind, path, argsKey]);
1343
1676
  return useSyncExternalStore(useCallback((onStoreChange) => watch?.onUpdate(onStoreChange) ?? (() => undefined), [watch]), useCallback(() => watch?.localReplicaState(), [watch]), () => undefined);
1344
1677
  }
1678
+ /** Subscribe to a bounded, dynamic set of slices of the same Replica Collection.
1679
+ * Results borrow normalized SDK snapshots; there is no second entity cache.
1680
+ * Deferred watches only start when React commits the subscription.
1681
+ */
1682
+ export function useReplicaCollectionStates(ref, args) {
1683
+ const client = useGonvexClient();
1684
+ const argsKey = JSON.stringify(args);
1685
+ const watches = useMemo(() => args.map(value => client.watchReplica(ref, value, { deferStart: true })),
1686
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1687
+ [client, ref.kind, ref.path, argsKey]);
1688
+ const getSnapshot = useMemo(() => {
1689
+ let snapshot = [];
1690
+ return () => {
1691
+ const next = watches.map(watch => watch.localReplicaState());
1692
+ if (next.length !== snapshot.length || next.some((state, index) => state !== snapshot[index]))
1693
+ snapshot = next;
1694
+ return snapshot;
1695
+ };
1696
+ }, [watches]);
1697
+ return useSyncExternalStore(useCallback(notify => {
1698
+ const releases = watches.map(watch => watch.onUpdate(notify));
1699
+ return () => releases.forEach(release => release());
1700
+ }, [watches]), getSnapshot, () => emptyCollectionStates);
1701
+ }
1702
+ const emptyCollectionStates = [];
1703
+ /** Select rows and authority metadata without rerendering for unused watermarks. */
1704
+ export function useReplicaCollectionStateSelector(ref, args, selector, isEqual = Object.is) {
1705
+ const client = useGonvexClient();
1706
+ const argsKey = JSON.stringify(args);
1707
+ const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args, { deferStart: true }),
1708
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1709
+ [client, ref.kind, ref.path, argsKey]);
1710
+ const committed = useRef(undefined);
1711
+ const getSnapshot = useMemo(() => {
1712
+ let initialized = false;
1713
+ let previous;
1714
+ let selected;
1715
+ return () => {
1716
+ const state = watch?.localReplicaState();
1717
+ if (initialized && state === previous)
1718
+ return selected;
1719
+ const next = state === undefined ? undefined : selector(state);
1720
+ const prior = initialized ? selected : committed.current && committed.current.watch === watch ? committed.current.value : undefined;
1721
+ selected = prior !== undefined && next !== undefined && isEqual(prior, next) ? prior : next;
1722
+ previous = state;
1723
+ initialized = true;
1724
+ return selected;
1725
+ };
1726
+ }, [watch, selector, isEqual]);
1727
+ const selected = useSyncExternalStore(useCallback((notify) => watch?.onUpdate(notify) ?? (() => undefined), [watch]), getSnapshot, () => undefined);
1728
+ useEffect(() => { committed.current = { watch, value: selected }; }, [watch, selected]);
1729
+ return selected;
1730
+ }
1345
1731
  export function useReplicaSelector(ref, args, selector, isEqual = Object.is) {
1346
1732
  const client = useGonvexClient();
1347
1733
  const path = ref.path;
1348
1734
  const kind = ref.kind;
1349
- const optimisticKey = JSON.stringify(ref.optimistic ?? null);
1735
+ const optimisticKey = useMemo(() => JSON.stringify(ref.optimistic ?? null), [ref.optimistic]);
1350
1736
  const argsKey = JSON.stringify(args);
1351
1737
  const selectorRef = useRef(selector);
1352
1738
  const equalityRef = useRef(isEqual);
1353
1739
  selectorRef.current = selector;
1354
1740
  equalityRef.current = isEqual;
1355
- const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args),
1741
+ const watch = useMemo(() => args === "skip" ? undefined : client.watchReplica(ref, args, { deferStart: true }),
1356
1742
  // eslint-disable-next-line react-hooks/exhaustive-deps
1357
1743
  [client, kind, path, optimisticKey, argsKey]);
1358
1744
  const selectedRef = useRef({
@@ -1404,6 +1790,7 @@ export function useReplicaSelector(ref, args, selector, isEqual = Object.is) {
1404
1790
  }
1405
1791
  export function useReducer(ref, options = {}) {
1406
1792
  const client = useGonvexClient();
1793
+ useEffect(() => { void client.prepareReducer?.(ref)?.catch(() => undefined); }, [client, ref]);
1407
1794
  const refRef = useRef(ref);
1408
1795
  refRef.current = ref;
1409
1796
  const timeoutMs = options.timeoutMs;
@@ -1437,6 +1824,69 @@ export function useGonvexConnectionState() {
1437
1824
  }, [client]);
1438
1825
  return state;
1439
1826
  }
1827
+ const EMPTY_INTENTS = [];
1828
+ const NO_SUBSCRIPTION = () => () => undefined;
1829
+ /**
1830
+ * Every durable reducer intent of the signed-in identity, oldest first:
1831
+ * pending and inflight intents, intents parked as `failed` after exhausting
1832
+ * their retry budget, and server-`rejected` intents awaiting dismissal.
1833
+ * Use `client.retryIntent(id)` / `client.discardIntent(id)` to act on them.
1834
+ */
1835
+ export function useOutboxIntents() {
1836
+ const client = useGonvexClient();
1837
+ const subscribe = useMemo(() => (typeof client.subscribeIntents === "function" ? client.subscribeIntents.bind(client) : NO_SUBSCRIPTION), [client]);
1838
+ const getSnapshot = useCallback(() => (typeof client.intentsSnapshot === "function" ? client.intentsSnapshot() : EMPTY_INTENTS), [client]);
1839
+ return useReactSyncExternalStore(subscribe, getSnapshot, () => EMPTY_INTENTS);
1840
+ }
1841
+ /**
1842
+ * Delivery status of one row: `failed` or `rejected` when an intent that
1843
+ * touched it needs attention, `syncing` while one is still queued, and
1844
+ * undefined once everything touching it has been acknowledged.
1845
+ */
1846
+ export function useEntityIntentStatus(entity, id) {
1847
+ const intents = useOutboxIntents();
1848
+ return useMemo(() => (id ? entityStatusFromIntents(intents, entity, id) : undefined), [intents, entity, id]);
1849
+ }
1850
+ /**
1851
+ * Observe locally executing Reducers that fell back to a queued-only intent
1852
+ * (no local result), for logging. The latest listener is always called; it
1853
+ * does not need to be memoized.
1854
+ */
1855
+ export function useLocalExecutionFallback(listener) {
1856
+ const client = useGonvexClient();
1857
+ const latest = useRef(listener);
1858
+ useEffect(() => { latest.current = listener; });
1859
+ useEffect(() => {
1860
+ if (typeof client.onLocalExecutionFallback !== "function")
1861
+ return undefined;
1862
+ return client.onLocalExecutionFallback((event) => latest.current(event));
1863
+ }, [client]);
1864
+ }
1865
+ /**
1866
+ * Drive a "Clear cache" control: discards the persisted Local Replica and
1867
+ * rehydrates it from the server, keeping queued intents unless asked.
1868
+ */
1869
+ export function useResetLocalReplica() {
1870
+ const client = useGonvexClient();
1871
+ const [isResetting, setResetting] = useState(false);
1872
+ const [error, setError] = useState();
1873
+ const reset = useCallback(async (options) => {
1874
+ setResetting(true);
1875
+ setError(undefined);
1876
+ try {
1877
+ return await client.resetLocalReplica(options);
1878
+ }
1879
+ catch (cause) {
1880
+ const failure = cause instanceof Error ? cause : new Error(String(cause));
1881
+ setError(failure);
1882
+ throw failure;
1883
+ }
1884
+ finally {
1885
+ setResetting(false);
1886
+ }
1887
+ }, [client]);
1888
+ return { reset, isResetting, error };
1889
+ }
1440
1890
  export function useGonvexClient() {
1441
1891
  const client = useContext(GonvexContext);
1442
1892
  if (!client)