From e2d4168f2c4bf9ff6e50f29711e4bbdd47564887 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 10 Jul 2026 16:52:44 -0400 Subject: [PATCH] feat(shared): expose moduleManager via getter across build boundary --- .changeset/shared-moduleManager-registry.md | 7 ++++ packages/clerk-js/src/core/clerk.ts | 16 ++++++++- .../src/__tests__/isomorphicClerk.test.ts | 25 +++++++++++++ packages/react/src/isomorphicClerk.ts | 13 +++++++ packages/shared/src/types/clerk.ts | 36 +++++++++++++++++-- 5 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 .changeset/shared-moduleManager-registry.md diff --git a/.changeset/shared-moduleManager-registry.md b/.changeset/shared-moduleManager-registry.md new file mode 100644 index 00000000000..a4ea2f1843e --- /dev/null +++ b/.changeset/shared-moduleManager-registry.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': patch +'@clerk/shared': patch +'@clerk/react': patch +--- + +Internal plumbing to support `@clerk/ui` composed `UserProfile` / `OrganizationProfile` subcomponents. diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 81714df9bc6..7498dd70973 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -274,6 +274,20 @@ export class Clerk implements ClerkInterface { #pageLifecycle: ReturnType | null = null; #touchThrottledUntil = 0; #publicEventBus = createClerkEventBus(); + #moduleManager = new ModuleManager(); + + /** + * Cross-bundle handle to the ModuleManager. clerk-js is loaded standalone + * from the CDN with its own inlined @clerk/shared, so plain property access + * is the only channel that reliably crosses that boundary. This getter is + * how IsomorphicClerk forwards the manager to consumers that import + * @clerk/shared from node_modules (e.g. @clerk/react, @clerk/ui). + * + * @internal + */ + get __internal_moduleManager(): ModuleManager { + return this.#moduleManager; + } get __internal_queryClient(): { __tag: 'clerk-rq-client'; client: QueryClient } | undefined { if (!this.#queryClient) { @@ -557,7 +571,7 @@ export class Clerk implements ClerkInterface { () => this, () => this.environment, this.#options, - new ModuleManager(), + this.#moduleManager, ), ); } diff --git a/packages/react/src/__tests__/isomorphicClerk.test.ts b/packages/react/src/__tests__/isomorphicClerk.test.ts index df2c5920dc0..b2081acd6a6 100644 --- a/packages/react/src/__tests__/isomorphicClerk.test.ts +++ b/packages/react/src/__tests__/isomorphicClerk.test.ts @@ -48,6 +48,31 @@ describe('isomorphicClerk', () => { }).not.toThrow(); }); + // Regression: composed/subcomponent UserProfile reads moduleManager via + // `useClerk().__internal_moduleManager`. `useClerk()` returns the + // IsomorphicClerk wrapper, so its getter must chain through to the loaded + // clerk-js's own `__internal_moduleManager`. This plain property access is + // the cross-bundle channel: clerk-js ships standalone from the CDN with its + // own inlined @clerk/shared, so module-scoped state cannot bridge the two. + // + // Without this chain, every dynamic-imported feature (Coinbase Wallet, Base, + // Stripe, zxcvbn) falls back to a rejecting manager. + it('exposes the inner clerk-js moduleManager through the __internal_moduleManager getter', () => { + const isomorphicClerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' }); + const mm = { import: vi.fn(() => Promise.resolve(undefined)) }; + + // Before clerk-js loads, the getter is undefined so readers fall back. + expect(isomorphicClerk.__internal_moduleManager).toBeUndefined(); + + const innerClerk: any = { + addListener: vi.fn(), + __internal_moduleManager: mm, + }; + (isomorphicClerk as any).replayInterceptedInvocations(innerClerk); + + expect(isomorphicClerk.__internal_moduleManager).toBe(mm); + }); + it('updates props asynchronously after clerkjs has loaded', async () => { const propsHistory: any[] = []; const dummyClerkJS = { diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 15f805e2f9b..e017ab2ddcd 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -2,6 +2,7 @@ import { inBrowser } from '@clerk/shared/browser'; import { clerkEvents, createClerkEventBus } from '@clerk/shared/clerkEventBus'; import { ALLOWED_PROTOCOLS, windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate'; import { loadClerkJSScript, loadClerkUIScript } from '@clerk/shared/loadClerkJsScript'; +import type { ModuleManager } from '@clerk/shared/moduleManager'; import type { __internal_AttemptToEnableEnvironmentSettingParams, __internal_AttemptToEnableEnvironmentSettingResult, @@ -285,6 +286,18 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { windowNavigate(to, { allowedProtocols }); }; + /** + * Proxies to the inner Clerk instance's ModuleManager. Returns `undefined` + * before clerk-js has loaded; composed UI components read this getter + * (via `useClerk()`) to resolve dynamic-imported modules and fall back to a + * rejecting manager while it is `undefined`. + * + * @internal + */ + public get __internal_moduleManager(): ModuleManager | undefined { + return this.clerkjs?.__internal_moduleManager; + } + constructor(options: IsomorphicClerkOptions) { this.#publishableKey = options?.publishableKey; this.#proxyUrl = options?.proxyUrl; diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 31fe0610746..6803dac6015 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -1,4 +1,6 @@ -import type { ClerkGlobalHookError } from '../errors/globalHookError'; +import type { ClerkGlobalHookError } from '@/errors/globalHookError'; + +import type { ModuleManager } from '../moduleManager'; import type { ClerkUIConstructor } from '../ui/types'; import type { APIKeysNamespace } from './apiKeys'; import type { @@ -139,11 +141,13 @@ export type SDKMetadata = { /** * A callback function that is called when Clerk resources change. + * * @inline */ export type ListenerCallback = (emission: Resources) => void; /** * Optional configuration for the `addListener()` method. + * * @param skipInitialEmit - If `true`, the callback will not be called immediately after registration. Defaults to `false`. * @inline */ @@ -188,7 +192,8 @@ export type SetActiveNavigate = (params: { /** * A callback that runs after sign out completes. - * @inline */ + * + @inline */ export type SignOutCallback = () => void | Promise; /** @@ -298,6 +303,20 @@ export interface Clerk { */ __internal_windowNavigate: (to: URL | string, opts?: { useStaticAllowlistOnly?: boolean }) => void; + /** + * Internal handle to the bundled ModuleManager. Exposed so framework SDK + * wrappers (e.g. IsomorphicClerk) can forward it to composed UI components + * that need dynamic-imported modules (Coinbase Wallet, Base, Stripe, zxcvbn). + * Plain property access crosses the bundle boundary that other channels + * cannot — clerk-js inlines its own @clerk/shared, so module-scoped state is + * invisible to consumers loading @clerk/shared from node_modules. It is + * `undefined` on a wrapper whose inner clerk-js has not loaded yet, so + * readers must handle the absent case. + * + * @internal + */ + __internal_moduleManager: ModuleManager | undefined; + frontendApi: string; /** Your Clerk [Publishable Key](!publishable-key). */ @@ -317,6 +336,7 @@ export interface Clerk { /** * Indicates whether the instance is being loaded in a standard browser environment. Set to `false` on native platforms where cookies cannot be set. When `undefined`, Clerk assumes a standard browser. + * * @inline */ isStandardBrowser: boolean | undefined; @@ -350,6 +370,7 @@ export interface Clerk { * `effect()` that can be used to subscribe to changes from Signals. * * @hidden + * * @experimental This experimental API is subject to change. */ __internal_state: State; @@ -398,6 +419,7 @@ export interface Clerk { /** * Closes the Clerk Checkout drawer. + * * @hidden */ __internal_closeCheckout: () => void; @@ -412,6 +434,7 @@ export interface Clerk { /** * Closes the Clerk PlanDetails drawer. + * * @hidden */ __internal_closePlanDetails: () => void; @@ -426,6 +449,7 @@ export interface Clerk { /** * Closes the Clerk SubscriptionDetails drawer. + * * @hidden */ __internal_closeSubscriptionDetails: () => void; @@ -440,12 +464,14 @@ export interface Clerk { /** * Closes the Clerk user verification modal. + * * @hidden */ __internal_closeReverification: () => void; /** * Attempts to enable a environment setting from a development instance, prompting if disabled. + * * @hidden */ __internal_attemptToEnableEnvironmentSetting: ( @@ -454,12 +480,14 @@ export interface Clerk { /** * Opens the Clerk Enable Organizations prompt for development instance + * * @hidden */ __internal_openEnableOrganizationsPrompt: (props: __internal_EnableOrganizationsPromptProps) => void; /** * Closes the Clerk Enable Organizations modal. + * * @hidden */ __internal_closeEnableOrganizationsPrompt: () => void; @@ -939,12 +967,14 @@ export interface Clerk { /** * Returns the configured `afterSignInUrl` of the instance. + * * @param params - Optional query parameters to append to the URL. */ buildAfterSignInUrl({ params }?: { params?: URLSearchParams }): string; /** * Returns the configured `afterSignUpUrl` of the instance. + * * @param params - Optional query parameters to append to the URL. */ buildAfterSignUpUrl({ params }?: { params?: URLSearchParams }): string; @@ -1100,6 +1130,7 @@ export interface Clerk { /** * Completes an email link verification flow started by `Clerk.client.signIn.createEmailLinkFlow` or `Clerk.client.signUp.createEmailLinkFlow`, by processing the verification results from the redirect URL query parameters. This method should be called after the user is redirected back from visiting the verification link in their email. + * * @param params - Allows you to define the URLs where the user should be redirected to on successful verification or pending/completed sign-up or sign-in attempts. If the email link is successfully verified on another device, there's a callback function parameter that allows custom code execution. * @param customNavigate - A function that overrides Clerk's default navigation behavior, allowing custom handling of navigation during sign-up and sign-in flows. */ @@ -1385,6 +1416,7 @@ export type ClerkOptions = ClerkOptionsNavigation & localization?: LocalizationResource; /** * Indicates whether Clerk should poll against Clerk's backend every 5 minutes. + * * @default true */ polling?: boolean;