refactor: move auth side effects from React lifecycle to router loader via RootRouteContext#155
refactor: move auth side effects from React lifecycle to router loader via RootRouteContext#155
Conversation
commit: |
c65fcc4 to
310d17c
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 { |
There was a problem hiding this comment.
[1/2 — Medium] URL cleanup after OAuth callback relies on an undocumented/untested assumption
The old code explicitly cleaned ?code= and ?state= from the browser URL after handleCallback:
const cleanUrl = buildCleanOAuthCallbackUrl(new URL(window.location.href));
window.history.replaceState({}, "", cleanUrl);This PR removes it with the comment "handleCallback() internally cleans up the OAuth-related query parameters from the URL". However:
- The new tests removed the
replaceStateSpyassertion — so this assumption is no longer verified. - If
@tailor-platform/auth-public-client'shandleCallbackdoes not callwindow.history.replaceState, the URL bar will continue to show?code=xxx&state=yyyafter the OAuth redirect is handled. More critically, because react-router re-runs the loader on every navigation, a stale?code=in the URL could causehandleCallbackto be re-invoked on the next route change with an already-consumed code, likely resulting in an error.
Recommendation: Either (a) add a test that verifies URL cleanup happens after OAuth callback (e.g. assert window.location.search is clean), or (b) restore the explicit window.history.replaceState call here as a defensive measure, independent of what handleCallback does internally.
| }, [authState.isReady, authState.isAuthenticated, props.autoLogin, client]); | ||
| // autoLogin is evaluated separately from checkAuthStatus so that it | ||
| // still fires after handleCallback updates the internal state via | ||
| // getState() (e.g. setting isAuthenticated to true on success). |
There was a problem hiding this comment.
[2/2 — Medium] autoLogin: true no longer reacts to session expiry during active use
The old useEffect subscribed to [authState.isReady, authState.isAuthenticated]. This meant that if a user's session expired mid-session (the client emits a new state with isAuthenticated: false), client.login() would fire reactively and redirect them to the identity provider.
The new loader-only approach only evaluates autoLogin when a route transition occurs (loader re-run). If a session expires while the user is idle on a page, useSyncExternalStore will update authState and AuthGuard will render guardComponent(), but client.login() will never be called — the user is left stuck on the guard UI until they navigate somewhere.
This is a silent behavioral regression for apps relying on autoLogin: true to maintain uninterrupted sessions.
Recommendation: Add a useEffect in AuthProvider (or inside AuthGuard) that watches authState.isAuthenticated and calls client.login() when autoLogin is enabled and auth is lost after the initial load:
useEffect(() => {
if (!props.autoLogin || !authState.isReady || authState.isAuthenticated) return;
client.login();
}, [authState.isReady, authState.isAuthenticated, props.autoLogin, client]);This preserves the original reactive behavior while keeping the loader responsible for the initial auth check.
Motivation
AuthProviderpreviously useduseEffectto handle three auth-related side effects:handleCallback) — processing the?code=parameter on redirect back from the OAuth providercheckAuthStatus) — verifying the current auth state on initial loadautoLogin) — triggeringlogin()when the user is unauthenticatedThis caused a concrete problem under React strict mode, where
useEffectis intentionally invoked twice during development. This meanthandleCallbackcould be called multiple times for the same OAuth code, leading to unpredictable behavior.Beyond the strict mode issue, running auth side effects inside
useEffecthas an inherent timing problem: because effects run after rendering, the component tree briefly renders in an unauthenticated state before the auth check completes. Moving these operations to a react-router loader — which runs before any rendering — eliminates this flash and makes the auth initialization more predictable.Design
RootRouteContext — decoupling router from auth internals
The central design decision is the introduction of
RootRouteContext(root-route-context.tsx), a generic injection point for the router's root route. This context carries two hook points:loader(URL) => Promise<Response | null>— executes in the react-router loader phase before any component renders. AuthProvider uses this to runhandleCallback,checkAuthStatus, andautoLogin.wrapComponent(children) => ReactNode— wraps the root route element during React's render phase. AuthProvider uses this to render theguardComponent(showing a loading/login UI while auth is pending).These two are independent concerns that happen to share a delivery mechanism: both need to be injected into the root route by an external provider.
Why a separate context?
The router (
router.tsx) does not import fromauth-context.tsx. It only consumesRootRouteContext, which exposes a genericloaderfunction and awrapComponentcallback — no auth-specific types. This means:EnhancedAuthClient,AuthState, or any auth internalsAuthProviderbuilds the complete loader (with all auth logic) internally and provides it viaRootRouteContext.ProviderAuthGuardcomponent (which readsuseAuth()to decide whether to show the guard UI) lives inauth-context.tsx, not in the routerThis separation keeps the router purely concerned with route construction and makes the auth integration a plug-in that works through a well-defined interface.
Single-provider limitation
RootRouteContextcurrently supports only one provider (a singlecreateContextholds one value). Today the only provider isAuthProvider. If additional providers need to inject root-route behavior in the future, a composition mechanism would be needed. This trade-off is documented in the code.No Public API Changes
All public exports (
AuthProvider,useAuth,createAuthClient,guardComponentprop, etc.) remain unchanged. This is a purely internal refactoring.