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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
MemberAuthor

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(ui): remove ticket parameters on sign up continue by dstaley · Pull Request #9255 · clerk/javascript · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
MemberAuthor

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(ui): remove ticket parameters on sign up continue by dstaley · Pull Request #9255 · clerk/javascript · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
MemberAuthor

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(ui): remove ticket parameters on sign up continue by dstaley · Pull Request #9255 · clerk/javascript · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
MemberAuthor

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(ui): remove ticket parameters on sign up continue by dstaley · Pull Request #9255 · clerk/javascript · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
MemberAuthor

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(ui): remove ticket parameters on sign up continue by dstaley · Pull Request #9255 · clerk/javascript · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
MemberAuthor

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(ui): remove ticket parameters on sign up continue by dstaley · Pull Request #9255 · clerk/javascript · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
MemberAuthor

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(ui): remove ticket parameters on sign up continue by dstaley · Pull Request #9255 · clerk/javascript · GitHub
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
Original file line numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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
MemberAuthor

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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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