Skip to content

Commit 0fb65d0

Browse files
Allow users to specify client instead of creating it for them (#82)
Currently, you have to specify an authUrl and we construct the client. This is fine, and still supported, but we do have cases where people want to use the client outside of a react context and it's nicer to just let them pass in a client to share. Additionally, this fixes a case where unmounting and remounting the authprovider can create extra clients.
1 parent 75ccb8e commit 0fb65d0

File tree

5 files changed

+174
-41
lines changed

5 files changed

+174
-41
lines changed

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,15 @@
55
"type": "git",
66
"url": "https://github.com/PropelAuth/react"
77
},
8-
"version": "2.0.32",
8+
"version": "2.1.0",
99
"license": "MIT",
1010
"keywords": [
1111
"auth",
1212
"react",
1313
"user"
1414
],
1515
"dependencies": {
16-
"@propelauth/javascript": "^2.0.23",
16+
"@propelauth/javascript": "^2.0.24",
1717
"hoist-non-react-statics": "^3.3.2",
1818
"utility-types": "^3.10.0"
1919
},
@@ -62,7 +62,7 @@
6262
"build:types": "tsc --emitDeclarationOnly",
6363
"build:js": "rollup -c",
6464
"lint": "eslint --ext .ts,.tsx .",
65-
"build": "npm run test && npm run lint && npm run build:types && npm run build:js",
65+
"build": "npm run test && npm run build:types && npm run build:js",
6666
"test": "npm run lint && jest --silent",
6767
"prepublishOnly": "npm run build"
6868
},

src/AuthContext.tsx

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
AccessTokenForActiveOrg,
33
AuthenticationInfo,
4+
IAuthClient,
45
RedirectToAccountOptions,
56
RedirectToCreateOrgOptions,
67
RedirectToLoginOptions,
@@ -46,24 +47,52 @@ export interface InternalAuthState {
4647
defaultDisplayIfLoggedOut?: React.ReactElement
4748
}
4849

49-
export type AuthProviderProps = {
50-
authUrl: string
50+
type BaseAuthProviderProps = {
5151
defaultDisplayWhileLoading?: React.ReactElement
5252
defaultDisplayIfLoggedOut?: React.ReactElement
5353
/**
5454
* getActiveOrgFn is deprecated. Use `useActiveOrgV2` instead.
5555
*/
5656
getActiveOrgFn?: () => string | null
5757
children?: React.ReactNode
58+
}
59+
60+
type AuthProviderWithAuthUrl = BaseAuthProviderProps & {
61+
authUrl: string
5862
minSecondsBeforeRefresh?: number
63+
client?: never
64+
}
65+
66+
type AuthProviderWithClient = BaseAuthProviderProps & {
67+
client: IAuthClient
68+
authUrl?: never
69+
minSecondsBeforeRefresh?: never
5970
}
6071

61-
export interface RequiredAuthProviderProps
62-
extends Omit<AuthProviderProps, "defaultDisplayWhileLoading" | "defaultDisplayIfLoggedOut"> {
72+
export type AuthProviderProps = AuthProviderWithAuthUrl | AuthProviderWithClient
73+
74+
type BaseRequiredAuthProviderProps = Omit<
75+
BaseAuthProviderProps,
76+
"defaultDisplayWhileLoading" | "defaultDisplayIfLoggedOut"
77+
> & {
6378
displayWhileLoading?: React.ReactElement
6479
displayIfLoggedOut?: React.ReactElement
6580
}
6681

82+
type RequiredAuthProviderWithAuthUrl = BaseRequiredAuthProviderProps & {
83+
authUrl: string
84+
minSecondsBeforeRefresh?: number
85+
client?: never
86+
}
87+
88+
type RequiredAuthProviderWithClient = BaseRequiredAuthProviderProps & {
89+
client: IAuthClient
90+
authUrl?: never
91+
minSecondsBeforeRefresh?: never
92+
}
93+
94+
export type RequiredAuthProviderProps = RequiredAuthProviderWithAuthUrl | RequiredAuthProviderWithClient
95+
6796
export const AuthContext = React.createContext<InternalAuthState | undefined>(undefined)
6897

6998
type AuthInfoState = {
@@ -103,18 +132,22 @@ function authInfoStateReducer(_state: AuthInfoState, action: AuthInfoStateAction
103132

104133
export const AuthProvider = (props: AuthProviderProps) => {
105134
const {
106-
authUrl,
107-
minSecondsBeforeRefresh,
108135
getActiveOrgFn: deprecatedGetActiveOrgFn,
109136
children,
110137
defaultDisplayWhileLoading,
111138
defaultDisplayIfLoggedOut,
112139
} = props
140+
141+
const clientRefProps =
142+
"client" in props && props.client
143+
? { client: props.client }
144+
: { authUrl: props.authUrl!, minSecondsBeforeRefresh: props.minSecondsBeforeRefresh }
145+
146+
const authUrl =
147+
"client" in clientRefProps ? clientRefProps.client!.getAuthOptions().authUrl : clientRefProps.authUrl
148+
113149
const [authInfoState, dispatch] = useReducer(authInfoStateReducer, initialAuthInfoState)
114-
const { clientRef, accessTokenChangeCounter } = useClientRef({
115-
authUrl,
116-
minSecondsBeforeRefresh,
117-
})
150+
const { clientRef, accessTokenChangeCounter } = useClientRef(clientRefProps)
118151

119152
// Refresh the token when the user has logged in or out
120153
useEffect(() => {

src/index.test.js

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -326,7 +326,7 @@ it("redirectToLoginPage calls into the client", async () => {
326326
return <div>Finished</div>
327327
}
328328
render(
329-
<AuthProvider authUrl={AUTH_URL}>
329+
<AuthProvider client={mockClient}>
330330
<Component />
331331
</AuthProvider>
332332
)
@@ -341,7 +341,7 @@ it("redirectToSignupPage calls into the client", async () => {
341341
return <div>Finished</div>
342342
}
343343
render(
344-
<AuthProvider authUrl={AUTH_URL}>
344+
<AuthProvider client={mockClient}>
345345
<Component />
346346
</AuthProvider>
347347
)
@@ -356,7 +356,7 @@ it("redirectToCreateOrgPage calls into the client", async () => {
356356
return <div>Finished</div>
357357
}
358358
render(
359-
<AuthProvider authUrl={AUTH_URL}>
359+
<AuthProvider client={mockClient}>
360360
<Component />
361361
</AuthProvider>
362362
)
@@ -371,7 +371,7 @@ it("redirectToAccountPage calls into the client", async () => {
371371
return <div>Finished</div>
372372
}
373373
render(
374-
<AuthProvider authUrl={AUTH_URL}>
374+
<AuthProvider client={mockClient}>
375375
<Component />
376376
</AuthProvider>
377377
)
@@ -386,7 +386,7 @@ it("redirectToOrgPage calls into the client", async () => {
386386
return <div>Finished</div>
387387
}
388388
render(
389-
<AuthProvider authUrl={AUTH_URL}>
389+
<AuthProvider client={mockClient}>
390390
<Component />
391391
</AuthProvider>
392392
)
@@ -401,14 +401,49 @@ it("logout calls into the client", async () => {
401401
return <div>Finished</div>
402402
}
403403
render(
404-
<AuthProvider authUrl={AUTH_URL}>
404+
<AuthProvider client={mockClient}>
405405
<Component />
406406
</AuthProvider>
407407
)
408408
await waitFor(() => screen.getByText("Finished"))
409409
expect(mockClient.logout).toBeCalled()
410410
})
411411

412+
it("external client is not destroyed on unmount", async () => {
413+
const { unmount } = render(
414+
<AuthProvider client={mockClient}>
415+
<div>Finished</div>
416+
</AuthProvider>
417+
)
418+
await waitFor(() => screen.getByText("Finished"))
419+
unmount()
420+
expect(mockClient.destroy).not.toHaveBeenCalled()
421+
})
422+
423+
it("useAuthInfo returns auth info from external client", async () => {
424+
const authenticationInfo = createAuthenticationInfo()
425+
mockClient.getAuthenticationInfoOrNull.mockReturnValue(authenticationInfo)
426+
427+
const Component = () => {
428+
const authInfo = useAuthInfo()
429+
if (authInfo.loading) {
430+
return <div>Loading...</div>
431+
}
432+
expect(authInfo.accessToken).toBe(authenticationInfo.accessToken)
433+
expect(authInfo.user).toStrictEqual(authenticationInfo.user)
434+
expect(authInfo.isLoggedIn).toBe(true)
435+
return <div>Finished</div>
436+
}
437+
438+
render(
439+
<AuthProvider client={mockClient}>
440+
<Component />
441+
</AuthProvider>
442+
)
443+
444+
await waitFor(() => screen.getByText("Finished"))
445+
})
446+
412447
it("when client logs out, authInfo is refreshed", async () => {
413448
const initialAuthInfo = createAuthenticationInfo()
414449
mockClient.getAuthenticationInfoOrNull.mockReturnValueOnce(initialAuthInfo).mockReturnValueOnce(null)
@@ -583,9 +618,18 @@ it("AuthProviderForTesting can be used with useAuthInfo", async () => {
583618
await waitFor(() => screen.getByText("Finished"))
584619
})
585620

621+
const AUTH_URL = "authUrl"
622+
586623
function createMockClient() {
587624
return {
588625
getAuthenticationInfoOrNull: jest.fn(),
626+
getAuthOptions: jest.fn().mockReturnValue({
627+
authUrl: AUTH_URL,
628+
enableBackgroundTokenRefresh: true,
629+
minSecondsBeforeRefresh: 120,
630+
disableRefreshOnFocus: false,
631+
skipInitialFetch: true,
632+
}),
589633
logout: jest.fn(),
590634
redirectToSignupPage: jest.fn(),
591635
redirectToLoginPage: jest.fn(),
@@ -600,10 +644,12 @@ function createMockClient() {
600644
}
601645
}
602646

603-
const AUTH_URL = "authUrl"
604-
605647
function expectCreateClientWasCalledCorrectly() {
606-
expect(createClient).toHaveBeenCalledWith({ authUrl: AUTH_URL, enableBackgroundTokenRefresh: true, skipInitialFetch: true })
648+
expect(createClient).toHaveBeenCalledWith({
649+
authUrl: AUTH_URL,
650+
enableBackgroundTokenRefresh: true,
651+
skipInitialFetch: true,
652+
})
607653
}
608654

609655
function createOrg() {

src/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
export { OrgMemberInfoClass, UserClass } from "@propelauth/javascript"
1+
export { createClient, OrgMemberInfoClass, UserClass } from "@propelauth/javascript"
22
export type {
33
AccessHelper,
44
AccessHelperWithOrg,
5+
IAuthClient,
6+
IAuthOptions,
57
OrgHelper,
68
OrgIdToOrgMemberInfo,
79
OrgIdToOrgMemberInfoClass,

src/useClientRef.tsx

Lines changed: 70 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,37 +6,82 @@ type ClientRef = {
66
client: IAuthClient
77
}
88

9-
interface UseClientRefProps {
9+
// Props when creating a new client internally
10+
type UseClientRefCreateProps = {
1011
authUrl: string
1112
minSecondsBeforeRefresh?: number
13+
client?: never
1214
}
1315

16+
// Props when using an externally-provided client
17+
type UseClientRefExternalProps = {
18+
client: IAuthClient
19+
authUrl?: never
20+
minSecondsBeforeRefresh?: never
21+
}
22+
23+
type UseClientRefProps = UseClientRefCreateProps | UseClientRefExternalProps
24+
1425
export const useClientRef = (props: UseClientRefProps) => {
1526
const [accessTokenChangeCounter, setAccessTokenChangeCounter] = useState(0)
16-
const { authUrl, minSecondsBeforeRefresh } = props
1727

18-
// Use a ref to store the client so that it doesn't get recreated on every render
19-
const clientRef = useRef<ClientRef | null>(null)
20-
if (clientRef.current === null) {
21-
const client = createClient({ authUrl, enableBackgroundTokenRefresh: true, minSecondsBeforeRefresh, skipInitialFetch: true })
22-
client.addAccessTokenChangeObserver(() => setAccessTokenChangeCounter((x) => x + 1))
23-
clientRef.current = { authUrl, client }
24-
}
28+
const externalClient = "client" in props ? props.client : undefined
29+
const authUrl = "authUrl" in props ? props.authUrl : undefined
30+
const minSecondsBeforeRefresh = "minSecondsBeforeRefresh" in props ? props.minSecondsBeforeRefresh : undefined
2531

26-
// If the authUrl changes, destroy the old client and create a new one
32+
// Initialize ref immediately with external client (available during render)
33+
// or null (will be set in useEffect for internally-created clients)
34+
const clientRef = useRef<ClientRef | null>(
35+
externalClient ? { authUrl: externalClient.getAuthOptions().authUrl, client: externalClient } : null
36+
)
37+
38+
// Effect for external client: set up observer
2739
useEffect(() => {
28-
if (clientRef.current === null) {
40+
if (!externalClient) {
2941
return
30-
} else if (clientRef.current.authUrl === authUrl) {
42+
}
43+
44+
// Warning for disabled background refresh
45+
const options = externalClient.getAuthOptions()
46+
if (!options.enableBackgroundTokenRefresh) {
47+
console.warn(
48+
"[@propelauth/react] The provided client has enableBackgroundTokenRefresh disabled. " +
49+
"This may cause authentication state to become stale."
50+
)
51+
}
52+
53+
const observer = () => setAccessTokenChangeCounter((x) => x + 1)
54+
externalClient.addAccessTokenChangeObserver(observer)
55+
56+
return () => {
57+
externalClient.removeAccessTokenChangeObserver(observer)
58+
}
59+
}, [externalClient])
60+
61+
// Effect for internal client: create, set up observer, and manage lifecycle
62+
useEffect(() => {
63+
if (externalClient) {
3164
return
32-
} else {
33-
clientRef.current.client.destroy()
65+
}
3466

35-
const newClient = createClient({ authUrl, enableBackgroundTokenRefresh: true, minSecondsBeforeRefresh, skipInitialFetch: true })
36-
newClient.addAccessTokenChangeObserver(() => setAccessTokenChangeCounter((x) => x + 1))
37-
clientRef.current = { authUrl, client: newClient }
67+
const client = createClient({
68+
authUrl: authUrl!,
69+
enableBackgroundTokenRefresh: true,
70+
minSecondsBeforeRefresh,
71+
skipInitialFetch: true,
72+
})
73+
74+
client.addAccessTokenChangeObserver(() => setAccessTokenChangeCounter((x) => x + 1))
75+
76+
clientRef.current = { authUrl: client.getAuthOptions().authUrl, client }
77+
78+
return () => {
79+
client.destroy()
80+
if (clientRef.current?.client === client) {
81+
clientRef.current = null
82+
}
3883
}
39-
}, [authUrl])
84+
}, [externalClient, authUrl, minSecondsBeforeRefresh])
4085

4186
return { clientRef, accessTokenChangeCounter }
4287
}
@@ -49,6 +94,13 @@ export const useClientRefCallback = <I extends unknown[], O>(
4994
(...inputs: I) => {
5095
const client = clientRef.current?.client
5196
if (!client) {
97+
console.error(
98+
"[@propelauth/react] Auth client is not initialized yet. " +
99+
"The client is created in a useEffect, which runs after render. " +
100+
"This error typically occurs when calling auth methods during component render. " +
101+
"To fix this, either move auth calls to useEffect/event handlers, or create " +
102+
"the client yourself with createClient() and pass it to AuthProvider via the 'client' prop."
103+
)
52104
throw new Error("Client is not initialized")
53105
}
54106
return callback(client)(...inputs)

0 commit comments

Comments
 (0)