diff --git a/.changeset/quiet-auth-stability.md b/.changeset/quiet-auth-stability.md new file mode 100644 index 00000000..d5b770a1 --- /dev/null +++ b/.changeset/quiet-auth-stability.md @@ -0,0 +1,7 @@ +--- +"@tailor-platform/app-shell": patch +--- + +Fix OAuth callback handling so auth redirects do not re-run unnecessarily when AppShell re-renders. + +Auth initialization now also starts from `AuthProvider`, which avoids unresolved auth state when consumers are mounted outside the router-driven AppShell flow. diff --git a/packages/core/src/contexts/auth-context.test.tsx b/packages/core/src/contexts/auth-context.test.tsx index d558258d..8e94f64d 100644 --- a/packages/core/src/contexts/auth-context.test.tsx +++ b/packages/core/src/contexts/auth-context.test.tsx @@ -8,13 +8,20 @@ vi.mock("@tailor-platform/auth-public-client", () => ({ createAuthClient: vi.fn(), })); -import { AuthProvider, useAuth, useAuthSuspense, type EnhancedAuthClient } from "./auth-context"; +import { + AuthProvider, + useAuth, + useEnsureAuthInitialized, + useAuthSuspense, + type EnhancedAuthClient, +} from "./auth-context"; import { useRootRouteContext } from "./root-route-context"; afterEach(() => { cleanup(); vi.clearAllMocks(); vi.unstubAllGlobals(); + window.history.replaceState({}, "", "/"); }); const LoadingGuard = () =>
Loading...
; @@ -38,12 +45,27 @@ describe("AuthProvider", () => { isReady: false, }; + const baseHandleCallback = overrides?.handleCallback ?? vi.fn(); + let handleCallbackInFlight: Promise | null = null; + const handleCallback = vi.fn(() => { + if (handleCallbackInFlight) { + return handleCallbackInFlight; + } + + const callbackPromise = Promise.resolve(baseHandleCallback()).finally(() => { + handleCallbackInFlight = null; + }); + handleCallbackInFlight = callbackPromise; + return callbackPromise; + }); + + const { handleCallback: _ignoredHandleCallback, ...otherOverrides } = overrides ?? {}; + return { getState: vi.fn(() => state), login: vi.fn(), logout: vi.fn(), getAuthUrl: vi.fn(), - handleCallback: vi.fn(), checkAuthStatus: vi.fn().mockResolvedValue({ isAuthenticated: false, error: null, @@ -56,10 +78,110 @@ describe("AuthProvider", () => { getAuthHeaders: vi.fn(), fetch: vi.fn(), getAppUri: vi.fn(() => "https://api.test.com"), - ...overrides, + ...otherOverrides, + handleCallback, } as EnhancedAuthClient; }; + describe("useEnsureAuthInitialized", () => { + it("should initialize auth status on mount", async () => { + const state = { + isAuthenticated: false, + error: null, + isReady: false, + }; + const mockCheckAuthStatus = vi.fn().mockResolvedValue({ + isAuthenticated: true, + error: null, + isReady: true, + }); + + const mockClient = createMockAuthClient(state, { + checkAuthStatus: mockCheckAuthStatus, + }); + + const { result } = renderHook(() => useEnsureAuthInitialized(mockClient)); + + await act(async () => { + await result.current(); + }); + + expect(mockCheckAuthStatus).toHaveBeenCalledTimes(1); + }); + + it("should coalesce overlapping auth initialization checks", async () => { + const state = { + isAuthenticated: false, + error: null, + isReady: false, + }; + + let resolveCheckAuthStatus: (() => void) | undefined; + const mockCheckAuthStatus = vi.fn( + () => + new Promise<{ + isAuthenticated: boolean; + error: null; + isReady: true; + }>((resolve) => { + resolveCheckAuthStatus = () => + resolve({ + isAuthenticated: true, + error: null, + isReady: true, + }); + }), + ); + + const mockClient = createMockAuthClient(state, { + checkAuthStatus: mockCheckAuthStatus, + }); + + const { result } = renderHook(() => useEnsureAuthInitialized(mockClient)); + + const mountRetry = result.current(); + + await waitFor(() => { + expect(mockCheckAuthStatus).toHaveBeenCalledTimes(1); + }); + + const firstRetry = result.current(); + const secondRetry = result.current(); + + expect(mockCheckAuthStatus).toHaveBeenCalledTimes(1); + + resolveCheckAuthStatus?.(); + await Promise.all([mountRetry, firstRetry, secondRetry]); + }); + + it("should skip auth initialization while handling an OAuth callback", async () => { + window.history.replaceState({}, "", "/?code=auth-code-123&state=abc"); + + const state = { + isAuthenticated: false, + error: null, + isReady: false, + }; + const mockCheckAuthStatus = vi.fn().mockResolvedValue({ + isAuthenticated: false, + error: null, + isReady: true, + }); + + const mockClient = createMockAuthClient(state, { + checkAuthStatus: mockCheckAuthStatus, + }); + + const { result } = renderHook(() => useEnsureAuthInitialized(mockClient)); + + await act(async () => { + await result.current(); + }); + + expect(mockCheckAuthStatus).not.toHaveBeenCalled(); + }); + }); + describe("initial state", () => { it("should render children when not using guard component", () => { const mockClient = createMockAuthClient(); @@ -137,7 +259,7 @@ describe("AuthProvider", () => { }); describe("authentication flow", () => { - it("should check auth status via useRootRouteContext", async () => { + it("should not check auth status via useRootRouteContext on non-callback URLs", async () => { const state = { isAuthenticated: false, error: null, @@ -158,8 +280,12 @@ describe("AuthProvider", () => { }); expect(result.current).not.toBeNull(); + await waitFor(() => { + expect(mockCheckAuthStatus).toHaveBeenCalledTimes(1); + }); + const response = await result.current!.loader(new URL("http://localhost/")); - expect(mockCheckAuthStatus).toHaveBeenCalled(); + expect(mockCheckAuthStatus).toHaveBeenCalledTimes(1); expect(response).toBeNull(); }); @@ -187,6 +313,51 @@ describe("AuthProvider", () => { expect(response).toBeNull(); }); + it("should deduplicate concurrent OAuth callback handling", async () => { + const state = { + isAuthenticated: true, + error: null, + isReady: true, + }; + + let resolveFirstCallback: (() => void) | undefined; + const mockHandleCallback = vi.fn(() => { + if (mockHandleCallback.mock.calls.length === 0) { + return Promise.resolve(); + } + + if (mockHandleCallback.mock.calls.length === 1) { + return new Promise((resolve) => { + resolveFirstCallback = resolve; + }); + } + + return Promise.resolve(); + }); + + const mockClient = createMockAuthClient(state, { + handleCallback: mockHandleCallback, + }); + + const { result } = renderHook(() => useRootRouteContext(), { + wrapper: ({ children }) => {children}, + }); + + expect(result.current).not.toBeNull(); + + const requestUrl = new URL("http://localhost/?code=auth-code-123&state=abc"); + const firstLoad = result.current!.loader(requestUrl); + const secondLoad = result.current!.loader(requestUrl); + + expect(mockHandleCallback).toHaveBeenCalledTimes(1); + + resolveFirstCallback?.(); + await Promise.all([firstLoad, secondLoad]); + + await result.current!.loader(requestUrl); + expect(mockHandleCallback).toHaveBeenCalledTimes(2); + }); + it("should be authenticated when logged in", async () => { const state = { isAuthenticated: true, @@ -597,6 +768,59 @@ describe("AuthProvider", () => { { timeout: 1000 }, ); }); + + it("should not login while the current URL is an OAuth callback", async () => { + window.history.replaceState({}, "", "/?code=auth-code-123&state=abc"); + + let authEventListener: ((event: { type: string; data?: unknown }) => void) | undefined; + + const mockAddEventListener = vi.fn( + (listener: (event: { type: string; data?: unknown }) => void) => { + authEventListener = listener; + return () => {}; + }, + ); + + let currentState = { + isAuthenticated: false, + error: null as string | null, + isReady: true, + }; + + const mockLogin = vi.fn().mockResolvedValue(undefined); + const mockClient = createMockAuthClient(undefined, { + login: mockLogin, + addEventListener: mockAddEventListener, + getState: vi.fn(() => currentState), + }); + + render( + +
Content
+
, + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockLogin).not.toHaveBeenCalled(); + + act(() => { + currentState = { + isAuthenticated: false, + error: null, + isReady: true, + }; + authEventListener?.({ type: "auth_state_changed", data: {} }); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(mockLogin).not.toHaveBeenCalled(); + }); }); describe("event listeners", () => { diff --git a/packages/core/src/contexts/auth-context.tsx b/packages/core/src/contexts/auth-context.tsx index d5719a1f..aab8af36 100644 --- a/packages/core/src/contexts/auth-context.tsx +++ b/packages/core/src/contexts/auth-context.tsx @@ -3,6 +3,7 @@ import { useContext, useSyncExternalStore, useCallback, + useEffect, useMemo, useRef, } from "react"; @@ -160,6 +161,17 @@ type AuthContextType = { const AuthContext = createContext(null); +const isOAuthCallbackUrl = (url: URL) => + url.searchParams.has("code") || url.searchParams.has("error"); + +const isCurrentOAuthCallbackUrl = () => { + if (typeof window === "undefined") { + return false; + } + + return isOAuthCallbackUrl(new URL(window.location.href)); +}; + /** * Guard component that shows a fallback UI while auth is not ready or * not authenticated. Defined here so that the router layer does not @@ -221,6 +233,7 @@ const useAutoLogin = (props: { client: EnhancedAuthClient; enabled?: boolean }) const authState = props.client.getState(); if ( !props.enabled || + isCurrentOAuthCallbackUrl() || !authState.isReady || authState.isAuthenticated || loginInFlightRef.current @@ -262,6 +275,38 @@ const useAutoLogin = (props: { client: EnhancedAuthClient; enabled?: boolean }) }; }; +/** + * Builds a stable function that resolves the initial auth state once. + * + * AuthProvider uses the returned function from its own useEffect so the + * initialization flow stays visible at the call site, while overlapping + * checks still collapse into a single request. + */ +export const useEnsureAuthInitialized = (client: EnhancedAuthClient) => { + const initInFlightRef = useRef | null>(null); + + const ensureInitialized = useCallback(async (): Promise => { + if (isCurrentOAuthCallbackUrl() || client.getState().isReady) { + return; + } + + if (initInFlightRef.current) { + return initInFlightRef.current; + } + + initInFlightRef.current = client + .checkAuthStatus() + .then(() => undefined) + .finally(() => { + initInFlightRef.current = null; + }); + + return initInFlightRef.current; + }, [client]); + + return ensureInitialized; +}; + /** * Authentication provider component. * @@ -301,33 +346,33 @@ export const AuthProvider = (props: React.PropsWithChildren) // Use useSyncExternalStore for state management const authState = useSyncExternalStore(subscribeAuthState, getSnapshot); - // Build the root loader inside AuthProvider so that the router layer - // never needs to know about EnhancedAuthClient internals. + // Prepare a shared initialization function so AuthProvider can start the + // first auth check itself without depending on router navigation. + const ensureAuthInitialized = useEnsureAuthInitialized(client); + + // AuthProvider owns the normal startup path: on mount, ask the auth client + // to resolve the current session so consumers can rely on authState even + // before any router loader has run. + useEffect(() => { + ensureAuthInitialized().catch((error) => { + console.error("Failed to check auth status:", error); + }); + }, [ensureAuthInitialized]); + + // The router loader is reserved for OAuth callback URLs. Its job is to + // finish the redirect handshake before rendering continues, while the + // ordinary "am I already signed in?" check stays with AuthProvider. const authLoader = useCallback( async (requestUrl: URL): Promise => { - // The "code" query parameter indicates a redirect back from the OAuth provider. - // 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")) { + // handleCallback() exchanges the callback parameters, stores the new + // session, and removes the temporary OAuth query parameters from the URL. + if (isOAuthCallbackUrl(requestUrl)) { try { await client.handleCallback(); } catch (error) { console.error("Failed to handle callback:", error); } - } - - // Only check auth status on first load (when isReady is false). - // Subsequent navigations skip this because the client already holds - // the cached auth state via useSyncExternalStore. - if (!client.getState().isReady) { - try { - await client.checkAuthStatus(); - } catch (error) { - // Intentionally swallow errors to avoid rendering the error boundary - // on transient failures (e.g. network timeouts). The next navigation - // will re-run this loader and retry automatically. - console.error("Failed to check auth status:", error); - } + return null; } return null; diff --git a/packages/core/src/contexts/root-route-context.tsx b/packages/core/src/contexts/root-route-context.tsx index 3e943309..d0aa8f6f 100644 --- a/packages/core/src/contexts/root-route-context.tsx +++ b/packages/core/src/contexts/root-route-context.tsx @@ -7,8 +7,7 @@ export type RootRouteLoaderFn = (requestUrl: URL) => Promise; export type RootRouteContextType = { /** * Runs in the react-router loader phase (before rendering). - * Used for side effects such as OAuth callback handling and auth - * status checks. + * Used for side effects such as OAuth callback handling. * * ``` * loader runs ──▶ rendering starts ──▶ wrapComponent applied diff --git a/packages/core/src/routing/router.test.tsx b/packages/core/src/routing/router.test.tsx index 0fb7c3d7..ce368cf4 100644 --- a/packages/core/src/routing/router.test.tsx +++ b/packages/core/src/routing/router.test.tsx @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { RouterContainer } from "./router"; import { AppShellConfigContext, AppShellDataContext } from "@/contexts/appshell-context"; +import * as ReactRouter from "react-router"; import { Link, Outlet, useNavigate } from "react-router"; import { defineModule, @@ -19,9 +20,12 @@ afterEach(() => { cleanup(); }); +const homeRootComponent = () =>
Home
; + const renderWithConfig = ({ modules = [], basePath, + locale = "en", rootComponent, initialEntries, contextData = {}, @@ -31,6 +35,7 @@ const renderWithConfig = ({ }: { modules?: Array; basePath?: string; + locale?: string; rootComponent?: () => ReactNode; initialEntries: Array; contextData?: ContextData; @@ -43,7 +48,7 @@ const renderWithConfig = ({ settingsResources: [] as Array, basePath, errorBoundary: undefined, - locale: "en", + locale, }; setContextData(contextData); @@ -58,7 +63,7 @@ const renderWithConfig = ({ ); - render( + return render( authClient ? ( {tree} @@ -430,7 +435,7 @@ const createMockAuthClient = ( }; describe("RouterContainer with AuthProvider", () => { - it("calls checkAuthStatus via loader on initial load", async () => { + it("does not call checkAuthStatus via loader on initial load", async () => { const mockCheckAuthStatus = vi.fn().mockResolvedValue({ isAuthenticated: true, error: null, @@ -455,7 +460,7 @@ describe("RouterContainer with AuthProvider", () => { }); await screen.findByText("Dashboard"); - expect(mockCheckAuthStatus).toHaveBeenCalled(); + expect(mockCheckAuthStatus).toHaveBeenCalledTimes(1); }); it("calls handleCallback when OAuth code is present in URL", async () => { @@ -484,6 +489,209 @@ describe("RouterContainer with AuthProvider", () => { expect(mockHandleCallback).toHaveBeenCalled(); }); + it("does not recreate the router when auth state changes", async () => { + let snapshot = { + isAuthenticated: false, + error: null as string | null, + isReady: false, + }; + const listeners: Array<(event: { type: string }) => void> = []; + const createMemoryRouterSpy = vi.spyOn(ReactRouter, "createMemoryRouter"); + + const authClient = createMockAuthClient(snapshot, { + getState: vi.fn(() => snapshot), + addEventListener: vi.fn((listener) => { + listeners.push(listener); + return () => { + const idx = listeners.indexOf(listener); + if (idx >= 0) listeners.splice(idx, 1); + }; + }), + }); + + try { + renderWithConfig({ + modules: [], + rootComponent: () =>
Home
, + initialEntries: ["/"], + authClient, + guardComponent: () =>
Loading...
, + }); + + expect(await screen.findByText("Loading...")).toBeDefined(); + expect(createMemoryRouterSpy).toHaveBeenCalledTimes(1); + + act(() => { + snapshot = { + isAuthenticated: true, + error: null, + isReady: true, + }; + for (const listener of listeners) { + listener({ type: "auth_state_changed" }); + } + }); + + expect(await screen.findByText("Home")).toBeDefined(); + expect(createMemoryRouterSpy).toHaveBeenCalledTimes(1); + } finally { + createMemoryRouterSpy.mockRestore(); + } + }); + + it("does not recreate the router when shell children change", async () => { + const createMemoryRouterSpy = vi.spyOn(ReactRouter, "createMemoryRouter"); + const initialEntries = ["/"]; + const configurations = { + modules: [], + settingsResources: [] as Array, + basePath: undefined, + errorBoundary: undefined, + locale: "en", + }; + + try { + const view = render( + + + +
+ Shell A + +
+
+
+
, + ); + + expect(await screen.findByText("Shell A")).toBeDefined(); + expect(await screen.findByText("Home")).toBeDefined(); + expect(createMemoryRouterSpy).toHaveBeenCalledTimes(1); + + view.rerender( + + + +
+ Shell B + +
+
+
+
, + ); + + expect(await screen.findByText("Shell B")).toBeDefined(); + expect(screen.queryByText("Shell A")).toBeNull(); + expect(await screen.findByText("Home")).toBeDefined(); + expect(createMemoryRouterSpy).toHaveBeenCalledTimes(1); + } finally { + createMemoryRouterSpy.mockRestore(); + } + }); + + it("does not recreate the router when guard wrapper changes", async () => { + const createMemoryRouterSpy = vi.spyOn(ReactRouter, "createMemoryRouter"); + const initialEntries = ["/"]; + const authClient = createMockAuthClient({ + isAuthenticated: false, + error: null, + isReady: true, + }); + const configurations = { + modules: [], + settingsResources: [] as Array, + basePath: undefined, + errorBoundary: undefined, + locale: "en", + }; + + try { + const tree = (guardComponent: () => React.ReactNode) => ( + + + + + + + + + + ); + + const view = render(tree(() =>
Guard A
)); + + expect(await screen.findByText("Guard A")).toBeDefined(); + expect(createMemoryRouterSpy).toHaveBeenCalledTimes(1); + + view.rerender(tree(() =>
Guard B
)); + + expect(await screen.findByText("Guard B")).toBeDefined(); + expect(screen.queryByText("Guard A")).toBeNull(); + expect(createMemoryRouterSpy).toHaveBeenCalledTimes(1); + } finally { + createMemoryRouterSpy.mockRestore(); + } + }); + + it("recreates the router when route-defining config changes", async () => { + const createMemoryRouterSpy = vi.spyOn(ReactRouter, "createMemoryRouter"); + const module = defineModule({ + path: "dashboard", + component: () =>
Dashboard
, + meta: { title: "Dashboard" }, + resources: [], + }); + + try { + const view = renderWithConfig({ + modules: [module], + locale: "en", + initialEntries: ["/dashboard"], + }); + + expect(await screen.findByText("Dashboard")).toBeDefined(); + expect(createMemoryRouterSpy).toHaveBeenCalledTimes(1); + + view.rerender( + + + + + + + , + ); + + expect(await screen.findByText("Dashboard")).toBeDefined(); + expect(createMemoryRouterSpy).toHaveBeenCalledTimes(2); + } finally { + createMemoryRouterSpy.mockRestore(); + } + }); + it("calls login automatically when autoLogin is enabled and not authenticated", async () => { const mockLogin = vi.fn().mockResolvedValue(undefined); const authClient = createMockAuthClient( @@ -595,8 +803,8 @@ describe("RouterContainer with AuthProvider", () => { it("transitions from guard to children when auth state changes", async () => { // Mutable snapshot; initially not ready, not authenticated. - // The loader calls checkAuthStatus, but the mock does NOT update the - // snapshot during the loader — so the guard is shown on first render. + // Mount-time initialization runs outside the router loader, so the guard + // remains visible until the auth client publishes a ready state. let snapshot = { isAuthenticated: false, error: null as string | null, diff --git a/packages/core/src/routing/router.tsx b/packages/core/src/routing/router.tsx index f8bd875c..9e3c6166 100644 --- a/packages/core/src/routing/router.tsx +++ b/packages/core/src/routing/router.tsx @@ -1,11 +1,23 @@ -import { type PropsWithChildren, type ReactNode } from "react"; +import { createContext, type PropsWithChildren, type ReactNode, useContext, useMemo } from "react"; import { Outlet, createMemoryRouter, createBrowserRouter, RouterProvider } from "react-router"; import type { LoaderFunctionArgs, RouteObject } from "react-router"; import { createContentRoutes, RootComponentOption, wrapErrorBoundary } from "./routes"; import { useAppShellConfig, type RootConfiguration } from "@/contexts/appshell-context"; import { createNavItemsLoader } from "@/routing/navigation"; import type { Guard } from "@/resource"; -import { useRootRouteContext, type RootRouteContextType } from "@/contexts/root-route-context"; +import { useRootRouteContext, type RootRouteLoaderFn } from "@/contexts/root-route-context"; + +// The router should only be recreated when route-defining inputs change. +// The shell element and auth guard wrapper are render-time concerns, so keep +// them in a separate React context instead of baking them into the route tree. +const RootRouteContext = createContext(null); + +const RootRouteElement = () => { + const shell = useContext(RootRouteContext); + const rootRouteCtx = useRootRouteContext(); + + return rootRouteCtx?.wrapComponent ? rootRouteCtx.wrapComponent(shell) : shell; +}; // ============================================================================ // Root Route @@ -15,21 +27,18 @@ import { useRootRouteContext, type RootRouteContextType } from "@/contexts/root- * Create the root route that combines root loader, navigation loading, * error boundary, and optional element wrapping into a single RouteObject. * - * When AuthProvider wraps AppShell, rootRouteCtx provides: - * - loader: runs before rendering (e.g. OAuth callback, auth status check) - * - wrapComponent: wraps the root component (e.g. guard UI while loading) - * When AuthProvider is not used, rootRouteCtx is null and these are skipped. + * When AuthProvider wraps AppShell, rootLoader runs before rendering + * (e.g. OAuth callback handling). The element wrapper stays outside the + * route definition so UI-only rerenders do not force router recreation. */ const createRootRoute = (params: { configurations: RootConfiguration; - rootRouteCtx: RootRouteContextType | null; + rootLoader: RootRouteLoaderFn | null; contentRoutes: Array; - children: ReactNode; }): RouteObject => { - const { configurations, rootRouteCtx, contentRoutes, children } = params; + const { configurations, rootLoader, contentRoutes } = params; // --- Loader: combine auth callback handling with navigation loading --- - const rootLoader = rootRouteCtx?.loader ?? null; const { loaderID, loader: navLoader } = createNavItemsLoader({ modules: configurations.modules, locale: configurations.locale, @@ -42,10 +51,6 @@ const createRootRoute = (params: { return navLoader(); }; - // --- Element: apply wrapper when provided (e.g. auth guard) --- - const wrapComponent = rootRouteCtx?.wrapComponent; - const element = wrapComponent ? wrapComponent(children) : children; - // --- Children: wrap with error boundary when configured --- const globalErrorBoundary = configurations.errorBoundary; const routeChildren = globalErrorBoundary @@ -61,7 +66,7 @@ const createRootRoute = (params: { return { id: loaderID, loader, - element, + element: , children: routeChildren, // Hydration fallback is unused in CSR-only usage of AppShell. // Return null to silence hydration warnings. @@ -91,25 +96,52 @@ export const RouterContainer = (props: PropsWithChildren) const { rootComponent, children } = props; const { configurations } = useAppShellConfig(); const rootRouteCtx = useRootRouteContext(); - const contentRoutes = createContentRoutes({ - modules: configurations.modules, - settingsResources: configurations.settingsResources, - rootComponent, - rootGuards: props.rootGuards, - }); - const routes = [ - createRootRoute({ configurations, rootRouteCtx, contentRoutes, children }), - ] satisfies Array; + const rootLoader = rootRouteCtx?.loader ?? null; + const contentRoutes = useMemo( + () => + createContentRoutes({ + modules: configurations.modules, + settingsResources: configurations.settingsResources, + rootComponent, + rootGuards: props.rootGuards, + }), + [configurations.modules, configurations.settingsResources, rootComponent, props.rootGuards], + ); + const routes = useMemo( + () => + [ + createRootRoute({ + configurations, + rootLoader, + contentRoutes, + }), + ] satisfies Array, + [configurations, rootLoader, contentRoutes], + ); const basename = configurations.basePath ? "/" + configurations.basePath : undefined; - const router = props.memory - ? createMemoryRouter(routes, { - basename, - ...(props.initialEntries ? { initialEntries: props.initialEntries } : {}), - }) - : createBrowserRouter(routes, { - basename, - }); + const initialEntries = props.memory ? props.initialEntries : undefined; + + // Keep the router instance stable across auth-driven rerenders. Recreating the + // router would re-run the root loader for the same location, which can cause + // OAuth callback URLs to be processed more than once. Still rebuild when + // route-defining inputs such as routes, basename, or initial entries change. + const router = useMemo( + () => + props.memory + ? createMemoryRouter(routes, { + basename, + ...(initialEntries ? { initialEntries } : {}), + }) + : createBrowserRouter(routes, { + basename, + }), + [basename, initialEntries, props.memory, routes], + ); - return ; + return ( + + + + ); };