@adventurelabs/scout-core 2.0.3 → 2.0.5

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.
@@ -15,19 +15,15 @@ function dispatchInTransition(dispatch, action) {
15
15
  dispatch(action);
16
16
  });
17
17
  }
18
- async function loadAuthenticatedUser(supabase, offline) {
18
+ async function loadAuthenticatedUser(supabase) {
19
19
  const startTime = Date.now();
20
- if (offline) {
21
- const { data: { session }, } = await supabase.auth.getSession();
22
- if (!session?.user) {
23
- throw new Error("No authenticated user");
24
- }
25
- return { user: session.user, duration: Date.now() - startTime };
26
- }
27
20
  let lastError = new Error("Unable to load authenticated user");
28
21
  for (let attempt = 0; attempt < 2; attempt++) {
29
22
  try {
30
- const { data } = await supabase.auth.getUser();
23
+ const { data, error } = await supabase.auth.getUser();
24
+ if (error) {
25
+ throw error;
26
+ }
31
27
  if (!data.user) {
32
28
  throw new Error("Invalid user response");
33
29
  }
@@ -59,16 +55,30 @@ async function recoverScoutCache(error, operation, userId, retry) {
59
55
  *
60
56
  * Automatic refreshes hydrate from cache before revalidating online.
61
57
  */
62
- export function useScoutRefresh(options = {}) {
63
- const { supabase: supabaseOption, autoRefresh = true, onRefreshComplete, cacheFirst = true, cacheTtlMs = 24 * 60 * 60 * 1000, onlineRefetchMinIntervalMs = 15 * 1000, } = options;
58
+ export function useScoutRefresh(options = {}, identityOptions = {}) {
59
+ const { supabase: supabaseOption, autoRefresh = true, onRefreshComplete, cacheFirst = true, cacheTtlMs = 24 * 60 * 60 * 1000, onlineRefetchMinIntervalMs = 15 * 1000, offlineUserId, } = options;
60
+ const { validatedUserId, onUserValidated } = identityOptions;
64
61
  const dispatch = useAppDispatch();
65
62
  const refreshIdRef = useRef(0);
66
63
  const activeUserIdRef = useRef(null);
64
+ const validatedUserIdRef = useRef(null);
65
+ const providedValidatedUserIdRef = useRef(validatedUserId);
66
+ providedValidatedUserIdRef.current = validatedUserId;
67
67
  const lastQueryAtRef = useRef(0);
68
68
  const onRefreshCompleteRef = useRef(onRefreshComplete);
69
+ const onUserValidatedRef = useRef(onUserValidated);
69
70
  useEffect(() => {
70
71
  onRefreshCompleteRef.current = onRefreshComplete;
71
- }, [onRefreshComplete]);
72
+ onUserValidatedRef.current = onUserValidated;
73
+ }, [onRefreshComplete, onUserValidated]);
74
+ const notifyUserValidated = useCallback((userId) => {
75
+ try {
76
+ onUserValidatedRef.current?.(userId);
77
+ }
78
+ catch (error) {
79
+ console.error("[useScoutRefresh] User validation callback failed:", error);
80
+ }
81
+ }, []);
72
82
  const supabase = useMemo(() => {
73
83
  if (supabaseOption) {
74
84
  return supabaseOption;
@@ -79,12 +89,14 @@ export function useScoutRefresh(options = {}) {
79
89
  dispatch(resetScoutState());
80
90
  dispatch(scoutApi.util.resetApiState());
81
91
  }, [dispatch]);
92
+ const cacheIdentityRef = useRef({ supabase, offlineUserId });
82
93
  const handleRefresh = useCallback(async ({ force = true } = {}) => {
83
94
  const refreshId = ++refreshIdRef.current;
84
95
  const isCurrent = () => refreshIdRef.current === refreshId;
85
96
  const startTime = Date.now();
86
97
  const isOffline = typeof navigator === "undefined" || !navigator.onLine;
87
98
  let cachedHerdModules = null;
99
+ let cacheReady = false;
88
100
  const complete = (loadingState) => {
89
101
  if (!isCurrent())
90
102
  return;
@@ -93,57 +105,84 @@ export function useScoutRefresh(options = {}) {
93
105
  dispatch(setStatus(EnumScoutStateStatus.DONE_LOADING));
94
106
  onRefreshCompleteRef.current?.();
95
107
  };
96
- try {
97
- dispatch(setStatus(EnumScoutStateStatus.LOADING));
98
- dispatch(setHerdModulesLoadingState(EnumHerdModulesLoadingState.LOADING));
99
- const { user, duration } = await loadAuthenticatedUser(supabase, isOffline);
100
- if (!isCurrent())
101
- return;
102
- const userId = user.id;
108
+ const activateUser = (userId) => {
103
109
  if (activeUserIdRef.current &&
104
110
  activeUserIdRef.current !== userId) {
105
111
  resetClientState();
112
+ cachedHerdModules = null;
106
113
  }
107
114
  activeUserIdRef.current = userId;
108
- dispatch(setUserApiDuration(duration));
109
- dispatchInTransition(dispatch, setUser(user));
110
- let cacheReady = false;
115
+ };
116
+ const hydrateCache = async (userId) => {
117
+ activateUser(userId);
118
+ cacheReady = false;
111
119
  try {
112
120
  await scoutCache.setScope(userId);
113
121
  cacheReady = true;
114
122
  }
115
123
  catch (cacheError) {
116
124
  console.warn("[useScoutRefresh] Cache unavailable:", cacheError);
125
+ return;
117
126
  }
118
- if (cacheFirst && cacheReady && (!force || isOffline)) {
119
- const cacheStartTime = Date.now();
120
- try {
121
- const cacheResult = await scoutCache.getHerdModules(userId);
122
- if (!isCurrent() || activeUserIdRef.current !== userId)
123
- return;
124
- const cacheLoadDuration = Date.now() - cacheStartTime;
125
- dispatch(setCacheLoadDuration(cacheLoadDuration));
126
- if (cacheResult.data !== null) {
127
- cachedHerdModules = cacheResult.data;
128
- dispatch(setDataSource(EnumDataSource.CACHE));
129
- dispatch(setDataSourceInfo({
130
- source: EnumDataSource.CACHE,
131
- timestamp: Date.now(),
132
- cacheAge: cacheResult.age,
133
- isStale: cacheResult.isStale,
134
- }));
135
- dispatchInTransition(dispatch, setHerdModules({
136
- modules: cachedHerdModules,
137
- }));
138
- dispatch(setHerdModulesLoadingState(EnumHerdModulesLoadingState.SUCCESSFULLY_LOADED));
139
- }
140
- }
141
- catch (cacheError) {
142
- console.warn("[useScoutRefresh] Cache load failed:", cacheError);
143
- await recoverScoutCache(cacheError, "cache load", userId);
127
+ if (!cacheFirst || (force && !isOffline)) {
128
+ return;
129
+ }
130
+ const cacheStartTime = Date.now();
131
+ try {
132
+ const cacheResult = await scoutCache.getHerdModules(userId);
133
+ if (!isCurrent() || activeUserIdRef.current !== userId)
134
+ return;
135
+ dispatch(setCacheLoadDuration(Date.now() - cacheStartTime));
136
+ if (cacheResult.data === null) {
137
+ return;
144
138
  }
139
+ cachedHerdModules = cacheResult.data;
140
+ dispatch(setDataSource(EnumDataSource.CACHE));
141
+ dispatch(setDataSourceInfo({
142
+ source: EnumDataSource.CACHE,
143
+ timestamp: Date.now(),
144
+ cacheAge: cacheResult.age,
145
+ isStale: cacheResult.isStale,
146
+ }));
147
+ dispatchInTransition(dispatch, setHerdModules({ modules: cachedHerdModules }));
148
+ dispatch(setHerdModulesLoadingState(EnumHerdModulesLoadingState.SUCCESSFULLY_LOADED));
149
+ }
150
+ catch (cacheError) {
151
+ console.warn("[useScoutRefresh] Cache load failed:", cacheError);
152
+ await recoverScoutCache(cacheError, "cache load", userId);
153
+ }
154
+ };
155
+ try {
156
+ dispatch(setStatus(EnumScoutStateStatus.LOADING));
157
+ dispatch(setHerdModulesLoadingState(EnumHerdModulesLoadingState.LOADING));
158
+ const providedValidatedUserId = providedValidatedUserIdRef.current;
159
+ const hasProvidedValidation = providedValidatedUserId !== undefined;
160
+ let cacheUserId = hasProvidedValidation
161
+ ? providedValidatedUserId
162
+ : validatedUserIdRef.current ??
163
+ offlineUserId ??
164
+ activeUserIdRef.current;
165
+ let sessionUser = null;
166
+ let sessionDuration = 0;
167
+ if (!cacheUserId && !hasProvidedValidation) {
168
+ const sessionStart = Date.now();
169
+ const { data: { session }, } = await supabase.auth.getSession();
170
+ if (!isCurrent())
171
+ return;
172
+ sessionDuration = Date.now() - sessionStart;
173
+ sessionUser = session?.user ?? null;
174
+ cacheUserId = sessionUser?.id ?? null;
175
+ }
176
+ if (cacheUserId) {
177
+ await hydrateCache(cacheUserId);
178
+ if (!isCurrent())
179
+ return;
145
180
  }
146
181
  if (isOffline) {
182
+ if (sessionUser) {
183
+ dispatch(setUserApiDuration(sessionDuration));
184
+ dispatchInTransition(dispatch, setUser(sessionUser));
185
+ }
147
186
  if (cachedHerdModules === null) {
148
187
  dispatch(setDataSource(EnumDataSource.UNKNOWN));
149
188
  dispatch(setDataSourceInfo({
@@ -156,6 +195,19 @@ export function useScoutRefresh(options = {}) {
156
195
  : EnumHerdModulesLoadingState.UNSUCCESSFULLY_LOADED);
157
196
  return;
158
197
  }
198
+ const { user, duration } = await loadAuthenticatedUser(supabase);
199
+ if (!isCurrent())
200
+ return;
201
+ const userId = user.id;
202
+ validatedUserIdRef.current = userId;
203
+ notifyUserValidated(userId);
204
+ if (cacheUserId !== userId || !cacheReady) {
205
+ await hydrateCache(userId);
206
+ if (!isCurrent())
207
+ return;
208
+ }
209
+ dispatch(setUserApiDuration(duration));
210
+ dispatchInTransition(dispatch, setUser(user));
159
211
  const queryStart = Date.now();
160
212
  const herdModulesResponse = await load_herd_modules_query(supabase);
161
213
  if (!isCurrent() || activeUserIdRef.current !== userId)
@@ -225,8 +277,48 @@ export function useScoutRefresh(options = {}) {
225
277
  supabase,
226
278
  cacheFirst,
227
279
  cacheTtlMs,
280
+ offlineUserId,
281
+ notifyUserValidated,
228
282
  resetClientState,
229
283
  ]);
284
+ useEffect(() => {
285
+ const previousIdentity = cacheIdentityRef.current;
286
+ if (previousIdentity.supabase === supabase &&
287
+ previousIdentity.offlineUserId === offlineUserId) {
288
+ return;
289
+ }
290
+ cacheIdentityRef.current = { supabase, offlineUserId };
291
+ refreshIdRef.current++;
292
+ activeUserIdRef.current = null;
293
+ validatedUserIdRef.current = null;
294
+ resetClientState();
295
+ }, [supabase, offlineUserId, resetClientState]);
296
+ useEffect(() => {
297
+ if (validatedUserId === undefined) {
298
+ return;
299
+ }
300
+ validatedUserIdRef.current = validatedUserId;
301
+ if (validatedUserId === null) {
302
+ if (activeUserIdRef.current) {
303
+ refreshIdRef.current++;
304
+ activeUserIdRef.current = null;
305
+ resetClientState();
306
+ }
307
+ return;
308
+ }
309
+ if (validatedUserId === activeUserIdRef.current) {
310
+ return;
311
+ }
312
+ if (!autoRefresh) {
313
+ refreshIdRef.current++;
314
+ if (activeUserIdRef.current) {
315
+ resetClientState();
316
+ }
317
+ activeUserIdRef.current = validatedUserId;
318
+ return;
319
+ }
320
+ void handleRefresh({ force: false });
321
+ }, [autoRefresh, validatedUserId, handleRefresh, resetClientState]);
230
322
  useEffect(() => {
231
323
  if (autoRefresh) {
232
324
  void handleRefresh({ force: false });
@@ -238,6 +330,8 @@ export function useScoutRefresh(options = {}) {
238
330
  const previousUserId = activeUserIdRef.current ?? scoutCache.getScopeUserId();
239
331
  refreshIdRef.current++;
240
332
  activeUserIdRef.current = null;
333
+ validatedUserIdRef.current = null;
334
+ notifyUserValidated(null);
241
335
  resetClientState();
242
336
  void clearScoutClientState(previousUserId ?? undefined).catch((error) => {
243
337
  console.warn("[useScoutRefresh] Sign-out cache clear failed:", error);
@@ -245,16 +339,19 @@ export function useScoutRefresh(options = {}) {
245
339
  return;
246
340
  }
247
341
  const nextUserId = session?.user?.id;
248
- if (event === "SIGNED_IN" &&
249
- nextUserId &&
250
- nextUserId !== activeUserIdRef.current) {
342
+ if (event === "SIGNED_IN" && nextUserId) {
343
+ validatedUserIdRef.current = nextUserId;
344
+ notifyUserValidated(nextUserId);
345
+ if (nextUserId === activeUserIdRef.current) {
346
+ return;
347
+ }
251
348
  refreshIdRef.current++;
252
349
  activeUserIdRef.current = nextUserId;
253
350
  resetClientState();
254
351
  void handleRefresh({ force: true });
255
352
  }
256
353
  });
257
- }, [supabase, handleRefresh, resetClientState]);
354
+ }, [supabase, handleRefresh, notifyUserValidated, resetClientState]);
258
355
  useEffect(() => {
259
356
  if (!autoRefresh || typeof window === "undefined")
260
357
  return;