Skip to content

Commit

Permalink
refactor(react): use reducer (#91)
Browse files Browse the repository at this point in the history
* refactor(react): use reducer

* fix: pr fix
  • Loading branch information
wangsijie authored Nov 9, 2021
1 parent 1316dc3 commit 0244596
Show file tree
Hide file tree
Showing 6 changed files with 154 additions and 52 deletions.
12 changes: 12 additions & 0 deletions packages/react/src/auth-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export interface AuthState {
isLoading: boolean;
isInitialized: boolean;
isAuthenticated: boolean;
error?: Error;
}

export const defaultAuthState: AuthState = {
isLoading: false,
isInitialized: false,
isAuthenticated: false,
};
19 changes: 16 additions & 3 deletions packages/react/src/context.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
import LogtoClient from '@logto/client';
import { createContext } from 'react';

export interface LogtoContextProperties {
import { AuthState, defaultAuthState } from './auth-state';

const notInProvider = (): never => {
throw new Error('Must be used inside <LogtoProvider>');
};

export interface LogtoContextProperties extends AuthState {
logtoClient?: LogtoClient;
isAuthenticated: boolean;
loginWithRedirect: (redirectUri: string) => Promise<void>;
handleCallback: (url: string) => Promise<void>;
logout: (redirectUri: string) => void;
}

export const LogtoContext = createContext<LogtoContextProperties>({ isAuthenticated: false });
export const LogtoContext = createContext<LogtoContextProperties>({
...defaultAuthState,
loginWithRedirect: notInProvider,
handleCallback: notInProvider,
logout: notInProvider,
});
72 changes: 62 additions & 10 deletions packages/react/src/provider.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import LogtoClient, { ConfigParameters } from '@logto/client';
import React, { useEffect, useState } from 'react';
import React, { useCallback, useEffect, useReducer, useState } from 'react';

import { defaultAuthState } from './auth-state';
import { LogtoContext } from './context';
import { reducer } from './reducer';

export interface LogtoProviderProperties {
logtoConfig: ConfigParameters;
Expand All @@ -10,24 +12,74 @@ export interface LogtoProviderProperties {

export const LogtoProvider = ({ logtoConfig, children }: LogtoProviderProperties) => {
const [logtoClient, setLogtoClient] = useState<LogtoClient>();
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [state, dispatch] = useReducer(reducer, defaultAuthState);

useEffect(() => {
const createClient = async () => {
const client = await LogtoClient.create({
...logtoConfig,
onAuthStateChange: () => {
setIsAuthenticated(client.isAuthenticated());
},
});
setLogtoClient(client);
try {
const client = await LogtoClient.create(logtoConfig);
dispatch({ type: 'INITIALIZE', isAuthenticated: client.isAuthenticated() });
setLogtoClient(client);
} catch (error: unknown) {
dispatch({ type: 'ERROR', error });
}
};

void createClient();
}, [logtoConfig]);

const loginWithRedirect = useCallback(
async (redirectUri: string) => {
if (!logtoClient) {
dispatch({ type: 'ERROR', error: new Error('Should init first') });
return;
}

dispatch({ type: 'LOGIN_WITH_REDIRECT' });
try {
await logtoClient.loginWithRedirect(redirectUri);
} catch (error: unknown) {
dispatch({ type: 'ERROR', error });
}
},
[logtoClient]
);

const handleCallback = useCallback(
async (uri: string) => {
if (!logtoClient) {
dispatch({ type: 'ERROR', error: new Error('Should init first') });
return;
}

dispatch({ type: 'HANDLE_CALLBACK_REQUEST' });
try {
await logtoClient.handleCallback(uri);
dispatch({ type: 'HANDLE_CALLBACK_SUCCESS' });
} catch (error: unknown) {
dispatch({ type: 'ERROR', error });
}
},
[logtoClient]
);

const logout = useCallback(
(redirectUri: string) => {
if (!logtoClient) {
dispatch({ type: 'ERROR', error: new Error('Should init first') });
return;
}

logtoClient.logout(redirectUri);
dispatch({ type: 'LOGOUT' });
},
[logtoClient]
);

return (
<LogtoContext.Provider value={{ logtoClient, isAuthenticated }}>
<LogtoContext.Provider
value={{ ...state, logtoClient, loginWithRedirect, handleCallback, logout }}
>
{children}
</LogtoContext.Provider>
);
Expand Down
42 changes: 42 additions & 0 deletions packages/react/src/reducer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { AuthState } from './auth-state';

type Action =
| { type: 'INITIALIZE'; isAuthenticated: boolean }
| { type: 'LOGIN_WITH_REDIRECT' }
| { type: 'HANDLE_CALLBACK_REQUEST' }
| { type: 'HANDLE_CALLBACK_SUCCESS' }
| { type: 'LOGOUT' }
| { type: 'ERROR'; error: unknown };

export const reducer = (state: AuthState, action: Action): AuthState => {
if (action.type === 'INITIALIZE') {
return { ...state, isInitialized: true, isAuthenticated: action.isAuthenticated };
}

if (action.type === 'LOGIN_WITH_REDIRECT') {
return { ...state, isLoading: true };
}

if (action.type === 'HANDLE_CALLBACK_REQUEST') {
return { ...state, isLoading: true };
}

if (action.type === 'HANDLE_CALLBACK_SUCCESS') {
return { ...state, isLoading: false, isAuthenticated: true };
}

if (action.type === 'LOGOUT') {
return { ...state, isAuthenticated: false };
}

if (action.type === 'ERROR') {
const { error } = action;
if (!(error instanceof Error)) {
throw error;
}

return { ...state, error, isLoading: false };
}

return state;
};
51 changes: 21 additions & 30 deletions packages/react/src/use-logto.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,16 @@ import useLogto from './use-logto';
const isAuthenticated = jest.fn();

jest.mock('@logto/client', () => {
const Mocked = jest.fn(({ onAuthStateChange }: { onAuthStateChange: () => void }) => {
const Mocked = jest.fn(() => {
return {
isAuthenticated,
handleCallback: jest.fn(() => {
onAuthStateChange();
}),
logout: jest.fn(() => {
onAuthStateChange();
}),
handleCallback: jest.fn(),
logout: jest.fn(),
};
});
return {
default: Mocked,
create: jest.fn(
({ onAuthStateChange }: { onAuthStateChange: () => void }) =>
new Mocked({ onAuthStateChange })
),
create: jest.fn(() => new Mocked()),
};
});

Expand Down Expand Up @@ -58,51 +51,49 @@ describe('useLogto', () => {
const { result, waitFor } = renderHook(() => useLogto(), {
wrapper: createHookWrapper(),
});
const { isAuthenticated, isLoading } = result.current;
expect(isLoading).toBeTruthy();
const { isAuthenticated, isInitialized } = result.current;
expect(isInitialized).toBeFalsy();
await waitFor(() => {
const { isLoading } = result.current;
expect(isLoading).toBeFalsy();
const { isInitialized } = result.current;
expect(isInitialized).toBeTruthy();
});
expect(isAuthenticated).toBeFalsy();
});

test('change from not authenticated to authenticated', async () => {
isAuthenticated.mockImplementationOnce(() => true);
const { result, waitFor } = renderHook(() => useLogto(), {
wrapper: createHookWrapper(),
});
const { isLoading } = result.current;
expect(isLoading).toBeTruthy();
await waitFor(() => {
const { isLoading } = result.current;
expect(isLoading).toBeFalsy();
const { isInitialized } = result.current;
expect(isInitialized).toBeTruthy();
});
expect(result.current.isAuthenticated).toBeFalsy();
act(() => {
result.current.logtoClient?.handleCallback('url');
await act(async () => result.current.handleCallback('url'));
await waitFor(() => {
const { isLoading } = result.current;
expect(isLoading).toBeFalsy();
});
expect(result.current.isAuthenticated).toBeTruthy();
});

test('change from authenticated to not authenticated', async () => {
isAuthenticated.mockImplementationOnce(() => true).mockImplementationOnce(() => false);
const { result, waitFor } = renderHook(() => useLogto(), {
wrapper: createHookWrapper(),
});
const { isLoading } = result.current;
expect(isLoading).toBeTruthy();
await waitFor(() => {
const { isLoading } = result.current;
expect(isLoading).toBeFalsy();
const { isInitialized } = result.current;
expect(isInitialized).toBeTruthy();
});
expect(result.current.isAuthenticated).toBeFalsy();
act(() => {
result.current.logtoClient?.handleCallback('url');
await act(async () => result.current.handleCallback('url'));
await waitFor(() => {
const { isLoading } = result.current;
expect(isLoading).toBeFalsy();
});
expect(result.current.isAuthenticated).toBeTruthy();
act(() => {
result.current.logtoClient?.logout('url');
result.current.logout('url');
});
expect(result.current.isAuthenticated).toBeFalsy();
});
Expand Down
10 changes: 1 addition & 9 deletions packages/react/src/use-logto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,5 @@ export default function useLogto() {
throw new Error('useLogto hook must be used inside LogtoProvider context');
}

const { logtoClient, isAuthenticated } = context;

const isLoading = !logtoClient;

return {
logtoClient,
isLoading,
isAuthenticated,
};
return context;
}

0 comments on commit 0244596

Please sign in to comment.