From d42d100c2ac14af76460199d64ef0328ad414a8d Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 19 May 2026 08:40:07 -0500 Subject: [PATCH] fix(react): attach UI for preloaded Clerk instances --- .changeset/preloaded-clerk-ui-attachment.md | 6 ++++ .../clerk-js/src/core/__tests__/clerk.test.ts | 28 +++++++++++++++ packages/clerk-js/src/core/clerk.ts | 36 ++++++++++++------- .../src/__tests__/isomorphicClerk.test.ts | 29 +++++++++++++++ packages/react/src/isomorphicClerk.ts | 15 +++++--- 5 files changed, 97 insertions(+), 17 deletions(-) create mode 100644 .changeset/preloaded-clerk-ui-attachment.md diff --git a/.changeset/preloaded-clerk-ui-attachment.md b/.changeset/preloaded-clerk-ui-attachment.md new file mode 100644 index 00000000000..47fc64ab6de --- /dev/null +++ b/.changeset/preloaded-clerk-ui-attachment.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/react': patch +--- + +Attach Clerk UI when an already-loaded browser Clerk instance is reused by React. diff --git a/packages/clerk-js/src/core/__tests__/clerk.test.ts b/packages/clerk-js/src/core/__tests__/clerk.test.ts index 979cf6e24fa..86be1d9f94c 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -2973,6 +2973,34 @@ describe('Clerk singleton', () => { expect(mockClerkUICtor).toHaveBeenCalled(); }); + it('attaches ui.ClerkUI to an already loaded instance without refetching initial resources', async () => { + const mockControls = { mountComponent: vi.fn() }; + const mockClerkUIInstance = { + ensureMounted: vi.fn().mockResolvedValue(mockControls), + }; + const mockClerkUICtor = vi.fn(() => mockClerkUIInstance); + + mockClientFetch.mockClear(); + mockEnvironmentFetch.mockClear(); + + const sut = new Clerk(productionPublishableKey); + await sut.load(mockedLoadOptions); + + expect(mockClientFetch).toHaveBeenCalledTimes(1); + expect(mockEnvironmentFetch).toHaveBeenCalledTimes(1); + + await sut.load({ + ...mockedLoadOptions, + ui: { ClerkUI: mockClerkUICtor }, + }); + await Promise.resolve(); + + expect(mockClerkUICtor).toHaveBeenCalledTimes(1); + expect(mockClientFetch).toHaveBeenCalledTimes(1); + expect(mockEnvironmentFetch).toHaveBeenCalledTimes(1); + expect(() => sut.mountUserButton(document.createElement('div'))).not.toThrow(); + }); + it('supports legacy clerkUICtor option for backwards compatibility', async () => { const mockClerkUIInstance = { mount: vi.fn() }; const mockClerkUICtor = vi.fn(() => mockClerkUIInstance); diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index c96ae678925..23961ce6cff 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -518,6 +518,13 @@ export class Clerk implements ClerkInterface { public load = async (options?: ClerkOptions): Promise => { debugLogger.info('load() start', {}, 'clerk'); if (this.loaded) { + const legacy = options as Record | undefined; + const hasClerkUI = Boolean(options?.ui?.ClerkUI || legacy?.clerkUICtor || legacy?.clerkUiCtor); + if (!this.#clerkUI && hasClerkUI) { + const nextOptions = this.#initOptions({ ...this.#options, ...options }); + this.#options = nextOptions; + this.#initClerkUI(); + } return; } @@ -530,18 +537,7 @@ export class Clerk implements ClerkInterface { this.#options = this.#initOptions(options); - // Initialize ClerkUI if it was provided - if (this.#options.ui?.ClerkUI) { - this.#clerkUI = Promise.resolve(this.#options.ui.ClerkUI).then( - ClerkUI => - new ClerkUI( - () => this, - () => this.environment, - this.#options, - new ModuleManager(), - ), - ); - } + this.#initClerkUI(); // In development mode, if custom router options are provided, warn if both routerPush and routerReplace are not provided if ( @@ -618,6 +614,22 @@ export class Clerk implements ClerkInterface { } }; + #initClerkUI = (): void => { + if (this.#clerkUI || !this.#options.ui?.ClerkUI) { + return; + } + + this.#clerkUI = Promise.resolve(this.#options.ui.ClerkUI).then( + ClerkUI => + new ClerkUI( + () => this, + () => this.environment, + this.#options, + new ModuleManager(), + ), + ); + }; + #isCombinedSignInOrUpFlow(): boolean { return Boolean(!this.#options.signUpUrl && this.#options.signInUrl && !isAbsoluteUrl(this.#options.signInUrl)); } diff --git a/packages/react/src/__tests__/isomorphicClerk.test.ts b/packages/react/src/__tests__/isomorphicClerk.test.ts index 6b1e05a017d..d772b0c934d 100644 --- a/packages/react/src/__tests__/isomorphicClerk.test.ts +++ b/packages/react/src/__tests__/isomorphicClerk.test.ts @@ -243,6 +243,35 @@ describe('isomorphicClerk', () => { ); }); + it('attaches UI before replaying premounts when global Clerk is already loaded', async () => { + const mockLoad = vi.fn().mockResolvedValue(undefined); + const mockMountUserButton = vi.fn(); + const mockClerkInstance = { + load: mockLoad, + loaded: true, + status: 'ready', + mountUserButton: mockMountUserButton, + }; + (global as any).Clerk = mockClerkInstance; + + const clerk = new IsomorphicClerk({ publishableKey: 'pk_test_XXX' }); + const node = document.createElement('div'); + clerk.mountUserButton(node); + + await (clerk as any).getEntryChunks(); + + expect(loadClerkUIScript).toHaveBeenCalled(); + expect(mockLoad).toHaveBeenCalledWith( + expect.objectContaining({ + ui: expect.objectContaining({ + ClerkUI: (global as any).__internal_ClerkUICtor, + }), + }), + ); + expect(mockMountUserButton).toHaveBeenCalledWith(node, undefined); + expect(mockLoad.mock.invocationCallOrder[0]).toBeLessThan(mockMountUserButton.mock.invocationCallOrder[0]); + }); + // ─── @clerk/react with bundled ui prop (e.g. user passes ui={ui} from @clerk/ui) ─── // These SDKs: no Clerk prop, ui with ClerkUI, standardBrowser omitted // shouldLoadUi = (true && true) || true = true diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index b9c9bfd88cb..dd971efb1d6 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -512,16 +512,21 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { try { const clerk = await this.getClerkJsEntryChunk(); + // Load UI when: + // - standard browser and no pre-created Clerk instance (normal CDN path), OR + // - a bundled ClerkUI was provided via the ui prop (e.g. chrome-extension, even with standardBrowser: false) + const shouldLoadUi = + (this.options.standardBrowser !== false && !this.options.Clerk) || !!this.options.ui?.ClerkUI; if (!clerk.loaded) { this.beforeLoad(clerk); - // Load UI when: - // - standard browser and no pre-created Clerk instance (normal CDN path), OR - // - a bundled ClerkUI was provided via the ui prop (e.g. chrome-extension, even with standardBrowser: false) - const shouldLoadUi = - (this.options.standardBrowser !== false && !this.options.Clerk) || !!this.options.ui?.ClerkUI; const ClerkUI = shouldLoadUi ? await this.getClerkUIEntryChunk() : undefined; await clerk.load({ ...this.options, ui: { ...this.options.ui, ClerkUI } }); + } else if (shouldLoadUi) { + const ClerkUI = await this.getClerkUIEntryChunk(); + if (ClerkUI) { + await clerk.load({ ...this.options, ui: { ...this.options.ui, ClerkUI } }); + } } if (clerk.loaded) { this.replayInterceptedInvocations(clerk);