Skip to content
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
a659eaf
feat(react-router): Keyless support
wobsoriano Feb 6, 2026
e08192c
chore: clean up
wobsoriano Feb 6, 2026
140ba30
chore: clean up var name
wobsoriano Feb 6, 2026
6bc3243
chore: remove any assertion
wobsoriano Feb 6, 2026
15d5fc0
Merge branch 'main' into rob/react-router-keyless
wobsoriano Feb 6, 2026
6638d89
chore: throw only if not keyless mode
wobsoriano Feb 6, 2026
fe7577e
add integraiton test
wobsoriano Feb 6, 2026
4a6b959
chore: extract shared test utils
wobsoriano Feb 7, 2026
f79def9
chore: add changeset
wobsoriano Feb 7, 2026
9354709
chore: share main keyless fallback function
wobsoriano Feb 8, 2026
ae6c168
chore: delete md file
wobsoriano Feb 8, 2026
9e962c2
Merge branch 'main' into rob/react-router-keyless
wobsoriano Feb 8, 2026
818352a
chore: extract reusable file storage create function
wobsoriano Feb 9, 2026
57a8049
Add Keyless quickstart and refactor createFileStorage
wobsoriano Feb 9, 2026
834c6eb
chore: revert
wobsoriano Feb 9, 2026
069aaef
Merge branch 'main' into rob/react-router-keyless
wobsoriano Feb 9, 2026
32bec51
chore: Make resolveKeysWithKeylessFallback function a method on the k…
wobsoriano Feb 9, 2026
9fee85e
Merge branch 'main' into rob/react-router-keyless
wobsoriano Feb 9, 2026
a70b8a2
Merge branch 'main' into rob/react-router-keyless
wobsoriano Feb 9, 2026
1c39d21
fix(repo): Handle framework query param in keyless claim URLs integra…
wobsoriano Feb 10, 2026
6b04f9f
chore: share main keyless fallback function
wobsoriano Feb 8, 2026
567b5a5
refactor: use shared helper for Next.js keyless test
wobsoriano Feb 10, 2026
7183175
Merge branch 'main' into rob/react-router-keyless
wobsoriano Feb 10, 2026
0049b7f
delete doc
wobsoriano Feb 10, 2026
2e71569
chore: add missing framework requirement
wobsoriano Feb 10, 2026
ab36fa8
chore: remove redundant export
wobsoriano Feb 10, 2026
cda1649
fix type errors
wobsoriano Feb 10, 2026
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/curvy-jobs-pump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/react-router": minor
---

Introduce Keyless quickstart for React Router. This allows the Clerk SDK to be used without having to sign up and paste your keys manually.
148 changes: 148 additions & 0 deletions integration/testUtils/keylessHelpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import type { BrowserContext, Page } from '@playwright/test';
import { expect } from '@playwright/test';

import type { Application } from '../models/application';
import { createTestUtils } from './index';

/**
* Mocks the environment API call to return a claimed instance.
* Used in keyless mode tests to simulate an instance that has been claimed.
*/
export const mockClaimedInstanceEnvironmentCall = async (page: Page): Promise<void> => {
await page.route('*/**/v1/environment*', async route => {
const response = await route.fetch();
const json = await response.json();
const newJson = {
...json,
auth_config: {
...json.auth_config,
claimed_at: Date.now(),
},
};
await route.fulfill({ response, json: newJson });
});
};

/**
* Tests that the keyless popover can be toggled and the claim link opens the dashboard.
*/
export async function testToggleCollapsePopoverAndClaim({
page,
context,
app,
dashboardUrl,
framework,
}: {
page: Page;
context: BrowserContext;
app: Application;
dashboardUrl: string;
framework: string;
}): Promise<void> {
const u = createTestUtils({ app, page, context });
await u.page.goToAppHome();
await u.page.waitForClerkJsLoaded();
await u.po.expect.toBeSignedOut();

await u.po.keylessPopover.waitForMounted();

expect(await u.po.keylessPopover.isExpanded()).toBe(false);
await u.po.keylessPopover.toggle();
expect(await u.po.keylessPopover.isExpanded()).toBe(true);

const claim = u.po.keylessPopover.promptsToClaim();

const [newPage] = await Promise.all([context.waitForEvent('page'), claim.click()]);

await newPage.waitForLoadState();

await newPage.waitForURL(url => {
const signInForceRedirectUrl = url.searchParams.get('sign_in_force_redirect_url');
const signUpForceRedirectUrl = url.searchParams.get('sign_up_force_redirect_url');

const signInHasRequiredParams =
signInForceRedirectUrl?.includes(`${dashboardUrl}apps/claim`) &&
signInForceRedirectUrl?.includes('token=') &&
signInForceRedirectUrl?.includes(`framework=${framework}`);

const signUpRegularCase =
signUpForceRedirectUrl?.includes(`${dashboardUrl}apps/claim`) &&
signUpForceRedirectUrl?.includes('token=') &&
signUpForceRedirectUrl?.includes(`framework=${framework}`);

const signUpPrepareAccountCase =
signUpForceRedirectUrl?.startsWith(`${dashboardUrl}prepare-account`) &&
signUpForceRedirectUrl?.includes(encodeURIComponent('apps/claim')) &&
signUpForceRedirectUrl?.includes(encodeURIComponent('token=')) &&
signUpForceRedirectUrl?.includes(encodeURIComponent(`framework=${framework}`));

const signUpHasRequiredParams = signUpRegularCase || signUpPrepareAccountCase;

return url.pathname === '/apps/claim/sign-in' && signInHasRequiredParams && signUpHasRequiredParams;
});
}

/**
* Tests that a claimed application with missing explicit keys shows the popover expanded
* with a prompt to get keys from the dashboard.
*/
export async function testClaimedAppWithMissingKeys({
page,
context,
app,
dashboardUrl,
}: {
page: Page;
context: BrowserContext;
app: Application;
dashboardUrl: string;
}): Promise<void> {
await mockClaimedInstanceEnvironmentCall(page);
const u = createTestUtils({ app, page, context });
await u.page.goToAppHome();
await u.page.waitForClerkJsLoaded();

await u.po.keylessPopover.waitForMounted();
expect(await u.po.keylessPopover.isExpanded()).toBe(true);
await expect(u.po.keylessPopover.promptToUseClaimedKeys()).toBeVisible();

const [newPage] = await Promise.all([
context.waitForEvent('page'),
u.po.keylessPopover.promptToUseClaimedKeys().click(),
]);

await newPage.waitForLoadState();
await newPage.waitForURL(url => {
return url.href.startsWith(`${dashboardUrl}sign-in?redirect_url=${encodeURIComponent(dashboardUrl)}apps%2Fapp_`);
});
}

/**
* Tests that the keyless popover is removed after adding keys to .env and restarting the dev server.
*/
export async function testKeylessRemovedAfterEnvAndRestart({
page,
context,
app,
}: {
page: Page;
context: BrowserContext;
app: Application;
}): Promise<void> {
const u = createTestUtils({ app, page, context });
await u.page.goToAppHome();

await u.po.keylessPopover.waitForMounted();
expect(await u.po.keylessPopover.isExpanded()).toBe(false);

// Copy keys from keyless.json to .env
await app.keylessToEnv();

// Restart the dev server to pick up new env vars (Vite doesn't hot-reload .env)
await app.restart();

await u.page.goToAppHome();

// Keyless popover should no longer be present since we now have explicit keys
await u.po.keylessPopover.waitForUnmounted();
}
59 changes: 2 additions & 57 deletions integration/tests/next-quickstart-keyless.test.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,12 @@
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';

import type { Application } from '../models/application';
import { appConfigs } from '../presets';
import { createTestUtils } from '../testUtils';
import { mockClaimedInstanceEnvironmentCall, testToggleCollapsePopoverAndClaim } from '../testUtils/keylessHelpers';

const commonSetup = appConfigs.next.appRouterQuickstart.clone();

const mockClaimedInstanceEnvironmentCall = async (page: Page) => {
await page.route('*/**/v1/environment*', async route => {
const response = await route.fetch();
const json = await response.json();
const newJson = {
...json,
auth_config: {
...json.auth_config,
claimed_at: Date.now(),
},
};
await route.fulfill({ response, json: newJson });
});
};

test.describe('Keyless mode @quickstart', () => {
test.describe.configure({ mode: 'serial' });

Expand Down Expand Up @@ -71,47 +56,7 @@ test.describe('Keyless mode @quickstart', () => {
});

test('Toggle collapse popover and claim.', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goToAppHome();
await u.page.waitForClerkJsLoaded();
await u.po.expect.toBeSignedOut();

await u.po.keylessPopover.waitForMounted();

expect(await u.po.keylessPopover.isExpanded()).toBe(false);
await u.po.keylessPopover.toggle();
expect(await u.po.keylessPopover.isExpanded()).toBe(true);

const claim = await u.po.keylessPopover.promptsToClaim();

const [newPage] = await Promise.all([context.waitForEvent('page'), claim.click()]);

await newPage.waitForLoadState();

await newPage.waitForURL(url => {
const signInForceRedirectUrl = url.searchParams.get('sign_in_force_redirect_url');
const signUpForceRedirectUrl = url.searchParams.get('sign_up_force_redirect_url');

const signInHasRequiredParams =
signInForceRedirectUrl?.includes(`${dashboardUrl}apps/claim`) &&
signInForceRedirectUrl?.includes('token=') &&
signInForceRedirectUrl?.includes('framework=nextjs');

const signUpRegularCase =
signUpForceRedirectUrl?.includes(`${dashboardUrl}apps/claim`) &&
signUpForceRedirectUrl?.includes('token=') &&
signUpForceRedirectUrl?.includes('framework=nextjs');

const signUpPrepareAccountCase =
signUpForceRedirectUrl?.startsWith(`${dashboardUrl}prepare-account`) &&
signUpForceRedirectUrl?.includes(encodeURIComponent('apps/claim')) &&
signUpForceRedirectUrl?.includes(encodeURIComponent('token=')) &&
signUpForceRedirectUrl?.includes(encodeURIComponent('framework=nextjs'));

const signUpHasRequiredParams = signUpRegularCase || signUpPrepareAccountCase;

return url.pathname === '/apps/claim/sign-in' && signInHasRequiredParams && signUpHasRequiredParams;
});
await testToggleCollapsePopoverAndClaim({ page, context, app, dashboardUrl, framework: 'nextjs' });
});

test('Lands on claimed application with missing explicit keys, expanded by default, click to get keys from dashboard.', async ({
Expand Down
55 changes: 55 additions & 0 deletions integration/tests/react-router/keyless.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { test } from '@playwright/test';

import type { Application } from '../../models/application';
import { appConfigs } from '../../presets';
import {
testClaimedAppWithMissingKeys,
testKeylessRemovedAfterEnvAndRestart,
testToggleCollapsePopoverAndClaim,
} from '../../testUtils/keylessHelpers';

const commonSetup = appConfigs.reactRouter.reactRouterNode.clone();

test.describe('Keyless mode @react-router', () => {
test.describe.configure({ mode: 'serial' });
test.setTimeout(90_000);

test.use({
extraHTTPHeaders: {
'x-vercel-protection-bypass': process.env.VERCEL_AUTOMATION_BYPASS_SECRET || '',
},
});

let app: Application;
let dashboardUrl = 'https://dashboard.clerk.com/';

test.beforeAll(async () => {
app = await commonSetup.commit();
await app.setup();
await app.withEnv(appConfigs.envs.withKeyless);
if (appConfigs.envs.withKeyless.privateVariables.get('CLERK_API_URL')?.includes('clerkstage')) {
dashboardUrl = 'https://dashboard.clerkstage.dev/';
}
await app.dev();
});

test.afterAll(async () => {
// Keep files for debugging
await app?.teardown();
});

test('Toggle collapse popover and claim.', async ({ page, context }) => {
await testToggleCollapsePopoverAndClaim({ page, context, app, dashboardUrl });
});

test('Lands on claimed application with missing explicit keys, expanded by default, click to get keys from dashboard.', async ({
page,
context,
}) => {
await testClaimedAppWithMissingKeys({ page, context, app, dashboardUrl });
});

test('Keyless popover is removed after adding keys to .env and restarting.', async ({ page, context }) => {
await testKeylessRemovedAfterEnvAndRestart({ page, context, app });
});
});
Loading
Loading