diff --git a/.changeset/spotty-planes-nest.md b/.changeset/spotty-planes-nest.md new file mode 100644 index 00000000000..a9a017413c3 --- /dev/null +++ b/.changeset/spotty-planes-nest.md @@ -0,0 +1,5 @@ +--- +'@clerk/react': patch +--- + +Fix a false-positive "multiple ``" crash in apps that run more than one React root in a single JavaScript runtime, most commonly React Native Android apps during activity recreation. `` now throws this error only when it is genuinely nested inside another ``. diff --git a/packages/react/src/contexts/ClerkProvider.tsx b/packages/react/src/contexts/ClerkProvider.tsx index a2ac25bcd8c..fb0274520d6 100644 --- a/packages/react/src/contexts/ClerkProvider.tsx +++ b/packages/react/src/contexts/ClerkProvider.tsx @@ -1,10 +1,11 @@ -import { ClerkContextProvider } from '@clerk/shared/react'; +import { ClerkContextProvider, ClerkInstanceContext } from '@clerk/shared/react'; import React from 'react'; +import { errorThrower } from '../errors/errorThrower'; import { multipleClerkProvidersError } from '../errors/messages'; import { IsomorphicClerk } from '../isomorphicClerk'; import type { ClerkProviderProps, IsomorphicClerkOptions, Ui } from '../types'; -import { mergeWithEnv, withMaxAllowedInstancesGuard } from '../utils'; +import { mergeWithEnv } from '../utils'; import { IS_REACT_SHARED_VARIANT_COMPATIBLE } from '../utils/versionCheck'; function ClerkProviderBase(props: ClerkProviderProps) { @@ -26,7 +27,17 @@ function ClerkProviderBase(props: ClerkProviderProps) { ); } -const ClerkProvider = withMaxAllowedInstancesGuard(ClerkProviderBase, 'ClerkProvider', multipleClerkProvidersError); +function ClerkProviderGuard(props: ClerkProviderProps) { + // Context is per React tree, so a second root or React Native surface sharing + // the JS runtime can never false-positive as a nested provider. + if (React.useContext(ClerkInstanceContext)) { + errorThrower.throw(multipleClerkProvidersError); + } + return ; +} + +// Cast preserves the pre-existing public type of ClerkProvider so the export is not a breaking change. +const ClerkProvider = ClerkProviderGuard as typeof ClerkProviderBase & { displayName: string }; ClerkProvider.displayName = 'ClerkProvider'; diff --git a/packages/react/src/contexts/__tests__/ClerkProvider.test.tsx b/packages/react/src/contexts/__tests__/ClerkProvider.test.tsx index 375d0236ee6..5fd2191b08e 100644 --- a/packages/react/src/contexts/__tests__/ClerkProvider.test.tsx +++ b/packages/react/src/contexts/__tests__/ClerkProvider.test.tsx @@ -16,10 +16,30 @@ import { ukUA, } from '@clerk/localizations'; import { dark } from '@clerk/ui/themes'; -import { describe, expectTypeOf, it } from 'vitest'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { afterAll, beforeAll, describe, expect, expectTypeOf, it, vi } from 'vitest'; import type { ClerkProviderProps as GenericClerkProviderProps, Ui } from '../../types'; -import type { ClerkProvider } from '../ClerkProvider'; +import { ClerkProvider } from '../ClerkProvider'; + +vi.mock('../../isomorphicClerk', () => { + let instance: any; + class IsomorphicClerk { + status = 'loading'; + on = vi.fn(); + off = vi.fn(); + __internal_updateProps = vi.fn().mockResolvedValue(undefined); + static getOrCreateInstance() { + instance ??= new IsomorphicClerk(); + return instance; + } + static clearInstance() { + instance = undefined; + } + } + return { IsomorphicClerk }; +}); type ClerkProviderProps = Parameters[0]; type CustomAppearance = { @@ -232,4 +252,45 @@ describe('ClerkProvider', () => { }).toMatchTypeOf(); }); }); + + describe('duplicate detection', () => { + const pk = 'pk_test_Y2xlcmsuY2xlcmsuZGV2JA'; + const originalError = console.error; + + beforeAll(() => { + console.error = vi.fn(); + }); + + afterAll(() => { + console.error = originalError; + }); + + it('throws when a ClerkProvider is nested inside another ClerkProvider', () => { + expect(() => + render( + + +
+ + , + ), + ).toThrow(/multiple /); + }); + + it('does not throw when a second React root mounts while the first is still mounted', () => { + const first = render( + +
+ , + ); + expect(() => + render( + +
+ , + ), + ).not.toThrow(); + first.unmount(); + }); + }); });