Skip to content

Commit 9e6d3d2

Browse files
committed
Fix guardComponent resolution failure
1 parent 59552ce commit 9e6d3d2

4 files changed

Lines changed: 285 additions & 104 deletions

File tree

packages/core/src/contexts/auth-context.test.tsx

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ describe("AuthProvider", () => {
7878
expect(screen.getByText("Test Content")).toBeDefined();
7979
});
8080

81-
it("should show guard component when not ready", () => {
81+
it("should always render children even with guardComponent when not ready", () => {
8282
const state = {
8383
isAuthenticated: false,
8484
error: null,
@@ -92,11 +92,11 @@ describe("AuthProvider", () => {
9292
</AuthProvider>,
9393
);
9494

95-
expect(screen.getByText("Loading...")).toBeDefined();
96-
expect(screen.queryByText("Protected Content")).toBeNull();
95+
// AuthProvider always renders children; guard rendering is handled by the router
96+
expect(screen.getByText("Protected Content")).toBeDefined();
9797
});
9898

99-
it("should show guard component when not authenticated", async () => {
99+
it("should always render children even with guardComponent when not authenticated", async () => {
100100
const state = {
101101
isAuthenticated: false,
102102
error: null,
@@ -110,10 +110,8 @@ describe("AuthProvider", () => {
110110
</AuthProvider>,
111111
);
112112

113-
await waitFor(() => {
114-
expect(screen.getByText("Please log in")).toBeDefined();
115-
});
116-
expect(screen.queryByText("Protected Content")).toBeNull();
113+
// AuthProvider always renders children; guard rendering is handled by the router
114+
expect(screen.getByText("Protected Content")).toBeDefined();
117115
});
118116

119117
it("should show children when authenticated", async () => {
@@ -165,7 +163,7 @@ describe("AuthProvider", () => {
165163
});
166164

167165
expect(result.current).not.toBeNull();
168-
const response = await result.current!(new URL("http://localhost/"));
166+
const response = await result.current!.loader(new URL("http://localhost/"));
169167
expect(mockCheckAuthStatus).toHaveBeenCalled();
170168
expect(response).toBeNull();
171169
});
@@ -187,7 +185,9 @@ describe("AuthProvider", () => {
187185
});
188186

189187
expect(result.current).not.toBeNull();
190-
const response = await result.current!(new URL("http://localhost/?code=auth-code-123"));
188+
const response = await result.current!.loader(
189+
new URL("http://localhost/?code=auth-code-123"),
190+
);
191191
expect(mockHandleCallback).toHaveBeenCalled();
192192
expect(response).toBeNull();
193193
});
@@ -492,7 +492,7 @@ describe("AuthProvider", () => {
492492
});
493493

494494
expect(result.current).not.toBeNull();
495-
await result.current!(new URL("http://localhost/"));
495+
await result.current!.loader(new URL("http://localhost/"));
496496
expect(mockCheckAuthStatus).toHaveBeenCalled();
497497
expect(mockLogin).toHaveBeenCalled();
498498
});

packages/core/src/contexts/auth-context.tsx

Lines changed: 57 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -182,67 +182,78 @@ const AuthContext = createContext<AuthContextType | null>(null);
182182
type AuthClientContextType = {
183183
client: EnhancedAuthClient;
184184
autoLogin?: boolean;
185+
guardComponent?: () => React.ReactNode;
185186
};
186187

187188
const AuthClientContext = createContext<AuthClientContextType | null>(null);
188189

189190
/** @internal */
190191
export type AuthLoader = (requestUrl: URL) => Promise<Response | null>;
191192

193+
/** @internal */
194+
export type AuthLoaderContext = {
195+
loader: AuthLoader;
196+
guardComponent?: () => React.ReactNode;
197+
};
198+
192199
/**
193200
* Internal hook that returns a loader function for handling OAuth callbacks
194-
* and auth status checks, or null if AuthProvider is not present.
201+
* and auth status checks, along with the guard component configuration,
202+
* or null if AuthProvider is not present.
195203
*
196204
* The returned loader is intended to be called from a react-router loader,
197205
* which runs outside React's lifecycle and is therefore unaffected by strict
198206
* mode double-invocation.
199207
*
200-
* @returns A loader function, or null if not inside an AuthProvider.
208+
* @returns An object with loader and guardComponent, or null if not inside an AuthProvider.
201209
* @internal
202210
*/
203-
export const useAuthLoader = (): AuthLoader | null => {
211+
export const useAuthLoader = (): AuthLoaderContext | null => {
204212
const authClientCtx = useContext(AuthClientContext);
205213
if (!authClientCtx) return null;
206214

207-
const { client, autoLogin } = authClientCtx;
208-
return async (requestUrl: URL): Promise<Response | null> => {
209-
// The "code" query parameter indicates a redirect back from the OAuth provider.
210-
// handleCallback() internally cleans up the OAuth-related query parameters
211-
// from the URL, so no additional URL cleanup is needed here.
212-
if (requestUrl.searchParams.has("code")) {
213-
try {
214-
await client.handleCallback();
215-
} catch (error) {
216-
console.error("Failed to handle callback:", error);
215+
const { client, autoLogin, guardComponent } = authClientCtx;
216+
return {
217+
guardComponent,
218+
loader: async (requestUrl: URL): Promise<Response | null> => {
219+
// The "code" query parameter indicates a redirect back from the OAuth provider.
220+
// handleCallback() internally cleans up the OAuth-related query parameters
221+
// from the URL, so no additional URL cleanup is needed here.
222+
if (requestUrl.searchParams.has("code")) {
223+
try {
224+
await client.handleCallback();
225+
} catch (error) {
226+
console.error("Failed to handle callback:", error);
227+
}
217228
}
218-
}
219-
220-
// Only check auth status on first load (when isReady is false).
221-
// Subsequent navigations skip this because the client already holds
222-
// the cached auth state via useSyncExternalStore.
223-
if (!client.getState().isReady) {
224-
try {
225-
await client.checkAuthStatus();
226-
} catch (error) {
227-
// Intentionally swallow errors to avoid rendering the error boundary
228-
// on transient failures (e.g. network timeouts). The next navigation
229-
// will re-run this loader and retry automatically.
230-
console.error("Failed to check auth status:", error);
229+
230+
// Only check auth status on first load (when isReady is false).
231+
// Subsequent navigations skip this because the client already holds
232+
// the cached auth state via useSyncExternalStore.
233+
if (!client.getState().isReady) {
234+
try {
235+
await client.checkAuthStatus();
236+
} catch (error) {
237+
// Intentionally swallow errors to avoid rendering the error boundary
238+
// on transient failures (e.g. network timeouts). The next navigation
239+
// will re-run this loader and retry automatically.
240+
console.error("Failed to check auth status:", error);
241+
}
231242
}
232-
}
233-
234-
// autoLogin is evaluated separately from checkAuthStatus so that it
235-
// still fires after handleCallback updates the internal state via
236-
// getState() (e.g. setting isAuthenticated to true on success).
237-
if (autoLogin && !client.getState().isAuthenticated) {
238-
try {
239-
await client.login();
240-
} catch (error) {
241-
console.error("Failed to login:", error);
243+
244+
// autoLogin is evaluated separately from checkAuthStatus so that it
245+
// still fires after handleCallback updates the internal state via
246+
// getState() (e.g. setting isAuthenticated to true on success).
247+
if (autoLogin && !client.getState().isAuthenticated) {
248+
try {
249+
await client.login();
250+
} catch (error) {
251+
console.error("Failed to login:", error);
252+
}
242253
}
243-
}
244254

245-
return null;
255+
return null;
256+
},
246257
};
247258
};
248259

@@ -310,17 +321,15 @@ export const AuthProvider = (props: React.PropsWithChildren<AuthProviderProps>)
310321

311322
// Use useSyncExternalStore for state management
312323
const authState = useSyncExternalStore(subscribe, getSnapshot);
313-
const isAuthenticated = authState.isAuthenticated;
314-
315-
const contents =
316-
props.guardComponent && (!authState.isReady || !isAuthenticated) ? (
317-
<props.guardComponent />
318-
) : (
319-
props.children
320-
);
321324

322325
return (
323-
<AuthClientContext.Provider value={{ client, autoLogin: props.autoLogin }}>
326+
<AuthClientContext.Provider
327+
value={{
328+
client,
329+
autoLogin: props.autoLogin,
330+
guardComponent: props.guardComponent,
331+
}}
332+
>
324333
<AuthContext.Provider
325334
value={{
326335
authState,
@@ -330,7 +339,7 @@ export const AuthProvider = (props: React.PropsWithChildren<AuthProviderProps>)
330339
ready: () => client.ready(),
331340
}}
332341
>
333-
{contents}
342+
{props.children}
334343
</AuthContext.Provider>
335344
</AuthClientContext.Provider>
336345
);

packages/core/src/routing/router.test.tsx

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { afterEach, describe, expect, it, vi } from "vitest";
2-
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
2+
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
33
import { RouterContainer } from "./router";
44
import { AppShellConfigContext, AppShellDataContext } from "@/contexts/appshell-context";
55
import { Link, Outlet, useNavigate } from "react-router";
@@ -27,6 +27,7 @@ const renderWithConfig = ({
2727
contextData = {},
2828
authClient,
2929
autoLogin,
30+
guardComponent,
3031
}: {
3132
modules?: Array<Module>;
3233
basePath?: string;
@@ -35,6 +36,7 @@ const renderWithConfig = ({
3536
contextData?: ContextData;
3637
authClient?: EnhancedAuthClient;
3738
autoLogin?: boolean;
39+
guardComponent?: () => React.ReactNode;
3840
}) => {
3941
const configurations = {
4042
modules,
@@ -58,7 +60,7 @@ const renderWithConfig = ({
5860

5961
render(
6062
authClient ? (
61-
<AuthProvider client={authClient} autoLogin={autoLogin}>
63+
<AuthProvider client={authClient} autoLogin={autoLogin} guardComponent={guardComponent}>
6264
{tree}
6365
</AuthProvider>
6466
) : (
@@ -492,4 +494,129 @@ describe("RouterContainer with AuthProvider", () => {
492494
await screen.findByText("Home");
493495
expect(mockLogin).not.toHaveBeenCalled();
494496
});
497+
498+
it("shows guard component when not ready", async () => {
499+
const authClient = createMockAuthClient({
500+
isAuthenticated: false,
501+
error: null,
502+
isReady: false,
503+
});
504+
505+
renderWithConfig({
506+
modules: [],
507+
rootComponent: () => <div>Home</div>,
508+
initialEntries: ["/"],
509+
authClient,
510+
guardComponent: () => <div>Loading...</div>,
511+
});
512+
513+
expect(await screen.findByText("Loading...")).toBeDefined();
514+
expect(screen.queryByText("Home")).toBeNull();
515+
});
516+
517+
it("shows guard component when not authenticated", async () => {
518+
const authClient = createMockAuthClient({
519+
isAuthenticated: false,
520+
error: null,
521+
isReady: true,
522+
});
523+
524+
renderWithConfig({
525+
modules: [],
526+
rootComponent: () => <div>Home</div>,
527+
initialEntries: ["/"],
528+
authClient,
529+
guardComponent: () => <div>Please log in</div>,
530+
});
531+
532+
expect(await screen.findByText("Please log in")).toBeDefined();
533+
expect(screen.queryByText("Home")).toBeNull();
534+
});
535+
536+
it("shows children when authenticated with guardComponent", async () => {
537+
const mockCheckAuthStatus = vi.fn().mockResolvedValue({
538+
isAuthenticated: true,
539+
error: null,
540+
isReady: true,
541+
});
542+
const authClient = createMockAuthClient(
543+
{ isAuthenticated: true, error: null, isReady: true },
544+
{ checkAuthStatus: mockCheckAuthStatus },
545+
);
546+
547+
renderWithConfig({
548+
modules: [],
549+
rootComponent: () => <div>Home</div>,
550+
initialEntries: ["/"],
551+
authClient,
552+
guardComponent: () => <div>Please log in</div>,
553+
});
554+
555+
expect(await screen.findByText("Home")).toBeDefined();
556+
expect(screen.queryByText("Please log in")).toBeNull();
557+
});
558+
559+
it("transitions from guard to children when auth state changes", async () => {
560+
// Mutable snapshot; initially not ready, not authenticated.
561+
// The loader calls checkAuthStatus, but the mock does NOT update the
562+
// snapshot during the loader — so the guard is shown on first render.
563+
let snapshot = {
564+
isAuthenticated: false,
565+
error: null as string | null,
566+
isReady: false,
567+
};
568+
569+
// Collect listeners so we can trigger state change notifications
570+
const listeners: Array<(event: { type: string }) => void> = [];
571+
572+
// checkAuthStatus resolves without updating snapshot, mimicking a
573+
// still-pending auth check from the guard's perspective.
574+
const mockCheckAuthStatus = vi.fn().mockResolvedValue({
575+
isAuthenticated: false,
576+
error: null,
577+
isReady: true,
578+
});
579+
580+
const authClient = createMockAuthClient(snapshot, {
581+
checkAuthStatus: mockCheckAuthStatus,
582+
// Return the same reference until we reassign snapshot (required by useSyncExternalStore)
583+
getState: vi.fn(() => snapshot),
584+
addEventListener: vi.fn((listener) => {
585+
listeners.push(listener);
586+
return () => {
587+
const idx = listeners.indexOf(listener);
588+
if (idx >= 0) listeners.splice(idx, 1);
589+
};
590+
}),
591+
});
592+
593+
renderWithConfig({
594+
modules: [],
595+
rootComponent: () => <div>Home</div>,
596+
initialEntries: ["/"],
597+
authClient,
598+
guardComponent: () => <div>Loading...</div>,
599+
});
600+
601+
// Initially the guard should be shown
602+
expect(await screen.findByText("Loading...")).toBeDefined();
603+
expect(screen.queryByText("Home")).toBeNull();
604+
605+
// Simulate auth state becoming ready and authenticated (e.g. token refresh
606+
// or login flow completing outside the loader).
607+
act(() => {
608+
snapshot = {
609+
isAuthenticated: true,
610+
error: null,
611+
isReady: true,
612+
};
613+
for (const listener of listeners) {
614+
listener({ type: "auth_state_changed" });
615+
}
616+
});
617+
618+
// After auth state transitions, children should replace the guard
619+
expect(await screen.findByText("Home")).toBeDefined();
620+
expect(screen.queryByText("Loading...")).toBeNull();
621+
});
495622
});

0 commit comments

Comments
 (0)