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
5 changes: 5 additions & 0 deletions .changeset/quiet-tickets-continue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': patch
---

Fix an issue where Clerk's ticket query parameters were not removed from the URL when completing a sign-up that was missing requirements.
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,52 @@ describe('completeSignUpFlow', () => {
expect(mockNavigate).not.toHaveBeenCalled();
});

it('removes ticket query parameters before calling handleComplete', async () => {
const mockSignUp = {
status: 'complete',
missingFields: [] as SignUpField[],
} as SignUpResource;
let currentUrl = new URL(
'http://localhost/sign-up/continue?__clerk_ticket=test_ticket&__clerk_invitation_token=test_invitation',
);
let ticketAtHandleComplete: string | null | undefined;
let invitationAtHandleComplete: string | null | undefined;

Object.defineProperty(window, 'location', {
configurable: true,
value: {
get href() {
return currentUrl.href;
},
get search() {
return currentUrl.search;
},
},
});
Object.defineProperty(window, 'history', {
configurable: true,
value: {
state: undefined,
replaceState: vi.fn((_state, _title, url) => {
currentUrl = new URL(url, currentUrl);
}),
},
});
mockHandleComplete.mockImplementationOnce(() => {
ticketAtHandleComplete = currentUrl.searchParams.get('__clerk_ticket');
invitationAtHandleComplete = currentUrl.searchParams.get('__clerk_invitation_token');
});

await completeSignUpFlow({
signUp: mockSignUp,
handleComplete: mockHandleComplete,
navigate: mockNavigate,
});

expect(ticketAtHandleComplete).toBeNull();
expect(invitationAtHandleComplete).toBeNull();
});

it('navigates to verify email page if email still unverified', async () => {
const mockSignUp = {
status: 'missing_requirements',
Expand Down
4 changes: 3 additions & 1 deletion packages/shared/src/internal/clerk-js/completeSignUpFlow.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { SignUpResource } from '../../types';
import { forwardClerkQueryParams } from './queryParams';
import { forwardClerkQueryParams, removeClerkQueryParam } from './queryParams';

type CompleteSignUpFlowProps = {
signUp: SignUpResource;
Expand Down Expand Up @@ -27,6 +27,8 @@ export const completeSignUpFlow = ({
oidcPrompt,
}: CompleteSignUpFlowProps): Promise<unknown> | undefined => {
if (signUp.status === 'complete') {
removeClerkQueryParam('__clerk_ticket');
removeClerkQueryParam('__clerk_invitation_token');
return handleComplete && handleComplete();
} else if (signUp.status === 'missing_requirements') {
if (signUp.missingFields.some(mf => mf === 'enterprise_sso')) {
Expand Down
10 changes: 7 additions & 3 deletions packages/ui/src/components/SignUp/SignUpContinue.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { removeClerkQueryParam } from '@clerk/shared/internal/clerk-js/queryParams';
import { useClerk } from '@clerk/shared/react';
import React, { useEffect, useMemo } from 'react';

Expand Down Expand Up @@ -180,13 +181,16 @@ function SignUpContinueInternal() {
verifyEmailPath: './verify-email-address',
verifyPhonePath: './verify-phone-number',
protectCheckPath: '../protect-check',
handleComplete: () =>
clerk.setActive({
handleComplete: () => {
removeClerkQueryParam('__clerk_ticket');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cleanup only runs when signUp.update() completes immediately. completeSignUpFlow forwards the ticket into email/phone verification and protect-check routes, whose terminal handlers call setActive without removing it, so those flows can still remount and attempt to consume an already-used ticket. Please apply the cleanup to every terminal sign-up completion path (preferably via a shared helper) and cover a continue → verification → activation case.

~ 🤖

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was a bit nervous to impact ~all the sign up routes, but I feel like removing the tickets universally should be safe. So I updated to move the removal logic into completeSignUp instead of forcing each caller to individually remove the parameters.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't HAVE to, I just wanted to call it out. Feel free to ignore for now.

removeClerkQueryParam('__clerk_invitation_token');
return clerk.setActive({
session: res.createdSessionId,
navigate: async ({ session, decorateUrl }) => {
await navigateOnSetActive({ session, redirectUrl: afterSignUpUrl, decorateUrl });
},
}),
});
},
navigate,
oidcPrompt: ctx.oidcPrompt,
}),
Expand Down
4 changes: 1 addition & 3 deletions packages/ui/src/components/SignUp/SignUpStart.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { getAlternativePhoneCodeProviderData } from '@clerk/shared/alternativePhoneCode';
import { isClerkAPIResponseError } from '@clerk/shared/error';
import { ERROR_CODES, SIGN_UP_MODES } from '@clerk/shared/internal/clerk-js/constants';
import { getClerkQueryParam, removeClerkQueryParam } from '@clerk/shared/internal/clerk-js/queryParams';
import { getClerkQueryParam } from '@clerk/shared/internal/clerk-js/queryParams';
import { useClerk } from '@clerk/shared/react';
import type { PhoneCodeChannel, PhoneCodeChannelData, SignUpResource } from '@clerk/shared/types';
import React from 'react';
Expand Down Expand Up @@ -170,8 +170,6 @@ function SignUpStartInternal(): JSX.Element {
protectCheckPath: 'protect-check',
continuePath: 'continue',
handleComplete: () => {
removeClerkQueryParam('__clerk_ticket');
removeClerkQueryParam('__clerk_invitation_token');
return setActive({
session: signUp.createdSessionId,
navigate: async ({ session, decorateUrl }) => {
Expand Down
72 changes: 72 additions & 0 deletions packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { CardStateProvider } from '@/ui/elements/contexts';

import { OptionsProvider } from '../../../contexts';
import { AppearanceProvider } from '../../../customizables';
import { SignUpContinue } from '../SignUpContinue';
import { SignUpStart } from '../SignUpStart';

const { createFixtures } = bindCreateFixtures('SignUp');
Expand Down Expand Up @@ -497,6 +498,77 @@ describe('SignUpStart', () => {
);
});

it('removes the ticket before setting the session active after continuing the sign up', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress({ required: true });
f.withPassword({ required: true });
f.startSignUpWithEmailAddress({ emailVerificationStatus: 'verified' });
});
let currentUrl = new URL('http://localhost/sign-up?__clerk_ticket=test_ticket');
let ticketAtSetActive: string | null | undefined;

Object.defineProperty(window, 'location', {
configurable: true,
value: {
get href() {
return currentUrl.href;
},
get search() {
return currentUrl.search;
},
},
});
Object.defineProperty(window, 'history', {
configurable: true,
value: {
state: undefined,
replaceState: vi.fn((_state, _title, url) => {
currentUrl = new URL(url, currentUrl);
}),
},
});
Comment on lines +510 to +529

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for cleanup/restoration of window.location or window.history in this test file
rg -n "afterEach|afterAll|beforeEach" -A5 packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx
rg -n "window.location|window.history" -B2 -A2 packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx

Repository: clerk/javascript

Length of output: 1862


🏁 Script executed:

#!/bin/bash
sed -n '500,640p' packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx

Repository: clerk/javascript

Length of output: 5422


🏁 Script executed:

#!/bin/bash
rg -n "afterEach|afterAll|beforeEach|window\.history|window\.location" packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx

Repository: clerk/javascript

Length of output: 652


Restore the window.history stub after this test.
window.location is reset later for the captcha cases, but window.history stays replaced with the minimal mock here, so any later test that expects the real History API can inherit the stubbed object and behave inconsistently.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ui/src/components/SignUp/__tests__/SignUpStart.test.tsx` around
lines 510 - 529, Restore the original window.history object after the test that
defines the minimal history mock, ensuring cleanup runs even when the test
fails. Update the setup/teardown surrounding the window.history
Object.defineProperty and preserve the existing window.location cleanup
behavior.


fixtures.signUp.create.mockResolvedValueOnce(fixtures.signUp as SignUpResource);
fixtures.router.navigate.mockImplementation((to, options) => {
currentUrl = new URL(`/sign-up/${to}`, currentUrl);
currentUrl.search = options?.searchParams?.toString() || '';
return Promise.resolve(true);
});
fixtures.signUp.update.mockImplementationOnce(() => {
fixtures.signUp.status = 'complete';
fixtures.signUp.createdSessionId = 'sess_ticket';
return Promise.resolve(fixtures.signUp);
});
fixtures.clerk.setActive.mockImplementationOnce(() => {
ticketAtSetActive = new URL(window.location.href).searchParams.get('__clerk_ticket');
return Promise.resolve();
});

const { rerender, userEvent } = render(<SignUpStart />, { wrapper });

await waitFor(() =>
expect(fixtures.signUp.create).toHaveBeenCalledWith({
strategy: 'ticket',
ticket: 'test_ticket',
unsafeMetadata: undefined,
}),
);
await waitFor(() =>
expect(fixtures.router.navigate).toHaveBeenCalledWith('continue', {
searchParams: new URLSearchParams('__clerk_ticket=test_ticket'),
}),
);
expect(currentUrl.href).toBe('http://localhost/sign-up/continue?__clerk_ticket=test_ticket');

rerender(<SignUpContinue />);
await userEvent.type(screen.getByLabelText('Password'), 'a-secure-password');
await userEvent.click(screen.getByText('Continue'));

await waitFor(() => expect(fixtures.signUp.update).toHaveBeenCalled());
await waitFor(() => expect(fixtures.clerk.setActive).toHaveBeenCalled());
expect(ticketAtSetActive).toBeNull();
});

it('should show the sign up form when ticket detected and mode is restricted', async () => {
const { wrapper, fixtures } = await createFixtures(f => {
f.withEmailAddress();
Expand Down
Loading