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..e5a287f373f 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.test.ts @@ -2973,6 +2973,46 @@ describe('Clerk singleton', () => { expect(mockClerkUICtor).toHaveBeenCalled(); }); + it('attaches ui.ClerkUI to an already loaded instance without refetching initial resources or mutating load options', async () => { + const mockControls = { mountComponent: vi.fn() }; + const mockClerkUIInstance = { + ensureMounted: vi.fn().mockResolvedValue(mockControls), + }; + const mockClerkUICtor = vi.fn(() => mockClerkUIInstance); + const nextRouterPush = vi.fn(); + const appearance = { variables: { colorPrimary: 'red' } }; + + mockClientFetch.mockClear(); + mockEnvironmentFetch.mockClear(); + + const sut = new Clerk(productionPublishableKey); + await sut.load(mockedLoadOptions); + + expect(mockClientFetch).toHaveBeenCalledTimes(1); + expect(mockEnvironmentFetch).toHaveBeenCalledTimes(1); + + sut.__internal_attachClerkUI(mockClerkUICtor, { + ...mockedLoadOptions, + routerPush: nextRouterPush, + appearance, + ui: { ClerkUI: mockClerkUICtor }, + }); + await Promise.resolve(); + + expect(mockClerkUICtor).toHaveBeenCalledTimes(1); + expect(mockClientFetch).toHaveBeenCalledTimes(1); + expect(mockEnvironmentFetch).toHaveBeenCalledTimes(1); + expect(mockClerkUICtor.mock.calls[0]?.[2]).toEqual( + expect.objectContaining({ + appearance, + routerPush: nextRouterPush, + }), + ); + expect(sut.__internal_getOption('appearance')).toBeUndefined(); + expect(sut.__internal_getOption('routerPush')).toBe(mockedLoadOptions.routerPush); + 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 a762311e98e..fd306522daf 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -137,7 +137,7 @@ import type { WaitlistResource, Web3Provider, } from '@clerk/shared/types'; -import type { ClerkUI } from '@clerk/shared/ui'; +import type { ClerkUI, ClerkUIConstructor } from '@clerk/shared/ui'; import { addClerkPrefix, isAbsoluteUrl, stripScheme } from '@clerk/shared/url'; import { allSettled, handleValueOrFn, noop } from '@clerk/shared/utils'; import type { QueryClient } from '@tanstack/query-core'; @@ -530,18 +530,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 +607,39 @@ export class Clerk implements ClerkInterface { } }; + __internal_attachClerkUI = ( + ClerkUI: ClerkUIConstructor | Promise, + options?: ClerkOptions, + ): void => { + if (this.#clerkUI) { + return; + } + + const uiOptions = this.#initOptions({ + ...this.#options, + ...options, + ui: { ...this.#options.ui, ...options?.ui, ClerkUI }, + }); + + this.#initClerkUI(uiOptions); + }; + + #initClerkUI = (options = this.#options): void => { + if (this.#clerkUI || !options.ui?.ClerkUI) { + return; + } + + this.#clerkUI = Promise.resolve(options.ui.ClerkUI).then( + ClerkUI => + new ClerkUI( + () => this, + () => this.environment, + 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..f0d5073b90f 100644 --- a/packages/react/src/__tests__/isomorphicClerk.test.ts +++ b/packages/react/src/__tests__/isomorphicClerk.test.ts @@ -243,6 +243,41 @@ describe('isomorphicClerk', () => { ); }); + it('attaches UI before replaying premounts when global Clerk is already loaded', async () => { + const mockLoad = vi.fn().mockResolvedValue(undefined); + const mockAttachClerkUI = vi.fn(); + const mockMountUserButton = vi.fn(); + const mockClerkInstance = { + load: mockLoad, + loaded: true, + status: 'ready', + __internal_attachClerkUI: mockAttachClerkUI, + 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).not.toHaveBeenCalled(); + expect(mockAttachClerkUI).toHaveBeenCalledWith( + (global as any).__internal_ClerkUICtor, + expect.objectContaining({ + ui: expect.objectContaining({ + ClerkUI: (global as any).__internal_ClerkUICtor, + }), + }), + ); + expect(mockMountUserButton).toHaveBeenCalledWith(node, undefined); + expect(mockAttachClerkUI.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..e63e5d82905 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -512,16 +512,26 @@ 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) { + const options = { ...this.options, ui: { ...this.options.ui, ClerkUI } }; + if (clerk.__internal_attachClerkUI) { + clerk.__internal_attachClerkUI(ClerkUI, options); + } else { + await clerk.load(options); + } + } } if (clerk.loaded) { this.replayInterceptedInvocations(clerk); diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index 9a2c4a015d6..505ea2ad1fb 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -281,6 +281,14 @@ export interface Clerk { */ __internal_getOption(key: K): ClerkOptions[K]; + /** + * @internal + */ + __internal_attachClerkUI?: ( + ClerkUI: ClerkUIConstructor | Promise, + options?: ClerkOptions, + ) => void; + frontendApi: string; /** Your Clerk [Publishable Key](!publishable-key). */