@workos-inc/widgets 1.21.0 → 1.21.1-next.1794250769562

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.21.1-next.1794250769562
4
+
5
+ ### Patch Changes
6
+
7
+ - Refetch widget data when an `authToken` function resolves to a token for a different session, user or organization.
8
+
3
9
  ## 1.21.0
4
10
 
5
11
  ### Patch Changes
@@ -39,8 +39,7 @@ const ApiProvider = ({ authToken, baseUrl, children, widgetType }) => {
39
39
  });
40
40
  react.useEffect(() => {
41
41
  if (typeof authToken !== "string") return;
42
- const cached = queryClient.getQueryData(queryKey);
43
- if (cached?.authToken === authToken) return;
42
+ if (queryClient.getQueryData(queryKey)?.authToken === authToken) return;
44
43
  queryClient.cancelQueries({ queryKey });
45
44
  const claims = require_api_claims.getClaims(authToken);
46
45
  queryClient.setQueryData(queryKey, {
@@ -48,12 +47,38 @@ const ApiProvider = ({ authToken, baseUrl, children, widgetType }) => {
48
47
  permissions: claims.permissions,
49
48
  exp: claims.exp
50
49
  });
51
- if (cached && !isSameSubject(require_api_claims.getClaims(cached.authToken), claims)) queryClient.invalidateQueries({ predicate: (query) => require_api_client_graphql_query_keys.isClientGraphqlQueryKey(query.queryKey) });
52
50
  }, [
53
51
  authToken,
54
52
  queryClient,
55
53
  queryKey
56
54
  ]);
55
+ const currentToken = typeof authToken === "string" ? authToken : authTokenQuery.data?.authToken;
56
+ const currentIdentityKey = react.useMemo(() => currentToken ? getIdentityKey(require_api_claims.getClaims(currentToken)) : void 0, [currentToken]);
57
+ const [tokenIdentity, setTokenIdentity] = react.useState({
58
+ key: currentIdentityKey,
59
+ generation: 0
60
+ });
61
+ if (currentIdentityKey !== tokenIdentity.key) setTokenIdentity({
62
+ key: currentIdentityKey,
63
+ generation: tokenIdentity.key === void 0 ? tokenIdentity.generation : tokenIdentity.generation + 1
64
+ });
65
+ const [settledGeneration, setSettledGeneration] = react.useState(0);
66
+ const identityGeneration = tokenIdentity.generation;
67
+ const isIdentityPending = settledGeneration !== identityGeneration;
68
+ react.useEffect(() => {
69
+ if (!isIdentityPending) return;
70
+ queryClient.invalidateQueries({ predicate: isClientGraphqlQuery });
71
+ const settleWhenRefetched = () => {
72
+ if (!queryClient.getQueryCache().findAll({ predicate: isClientGraphqlQuery }).some((query) => query.state.fetchStatus === "fetching" || query.isActive() && query.state.isInvalidated)) setSettledGeneration(identityGeneration);
73
+ };
74
+ const unsubscribe = queryClient.getQueryCache().subscribe(settleWhenRefetched);
75
+ settleWhenRefetched();
76
+ return unsubscribe;
77
+ }, [
78
+ identityGeneration,
79
+ isIdentityPending,
80
+ queryClient
81
+ ]);
57
82
  const exp = authTokenQuery.data?.exp;
58
83
  react.useEffect(() => {
59
84
  if (!exp) return;
@@ -89,21 +114,30 @@ const ApiProvider = ({ authToken, baseUrl, children, widgetType }) => {
89
114
  baseUrl,
90
115
  elevatedAccess,
91
116
  setElevatedAccess,
92
- widgetType
117
+ widgetType,
118
+ isIdentityPending
93
119
  }), [
94
120
  authTokenQuery.data,
95
121
  baseUrl,
96
122
  elevatedAccess,
97
123
  setElevatedAccess,
98
- widgetType
124
+ widgetType,
125
+ isIdentityPending
99
126
  ]);
100
127
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ApiContext.Provider, {
101
128
  value,
102
129
  children
103
130
  });
104
131
  };
105
- function isSameSubject(a, b) {
106
- return a.sid === b.sid && a.sub === b.sub && a.org_id === b.org_id;
132
+ function isClientGraphqlQuery(query) {
133
+ return require_api_client_graphql_query_keys.isClientGraphqlQueryKey(query.queryKey);
134
+ }
135
+ function getIdentityKey(claims) {
136
+ return JSON.stringify([
137
+ claims.sid,
138
+ claims.sub,
139
+ claims.org_id
140
+ ]);
107
141
  }
108
142
  const useApi = () => {
109
143
  const context = react.useContext(ApiContext);
@@ -1 +1 @@
1
- {"version":3,"file":"api-provider.cjs","names":["React","useQueryClient","useQuery","getClaims","isClientGraphqlQueryKey","useIsHydrated"],"sources":["../../../src/api/api-provider.tsx"],"sourcesContent":["import { useQuery, useQueryClient } from \"@tanstack/react-query\";\nimport * as React from \"react\";\nimport { useIsHydrated } from \"../lib/use-is-hydrated.js\";\nimport { getClaims } from \"./claims.js\";\nimport type { Claims } from \"./claims.js\";\nimport { isClientGraphqlQueryKey } from \"./client-graphql/query-keys.js\";\n\ntype WidgetType =\n | \"admin-portal-audit-log-streaming\"\n | \"admin-portal-domain-verification\"\n | \"user-management\"\n | \"user-api-keys\"\n | \"organization-switcher\"\n | \"user-sessions\"\n | \"user-security\"\n | \"user-profile\"\n | \"admin-portal-sso-connection\"\n | \"api-keys\"\n | \"pipes\"\n | \"pipes-admin\"\n | \"directory-sync\";\n\ntype AuthToken = string | (() => Promise<string>);\n\ninterface ElevatedAccess {\n token: string;\n expiresAt: string;\n}\n\ninterface ApiContextValue {\n authToken: string | undefined;\n permissions: string[] | undefined;\n exp: number | undefined;\n baseUrl: string;\n widgetType: WidgetType;\n elevatedAccess?: ElevatedAccess;\n setElevatedAccess: (elevatedAccess?: ElevatedAccess) => void;\n}\n\ninterface AuthTokenQueryData {\n authToken: string;\n permissions: string[] | undefined;\n exp: number | undefined;\n}\n\nconst authTokenQueryKey = (widgetType: WidgetType) =>\n [\"authToken\", widgetType] as const;\n\n// Refetch this many ms before the JWT expires so data queries always see a\n// fresh token.\nconst TOKEN_REFRESH_BUFFER_MS = 30_000;\n\n// Cap setTimeout delays at ~24 days to stay well below the 32-bit signed int\n// limit (~24.8 days), which some runtimes silently coerce to 1ms.\nconst MAX_TIMEOUT_MS = 2_147_483_000;\n\nconst ApiContext = React.createContext<ApiContextValue | undefined>(undefined);\nApiContext.displayName = \"ApiContext\";\n\ninterface ApiProviderProps {\n authToken: AuthToken;\n baseUrl: string;\n children: React.ReactNode;\n widgetType: WidgetType;\n}\n\nconst ApiProvider: React.FC<ApiProviderProps> = ({\n authToken,\n baseUrl,\n children,\n widgetType,\n}) => {\n const queryClient = useQueryClient();\n const queryKey = authTokenQueryKey(widgetType);\n const authTokenQuery = useQuery<AuthTokenQueryData>({\n initialData: () => {\n if (typeof authToken !== \"string\") {\n return undefined;\n }\n\n const claims = getClaims(authToken);\n return {\n authToken,\n permissions: claims.permissions,\n exp: claims.exp,\n };\n },\n queryFn: async () => {\n const resolvedToken =\n typeof authToken === \"string\" ? authToken : await authToken();\n const claims = getClaims(resolvedToken);\n\n return {\n authToken: resolvedToken,\n permissions: claims.permissions,\n exp: claims.exp,\n };\n },\n queryKey,\n });\n\n // `initialData` only seeds the cache on first mount, so a new string token\n // has to be written to the cache explicitly. A token for a different session,\n // user or organization (e.g. after switching organizations) also invalidates\n // data fetched with the previous token.\n React.useEffect(() => {\n if (typeof authToken !== \"string\") {\n return;\n }\n\n const cached = queryClient.getQueryData<AuthTokenQueryData>(queryKey);\n if (cached?.authToken === authToken) {\n return;\n }\n\n // Keep an in-flight fetch holding the previous token from overwriting the\n // new one.\n void queryClient.cancelQueries({ queryKey });\n const claims = getClaims(authToken);\n queryClient.setQueryData<AuthTokenQueryData>(queryKey, {\n authToken,\n permissions: claims.permissions,\n exp: claims.exp,\n });\n\n if (cached && !isSameSubject(getClaims(cached.authToken), claims)) {\n void queryClient.invalidateQueries({\n predicate: (query) => isClientGraphqlQueryKey(query.queryKey),\n });\n }\n }, [authToken, queryClient, queryKey]);\n\n // Keep the token \"fresh\" until ~30s before it expires, so window-focus\n // re-fetches re-mint before data queries use a stale Bearer.\n //\n // TODO(v2): prefer the function-form `staleTime` on `useQuery` above once we\n // can bump the `@tanstack/react-query` peer dep to ^5.44.0 (the version that\n // introduced it). That lets react-query own the freshness window and avoids\n // the extra timer + window-focus nonsense this effect introduces.\n const exp = authTokenQuery.data?.exp;\n React.useEffect(() => {\n if (!exp) {\n return;\n }\n\n const delay = Math.min(\n MAX_TIMEOUT_MS,\n exp * 1000 - Date.now() - TOKEN_REFRESH_BUFFER_MS,\n );\n if (delay <= 0) {\n void queryClient.refetchQueries({\n queryKey: authTokenQueryKey(widgetType),\n });\n return;\n }\n const timeout = window.setTimeout(() => {\n void queryClient.refetchQueries({\n queryKey: authTokenQueryKey(widgetType),\n });\n }, delay);\n return () => window.clearTimeout(timeout);\n }, [exp, queryClient, widgetType]);\n\n const [elevatedAccess, setElevatedAccess] = React.useState<ElevatedAccess>();\n const elevatedAccessTimeout = React.useRef<number | undefined>(undefined);\n\n // This effect manages the expiration of elevated access tokens\n // When an elevated access token is present, it checks every 30 seconds if the token has expired\n // If the token has expired (current time > expiration time), it clears the elevated access\n React.useEffect(() => {\n if (elevatedAccessTimeout.current) {\n window.clearInterval(elevatedAccessTimeout.current);\n }\n\n if (elevatedAccess) {\n elevatedAccessTimeout.current = window.setInterval(() => {\n const now = new Date();\n const expiresAtDate = new Date(elevatedAccess.expiresAt);\n\n // Reset the elevated access if it has expired\n if (now > expiresAtDate) {\n setElevatedAccess(undefined);\n }\n }, 30_000); // every 30 seconds\n }\n\n return () => {\n if (elevatedAccessTimeout.current) {\n window.clearInterval(elevatedAccessTimeout.current);\n }\n };\n }, [elevatedAccess]);\n\n const value = React.useMemo(\n () => ({\n authToken: authTokenQuery.data?.authToken,\n permissions: authTokenQuery.data?.permissions,\n exp: authTokenQuery.data?.exp,\n baseUrl,\n elevatedAccess,\n setElevatedAccess,\n widgetType,\n }),\n [\n authTokenQuery.data,\n baseUrl,\n elevatedAccess,\n setElevatedAccess,\n widgetType,\n ],\n );\n\n return <ApiContext.Provider value={value}>{children}</ApiContext.Provider>;\n};\n\nfunction isSameSubject(a: Claims, b: Claims) {\n return a.sid === b.sid && a.sub === b.sub && a.org_id === b.org_id;\n}\n\nconst useApi = () => {\n const context = React.useContext(ApiContext);\n\n if (context === undefined) {\n throw new Error(\"useApi must be used within an ApiProvider\");\n }\n\n return context;\n};\n\nconst useElevatedAccessToken = () => {\n const { elevatedAccess, setElevatedAccess } = useApi();\n\n return { elevatedAccess, setElevatedAccess };\n};\n\nconst useApiReady = () => {\n const { authToken } = useApi();\n const isHydrated = useIsHydrated();\n\n return isHydrated && authToken !== undefined;\n};\nexport type { WidgetType, AuthToken, AuthTokenQueryData };\nexport {\n authTokenQueryKey,\n ApiProvider,\n useApi,\n useElevatedAccessToken,\n useApiReady,\n};\n"],"mappings":"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"}
1
+ {"version":3,"file":"api-provider.cjs","names":["React","useQueryClient","useQuery","getClaims","isClientGraphqlQueryKey","useIsHydrated"],"sources":["../../../src/api/api-provider.tsx"],"sourcesContent":["import { useQuery, useQueryClient } from \"@tanstack/react-query\";\nimport type { Query } from \"@tanstack/react-query\";\nimport * as React from \"react\";\nimport { useIsHydrated } from \"../lib/use-is-hydrated.js\";\nimport { getClaims } from \"./claims.js\";\nimport type { Claims } from \"./claims.js\";\nimport { isClientGraphqlQueryKey } from \"./client-graphql/query-keys.js\";\n\ntype WidgetType =\n | \"admin-portal-audit-log-streaming\"\n | \"admin-portal-domain-verification\"\n | \"user-management\"\n | \"user-api-keys\"\n | \"organization-switcher\"\n | \"user-sessions\"\n | \"user-security\"\n | \"user-profile\"\n | \"admin-portal-sso-connection\"\n | \"api-keys\"\n | \"pipes\"\n | \"pipes-admin\"\n | \"directory-sync\";\n\ntype AuthToken = string | (() => Promise<string>);\n\ninterface ElevatedAccess {\n token: string;\n expiresAt: string;\n}\n\ninterface ApiContextValue {\n authToken: string | undefined;\n permissions: string[] | undefined;\n exp: number | undefined;\n baseUrl: string;\n widgetType: WidgetType;\n elevatedAccess?: ElevatedAccess;\n setElevatedAccess: (elevatedAccess?: ElevatedAccess) => void;\n /**\n * The token now belongs to a different session, user or organization, but\n * the Client GraphQL data on screen may still belong to the previous one.\n */\n isIdentityPending: boolean;\n}\n\ninterface AuthTokenQueryData {\n authToken: string;\n permissions: string[] | undefined;\n exp: number | undefined;\n}\n\nconst authTokenQueryKey = (widgetType: WidgetType) =>\n [\"authToken\", widgetType] as const;\n\n// Refetch this many ms before the JWT expires so data queries always see a\n// fresh token.\nconst TOKEN_REFRESH_BUFFER_MS = 30_000;\n\n// Cap setTimeout delays at ~24 days to stay well below the 32-bit signed int\n// limit (~24.8 days), which some runtimes silently coerce to 1ms.\nconst MAX_TIMEOUT_MS = 2_147_483_000;\n\nconst ApiContext = React.createContext<ApiContextValue | undefined>(undefined);\nApiContext.displayName = \"ApiContext\";\n\ninterface ApiProviderProps {\n authToken: AuthToken;\n baseUrl: string;\n children: React.ReactNode;\n widgetType: WidgetType;\n}\n\nconst ApiProvider: React.FC<ApiProviderProps> = ({\n authToken,\n baseUrl,\n children,\n widgetType,\n}) => {\n const queryClient = useQueryClient();\n const queryKey = authTokenQueryKey(widgetType);\n const authTokenQuery = useQuery<AuthTokenQueryData>({\n initialData: () => {\n if (typeof authToken !== \"string\") {\n return undefined;\n }\n\n const claims = getClaims(authToken);\n return {\n authToken,\n permissions: claims.permissions,\n exp: claims.exp,\n };\n },\n queryFn: async () => {\n const resolvedToken =\n typeof authToken === \"string\" ? authToken : await authToken();\n const claims = getClaims(resolvedToken);\n\n return {\n authToken: resolvedToken,\n permissions: claims.permissions,\n exp: claims.exp,\n };\n },\n queryKey,\n });\n\n // `initialData` only seeds the cache on first mount, so a new string token\n // has to be written to the cache explicitly.\n React.useEffect(() => {\n if (typeof authToken !== \"string\") {\n return;\n }\n\n const cached = queryClient.getQueryData<AuthTokenQueryData>(queryKey);\n if (cached?.authToken === authToken) {\n return;\n }\n\n // Keep an in-flight fetch holding the previous token from overwriting the\n // new one.\n void queryClient.cancelQueries({ queryKey });\n const claims = getClaims(authToken);\n queryClient.setQueryData<AuthTokenQueryData>(queryKey, {\n authToken,\n permissions: claims.permissions,\n exp: claims.exp,\n });\n }, [authToken, queryClient, queryKey]);\n\n const currentToken =\n typeof authToken === \"string\" ? authToken : authTokenQuery.data?.authToken;\n const currentIdentityKey = React.useMemo(\n () => (currentToken ? getIdentityKey(getClaims(currentToken)) : undefined),\n [currentToken],\n );\n // Counts changes to the token's session, user or organization (e.g. after\n // switching organizations). Switching back to an earlier identity counts too,\n // since requests made for the one in between may still be in flight.\n const [tokenIdentity, setTokenIdentity] = React.useState({\n key: currentIdentityKey,\n generation: 0,\n });\n if (currentIdentityKey !== tokenIdentity.key) {\n setTokenIdentity({\n key: currentIdentityKey,\n generation:\n tokenIdentity.key === undefined\n ? tokenIdentity.generation\n : tokenIdentity.generation + 1,\n });\n }\n // The identity generation whose Client GraphQL data is on screen. The\n // previous identity's data stays on screen until it has been refetched with\n // the new token.\n const [settledGeneration, setSettledGeneration] = React.useState(0);\n const identityGeneration = tokenIdentity.generation;\n const isIdentityPending = settledGeneration !== identityGeneration;\n React.useEffect(() => {\n if (!isIdentityPending) {\n return;\n }\n\n // Cancels refetches still in flight for an earlier identity.\n void queryClient.invalidateQueries({ predicate: isClientGraphqlQuery });\n // A failed refetch leaves its query invalidated, so the previous identity's\n // data stays locked until a refetch succeeds.\n const settleWhenRefetched = () => {\n const isRefetching = queryClient\n .getQueryCache()\n .findAll({ predicate: isClientGraphqlQuery })\n .some(\n (query) =>\n query.state.fetchStatus === \"fetching\" ||\n (query.isActive() && query.state.isInvalidated),\n );\n if (!isRefetching) {\n setSettledGeneration(identityGeneration);\n }\n };\n const unsubscribe = queryClient\n .getQueryCache()\n .subscribe(settleWhenRefetched);\n settleWhenRefetched();\n return unsubscribe;\n }, [identityGeneration, isIdentityPending, queryClient]);\n\n // Keep the token \"fresh\" until ~30s before it expires, so window-focus\n // re-fetches re-mint before data queries use a stale Bearer.\n //\n // TODO(v2): prefer the function-form `staleTime` on `useQuery` above once we\n // can bump the `@tanstack/react-query` peer dep to ^5.44.0 (the version that\n // introduced it). That lets react-query own the freshness window and avoids\n // the extra timer + window-focus nonsense this effect introduces.\n const exp = authTokenQuery.data?.exp;\n React.useEffect(() => {\n if (!exp) {\n return;\n }\n\n const delay = Math.min(\n MAX_TIMEOUT_MS,\n exp * 1000 - Date.now() - TOKEN_REFRESH_BUFFER_MS,\n );\n if (delay <= 0) {\n void queryClient.refetchQueries({\n queryKey: authTokenQueryKey(widgetType),\n });\n return;\n }\n const timeout = window.setTimeout(() => {\n void queryClient.refetchQueries({\n queryKey: authTokenQueryKey(widgetType),\n });\n }, delay);\n return () => window.clearTimeout(timeout);\n }, [exp, queryClient, widgetType]);\n\n const [elevatedAccess, setElevatedAccess] = React.useState<ElevatedAccess>();\n const elevatedAccessTimeout = React.useRef<number | undefined>(undefined);\n\n // This effect manages the expiration of elevated access tokens\n // When an elevated access token is present, it checks every 30 seconds if the token has expired\n // If the token has expired (current time > expiration time), it clears the elevated access\n React.useEffect(() => {\n if (elevatedAccessTimeout.current) {\n window.clearInterval(elevatedAccessTimeout.current);\n }\n\n if (elevatedAccess) {\n elevatedAccessTimeout.current = window.setInterval(() => {\n const now = new Date();\n const expiresAtDate = new Date(elevatedAccess.expiresAt);\n\n // Reset the elevated access if it has expired\n if (now > expiresAtDate) {\n setElevatedAccess(undefined);\n }\n }, 30_000); // every 30 seconds\n }\n\n return () => {\n if (elevatedAccessTimeout.current) {\n window.clearInterval(elevatedAccessTimeout.current);\n }\n };\n }, [elevatedAccess]);\n\n const value = React.useMemo(\n () => ({\n authToken: authTokenQuery.data?.authToken,\n permissions: authTokenQuery.data?.permissions,\n exp: authTokenQuery.data?.exp,\n baseUrl,\n elevatedAccess,\n setElevatedAccess,\n widgetType,\n isIdentityPending,\n }),\n [\n authTokenQuery.data,\n baseUrl,\n elevatedAccess,\n setElevatedAccess,\n widgetType,\n isIdentityPending,\n ],\n );\n\n return <ApiContext.Provider value={value}>{children}</ApiContext.Provider>;\n};\n\nfunction isClientGraphqlQuery(query: Query) {\n return isClientGraphqlQueryKey(query.queryKey);\n}\n\nfunction getIdentityKey(claims: Claims) {\n return JSON.stringify([claims.sid, claims.sub, claims.org_id]);\n}\n\nconst useApi = () => {\n const context = React.useContext(ApiContext);\n\n if (context === undefined) {\n throw new Error(\"useApi must be used within an ApiProvider\");\n }\n\n return context;\n};\n\nconst useElevatedAccessToken = () => {\n const { elevatedAccess, setElevatedAccess } = useApi();\n\n return { elevatedAccess, setElevatedAccess };\n};\n\nconst useApiReady = () => {\n const { authToken } = useApi();\n const isHydrated = useIsHydrated();\n\n return isHydrated && authToken !== undefined;\n};\nexport type { WidgetType, AuthToken, AuthTokenQueryData };\nexport {\n authTokenQueryKey,\n ApiProvider,\n useApi,\n useElevatedAccessToken,\n useApiReady,\n};\n"],"mappings":"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"}
@@ -14,6 +14,11 @@ interface ApiContextValue {
14
14
  widgetType: WidgetType;
15
15
  elevatedAccess?: ElevatedAccess;
16
16
  setElevatedAccess: (elevatedAccess?: ElevatedAccess) => void;
17
+ /**
18
+ * The token now belongs to a different session, user or organization, but
19
+ * the Client GraphQL data on screen may still belong to the previous one.
20
+ */
21
+ isIdentityPending: boolean;
17
22
  }
18
23
  interface AuthTokenQueryData {
19
24
  authToken: string;
@@ -1 +1 @@
1
- {"version":3,"file":"api-provider.d.cts","names":[],"sources":["../../../src/api/api-provider.tsx"],"mappings":";;KAOK;KAeA,4BAA4B;UAEvB;EACR;EACA;;UAGQ;EACR;EACA;EACA;EACA;EACA,YAAY;EACZ,iBAAiB;EACjB,oBAAoB,iBAAiB;;UAG7B;EACR;EACA;EACA;;cAGI,oBAAiB,YAAgB,sCACzB;UAaJ;EACR,WAAW;EACX;EACA,UAAU,MAAM;EAChB,YAAY;;cAGR,aAAa,MAAM,GAAG;cAyJtB,cAAM;cAUN;kBAAA;EAjMiC,oBAAA,iBAAA;;cAuMjC"}
1
+ {"version":3,"file":"api-provider.d.cts","names":[],"sources":["../../../src/api/api-provider.tsx"],"mappings":";;KAQK;KAeA,4BAA4B;UAEvB;EACR;EACA;;UAGQ;EACR;EACA;EACA;EACA;EACA,YAAY;EACZ,iBAAiB;EACjB,oBAAoB,iBAAiB;;;;;EAKrC;;UAGQ;EACR;EACA;EACA;;cAGI,oBAAiB,YAAgB,sCACzB;UAaJ;EACR,WAAW;EACX;EACA,UAAU,MAAM;EAChB,YAAY;;cAGR,aAAa,MAAM,GAAG;cAgNtB,cAAM;cAUN;kBAAA;EA7PiC,oBAAA,iBAAA;;cAmQjC"}
@@ -14,6 +14,11 @@ interface ApiContextValue {
14
14
  widgetType: WidgetType;
15
15
  elevatedAccess?: ElevatedAccess;
16
16
  setElevatedAccess: (elevatedAccess?: ElevatedAccess) => void;
17
+ /**
18
+ * The token now belongs to a different session, user or organization, but
19
+ * the Client GraphQL data on screen may still belong to the previous one.
20
+ */
21
+ isIdentityPending: boolean;
17
22
  }
18
23
  interface AuthTokenQueryData {
19
24
  authToken: string;
@@ -1 +1 @@
1
- {"version":3,"file":"api-provider.d.ts","names":[],"sources":["../../../src/api/api-provider.tsx"],"mappings":";;KAOK;KAeA,4BAA4B;UAEvB;EACR;EACA;;UAGQ;EACR;EACA;EACA;EACA;EACA,YAAY;EACZ,iBAAiB;EACjB,oBAAoB,iBAAiB;;UAG7B;EACR;EACA;EACA;;cAGI,oBAAiB,YAAgB,sCACzB;UAaJ;EACR,WAAW;EACX;EACA,UAAU,MAAM;EAChB,YAAY;;cAGR,aAAa,MAAM,GAAG;cAyJtB,cAAM;cAUN;kBAAA;EAjMiC,oBAAA,iBAAA;;cAuMjC"}
1
+ {"version":3,"file":"api-provider.d.ts","names":[],"sources":["../../../src/api/api-provider.tsx"],"mappings":";;KAQK;KAeA,4BAA4B;UAEvB;EACR;EACA;;UAGQ;EACR;EACA;EACA;EACA;EACA,YAAY;EACZ,iBAAiB;EACjB,oBAAoB,iBAAiB;;;;;EAKrC;;UAGQ;EACR;EACA;EACA;;cAGI,oBAAiB,YAAgB,sCACzB;UAaJ;EACR,WAAW;EACX;EACA,UAAU,MAAM;EAChB,YAAY;;cAGR,aAAa,MAAM,GAAG;cAgNtB,cAAM;cAUN;kBAAA;EA7PiC,oBAAA,iBAAA;;cAmQjC"}
@@ -36,8 +36,7 @@ const ApiProvider = ({ authToken, baseUrl, children, widgetType }) => {
36
36
  });
37
37
  React.useEffect(() => {
38
38
  if (typeof authToken !== "string") return;
39
- const cached = queryClient.getQueryData(queryKey);
40
- if (cached?.authToken === authToken) return;
39
+ if (queryClient.getQueryData(queryKey)?.authToken === authToken) return;
41
40
  queryClient.cancelQueries({ queryKey });
42
41
  const claims = getClaims(authToken);
43
42
  queryClient.setQueryData(queryKey, {
@@ -45,12 +44,38 @@ const ApiProvider = ({ authToken, baseUrl, children, widgetType }) => {
45
44
  permissions: claims.permissions,
46
45
  exp: claims.exp
47
46
  });
48
- if (cached && !isSameSubject(getClaims(cached.authToken), claims)) queryClient.invalidateQueries({ predicate: (query) => isClientGraphqlQueryKey(query.queryKey) });
49
47
  }, [
50
48
  authToken,
51
49
  queryClient,
52
50
  queryKey
53
51
  ]);
52
+ const currentToken = typeof authToken === "string" ? authToken : authTokenQuery.data?.authToken;
53
+ const currentIdentityKey = React.useMemo(() => currentToken ? getIdentityKey(getClaims(currentToken)) : void 0, [currentToken]);
54
+ const [tokenIdentity, setTokenIdentity] = React.useState({
55
+ key: currentIdentityKey,
56
+ generation: 0
57
+ });
58
+ if (currentIdentityKey !== tokenIdentity.key) setTokenIdentity({
59
+ key: currentIdentityKey,
60
+ generation: tokenIdentity.key === void 0 ? tokenIdentity.generation : tokenIdentity.generation + 1
61
+ });
62
+ const [settledGeneration, setSettledGeneration] = React.useState(0);
63
+ const identityGeneration = tokenIdentity.generation;
64
+ const isIdentityPending = settledGeneration !== identityGeneration;
65
+ React.useEffect(() => {
66
+ if (!isIdentityPending) return;
67
+ queryClient.invalidateQueries({ predicate: isClientGraphqlQuery });
68
+ const settleWhenRefetched = () => {
69
+ if (!queryClient.getQueryCache().findAll({ predicate: isClientGraphqlQuery }).some((query) => query.state.fetchStatus === "fetching" || query.isActive() && query.state.isInvalidated)) setSettledGeneration(identityGeneration);
70
+ };
71
+ const unsubscribe = queryClient.getQueryCache().subscribe(settleWhenRefetched);
72
+ settleWhenRefetched();
73
+ return unsubscribe;
74
+ }, [
75
+ identityGeneration,
76
+ isIdentityPending,
77
+ queryClient
78
+ ]);
54
79
  const exp = authTokenQuery.data?.exp;
55
80
  React.useEffect(() => {
56
81
  if (!exp) return;
@@ -86,21 +111,30 @@ const ApiProvider = ({ authToken, baseUrl, children, widgetType }) => {
86
111
  baseUrl,
87
112
  elevatedAccess,
88
113
  setElevatedAccess,
89
- widgetType
114
+ widgetType,
115
+ isIdentityPending
90
116
  }), [
91
117
  authTokenQuery.data,
92
118
  baseUrl,
93
119
  elevatedAccess,
94
120
  setElevatedAccess,
95
- widgetType
121
+ widgetType,
122
+ isIdentityPending
96
123
  ]);
97
124
  return /* @__PURE__ */ jsx(ApiContext.Provider, {
98
125
  value,
99
126
  children
100
127
  });
101
128
  };
102
- function isSameSubject(a, b) {
103
- return a.sid === b.sid && a.sub === b.sub && a.org_id === b.org_id;
129
+ function isClientGraphqlQuery(query) {
130
+ return isClientGraphqlQueryKey(query.queryKey);
131
+ }
132
+ function getIdentityKey(claims) {
133
+ return JSON.stringify([
134
+ claims.sid,
135
+ claims.sub,
136
+ claims.org_id
137
+ ]);
104
138
  }
105
139
  const useApi = () => {
106
140
  const context = React.useContext(ApiContext);
@@ -1 +1 @@
1
- {"version":3,"file":"api-provider.js","names":[],"sources":["../../../src/api/api-provider.tsx"],"sourcesContent":["import { useQuery, useQueryClient } from \"@tanstack/react-query\";\nimport * as React from \"react\";\nimport { useIsHydrated } from \"../lib/use-is-hydrated.js\";\nimport { getClaims } from \"./claims.js\";\nimport type { Claims } from \"./claims.js\";\nimport { isClientGraphqlQueryKey } from \"./client-graphql/query-keys.js\";\n\ntype WidgetType =\n | \"admin-portal-audit-log-streaming\"\n | \"admin-portal-domain-verification\"\n | \"user-management\"\n | \"user-api-keys\"\n | \"organization-switcher\"\n | \"user-sessions\"\n | \"user-security\"\n | \"user-profile\"\n | \"admin-portal-sso-connection\"\n | \"api-keys\"\n | \"pipes\"\n | \"pipes-admin\"\n | \"directory-sync\";\n\ntype AuthToken = string | (() => Promise<string>);\n\ninterface ElevatedAccess {\n token: string;\n expiresAt: string;\n}\n\ninterface ApiContextValue {\n authToken: string | undefined;\n permissions: string[] | undefined;\n exp: number | undefined;\n baseUrl: string;\n widgetType: WidgetType;\n elevatedAccess?: ElevatedAccess;\n setElevatedAccess: (elevatedAccess?: ElevatedAccess) => void;\n}\n\ninterface AuthTokenQueryData {\n authToken: string;\n permissions: string[] | undefined;\n exp: number | undefined;\n}\n\nconst authTokenQueryKey = (widgetType: WidgetType) =>\n [\"authToken\", widgetType] as const;\n\n// Refetch this many ms before the JWT expires so data queries always see a\n// fresh token.\nconst TOKEN_REFRESH_BUFFER_MS = 30_000;\n\n// Cap setTimeout delays at ~24 days to stay well below the 32-bit signed int\n// limit (~24.8 days), which some runtimes silently coerce to 1ms.\nconst MAX_TIMEOUT_MS = 2_147_483_000;\n\nconst ApiContext = React.createContext<ApiContextValue | undefined>(undefined);\nApiContext.displayName = \"ApiContext\";\n\ninterface ApiProviderProps {\n authToken: AuthToken;\n baseUrl: string;\n children: React.ReactNode;\n widgetType: WidgetType;\n}\n\nconst ApiProvider: React.FC<ApiProviderProps> = ({\n authToken,\n baseUrl,\n children,\n widgetType,\n}) => {\n const queryClient = useQueryClient();\n const queryKey = authTokenQueryKey(widgetType);\n const authTokenQuery = useQuery<AuthTokenQueryData>({\n initialData: () => {\n if (typeof authToken !== \"string\") {\n return undefined;\n }\n\n const claims = getClaims(authToken);\n return {\n authToken,\n permissions: claims.permissions,\n exp: claims.exp,\n };\n },\n queryFn: async () => {\n const resolvedToken =\n typeof authToken === \"string\" ? authToken : await authToken();\n const claims = getClaims(resolvedToken);\n\n return {\n authToken: resolvedToken,\n permissions: claims.permissions,\n exp: claims.exp,\n };\n },\n queryKey,\n });\n\n // `initialData` only seeds the cache on first mount, so a new string token\n // has to be written to the cache explicitly. A token for a different session,\n // user or organization (e.g. after switching organizations) also invalidates\n // data fetched with the previous token.\n React.useEffect(() => {\n if (typeof authToken !== \"string\") {\n return;\n }\n\n const cached = queryClient.getQueryData<AuthTokenQueryData>(queryKey);\n if (cached?.authToken === authToken) {\n return;\n }\n\n // Keep an in-flight fetch holding the previous token from overwriting the\n // new one.\n void queryClient.cancelQueries({ queryKey });\n const claims = getClaims(authToken);\n queryClient.setQueryData<AuthTokenQueryData>(queryKey, {\n authToken,\n permissions: claims.permissions,\n exp: claims.exp,\n });\n\n if (cached && !isSameSubject(getClaims(cached.authToken), claims)) {\n void queryClient.invalidateQueries({\n predicate: (query) => isClientGraphqlQueryKey(query.queryKey),\n });\n }\n }, [authToken, queryClient, queryKey]);\n\n // Keep the token \"fresh\" until ~30s before it expires, so window-focus\n // re-fetches re-mint before data queries use a stale Bearer.\n //\n // TODO(v2): prefer the function-form `staleTime` on `useQuery` above once we\n // can bump the `@tanstack/react-query` peer dep to ^5.44.0 (the version that\n // introduced it). That lets react-query own the freshness window and avoids\n // the extra timer + window-focus nonsense this effect introduces.\n const exp = authTokenQuery.data?.exp;\n React.useEffect(() => {\n if (!exp) {\n return;\n }\n\n const delay = Math.min(\n MAX_TIMEOUT_MS,\n exp * 1000 - Date.now() - TOKEN_REFRESH_BUFFER_MS,\n );\n if (delay <= 0) {\n void queryClient.refetchQueries({\n queryKey: authTokenQueryKey(widgetType),\n });\n return;\n }\n const timeout = window.setTimeout(() => {\n void queryClient.refetchQueries({\n queryKey: authTokenQueryKey(widgetType),\n });\n }, delay);\n return () => window.clearTimeout(timeout);\n }, [exp, queryClient, widgetType]);\n\n const [elevatedAccess, setElevatedAccess] = React.useState<ElevatedAccess>();\n const elevatedAccessTimeout = React.useRef<number | undefined>(undefined);\n\n // This effect manages the expiration of elevated access tokens\n // When an elevated access token is present, it checks every 30 seconds if the token has expired\n // If the token has expired (current time > expiration time), it clears the elevated access\n React.useEffect(() => {\n if (elevatedAccessTimeout.current) {\n window.clearInterval(elevatedAccessTimeout.current);\n }\n\n if (elevatedAccess) {\n elevatedAccessTimeout.current = window.setInterval(() => {\n const now = new Date();\n const expiresAtDate = new Date(elevatedAccess.expiresAt);\n\n // Reset the elevated access if it has expired\n if (now > expiresAtDate) {\n setElevatedAccess(undefined);\n }\n }, 30_000); // every 30 seconds\n }\n\n return () => {\n if (elevatedAccessTimeout.current) {\n window.clearInterval(elevatedAccessTimeout.current);\n }\n };\n }, [elevatedAccess]);\n\n const value = React.useMemo(\n () => ({\n authToken: authTokenQuery.data?.authToken,\n permissions: authTokenQuery.data?.permissions,\n exp: authTokenQuery.data?.exp,\n baseUrl,\n elevatedAccess,\n setElevatedAccess,\n widgetType,\n }),\n [\n authTokenQuery.data,\n baseUrl,\n elevatedAccess,\n setElevatedAccess,\n widgetType,\n ],\n );\n\n return <ApiContext.Provider value={value}>{children}</ApiContext.Provider>;\n};\n\nfunction isSameSubject(a: Claims, b: Claims) {\n return a.sid === b.sid && a.sub === b.sub && a.org_id === b.org_id;\n}\n\nconst useApi = () => {\n const context = React.useContext(ApiContext);\n\n if (context === undefined) {\n throw new Error(\"useApi must be used within an ApiProvider\");\n }\n\n return context;\n};\n\nconst useElevatedAccessToken = () => {\n const { elevatedAccess, setElevatedAccess } = useApi();\n\n return { elevatedAccess, setElevatedAccess };\n};\n\nconst useApiReady = () => {\n const { authToken } = useApi();\n const isHydrated = useIsHydrated();\n\n return isHydrated && authToken !== undefined;\n};\nexport type { WidgetType, AuthToken, AuthTokenQueryData };\nexport {\n authTokenQueryKey,\n ApiProvider,\n useApi,\n useElevatedAccessToken,\n useApiReady,\n};\n"],"mappings":"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"}
1
+ {"version":3,"file":"api-provider.js","names":[],"sources":["../../../src/api/api-provider.tsx"],"sourcesContent":["import { useQuery, useQueryClient } from \"@tanstack/react-query\";\nimport type { Query } from \"@tanstack/react-query\";\nimport * as React from \"react\";\nimport { useIsHydrated } from \"../lib/use-is-hydrated.js\";\nimport { getClaims } from \"./claims.js\";\nimport type { Claims } from \"./claims.js\";\nimport { isClientGraphqlQueryKey } from \"./client-graphql/query-keys.js\";\n\ntype WidgetType =\n | \"admin-portal-audit-log-streaming\"\n | \"admin-portal-domain-verification\"\n | \"user-management\"\n | \"user-api-keys\"\n | \"organization-switcher\"\n | \"user-sessions\"\n | \"user-security\"\n | \"user-profile\"\n | \"admin-portal-sso-connection\"\n | \"api-keys\"\n | \"pipes\"\n | \"pipes-admin\"\n | \"directory-sync\";\n\ntype AuthToken = string | (() => Promise<string>);\n\ninterface ElevatedAccess {\n token: string;\n expiresAt: string;\n}\n\ninterface ApiContextValue {\n authToken: string | undefined;\n permissions: string[] | undefined;\n exp: number | undefined;\n baseUrl: string;\n widgetType: WidgetType;\n elevatedAccess?: ElevatedAccess;\n setElevatedAccess: (elevatedAccess?: ElevatedAccess) => void;\n /**\n * The token now belongs to a different session, user or organization, but\n * the Client GraphQL data on screen may still belong to the previous one.\n */\n isIdentityPending: boolean;\n}\n\ninterface AuthTokenQueryData {\n authToken: string;\n permissions: string[] | undefined;\n exp: number | undefined;\n}\n\nconst authTokenQueryKey = (widgetType: WidgetType) =>\n [\"authToken\", widgetType] as const;\n\n// Refetch this many ms before the JWT expires so data queries always see a\n// fresh token.\nconst TOKEN_REFRESH_BUFFER_MS = 30_000;\n\n// Cap setTimeout delays at ~24 days to stay well below the 32-bit signed int\n// limit (~24.8 days), which some runtimes silently coerce to 1ms.\nconst MAX_TIMEOUT_MS = 2_147_483_000;\n\nconst ApiContext = React.createContext<ApiContextValue | undefined>(undefined);\nApiContext.displayName = \"ApiContext\";\n\ninterface ApiProviderProps {\n authToken: AuthToken;\n baseUrl: string;\n children: React.ReactNode;\n widgetType: WidgetType;\n}\n\nconst ApiProvider: React.FC<ApiProviderProps> = ({\n authToken,\n baseUrl,\n children,\n widgetType,\n}) => {\n const queryClient = useQueryClient();\n const queryKey = authTokenQueryKey(widgetType);\n const authTokenQuery = useQuery<AuthTokenQueryData>({\n initialData: () => {\n if (typeof authToken !== \"string\") {\n return undefined;\n }\n\n const claims = getClaims(authToken);\n return {\n authToken,\n permissions: claims.permissions,\n exp: claims.exp,\n };\n },\n queryFn: async () => {\n const resolvedToken =\n typeof authToken === \"string\" ? authToken : await authToken();\n const claims = getClaims(resolvedToken);\n\n return {\n authToken: resolvedToken,\n permissions: claims.permissions,\n exp: claims.exp,\n };\n },\n queryKey,\n });\n\n // `initialData` only seeds the cache on first mount, so a new string token\n // has to be written to the cache explicitly.\n React.useEffect(() => {\n if (typeof authToken !== \"string\") {\n return;\n }\n\n const cached = queryClient.getQueryData<AuthTokenQueryData>(queryKey);\n if (cached?.authToken === authToken) {\n return;\n }\n\n // Keep an in-flight fetch holding the previous token from overwriting the\n // new one.\n void queryClient.cancelQueries({ queryKey });\n const claims = getClaims(authToken);\n queryClient.setQueryData<AuthTokenQueryData>(queryKey, {\n authToken,\n permissions: claims.permissions,\n exp: claims.exp,\n });\n }, [authToken, queryClient, queryKey]);\n\n const currentToken =\n typeof authToken === \"string\" ? authToken : authTokenQuery.data?.authToken;\n const currentIdentityKey = React.useMemo(\n () => (currentToken ? getIdentityKey(getClaims(currentToken)) : undefined),\n [currentToken],\n );\n // Counts changes to the token's session, user or organization (e.g. after\n // switching organizations). Switching back to an earlier identity counts too,\n // since requests made for the one in between may still be in flight.\n const [tokenIdentity, setTokenIdentity] = React.useState({\n key: currentIdentityKey,\n generation: 0,\n });\n if (currentIdentityKey !== tokenIdentity.key) {\n setTokenIdentity({\n key: currentIdentityKey,\n generation:\n tokenIdentity.key === undefined\n ? tokenIdentity.generation\n : tokenIdentity.generation + 1,\n });\n }\n // The identity generation whose Client GraphQL data is on screen. The\n // previous identity's data stays on screen until it has been refetched with\n // the new token.\n const [settledGeneration, setSettledGeneration] = React.useState(0);\n const identityGeneration = tokenIdentity.generation;\n const isIdentityPending = settledGeneration !== identityGeneration;\n React.useEffect(() => {\n if (!isIdentityPending) {\n return;\n }\n\n // Cancels refetches still in flight for an earlier identity.\n void queryClient.invalidateQueries({ predicate: isClientGraphqlQuery });\n // A failed refetch leaves its query invalidated, so the previous identity's\n // data stays locked until a refetch succeeds.\n const settleWhenRefetched = () => {\n const isRefetching = queryClient\n .getQueryCache()\n .findAll({ predicate: isClientGraphqlQuery })\n .some(\n (query) =>\n query.state.fetchStatus === \"fetching\" ||\n (query.isActive() && query.state.isInvalidated),\n );\n if (!isRefetching) {\n setSettledGeneration(identityGeneration);\n }\n };\n const unsubscribe = queryClient\n .getQueryCache()\n .subscribe(settleWhenRefetched);\n settleWhenRefetched();\n return unsubscribe;\n }, [identityGeneration, isIdentityPending, queryClient]);\n\n // Keep the token \"fresh\" until ~30s before it expires, so window-focus\n // re-fetches re-mint before data queries use a stale Bearer.\n //\n // TODO(v2): prefer the function-form `staleTime` on `useQuery` above once we\n // can bump the `@tanstack/react-query` peer dep to ^5.44.0 (the version that\n // introduced it). That lets react-query own the freshness window and avoids\n // the extra timer + window-focus nonsense this effect introduces.\n const exp = authTokenQuery.data?.exp;\n React.useEffect(() => {\n if (!exp) {\n return;\n }\n\n const delay = Math.min(\n MAX_TIMEOUT_MS,\n exp * 1000 - Date.now() - TOKEN_REFRESH_BUFFER_MS,\n );\n if (delay <= 0) {\n void queryClient.refetchQueries({\n queryKey: authTokenQueryKey(widgetType),\n });\n return;\n }\n const timeout = window.setTimeout(() => {\n void queryClient.refetchQueries({\n queryKey: authTokenQueryKey(widgetType),\n });\n }, delay);\n return () => window.clearTimeout(timeout);\n }, [exp, queryClient, widgetType]);\n\n const [elevatedAccess, setElevatedAccess] = React.useState<ElevatedAccess>();\n const elevatedAccessTimeout = React.useRef<number | undefined>(undefined);\n\n // This effect manages the expiration of elevated access tokens\n // When an elevated access token is present, it checks every 30 seconds if the token has expired\n // If the token has expired (current time > expiration time), it clears the elevated access\n React.useEffect(() => {\n if (elevatedAccessTimeout.current) {\n window.clearInterval(elevatedAccessTimeout.current);\n }\n\n if (elevatedAccess) {\n elevatedAccessTimeout.current = window.setInterval(() => {\n const now = new Date();\n const expiresAtDate = new Date(elevatedAccess.expiresAt);\n\n // Reset the elevated access if it has expired\n if (now > expiresAtDate) {\n setElevatedAccess(undefined);\n }\n }, 30_000); // every 30 seconds\n }\n\n return () => {\n if (elevatedAccessTimeout.current) {\n window.clearInterval(elevatedAccessTimeout.current);\n }\n };\n }, [elevatedAccess]);\n\n const value = React.useMemo(\n () => ({\n authToken: authTokenQuery.data?.authToken,\n permissions: authTokenQuery.data?.permissions,\n exp: authTokenQuery.data?.exp,\n baseUrl,\n elevatedAccess,\n setElevatedAccess,\n widgetType,\n isIdentityPending,\n }),\n [\n authTokenQuery.data,\n baseUrl,\n elevatedAccess,\n setElevatedAccess,\n widgetType,\n isIdentityPending,\n ],\n );\n\n return <ApiContext.Provider value={value}>{children}</ApiContext.Provider>;\n};\n\nfunction isClientGraphqlQuery(query: Query) {\n return isClientGraphqlQueryKey(query.queryKey);\n}\n\nfunction getIdentityKey(claims: Claims) {\n return JSON.stringify([claims.sid, claims.sub, claims.org_id]);\n}\n\nconst useApi = () => {\n const context = React.useContext(ApiContext);\n\n if (context === undefined) {\n throw new Error(\"useApi must be used within an ApiProvider\");\n }\n\n return context;\n};\n\nconst useElevatedAccessToken = () => {\n const { elevatedAccess, setElevatedAccess } = useApi();\n\n return { elevatedAccess, setElevatedAccess };\n};\n\nconst useApiReady = () => {\n const { authToken } = useApi();\n const isHydrated = useIsHydrated();\n\n return isHydrated && authToken !== undefined;\n};\nexport type { WidgetType, AuthToken, AuthTokenQueryData };\nexport {\n authTokenQueryKey,\n ApiProvider,\n useApi,\n useElevatedAccessToken,\n useApiReady,\n};\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos-inc/widgets",
3
- "version": "1.21.0",
3
+ "version": "1.21.1-next.1794250769562",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,4 +1,5 @@
1
1
  import { useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import type { Query } from "@tanstack/react-query";
2
3
  import * as React from "react";
3
4
  import { useIsHydrated } from "../lib/use-is-hydrated.js";
4
5
  import { getClaims } from "./claims.js";
@@ -35,6 +36,11 @@ interface ApiContextValue {
35
36
  widgetType: WidgetType;
36
37
  elevatedAccess?: ElevatedAccess;
37
38
  setElevatedAccess: (elevatedAccess?: ElevatedAccess) => void;
39
+ /**
40
+ * The token now belongs to a different session, user or organization, but
41
+ * the Client GraphQL data on screen may still belong to the previous one.
42
+ */
43
+ isIdentityPending: boolean;
38
44
  }
39
45
 
40
46
  interface AuthTokenQueryData {
@@ -100,9 +106,7 @@ const ApiProvider: React.FC<ApiProviderProps> = ({
100
106
  });
101
107
 
102
108
  // `initialData` only seeds the cache on first mount, so a new string token
103
- // has to be written to the cache explicitly. A token for a different session,
104
- // user or organization (e.g. after switching organizations) also invalidates
105
- // data fetched with the previous token.
109
+ // has to be written to the cache explicitly.
106
110
  React.useEffect(() => {
107
111
  if (typeof authToken !== "string") {
108
112
  return;
@@ -122,13 +126,64 @@ const ApiProvider: React.FC<ApiProviderProps> = ({
122
126
  permissions: claims.permissions,
123
127
  exp: claims.exp,
124
128
  });
129
+ }, [authToken, queryClient, queryKey]);
125
130
 
126
- if (cached && !isSameSubject(getClaims(cached.authToken), claims)) {
127
- void queryClient.invalidateQueries({
128
- predicate: (query) => isClientGraphqlQueryKey(query.queryKey),
129
- });
131
+ const currentToken =
132
+ typeof authToken === "string" ? authToken : authTokenQuery.data?.authToken;
133
+ const currentIdentityKey = React.useMemo(
134
+ () => (currentToken ? getIdentityKey(getClaims(currentToken)) : undefined),
135
+ [currentToken],
136
+ );
137
+ // Counts changes to the token's session, user or organization (e.g. after
138
+ // switching organizations). Switching back to an earlier identity counts too,
139
+ // since requests made for the one in between may still be in flight.
140
+ const [tokenIdentity, setTokenIdentity] = React.useState({
141
+ key: currentIdentityKey,
142
+ generation: 0,
143
+ });
144
+ if (currentIdentityKey !== tokenIdentity.key) {
145
+ setTokenIdentity({
146
+ key: currentIdentityKey,
147
+ generation:
148
+ tokenIdentity.key === undefined
149
+ ? tokenIdentity.generation
150
+ : tokenIdentity.generation + 1,
151
+ });
152
+ }
153
+ // The identity generation whose Client GraphQL data is on screen. The
154
+ // previous identity's data stays on screen until it has been refetched with
155
+ // the new token.
156
+ const [settledGeneration, setSettledGeneration] = React.useState(0);
157
+ const identityGeneration = tokenIdentity.generation;
158
+ const isIdentityPending = settledGeneration !== identityGeneration;
159
+ React.useEffect(() => {
160
+ if (!isIdentityPending) {
161
+ return;
130
162
  }
131
- }, [authToken, queryClient, queryKey]);
163
+
164
+ // Cancels refetches still in flight for an earlier identity.
165
+ void queryClient.invalidateQueries({ predicate: isClientGraphqlQuery });
166
+ // A failed refetch leaves its query invalidated, so the previous identity's
167
+ // data stays locked until a refetch succeeds.
168
+ const settleWhenRefetched = () => {
169
+ const isRefetching = queryClient
170
+ .getQueryCache()
171
+ .findAll({ predicate: isClientGraphqlQuery })
172
+ .some(
173
+ (query) =>
174
+ query.state.fetchStatus === "fetching" ||
175
+ (query.isActive() && query.state.isInvalidated),
176
+ );
177
+ if (!isRefetching) {
178
+ setSettledGeneration(identityGeneration);
179
+ }
180
+ };
181
+ const unsubscribe = queryClient
182
+ .getQueryCache()
183
+ .subscribe(settleWhenRefetched);
184
+ settleWhenRefetched();
185
+ return unsubscribe;
186
+ }, [identityGeneration, isIdentityPending, queryClient]);
132
187
 
133
188
  // Keep the token "fresh" until ~30s before it expires, so window-focus
134
189
  // re-fetches re-mint before data queries use a stale Bearer.
@@ -200,6 +255,7 @@ const ApiProvider: React.FC<ApiProviderProps> = ({
200
255
  elevatedAccess,
201
256
  setElevatedAccess,
202
257
  widgetType,
258
+ isIdentityPending,
203
259
  }),
204
260
  [
205
261
  authTokenQuery.data,
@@ -207,14 +263,19 @@ const ApiProvider: React.FC<ApiProviderProps> = ({
207
263
  elevatedAccess,
208
264
  setElevatedAccess,
209
265
  widgetType,
266
+ isIdentityPending,
210
267
  ],
211
268
  );
212
269
 
213
270
  return <ApiContext.Provider value={value}>{children}</ApiContext.Provider>;
214
271
  };
215
272
 
216
- function isSameSubject(a: Claims, b: Claims) {
217
- return a.sid === b.sid && a.sub === b.sub && a.org_id === b.org_id;
273
+ function isClientGraphqlQuery(query: Query) {
274
+ return isClientGraphqlQueryKey(query.queryKey);
275
+ }
276
+
277
+ function getIdentityKey(claims: Claims) {
278
+ return JSON.stringify([claims.sid, claims.sub, claims.org_id]);
218
279
  }
219
280
 
220
281
  const useApi = () => {