Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nice-walls-attend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"saleor-dashboard": patch
---

Fix race condition in React Strict Mode when fetching external authenticators
60 changes: 51 additions & 9 deletions src/auth/components/LoginPage/LoginPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { AvailableExternalAuthenticationsQuery } from "@dashboard/graphql";
import Wrapper from "@test/wrapper";
import { render, screen, waitFor } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import * as React from "react";
import { MemoryRouter } from "react-router-dom";

import LoginPage from "./LoginPage";

Expand All @@ -22,16 +23,59 @@ const defaultProps = {
onSubmit: jest.fn(),
};

jest.mock("react-router-dom", () => ({
MemoryRouter: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Link: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
const renderLoginPage = (props = {}) =>
render(
<Wrapper>
<MemoryRouter>
<LoginPage lastLoginMethod={null} {...defaultProps} {...props} />
</MemoryRouter>
</Wrapper>,
);

describe("LoginPage", () => {
describe("External Authentication", () => {
it("renders external authentication buttons when provided", () => {
// Arrange & Act
renderLoginPage();

// Assert
expect(screen.getByTestId("external-authentication")).toBeInTheDocument();
});

it("does not render external authentication buttons when empty array", () => {
// Arrange & Act
renderLoginPage({ externalAuthentications: [] });

// Assert
expect(screen.queryByTestId("external-authentication")).not.toBeInTheDocument();
});

it("does not render external authentication buttons when undefined", () => {
// Arrange & Act
renderLoginPage({ externalAuthentications: undefined });

// Assert
expect(screen.queryByTestId("external-authentication")).not.toBeInTheDocument();
});

it("renders 'Continue with Saleor Cloud' for cloud auth plugin", () => {
// Arrange
const cloudAuth = {
id: "cloud_auth.CloudAuthorizationPlugin",
name: "Cloud Auth",
__typename: "ExternalAuthentication",
} as AuthType[0];

// Act
renderLoginPage({ externalAuthentications: [cloudAuth] });

// Assert
expect(screen.getByText("Continue with Saleor Cloud")).toBeInTheDocument();
});

it("sets optimisticLoaderAuthId when clicking external auth button", async () => {
// Arrange & Act
render(<LoginPage lastLoginMethod={null} {...defaultProps} />);
renderLoginPage();

const authButton = screen.getByRole("button", { name: "Cloud" });

Expand All @@ -52,9 +96,7 @@ describe("LoginPage", () => {
] as AuthType;

// Act
render(
<LoginPage lastLoginMethod={null} {...defaultProps} externalAuthentications={twoAuths} />,
);
renderLoginPage({ externalAuthentications: twoAuths });

const oidcButton = screen.getByRole("button", { name: "OIDC" });
const cloudButton = screen.getByRole("button", { name: "Cloud" });
Expand Down
81 changes: 50 additions & 31 deletions src/auth/views/Login.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useAvailableExternalAuthenticationsLazyQuery } from "@dashboard/graphql";
import { useAvailableExternalAuthenticationsQuery } from "@dashboard/graphql";
import useNavigator from "@dashboard/hooks/useNavigator";
import { getAppMountUriForRedirect } from "@dashboard/utils/urls";
import { useEffect } from "react";
import { useCallback, useEffect, useRef } from "react";
import urlJoin from "url-join";
import useRouter from "use-react-router";

Expand All @@ -21,18 +21,26 @@ const LoginView = ({ params }: LoginViewProps) => {
const { location } = useRouter();
const { login, requestLoginByExternalPlugin, loginByExternalPlugin, authenticating, errors } =
useUser();
const [
queryExternalAuthentications,
{ data: externalAuthentications, loading: externalAuthenticationsLoading },
] = useAvailableExternalAuthenticationsLazyQuery();
const {
fallbackUri,
requestedExternalPluginId,
isCallbackPath,
setFallbackUri,
setRequestedExternalPluginId,
} = useAuthParameters();

// Determine if we should skip the query (when we're on callback path with auth params)
const { code, state } = params;
const isExternalAuthCallback = !!(code && state && isCallbackPath);

const { data: externalAuthentications, loading: externalAuthenticationsLoading } =
useAvailableExternalAuthenticationsQuery({
skip: isExternalAuthCallback,
});

const { lastLoginMethod, setLastLoginMethod } = useLastLoginMethod();
// Track if external auth callback has been processed to avoid double-processing in Strict Mode
const externalAuthProcessedRef = useRef(false);
Comment on lines +42 to +43
Copy link
Member

Choose a reason for hiding this comment

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

issue: I don't think we should create hacks to work around strict mode, if we have issue there it probably means we have something wrong with our implementation. It's worth checking ESLint warnings.

There's also a possibility we could introduce a bug here, as copilot wrote: https://github.com/saleor/saleor-dashboard/pull/6175/files#r2589250212

Copy link
Member Author

Choose a reason for hiding this comment

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

As far as I see it it's not necessarily a hack for React Strict Mode per se. What should we do when component remounts and Apollo has the lazy query in flight?

Copy link
Member Author

Choose a reason for hiding this comment

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

Assuming we can't have that race condition realistically outside of strict mode the ref is indeed unnecessary but what do we do then for the strict mode so it works as expected?

Copy link
Member Author

Choose a reason for hiding this comment

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

The comment was I think already addressed.

Copy link
Member

Choose a reason for hiding this comment

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

What should we do when component remounts and Apollo has the lazy query in flight?

I think apollo should cancel query if it is in flight and component re-renders, if it doesn't then probably there's some bug in Apollo, it might be because we use an old version though :/


const handleSubmit = async (data: LoginFormData) => {
if (!login) {
Expand Down Expand Up @@ -61,38 +69,49 @@ const LoginView = ({ params }: LoginViewProps) => {
window.location.href = data.authorizationUrl;
}
};
const handleExternalAuthentication = async (code: string, state: string) => {
await loginByExternalPlugin!(requestedExternalPluginId, {
code,
state,
});
setRequestedExternalPluginId(null);
navigate(fallbackUri);
setFallbackUri(null);
};
const handleExternalAuthentication = useCallback(
async (code: string, state: string) => {
await loginByExternalPlugin!(requestedExternalPluginId, {
code,
state,
});
setRequestedExternalPluginId(null);
navigate(fallbackUri);
setFallbackUri(null);
},
[
loginByExternalPlugin,
requestedExternalPluginId,
navigate,
fallbackUri,
setRequestedExternalPluginId,
setFallbackUri,
],
);

// Reset the ref when we're no longer on a callback path, allowing new auth attempts
useEffect(() => {
const { code, state } = params;
const externalAuthParamsExist = code && state && isCallbackPath;

if (!externalAuthParamsExist) {
queryExternalAuthentications();
if (!isExternalAuthCallback) {
externalAuthProcessedRef.current = false;
}
}, [isCallbackPath, params, queryExternalAuthentications]);
}, [isExternalAuthCallback]);

useEffect(() => {
const { code, state } = params;
const externalAuthParamsExist = code && state && isCallbackPath;
const externalAuthNotPerformed = !authenticating && !errors.length;

if (externalAuthParamsExist && externalAuthNotPerformed) {
handleExternalAuthentication(code, state);
// Guard against double-processing in React Strict Mode
if (isExternalAuthCallback && externalAuthNotPerformed && !externalAuthProcessedRef.current) {
externalAuthProcessedRef.current = true;
handleExternalAuthentication(code!, state!);
}
Comment on lines +102 to 106
Copy link
Member

Choose a reason for hiding this comment

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

issue: again we probably have issue somewhere else, we shouldn't write specific fixes to Strict mode


return () => {
setRequestedExternalPluginId(null);
setFallbackUri(null);
};
}, []);
}, [
isExternalAuthCallback,
authenticating,
errors.length,
handleExternalAuthentication,
code,
state,
]);

return (
<LoginPage
Expand Down
Loading