Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 49 additions & 11 deletions __tests__/useAccessToken.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,15 @@ describe('useAccessToken', () => {
);
};

it('should fetch an access token on mount without showing loading state', async () => {
it('should fetch an access token on mount and show loading state initially', async () => {
const mockToken =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature';
(getAccessTokenAction as jest.Mock).mockResolvedValueOnce(mockToken);

const { getByTestId } = render(<TestComponent />);

// Loading should remain false for background fetches
expect(getByTestId('loading')).toHaveTextContent('false');
// Loading should be true during initial fetch
expect(getByTestId('loading')).toHaveTextContent('true');

await waitFor(() => {
expect(getAccessTokenAction).toHaveBeenCalledTimes(1);
Expand All @@ -77,7 +77,7 @@ describe('useAccessToken', () => {
});
});

it('should handle token refresh when an expiring token is received without showing loading', async () => {
it('should handle token refresh when an expiring token is received', async () => {
// Create a token that's about to expire (exp is very close to current time)
const currentTimeInSeconds = Math.floor(Date.now() / 1000);
// Use 25 seconds to ensure it's within the 30-second buffer for short-lived tokens
Expand All @@ -97,8 +97,8 @@ describe('useAccessToken', () => {

const { getByTestId } = render(<TestComponent />);

// Loading should remain false throughout
expect(getByTestId('loading')).toHaveTextContent('false');
// Loading should be true initially during token fetch
expect(getByTestId('loading')).toHaveTextContent('true');

await waitFor(() => {
expect(getByTestId('loading')).toHaveTextContent('false');
Expand Down Expand Up @@ -155,14 +155,14 @@ describe('useAccessToken', () => {
});
});

it('should handle errors during token fetch without showing loading', async () => {
it('should handle errors during token fetch', async () => {
const error = new Error('Failed to fetch token');
(getAccessTokenAction as jest.Mock).mockRejectedValueOnce(error);

const { getByTestId } = render(<TestComponent />);

// Loading should remain false even when there's an error
expect(getByTestId('loading')).toHaveTextContent('false');
// Loading should be true initially
expect(getByTestId('loading')).toHaveTextContent('true');

await waitFor(() => {
expect(getByTestId('loading')).toHaveTextContent('false');
Expand Down Expand Up @@ -381,8 +381,8 @@ describe('useAccessToken', () => {

const { getByTestId } = render(<TestComponent />);

// Loading should remain false for background operations
expect(getByTestId('loading')).toHaveTextContent('false');
// Loading should be true initially during fetch
expect(getByTestId('loading')).toHaveTextContent('true');

await waitFor(() => {
expect(fetchCalls).toBe(1);
Expand Down Expand Up @@ -459,6 +459,44 @@ describe('useAccessToken', () => {
});
});

it('should show loading state immediately on first render when user exists but no token', () => {
// Mock user with no token initially
(useAuth as jest.Mock).mockImplementation(() => ({
user: { id: 'user_123' },
sessionId: 'session_123',
refreshAuth: jest.fn().mockResolvedValue({}),
}));

(getAccessTokenAction as jest.Mock).mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve('token'), 100)),
);

const { getByTestId } = render(<TestComponent />);

expect(getByTestId('loading')).toHaveTextContent('true');
expect(getByTestId('token')).toHaveTextContent('no-token');
});

it('should not show loading when a valid token already exists', async () => {
const existingToken =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJleGlzdGluZyIsInNpZCI6InNlc3Npb24xMjMiLCJleHAiOjk5OTk5OTk5OTl9.existing';

await act(async () => {
(getAccessTokenAction as jest.Mock).mockResolvedValueOnce(existingToken);
await tokenStore.getAccessTokenSilently();
});

// Reset the mock to track new calls
(getAccessTokenAction as jest.Mock).mockClear();

const { getByTestId } = render(<TestComponent />);

expect(getByTestId('loading')).toHaveTextContent('false');
expect(getByTestId('token')).toHaveTextContent(existingToken);

expect(getAccessTokenAction).not.toHaveBeenCalled();
});

// Additional test cases to increase coverage
it('should handle concurrent manual refresh attempts', async () => {
const initialToken =
Expand Down
39 changes: 34 additions & 5 deletions src/components/useAccessToken.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react';
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
import { useAuth } from './authkit-provider.js';
import { tokenStore } from './tokenStore.js';

Expand Down Expand Up @@ -42,9 +42,17 @@ export function useAccessToken(): UseAccessTokenReturn {

const tokenState = useSyncExternalStore(tokenStore.subscribe, tokenStore.getSnapshot, tokenStore.getServerSnapshot);

// Track if we're waiting for the initial token fetch for the current user
// Initialize synchronously to prevent first-paint flash
const [isInitialTokenLoading, setIsInitialTokenLoading] = useState(() => {
// Only show loading if we have a user but no token yet
return Boolean(user && !tokenState.token && !tokenState.error);
});

useEffect(() => {
if (!user) {
tokenStore.clearToken();
setIsInitialTokenLoading(false);
return;
}

Expand All @@ -59,10 +67,28 @@ export function useAccessToken(): UseAccessTokenReturn {
prevSessionRef.current = sessionId;
prevUserIdRef.current = userId;

// Check if getAccessTokenSilently will actually fetch (not just return cached)
const currentToken = tokenStore.getSnapshot().token;
const tokenData = currentToken ? tokenStore.parseToken(currentToken) : null;
const willActuallyFetch = !currentToken || (tokenData && tokenData.isExpiring);

// Only show loading if we're actually going to fetch
if (willActuallyFetch) {
setIsInitialTokenLoading(true);
}

/* istanbul ignore next */
tokenStore.getAccessTokenSilently().catch(() => {
// Error is handled in the store
});
tokenStore
.getAccessTokenSilently()
.catch(() => {
// Error is handled in the store
})
.finally(() => {
// Only clear loading if we were actually loading
if (willActuallyFetch) {
setIsInitialTokenLoading(false);
}
});
}, [userId, sessionId]);

useEffect(() => {
Expand Down Expand Up @@ -112,9 +138,12 @@ export function useAccessToken(): UseAccessTokenReturn {
return tokenStore.refreshToken();
}, []);

// Combine loading states: initial token fetch OR token store is loading
const isLoading = isInitialTokenLoading || tokenState.loading;

return {
accessToken: tokenState.token,
loading: tokenState.loading,
loading: isLoading,
error: tokenState.error,
refresh,
getAccessToken,
Expand Down