diff --git a/.changeset/wet-peaches-grow.md b/.changeset/wet-peaches-grow.md new file mode 100644 index 00000000000..7c9c5929ad3 --- /dev/null +++ b/.changeset/wet-peaches-grow.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/clerk-react': patch +--- + +Respect the `signInForceRedirectUrl`, `signInFallbackRedirectUrl`, `signUpForceRedirectUrl` and `signUpFallbackRedirectUrl` props passed to `SignInButton`, `SignUpButton` and the low-level `window.Clerk.buildSignInUrl` & `window.Clerk.buildSignUpUrl` methods. These props allow you to control the redirect behavior of the `SignIn` and `SignUp` components. For more information, refer to the [Custom Redirects](https://clerk.com/docs/guides/custom-redirects) guide. diff --git a/packages/clerk-js/src/core/__tests__/clerk.redirects.test.ts b/packages/clerk-js/src/core/__tests__/clerk.redirects.test.ts index 1de073cb788..9cd2364ecf3 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.redirects.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.redirects.test.ts @@ -123,33 +123,33 @@ describe('Clerk singleton - Redirects', () => { }); it('redirects to signInUrl for development instance', async () => { - await clerkForDevelopmentInstance.redirectToSignIn({ redirectUrl: 'https://www.example.com/' }); + await clerkForDevelopmentInstance.redirectToSignIn({ redirectUrl: '/example' }); expect(mockNavigate).toHaveBeenCalledWith( - '/sign-in#/?redirect_url=https%3A%2F%2Fwww.example.com%2F', + '/sign-in#/?redirect_url=http%3A%2F%2Ftest.host%2Fexample', undefined, ); }); it('redirects to signInUrl for production instance', async () => { - await clerkForProductionInstance.redirectToSignIn({ redirectUrl: 'https://www.example.com/' }); + await clerkForProductionInstance.redirectToSignIn({ redirectUrl: '/example' }); expect(mockNavigate).toHaveBeenCalledWith( - '/sign-in#/?redirect_url=https%3A%2F%2Fwww.example.com%2F', + '/sign-in#/?redirect_url=http%3A%2F%2Ftest.host%2Fexample', undefined, ); }); it('redirects to signUpUrl for development instance', async () => { - await clerkForDevelopmentInstance.redirectToSignUp({ redirectUrl: 'https://www.example.com/' }); + await clerkForDevelopmentInstance.redirectToSignUp({ redirectUrl: '/example' }); expect(mockNavigate).toHaveBeenCalledWith( - '/sign-up#/?redirect_url=https%3A%2F%2Fwww.example.com%2F', + '/sign-up#/?redirect_url=http%3A%2F%2Ftest.host%2Fexample', undefined, ); }); it('redirects to signUpUrl for production instance', async () => { - await clerkForProductionInstance.redirectToSignUp({ redirectUrl: 'https://www.example.com/' }); + await clerkForProductionInstance.redirectToSignUp({ redirectUrl: '/example' }); expect(mockNavigate).toHaveBeenCalledWith( - '/sign-up#/?redirect_url=https%3A%2F%2Fwww.example.com%2F', + '/sign-up#/?redirect_url=http%3A%2F%2Ftest.host%2Fexample', undefined, ); }); @@ -220,31 +220,31 @@ describe('Clerk singleton - Redirects', () => { const host = 'http://another-test.host'; it('redirects to signInUrl for development instance', async () => { - await clerkForDevelopmentInstance.redirectToSignIn({ redirectUrl: 'https://www.example.com/' }); + await clerkForDevelopmentInstance.redirectToSignIn({ redirectUrl: '/example' }); expect(mockHref).toHaveBeenCalledTimes(1); expect(mockHref).toHaveBeenCalledWith( - `${host}/sign-in?__clerk_db_jwt=deadbeef#/?redirect_url=https%3A%2F%2Fwww.example.com%2F`, + `${host}/sign-in?__clerk_db_jwt=deadbeef#/?redirect_url=http%3A%2F%2Ftest.host%2Fexample`, ); }); it('redirects to signInUrl for production instance', async () => { - await clerkForProductionInstance.redirectToSignIn({ redirectUrl: 'https://www.example.com/' }); + await clerkForProductionInstance.redirectToSignIn({ redirectUrl: '/example' }); expect(mockHref).toHaveBeenCalledTimes(1); - expect(mockHref).toHaveBeenCalledWith(`${host}/sign-in#/?redirect_url=https%3A%2F%2Fwww.example.com%2F`); + expect(mockHref).toHaveBeenCalledWith(`${host}/sign-in#/?redirect_url=http%3A%2F%2Ftest.host%2Fexample`); }); it('redirects to signUpUrl for development instance', async () => { - await clerkForDevelopmentInstance.redirectToSignUp({ redirectUrl: 'https://www.example.com/' }); + await clerkForDevelopmentInstance.redirectToSignUp({ redirectUrl: '/example' }); expect(mockHref).toHaveBeenCalledTimes(1); expect(mockHref).toHaveBeenCalledWith( - `${host}/sign-up?__clerk_db_jwt=deadbeef#/?redirect_url=https%3A%2F%2Fwww.example.com%2F`, + `${host}/sign-up?__clerk_db_jwt=deadbeef#/?redirect_url=http%3A%2F%2Ftest.host%2Fexample`, ); }); it('redirects to signUpUrl for production instance', async () => { - await clerkForProductionInstance.redirectToSignUp({ redirectUrl: 'https://www.example.com/' }); + await clerkForProductionInstance.redirectToSignUp({ redirectUrl: '/example' }); expect(mockHref).toHaveBeenCalledTimes(1); - expect(mockHref).toHaveBeenCalledWith(`${host}/sign-up#/?redirect_url=https%3A%2F%2Fwww.example.com%2F`); + expect(mockHref).toHaveBeenCalledWith(`${host}/sign-up#/?redirect_url=http%3A%2F%2Ftest.host%2Fexample`); }); it('redirects to userProfileUrl', async () => { diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 3894dae79fd..d3195a10cd6 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -37,6 +37,7 @@ import type { OrganizationProfileProps, OrganizationResource, OrganizationSwitcherProps, + RedirectOptions, Resources, SDKMetadata, SetActiveParams, @@ -826,19 +827,20 @@ export class Clerk implements ClerkInterface { return setDevBrowserJWTInURL(toURL, devBrowserJwt).href; } - public buildSignInUrl(options?: SignInRedirectOptions): string { - return this.#buildUrl('signInUrl', { - ...options?.initialValues, - redirect_url: options?.redirectUrl || window.location.href, - }); + return this.#buildUrl( + 'signInUrl', + { ...options, redirectUrl: options?.redirectUrl || window.location.href }, + options?.initialValues, + ); } public buildSignUpUrl(options?: SignUpRedirectOptions): string { - return this.#buildUrl('signUpUrl', { - ...options?.initialValues, - redirect_url: options?.redirectUrl || window.location.href, - }); + return this.#buildUrl( + 'signUpUrl', + { ...options, redirectUrl: options?.redirectUrl || window.location.href }, + options?.initialValues, + ); } public buildUserProfileUrl(): string { @@ -1642,13 +1644,19 @@ export class Clerk implements ClerkInterface { }); }; - #buildUrl = (key: 'signInUrl' | 'signUpUrl', params?: Record): string => { + #buildUrl = ( + key: 'signInUrl' | 'signUpUrl', + options: RedirectOptions, + _initValues?: Record, + ): string => { if (!key || !this.loaded || !this.#environment || !this.#environment.displayConfig) { return ''; } const signInOrUpUrl = this.#options[key] || this.#environment.displayConfig[key]; - const redirectUrls = new RedirectUrls(this.#options, params); - return this.buildUrlWithAuth(redirectUrls.appendPreservedPropsToUrl(signInOrUpUrl, params)); + const redirectUrls = new RedirectUrls(this.#options, options).toSearchParams(); + const initValues = new URLSearchParams(_initValues || {}); + const url = buildURL({ base: signInOrUpUrl, hashSearchParams: [initValues, redirectUrls] }, { stringify: true }); + return this.buildUrlWithAuth(url); }; assertComponentsReady(controls: unknown): asserts controls is ReturnType { diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx index a5f58f755ed..2236f2bab3d 100644 --- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx +++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx @@ -91,8 +91,9 @@ export const useSignUpContext = (): SignUpContextType => { let signUpUrl = (ctx.routing === 'path' && ctx.path) || options.signUpUrl || displayConfig.signUpUrl; let signInUrl = ctx.signInUrl || options.signInUrl || displayConfig.signInUrl; - signUpUrl = redirectUrls.appendPreservedPropsToUrl(signUpUrl, queryParams); - signInUrl = redirectUrls.appendPreservedPropsToUrl(signInUrl, queryParams); + const preservedParams = redirectUrls.getPreservedSearchParams(); + signInUrl = buildURL({ base: signInUrl, hashSearchParams: [queryParams, preservedParams] }, { stringify: true }); + signUpUrl = buildURL({ base: signUpUrl, hashSearchParams: [queryParams, preservedParams] }, { stringify: true }); // TODO: Avoid building this url again to remove duplicate code. Get it from window.Clerk instead. const secondFactorUrl = buildURL({ base: signInUrl, hashPath: '/factor-two' }, { stringify: true }); @@ -161,8 +162,10 @@ export const useSignInContext = (): SignInContextType => { let signInUrl = (ctx.routing === 'path' && ctx.path) || options.signInUrl || displayConfig.signInUrl; let signUpUrl = ctx.signUpUrl || options.signUpUrl || displayConfig.signUpUrl; - signInUrl = redirectUrls.appendPreservedPropsToUrl(signInUrl, queryParams); - signUpUrl = redirectUrls.appendPreservedPropsToUrl(signUpUrl, queryParams); + const preservedParams = redirectUrls.getPreservedSearchParams(); + signInUrl = buildURL({ base: signInUrl, hashSearchParams: [queryParams, preservedParams] }, { stringify: true }); + signUpUrl = buildURL({ base: signUpUrl, hashSearchParams: [queryParams, preservedParams] }, { stringify: true }); + const signUpContinueUrl = buildURL({ base: signUpUrl, hashPath: '/continue' }, { stringify: true }); return { diff --git a/packages/clerk-js/src/ui/router/RouteContext.tsx b/packages/clerk-js/src/ui/router/RouteContext.tsx index 370e776ac96..8496c211856 100644 --- a/packages/clerk-js/src/ui/router/RouteContext.tsx +++ b/packages/clerk-js/src/ui/router/RouteContext.tsx @@ -1,4 +1,3 @@ -import type { ParsedQs } from 'qs'; import React from 'react'; export interface RouteContextValue { @@ -15,7 +14,7 @@ export interface RouteContextValue { refresh: () => void; params: { [key: string]: string }; queryString: string; - queryParams: ParsedQs; + queryParams: Record; preservedParams?: string[]; getMatchData: (path?: string, index?: boolean) => false | object; urlStateParam?: { diff --git a/packages/clerk-js/src/utils/__tests__/redirectUrls.test.ts b/packages/clerk-js/src/utils/__tests__/redirectUrls.test.ts index 676d18c9774..a5433d6d6df 100644 --- a/packages/clerk-js/src/utils/__tests__/redirectUrls.test.ts +++ b/packages/clerk-js/src/utils/__tests__/redirectUrls.test.ts @@ -247,7 +247,7 @@ describe('redirectUrls', () => { }); describe('search params', () => { - it('appends only the preserved props', () => { + it('flattens and returns all params', () => { const redirectUrls = new RedirectUrls( { signInFallbackRedirectUrl: 'sign-in-fallback-redirect-url', @@ -263,66 +263,33 @@ describe('redirectUrls', () => { ); const params = redirectUrls.toSearchParams(); - expect([...params.keys()].length).toBe(1); + expect([...params.keys()].length).toBe(3); + expect(params.get('sign_in_force_redirect_url')).toBe( + `${mockWindowLocation.href}props-sign-in-force-redirect-url`, + ); + expect(params.get('sign_up_fallback_redirect_url')).toBe( + `${mockWindowLocation.href}search-param-sign-up-fallback-redirect-url`, + ); expect(params.get('redirect_url')).toBe(`${mockWindowLocation.href}search-param-redirect-url`); }); }); - describe('append to url', () => { - it('does not append redirect urls from options to the url if the url is same origin', () => { + describe('preserved search params', () => { + it('does not return redirect urls if they are not in the preserved props array', () => { const redirectUrls = new RedirectUrls({ signInFallbackRedirectUrl: 'sign-in-fallback-redirect-url', signUpFallbackRedirectUrl: 'sign-up-fallback-redirect-url', }); - const url = redirectUrls.appendPreservedPropsToUrl('https://www.clerk.com'); - expect(url).toBe('https://www.clerk.com/'); - }); - - it('appends redirect urls from options to the url if the url is cross origin', () => { - const redirectUrls = new RedirectUrls({}, {}, { redirect_url: '/search-param-redirect-url' }); - - const url = redirectUrls.appendPreservedPropsToUrl('https://www.example.com'); - expect(url).toContain('search-param-redirect-url'); - }); - - it('overrides the existing search params', () => { - const redirectUrls = new RedirectUrls( - { - signInFallbackRedirectUrl: 'sign-in-fallback-redirect-url', - signUpFallbackRedirectUrl: 'sign-up-fallback-redirect-url', - }, - {}, - { redirect_url: '/search-param-redirect-url' }, - ); - - const url = redirectUrls.appendPreservedPropsToUrl('https://www.example.com?redirect_url=existing'); - expect(url).toBe( - 'https://www.example.com/?redirect_url=existing#/?redirect_url=https%3A%2F%2Fwww.clerk.com%2Fsearch-param-redirect-url', - ); + const params = redirectUrls.getPreservedSearchParams(); + expect([...params.keys()].length).toBe(0); }); it('appends redirect urls from props to the url even if the url is same origin', () => { const redirectUrls = new RedirectUrls({}, {}, { redirect_url: '/search-param-redirect-url' }); - const url = redirectUrls.appendPreservedPropsToUrl('https://www.clerk.com'); - expect(url).toContain('search-param-redirect-url'); - }); - - it('does not append redirect urls from props to the url if the url is same origin if they match the options urls', () => { - const redirectUrls = new RedirectUrls( - { - signInFallbackRedirectUrl: 'sign-in-fallback-redirect-url', - signUpFallbackRedirectUrl: 'sign-up-fallback-redirect-url', - }, - { - signInFallbackRedirectUrl: 'sign-in-fallback-redirect-url', - signUpFallbackRedirectUrl: 'sign-up-fallback-redirect-url', - }, - ); - - const url = redirectUrls.appendPreservedPropsToUrl('https://www.clerk.com'); - expect(url).toBe('https://www.clerk.com/'); + const params = redirectUrls.getPreservedSearchParams(); + expect(params.get('redirect_url')).toContain('search-param-redirect-url'); }); }); }); diff --git a/packages/clerk-js/src/utils/__tests__/url.test.ts b/packages/clerk-js/src/utils/__tests__/url.test.ts index fbef75a5b5e..60b924fd19c 100644 --- a/packages/clerk-js/src/utils/__tests__/url.test.ts +++ b/packages/clerk-js/src/utils/__tests__/url.test.ts @@ -1,7 +1,6 @@ import type { SignUpResource } from '@clerk/types'; import { - appendAsQueryParams, buildURL, createAllowedRedirectOrigins, getETLDPlusOneFromFrontendApi, @@ -257,74 +256,75 @@ describe('buildURL(options: URLParams, skipOrigin)', () => { ), ).toBe('http://test.host/foo?my-search=42#my-hash/qux?my-hash-search-1=42&my-hash-search-2=42'); }); -}); -describe('trimTrailingSlash(string)', () => { - it('trims all the final slashes', () => { - expect(trimTrailingSlash('')).toBe(''); - expect(trimTrailingSlash('/foo')).toBe('/foo'); - expect(trimTrailingSlash('/foo/')).toBe('/foo'); - expect(trimTrailingSlash('//foo//bar///')).toBe('//foo//bar'); + it('appends search params passed to hashSearchParams in the URL fragment', () => { + const base = 'https://clerk.com/'; + const params = new URLSearchParams({ test1: '1', test2: '2' }); + const url = buildURL({ base, hashSearchParams: params }, { stringify: true }); + expect(url).toBe('https://clerk.com/#/?test1=1&test2=2'); }); -}); -describe('trimLeadingSlash(string)', () => { - it('trims all the leading slashes', () => { - expect(trimLeadingSlash('')).toBe(''); - expect(trimLeadingSlash('/foo')).toBe('foo'); - expect(trimLeadingSlash('/foo/')).toBe('foo/'); - expect(trimLeadingSlash('//foo//bar///')).toBe('foo//bar///'); + it('does not append a URL fragment if nothing was passed', () => { + const base = 'https://clerk.com/'; + const url = buildURL({ base }, { stringify: true }); + expect(url).toBe('https://clerk.com/'); }); -}); -describe('appendQueryParams(base,url)', () => { - it('returns the same url if no params provided', () => { - const base = new URL('https://dashboard.clerk.com'); - const res = appendAsQueryParams(base); - expect(res).toBe('https://dashboard.clerk.com/'); + it('does not append a URL fragment if search params were passed but were empty', () => { + const base = 'https://clerk.com/'; + const params = new URLSearchParams({}); + const url = buildURL({ base, hashSearchParams: params }, { stringify: true }); + expect(url).toBe('https://clerk.com/'); }); - it('handles plain strings', () => { - const base = 'https://dashboard.clerk.com'; - const url = 'https://dashboard.clerk.com/applications/appid/instances/'; - const res = appendAsQueryParams(base, { redirect_url: url }); - expect(res).toBe( - 'https://dashboard.clerk.com/#/?redirect_url=https%3A%2F%2Fdashboard.clerk.com%2Fapplications%2Fappid%2Finstances%2F', - ); + it('appends search params to the fragment if search params is a plain object', () => { + const base = 'https://clerk.com'; + const params = { test1: '1', test2: '2' }; + const url = buildURL({ base, hashSearchParams: params }, { stringify: true }); + expect(url).toBe('https://clerk.com/#/?test1=1&test2=2'); }); - it('handles multiple params', () => { - const base = 'https://dashboard.clerk.com'; - const url = 'https://dashboard.clerk.com/applications/appid/instances/'; - const res = appendAsQueryParams(base, { redirect_url: url, after_sign_in_url: url }); - expect(res).toBe( - 'https://dashboard.clerk.com/#/?redirect_url=https%3A%2F%2Fdashboard.clerk.com%2Fapplications%2Fappid%2Finstances%2F&after_sign_in_url=https%3A%2F%2Fdashboard.clerk.com%2Fapplications%2Fappid%2Finstances%2F', + it('appends search params to the fragment by merging all passed in params', () => { + const base = 'https://clerk.com'; + const url = buildURL( + { base, hashSearchParams: [new URLSearchParams({ test1: '1', test2: '2' }), { test3: '3' }] }, + { stringify: true }, ); + expect(url).toBe('https://clerk.com/#/?test1=1&test2=2&test3=3'); }); - it('skips falsy values', () => { - const base = new URL('https://dashboard.clerk.com'); - const res = appendAsQueryParams(base, { redirect_url: undefined }); - expect(res).toBe('https://dashboard.clerk.com/'); + it('overrides duplicate search params, giving priority to objects passed last', () => { + const base = 'https://clerk.com'; + const url = buildURL( + { base, hashSearchParams: [new URLSearchParams({ test1: '1', test2: '2' }), { test2: '3' }] }, + { stringify: true }, + ); + expect(url).toBe('https://clerk.com/#/?test1=1&test2=3'); }); - it('converts relative to absolute urls', () => { - const base = new URL('https://dashboard.clerk.com'); - const res = appendAsQueryParams(base, { redirect_url: 'http://localhost/test' }); - expect(res).toBe('https://dashboard.clerk.com/#/?redirect_url=http%3A%2F%2Flocalhost%2Ftest'); + it('snake_cases all params', () => { + const base = 'https://clerk.com'; + const params = { redirectUrl: '1', test2: '2' }; + const url = buildURL({ base, hashSearchParams: params }, { stringify: true }); + expect(url).toBe('https://clerk.com/#/?redirect_url=1&test2=2'); }); +}); - it('converts keys from camel to snake case', () => { - const base = new URL('https://dashboard.clerk.com'); - const res = appendAsQueryParams(base, { redirectUrl: 'http://localhost/test' }); - expect(res).toBe('https://dashboard.clerk.com/#/?redirect_url=http%3A%2F%2Flocalhost%2Ftest'); +describe('trimTrailingSlash(string)', () => { + it('trims all the final slashes', () => { + expect(trimTrailingSlash('')).toBe(''); + expect(trimTrailingSlash('/foo')).toBe('/foo'); + expect(trimTrailingSlash('/foo/')).toBe('/foo'); + expect(trimTrailingSlash('//foo//bar///')).toBe('//foo//bar'); }); +}); - it('keeps origin before appending if base and url have different origin', () => { - const base = new URL('https://dashboard.clerk.com'); - const url = new URL('https://www.google.com/something').href; - const res = appendAsQueryParams(base, { redirect_url: url }); - expect(res).toBe('https://dashboard.clerk.com/#/?redirect_url=https%3A%2F%2Fwww.google.com%2Fsomething'); +describe('trimLeadingSlash(string)', () => { + it('trims all the leading slashes', () => { + expect(trimLeadingSlash('')).toBe(''); + expect(trimLeadingSlash('/foo')).toBe('foo'); + expect(trimLeadingSlash('/foo/')).toBe('foo/'); + expect(trimLeadingSlash('//foo//bar///')).toBe('foo//bar///'); }); }); diff --git a/packages/clerk-js/src/utils/querystring.ts b/packages/clerk-js/src/utils/querystring.ts index b0f3f03ea3b..b5b6138a589 100644 --- a/packages/clerk-js/src/utils/querystring.ts +++ b/packages/clerk-js/src/utils/querystring.ts @@ -3,7 +3,7 @@ import qs from 'qs'; export const getQueryParams = (queryString: string) => { return qs.parse(queryString || '', { ignoreQueryPrefix: true, - }); + }) as Record; }; export const stringifyQueryParams = (params: Record | Array) => { diff --git a/packages/clerk-js/src/utils/redirectUrls.ts b/packages/clerk-js/src/utils/redirectUrls.ts index 2ed410b7c51..9038af2fd81 100644 --- a/packages/clerk-js/src/utils/redirectUrls.ts +++ b/packages/clerk-js/src/utils/redirectUrls.ts @@ -1,10 +1,9 @@ import { applyFunctionToObj, filterProps, removeUndefined } from '@clerk/shared/object'; import { camelToSnake } from '@clerk/shared/underscore'; import type { ClerkOptions, RedirectOptions } from '@clerk/types'; -import type { ParsedQs } from 'qs'; import { assertNoLegacyProp, warnForNewPropShadowingLegacyProp } from './assertNoLegacyProp'; -import { buildURL, isAllowedRedirectOrigin, relativeToAbsoluteUrl } from './url'; +import { isAllowedRedirectOrigin, relativeToAbsoluteUrl } from './url'; export class RedirectUrls { private static keys: (keyof RedirectOptions)[] = [ @@ -39,23 +38,12 @@ export class RedirectUrls { return this.#getRedirectUrl('signUp'); } - toSearchParams() { - return this.#toSearchParams(this.#flattenPreservedProps()); + getPreservedSearchParams() { + return this.#toSearchParams(this.#flattenPreserved()); } - appendPreservedPropsToUrl(url: string, _otherParams: ParsedQs = {}) { - const params = new URLSearchParams(); - const otherParams = Object.entries(_otherParams); - const redirectParams = [...this.#toSearchParams(this.#flattenPreservedProps()).entries()]; - // merge with existing search params, if any - // redirect params should always win - [otherParams, redirectParams].flat().forEach(([key, value]) => { - typeof value === 'string' && params.set(key, value); - }); - - // TODO: A potential future improvement here is to remove the origin from the params we append - // if `url` and the param share the same origin - return buildURL({ base: url, hashSearch: params.toString() }, { stringify: true }); + toSearchParams() { + return this.#toSearchParams(this.#flattenAll()); } #toSearchParams(obj: Record): URLSearchParams { @@ -63,12 +51,56 @@ export class RedirectUrls { return new URLSearchParams(removeUndefined(camelCased) as Record); } - #flattenPreservedProps() { + #flattenPreserved() { return Object.fromEntries( Object.entries({ ...this.fromSearchParams }).filter(([key]) => RedirectUrls.preserved.includes(key)), ); } + #flattenAll() { + const signUpForceRedirectUrl = + this.fromSearchParams.signUpForceRedirectUrl || + this.fromProps.signUpForceRedirectUrl || + this.fromOptions.signUpForceRedirectUrl; + const signUpFallbackRedirectUrl = + this.fromSearchParams.signUpFallbackRedirectUrl || + this.fromProps.signUpFallbackRedirectUrl || + this.fromOptions.signUpFallbackRedirectUrl; + const signInForceRedirectUrl = + this.fromSearchParams.signInForceRedirectUrl || + this.fromProps.signInForceRedirectUrl || + this.fromOptions.signInForceRedirectUrl; + const signInFallbackRedirectUrl = + this.fromSearchParams.signInFallbackRedirectUrl || + this.fromProps.signInFallbackRedirectUrl || + this.fromOptions.signInFallbackRedirectUrl; + const afterSignInUrl = + this.fromSearchParams.afterSignInUrl || this.fromProps.afterSignInUrl || this.fromOptions.afterSignInUrl; + const afterSignUpUrl = + this.fromSearchParams.afterSignUpUrl || this.fromProps.afterSignUpUrl || this.fromOptions.afterSignUpUrl; + const redirectUrl = this.fromSearchParams.redirectUrl || this.fromProps.redirectUrl || this.fromOptions.redirectUrl; + + const res: RedirectOptions = { + signUpForceRedirectUrl, + signUpFallbackRedirectUrl, + signInFallbackRedirectUrl, + signInForceRedirectUrl, + afterSignInUrl, + afterSignUpUrl, + redirectUrl, + }; + + if (signUpForceRedirectUrl) { + delete res.signUpFallbackRedirectUrl; + } + + if (signInForceRedirectUrl) { + delete res.signInFallbackRedirectUrl; + } + + return res; + } + #getRedirectUrl(prefix: 'signIn' | 'signUp') { const forceKey = `${prefix}ForceRedirectUrl` as const; const fallbackKey = `${prefix}FallbackRedirectUrl` as const; diff --git a/packages/clerk-js/src/utils/url.ts b/packages/clerk-js/src/utils/url.ts index ddd3ff8d8bd..afe03cce608 100644 --- a/packages/clerk-js/src/utils/url.ts +++ b/packages/clerk-js/src/utils/url.ts @@ -47,6 +47,7 @@ interface BuildURLParams extends Partial { base?: string; hashPath?: string; hashSearch?: string; + hashSearchParams?: URLSearchParams | Record | Array>; } interface BuildURLOptions { @@ -77,7 +78,7 @@ export function buildURL( ): B extends true ? string : URL; export function buildURL(params: BuildURLParams, options: BuildURLOptions = {}): URL | string { - const { base, hashPath, hashSearch, searchParams, ...rest } = params; + const { base, hashPath, hashSearch, searchParams, hashSearchParams, ...rest } = params; let fallbackBase = ''; // This check is necessary for React native environments where window is undefined. @@ -94,7 +95,9 @@ export function buildURL(params: BuildURLParams, options: BuildURLOptions { - url.searchParams.set(key, value); + if (value !== null && value !== undefined) { + url.searchParams.set(camelToSnake(key), value); + } }); } @@ -103,24 +106,44 @@ export function buildURL(params: BuildURLParams, options: BuildURLOptions { + if (value !== null && value !== undefined) { + dummyUrlForHash.searchParams.set(camelToSnake(key), value); + } + }); + } + } + + // Keep just the pathname and the search const newHash = dummyUrlForHash.href.replace(DUMMY_URL_BASE, ''); - // Assign them to the hash of the main url - url.hash = newHash; + // if the hash is `/`, it means that nothing new was added to the hash + // so we can skip assigning it to the hash of the main url + if (newHash !== '/') { + // Assign them to the hash of the main url + url.hash = newHash; + } } const { stringify, skipOrigin } = options; @@ -180,25 +203,6 @@ export const stripSameOrigin = (url: URL, baseUrl: URL): string => { return sameOrigin ? stripOrigin(url) : `${url}`; }; -export const appendAsQueryParams = ( - baseUrl: string | URL, - values: Record = {}, -): string => { - const base = toURL(baseUrl); - const params = new URLSearchParams(); - for (const [key, val] of Object.entries(values)) { - if (!val) { - continue; - } - params.append(camelToSnake(key), val); - } - - // The following line will prepend the hash with a `/`. - // This is required for ClerkJS Components Hash router to work as expected - // as it treats the hash as sub-path with its nested querystring parameters. - return `${base}${params.toString() ? '#/?' + params.toString() : ''}`; -}; - export const hasExternalAccountSignUpError = (signUp: SignUpResource): boolean => { const { externalAccount } = signUp.verifications; return !!externalAccount.error; diff --git a/packages/react/src/components/SignInButton.tsx b/packages/react/src/components/SignInButton.tsx index 1f09b812eea..50cec831de3 100644 --- a/packages/react/src/components/SignInButton.tsx +++ b/packages/react/src/components/SignInButton.tsx @@ -7,12 +7,17 @@ import { withClerk } from './withClerk'; export const SignInButton = withClerk(({ clerk, children, ...props }: WithClerkProp) => { const { signUpFallbackRedirectUrl, forceRedirectUrl, fallbackRedirectUrl, signUpForceRedirectUrl, mode, ...rest } = props; - children = normalizeWithDefaultValue(children, 'Sign in'); const child = assertSingleChild(children)('SignInButton'); const clickHandler = () => { - const opts = { signUpFallbackRedirectUrl, forceRedirectUrl, fallbackRedirectUrl, signUpForceRedirectUrl }; + const opts = { + signUpFallbackRedirectUrl, + signUpForceRedirectUrl, + signInForceRedirectUrl: forceRedirectUrl, + signInFallbackRedirectUrl: fallbackRedirectUrl, + }; + if (mode === 'modal') { return clerk.openSignIn(opts); } diff --git a/packages/react/src/components/SignUpButton.tsx b/packages/react/src/components/SignUpButton.tsx index 68a7c7d904e..1db1a5f30ea 100644 --- a/packages/react/src/components/SignUpButton.tsx +++ b/packages/react/src/components/SignUpButton.tsx @@ -20,8 +20,8 @@ export const SignUpButton = withClerk(({ clerk, children, ...props }: WithClerkP const clickHandler = () => { const opts = { - fallbackRedirectUrl, - forceRedirectUrl, + signUpFallbackRedirectUrl: fallbackRedirectUrl, + signUpForceRedirectUrl: forceRedirectUrl, signInFallbackRedirectUrl, signInForceRedirectUrl, unsafeMetadata, diff --git a/packages/react/src/components/__tests__/SignInButton.test.tsx b/packages/react/src/components/__tests__/SignInButton.test.tsx index 3f82f3e3d3d..22050952ef6 100644 --- a/packages/react/src/components/__tests__/SignInButton.test.tsx +++ b/packages/react/src/components/__tests__/SignInButton.test.tsx @@ -52,28 +52,17 @@ describe('', () => { const btn = screen.getByText('Sign in'); await userEvent.click(btn); - expect(mockRedirectToSignIn).toHaveBeenCalledWith({ forceRedirectUrl: url }); + expect(mockRedirectToSignIn).toHaveBeenCalledWith({ signInForceRedirectUrl: url }); }); - it('handles forceRedirectUrl prop', async () => { - render(); - - const btn = screen.getByText('Sign in'); - await userEvent.click(btn); - - expect(mockRedirectToSignIn).toHaveBeenCalledWith({ - forceRedirectUrl: url, - }); - }); - - it('handles signUpForceRedirectUrl prop', async () => { - render(); + it('handles fallbackRedirectUrl prop', async () => { + render(); const btn = screen.getByText('Sign in'); await userEvent.click(btn); expect(mockRedirectToSignIn).toHaveBeenCalledWith({ - signUpForceRedirectUrl: url, + signInFallbackRedirectUrl: url, }); }); diff --git a/packages/react/src/components/__tests__/SignUpButton.test.tsx b/packages/react/src/components/__tests__/SignUpButton.test.tsx index 5c3f9542051..918ecf8baa6 100644 --- a/packages/react/src/components/__tests__/SignUpButton.test.tsx +++ b/packages/react/src/components/__tests__/SignUpButton.test.tsx @@ -53,28 +53,17 @@ describe('', () => { const btn = screen.getByText('Sign up'); userEvent.click(btn); await waitFor(() => { - expect(mockRedirectToSignUp).toHaveBeenCalledWith({ forceRedirectUrl: url }); + expect(mockRedirectToSignUp).toHaveBeenCalledWith({ signUpForceRedirectUrl: url }); }); }); - it('handles forceRedirectUrl prop', async () => { - render(); - const btn = screen.getByText('Sign up'); - userEvent.click(btn); - await waitFor(() => { - expect(mockRedirectToSignUp).toHaveBeenCalledWith({ - forceRedirectUrl: url, - }); - }); - }); - - it('handles forceRedirectUrl prop', async () => { - render(); + it('handles fallbackRedirectUrl prop', async () => { + render(); const btn = screen.getByText('Sign up'); userEvent.click(btn); await waitFor(() => { expect(mockRedirectToSignUp).toHaveBeenCalledWith({ - forceRedirectUrl: url, + signUpFallbackRedirectUrl: url, }); }); });