Skip to content

Commit 28b77ac

Browse files
authored
fix(electron,shared,ui): keep Clerk navigation inside the Electron renderer (#9530)
1 parent 46bf7ce commit 28b77ac

10 files changed

Lines changed: 216 additions & 6 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@clerk/electron': patch
3+
'@clerk/shared': patch
4+
'@clerk/ui': patch
5+
---
6+
7+
Keep Clerk's navigation inside the renderer. `ClerkProvider` now always supplies `routerPush`/`routerReplace`, so Clerk routes through your application's router when you provide one, and never navigates the window to an internal `/CLERK-ROUTER/VIRTUAL/...` path — which no custom protocol handler can serve, and which reloaded the renderer and dropped the user out of sign-in.
8+
9+
Applications that worked around this by passing no-op router functions, or by filtering `CLERK-ROUTER/VIRTUAL` out themselves, can remove those workarounds.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/ui': patch
3+
---
4+
5+
Route an OAuth transfer to the sign-up continue step when sign-in uses a native OAuth transport. The callback previously navigated with hash-style URLs (`<sign-up-url>#/continue`) that the in-place component router cannot resolve, landing transferred sign-ups on the start card where submitting created a fresh sign-up without the verified external account.

‎integration/templates/electron-vite/src/main.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ function App() {
1212
<ClerkProvider
1313
publishableKey={PUBLISHABLE_KEY}
1414
__internal_clerkUIUrl={CLERK_UI_URL}
15-
routerPush={() => {}}
16-
routerReplace={() => {}}
15+
routerPush={(to: string) => window.history.pushState(null, '', to)}
16+
routerReplace={(to: string) => window.history.replaceState(null, '', to)}
1717
>
1818
<main data-testid='electron-app'>
1919
<Show when='signed-out'>

‎integration/tests/electron/basic.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ type ElectronWindow = Window & {
1010
tokenCache?: Partial<Record<'clearToken' | 'getToken' | 'saveToken', unknown>>;
1111
oauthTransport?: Partial<Record<'getRedirectUrl' | 'open', unknown>>;
1212
};
13+
__hardNavigations?: string[];
1314
};
1415

1516
test.describe('electron basic auth @electron', () => {
@@ -74,4 +75,24 @@ test.describe('electron basic auth @electron', () => {
7475
test('keeps the signed-out state after relaunch', async ({ electronPage }) => {
7576
await expect(electronPage.locator('.cl-signIn-root')).toBeVisible({ timeout: 30_000 });
7677
});
78+
79+
test('never hard navigates the renderer during sign-in', async ({ electronPage }) => {
80+
const { signIn } = createPageObjects({ page: electronPage, useTestingToken: false });
81+
82+
await electronPage.evaluate(() => {
83+
(window as ElectronWindow).__hardNavigations = [];
84+
addEventListener('clerk:beforeunload', () => {
85+
(window as ElectronWindow).__hardNavigations?.push(location.href);
86+
});
87+
});
88+
89+
await signIn.waitForMounted();
90+
await signIn.setIdentifier(fakeUser.email!);
91+
await signIn.continue();
92+
await signIn.setPassword(fakeUser.password);
93+
await signIn.continue();
94+
95+
await expect(electronPage.locator('[data-testid="user-id"]')).toHaveText(/^user_/, { timeout: 30_000 });
96+
await expect(electronPage.evaluate(() => (window as ElectronWindow).__hardNavigations)).resolves.toEqual([]);
97+
});
7798
});

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

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,77 @@ describe('Electron ClerkProvider', () => {
9999
});
100100
});
101101

102+
describe('router handlers', () => {
103+
const renderWithRouter = (props: Record<string, unknown> = {}) => {
104+
renderToStaticMarkup(
105+
<ClerkProvider
106+
publishableKey='pk_test_provider'
107+
{...props}
108+
>
109+
<span>App</span>
110+
</ClerkProvider>,
111+
);
112+
113+
return {
114+
routerPush: capturedProviderProps?.routerPush as (to: string, metadata?: unknown) => void,
115+
routerReplace: capturedProviderProps?.routerReplace as (to: string, metadata?: unknown) => void,
116+
};
117+
};
118+
119+
it('always supplies both handlers so clerk-js never falls back to a window navigation', () => {
120+
const { routerPush, routerReplace } = renderWithRouter();
121+
122+
expect(routerPush).toBeTypeOf('function');
123+
expect(routerReplace).toBeTypeOf('function');
124+
});
125+
126+
it('absorbs virtual router paths instead of forwarding them to the application router', () => {
127+
const push = vi.fn();
128+
const replace = vi.fn();
129+
const windowNavigate = vi.fn();
130+
const { routerPush, routerReplace } = renderWithRouter({ routerPush: push, routerReplace: replace });
131+
132+
routerPush('/CLERK-ROUTER/VIRTUAL/sign-up#/continue', { windowNavigate });
133+
routerReplace('/CLERK-ROUTER/VIRTUAL/sign-in#/factor-two', { windowNavigate });
134+
135+
expect(push).not.toHaveBeenCalled();
136+
expect(replace).not.toHaveBeenCalled();
137+
expect(windowNavigate).not.toHaveBeenCalled();
138+
});
139+
140+
it('absorbs virtual router paths even without an application router', () => {
141+
const windowNavigate = vi.fn();
142+
const { routerPush } = renderWithRouter();
143+
144+
routerPush('/CLERK-ROUTER/VIRTUAL/sign-up#/continue', { windowNavigate });
145+
146+
expect(windowNavigate).not.toHaveBeenCalled();
147+
});
148+
149+
it('forwards real destinations to the application router', () => {
150+
const push = vi.fn();
151+
const replace = vi.fn();
152+
const windowNavigate = vi.fn();
153+
const { routerPush, routerReplace } = renderWithRouter({ routerPush: push, routerReplace: replace });
154+
155+
routerPush('/settings/connections', { windowNavigate });
156+
routerReplace('/dashboard', { windowNavigate });
157+
158+
expect(push).toHaveBeenCalledWith('/settings/connections', { windowNavigate });
159+
expect(replace).toHaveBeenCalledWith('/dashboard', { windowNavigate });
160+
expect(windowNavigate).not.toHaveBeenCalled();
161+
});
162+
163+
it('falls back to the host navigation for real destinations when no application router is provided', () => {
164+
const windowNavigate = vi.fn();
165+
const { routerPush } = renderWithRouter();
166+
167+
routerPush('/settings/connections', { windowNavigate });
168+
169+
expect(windowNavigate).toHaveBeenCalledWith('/settings/connections');
170+
});
171+
});
172+
102173
it('defaults allowedRedirectProtocols to the renderer custom scheme', () => {
103174
stubWindowProtocol('clerk:');
104175

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ClerkProviderProps as ReactClerkProviderProps } from '@clerk/react';
22
import { InternalClerkProvider as ReactClerkProvider } from '@clerk/react/internal';
3+
import { isVirtualRouterPath } from '@clerk/shared/internal/clerk-js/url';
34
import { ALLOWED_PROTOCOLS } from '@clerk/shared/internal/clerk-js/windowNavigate';
45
import { loadClerkUIScript } from '@clerk/shared/loadClerkJsScript';
56
import type { ClerkUIConstructor } from '@clerk/shared/ui';
@@ -79,6 +80,30 @@ function createOAuthTransport(): ClerkOAuthTransport | undefined {
7980
};
8081
}
8182

83+
type ClerkRouterFn = NonNullable<ReactClerkProviderProps['routerPush']>;
84+
85+
/** Always supplied, so clerk-js never reaches `window.location` and reloads the renderer. */
86+
function createRouterHandlers(
87+
routerPush: ClerkRouterFn | undefined,
88+
routerReplace: ClerkRouterFn | undefined,
89+
): { routerPush: ClerkRouterFn; routerReplace: ClerkRouterFn } {
90+
const wrap =
91+
(delegate: ClerkRouterFn | undefined): ClerkRouterFn =>
92+
(to, metadata) => {
93+
if (isVirtualRouterPath(to)) {
94+
return;
95+
}
96+
97+
if (delegate) {
98+
return delegate(to, metadata);
99+
}
100+
101+
metadata?.windowNavigate(to);
102+
};
103+
104+
return { routerPush: wrap(routerPush), routerReplace: wrap(routerReplace) };
105+
}
106+
82107
/**
83108
* Infer the custom renderer scheme registered with `createClerkBridge({ renderer })`.
84109
* Built-in Clerk protocols and local file renderers are not inferred.
@@ -98,15 +123,19 @@ export function ClerkProvider({
98123
publishableKey,
99124
passkeys,
100125
allowedRedirectProtocols,
126+
routerPush,
127+
routerReplace,
101128
...props
102129
}: ClerkProviderProps): JSX.Element {
103130
const clerk = createClerkInstance(publishableKey, passkeys);
104131
const oauthTransport = createOAuthTransport();
105132
const clerkUI = loadClerkUI(publishableKey, props);
133+
const routerHandlers = createRouterHandlers(routerPush, routerReplace);
106134

107135
return (
108136
<ReactClerkProvider
109137
{...props}
138+
{...routerHandlers}
110139
Clerk={clerk}
111140
__internal_oauthTransport={oauthTransport}
112141
allowedRedirectProtocols={allowedRedirectProtocols ?? defaultAllowedRedirectProtocols()}

‎packages/shared/src/internal/clerk-js/url.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,13 @@ export const pathFromFullPath = (fullPath: string) => {
398398
return fullPath.replace(/CLERK-ROUTER\/(.*?)\//, '');
399399
};
400400

401+
export const VIRTUAL_ROUTER_BASE_PATH = 'CLERK-ROUTER/VIRTUAL';
402+
403+
/**
404+
* Whether `to` addresses the in-memory component router rather than a real application route.
405+
*/
406+
export const isVirtualRouterPath = (to: string): boolean => to.includes(VIRTUAL_ROUTER_BASE_PATH);
407+
401408
const frontendApiRedirectPathsWithUserInput: string[] = [
402409
'/oauth/authorize', // OAuth2 identify provider flow
403410
];

‎packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts‎

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,22 +55,72 @@ describe('buildSignInOAuthTransportCallbackParams', () => {
5555
unsafeMetadata: { a: 1 },
5656
} as any;
5757

58+
const origin = window.location.origin;
59+
5860
expect(buildSignInOAuthTransportCallbackParams(ctx)).toEqual({
5961
signUpUrl: '/sign-up',
6062
signInUrl: '/sign-in',
6163
signInForceRedirectUrl: '/after-in',
6264
signUpForceRedirectUrl: '/after-up',
63-
continueSignUpUrl: '/continue',
6465
transferable: true,
6566
firstFactorUrl: 'factor-one',
6667
secondFactorUrl: 'factor-two',
6768
resetPasswordUrl: 'reset-password',
68-
// Relative to the SignIn start route; the sign-up gate URL stays absolute (combined-aware).
6969
signInProtectCheckUrl: 'protect-check',
70-
signUpProtectCheckUrl: '/sign-up-protect-check',
70+
// Sign-up steps are path routes on the sign-up component; hash-style URLs would lose their
71+
// hash in the virtual router and land a transferred sign-up on the start card.
72+
continueSignUpUrl: `${origin}/sign-up/continue`,
73+
verifyEmailAddressUrl: `${origin}/sign-up/verify-email-address`,
74+
verifyPhoneNumberUrl: `${origin}/sign-up/verify-phone-number`,
75+
signUpProtectCheckUrl: `${origin}/sign-up/protect-check`,
7176
unsafeMetadata: { a: 1 },
7277
});
7378
});
79+
80+
it('targets the virtual sign-up routes for modal transport callbacks', () => {
81+
const ctx = {
82+
signUpUrl: '/CLERK-ROUTER/VIRTUAL/sign-up',
83+
signInUrl: '/CLERK-ROUTER/VIRTUAL/sign-in',
84+
} as any;
85+
86+
const params = buildSignInOAuthTransportCallbackParams(ctx);
87+
const origin = window.location.origin;
88+
89+
expect(params.continueSignUpUrl).toBe(`${origin}/CLERK-ROUTER/VIRTUAL/sign-up/continue`);
90+
expect(params.verifyEmailAddressUrl).toBe(`${origin}/CLERK-ROUTER/VIRTUAL/sign-up/verify-email-address`);
91+
expect(params.verifyPhoneNumberUrl).toBe(`${origin}/CLERK-ROUTER/VIRTUAL/sign-up/verify-phone-number`);
92+
expect(params.signUpProtectCheckUrl).toBe(`${origin}/CLERK-ROUTER/VIRTUAL/sign-up/protect-check`);
93+
});
94+
95+
it('drops a hash fragment from signUpUrl when building sign-up step URLs', () => {
96+
const ctx = {
97+
signUpUrl: '/sign-up#/continue',
98+
signInUrl: '/sign-in',
99+
} as any;
100+
101+
const params = buildSignInOAuthTransportCallbackParams(ctx);
102+
const origin = window.location.origin;
103+
104+
expect(params.continueSignUpUrl).toBe(`${origin}/sign-up/continue`);
105+
expect(params.verifyEmailAddressUrl).toBe(`${origin}/sign-up/verify-email-address`);
106+
expect(params.verifyPhoneNumberUrl).toBe(`${origin}/sign-up/verify-phone-number`);
107+
expect(params.signUpProtectCheckUrl).toBe(`${origin}/sign-up/protect-check`);
108+
});
109+
110+
it('targets the embedded create subtree in the combined flow', () => {
111+
const ctx = {
112+
signUpUrl: '/sign-in#/create',
113+
signInUrl: '/sign-in',
114+
isCombinedFlow: true,
115+
} as any;
116+
117+
const params = buildSignInOAuthTransportCallbackParams(ctx);
118+
119+
expect(params.continueSignUpUrl).toBe('create/continue');
120+
expect(params.verifyEmailAddressUrl).toBe('create/verify-email-address');
121+
expect(params.verifyPhoneNumberUrl).toBe('create/verify-phone-number');
122+
expect(params.signUpProtectCheckUrl).toBe('create/protect-check');
123+
});
74124
});
75125

76126
describe('buildSignUpOAuthCallbackParams', () => {

‎packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { buildURL, trimTrailingSlash } from '@clerk/shared/internal/clerk-js/url';
12
import type { HandleOAuthCallbackParams } from '@clerk/shared/types';
23

34
import type { SignInContextType } from '../../contexts/components/SignIn';
@@ -23,12 +24,27 @@ export function buildSignInOAuthCallbackParams(ctx: SignInContextType): HandleOA
2324
}
2425

2526
export function buildSignInOAuthTransportCallbackParams(ctx: SignInContextType): HandleOAuthCallbackParams {
27+
// Path form, not `#/step`: the in-place component router matches on pathname only and would drop the hash.
28+
const signUpStepUrl = (step: string): string => {
29+
if (ctx.isCombinedFlow) {
30+
return `create/${step}`;
31+
}
32+
const url = buildURL({ base: ctx.signUpUrl }, { stringify: false });
33+
url.pathname = `${trimTrailingSlash(url.pathname)}/${step}`;
34+
url.hash = '';
35+
return url.href;
36+
};
37+
2638
return {
2739
...buildSignInOAuthCallbackParams(ctx),
2840
firstFactorUrl: 'factor-one',
2941
secondFactorUrl: 'factor-two',
3042
resetPasswordUrl: 'reset-password',
3143
signInProtectCheckUrl: 'protect-check',
44+
continueSignUpUrl: signUpStepUrl('continue'),
45+
verifyEmailAddressUrl: signUpStepUrl('verify-email-address'),
46+
verifyPhoneNumberUrl: signUpStepUrl('verify-phone-number'),
47+
signUpProtectCheckUrl: signUpStepUrl('protect-check'),
3248
};
3349
}
3450

‎packages/ui/src/router/VirtualRouter.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import { VIRTUAL_ROUTER_BASE_PATH } from '@clerk/shared/internal/clerk-js/url';
12
import { useClerk } from '@clerk/shared/react';
23
import React, { useEffect } from 'react';
34

45
import { useClerkModalStateParams } from '../hooks';
56
import { BaseRouter } from './BaseRouter';
6-
export const VIRTUAL_ROUTER_BASE_PATH = 'CLERK-ROUTER/VIRTUAL';
7+
8+
export { VIRTUAL_ROUTER_BASE_PATH };
79

810
interface VirtualRouterProps {
911
startPath: string;

0 commit comments

Comments
 (0)