@adventurelabs/scout-core 2.0.8 → 2.0.10

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 CHANGED
@@ -35,6 +35,8 @@ barrel.
35
35
  `ScoutRefreshProvider` accepts an existing browser Supabase client and otherwise
36
36
  creates one stable client. Its automatic refresh uses a fresh IndexedDB cache
37
37
  without another request; manual and reconnect refreshes still query the API.
38
+ Returning to a visible tab revalidates herd data and refreshes JWT mints that
39
+ are near expiry, covering browsers that suspend background timers.
38
40
  Offline PWAs can pass a persisted `offlineUserId` to restore that user's herd
39
41
  modules and JWT mints before online authentication is revalidated. Cached mints
40
42
  remain available when expired; pass `false` as the fourth argument to
@@ -60,6 +60,7 @@ export function useScoutRefresh(options = {}, identityOptions = {}) {
60
60
  const { validatedUserId, onUserValidated } = identityOptions;
61
61
  const dispatch = useAppDispatch();
62
62
  const refreshIdRef = useRef(0);
63
+ const refreshInProgressIdRef = useRef(null);
63
64
  const activeUserIdRef = useRef(null);
64
65
  const validatedUserIdRef = useRef(null);
65
66
  const providedValidatedUserIdRef = useRef(validatedUserId);
@@ -91,7 +92,10 @@ export function useScoutRefresh(options = {}, identityOptions = {}) {
91
92
  }, [dispatch]);
92
93
  const cacheIdentityRef = useRef({ supabase, offlineUserId });
93
94
  const handleRefresh = useCallback(async ({ force = true } = {}) => {
95
+ if (refreshInProgressIdRef.current !== null && !force)
96
+ return;
94
97
  const refreshId = ++refreshIdRef.current;
98
+ refreshInProgressIdRef.current = refreshId;
95
99
  const isCurrent = () => refreshIdRef.current === refreshId;
96
100
  const startTime = Date.now();
97
101
  const isOffline = typeof navigator === "undefined" || !navigator.onLine;
@@ -268,6 +272,9 @@ export function useScoutRefresh(options = {}, identityOptions = {}) {
268
272
  }
269
273
  }
270
274
  finally {
275
+ if (refreshInProgressIdRef.current === refreshId) {
276
+ refreshInProgressIdRef.current = null;
277
+ }
271
278
  if (isCurrent()) {
272
279
  lastQueryAtRef.current = Date.now();
273
280
  }
@@ -355,14 +362,25 @@ export function useScoutRefresh(options = {}, identityOptions = {}) {
355
362
  useEffect(() => {
356
363
  if (!autoRefresh || typeof window === "undefined")
357
364
  return;
358
- const handleOnline = () => {
365
+ const revalidate = () => {
366
+ if (!navigator.onLine)
367
+ return;
359
368
  const now = Date.now();
360
369
  if (now - lastQueryAtRef.current >= onlineRefetchMinIntervalMs) {
361
370
  void handleRefresh({ force: true });
362
371
  }
363
372
  };
364
- window.addEventListener("online", handleOnline);
365
- return () => window.removeEventListener("online", handleOnline);
373
+ const handleVisibilityChange = () => {
374
+ if (document.visibilityState === "visible") {
375
+ revalidate();
376
+ }
377
+ };
378
+ window.addEventListener("online", revalidate);
379
+ document.addEventListener("visibilitychange", handleVisibilityChange);
380
+ return () => {
381
+ window.removeEventListener("online", revalidate);
382
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
383
+ };
366
384
  }, [autoRefresh, handleRefresh, onlineRefetchMinIntervalMs]);
367
385
  const clearCache = useCallback(async () => {
368
386
  try {
@@ -82,7 +82,9 @@ function useJwtMintLifecycle({ enabled, supabase, identity, cacheKey, ttlSec, re
82
82
  const [mint, setMint] = useState(null);
83
83
  const [inFlight, setInFlight] = useState(false);
84
84
  const [error, setError] = useState(null);
85
+ const [offline, setOffline] = useState(isBrowserOffline);
85
86
  const refreshIdRef = useRef(0);
87
+ const refreshInProgressRef = useRef(false);
86
88
  const authUserIdRef = useRef(null);
87
89
  const cacheHydrationIdRef = useRef(0);
88
90
  const signedOutRef = useRef(false);
@@ -101,15 +103,16 @@ function useJwtMintLifecycle({ enabled, supabase, identity, cacheKey, ttlSec, re
101
103
  console.error("[ScoutRefreshProvider] User validation callback failed:", validationError);
102
104
  }
103
105
  }, [onUserValidated]);
104
- const status = useMemo(() => derive_jwt_mint_status(enabled, mint, inFlight, error), [enabled, mint, inFlight, error]);
106
+ const status = useMemo(() => derive_jwt_mint_status(enabled, mint, inFlight, error, offline), [enabled, mint, inFlight, error, offline]);
105
107
  const refreshMint = useCallback(async (force = true) => {
106
108
  if (!enabled || isBrowserOffline())
107
109
  return;
110
+ if (refreshInProgressRef.current && !force)
111
+ return;
112
+ refreshInProgressRef.current = true;
108
113
  cacheHydrationIdRef.current++;
109
114
  const refreshId = ++refreshIdRef.current;
110
115
  const isCurrent = () => refreshIdRef.current === refreshId;
111
- setInFlight(true);
112
- setError(null);
113
116
  try {
114
117
  const { data, error: authError } = await supabase.auth.getUser();
115
118
  if (authError) {
@@ -142,6 +145,8 @@ function useJwtMintLifecycle({ enabled, supabase, identity, cacheKey, ttlSec, re
142
145
  !shouldRefreshMint(currentMint, ttlSec, refreshBeforeExpirySec)) {
143
146
  return;
144
147
  }
148
+ setInFlight(true);
149
+ setError(null);
145
150
  const mintResponse = await mintToken(supabase);
146
151
  if (!isCurrent() || authUserIdRef.current !== userId)
147
152
  return;
@@ -176,6 +181,7 @@ function useJwtMintLifecycle({ enabled, supabase, identity, cacheKey, ttlSec, re
176
181
  }
177
182
  finally {
178
183
  if (isCurrent()) {
184
+ refreshInProgressRef.current = false;
179
185
  setInFlight(false);
180
186
  }
181
187
  }
@@ -201,10 +207,12 @@ function useJwtMintLifecycle({ enabled, supabase, identity, cacheKey, ttlSec, re
201
207
  authUserIdRef.current = null;
202
208
  cacheHydrationIdRef.current++;
203
209
  refreshIdRef.current++;
210
+ refreshInProgressRef.current = false;
204
211
  updateMint(null);
205
212
  setInFlight(false);
206
213
  }
207
214
  if (!enabled) {
215
+ refreshInProgressRef.current = false;
208
216
  updateMint(null);
209
217
  setError(null);
210
218
  setInFlight(false);
@@ -219,6 +227,7 @@ function useJwtMintLifecycle({ enabled, supabase, identity, cacheKey, ttlSec, re
219
227
  authUserIdRef.current = null;
220
228
  cacheHydrationIdRef.current++;
221
229
  refreshIdRef.current++;
230
+ refreshInProgressRef.current = false;
222
231
  updateMint(null);
223
232
  setError(null);
224
233
  setInFlight(false);
@@ -287,40 +296,78 @@ function useJwtMintLifecycle({ enabled, supabase, identity, cacheKey, ttlSec, re
287
296
  }
288
297
  };
289
298
  void initializeMint();
299
+ const refreshCurrentMintIfDue = () => {
300
+ const currentMint = mintRef.current;
301
+ if (!currentMint ||
302
+ shouldRefreshMint(currentMint, ttlSec, refreshBeforeExpirySec)) {
303
+ void refreshMint(false);
304
+ }
305
+ };
290
306
  const unsubscribeAuth = subscribeToSupabaseAuth(supabase, (event, session) => {
291
307
  if (event === "SIGNED_OUT") {
292
308
  signedOutRef.current = true;
293
309
  authUserIdRef.current = null;
294
310
  cacheHydrationIdRef.current++;
295
311
  refreshIdRef.current++;
312
+ refreshInProgressRef.current = false;
296
313
  updateMint(null);
297
314
  setError(null);
298
315
  setInFlight(false);
299
316
  return;
300
317
  }
301
318
  if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
319
+ const wasSignedOut = signedOutRef.current;
302
320
  signedOutRef.current = false;
303
- refreshIdRef.current++;
304
- setInFlight(false);
305
321
  const userId = session?.user?.id;
306
- if (userId) {
322
+ if (!userId) {
323
+ refreshCurrentMintIfDue();
324
+ return;
325
+ }
326
+ const knownUserId = authUserIdRef.current;
327
+ const userChanged = wasSignedOut ||
328
+ (knownUserId !== null && knownUserId !== userId);
329
+ if (userChanged) {
330
+ cacheHydrationIdRef.current++;
331
+ refreshIdRef.current++;
332
+ refreshInProgressRef.current = false;
307
333
  void initializeMint(userId, true);
308
334
  }
335
+ else if (knownUserId === null || !mintRef.current) {
336
+ void initializeMint(userId);
337
+ }
309
338
  else {
310
- void refreshMint();
339
+ refreshCurrentMintIfDue();
311
340
  }
312
341
  }
313
342
  });
314
343
  const handleOnline = () => {
344
+ setOffline(false);
315
345
  void refreshMint(false);
316
346
  };
347
+ const handleOffline = () => {
348
+ setOffline(true);
349
+ };
350
+ const handleVisibilityChange = () => {
351
+ if (document.visibilityState !== "visible")
352
+ return;
353
+ const currentlyOffline = isBrowserOffline();
354
+ setOffline(currentlyOffline);
355
+ if (currentlyOffline)
356
+ return;
357
+ refreshCurrentMintIfDue();
358
+ };
317
359
  window.addEventListener("online", handleOnline);
360
+ window.addEventListener("offline", handleOffline);
361
+ document.addEventListener("visibilitychange", handleVisibilityChange);
318
362
  return () => {
319
363
  cancelled = true;
320
364
  cacheHydrationIdRef.current++;
321
365
  refreshIdRef.current++;
366
+ refreshInProgressRef.current = false;
322
367
  unsubscribeAuth();
323
368
  window.removeEventListener("online", handleOnline);
369
+ window.removeEventListener("offline", handleOffline);
370
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
324
371
  };
325
372
  }, [
326
373
  enabled,
@@ -328,6 +375,8 @@ function useJwtMintLifecycle({ enabled, supabase, identity, cacheKey, ttlSec, re
328
375
  offlineUserId,
329
376
  validatedUserId,
330
377
  cacheKey,
378
+ ttlSec,
379
+ refreshBeforeExpirySec,
331
380
  refreshMint,
332
381
  updateMint,
333
382
  ]);
@@ -340,7 +389,7 @@ function useJwtMintLifecycle({ enabled, supabase, identity, cacheKey, ttlSec, re
340
389
  const refreshAtSec = Math.max(expSec - refreshBeforeExpirySec, mint.iat + 1);
341
390
  const delayMs = Math.max((refreshAtSec - nowSec) * 1000, 1000);
342
391
  const timer = setTimeout(() => {
343
- void refreshMint();
392
+ void refreshMint(false);
344
393
  }, delayMs);
345
394
  return () => clearTimeout(timer);
346
395
  }, [enabled, mint, ttlSec, refreshBeforeExpirySec, refreshMint]);
@@ -7,4 +7,5 @@ export declare enum EnumJwtMintStatus {
7
7
  }
8
8
  export declare function derive_jwt_mint_status(enabled: boolean, mint: {
9
9
  token: string;
10
- } | null | undefined, inFlight: boolean, error: string | null): EnumJwtMintStatus;
10
+ exp?: number;
11
+ } | null | undefined, inFlight: boolean, error: string | null, allowExpired?: boolean): EnumJwtMintStatus;
@@ -6,17 +6,18 @@ export var EnumJwtMintStatus;
6
6
  EnumJwtMintStatus["READY"] = "ready";
7
7
  EnumJwtMintStatus["ERROR"] = "error";
8
8
  })(EnumJwtMintStatus || (EnumJwtMintStatus = {}));
9
- export function derive_jwt_mint_status(enabled, mint, inFlight, error) {
9
+ export function derive_jwt_mint_status(enabled, mint, inFlight, error, allowExpired = false) {
10
10
  if (!enabled) {
11
11
  return EnumJwtMintStatus.IDLE;
12
12
  }
13
- if (inFlight && !mint?.token) {
14
- return EnumJwtMintStatus.LOADING;
13
+ const expired = mint?.exp != null && mint.exp <= Math.floor(Date.now() / 1000);
14
+ const hasUsableMint = Boolean(mint?.token) && (allowExpired || !expired);
15
+ if (inFlight) {
16
+ return hasUsableMint
17
+ ? EnumJwtMintStatus.REFRESHING
18
+ : EnumJwtMintStatus.LOADING;
15
19
  }
16
- if (inFlight && mint?.token) {
17
- return EnumJwtMintStatus.REFRESHING;
18
- }
19
- if (mint?.token) {
20
+ if (hasUsableMint) {
20
21
  return EnumJwtMintStatus.READY;
21
22
  }
22
23
  if (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adventurelabs/scout-core",
3
- "version": "2.0.8",
3
+ "version": "2.0.10",
4
4
  "description": "Core utilities and helpers for Adventure Labs Scout applications",
5
5
  "exports": {
6
6
  "./client": {