diff --git a/.changeset/electron-virtual-router-navigation.md b/.changeset/electron-virtual-router-navigation.md new file mode 100644 index 00000000000..8340e26d4a5 --- /dev/null +++ b/.changeset/electron-virtual-router-navigation.md @@ -0,0 +1,9 @@ +--- +'@clerk/electron': patch +'@clerk/shared': patch +'@clerk/ui': patch +--- + +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. + +Applications that worked around this by passing no-op router functions, or by filtering `CLERK-ROUTER/VIRTUAL` out themselves, can remove those workarounds. diff --git a/.changeset/signin-transport-transfer-next-step.md b/.changeset/signin-transport-transfer-next-step.md new file mode 100644 index 00000000000..b30ceeb005e --- /dev/null +++ b/.changeset/signin-transport-transfer-next-step.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +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 (`#/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. diff --git a/integration/templates/electron-vite/src/main.tsx b/integration/templates/electron-vite/src/main.tsx index fdb63d41444..9cb1720af92 100644 --- a/integration/templates/electron-vite/src/main.tsx +++ b/integration/templates/electron-vite/src/main.tsx @@ -12,8 +12,8 @@ function App() { {}} - routerReplace={() => {}} + routerPush={(to: string) => window.history.pushState(null, '', to)} + routerReplace={(to: string) => window.history.replaceState(null, '', to)} >
diff --git a/integration/tests/electron/basic.test.ts b/integration/tests/electron/basic.test.ts index bb32d902719..5bea8825a03 100644 --- a/integration/tests/electron/basic.test.ts +++ b/integration/tests/electron/basic.test.ts @@ -10,6 +10,7 @@ type ElectronWindow = Window & { tokenCache?: Partial>; oauthTransport?: Partial>; }; + __hardNavigations?: string[]; }; test.describe('electron basic auth @electron', () => { @@ -74,4 +75,24 @@ test.describe('electron basic auth @electron', () => { test('keeps the signed-out state after relaunch', async ({ electronPage }) => { await expect(electronPage.locator('.cl-signIn-root')).toBeVisible({ timeout: 30_000 }); }); + + test('never hard navigates the renderer during sign-in', async ({ electronPage }) => { + const { signIn } = createPageObjects({ page: electronPage, useTestingToken: false }); + + await electronPage.evaluate(() => { + (window as ElectronWindow).__hardNavigations = []; + addEventListener('clerk:beforeunload', () => { + (window as ElectronWindow).__hardNavigations?.push(location.href); + }); + }); + + await signIn.waitForMounted(); + await signIn.setIdentifier(fakeUser.email!); + await signIn.continue(); + await signIn.setPassword(fakeUser.password); + await signIn.continue(); + + await expect(electronPage.locator('[data-testid="user-id"]')).toHaveText(/^user_/, { timeout: 30_000 }); + await expect(electronPage.evaluate(() => (window as ElectronWindow).__hardNavigations)).resolves.toEqual([]); + }); }); diff --git a/packages/electron/src/react/__tests__/ClerkProvider.test.tsx b/packages/electron/src/react/__tests__/ClerkProvider.test.tsx index db19521fe50..a67d00d501c 100644 --- a/packages/electron/src/react/__tests__/ClerkProvider.test.tsx +++ b/packages/electron/src/react/__tests__/ClerkProvider.test.tsx @@ -99,6 +99,77 @@ describe('Electron ClerkProvider', () => { }); }); + describe('router handlers', () => { + const renderWithRouter = (props: Record = {}) => { + renderToStaticMarkup( + + App + , + ); + + return { + routerPush: capturedProviderProps?.routerPush as (to: string, metadata?: unknown) => void, + routerReplace: capturedProviderProps?.routerReplace as (to: string, metadata?: unknown) => void, + }; + }; + + it('always supplies both handlers so clerk-js never falls back to a window navigation', () => { + const { routerPush, routerReplace } = renderWithRouter(); + + expect(routerPush).toBeTypeOf('function'); + expect(routerReplace).toBeTypeOf('function'); + }); + + it('absorbs virtual router paths instead of forwarding them to the application router', () => { + const push = vi.fn(); + const replace = vi.fn(); + const windowNavigate = vi.fn(); + const { routerPush, routerReplace } = renderWithRouter({ routerPush: push, routerReplace: replace }); + + routerPush('/CLERK-ROUTER/VIRTUAL/sign-up#/continue', { windowNavigate }); + routerReplace('/CLERK-ROUTER/VIRTUAL/sign-in#/factor-two', { windowNavigate }); + + expect(push).not.toHaveBeenCalled(); + expect(replace).not.toHaveBeenCalled(); + expect(windowNavigate).not.toHaveBeenCalled(); + }); + + it('absorbs virtual router paths even without an application router', () => { + const windowNavigate = vi.fn(); + const { routerPush } = renderWithRouter(); + + routerPush('/CLERK-ROUTER/VIRTUAL/sign-up#/continue', { windowNavigate }); + + expect(windowNavigate).not.toHaveBeenCalled(); + }); + + it('forwards real destinations to the application router', () => { + const push = vi.fn(); + const replace = vi.fn(); + const windowNavigate = vi.fn(); + const { routerPush, routerReplace } = renderWithRouter({ routerPush: push, routerReplace: replace }); + + routerPush('/settings/connections', { windowNavigate }); + routerReplace('/dashboard', { windowNavigate }); + + expect(push).toHaveBeenCalledWith('/settings/connections', { windowNavigate }); + expect(replace).toHaveBeenCalledWith('/dashboard', { windowNavigate }); + expect(windowNavigate).not.toHaveBeenCalled(); + }); + + it('falls back to the host navigation for real destinations when no application router is provided', () => { + const windowNavigate = vi.fn(); + const { routerPush } = renderWithRouter(); + + routerPush('/settings/connections', { windowNavigate }); + + expect(windowNavigate).toHaveBeenCalledWith('/settings/connections'); + }); + }); + it('defaults allowedRedirectProtocols to the renderer custom scheme', () => { stubWindowProtocol('clerk:'); diff --git a/packages/electron/src/react/index.tsx b/packages/electron/src/react/index.tsx index 19a07449162..5dffcb43752 100644 --- a/packages/electron/src/react/index.tsx +++ b/packages/electron/src/react/index.tsx @@ -1,5 +1,6 @@ import type { ClerkProviderProps as ReactClerkProviderProps } from '@clerk/react'; import { InternalClerkProvider as ReactClerkProvider } from '@clerk/react/internal'; +import { isVirtualRouterPath } from '@clerk/shared/internal/clerk-js/url'; import { ALLOWED_PROTOCOLS } from '@clerk/shared/internal/clerk-js/windowNavigate'; import { loadClerkUIScript } from '@clerk/shared/loadClerkJsScript'; import type { ClerkUIConstructor } from '@clerk/shared/ui'; @@ -79,6 +80,30 @@ function createOAuthTransport(): ClerkOAuthTransport | undefined { }; } +type ClerkRouterFn = NonNullable; + +/** Always supplied, so clerk-js never reaches `window.location` and reloads the renderer. */ +function createRouterHandlers( + routerPush: ClerkRouterFn | undefined, + routerReplace: ClerkRouterFn | undefined, +): { routerPush: ClerkRouterFn; routerReplace: ClerkRouterFn } { + const wrap = + (delegate: ClerkRouterFn | undefined): ClerkRouterFn => + (to, metadata) => { + if (isVirtualRouterPath(to)) { + return; + } + + if (delegate) { + return delegate(to, metadata); + } + + metadata?.windowNavigate(to); + }; + + return { routerPush: wrap(routerPush), routerReplace: wrap(routerReplace) }; +} + /** * Infer the custom renderer scheme registered with `createClerkBridge({ renderer })`. * Built-in Clerk protocols and local file renderers are not inferred. @@ -98,15 +123,19 @@ export function ClerkProvider({ publishableKey, passkeys, allowedRedirectProtocols, + routerPush, + routerReplace, ...props }: ClerkProviderProps): JSX.Element { const clerk = createClerkInstance(publishableKey, passkeys); const oauthTransport = createOAuthTransport(); const clerkUI = loadClerkUI(publishableKey, props); + const routerHandlers = createRouterHandlers(routerPush, routerReplace); return ( { return fullPath.replace(/CLERK-ROUTER\/(.*?)\//, ''); }; +export const VIRTUAL_ROUTER_BASE_PATH = 'CLERK-ROUTER/VIRTUAL'; + +/** + * Whether `to` addresses the in-memory component router rather than a real application route. + */ +export const isVirtualRouterPath = (to: string): boolean => to.includes(VIRTUAL_ROUTER_BASE_PATH); + const frontendApiRedirectPathsWithUserInput: string[] = [ '/oauth/authorize', // OAuth2 identify provider flow ]; diff --git a/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts b/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts index b8f8b2cb088..1f960816b16 100644 --- a/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts +++ b/packages/ui/src/components/SignIn/__tests__/buildOAuthCallbackParams.test.ts @@ -55,22 +55,72 @@ describe('buildSignInOAuthTransportCallbackParams', () => { unsafeMetadata: { a: 1 }, } as any; + const origin = window.location.origin; + expect(buildSignInOAuthTransportCallbackParams(ctx)).toEqual({ signUpUrl: '/sign-up', signInUrl: '/sign-in', signInForceRedirectUrl: '/after-in', signUpForceRedirectUrl: '/after-up', - continueSignUpUrl: '/continue', transferable: true, firstFactorUrl: 'factor-one', secondFactorUrl: 'factor-two', resetPasswordUrl: 'reset-password', - // Relative to the SignIn start route; the sign-up gate URL stays absolute (combined-aware). signInProtectCheckUrl: 'protect-check', - signUpProtectCheckUrl: '/sign-up-protect-check', + // Sign-up steps are path routes on the sign-up component; hash-style URLs would lose their + // hash in the virtual router and land a transferred sign-up on the start card. + continueSignUpUrl: `${origin}/sign-up/continue`, + verifyEmailAddressUrl: `${origin}/sign-up/verify-email-address`, + verifyPhoneNumberUrl: `${origin}/sign-up/verify-phone-number`, + signUpProtectCheckUrl: `${origin}/sign-up/protect-check`, unsafeMetadata: { a: 1 }, }); }); + + it('targets the virtual sign-up routes for modal transport callbacks', () => { + const ctx = { + signUpUrl: '/CLERK-ROUTER/VIRTUAL/sign-up', + signInUrl: '/CLERK-ROUTER/VIRTUAL/sign-in', + } as any; + + const params = buildSignInOAuthTransportCallbackParams(ctx); + const origin = window.location.origin; + + expect(params.continueSignUpUrl).toBe(`${origin}/CLERK-ROUTER/VIRTUAL/sign-up/continue`); + expect(params.verifyEmailAddressUrl).toBe(`${origin}/CLERK-ROUTER/VIRTUAL/sign-up/verify-email-address`); + expect(params.verifyPhoneNumberUrl).toBe(`${origin}/CLERK-ROUTER/VIRTUAL/sign-up/verify-phone-number`); + expect(params.signUpProtectCheckUrl).toBe(`${origin}/CLERK-ROUTER/VIRTUAL/sign-up/protect-check`); + }); + + it('drops a hash fragment from signUpUrl when building sign-up step URLs', () => { + const ctx = { + signUpUrl: '/sign-up#/continue', + signInUrl: '/sign-in', + } as any; + + const params = buildSignInOAuthTransportCallbackParams(ctx); + const origin = window.location.origin; + + expect(params.continueSignUpUrl).toBe(`${origin}/sign-up/continue`); + expect(params.verifyEmailAddressUrl).toBe(`${origin}/sign-up/verify-email-address`); + expect(params.verifyPhoneNumberUrl).toBe(`${origin}/sign-up/verify-phone-number`); + expect(params.signUpProtectCheckUrl).toBe(`${origin}/sign-up/protect-check`); + }); + + it('targets the embedded create subtree in the combined flow', () => { + const ctx = { + signUpUrl: '/sign-in#/create', + signInUrl: '/sign-in', + isCombinedFlow: true, + } as any; + + const params = buildSignInOAuthTransportCallbackParams(ctx); + + expect(params.continueSignUpUrl).toBe('create/continue'); + expect(params.verifyEmailAddressUrl).toBe('create/verify-email-address'); + expect(params.verifyPhoneNumberUrl).toBe('create/verify-phone-number'); + expect(params.signUpProtectCheckUrl).toBe('create/protect-check'); + }); }); describe('buildSignUpOAuthCallbackParams', () => { diff --git a/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts b/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts index 334e604300f..e78b09401fe 100644 --- a/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts +++ b/packages/ui/src/components/SignIn/buildOAuthCallbackParams.ts @@ -1,3 +1,4 @@ +import { buildURL, trimTrailingSlash } from '@clerk/shared/internal/clerk-js/url'; import type { HandleOAuthCallbackParams } from '@clerk/shared/types'; import type { SignInContextType } from '../../contexts/components/SignIn'; @@ -23,12 +24,27 @@ export function buildSignInOAuthCallbackParams(ctx: SignInContextType): HandleOA } export function buildSignInOAuthTransportCallbackParams(ctx: SignInContextType): HandleOAuthCallbackParams { + // Path form, not `#/step`: the in-place component router matches on pathname only and would drop the hash. + const signUpStepUrl = (step: string): string => { + if (ctx.isCombinedFlow) { + return `create/${step}`; + } + const url = buildURL({ base: ctx.signUpUrl }, { stringify: false }); + url.pathname = `${trimTrailingSlash(url.pathname)}/${step}`; + url.hash = ''; + return url.href; + }; + return { ...buildSignInOAuthCallbackParams(ctx), firstFactorUrl: 'factor-one', secondFactorUrl: 'factor-two', resetPasswordUrl: 'reset-password', signInProtectCheckUrl: 'protect-check', + continueSignUpUrl: signUpStepUrl('continue'), + verifyEmailAddressUrl: signUpStepUrl('verify-email-address'), + verifyPhoneNumberUrl: signUpStepUrl('verify-phone-number'), + signUpProtectCheckUrl: signUpStepUrl('protect-check'), }; } diff --git a/packages/ui/src/router/VirtualRouter.tsx b/packages/ui/src/router/VirtualRouter.tsx index 3b152d4fd24..ebedfdf7dab 100644 --- a/packages/ui/src/router/VirtualRouter.tsx +++ b/packages/ui/src/router/VirtualRouter.tsx @@ -1,9 +1,11 @@ +import { VIRTUAL_ROUTER_BASE_PATH } from '@clerk/shared/internal/clerk-js/url'; import { useClerk } from '@clerk/shared/react'; import React, { useEffect } from 'react'; import { useClerkModalStateParams } from '../hooks'; import { BaseRouter } from './BaseRouter'; -export const VIRTUAL_ROUTER_BASE_PATH = 'CLERK-ROUTER/VIRTUAL'; + +export { VIRTUAL_ROUTER_BASE_PATH }; interface VirtualRouterProps { startPath: string;