From b8858a9dc67626c72c2441d9eba392300f98322b Mon Sep 17 00:00:00 2001 From: Andrew Israel Date: Mon, 24 Nov 2025 23:50:21 -0800 Subject: [PATCH] Allow users to specify `client` instead of creating it for them 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. --- package.json | 6 +-- src/AuthContext.tsx | 53 +++++++++++++++++++++----- src/index.test.js | 64 +++++++++++++++++++++++++++----- src/index.tsx | 4 +- src/useClientRef.tsx | 88 +++++++++++++++++++++++++++++++++++--------- 5 files changed, 174 insertions(+), 41 deletions(-) diff --git a/package.json b/package.json index aaf111b..33c1f6b 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "git", "url": "https://github.com/PropelAuth/react" }, - "version": "2.0.32", + "version": "2.1.0", "license": "MIT", "keywords": [ "auth", @@ -13,7 +13,7 @@ "user" ], "dependencies": { - "@propelauth/javascript": "^2.0.23", + "@propelauth/javascript": "^2.0.24", "hoist-non-react-statics": "^3.3.2", "utility-types": "^3.10.0" }, @@ -62,7 +62,7 @@ "build:types": "tsc --emitDeclarationOnly", "build:js": "rollup -c", "lint": "eslint --ext .ts,.tsx .", - "build": "npm run test && npm run lint && npm run build:types && npm run build:js", + "build": "npm run test && npm run build:types && npm run build:js", "test": "npm run lint && jest --silent", "prepublishOnly": "npm run build" }, diff --git a/src/AuthContext.tsx b/src/AuthContext.tsx index 0a8ef77..2570267 100644 --- a/src/AuthContext.tsx +++ b/src/AuthContext.tsx @@ -1,6 +1,7 @@ import { AccessTokenForActiveOrg, AuthenticationInfo, + IAuthClient, RedirectToAccountOptions, RedirectToCreateOrgOptions, RedirectToLoginOptions, @@ -46,8 +47,7 @@ export interface InternalAuthState { defaultDisplayIfLoggedOut?: React.ReactElement } -export type AuthProviderProps = { - authUrl: string +type BaseAuthProviderProps = { defaultDisplayWhileLoading?: React.ReactElement defaultDisplayIfLoggedOut?: React.ReactElement /** @@ -55,15 +55,44 @@ export type AuthProviderProps = { */ getActiveOrgFn?: () => string | null children?: React.ReactNode +} + +type AuthProviderWithAuthUrl = BaseAuthProviderProps & { + authUrl: string minSecondsBeforeRefresh?: number + client?: never +} + +type AuthProviderWithClient = BaseAuthProviderProps & { + client: IAuthClient + authUrl?: never + minSecondsBeforeRefresh?: never } -export interface RequiredAuthProviderProps - extends Omit { +export type AuthProviderProps = AuthProviderWithAuthUrl | AuthProviderWithClient + +type BaseRequiredAuthProviderProps = Omit< + BaseAuthProviderProps, + "defaultDisplayWhileLoading" | "defaultDisplayIfLoggedOut" +> & { displayWhileLoading?: React.ReactElement displayIfLoggedOut?: React.ReactElement } +type RequiredAuthProviderWithAuthUrl = BaseRequiredAuthProviderProps & { + authUrl: string + minSecondsBeforeRefresh?: number + client?: never +} + +type RequiredAuthProviderWithClient = BaseRequiredAuthProviderProps & { + client: IAuthClient + authUrl?: never + minSecondsBeforeRefresh?: never +} + +export type RequiredAuthProviderProps = RequiredAuthProviderWithAuthUrl | RequiredAuthProviderWithClient + export const AuthContext = React.createContext(undefined) type AuthInfoState = { @@ -103,18 +132,22 @@ function authInfoStateReducer(_state: AuthInfoState, action: AuthInfoStateAction export const AuthProvider = (props: AuthProviderProps) => { const { - authUrl, - minSecondsBeforeRefresh, getActiveOrgFn: deprecatedGetActiveOrgFn, children, defaultDisplayWhileLoading, defaultDisplayIfLoggedOut, } = props + + const clientRefProps = + "client" in props && props.client + ? { client: props.client } + : { authUrl: props.authUrl!, minSecondsBeforeRefresh: props.minSecondsBeforeRefresh } + + const authUrl = + "client" in clientRefProps ? clientRefProps.client!.getAuthOptions().authUrl : clientRefProps.authUrl + const [authInfoState, dispatch] = useReducer(authInfoStateReducer, initialAuthInfoState) - const { clientRef, accessTokenChangeCounter } = useClientRef({ - authUrl, - minSecondsBeforeRefresh, - }) + const { clientRef, accessTokenChangeCounter } = useClientRef(clientRefProps) // Refresh the token when the user has logged in or out useEffect(() => { diff --git a/src/index.test.js b/src/index.test.js index 2b56a9e..f78e55d 100644 --- a/src/index.test.js +++ b/src/index.test.js @@ -326,7 +326,7 @@ it("redirectToLoginPage calls into the client", async () => { return
Finished
} render( - + ) @@ -341,7 +341,7 @@ it("redirectToSignupPage calls into the client", async () => { return
Finished
} render( - + ) @@ -356,7 +356,7 @@ it("redirectToCreateOrgPage calls into the client", async () => { return
Finished
} render( - + ) @@ -371,7 +371,7 @@ it("redirectToAccountPage calls into the client", async () => { return
Finished
} render( - + ) @@ -386,7 +386,7 @@ it("redirectToOrgPage calls into the client", async () => { return
Finished
} render( - + ) @@ -401,7 +401,7 @@ it("logout calls into the client", async () => { return
Finished
} render( - + ) @@ -409,6 +409,41 @@ it("logout calls into the client", async () => { expect(mockClient.logout).toBeCalled() }) +it("external client is not destroyed on unmount", async () => { + const { unmount } = render( + +
Finished
+
+ ) + await waitFor(() => screen.getByText("Finished")) + unmount() + expect(mockClient.destroy).not.toHaveBeenCalled() +}) + +it("useAuthInfo returns auth info from external client", async () => { + const authenticationInfo = createAuthenticationInfo() + mockClient.getAuthenticationInfoOrNull.mockReturnValue(authenticationInfo) + + const Component = () => { + const authInfo = useAuthInfo() + if (authInfo.loading) { + return
Loading...
+ } + expect(authInfo.accessToken).toBe(authenticationInfo.accessToken) + expect(authInfo.user).toStrictEqual(authenticationInfo.user) + expect(authInfo.isLoggedIn).toBe(true) + return
Finished
+ } + + render( + + + + ) + + await waitFor(() => screen.getByText("Finished")) +}) + it("when client logs out, authInfo is refreshed", async () => { const initialAuthInfo = createAuthenticationInfo() mockClient.getAuthenticationInfoOrNull.mockReturnValueOnce(initialAuthInfo).mockReturnValueOnce(null) @@ -583,9 +618,18 @@ it("AuthProviderForTesting can be used with useAuthInfo", async () => { await waitFor(() => screen.getByText("Finished")) }) +const AUTH_URL = "authUrl" + function createMockClient() { return { getAuthenticationInfoOrNull: jest.fn(), + getAuthOptions: jest.fn().mockReturnValue({ + authUrl: AUTH_URL, + enableBackgroundTokenRefresh: true, + minSecondsBeforeRefresh: 120, + disableRefreshOnFocus: false, + skipInitialFetch: true, + }), logout: jest.fn(), redirectToSignupPage: jest.fn(), redirectToLoginPage: jest.fn(), @@ -600,10 +644,12 @@ function createMockClient() { } } -const AUTH_URL = "authUrl" - function expectCreateClientWasCalledCorrectly() { - expect(createClient).toHaveBeenCalledWith({ authUrl: AUTH_URL, enableBackgroundTokenRefresh: true, skipInitialFetch: true }) + expect(createClient).toHaveBeenCalledWith({ + authUrl: AUTH_URL, + enableBackgroundTokenRefresh: true, + skipInitialFetch: true, + }) } function createOrg() { diff --git a/src/index.tsx b/src/index.tsx index b903d39..c7ca4bb 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,7 +1,9 @@ -export { OrgMemberInfoClass, UserClass } from "@propelauth/javascript" +export { createClient, OrgMemberInfoClass, UserClass } from "@propelauth/javascript" export type { AccessHelper, AccessHelperWithOrg, + IAuthClient, + IAuthOptions, OrgHelper, OrgIdToOrgMemberInfo, OrgIdToOrgMemberInfoClass, diff --git a/src/useClientRef.tsx b/src/useClientRef.tsx index aba5652..6b2d22b 100644 --- a/src/useClientRef.tsx +++ b/src/useClientRef.tsx @@ -6,37 +6,82 @@ type ClientRef = { client: IAuthClient } -interface UseClientRefProps { +// Props when creating a new client internally +type UseClientRefCreateProps = { authUrl: string minSecondsBeforeRefresh?: number + client?: never } +// Props when using an externally-provided client +type UseClientRefExternalProps = { + client: IAuthClient + authUrl?: never + minSecondsBeforeRefresh?: never +} + +type UseClientRefProps = UseClientRefCreateProps | UseClientRefExternalProps + export const useClientRef = (props: UseClientRefProps) => { const [accessTokenChangeCounter, setAccessTokenChangeCounter] = useState(0) - const { authUrl, minSecondsBeforeRefresh } = props - // Use a ref to store the client so that it doesn't get recreated on every render - const clientRef = useRef(null) - if (clientRef.current === null) { - const client = createClient({ authUrl, enableBackgroundTokenRefresh: true, minSecondsBeforeRefresh, skipInitialFetch: true }) - client.addAccessTokenChangeObserver(() => setAccessTokenChangeCounter((x) => x + 1)) - clientRef.current = { authUrl, client } - } + const externalClient = "client" in props ? props.client : undefined + const authUrl = "authUrl" in props ? props.authUrl : undefined + const minSecondsBeforeRefresh = "minSecondsBeforeRefresh" in props ? props.minSecondsBeforeRefresh : undefined - // If the authUrl changes, destroy the old client and create a new one + // Initialize ref immediately with external client (available during render) + // or null (will be set in useEffect for internally-created clients) + const clientRef = useRef( + externalClient ? { authUrl: externalClient.getAuthOptions().authUrl, client: externalClient } : null + ) + + // Effect for external client: set up observer useEffect(() => { - if (clientRef.current === null) { + if (!externalClient) { return - } else if (clientRef.current.authUrl === authUrl) { + } + + // Warning for disabled background refresh + const options = externalClient.getAuthOptions() + if (!options.enableBackgroundTokenRefresh) { + console.warn( + "[@propelauth/react] The provided client has enableBackgroundTokenRefresh disabled. " + + "This may cause authentication state to become stale." + ) + } + + const observer = () => setAccessTokenChangeCounter((x) => x + 1) + externalClient.addAccessTokenChangeObserver(observer) + + return () => { + externalClient.removeAccessTokenChangeObserver(observer) + } + }, [externalClient]) + + // Effect for internal client: create, set up observer, and manage lifecycle + useEffect(() => { + if (externalClient) { return - } else { - clientRef.current.client.destroy() + } - const newClient = createClient({ authUrl, enableBackgroundTokenRefresh: true, minSecondsBeforeRefresh, skipInitialFetch: true }) - newClient.addAccessTokenChangeObserver(() => setAccessTokenChangeCounter((x) => x + 1)) - clientRef.current = { authUrl, client: newClient } + const client = createClient({ + authUrl: authUrl!, + enableBackgroundTokenRefresh: true, + minSecondsBeforeRefresh, + skipInitialFetch: true, + }) + + client.addAccessTokenChangeObserver(() => setAccessTokenChangeCounter((x) => x + 1)) + + clientRef.current = { authUrl: client.getAuthOptions().authUrl, client } + + return () => { + client.destroy() + if (clientRef.current?.client === client) { + clientRef.current = null + } } - }, [authUrl]) + }, [externalClient, authUrl, minSecondsBeforeRefresh]) return { clientRef, accessTokenChangeCounter } } @@ -49,6 +94,13 @@ export const useClientRefCallback = ( (...inputs: I) => { const client = clientRef.current?.client if (!client) { + console.error( + "[@propelauth/react] Auth client is not initialized yet. " + + "The client is created in a useEffect, which runs after render. " + + "This error typically occurs when calling auth methods during component render. " + + "To fix this, either move auth calls to useEffect/event handlers, or create " + + "the client yourself with createClient() and pass it to AuthProvider via the 'client' prop." + ) throw new Error("Client is not initialized") } return callback(client)(...inputs)