@oxyhq/services 10.2.1 → 10.2.2

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.
@@ -4,6 +4,7 @@ import {
4
4
  useCallback,
5
5
  useContext,
6
6
  useEffect,
7
+ useLayoutEffect,
7
8
  useMemo,
8
9
  useRef,
9
10
  useState,
@@ -287,6 +288,12 @@ function isSameSiteIdP(idpOrigin: string): boolean {
287
288
  return idpHostname === pageApex || idpHostname.endsWith(`.${pageApex}`);
288
289
  }
289
290
 
291
+ function isOnSsoCallbackPath(): boolean {
292
+ return isWebBrowser() && window.location.pathname === SSO_CALLBACK_PATH;
293
+ }
294
+
295
+ const useBrowserLayoutEffect = typeof document !== 'undefined' ? useLayoutEffect : useEffect;
296
+
290
297
  let cachedUseFollowHook: UseFollowHook | null = null;
291
298
 
292
299
  const loadUseFollowHook = (): UseFollowHook => {
@@ -387,6 +394,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
387
394
  const [authResolved, setAuthResolved] = useState(false);
388
395
  const authResolvedRef = useRef(false);
389
396
  const [initialized, setInitialized] = useState(false);
397
+ const [ssoCallbackIntercepting, setSsoCallbackIntercepting] = useState(false);
390
398
  const setAuthState = useAuthStore.setState;
391
399
 
392
400
  // Keep the shared `oxyClient` singleton's token store in lockstep with the
@@ -1293,13 +1301,12 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1293
1301
  // +not-found screen before the storage-gated cold-boot `sso-return` step gets
1294
1302
  // a chance to strip the fragment and restore the real destination.
1295
1303
  //
1296
- // This effect fires the SAME `runSsoReturn` kernel the instant we mount ON the
1297
- // callback path, BEFORE the cold boot (which awaits storage init). It restores
1298
- // the pre-bounce destination (and, on `ok`, commits the exchanged session via
1299
- // `handleWebSSOSession`) immediately, so the router re-syncs off the callback
1300
- // path and never lingers on it. Because the SDK owns this interception
1301
- // entirely, NO app needs a `/__oxy/sso-callback` route — it works identically
1302
- // across every consumer with zero per-app code.
1304
+ // This effect fires the SAME `runSsoReturn` kernel the instant we hydrate ON
1305
+ // the callback path, BEFORE the cold boot (which awaits storage init). The
1306
+ // first render intentionally matches the app/router's static HTML; the
1307
+ // browser layout effect then hides the internal route and consumes the
1308
+ // callback before the first visible paint. That keeps SSR/SSG hydration stable
1309
+ // while still ensuring no app needs a `/__oxy/sso-callback` route.
1303
1310
  //
1304
1311
  // It is purely ADDITIVE. The later cold-boot `sso-return` step stays as
1305
1312
  // defense-in-depth for the non-callback-path case; `consumeSsoReturn` strips
@@ -1315,13 +1322,13 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1315
1322
  // already wired when this fires at eager-mount time. If for any reason it were
1316
1323
  // not yet set, the later cold-boot `sso-return` step would commit it — but the
1317
1324
  // ref IS set during render, so the eager `ok` commit works.
1318
- useEffect(() => {
1319
- if (!isWebBrowser()) {
1320
- return;
1321
- }
1322
- if (window.location.pathname !== SSO_CALLBACK_PATH) {
1325
+ useBrowserLayoutEffect(() => {
1326
+ if (!isOnSsoCallbackPath()) {
1327
+ setSsoCallbackIntercepting(false);
1323
1328
  return;
1324
1329
  }
1330
+ let mounted = true;
1331
+ setSsoCallbackIntercepting(true);
1325
1332
  runSsoReturnRef.current().catch((error) => {
1326
1333
  if (__DEV__) {
1327
1334
  loggerUtil.debug(
@@ -1330,7 +1337,15 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1330
1337
  error,
1331
1338
  );
1332
1339
  }
1340
+ }).finally(() => {
1341
+ if (mounted) {
1342
+ setSsoCallbackIntercepting(false);
1343
+ }
1333
1344
  });
1345
+
1346
+ return () => {
1347
+ mounted = false;
1348
+ };
1334
1349
  }, []);
1335
1350
 
1336
1351
  // Web SSO: automatically check for cross-domain session on web platforms.
@@ -1718,7 +1733,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1718
1733
 
1719
1734
  return (
1720
1735
  <OxyContext.Provider value={contextValue}>
1721
- {children}
1736
+ {ssoCallbackIntercepting ? null : children}
1722
1737
  </OxyContext.Provider>
1723
1738
  );
1724
1739
  };
@@ -194,7 +194,7 @@ export function useAuth(): UseAuthReturn {
194
194
  // State
195
195
  user,
196
196
  isAuthenticated,
197
- isLoading,
197
+ isLoading: isLoading || !isAuthResolved,
198
198
  isReady: isTokenReady,
199
199
  isAuthResolved,
200
200
  error,
@@ -1,7 +1,7 @@
1
1
  import { useCallback, useMemo, useEffect } from 'react';
2
2
  import { useFollowStore } from '../stores/followStore';
3
3
  import { useOxy } from '../context/OxyContext';
4
- import type { OxyServices } from '@oxyhq/core';
4
+ import { logger as loggerUtil, type OxyServices } from '@oxyhq/core';
5
5
  import { useShallow } from 'zustand/react/shallow';
6
6
 
7
7
  /**
@@ -18,7 +18,8 @@ import { useShallow } from 'zustand/react/shallow';
18
18
  * them in selectors would cause unnecessary selector recalculations).
19
19
  */
20
20
  export const useFollow = (userId?: string | string[]) => {
21
- const { oxyServices } = useOxy();
21
+ const { oxyServices, isAuthenticated, isAuthResolved, isTokenReady } = useOxy();
22
+ const canUsePrivateApi = isAuthResolved && isTokenReady && isAuthenticated;
22
23
  const userIds = useMemo(() => (Array.isArray(userId) ? userId : userId ? [userId] : []), [userId]);
23
24
  const isSingleUser = typeof userId === 'string';
24
25
 
@@ -75,9 +76,10 @@ export const useFollow = (userId?: string | string[]) => {
75
76
  // Store actions are accessed via getState() to avoid subscribing to them.
76
77
  const toggleFollow = useCallback(async () => {
77
78
  if (!isSingleUser || !userId) throw new Error('toggleFollow is only available for single user mode');
79
+ if (!canUsePrivateApi) throw new Error('Authentication is required to follow users');
78
80
  const currentlyFollowing = useFollowStore.getState().followingUsers[userId] ?? false;
79
81
  await useFollowStore.getState().toggleFollowUser(userId, oxyServices, currentlyFollowing);
80
- }, [isSingleUser, userId, oxyServices]);
82
+ }, [isSingleUser, userId, canUsePrivateApi, oxyServices]);
81
83
 
82
84
  const setFollowStatus = useCallback((following: boolean) => {
83
85
  if (!isSingleUser || !userId) throw new Error('setFollowStatus is only available for single user mode');
@@ -86,8 +88,9 @@ export const useFollow = (userId?: string | string[]) => {
86
88
 
87
89
  const fetchStatus = useCallback(async () => {
88
90
  if (!isSingleUser || !userId) throw new Error('fetchStatus is only available for single user mode');
91
+ if (!canUsePrivateApi) return;
89
92
  await useFollowStore.getState().fetchFollowStatus(userId, oxyServices);
90
- }, [isSingleUser, userId, oxyServices]);
93
+ }, [isSingleUser, userId, canUsePrivateApi, oxyServices]);
91
94
 
92
95
  const clearError = useCallback(() => {
93
96
  if (!isSingleUser || !userId) throw new Error('clearError is only available for single user mode');
@@ -114,28 +117,33 @@ export const useFollow = (userId?: string | string[]) => {
114
117
  if (!isSingleUser || !userId) return;
115
118
 
116
119
  if ((followerCount === null || followingCount === null) && !isLoadingCounts) {
117
- fetchUserCounts().catch((err: unknown) => console.warn('useFollow: fetchUserCounts failed', err));
120
+ fetchUserCounts().catch((error: unknown) => {
121
+ loggerUtil.warn('useFollow: fetchUserCounts failed', { component: 'useFollow' }, error);
122
+ });
118
123
  }
119
124
  }, [isSingleUser, userId, followerCount, followingCount, isLoadingCounts, fetchUserCounts]);
120
125
 
121
126
  // Multi-user callbacks
122
127
  const toggleFollowForUser = useCallback(async (targetUserId: string) => {
128
+ if (!canUsePrivateApi) throw new Error('Authentication is required to follow users');
123
129
  const currentState = useFollowStore.getState().followingUsers[targetUserId] ?? false;
124
130
  await useFollowStore.getState().toggleFollowUser(targetUserId, oxyServices, currentState);
125
- }, [oxyServices]);
131
+ }, [canUsePrivateApi, oxyServices]);
126
132
 
127
133
  const setFollowStatusForUser = useCallback((targetUserId: string, following: boolean) => {
128
134
  useFollowStore.getState().setFollowingStatus(targetUserId, following);
129
135
  }, []);
130
136
 
131
137
  const fetchStatusForUser = useCallback(async (targetUserId: string) => {
138
+ if (!canUsePrivateApi) return;
132
139
  await useFollowStore.getState().fetchFollowStatus(targetUserId, oxyServices);
133
- }, [oxyServices]);
140
+ }, [canUsePrivateApi, oxyServices]);
134
141
 
135
142
  const fetchAllStatuses = useCallback(async () => {
143
+ if (!canUsePrivateApi) return;
136
144
  const store = useFollowStore.getState();
137
145
  await Promise.all(userIds.map(uid => store.fetchFollowStatus(uid, oxyServices)));
138
- }, [userIds, oxyServices]);
146
+ }, [canUsePrivateApi, userIds, oxyServices]);
139
147
 
140
148
  const clearErrorForUser = useCallback((targetUserId: string) => {
141
149
  useFollowStore.getState().clearFollowError(targetUserId);