diff --git a/.changeset/six-lobsters-happen.md b/.changeset/six-lobsters-happen.md new file mode 100644 index 00000000000..5751b72fbd5 --- /dev/null +++ b/.changeset/six-lobsters-happen.md @@ -0,0 +1,12 @@ +--- +'@clerk/elements': minor +--- + +The `path` prop on the `` and `` component is now automatically inferred. Previously, the default values were `/sign-in` and `/sign-up`, on other routes you had to explicitly define your route. + +The new heuristic for determining the path where `` and `` are mounted is: + +1. `path` prop +2. Automatically inferred +3. If it can't be inferred, fallback to `CLERK_SIGN_IN_URL` and `CLERK_SIGN_UP_URL` env var +4. Fallback to `/sign-in` and `/sign-up` diff --git a/packages/elements/src/react/sign-in/root.tsx b/packages/elements/src/react/sign-in/root.tsx index 7b0924b1dfd..82d860ab2e7 100644 --- a/packages/elements/src/react/sign-in/root.tsx +++ b/packages/elements/src/react/sign-in/root.tsx @@ -12,6 +12,7 @@ import { Router, useClerkRouter, useNextRouter, useVirtualRouter } from '~/react import { SignInRouterCtx } from '~/react/sign-in/context'; import { Form } from '../common/form'; +import { usePathnameWithoutCatchAll } from '../utils/path-inference/next'; type SignInFlowProviderProps = { children: React.ReactNode; @@ -62,13 +63,19 @@ function SignInFlowProvider({ children, exampleMode }: SignInFlowProviderProps) } export type SignInRootProps = SignInFlowProviderProps & { + /** + * Fallback markup to render while Clerk is loading + */ fallback?: React.ReactNode; /** - * The base path for your sign-in route. Defaults to `/sign-in`. - * - * TODO: re-use usePathnameWithoutCatchAll from the next SDK + * The base path for your sign-in route. + * Will be automatically inferred in Next.js. + * @example `/sign-in` */ path?: string; + /** + * If you want to render Clerk Elements in e.g. a modal, use the `virtual` routing mode. + */ routing?: ROUTING; }; @@ -76,8 +83,9 @@ export type SignInRootProps = SignInFlowProviderProps & { * Root component for the sign-in flow. It sets up providers and state management for its children. * Must wrap all sign-in related components. * - * @param {string} path - The root path the sign-in flow is mounted at. Default: `/sign-in` + * @param {string} path - The root path the sign-in flow is mounted at. Will be automatically inferred in Next.js. You can set it to `/sign-in` for example. * @param {React.ReactNode} fallback - Fallback markup to render while Clerk is loading. Default: `null` + * @param {string} routing - If you want to render Clerk Elements in e.g. a modal, use the `'virtual'` routing mode. Default: `'path'` * * @example * import * as SignIn from "@clerk/elements/sign-in" @@ -89,18 +97,21 @@ export type SignInRootProps = SignInFlowProviderProps & { */ export function SignInRoot({ children, - exampleMode, + exampleMode = false, fallback = null, - path = SIGN_IN_DEFAULT_BASE_PATH, - routing, + path: pathProp, + routing = ROUTING.path, }: SignInRootProps): JSX.Element | null { const clerk = useClerk(); + const inferredPath = usePathnameWithoutCatchAll(); + const path = pathProp || inferredPath || SIGN_IN_DEFAULT_BASE_PATH; clerk.telemetry?.record( eventComponentMounted('Elements_SignInRoot', { - exampleMode: Boolean(exampleMode), + exampleMode, fallback: Boolean(fallback), path, + routing, }), ); diff --git a/packages/elements/src/react/sign-up/root.tsx b/packages/elements/src/react/sign-up/root.tsx index 3c6e514be24..4ce7f86526a 100644 --- a/packages/elements/src/react/sign-up/root.tsx +++ b/packages/elements/src/react/sign-up/root.tsx @@ -13,6 +13,7 @@ import { Router, useClerkRouter, useNextRouter, useVirtualRouter } from '~/react import { SignUpRouterCtx } from '~/react/sign-up/context'; import { Form } from '../common/form'; +import { usePathnameWithoutCatchAll } from '../utils/path-inference/next'; type SignUpFlowProviderProps = { children: React.ReactNode; @@ -63,8 +64,19 @@ function SignUpFlowProvider({ children, exampleMode }: SignUpFlowProviderProps) } export type SignUpRootProps = SignUpFlowProviderProps & { + /** + * Fallback markup to render while Clerk is loading + */ fallback?: React.ReactNode; + /** + * The base path for your sign-up route. + * Will be automatically inferred in Next.js. + * @example `/sign-up` + */ path?: string; + /** + * If you want to render Clerk Elements in e.g. a modal, use the `virtual` routing mode. + */ routing?: ROUTING; }; @@ -72,8 +84,9 @@ export type SignUpRootProps = SignUpFlowProviderProps & { * Root component for the sign-up flow. It sets up providers and state management for its children. * Must wrap all sign-up related components. * - * @param {string} path - The root path the sign-up flow is mounted at. Default: `/sign-up` + * @param {string} path - The root path the sign-up flow is mounted at. Will be automatically inferred in Next.js. You can set it to `/sign-up` for example. * @param {React.ReactNode} fallback - Fallback markup to render while Clerk is loading. Default: `null` + * @param {string} routing - If you want to render Clerk Elements in e.g. a modal, use the `'virtual'` routing mode. Default: `'path'` * * @example * import * as SignUp from "@clerk/elements/sign-up" @@ -85,18 +98,21 @@ export type SignUpRootProps = SignUpFlowProviderProps & { */ export function SignUpRoot({ children, - exampleMode, + exampleMode = false, fallback = null, - path = SIGN_UP_DEFAULT_BASE_PATH, - routing, + path: pathProp, + routing = ROUTING.path, }: SignUpRootProps): JSX.Element | null { const clerk = useClerk(); + const inferredPath = usePathnameWithoutCatchAll(); + const path = pathProp || inferredPath || SIGN_UP_DEFAULT_BASE_PATH; clerk.telemetry?.record( eventComponentMounted('Elements_SignUpRoot', { - path, + exampleMode, fallback: Boolean(fallback), - exampleMode: Boolean(exampleMode), + path, + routing, }), ); diff --git a/packages/elements/src/react/utils/path-inference/__tests__/next.test.ts b/packages/elements/src/react/utils/path-inference/__tests__/next.test.ts new file mode 100644 index 00000000000..75451f5d8d3 --- /dev/null +++ b/packages/elements/src/react/utils/path-inference/__tests__/next.test.ts @@ -0,0 +1,62 @@ +import { renderHook } from '@testing-library/react'; +import { useRouter } from 'next/compat/router'; +import { useParams, usePathname } from 'next/navigation'; + +import { usePathnameWithoutCatchAll } from '../next'; + +jest.mock('next/compat/router', () => ({ + useRouter: jest.fn(), +})); +jest.mock('next/navigation', () => ({ + usePathname: jest.fn(), + useParams: jest.fn(), +})); + +describe('usePathnameWithoutCatchAll', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should work with pages router', () => { + const pathname = '/user/123/profile/[[...rest]]/page'; + const expectedPath = '/user/123/profile'; + + (useRouter as jest.Mock).mockImplementation(() => ({ + push: jest.fn(), + pathname: pathname, + route: '/', + asPath: '/', + query: '', + })); + + const { result } = renderHook(() => usePathnameWithoutCatchAll()); + + expect(result.current).toBe(expectedPath); + }); + + it('should work with app router', () => { + const pathname = '/user/123/profile/security'; + const expectedPath = '/user/123/profile'; + + (useRouter as jest.Mock).mockImplementation(() => null); + (usePathname as jest.Mock).mockReturnValue(pathname); + (useParams as jest.Mock).mockReturnValue({ id: '123', rest: ['security'] }); + + const { result } = renderHook(() => usePathnameWithoutCatchAll()); + + expect(result.current).toBe(expectedPath); + }); + + it('should work with multiple catch all params (app router)', () => { + const pathname = '/user/123/profile/security/otp'; + const expectedPath = '/user/123/profile'; + + (useRouter as jest.Mock).mockImplementation(() => null); + (usePathname as jest.Mock).mockReturnValue(pathname); + (useParams as jest.Mock).mockReturnValue({ id: '123', rest: ['security', 'otp'] }); + + const { result } = renderHook(() => usePathnameWithoutCatchAll()); + + expect(result.current).toBe(expectedPath); + }); +}); diff --git a/packages/elements/src/react/utils/path-inference/__tests__/utils.test.ts b/packages/elements/src/react/utils/path-inference/__tests__/utils.test.ts new file mode 100644 index 00000000000..9cc92f0a537 --- /dev/null +++ b/packages/elements/src/react/utils/path-inference/__tests__/utils.test.ts @@ -0,0 +1,14 @@ +import { removeOptionalCatchAllSegment } from '../utils'; + +describe('removeOptionalCatchAllSegment', () => { + it('should remove optional catch-all segment from the pathname', () => { + const pathname = '/users/[[...slug]].js'; + const expected = '/users'; + expect(removeOptionalCatchAllSegment(pathname)).toEqual(expected); + }); + + it('should not remove anything if there is no optional catch-all segment', () => { + const pathname = '/users/john/profile'; + expect(removeOptionalCatchAllSegment(pathname)).toEqual(pathname); + }); +}); diff --git a/packages/elements/src/react/utils/path-inference/next.tsx b/packages/elements/src/react/utils/path-inference/next.tsx new file mode 100644 index 00000000000..43083fabbb4 --- /dev/null +++ b/packages/elements/src/react/utils/path-inference/next.tsx @@ -0,0 +1,61 @@ +import { useRouter } from 'next/compat/router'; +import { useParams, usePathname } from 'next/navigation'; +import React from 'react'; + +import { removeOptionalCatchAllSegment } from './utils'; + +// Adapted from packages/nextjs/src/client-boundary/hooks/usePathnameWithoutCatchAll.tsx + +/** + * This hook grabs the current pathname (both in pages and app router) and removes any (optional) catch all segments. + * @example + * 1. /user/[id]/profile/[[...rest]]/page.tsx + * 2. /user/123/profile/security + * 3. /user/123/profile + * @returns The pathname without any catch all segments + */ +export const usePathnameWithoutCatchAll = () => { + const pathRef = React.useRef(); + + /** + * The compat version of useRouter returns null instead of throwing an error when used inside App router. + * Use it to detect if the component is used inside pages or app router + */ + const pagesRouter = useRouter(); + + if (pagesRouter) { + if (pathRef.current) { + return pathRef.current; + } else { + // The optional catch all route is included in the pathname in pages router. It starts with [[... and we can just remove it + pathRef.current = removeOptionalCatchAllSegment(pagesRouter.pathname); + return pathRef.current; + } + } + + /** + * Get the pathname that includes any named or catch all params. + * @example + * /user/[id]/profile/[[...rest]]/page.tsx + * + * This filesystem route could give us the following pathname: + * /user/123/profile/security + * if the user navigates to the security section of the user profile + */ + const pathname = usePathname() || ''; + const pathParts = pathname.split('/').filter(Boolean); + /** + * For /user/[id]/profile/[[...rest]]/page.tsx useParams will return { id: '123', rest: ['security'] }. + * So catch all params are always arrays + */ + const catchAllParams = Object.values(useParams() || {}) + .filter(v => Array.isArray(v)) + .flat(Infinity); + if (pathRef.current) { + return pathRef.current; + } else { + // /user/123/profile/security should be transformed to /user/123/profile + pathRef.current = `/${pathParts.slice(0, pathParts.length - catchAllParams.length).join('/')}`; + return pathRef.current; + } +}; diff --git a/packages/elements/src/react/utils/path-inference/utils.ts b/packages/elements/src/react/utils/path-inference/utils.ts new file mode 100644 index 00000000000..d616c67d28d --- /dev/null +++ b/packages/elements/src/react/utils/path-inference/utils.ts @@ -0,0 +1,3 @@ +export function removeOptionalCatchAllSegment(pathname: string) { + return pathname.replace(/\/\[\[\.\.\..*/, ''); +} diff --git a/packages/elements/tsup.config.ts b/packages/elements/tsup.config.ts index c8cb1e480ad..8a60447c3af 100644 --- a/packages/elements/tsup.config.ts +++ b/packages/elements/tsup.config.ts @@ -21,7 +21,7 @@ export default defineConfig(overrideOptions => { 'react/sign-in/index': 'src/react/sign-in/index.ts', 'react/sign-up/index': 'src/react/sign-up/index.ts', }, - external: ['react', 'react-dom', '@statelyai/inspect'], + external: ['react', 'react-dom', 'next', '@statelyai/inspect'], format: ['cjs', 'esm'], minify: false, sourcemap: true,