Skip to content

refactor: move auth side effects from React lifecycle to router loader via RootRouteContext#155

Draft
IzumiSy wants to merge 3 commits intomainfrom
refactor/auth-root-route-context
Draft

refactor: move auth side effects from React lifecycle to router loader via RootRouteContext#155
IzumiSy wants to merge 3 commits intomainfrom
refactor/auth-root-route-context

Conversation

@IzumiSy
Copy link
Copy Markdown
Contributor

@IzumiSy IzumiSy commented Apr 3, 2026

Motivation

AuthProvider previously used useEffect to handle three auth-related side effects:

  1. OAuth callback handling (handleCallback) — processing the ?code= parameter on redirect back from the OAuth provider
  2. Auth status check (checkAuthStatus) — verifying the current auth state on initial load
  3. Auto-login (autoLogin) — triggering login() when the user is unauthenticated

This caused a concrete problem under React strict mode, where useEffect is intentionally invoked twice during development. This meant handleCallback could be called multiple times for the same OAuth code, leading to unpredictable behavior.

Beyond the strict mode issue, running auth side effects inside useEffect has 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 runs  ──▶  rendering starts  ──▶  wrapComponent applied
(pre-render)       (React lifecycle)      (render phase)
  • loader (URL) => Promise<Response | null> — executes in the react-router loader phase before any component renders. AuthProvider uses this to run handleCallback, checkAuthStatus, and autoLogin.
  • wrapComponent (children) => ReactNode — wraps the root route element during React's render phase. AuthProvider uses this to render the guardComponent (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 from auth-context.tsx. It only consumes RootRouteContext, which exposes a generic loader function and a wrapComponent callback — no auth-specific types. This means:

  • The router has zero knowledge of EnhancedAuthClient, AuthState, or any auth internals
  • AuthProvider builds the complete loader (with all auth logic) internally and provides it via RootRouteContext.Provider
  • The AuthGuard component (which reads useAuth() to decide whether to show the guard UI) lives in auth-context.tsx, not in the router

This 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

RootRouteContext currently supports only one provider (a single createContext holds one value). Today the only provider is AuthProvider. 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, guardComponent prop, etc.) remain unchanged. This is a purely internal refactoring.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 3, 2026

Open in StackBlitz

npm i https://pkg.pr.new/@tailor-platform/app-shell@155
npm i https://pkg.pr.new/@tailor-platform/app-shell-vite-plugin@155

commit: 524751d

@IzumiSy IzumiSy force-pushed the refactor/auth-root-route-context branch from c65fcc4 to 310d17c Compare April 3, 2026 07:23
@IzumiSy IzumiSy changed the title Refactor: move auth side effects from React lifecycle to router loader via RootRouteContext refactor: move auth side effects from React lifecycle to router loader via RootRouteContext Apr 3, 2026
@IzumiSy
Copy link
Copy Markdown
Contributor Author

IzumiSy commented Apr 3, 2026

/review

Copy link
Copy Markdown
Contributor

@github-actions github-actions bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated by API Design Review for issue #155

// 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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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:

  1. The new tests removed the replaceStateSpy assertion — so this assumption is no longer verified.
  2. If @tailor-platform/auth-public-client's handleCallback does not call window.history.replaceState, the URL bar will continue to show ?code=xxx&state=yyy after the OAuth redirect is handled. More critically, because react-router re-runs the loader on every navigation, a stale ?code= in the URL could cause handleCallback to 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).
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant