Conversation
commit: |
|
/review |
useEffect in authentication mechanism
|
/review |
|
/review |
3a87876 to
4716d75
Compare
|
Confirmed changes are working fine |
4716d75 to
3a42209
Compare
Move replace(buildCleanOAuthCallbackUrl()) outside of the try block so the ?code= parameter is always removed from the URL regardless of whether handleCallback() succeeds or fails. This prevents an unrecoverable auth error loop caused by re-attempting the callback with an expired code.
Replace inline function type with the exported AuthLoader type for consistency and single-site maintenance.
…ize checkAuthStatus calls - Add try/catch around checkAuthStatus and login in the loader to prevent error boundary rendering on transient failures - Add @internal annotation to AuthLoader type for consistency - Skip checkAuthStatus on subsequent navigations (only run when isReady is false) - Separate autoLogin evaluation from checkAuthStatus to handle post-callback flow - Remove buildCleanOAuthCallbackUrl (handleCallback handles URL cleanup internally) - Update tests to reflect new behavior
3fe2bb6 to
9e6d3d2
Compare
|
/review |
| // handleCallback() internally cleans up the OAuth-related query parameters | ||
| // from the URL, so no additional URL cleanup is needed here. | ||
| if (requestUrl.searchParams.has("code")) { | ||
| try { | ||
| await client.handleCallback(); | ||
| } catch (error) { |
There was a problem hiding this comment.
[1/4 — High] Unverified assumption: handleCallback() cleans the URL
The old code explicitly removed OAuth params from the URL:
// Old code
await client.handleCallback();
const cleanUrl = buildCleanOAuthCallbackUrl(new URL(window.location.href));
window.history.replaceState({}, "", cleanUrl);The new code relies on the comment "handleCallback() internally cleans up the OAuth-related query parameters", but this is an unverified assumption about a third-party library (@tailor-platform/auth-public-client). If handleCallback doesn't update window.location, then after a successful OAuth login, refreshing the page would re-run this loader with ?code= still in the URL, calling handleCallback again with a stale one-time-use authorization code — an error most OAuth servers will reject.
The matching test (router.test.tsx "handles OAuth callback and redirects to clean URL") doesn't assert URL cleanup, so there's no regression protection.
Consider adding explicit URL cleanup after handleCallback, or adding a test assertion that verifies the URL no longer contains ?code= after the loader runs.
| it("handles OAuth callback and redirects to clean URL", async () => { | ||
| const mockHandleCallback = vi.fn().mockResolvedValue(undefined); | ||
| const mockCheckAuthStatus = vi.fn().mockResolvedValue({ | ||
| isAuthenticated: true, | ||
| error: null, | ||
| isReady: true, | ||
| }); | ||
| const authClient = createMockAuthClient( | ||
| { isAuthenticated: true, error: null, isReady: true }, | ||
| { | ||
| handleCallback: mockHandleCallback, | ||
| checkAuthStatus: mockCheckAuthStatus, | ||
| }, | ||
| ); | ||
|
|
||
| renderWithConfig({ | ||
| modules: [], | ||
| rootComponent: () => <div>Home</div>, | ||
| initialEntries: ["/?code=auth-code-123&state=abc"], | ||
| authClient, | ||
| }); | ||
|
|
||
| await screen.findByText("Home"); |
There was a problem hiding this comment.
[2/4 — Medium] Test name promises URL cleanup that isn't asserted
This test is titled "handles OAuth callback and redirects to clean URL" but only asserts mockHandleCallback was called — it does not verify the URL was actually cleaned (no assertion on the browser URL, no replaceState spy).
The previous test explicitly used replaceStateSpy to confirm URL cleanup. That assertion was removed here, leaving the "redirects to clean URL" claim unverified. If URL cleanup regresses (e.g., if handleCallback never cleaned up the URL), this test would still pass.
Either rename the test to accurately describe what's tested, or add an assertion that the URL no longer contains ?code= after the loader runs (e.g., inspecting the rendered URL or window.location.href on a MemoryRouter).
| value={{ | ||
| authState, | ||
| login: () => client.login(), | ||
| logout: () => client.logout(), | ||
| checkAuthStatus: () => client.checkAuthStatus(), | ||
| ready: () => client.ready(), | ||
| client, | ||
| autoLogin: props.autoLogin, | ||
| guardComponent: props.guardComponent, | ||
| }} |
There was a problem hiding this comment.
[3/4 — Medium] AuthClientContext.Provider has an unstable value (missing useMemo)
// Current code — new object on every render
(AuthClientContext.Provider
value=\{\{
client,
autoLogin: props.autoLogin,
guardComponent: props.guardComponent,
}}
)The value object is recreated on every AuthProvider render. AuthProvider re-renders on every auth state change (via useSyncExternalStore), which means every auth state transition produces a new context value. All AuthClientContext consumers (notably RouterContainer via useAuthLoader) will re-render in response.
This is inconsistent with how AppShell manages its context values (with useMemo). More critically: RouterContainer calls createBrowserRouter/createMemoryRouter unconditionally in its render body, so any re-render of RouterContainer recreates the entire router — resetting navigation state (current route, history, etc.) on each auth state change (e.g., the transition from isReady: false → isReady: true on initial load).
// Suggested fix
const authClientContextValue = useMemo(
() => ({ client, autoLogin: props.autoLogin, guardComponent: props.guardComponent }),
[client, props.autoLogin, props.guardComponent],
);
// ...
(AuthClientContext.Provider value={authClientContextValue})| useAuth, | ||
| useAuthSuspense, | ||
| buildCleanOAuthCallbackUrl, | ||
| useAuthLoader, |
There was a problem hiding this comment.
[4/4 — Low] buildCleanOAuthCallbackUrl is now dead code — exported but never called, tests removed
This PR removed the useEffect that called buildCleanOAuthCallbackUrl, and deleted its 10 well-specified tests ("removes code parameter", "preserves hash fragments", etc.). However, buildCleanOAuthCallbackUrl is still exported from auth-context.tsx and remains in the public-facing file — it's just no longer called anywhere.
If the function is no longer needed (because handleCallback handles URL cleanup internally), it should be removed along with its JSDoc. If it's intentionally kept as a utility for external callers, its tests should be restored.
Leaving it exported without tests and without usages is confusing and creates false surface area.
Improve authentication initialization to run outside of React's lifecycle by moving it into a react-router loader.
Motivation
AuthProviderpreviously useduseEffectto handle OAuth callbacks (handleCallback), check auth status (checkAuthStatus), and trigger auto-login. This caused issues under React strict mode, whereuseEffectis invoked twice in development, potentially callinghandleCallbackmultiple times.Changes
useEffectfromAuthProvider: All auth side effects (handleCallback,checkAuthStatus,autoLogin) have been removed from React's lifecycle.useAuthLoaderhook (auth-context.tsx): An internal hook that returns a loader function for handling OAuth callbacks and auth status checks. ReturnsnullwhenAuthProvideris not present, making auth integration optional.createRootLoader(router.tsx): A higher-order function that combines the auth loader with the existing navigation loader. WhenauthLoaderis available, it runs before navigation loading and can short-circuit with a redirect response.?code=params) now complete before any UI is mounted, eliminating unnecessary renders.No Public API Changes
This is a purely internal refactoring.
AuthProvider,useAuth,createAuthClient, and all other public exports remain unchanged.