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/shared-context-singleton.md
Original file line number Diff line number Diff line change
@@ -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.
58 changes: 58 additions & 0 deletions packages/shared/src/react/__tests__/createContextAndHook.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <div data-testid='value'>{label}</div>;
};

render(
<ProviderCtx.Provider value={{ value: { label: 'from-provider' } }}>
<Consumer />
</ProviderCtx.Provider>,
);

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(<Consumer />)).toThrow(/MissingContext not found/);
});
});
37 changes: 35 additions & 2 deletions packages/shared/src/react/hooks/createContextAndHook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,40 @@ type Options = { assertCtxFn?: (v: unknown, msg: string) => void };
type ContextOf<T> = React.Context<{ value: T } | undefined>;
type UseCtxFn<T> = () => T;

declare global {
var __clerk_shared_react_contexts: Map<string, React.Context<{ value: unknown } | undefined>> | 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 = <CtxVal>(displayName: string): ContextOf<CtxVal> => {
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<CtxVal>(displayName) call that owns that name, so
// the stored context always carries the CtxVal this caller expects.
return existing as ContextOf<CtxVal>;
}

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.
Expand All @@ -30,8 +64,7 @@ export const createContextAndHook = <CtxVal>(
options?: Options,
): [ContextOf<CtxVal>, UseCtxFn<CtxVal>, UseCtxFn<CtxVal | Partial<CtxVal>>] => {
const { assertCtxFn = assertContextExists } = options || {};
const Ctx = React.createContext<{ value: CtxVal } | undefined>(undefined);
Ctx.displayName = displayName;
const Ctx = getOrCreateContext<CtxVal>(displayName);

const useCtx = () => {
const ctx = React.useContext(Ctx);
Expand Down
Loading