Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/tricky-apples-attend.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/react': minor
---

The `ui` prop is now respected if a Clerk instance is passed via the `Clerk` prop to `IsomorphicClerk`. This fixes the 'Clerk was not loaded with Ui components' error in the Chrome Extension SDK.
189 changes: 189 additions & 0 deletions packages/react/src/__tests__/isomorphicClerk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,4 +200,193 @@ describe('isomorphicClerk', () => {
expect(result).toBe(mockClerkUI);
});
});

describe('shouldLoadUi across SDK scenarios', () => {
// Helper to run getEntryChunks and return what clerk.load was called with
async function runGetEntryChunks(options: Record<string, any>) {
const mockLoad = vi.fn().mockResolvedValue(undefined);
const mockClerkInstance = options.Clerk || {
load: mockLoad,
loaded: false,
};
if (options.Clerk) {
options.Clerk.load = mockLoad;
options.Clerk.loaded = false;
}

(global as any).Clerk = mockClerkInstance;

const clerk = new IsomorphicClerk({
publishableKey: 'pk_test_XXX',
...options,
});

await (clerk as any).getEntryChunks();

return { mockLoad };
}

// ─── @clerk/react, @clerk/nextjs, @clerk/react-router, @clerk/tanstack-react-start ───
// These SDKs: no Clerk prop, no ui prop, standardBrowser omitted (undefined)
// shouldLoadUi = (undefined !== false && !undefined) || !!undefined = (true && true) || false = true
// → loads UI from CDN
it('loads UI from CDN when no Clerk, no ui, standardBrowser omitted (nextjs/react-router/tanstack)', async () => {
const { mockLoad } = await runGetEntryChunks({});

expect(loadClerkUIScript).toHaveBeenCalled();
expect(mockLoad).toHaveBeenCalledWith(
expect.objectContaining({
ui: expect.objectContaining({
ClerkUI: (global as any).__internal_ClerkUICtor,
}),
}),
);
});

// ─── @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
// → getClerkUIEntryChunk returns the bundled ClerkUI (no CDN)
it('uses bundled ClerkUI when ui prop is passed without Clerk instance (react with ui prop)', async () => {
const mockClerkUI = vi.fn();
const { mockLoad } = await runGetEntryChunks({
ui: { ClerkUI: mockClerkUI },
});

expect(loadClerkUIScript).not.toHaveBeenCalled();
expect(mockLoad).toHaveBeenCalledWith(
expect.objectContaining({
ui: expect.objectContaining({
ClerkUI: mockClerkUI,
}),
}),
);
});

// ─── @clerk/expo (native mode) ───
// Expo native: Clerk instance, no ui prop, standardBrowser: false
// shouldLoadUi = (false !== false && ...) || !!undefined = false || false = false
// → no UI loaded (correct: native apps don't render prebuilt UI)
it('does not load UI for Expo native (Clerk instance, no ui, standardBrowser: false)', async () => {
const mockClerkInstance = {} as any;
const { mockLoad } = await runGetEntryChunks({
Clerk: mockClerkInstance,
standardBrowser: false,
});

expect(loadClerkUIScript).not.toHaveBeenCalled();
expect(mockLoad).toHaveBeenCalledWith(
expect.objectContaining({
ui: expect.objectContaining({
ClerkUI: undefined,
}),
}),
);
});

// ─── @clerk/expo (web mode) ───
// Expo web: Clerk is null, no ui prop, standardBrowser: true
// shouldLoadUi = (true !== false && !null) || false = (true && true) || false = true
// → loads UI from CDN (correct: web mode uses normal browser flow)
it('loads UI from CDN for Expo web (Clerk: null, standardBrowser: true)', async () => {
const { mockLoad } = await runGetEntryChunks({
Clerk: null,
standardBrowser: true,
});

expect(loadClerkUIScript).toHaveBeenCalled();
expect(mockLoad).toHaveBeenCalledWith(
expect.objectContaining({
ui: expect.objectContaining({
ClerkUI: (global as any).__internal_ClerkUICtor,
}),
}),
);
});

// ─── @clerk/chrome-extension (without syncHost) ───
// No syncHost: Clerk instance, ui with ClerkUI, standardBrowser: true
// shouldLoadUi = (true && !instance) || true = false || true = true
// → uses bundled ClerkUI (no CDN)
it('uses bundled ClerkUI for chrome-extension without syncHost (standardBrowser: true)', async () => {
const mockClerkUI = vi.fn();
const mockClerkInstance = {} as any;
const { mockLoad } = await runGetEntryChunks({
Clerk: mockClerkInstance,
ui: { ClerkUI: mockClerkUI },
standardBrowser: true,
});

expect(loadClerkUIScript).not.toHaveBeenCalled();
expect(mockLoad).toHaveBeenCalledWith(
expect.objectContaining({
ui: expect.objectContaining({
ClerkUI: mockClerkUI,
}),
}),
);
});

// ─── @clerk/chrome-extension (with syncHost) ───
// With syncHost: Clerk instance, ui with ClerkUI, standardBrowser: false
// shouldLoadUi = (false !== false && ...) || !!ClerkUI = false || true = true
// → uses bundled ClerkUI (no CDN)
it('uses bundled ClerkUI for chrome-extension with syncHost (standardBrowser: false)', async () => {
const mockClerkUI = vi.fn();
const mockClerkInstance = {} as any;
const { mockLoad } = await runGetEntryChunks({
Clerk: mockClerkInstance,
ui: { ClerkUI: mockClerkUI },
standardBrowser: false,
});

expect(loadClerkUIScript).not.toHaveBeenCalled();
expect(mockLoad).toHaveBeenCalledWith(
expect.objectContaining({
ui: expect.objectContaining({
ClerkUI: mockClerkUI,
}),
}),
);
});

// ─── Clerk instance provided, no ui prop, standardBrowser: true ───
// shouldLoadUi = (true && !instance) || false = false || false = false
// → no UI loaded (correct: Clerk instance without bundled UI, no CDN attempt)
it('does not load UI when Clerk instance provided without ui prop (standardBrowser: true)', async () => {
const mockClerkInstance = {} as any;
const { mockLoad } = await runGetEntryChunks({
Clerk: mockClerkInstance,
standardBrowser: true,
});

expect(loadClerkUIScript).not.toHaveBeenCalled();
expect(mockLoad).toHaveBeenCalledWith(
expect.objectContaining({
ui: expect.objectContaining({
ClerkUI: undefined,
}),
}),
);
});

// ─── ui prop passed as server marker (no ClerkUI), no Clerk instance ───
// RSC react-server export may provide ui without ClerkUI initially
// shouldLoadUi = (true && true) || false = true
// → getClerkUIEntryChunk is called, but uiProp exists without ClerkUI → returns undefined (skips CDN)
it('skips CDN when ui prop exists without ClerkUI (server marker object)', async () => {
const { mockLoad } = await runGetEntryChunks({
ui: { __brand: '__clerkUI', version: '1.0.0' },
});

expect(loadClerkUIScript).not.toHaveBeenCalled();
expect(mockLoad).toHaveBeenCalledWith(
expect.objectContaining({
ui: expect.objectContaining({
ClerkUI: undefined,
}),
}),
);
});
});
});
7 changes: 5 additions & 2 deletions packages/react/src/isomorphicClerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,8 +514,11 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {

if (!clerk.loaded) {
this.beforeLoad(clerk);
// Only load UI scripts in standard browser environments (not native/headless)
const shouldLoadUi = !this.options.Clerk && this.options.standardBrowser !== false;
// 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 } });
}
Expand Down
Loading