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
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 @@ -472,8 +472,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