Skip to content

Commit 1ef1b8a

Browse files
authored
chore(electron): Hotload UI (#8888)
1 parent 235c6e7 commit 1ef1b8a

6 files changed

Lines changed: 175 additions & 69 deletions

File tree

‎packages/electron/README.md‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,69 @@ import { ClerkProvider } from '@clerk/electron/react';
8888
<ClerkProvider publishableKey={import.meta.env.VITE_CLERK_PUBLISHABLE_KEY}>{/* ... */}</ClerkProvider>;
8989
```
9090

91+
## Content Security Policy
92+
93+
`@clerk/electron` loads Clerk's prebuilt UI from Clerk's CDN at runtime rather than bundling it, so your renderer's Content Security Policy must allow Clerk's Frontend API host. If it doesn't, the UI script fails to load and Clerk components never render.
94+
95+
Replace `{fapi_host}` below with your instance's **Frontend API** host, found in the [Clerk Dashboard](https://dashboard.clerk.com) under **API keys**. It includes a `clerk.` segment (for example, `clerk.your-app.com` in production or `your-slug.clerk.accounts.dev` in development). This is _not_ the Account Portal URL (`your-slug.accounts.dev`) — using that host blocks the UI script from loading.
96+
97+
```
98+
default-src 'self';
99+
script-src 'self' 'unsafe-inline' https://{fapi_host} https://challenges.cloudflare.com;
100+
connect-src 'self' https://{fapi_host};
101+
img-src 'self' https://img.clerk.com data:;
102+
style-src 'self' 'unsafe-inline';
103+
worker-src 'self' blob:;
104+
frame-src 'self' https://challenges.cloudflare.com;
105+
form-action 'self';
106+
```
107+
108+
> [!NOTE]
109+
> This covers sign-in/up and the hotloaded UI. If you use Clerk Billing (Stripe) or other features, you'll need to allow additional origins; see Clerk's [CSP guide](https://clerk.com/docs/guides/secure/best-practices/csp-headers) for the full list.
110+
111+
Apply it either with a `<meta>` tag in your renderer HTML:
112+
113+
```html
114+
<meta
115+
http-equiv="Content-Security-Policy"
116+
content="default-src 'self'; script-src 'self' 'unsafe-inline' https://{fapi_host} https://challenges.cloudflare.com; connect-src 'self' https://{fapi_host}; img-src 'self' https://img.clerk.com data:; style-src 'self' 'unsafe-inline'; worker-src 'self' blob:; frame-src 'self' https://challenges.cloudflare.com; form-action 'self';"
117+
/>
118+
```
119+
120+
or, as [Electron's security guide](https://www.electronjs.org/docs/latest/tutorial/security#7-define-a-content-security-policy) recommends, as a response header from the main process:
121+
122+
```ts
123+
// main.ts
124+
import { app, session } from 'electron';
125+
126+
const fapiHost = 'clerk.your-app.com';
127+
128+
app.whenReady().then(() => {
129+
session.defaultSession.webRequest.onHeadersReceived((details, callback) => {
130+
callback({
131+
responseHeaders: {
132+
...details.responseHeaders,
133+
'Content-Security-Policy': [
134+
[
135+
"default-src 'self'",
136+
`script-src 'self' 'unsafe-inline' https://${fapiHost} https://challenges.cloudflare.com`,
137+
`connect-src 'self' https://${fapiHost}`,
138+
"img-src 'self' https://img.clerk.com data:",
139+
"style-src 'self' 'unsafe-inline'",
140+
"worker-src 'self' blob:",
141+
"frame-src 'self' https://challenges.cloudflare.com",
142+
"form-action 'self'",
143+
].join('; '),
144+
],
145+
},
146+
});
147+
});
148+
});
149+
```
150+
151+
> [!NOTE]
152+
> Loading the renderer from a dev server (such as Vite) requires looser rules for HMR: add `'unsafe-eval'` to `script-src` and your dev server's origin to `connect-src` (for example, `ws://localhost:<port> http://localhost:<port>`). Many apps skip CSP during development and apply it only to packaged builds.
153+
91154
## Support
92155

93156
For help, visit our [support page](https://clerk.com/contact/support?utm_source=github&utm_medium=clerk_electron).

‎packages/electron/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@
9090
"dependencies": {
9191
"@clerk/clerk-js": "workspace:^",
9292
"@clerk/react": "workspace:^",
93-
"@clerk/ui": "workspace:^",
93+
"@clerk/shared": "workspace:^",
9494
"tslib": "catalog:repo"
9595
},
9696
"devDependencies": {

‎packages/electron/src/global.d.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { ClerkUIConstructor } from '@clerk/shared/ui';
2+
13
import type { OAuthTransport, TokenCache } from './shared/types';
24

35
declare const PACKAGE_NAME: string;
@@ -10,5 +12,6 @@ declare global {
1012
tokenCache: TokenCache;
1113
oauthTransport: OAuthTransport;
1214
};
15+
__internal_ClerkUICtor?: ClerkUIConstructor;
1316
}
1417
}

‎packages/electron/src/react/__tests__/ClerkProvider.test.tsx‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ let beforeRequest:
1010
let afterResponse: ((request: unknown, response: Response) => Promise<void>) | null = null;
1111

1212
const clerkConstructor = vi.hoisted(() => vi.fn());
13+
const loadClerkUIScript = vi.hoisted(() => vi.fn());
1314

1415
vi.mock('@clerk/clerk-js', () => ({
1516
Clerk: class MockClerk {
@@ -34,8 +35,9 @@ vi.mock('@clerk/react/internal', () => ({
3435
},
3536
}));
3637

37-
vi.mock('@clerk/ui', () => ({
38-
ui: { ClerkUI: 'mock-ui' },
38+
vi.mock('@clerk/shared/loadClerkJsScript', async importOriginal => ({
39+
...(await importOriginal<Record<string, unknown>>()),
40+
loadClerkUIScript,
3941
}));
4042

4143
describe('Electron ClerkProvider', () => {
@@ -60,6 +62,11 @@ describe('Electron ClerkProvider', () => {
6062
oauthTransport,
6163
},
6264
});
65+
// Resolve the UI hotload so the provider's `ui.ClerkUI` promise does not reject during render.
66+
loadClerkUIScript.mockImplementation(() => {
67+
(window as unknown as { __internal_ClerkUICtor?: unknown }).__internal_ClerkUICtor = 'mock-ui-ctor';
68+
return Promise.resolve(null);
69+
});
6370
});
6471

6572
it('renders React ClerkProvider with Electron defaults', () => {
@@ -77,7 +84,6 @@ describe('Electron ClerkProvider', () => {
7784
publishableKey: 'pk_test_provider',
7885
signInUrl: '/sign-in',
7986
standardBrowser: false,
80-
ui: { ClerkUI: 'mock-ui' },
8187
});
8288
expect(capturedProviderProps?.Clerk).toBeDefined();
8389
expect(capturedProviderProps?.__internal_oauthTransport).toEqual({

‎packages/electron/src/react/index.tsx‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { ClerkProviderProps as ReactClerkProviderProps } from '@clerk/react';
22
import { InternalClerkProvider as ReactClerkProvider } from '@clerk/react/internal';
3-
import { ui } from '@clerk/ui';
3+
import { loadClerkUIScript } from '@clerk/shared/loadClerkJsScript';
4+
import type { ClerkUIConstructor } from '@clerk/shared/ui';
45
import type { ReactNode } from 'react';
56

67
import { createClerkInstance } from './create-clerk-instance';
@@ -18,6 +19,39 @@ export type ClerkProviderProps = Omit<
1819
publishableKey: string;
1920
};
2021

22+
let cachedClerkUI: { promise: Promise<ClerkUIConstructor>; publishableKey: string } | null = null;
23+
24+
function loadClerkUI(publishableKey: string, props: Partial<ClerkProviderProps>): Promise<ClerkUIConstructor> {
25+
if (cachedClerkUI?.publishableKey === publishableKey) {
26+
return cachedClerkUI.promise;
27+
}
28+
29+
// Undocumented escape hatch for self-hosting/proxying the UI bundle; not part of the public props.
30+
const { __internal_clerkUIUrl, __internal_clerkUIVersion } = props as {
31+
__internal_clerkUIUrl?: string;
32+
__internal_clerkUIVersion?: string;
33+
};
34+
35+
const promise = loadClerkUIScript({
36+
publishableKey,
37+
proxyUrl: typeof props.proxyUrl === 'string' ? props.proxyUrl : undefined,
38+
domain: typeof props.domain === 'string' ? props.domain : undefined,
39+
nonce: props.nonce,
40+
__internal_clerkUIUrl,
41+
__internal_clerkUIVersion,
42+
}).then(() => {
43+
if (!window.__internal_ClerkUICtor) {
44+
throw new Error(
45+
'Clerk: Failed to load Clerk UI from the CDN. Ensure your Content Security Policy allows the Clerk Frontend API host in `script-src`. Contact support@clerk.com.',
46+
);
47+
}
48+
return window.__internal_ClerkUICtor;
49+
});
50+
51+
cachedClerkUI = { promise, publishableKey };
52+
return promise;
53+
}
54+
2155
function createOAuthTransport(): ClerkOAuthTransport | undefined {
2256
const bridge = window.__clerk_internal_electron?.oauthTransport;
2357

@@ -34,6 +68,7 @@ function createOAuthTransport(): ClerkOAuthTransport | undefined {
3468
export function ClerkProvider({ children, publishableKey, ...props }: ClerkProviderProps): JSX.Element {
3569
const clerk = createClerkInstance(publishableKey);
3670
const oauthTransport = createOAuthTransport();
71+
const clerkUI = loadClerkUI(publishableKey, props);
3772

3873
return (
3974
<ReactClerkProvider
@@ -42,7 +77,7 @@ export function ClerkProvider({ children, publishableKey, ...props }: ClerkProvi
4277
__internal_oauthTransport={oauthTransport}
4378
publishableKey={publishableKey}
4479
standardBrowser={false}
45-
ui={ui}
80+
ui={{ ClerkUI: clerkUI }}
4681
>
4782
{children}
4883
</ReactClerkProvider>

0 commit comments

Comments
 (0)