Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/lemon-crews-hammer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/elements': minor
---

- Adds virtual router to support modal scenarios
- Adds `routing` prop to `SignIn.Root` and `SignUp.Root` for handling `virtual` routing
- Better support for Account Portal redirect callback flows
422 changes: 422 additions & 0 deletions packages/elements/examples/nextjs/app/modal/page.tsx

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions packages/elements/examples/nextjs/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,19 @@ export default function Home() {
<p className='m-0 max-w-[30ch] text-sm opacity-50'>OTP Playground</p>
</Link>

<Link
href='/modal'
className='group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30'
>
<h2 className='mb-3 text-2xl font-semibold'>
Modal{' '}
<span className='inline-block transition-transform group-hover:translate-x-1 motion-reduce:transform-none'>
-&gt;
</span>
</h2>
<p className='m-0 max-w-[30ch] text-sm opacity-50'>Modal Playground</p>
</Link>

<a
href='https://clerk.com/docs/custom-flows/overview#sign-in-flow'
className='group rounded-lg border border-transparent px-5 py-4 transition-colors hover:border-gray-300 hover:bg-gray-100 hover:dark:border-neutral-700 hover:dark:bg-neutral-800/30'
Expand Down
1 change: 1 addition & 0 deletions packages/elements/examples/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"@clerk/elements": "file:../../elements",
"@clerk/nextjs": "file:../../nextjs",
"@radix-ui/react-form": "^0.0.3",
"@radix-ui/react-popover": "^1.0.7",
"clsx": "^2.0.0",
"framer-motion": "^11.0.28",
"geist": "^1.3",
Expand Down
13 changes: 11 additions & 2 deletions packages/elements/src/internals/constants/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
export const SSO_CALLBACK_PATH_ROUTE = '/sso-callback';
export const MAGIC_LINK_VERIFY_PATH_ROUTE = '/verify';

export const SIGN_IN_DEFAULT_BASE_PATH = '/sign-in';
export const SIGN_UP_DEFAULT_BASE_PATH = '/sign-up';
export const SIGN_IN_DEFAULT_BASE_PATH =
process.env.CLERK_SIGN_IN_URL ?? process.env.NEXT_PUBLIC_CLERK_SIGN_IN_URL ?? '/sign-in';
export const SIGN_UP_DEFAULT_BASE_PATH =
process.env.CLERK_SIGN_UP_URL ?? process.env.NEXT_PUBLIC_CLERK_SIGN_UP_URL ?? '/sign-up';

// The version that Next added support for the window.history.pushState and replaceState APIs.
// ref: https://nextjs.org/blog/next-14-1#windowhistorypushstate-and-windowhistoryreplacestate
Expand Down Expand Up @@ -37,3 +39,10 @@ export const ERROR_CODES = {
SAML_USER_ATTRIBUTE_MISSING: 'saml_user_attribute_missing',
USER_LOCKED: 'user_locked',
};

export const ROUTING = {
path: 'path',
virtual: 'virtual',
} as const;

export type ROUTING = (typeof ROUTING)[keyof typeof ROUTING];
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { and, assign, enqueueActions, not, or, raise, sendTo, setup } from 'xsta

import {
ERROR_CODES,
ROUTING,
SIGN_IN_DEFAULT_BASE_PATH,
SIGN_UP_DEFAULT_BASE_PATH,
SSO_CALLBACK_PATH_ROUTE,
Expand Down Expand Up @@ -162,10 +163,16 @@ export const SignInRouterMachine = setup({
initial: 'Idle',
on: {
'AUTHENTICATE.OAUTH': {
actions: sendTo(ThirdPartyMachineId, ({ event }) => ({
actions: sendTo(ThirdPartyMachineId, ({ context, event }) => ({
type: 'REDIRECT',
params: {
strategy: event.strategy,
redirectUrl: `${
context.router?.mode === ROUTING.virtual
? context.clerk.__unstable__environment?.displayConfig.signInUrl
: context.router?.basePath
}${SSO_CALLBACK_PATH_ROUTE}`,
redirectUrlComplete: context.clerk.buildAfterSignInUrl(),
},
})),
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { and, assign, enqueueActions, log, not, or, raise, sendTo, setup } from

import {
ERROR_CODES,
ROUTING,
SEARCH_PARAMS,
SIGN_IN_DEFAULT_BASE_PATH,
SIGN_UP_DEFAULT_BASE_PATH,
Expand Down Expand Up @@ -161,10 +162,16 @@ export const SignUpRouterMachine = setup({
initial: 'Idle',
on: {
'AUTHENTICATE.OAUTH': {
actions: sendTo(ThirdPartyMachineId, ({ event }) => ({
actions: sendTo(ThirdPartyMachineId, ({ context, event }) => ({
type: 'REDIRECT',
params: {
strategy: event.strategy,
redirectUrl: `${
context.router?.mode === ROUTING.virtual
? context.clerk.__unstable__environment?.displayConfig.signUpUrl
: context.router?.basePath
}${SSO_CALLBACK_PATH_ROUTE}`,
redirectUrlComplete: context.clerk.buildAfterSignUpUrl(),
},
})),
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import type { SetOptional } from 'type-fest';
import type { AnyActorRef, AnyEventObject } from 'xstate';
import { fromCallback, fromPromise } from 'xstate';

import { SSO_CALLBACK_PATH_ROUTE } from '~/internals/constants';
import { ClerkElementsRuntimeError } from '~/internals/errors';
import type { WithParams, WithUnsafeMetadata } from '~/internals/machines/shared';
import { ClerkJSNavigationEvent, isClerkJSNavigationEvent } from '~/internals/machines/utils/clerkjs';
Expand All @@ -27,13 +26,12 @@ export type AuthenticateWithRedirectInput = (
) & { basePath: string; parent: AnyActorRef }; // TODO: Fix circular dependency

export const redirect = fromPromise<void, AuthenticateWithRedirectInput>(
async ({ input: { basePath, flow, params, parent } }) => {
async ({ input: { flow, params, parent } }) => {
const clerk: LoadedClerk = parent.getSnapshot().context.clerk;
const path = clerk.buildUrlWithAuth(`${basePath}${SSO_CALLBACK_PATH_ROUTE}`);

return clerk.client[flow].authenticateWithRedirect({
redirectUrl: path,
redirectUrlComplete: path,
redirectUrl: clerk.buildUrlWithAuth(params.redirectUrl || '/'),
redirectUrlComplete: clerk.buildUrlWithAuth(params.redirectUrlComplete || '/'),
...params,
});
},
Expand Down Expand Up @@ -80,8 +78,6 @@ export const handleRedirectCallback = fromCallback<AnyEventObject, HandleRedirec

void loadedClerk.handleRedirectCallback(
{
afterSignInUrl: ClerkJSNavigationEvent.signIn,
afterSignUpUrl: ClerkJSNavigationEvent.signUp,
signInForceRedirectUrl: ClerkJSNavigationEvent.complete,
signInFallbackRedirectUrl: ClerkJSNavigationEvent.complete,
signUpForceRedirectUrl: ClerkJSNavigationEvent.signUp,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import type { LoadedClerk } from '@clerk/types';
import { assertEvent, assign, log, not, sendTo, setup } from 'xstate';

import { SSO_CALLBACK_PATH_ROUTE } from '~/internals/constants';
import { sendToLoading } from '~/internals/machines/shared';
import { assertActorEventError } from '~/internals/machines/utils/assert';

Expand Down Expand Up @@ -79,20 +77,10 @@ export const ThirdPartyMachine = setup({
input: ({ context, event }) => {
assertEvent(event, 'REDIRECT');

const clerk: LoadedClerk = context.parent.getSnapshot().context.clerk;

const redirectUrl =
event.params.redirectUrl || clerk.buildUrlWithAuth(`${context.basePath}${SSO_CALLBACK_PATH_ROUTE}`);
const redirectUrlComplete = event.params.redirectUrlComplete || redirectUrl;

return {
basePath: context.basePath,
flow: context.flow,
params: {
redirectUrl,
redirectUrlComplete,
...event.params,
},
params: event.params,
parent: context.parent,
};
},
Expand Down
2 changes: 2 additions & 0 deletions packages/elements/src/react/router/__tests__/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { createClerkRouter } from '../router';

describe('createClerkRouter', () => {
const mockRouter = {
name: 'mockRouter',
mode: 'path' as const,
pathname: jest.fn(),
searchParams: jest.fn(),
push: jest.fn(),
Expand Down
1 change: 1 addition & 0 deletions packages/elements/src/react/router/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { useNextRouter } from './next';
export { Route, Router, useClerkRouter } from './react';
export { useVirtualRouter } from './virtual';

export type { ClerkRouter, ClerkHostRouter } from './router';
2 changes: 2 additions & 0 deletions packages/elements/src/react/router/next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export const useNextRouter = (): ClerkHostRouter => {
typeof window !== 'undefined' && window.next && window.next.version >= NEXT_WINDOW_HISTORY_SUPPORT_VERSION;

return {
mode: 'path',
name: 'NextRouter',
push: (path: string) => router.push(path),
replace: (path: string) =>
canUseWindowHistoryAPIs ? window.history.replaceState(null, '', path) : router.replace(path),
Expand Down
4 changes: 2 additions & 2 deletions packages/elements/src/react/router/react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ export function useClerkRouter() {
}

export function Router({
basePath,
children,
router,
basePath,
}: {
router: ClerkHostRouter;
children: React.ReactNode;
basePath?: string;
router: ClerkHostRouter;
}) {
const clerkRouter = createClerkRouter(router, basePath);

Expand Down
21 changes: 19 additions & 2 deletions packages/elements/src/react/router/router.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { withLeadingSlash, withoutTrailingSlash } from '@clerk/shared/url';

import type { ROUTING } from '~/internals/constants';

export const PRESERVED_QUERYSTRING_PARAMS = ['after_sign_in_url', 'after_sign_up_url', 'redirect_url'];

/**
* This type represents a generic router interface that Clerk relies on to interact with the host router.
*/
export type ClerkHostRouter = {
readonly mode: ROUTING;
readonly name: string;
pathname: () => string;
push: (path: string) => void;
replace: (path: string) => void;
shallowPush: (path: string) => void;
pathname: () => string;
searchParams: () => URLSearchParams;
shallowPush: (path: string) => void;
};

/**
Expand All @@ -29,6 +33,17 @@ export type ClerkRouter = {
* Matches the provided path against the router's current path. If index is provided, matches against the root route of the router.
*/
match: (path?: string, index?: boolean) => boolean;

/**
* Mode of the router instance, path-based or virtual
*/
readonly mode: ROUTING;

/**
* Name of the router instance
*/
readonly name: string;

/**
* Navigates to the provided path via a history push
*/
Expand Down Expand Up @@ -119,6 +134,8 @@ export function createClerkRouter(router: ClerkHostRouter, basePath: string = '/
return {
child,
match,
mode: router.mode,
name: router.name,
push,
replace,
shallowPush,
Expand Down
79 changes: 79 additions & 0 deletions packages/elements/src/react/router/virtual.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
'use client';

import { useSyncExternalStore } from 'react';

import type { ClerkHostRouter } from './router';

const DUMMY_ORIGIN = 'https://clerk.dummy';

// TODO: introduce history stack?
class VirtualRouter implements ClerkHostRouter {
readonly name = 'VirtualRouter';
readonly mode = 'virtual';

#url: URL;
#listeners: Set<(url: URL) => void> = new Set();

constructor(path?: string) {
const origin = typeof window === 'undefined' ? DUMMY_ORIGIN : window.location.origin;

this.#url = new URL(path ?? '/', origin);
}

push(path: string) {
const newUrl = new URL(this.#url.toString());
newUrl.pathname = path;

this.#url = newUrl;
this.emit();
}

replace(path: string) {
this.push(path);
}

shallowPush(path: string) {
this.push(path);
}

pathname() {
return this.#url.pathname;
}

searchParams() {
return this.#url.searchParams;
}

subscribe(listener: () => void) {
this.#listeners.add(listener);

return () => this.#listeners.delete(listener);
}

emit() {
this.#listeners.forEach(listener => listener(this.#url));
}

getSnapshot() {
return this.#url;
}
}

const virtualRouter = new VirtualRouter('/');

export const useVirtualRouter = (): ClerkHostRouter => {
const url = useSyncExternalStore(
virtualRouter.subscribe.bind(virtualRouter),
virtualRouter.getSnapshot.bind(virtualRouter),
);

return {
mode: virtualRouter.mode,
name: virtualRouter.name,
pathname: () => url.pathname,
push: virtualRouter.push.bind(virtualRouter),
replace: virtualRouter.replace.bind(virtualRouter),
searchParams: () => url.searchParams,
shallowPush: virtualRouter.shallowPush.bind(virtualRouter),
};
};
Loading