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.
28 changes: 28 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,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);
Expand Down
36 changes: 24 additions & 12 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,13 @@ export class Clerk implements ClerkInterface {
public load = async (options?: ClerkOptions): Promise<void> => {
debugLogger.info('load() start', {}, 'clerk');
if (this.loaded) {
const legacy = options as Record<string, unknown> | 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;
Comment on lines +524 to +525
Copy link
Copy Markdown
Member

@Ephem Ephem May 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part worries me a bit. After the initial load, the options have always been immutable before, now they can theoretically change, but they aren't reactive so if we've read them anywhere already, those values might now be stale (imagine clerk-js loads -> components in the host app rely on some options via the hooks -> ui loads with other options).

Supporting options updating for real with reactivity would be a huge pain for no real gain.

I think a clean alternative is to lift this to an __internal_attachUi(ui) function that's only callable after the initial load?

Internal only to avoid increasing the API surface needlessly for an advanced feature only we are likely to use, could make it public if there is demand.

this.#initClerkUI();
}
return;
}

Expand All @@ -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 (
Expand Down Expand Up @@ -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));
}
Expand Down
29 changes: 29 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,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
Expand Down
15 changes: 10 additions & 5 deletions packages/react/src/isomorphicClerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading