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 (
+
+
+
+ );
};