diff --git a/.changeset/custom-oauth-provider-display-data.md b/.changeset/custom-oauth-provider-display-data.md new file mode 100644 index 00000000000..0c9c4061756 --- /dev/null +++ b/.changeset/custom-oauth-provider-display-data.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +Fix custom OAuth provider names and icons incorrectly carrying over from a previously loaded environment. diff --git a/eslint.config.mjs b/eslint.config.mjs index d1dc74af297..f41ac37bd92 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -586,12 +586,6 @@ export default tseslint.config([ rules: { ...pluginReactHooks.configs.recommended.rules, 'react-hooks/rules-of-hooks': 'warn', - // Turned off while upgrading eslint-plugin-react-hooks to v7, which added these to the recommended preset. - // They are not meant to stay off; they will be enabled in follow-up changes. - 'react-hooks/immutability': 'off', - 'react-hooks/preserve-manual-memoization': 'off', - 'react-hooks/refs': 'off', - 'react-hooks/set-state-in-effect': 'off', }, }, { diff --git a/packages/chrome-extension/eslint-suppressions.json b/packages/chrome-extension/eslint-suppressions.json new file mode 100644 index 00000000000..2f5a3f287a0 --- /dev/null +++ b/packages/chrome-extension/eslint-suppressions.json @@ -0,0 +1,7 @@ +{ + "src/react/ClerkProvider.tsx": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + } +} \ No newline at end of file diff --git a/packages/expo/eslint-suppressions.json b/packages/expo/eslint-suppressions.json new file mode 100644 index 00000000000..f718808fbdd --- /dev/null +++ b/packages/expo/eslint-suppressions.json @@ -0,0 +1,12 @@ +{ + "src/native-components/useAuthViewState.ts": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/provider/ClerkProvider.tsx": { + "react-hooks/refs": { + "count": 2 + } + } +} \ No newline at end of file diff --git a/packages/mosaic/eslint-suppressions.json b/packages/mosaic/eslint-suppressions.json index c165f60fef6..d8a07c970f8 100644 --- a/packages/mosaic/eslint-suppressions.json +++ b/packages/mosaic/eslint-suppressions.json @@ -1,4 +1,9 @@ { + "src/blocks/destructive/destructive.tsx": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, "src/components/dialog/dialog.tsx": { "@typescript-eslint/consistent-type-assertions": { "count": 1 @@ -9,6 +14,11 @@ "count": 1 } }, + "src/components/form/use-form.ts": { + "react-hooks/refs": { + "count": 1 + } + }, "src/components/icon/icon.tsx": { "@typescript-eslint/consistent-type-assertions": { "count": 1 @@ -22,6 +32,19 @@ "src/components/profile/profile.tsx": { "@typescript-eslint/consistent-type-assertions": { "count": 1 + }, + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/features/api-keys/api-keys-table.model.ts": { + "react-hooks/refs": { + "count": 1 + } + }, + "src/features/organization-profile/organization-profile-members-panel/members-table-tab.model.ts": { + "react-hooks/refs": { + "count": 1 } }, "src/features/reverification/use-reverification-with-state.ts": { @@ -29,6 +52,11 @@ "count": 1 } }, + "src/features/switcher/switcher.view.tsx": { + "react-hooks/refs": { + "count": 8 + } + }, "src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.ts": { "boundaries/dependencies": { "count": 1 @@ -39,6 +67,21 @@ "count": 1 } }, + "src/hooks/use-debounced-async.ts": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/hooks/use-in-view.ts": { + "react-hooks/refs": { + "count": 1 + } + }, + "src/hooks/use-spin-delay.ts": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, "src/localization/context.tsx": { "@typescript-eslint/consistent-type-assertions": { "count": 1 @@ -69,12 +112,46 @@ "count": 2 } }, + "src/machine/use-machine.ts": { + "react-hooks/refs": { + "count": 11 + } + }, + "src/primitives/autocomplete/autocomplete-root.tsx": { + "react-hooks/refs": { + "count": 1 + } + }, + "src/primitives/combobox/combobox-root.tsx": { + "react-hooks/refs": { + "count": 3 + }, + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/primitives/dialog/dialog-popup.tsx": { + "react-hooks/refs": { + "count": 2 + } + }, "src/primitives/dialog/dialog-trigger.tsx": { "@typescript-eslint/consistent-type-assertions": { "count": 1 } }, + "src/primitives/drawer/drawer-root.tsx": { + "react-hooks/refs": { + "count": 3 + } + }, "src/primitives/drawer/use-drawer-drag.ts": { + "react-hooks/refs": { + "count": 1 + }, + "react-hooks/set-state-in-effect": { + "count": 1 + }, "sonarjs/cognitive-complexity": { "count": 1 } @@ -84,6 +161,11 @@ "count": 1 } }, + "src/primitives/file-upload/use-object-url.ts": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, "src/primitives/hooks/use-data-table.ts": { "@typescript-eslint/consistent-type-assertions": { "count": 1 @@ -92,6 +174,14 @@ "src/primitives/hooks/use-focus-target.ts": { "@typescript-eslint/consistent-type-assertions": { "count": 2 + }, + "react-hooks/refs": { + "count": 1 + } + }, + "src/primitives/menu/menu-root.tsx": { + "react-hooks/refs": { + "count": 1 } }, "src/primitives/menu/menu-trigger.tsx": { @@ -104,6 +194,47 @@ "count": 1 } }, + "src/primitives/popover/popover-root.tsx": { + "react-hooks/refs": { + "count": 1 + } + }, + "src/primitives/portal/portal.tsx": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/primitives/select/select-root.tsx": { + "react-hooks/refs": { + "count": 4 + }, + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/primitives/tabs/tabs-root.tsx": { + "react-hooks/refs": { + "count": 1 + } + }, + "src/primitives/toast/toast-provider.tsx": { + "react-hooks/refs": { + "count": 5 + }, + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/primitives/toast/toast-root.tsx": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/primitives/tooltip/tooltip-root.tsx": { + "react-hooks/refs": { + "count": 1 + } + }, "src/primitives/utils/interaction-modality.ts": { "@typescript-eslint/consistent-type-assertions": { "count": 1 @@ -121,5 +252,10 @@ "sonarjs/cognitive-complexity": { "count": 1 } + }, + "src/utils/feedback.tsx": { + "react-hooks/refs": { + "count": 2 + } } } \ No newline at end of file diff --git a/packages/mosaic/src/primitives/combobox/combobox-root.tsx b/packages/mosaic/src/primitives/combobox/combobox-root.tsx index e41883e3653..39d6fd6a3ac 100644 --- a/packages/mosaic/src/primitives/combobox/combobox-root.tsx +++ b/packages/mosaic/src/primitives/combobox/combobox-root.tsx @@ -73,6 +73,8 @@ function ComboboxInner(props: ComboboxProps) { ), ); + const highlightFirstRef = useRef(false); + const setOpen = useCallback( (nextOpen: boolean) => { if (!nextOpen) { @@ -107,7 +109,6 @@ function ComboboxInner(props: ComboboxProps) { const popupRef = useRef(null); const triggerRef = useRef(null); const valuesByIndexRef = useRef>(new Map()); - const highlightFirstRef = useRef(false); const registerOption = useCallback((index: number) => { if (index === 0 && highlightFirstRef.current) { highlightFirstRef.current = false; diff --git a/packages/mosaic/src/primitives/hooks/use-focus-target.ts b/packages/mosaic/src/primitives/hooks/use-focus-target.ts index f294d220f18..4468c955018 100644 --- a/packages/mosaic/src/primitives/hooks/use-focus-target.ts +++ b/packages/mosaic/src/primitives/hooks/use-focus-target.ts @@ -34,6 +34,7 @@ export function useInitialFocus( floatingContext: FloatingContext, ): ResolvedInitialFocus { const elementRef = React.useRef(null); + // eslint-disable-next-line react-hooks/preserve-manual-memoization -- this flags a real bug: openEvent is only updated through floatingContext.onOpenChange, so a controlled open after a controlled close passes initialFocus the previous interaction type return React.useMemo(() => { if (!open || initialFocus === undefined || initialFocus === true) { return 0; diff --git a/packages/mosaic/src/primitives/select/select-root.tsx b/packages/mosaic/src/primitives/select/select-root.tsx index 07462834642..60ce17039ea 100644 --- a/packages/mosaic/src/primitives/select/select-root.tsx +++ b/packages/mosaic/src/primitives/select/select-root.tsx @@ -132,7 +132,7 @@ function SelectInner(props: SelectProps) { } setOpen(next); }, - [setOpen], + [setOpen, setTouchOpen], ); const { diff --git a/packages/mosaic/src/primitives/table/table-root.tsx b/packages/mosaic/src/primitives/table/table-root.tsx index 6775870a6c9..11a9935de90 100644 --- a/packages/mosaic/src/primitives/table/table-root.tsx +++ b/packages/mosaic/src/primitives/table/table-root.tsx @@ -64,7 +64,7 @@ export const TableRoot = React.forwardRef(func const [columnCount, registerColumn] = useCounter(); const [rowHeaderColumns, registerRowHeaderColumn] = useRegistry(); const anchorRef = React.useRef(null); - const excluded = React.useMemo(() => new WeakSet(), []); + const [excluded] = React.useState(() => new WeakSet()); const { tree } = bodyRows; const selectable = React.useMemo( diff --git a/packages/mosaic/src/primitives/toast/toast-root.tsx b/packages/mosaic/src/primitives/toast/toast-root.tsx index 93203713021..f13bcab8843 100644 --- a/packages/mosaic/src/primitives/toast/toast-root.tsx +++ b/packages/mosaic/src/primitives/toast/toast-root.tsx @@ -150,7 +150,8 @@ export const ToastRoot = React.forwardRef(functi const index = peers.indexOf(toast); const offsetY = peers.slice(0, Math.max(index, 0)).reduce((sum, t) => sum + (t.limited ? 0 : (t.height ?? 0)), 0); - const behind = frontmost !== undefined && peers.includes(frontmost) && index > peers.indexOf(frontmost); + const frontmostIndex = frontmost === undefined ? -1 : peers.indexOf(frontmost); + const behind = frontmostIndex !== -1 && index > frontmostIndex; const contextValue = useMemo( () => ({ toast, behind, rootId, titleId, descriptionId, setTitleId, setDescriptionId }), diff --git a/packages/nextjs/eslint-suppressions.json b/packages/nextjs/eslint-suppressions.json new file mode 100644 index 00000000000..9b6d3ed7b6d --- /dev/null +++ b/packages/nextjs/eslint-suppressions.json @@ -0,0 +1,7 @@ +{ + "src/client-boundary/hooks/usePathnameWithoutCatchAll.tsx": { + "react-hooks/refs": { + "count": 8 + } + } +} \ No newline at end of file diff --git a/packages/nextjs/src/pages/ClerkProvider.tsx b/packages/nextjs/src/pages/ClerkProvider.tsx index 6d47886a470..13527e51075 100644 --- a/packages/nextjs/src/pages/ClerkProvider.tsx +++ b/packages/nextjs/src/pages/ClerkProvider.tsx @@ -18,11 +18,11 @@ import { ClerkScripts } from './ClerkScripts'; setErrorThrowerOptions({ packageName: PACKAGE_NAME }); setClerkJSLoadingErrorPackageName(PACKAGE_NAME); +ReactClerkProvider.displayName = 'ReactClerkProvider'; export function ClerkProvider({ children, ...props }: NextClerkProviderProps): JSX.Element { const { __internal_invokeMiddlewareOnAuthStateChange = true } = props; const { push, replace } = useRouter(); - ReactClerkProvider.displayName = 'ReactClerkProvider'; useSafeLayoutEffect(() => { window.__internal_onBeforeSetActive = invalidateNextRouterCache; diff --git a/packages/react-router/eslint-suppressions.json b/packages/react-router/eslint-suppressions.json new file mode 100644 index 00000000000..8345afb0481 --- /dev/null +++ b/packages/react-router/eslint-suppressions.json @@ -0,0 +1,7 @@ +{ + "src/client/usePathnameWithoutSplatRouteParams.tsx": { + "react-hooks/refs": { + "count": 1 + } + } +} \ No newline at end of file diff --git a/packages/react-router/src/client/ReactRouterClerkProvider.tsx b/packages/react-router/src/client/ReactRouterClerkProvider.tsx index 3883b95ea55..b51704551b3 100644 --- a/packages/react-router/src/client/ReactRouterClerkProvider.tsx +++ b/packages/react-router/src/client/ReactRouterClerkProvider.tsx @@ -17,6 +17,8 @@ const SDK_METADATA = { version: PACKAGE_VERSION, }; +ReactClerkProvider.displayName = 'ReactClerkProvider'; + /** * React Router hydration errors should not stop Clerk navigation from working, as the components mount only after * hydration is done (in the case of a hydration error, the components will simply mount after client-side hydration). @@ -40,7 +42,6 @@ function ClerkProviderBase({ children, ...rest }: ClerkProv }, [awaitableNavigate]); const { clerkState, ...restProps } = rest; - ReactClerkProvider.displayName = 'ReactClerkProvider'; if (typeof isSpaMode !== 'undefined' && !isSpaMode) { assertValidClerkState(clerkState); diff --git a/packages/react/eslint-suppressions.json b/packages/react/eslint-suppressions.json new file mode 100644 index 00000000000..92bb40c6a3a --- /dev/null +++ b/packages/react/eslint-suppressions.json @@ -0,0 +1,12 @@ +{ + "src/contexts/ClerkProvider.tsx": { + "react-hooks/refs": { + "count": 3 + } + }, + "src/utils/useCustomElementPortal.tsx": { + "react-hooks/refs": { + "count": 6 + } + } +} \ No newline at end of file diff --git a/packages/shared/eslint-suppressions.json b/packages/shared/eslint-suppressions.json new file mode 100644 index 00000000000..b4dc7203fbd --- /dev/null +++ b/packages/shared/eslint-suppressions.json @@ -0,0 +1,37 @@ +{ + "src/react/hooks/useDeepEqualMemo.ts": { + "react-hooks/refs": { + "count": 3 + } + }, + "src/react/hooks/useOrganizationDomains.tsx": { + "react-hooks/refs": { + "count": 1 + } + }, + "src/react/hooks/useOrganizationEnterpriseConnectionTestRuns.tsx": { + "react-hooks/set-state-in-effect": { + "count": 2 + } + }, + "src/react/hooks/usePagesOrInfinite.shared.ts": { + "react-hooks/refs": { + "count": 3 + } + }, + "src/react/hooks/usePagesOrInfinite.tsx": { + "react-hooks/refs": { + "count": 21 + } + }, + "src/react/hooks/usePreviousValue.ts": { + "react-hooks/refs": { + "count": 5 + } + }, + "src/react/stripe-react/utils.ts": { + "react-hooks/refs": { + "count": 1 + } + } +} \ No newline at end of file diff --git a/packages/shared/src/react/query/useBaseQuery.ts b/packages/shared/src/react/query/useBaseQuery.ts index 2c044590b1d..05238dffc6e 100644 --- a/packages/shared/src/react/query/useBaseQuery.ts +++ b/packages/shared/src/react/query/useBaseQuery.ts @@ -30,6 +30,7 @@ export function useBaseQuery); // Make sure results are optimistically set in fetching state before subscribing or updating options + // eslint-disable-next-line react-hooks/immutability -- kept in sync with TanStack Query's useBaseQuery, which sets this on the defaulted options defaultedOptions._optimisticResults = 'optimistic'; const observer = React.useMemo(() => { diff --git a/packages/swingset/eslint-suppressions.json b/packages/swingset/eslint-suppressions.json new file mode 100644 index 00000000000..176402928ce --- /dev/null +++ b/packages/swingset/eslint-suppressions.json @@ -0,0 +1,37 @@ +{ + "src/components/CommandPalette.tsx": { + "react-hooks/set-state-in-effect": { + "count": 2 + } + }, + "src/components/StoryPreview.tsx": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/components/ThemeToggle.tsx": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/components/app-sidebar.tsx": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/hooks/use-mobile.ts": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/stories/fixtures/api-keys-table.tsx": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/stories/user-button.stories.tsx": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + } +} diff --git a/packages/swingset/next.config.mjs b/packages/swingset/next.config.mjs index 4af6a776ac3..9e1fae9619a 100644 --- a/packages/swingset/next.config.mjs +++ b/packages/swingset/next.config.mjs @@ -31,6 +31,9 @@ const withMDX = createMDX({ /** @type {import('next').NextConfig} */ const nextConfig = { pageExtensions: ['tsx', 'ts', 'mdx'], + eslint: { + ignoreDuringBuilds: true, + }, webpack(config) { // `import src from './foo.stories.tsx?raw'` must return the file's *untransformed* text. // Next's SWC loader matches by extension and ignores the `?raw` query, so on its own it diff --git a/packages/swingset/package.json b/packages/swingset/package.json index 45693217d76..c5c8980151d 100644 --- a/packages/swingset/package.json +++ b/packages/swingset/package.json @@ -7,7 +7,8 @@ "build": "next build", "dev": "node scripts/dev.mjs", "format": "node ../../scripts/format-package.mjs", - "format:check": "node ../../scripts/format-package.mjs --check" + "format:check": "node ../../scripts/format-package.mjs --check", + "lint": "eslint src" }, "dependencies": { "@base-ui/react": "^1.5.0", diff --git a/packages/tanstack-react-start/eslint-suppressions.json b/packages/tanstack-react-start/eslint-suppressions.json new file mode 100644 index 00000000000..fbe251e9be6 --- /dev/null +++ b/packages/tanstack-react-start/eslint-suppressions.json @@ -0,0 +1,7 @@ +{ + "src/client/uiComponents.tsx": { + "react-hooks/refs": { + "count": 1 + } + } +} \ No newline at end of file diff --git a/packages/ui/bundlewatch.config.json b/packages/ui/bundlewatch.config.json index 3bfffc77315..5996d76567e 100644 --- a/packages/ui/bundlewatch.config.json +++ b/packages/ui/bundlewatch.config.json @@ -5,7 +5,7 @@ { "path": "./dist/ui.shared.browser.js", "maxSize": "44KB" }, { "path": "./dist/framework*.js", "maxSize": "44KB" }, { "path": "./dist/vendors*.js", "maxSize": "73KB" }, - { "path": "./dist/ui-common*.js", "maxSize": "139KB" }, + { "path": "./dist/ui-common*.js", "maxSize": "140KB" }, { "path": "./dist/signin*.js", "maxSize": "19KB" }, { "path": "./dist/signup*.js", "maxSize": "13KB" }, { "path": "./dist/userprofile*.js", "maxSize": "16KB" }, diff --git a/packages/ui/eslint-suppressions.json b/packages/ui/eslint-suppressions.json new file mode 100644 index 00000000000..73f2839ae2e --- /dev/null +++ b/packages/ui/eslint-suppressions.json @@ -0,0 +1,205 @@ +{ + "src/Components.tsx": { + "react-hooks/refs": { + "count": 3 + } + }, + "src/common/EmailLinkVerify.tsx": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/common/PrintableComponent.tsx": { + "react-hooks/refs": { + "count": 1 + } + }, + "src/components/ConfigureDirectorySync/steps/TestSyncStep.tsx": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/components/ConfigureSSO/elements/Wizard/useWizardMachine.ts": { + "react-hooks/refs": { + "count": 3 + } + }, + "src/components/ConfigureSSO/hooks/useOrganizationEnterpriseConnection.ts": { + "react-hooks/refs": { + "count": 4 + } + }, + "src/components/OAuthDeviceVerification/OAuthDeviceVerification.tsx": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/components/OrganizationProfile/RemoveDomainForm.tsx": { + "react-hooks/refs": { + "count": 4 + } + }, + "src/components/PaymentMethods/AddPaymentMethod.tsx": { + "react-hooks/refs": { + "count": 1 + } + }, + "src/components/PaymentMethods/PaymentMethods.tsx": { + "react-hooks/refs": { + "count": 5 + } + }, + "src/components/PricingTable/PricingTable.tsx": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/components/ProtectCheckModal/index.tsx": { + "react-hooks/refs": { + "count": 3 + } + }, + "src/components/SignIn/SignInFactorOne.tsx": { + "react-hooks/refs": { + "count": 8 + } + }, + "src/components/SignIn/SignInStart.tsx": { + "react-hooks/set-state-in-effect": { + "count": 2 + } + }, + "src/components/SignIn/useSecondFactorSelection.ts": { + "react-hooks/refs": { + "count": 1 + } + }, + "src/components/UserProfile/MfaPhoneCodeScreen.tsx": { + "react-hooks/refs": { + "count": 9 + } + }, + "src/components/UserProfile/PhoneForm.tsx": { + "react-hooks/refs": { + "count": 9 + } + }, + "src/components/UserProfile/RemoveResourceForm.tsx": { + "react-hooks/refs": { + "count": 23 + } + }, + "src/components/UserVerification/UserVerificationFactorOne.tsx": { + "react-hooks/refs": { + "count": 2 + } + }, + "src/components/devPrompts/EnableOrganizationsPrompt/index.tsx": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/components/devPrompts/KeylessPrompt/use-drag-to-corner.ts": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/elements/Avatar.tsx": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/elements/CaptchaElement.tsx": { + "react-hooks/refs": { + "count": 5 + } + }, + "src/elements/CodeControl.tsx": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/elements/Collapsible.tsx": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/elements/FormControl.tsx": { + "react-hooks/refs": { + "count": 42 + } + }, + "src/elements/IdentityPreview.tsx": { + "react-hooks/refs": { + "count": 8 + } + }, + "src/elements/Menu.tsx": { + "react-hooks/refs": { + "count": 2 + } + }, + "src/elements/PhoneInput/useFormattedPhoneNumber.ts": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/elements/Portal.tsx": { + "react-hooks/refs": { + "count": 1 + } + }, + "src/elements/Select.tsx": { + "react-hooks/refs": { + "count": 1 + } + }, + "src/elements/Tooltip.tsx": { + "react-hooks/refs": { + "count": 1 + } + }, + "src/elements/contexts/index.tsx": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/hooks/useClerkModalStateParams.tsx": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/hooks/useInView.ts": { + "react-hooks/refs": { + "count": 1 + } + }, + "src/hooks/usePasswordComplexity.ts": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/hooks/useProtectCheckRunner.ts": { + "react-hooks/refs": { + "count": 1 + }, + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/hooks/useTabState.ts": { + "react-hooks/set-state-in-effect": { + "count": 1 + } + }, + "src/router/BaseRouter.tsx": { + "react-hooks/refs": { + "count": 2 + } + }, + "src/utils/ExternalElementMounter.tsx": { + "react-hooks/refs": { + "count": 2 + } + } +} \ No newline at end of file diff --git a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx index b26438bf192..322cb581c03 100644 --- a/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx +++ b/packages/ui/src/components/ConfigureDirectorySync/ConfigureDirectorySyncContext.tsx @@ -131,16 +131,16 @@ export const ConfigureDirectorySyncProvider = ({ return rotated; }, [rotateDirectorySyncToken]); - const setDirectoryEnabled = React.useCallback( - (enabled: boolean) => updateDirectorySync({ enabled }), - [updateDirectorySync], - ); - const provider = directory?.provider ?? (connection ? directorySyncProviderForConnection(connection.provider) : undefined); const roleMapping = useRoleMapping(directory, updateDirectorySync); + const setDirectoryEnabled = React.useCallback( + (enabled: boolean) => updateDirectorySync({ enabled }), + [updateDirectorySync], + ); + const value: ConfigureDirectorySyncData = { isLoading: isLoadingConnections || (Boolean(enterpriseConnectionId) && isLoadingDirectory), connection, diff --git a/packages/ui/src/components/SessionTasks/index.tsx b/packages/ui/src/components/SessionTasks/index.tsx index 0672ff7ff6a..ed936690628 100644 --- a/packages/ui/src/components/SessionTasks/index.tsx +++ b/packages/ui/src/components/SessionTasks/index.tsx @@ -64,16 +64,16 @@ function SessionTasksRoutes(): JSX.Element { // here to be defensive and ensure proper redirection const task = clerk.session?.currentTask; if (!task || clerk.session?.status === 'active') { - if (ctx.redirectOnActiveSession?.current) { + if (ctx.redirectOnActiveSessionRef?.current) { void navigate(ctx.redirectUrlComplete); } return; } clerk.telemetry?.record(eventComponentMounted('SessionTask', { task: task.key })); - }, [clerk, currentPath, navigate, ctx.redirectUrlComplete, ctx.redirectOnActiveSession]); + }, [clerk, currentPath, navigate, ctx.redirectUrlComplete, ctx.redirectOnActiveSessionRef]); - if (!clerk.session?.currentTask && ctx.redirectOnActiveSession?.current) { + if (!clerk.session?.currentTask && ctx.redirectOnActiveSessionRef?.current) { return ( ({ flex: 1 })}> @@ -126,7 +126,7 @@ type SessionTasksProps = { export const SessionTasks = withCardStateProvider(({ redirectUrlComplete }: SessionTasksProps) => { const redirectOnActiveSessionRef = useRef(true); return ( - + ); diff --git a/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/index.tsx b/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/index.tsx index 299031a1ece..6038ea902a3 100644 --- a/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/index.tsx +++ b/packages/ui/src/components/SessionTasks/tasks/TaskSetupMfa/index.tsx @@ -50,11 +50,11 @@ const TaskSetupMFAInternal = () => { defaultStep: secondFactorsAvailableToAdd.length > 1 ? WIZARD_STEPS.start : defaultStep, }); const { redirectUrlComplete } = useTaskSetupMFAContext(); - const { navigateOnSetActive, redirectOnActiveSession } = useSessionTasksContext(); + const { navigateOnSetActive, redirectOnActiveSessionRef } = useSessionTasksContext(); const handleSuccess = async () => { - if (redirectOnActiveSession) { - redirectOnActiveSession.current = false; + if (redirectOnActiveSessionRef) { + redirectOnActiveSessionRef.current = false; } await clerk.setActive({ session: session?.id, @@ -68,10 +68,10 @@ const TaskSetupMFAInternal = () => { // This needed because we want the user to explicitly click the to go the the next page because they // need to see the backups codes. useEffect(() => { - if (redirectOnActiveSession) { - redirectOnActiveSession.current = false; + if (redirectOnActiveSessionRef) { + redirectOnActiveSessionRef.current = false; } - }, [redirectOnActiveSession]); + }, [redirectOnActiveSessionRef]); return ( diff --git a/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx b/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx index 382b86207dd..13347425b68 100644 --- a/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx +++ b/packages/ui/src/components/SignIn/SignInFactorOneEmailLinkCard.tsx @@ -35,10 +35,6 @@ export const SignInFactorOneEmailLinkCard = (props: SignInFactorOneEmailLinkCard const [switchTabStatus, setSwitchTabStatus] = React.useState<'verified_switch_tab' | 'transferable' | null>(null); const clerk = useClerk(); - React.useEffect(() => { - void startEmailLinkVerification(); - }, []); - const restartVerification = () => { cancelEmailLinkFlow(); void startEmailLinkVerification(); @@ -102,6 +98,10 @@ export const SignInFactorOneEmailLinkCard = (props: SignInFactorOneEmailLinkCard } }; + React.useEffect(() => { + void startEmailLinkVerification(); + }, []); + if (switchTabStatus) { return ( return signIn.clientTrustState === 'new' && !anyAttributeUsedForSecondFactor; }, [signIn.clientTrustState, env.userSettings.attributes]); - React.useEffect(() => { - if (props.factorAlreadyPrepared) { - return; - } - - void prepare?.(); - }, []); - const prepare = props.prepare ? () => { return props @@ -113,6 +105,14 @@ export const SignInFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) => }); }; + React.useEffect(() => { + if (props.factorAlreadyPrepared) { + return; + } + + void prepare?.(); + }, []); + return ( { - void startEmailLinkVerification(); - }, []); - const restartVerification = () => { cancelEmailLinkFlow(); void startEmailLinkVerification(); @@ -83,6 +79,10 @@ export const SignInFactorTwoEmailLinkCard = (props: SignInFactorTwoEmailLinkCard } }; + React.useEffect(() => { + void startEmailLinkVerification(); + }, []); + if (showVerifyModal) { return ( { - if (!organizationTicket) { - return; - } - - if (clerkStatus === 'sign_up') { - const paramsToForward = new URLSearchParams(); - if (organizationTicket) { - paramsToForward.set('__clerk_ticket', organizationTicket); - } - // We explicitly navigate to 'create' in the combined flow to trigger a client-side navigation. Navigating to - // signUpUrl triggers a full page reload when used with the hash router. - void navigate(isCombinedFlow ? `create` : signUpUrl, { searchParams: paramsToForward }); - return; - } - - status.setLoading(); - card.setLoading(); - signIn - .create({ - strategy: 'ticket', - ticket: organizationTicket, - }) - .then(res => { - if (navigateOnSignInProtectGate(res, navigate, 'protect-check')) { - return; - } - switch (res.status) { - case 'needs_first_factor': { - if (!shouldHandOffToEnterpriseConnection(res)) { - return navigate('factor-one'); - } - - return authenticateWithEnterpriseSSO(); - } - case 'needs_second_factor': - return navigate('factor-two'); - case 'needs_client_trust': - return navigate('client-trust'); - case 'complete': - removeClerkQueryParam('__clerk_ticket'); - return clerk.setActive({ - session: res.createdSessionId, - navigate: async ({ session, decorateUrl }) => { - await navigateOnSetActive({ session, redirectUrl: afterSignInUrl, decorateUrl }); - }, - }); - default: { - console.error(clerkInvalidFAPIResponse(res.status, supportEmail)); - return; - } - } - }) - .catch(err => { - return attemptToRecoverFromSignInError(err); - }) - .finally(() => { - // Keep the card in loading state during SSO redirect to prevent UI flicker - // This is necessary because there's a brief delay between initiating the SSO flow - // and the actual redirect to the external Identity Provider - const isRedirectingToSSOProvider = !!hasOnlyEnterpriseSSOFirstFactors(signIn); - if (isRedirectingToSSOProvider) { - return; - } - - status.setIdle(); - card.setIdle(); - }); - }, []); - - useEffect(() => { - async function handleOauthError() { - const defaultErrorHandler = () => { - // Error from server may be too much information for the end user, so set a generic error - card.setError('Unable to complete action at this time. If the problem persists please contact support.'); - }; - - const error = signIn?.firstFactorVerification?.error; - if (error) { - switch (error.code) { - case ERROR_CODES.NOT_ALLOWED_TO_SIGN_UP: - case ERROR_CODES.OAUTH_ACCESS_DENIED: - case ERROR_CODES.NOT_ALLOWED_ACCESS: - case ERROR_CODES.SAML_USER_ATTRIBUTE_MISSING: - case ERROR_CODES.OAUTH_EMAIL_DOMAIN_RESERVED_BY_SAML: - case ERROR_CODES.USER_LOCKED: - case ERROR_CODES.EXTERNAL_ACCOUNT_NOT_FOUND: - case ERROR_CODES.SIGN_UP_MODE_RESTRICTED: - case ERROR_CODES.SIGN_UP_MODE_RESTRICTED_WAITLIST: - case ERROR_CODES.ENTERPRISE_SSO_USER_ATTRIBUTE_MISSING: - case ERROR_CODES.ENTERPRISE_SSO_EMAIL_ADDRESS_DOMAIN_MISMATCH: - case ERROR_CODES.ENTERPRISE_SSO_HOSTED_DOMAIN_MISMATCH: - case ERROR_CODES.SAML_EMAIL_ADDRESS_DOMAIN_MISMATCH: - case ERROR_CODES.ORGANIZATION_MEMBERSHIP_QUOTA_EXCEEDED_FOR_SSO: - case ERROR_CODES.CAPTCHA_INVALID: - case ERROR_CODES.FRAUD_DEVICE_BLOCKED: - case ERROR_CODES.FRAUD_ACTION_BLOCKED: - case ERROR_CODES.SIGNUP_RATE_LIMIT_EXCEEDED: - case ERROR_CODES.USER_BANNED: - case ERROR_CODES.USER_DEACTIVATED: - card.setError(error); - break; - default: - defaultErrorHandler(); - } - - // TODO: This is a workaround in order to reset the sign in attempt - // so that the oauth error does not persist on full page reloads. - void (await signIn.create({})); - } - } - - void handleOauthError(); - }, []); - const buildSignInParams = (fields: Array>): SignInCreateParams => { const hasPassword = fields.some(f => f.name === 'password' && !!f.value); @@ -532,8 +417,6 @@ function SignInStartInternal(): JSX.Element { return navigate(waitlistUrl); } - clerk.client.signUp[attribute] = identifierField.value; - const redirectUrl = ctx.ssoCallbackUrl; const redirectUrlComplete = ctx.afterSignUpUrl || '/'; @@ -565,6 +448,121 @@ function SignInStartInternal(): JSX.Element { } }; + useEffect(() => { + if (!organizationTicket) { + return; + } + + if (clerkStatus === 'sign_up') { + const paramsToForward = new URLSearchParams(); + if (organizationTicket) { + paramsToForward.set('__clerk_ticket', organizationTicket); + } + // We explicitly navigate to 'create' in the combined flow to trigger a client-side navigation. Navigating to + // signUpUrl triggers a full page reload when used with the hash router. + void navigate(isCombinedFlow ? `create` : signUpUrl, { searchParams: paramsToForward }); + return; + } + + status.setLoading(); + card.setLoading(); + signIn + .create({ + strategy: 'ticket', + ticket: organizationTicket, + }) + .then(res => { + if (navigateOnSignInProtectGate(res, navigate, 'protect-check')) { + return; + } + switch (res.status) { + case 'needs_first_factor': { + if (!shouldHandOffToEnterpriseConnection(res)) { + return navigate('factor-one'); + } + + return authenticateWithEnterpriseSSO(); + } + case 'needs_second_factor': + return navigate('factor-two'); + case 'needs_client_trust': + return navigate('client-trust'); + case 'complete': + removeClerkQueryParam('__clerk_ticket'); + return clerk.setActive({ + session: res.createdSessionId, + navigate: async ({ session, decorateUrl }) => { + await navigateOnSetActive({ session, redirectUrl: afterSignInUrl, decorateUrl }); + }, + }); + default: { + console.error(clerkInvalidFAPIResponse(res.status, supportEmail)); + return; + } + } + }) + .catch(err => { + return attemptToRecoverFromSignInError(err); + }) + .finally(() => { + // Keep the card in loading state during SSO redirect to prevent UI flicker + // This is necessary because there's a brief delay between initiating the SSO flow + // and the actual redirect to the external Identity Provider + const isRedirectingToSSOProvider = !!hasOnlyEnterpriseSSOFirstFactors(signIn); + if (isRedirectingToSSOProvider) { + return; + } + + status.setIdle(); + card.setIdle(); + }); + }, []); + + useEffect(() => { + async function handleOauthError() { + const defaultErrorHandler = () => { + // Error from server may be too much information for the end user, so set a generic error + card.setError('Unable to complete action at this time. If the problem persists please contact support.'); + }; + + const error = signIn?.firstFactorVerification?.error; + if (error) { + switch (error.code) { + case ERROR_CODES.NOT_ALLOWED_TO_SIGN_UP: + case ERROR_CODES.OAUTH_ACCESS_DENIED: + case ERROR_CODES.NOT_ALLOWED_ACCESS: + case ERROR_CODES.SAML_USER_ATTRIBUTE_MISSING: + case ERROR_CODES.OAUTH_EMAIL_DOMAIN_RESERVED_BY_SAML: + case ERROR_CODES.USER_LOCKED: + case ERROR_CODES.EXTERNAL_ACCOUNT_NOT_FOUND: + case ERROR_CODES.SIGN_UP_MODE_RESTRICTED: + case ERROR_CODES.SIGN_UP_MODE_RESTRICTED_WAITLIST: + case ERROR_CODES.ENTERPRISE_SSO_USER_ATTRIBUTE_MISSING: + case ERROR_CODES.ENTERPRISE_SSO_EMAIL_ADDRESS_DOMAIN_MISMATCH: + case ERROR_CODES.ENTERPRISE_SSO_HOSTED_DOMAIN_MISMATCH: + case ERROR_CODES.SAML_EMAIL_ADDRESS_DOMAIN_MISMATCH: + case ERROR_CODES.ORGANIZATION_MEMBERSHIP_QUOTA_EXCEEDED_FOR_SSO: + case ERROR_CODES.CAPTCHA_INVALID: + case ERROR_CODES.FRAUD_DEVICE_BLOCKED: + case ERROR_CODES.FRAUD_ACTION_BLOCKED: + case ERROR_CODES.SIGNUP_RATE_LIMIT_EXCEEDED: + case ERROR_CODES.USER_BANNED: + case ERROR_CODES.USER_DEACTIVATED: + card.setError(error); + break; + default: + defaultErrorHandler(); + } + + // TODO: This is a workaround in order to reset the sign in attempt + // so that the oauth error does not persist on full page reloads. + void (await signIn.create({})); + } + } + + void handleOauthError(); + }, []); + const handleFirstPartySubmit = async (e: React.FormEvent) => { e.preventDefault(); return signInWithFields([identifierField, instantPasswordField]); diff --git a/packages/ui/src/components/SignUp/SignUpEmailLinkCard.tsx b/packages/ui/src/components/SignUp/SignUpEmailLinkCard.tsx index 34a9767cf40..c01c3528bad 100644 --- a/packages/ui/src/components/SignUp/SignUpEmailLinkCard.tsx +++ b/packages/ui/src/components/SignUp/SignUpEmailLinkCard.tsx @@ -21,10 +21,6 @@ export const SignUpEmailLinkCard = () => { const { startEmailLinkFlow, cancelEmailLinkFlow } = useEmailLink(signUp); - React.useEffect(() => { - void startEmailLinkVerification(); - }, []); - const restartVerification = () => { cancelEmailLinkFlow(); void startEmailLinkVerification(); @@ -57,6 +53,10 @@ export const SignUpEmailLinkCard = () => { } }; + React.useEffect(() => { + void startEmailLinkVerification(); + }, []); + if (showVerifyModal) { return ( { - startVerification(); - }, []); - function startVerification() { const { mode, componentName } = profileContext; @@ -43,6 +39,10 @@ export const VerifyWithEnterpriseConnection = (props: VerifyWithEnterpriseConnec await navigate(email.verification.externalVerificationRedirectURL?.href || ''); } + React.useEffect(() => { + startVerification(); + }, []); + return ( <> diff --git a/packages/ui/src/components/UserVerification/UVFactorOneCodeForm.tsx b/packages/ui/src/components/UserVerification/UVFactorOneCodeForm.tsx index 3f18d9fb364..1f4e21b0a4f 100644 --- a/packages/ui/src/components/UserVerification/UVFactorOneCodeForm.tsx +++ b/packages/ui/src/components/UserVerification/UVFactorOneCodeForm.tsx @@ -33,12 +33,6 @@ export const UVFactorOneCodeForm = (props: UVFactorOneCodeFormProps) => { const { handleVerificationResponse } = useAfterVerification(); - React.useEffect(() => { - if (!props.factorAlreadyPrepared) { - prepare(); - } - }, []); - const prepare = () => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion void session! @@ -58,6 +52,12 @@ export const UVFactorOneCodeForm = (props: UVFactorOneCodeFormProps) => { .catch(reject); }; + React.useEffect(() => { + if (!props.factorAlreadyPrepared) { + prepare(); + } + }, []); + return ( { const { session } = useSession(); const { handleVerificationResponse } = useAfterVerification(); - React.useEffect(() => { - if (props.factorAlreadyPrepared) { - return; - } - - void prepare?.(); - }, []); - const prepare = props.prepare ? () => props @@ -58,6 +50,14 @@ export const UVFactorTwoCodeForm = (props: SignInFactorTwoCodeFormProps) => { .catch(reject); }; + React.useEffect(() => { + if (props.factorAlreadyPrepared) { + return; + } + + void prepare?.(); + }, []); + return ( { [clerk, subscriberType, revalidateAll], ); + const sessionId = session?.id; + // handle the selection of a plan, either by opening the subscription details or checkout const handleSelectPlan = useCallback( ({ @@ -369,8 +371,8 @@ export const usePlansContext = () => { onSubscriptionComplete?.(); }, onClose: () => { - if (session?.id) { - void clerk.setActive({ session: session.id }); + if (sessionId) { + void clerk.setActive({ session: sessionId }); } }, appearance, @@ -378,7 +380,7 @@ export const usePlansContext = () => { newSubscriptionRedirectUrl, }); }, - [clerk, revalidateAll, subscriberType, session?.id], + [clerk, revalidateAll, subscriberType, sessionId], ); const defaultFreePlan = useMemo(() => { diff --git a/packages/ui/src/elements/TimerButton.tsx b/packages/ui/src/elements/TimerButton.tsx index b4be1753b9f..e4e0d32a1aa 100644 --- a/packages/ui/src/elements/TimerButton.tsx +++ b/packages/ui/src/elements/TimerButton.tsx @@ -24,12 +24,6 @@ export const TimerButton = (props: TimerButtonProps) => { const [remainingSeconds, setRemainingSeconds] = React.useState(0); const intervalIdRef = React.useRef(undefined); - useSafeLayoutEffect(() => { - if (startDisabled) { - disable(); - } - }, []); - useEffect(() => { return () => clearInterval(intervalIdRef.current); }, []); @@ -54,6 +48,12 @@ export const TimerButton = (props: TimerButtonProps) => { disable(); }; + useSafeLayoutEffect(() => { + if (startDisabled) { + disable(); + } + }, []); + return (