diff --git a/.changeset/shared-context-singleton.md b/.changeset/shared-context-singleton.md new file mode 100644 index 00000000000..4d6a82f002a --- /dev/null +++ b/.changeset/shared-context-singleton.md @@ -0,0 +1,5 @@ +--- +'@clerk/shared': patch +--- + +Make React contexts created by `createContextAndHook` resilient to duplicate module instances. When a bundler ends up with more than one copy of `@clerk/shared/react` (for example Vite/Rolldown pre-bundling it separately from the `ClerkProvider`), each copy previously created its own context object, so a provider from one copy and a `useClerk`/`use*Context` hook from another would not match and threw "... not found". Contexts are now cached on a global registry keyed by package version and display name, so every duplicated instance shares one context object. diff --git a/packages/shared/src/react/__tests__/createContextAndHook.test.tsx b/packages/shared/src/react/__tests__/createContextAndHook.test.tsx new file mode 100644 index 00000000000..d98f4097a50 --- /dev/null +++ b/packages/shared/src/react/__tests__/createContextAndHook.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { createContextAndHook } from '../hooks/createContextAndHook'; + +afterEach(() => { + // Reset the cross-instance registry between tests. + globalThis.__clerk_shared_react_contexts = undefined; +}); + +describe('createContextAndHook', () => { + it('returns the same context object for repeated calls with the same display name', () => { + const [CtxA] = createContextAndHook<{ label: string }>('SharedContext'); + const [CtxB] = createContextAndHook<{ label: string }>('SharedContext'); + + // A duplicated module instance would call createContextAndHook again; both must + // resolve to one context object so provider/consumer identity matches. + expect(CtxA).toBe(CtxB); + }); + + it('returns different context objects for different display names', () => { + const [CtxA] = createContextAndHook<{ label: string }>('ContextOne'); + const [CtxB] = createContextAndHook<{ label: string }>('ContextTwo'); + + expect(CtxA).not.toBe(CtxB); + }); + + it('lets a provider from one instance satisfy a hook from a duplicate instance', () => { + // Simulate two separately-bundled copies of this module by calling the factory twice. + const [ProviderCtx] = createContextAndHook<{ label: string }>('DuplicatedContext'); + const [, useDuplicatedContext] = createContextAndHook<{ label: string }>('DuplicatedContext'); + + const Consumer = () => { + const { label } = useDuplicatedContext(); + return
{label}
; + }; + + render( + + + , + ); + + expect(screen.getByTestId('value').textContent).toBe('from-provider'); + }); + + it('still throws when the hook is used without a provider', () => { + const [, useMissingContext] = createContextAndHook<{ label: string }>('MissingContext'); + + const Consumer = () => { + useMissingContext(); + return null; + }; + + expect(() => render()).toThrow(/MissingContext not found/); + }); +}); diff --git a/packages/shared/src/react/hooks/createContextAndHook.ts b/packages/shared/src/react/hooks/createContextAndHook.ts index 390ba4baf50..8e073c7b000 100644 --- a/packages/shared/src/react/hooks/createContextAndHook.ts +++ b/packages/shared/src/react/hooks/createContextAndHook.ts @@ -16,6 +16,40 @@ type Options = { assertCtxFn?: (v: unknown, msg: string) => void }; type ContextOf = React.Context<{ value: T } | undefined>; type UseCtxFn = () => T; +declare global { + var __clerk_shared_react_contexts: Map> | undefined; +} + +/** + * React matches a provider to a consumer by the identity of the context object returned + * from `React.createContext`. When a bundler ends up with more than one instance of this + * module (for example Vite/Rolldown pre-bundling `@clerk/shared/react` separately from the + * `ClerkProvider`), each instance would call `createContext` again and produce a *different* + * context object, so a provider from one instance and a `useClerk`/`use*Context` from another + * would never match and the assert hook throws "... not found". + * + * Cache each context on a global registry, keyed by package version + display name, so every + * duplicated instance of this module shares one context object. Keying by version keeps two + * legitimately different major versions of `@clerk/shared` from colliding on the same key. + */ +const getOrCreateContext = (displayName: string): ContextOf => { + const registry = (globalThis.__clerk_shared_react_contexts ??= new Map()); + const key = `${PACKAGE_VERSION}:${displayName}`; + + const existing = registry.get(key); + if (existing) { + // SAFETY: the registry is keyed by version + displayName, and each key is only ever + // written by the createContextAndHook(displayName) call that owns that name, so + // the stored context always carries the CtxVal this caller expects. + return existing as ContextOf; + } + + const Ctx = React.createContext<{ value: CtxVal } | undefined>(undefined); + Ctx.displayName = displayName; + registry.set(key, Ctx as React.Context<{ value: unknown } | undefined>); + return Ctx; +}; + /** * Create and return a Context and two hooks that return the context value. * The Context type is derived from the type passed in by the user. @@ -30,8 +64,7 @@ export const createContextAndHook = ( options?: Options, ): [ContextOf, UseCtxFn, UseCtxFn>] => { const { assertCtxFn = assertContextExists } = options || {}; - const Ctx = React.createContext<{ value: CtxVal } | undefined>(undefined); - Ctx.displayName = displayName; + const Ctx = getOrCreateContext(displayName); const useCtx = () => { const ctx = React.useContext(Ctx);