Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/preloaded-clerk-ui-attachment.md
Original file line number Diff line number Diff line change
@@ -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.
40 changes: 40 additions & 0 deletions packages/clerk-js/src/core/__tests__/clerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
48 changes: 35 additions & 13 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -618,6 +607,39 @@ export class Clerk implements ClerkInterface {
}
};

__internal_attachClerkUI = (
ClerkUI: ClerkUIConstructor | Promise<ClerkUIConstructor>,
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));
}
Expand Down
35 changes: 35 additions & 0 deletions packages/react/src/__tests__/isomorphicClerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 15 additions & 5 deletions packages/react/src/isomorphicClerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions packages/shared/src/types/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,14 @@ export interface Clerk {
*/
__internal_getOption<K extends keyof ClerkOptions>(key: K): ClerkOptions[K];

/**
* @internal
*/
__internal_attachClerkUI?: (
ClerkUI: ClerkUIConstructor | Promise<ClerkUIConstructor>,
options?: ClerkOptions,
) => void;

frontendApi: string;

/** Your Clerk [Publishable Key](!publishable-key). */
Expand Down
Loading