diff --git a/.changeset/gold-snails-care.md b/.changeset/gold-snails-care.md
new file mode 100644
index 00000000000..f45c7a4ea52
--- /dev/null
+++ b/.changeset/gold-snails-care.md
@@ -0,0 +1,5 @@
+---
+'@clerk/clerk-react': patch
+---
+
+[Experimental] Fix issue with property access for state proxy
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b3ddf289db2..a86875008ee 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -293,7 +293,8 @@ jobs:
'nuxt',
'react-router',
'billing',
- 'machine'
+ 'machine',
+ 'custom',
]
test-project: ['chrome']
include:
diff --git a/integration/presets/custom-flows.ts b/integration/presets/custom-flows.ts
new file mode 100644
index 00000000000..bda524479f6
--- /dev/null
+++ b/integration/presets/custom-flows.ts
@@ -0,0 +1,19 @@
+import { constants } from '../constants';
+import { applicationConfig } from '../models/applicationConfig';
+import { templates } from '../templates';
+import { linkPackage } from './utils';
+
+const reactVite = applicationConfig()
+ .setName('custom-flows-react-vite')
+ .useTemplate(templates['custom-flows-react-vite'])
+ .setEnvFormatter('public', key => `VITE_${key}`)
+ .addScript('setup', 'pnpm install')
+ .addScript('dev', 'pnpm dev')
+ .addScript('build', 'pnpm build')
+ .addScript('serve', 'pnpm preview')
+ .addDependency('@clerk/clerk-react', constants.E2E_CLERK_VERSION || linkPackage('react'))
+ .addDependency('@clerk/themes', constants.E2E_CLERK_VERSION || linkPackage('themes'));
+
+export const customFlows = {
+ reactVite,
+} as const;
diff --git a/integration/presets/index.ts b/integration/presets/index.ts
index 08bb8abed3d..5048abef518 100644
--- a/integration/presets/index.ts
+++ b/integration/presets/index.ts
@@ -1,4 +1,5 @@
import { astro } from './astro';
+import { customFlows } from './custom-flows';
import { elements } from './elements';
import { envs, instanceKeys } from './envs';
import { expo } from './expo';
@@ -12,6 +13,7 @@ import { tanstack } from './tanstack';
import { vue } from './vue';
export const appConfigs = {
+ customFlows,
envs,
express,
longRunningApps: createLongRunningApps(),
diff --git a/integration/templates/custom-flows-react-vite/.gitignore b/integration/templates/custom-flows-react-vite/.gitignore
new file mode 100644
index 00000000000..a547bf36d8d
--- /dev/null
+++ b/integration/templates/custom-flows-react-vite/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/integration/templates/custom-flows-react-vite/components.json b/integration/templates/custom-flows-react-vite/components.json
new file mode 100644
index 00000000000..13e1db0b7a1
--- /dev/null
+++ b/integration/templates/custom-flows-react-vite/components.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": false,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/index.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "iconLibrary": "lucide"
+}
diff --git a/integration/templates/custom-flows-react-vite/eslint.config.js b/integration/templates/custom-flows-react-vite/eslint.config.js
new file mode 100644
index 00000000000..e821a89d6d5
--- /dev/null
+++ b/integration/templates/custom-flows-react-vite/eslint.config.js
@@ -0,0 +1,23 @@
+import js from '@eslint/js';
+import globals from 'globals';
+import reactHooks from 'eslint-plugin-react-hooks';
+import reactRefresh from 'eslint-plugin-react-refresh';
+import tseslint from 'typescript-eslint';
+import { globalIgnores } from 'eslint/config';
+
+export default tseslint.config([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{ts,tsx}'],
+ extends: [
+ js.configs.recommended,
+ tseslint.configs.recommended,
+ reactHooks.configs['recommended-latest'],
+ reactRefresh.configs.vite,
+ ],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ },
+ },
+]);
diff --git a/integration/templates/custom-flows-react-vite/index.html b/integration/templates/custom-flows-react-vite/index.html
new file mode 100644
index 00000000000..e4b78eae123
--- /dev/null
+++ b/integration/templates/custom-flows-react-vite/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Vite + React + TS
+
+
+
+
+
+
diff --git a/integration/templates/custom-flows-react-vite/package.json b/integration/templates/custom-flows-react-vite/package.json
new file mode 100644
index 00000000000..31bfde81a54
--- /dev/null
+++ b/integration/templates/custom-flows-react-vite/package.json
@@ -0,0 +1,40 @@
+{
+ "name": "hooks-revamp-vite-react",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "tsc -b && vite build",
+ "dev": "vite --port $PORT --no-open",
+ "lint": "eslint .",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@radix-ui/react-label": "^2.1.7",
+ "@radix-ui/react-slot": "^1.2.3",
+ "@tailwindcss/vite": "^4.1.11",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "lucide-react": "^0.539.0",
+ "react": "^19.1.1",
+ "react-dom": "^19.1.1",
+ "react-router": "^7.8.1",
+ "tailwind-merge": "^3.3.1",
+ "tailwindcss": "^4.1.11"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.30.1",
+ "@types/node": "^24.2.1",
+ "@types/react": "^19.1.8",
+ "@types/react-dom": "^19.1.6",
+ "@vitejs/plugin-react": "^4.6.0",
+ "eslint": "^9.30.1",
+ "eslint-plugin-react-hooks": "^5.2.0",
+ "eslint-plugin-react-refresh": "^0.4.20",
+ "globals": "^16.3.0",
+ "tw-animate-css": "^1.3.6",
+ "typescript": "~5.8.3",
+ "typescript-eslint": "^8.35.1",
+ "vite": "^7.0.4"
+ }
+}
diff --git a/integration/templates/custom-flows-react-vite/src/components/ui/button.tsx b/integration/templates/custom-flows-react-vite/src/components/ui/button.tsx
new file mode 100644
index 00000000000..0c4c9a7d343
--- /dev/null
+++ b/integration/templates/custom-flows-react-vite/src/components/ui/button.tsx
@@ -0,0 +1,56 @@
+import * as React from 'react';
+import { Slot } from '@radix-ui/react-slot';
+import { cva, type VariantProps } from 'class-variance-authority';
+
+import { cn } from '@/lib/utils';
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
+ destructive:
+ 'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
+ outline:
+ 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
+ secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
+ ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ size: {
+ default: 'h-9 px-4 py-2 has-[>svg]:px-3',
+ sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
+ lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
+ icon: 'size-9',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ },
+);
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ ...props
+}: React.ComponentProps<'button'> &
+ VariantProps & {
+ asChild?: boolean;
+ }) {
+ const Comp = asChild ? Slot : 'button';
+
+ return (
+
+ );
+}
+
+export { Button, buttonVariants };
diff --git a/integration/templates/custom-flows-react-vite/src/components/ui/card.tsx b/integration/templates/custom-flows-react-vite/src/components/ui/card.tsx
new file mode 100644
index 00000000000..961d8844f2e
--- /dev/null
+++ b/integration/templates/custom-flows-react-vite/src/components/ui/card.tsx
@@ -0,0 +1,78 @@
+import * as React from 'react';
+
+import { cn } from '@/lib/utils';
+
+function Card({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
+ return (
+
+ );
+}
+
+export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
diff --git a/integration/templates/custom-flows-react-vite/src/components/ui/input.tsx b/integration/templates/custom-flows-react-vite/src/components/ui/input.tsx
new file mode 100644
index 00000000000..bace6d65566
--- /dev/null
+++ b/integration/templates/custom-flows-react-vite/src/components/ui/input.tsx
@@ -0,0 +1,21 @@
+import * as React from 'react';
+
+import { cn } from '@/lib/utils';
+
+function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
+ return (
+
+ );
+}
+
+export { Input };
diff --git a/integration/templates/custom-flows-react-vite/src/components/ui/label.tsx b/integration/templates/custom-flows-react-vite/src/components/ui/label.tsx
new file mode 100644
index 00000000000..f0d7164be5e
--- /dev/null
+++ b/integration/templates/custom-flows-react-vite/src/components/ui/label.tsx
@@ -0,0 +1,19 @@
+import * as React from 'react';
+import * as LabelPrimitive from '@radix-ui/react-label';
+
+import { cn } from '@/lib/utils';
+
+function Label({ className, ...props }: React.ComponentProps) {
+ return (
+
+ );
+}
+
+export { Label };
diff --git a/integration/templates/custom-flows-react-vite/src/index.css b/integration/templates/custom-flows-react-vite/src/index.css
new file mode 100644
index 00000000000..7550e245bf6
--- /dev/null
+++ b/integration/templates/custom-flows-react-vite/src/index.css
@@ -0,0 +1,120 @@
+@import "tailwindcss";
+@import "tw-animate-css";
+
+@custom-variant dark (&:is(.dark *));
+
+@theme inline {
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+}
+
+:root {
+ --radius: 0.625rem;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+}
+
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
\ No newline at end of file
diff --git a/integration/templates/custom-flows-react-vite/src/lib/utils.ts b/integration/templates/custom-flows-react-vite/src/lib/utils.ts
new file mode 100644
index 00000000000..2819a830d24
--- /dev/null
+++ b/integration/templates/custom-flows-react-vite/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/integration/templates/custom-flows-react-vite/src/main.tsx b/integration/templates/custom-flows-react-vite/src/main.tsx
new file mode 100644
index 00000000000..7b170e17b18
--- /dev/null
+++ b/integration/templates/custom-flows-react-vite/src/main.tsx
@@ -0,0 +1,50 @@
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { BrowserRouter, Route, Routes } from 'react-router';
+import './index.css';
+import { ClerkProvider } from '@clerk/clerk-react';
+import { Home } from './routes/Home';
+import { SignIn } from './routes/SignIn';
+import { SignUp } from './routes/SignUp';
+import { Protected } from './routes/Protected';
+
+// Import your Publishable Key
+const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;
+
+if (!PUBLISHABLE_KEY) {
+ throw new Error('Add your Clerk Publishable Key to the .env file');
+}
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+
+
+
+ }
+ />
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+
+
+
+ ,
+);
diff --git a/integration/templates/custom-flows-react-vite/src/routes/Home.tsx b/integration/templates/custom-flows-react-vite/src/routes/Home.tsx
new file mode 100644
index 00000000000..2ce81082a77
--- /dev/null
+++ b/integration/templates/custom-flows-react-vite/src/routes/Home.tsx
@@ -0,0 +1,10 @@
+import { NavLink } from 'react-router';
+
+export function Home() {
+ return (
+
+ Sign In
+ Sign Up
+
+ );
+}
diff --git a/integration/templates/custom-flows-react-vite/src/routes/Protected.tsx b/integration/templates/custom-flows-react-vite/src/routes/Protected.tsx
new file mode 100644
index 00000000000..1f937b66941
--- /dev/null
+++ b/integration/templates/custom-flows-react-vite/src/routes/Protected.tsx
@@ -0,0 +1,15 @@
+import { useUser } from '@clerk/clerk-react';
+
+export function Protected() {
+ const { user, isLoaded } = useUser();
+ if (!isLoaded || !user) {
+ return;
+ }
+
+ return (
+
+
Protected
+
{user.emailAddresses[0].emailAddress}
+
+ );
+}
diff --git a/integration/templates/custom-flows-react-vite/src/routes/SignIn.tsx b/integration/templates/custom-flows-react-vite/src/routes/SignIn.tsx
new file mode 100644
index 00000000000..60f08d1734c
--- /dev/null
+++ b/integration/templates/custom-flows-react-vite/src/routes/SignIn.tsx
@@ -0,0 +1,314 @@
+'use client';
+
+import { cn } from '@/lib/utils';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { useUser } from '@clerk/clerk-react';
+import { useSignInSignal } from '@clerk/clerk-react/experimental';
+import { useState } from 'react';
+import { NavLink, useNavigate } from 'react-router';
+
+type AvailableStrategy = 'email_code' | 'phone_code' | 'password' | 'reset_password_email_code';
+
+export function SignIn({ className, ...props }: React.ComponentProps<'div'>) {
+ const { signIn, errors, fetchStatus } = useSignInSignal();
+ const [selectedStrategy, setSelectedStrategy] = useState(null);
+ const { isSignedIn } = useUser();
+ const navigate = useNavigate();
+
+ const handleOauth = async (strategy: 'oauth_google') => {
+ await signIn.sso({
+ strategy,
+ redirectUrl: '/sso-callback',
+ redirectUrlComplete: '/protected',
+ });
+ };
+
+ const handleSubmit = async (formData: FormData) => {
+ const identifier = formData.get('identifier');
+ if (!identifier) {
+ return;
+ }
+
+ await signIn.create({ identifier: identifier as string });
+ };
+
+ const handleSubmitResetPassword = async (formData: FormData) => {
+ const password = formData.get('password');
+ if (!password) {
+ return;
+ }
+
+ await signIn.resetPasswordEmailCode.submitPassword({
+ password: password as string,
+ });
+
+ if (signIn.status === 'complete') {
+ await signIn.finalize({
+ navigate: async () => {
+ navigate('/protected');
+ },
+ });
+ }
+ };
+
+ const handleVerify = async (formData: FormData) => {
+ const code = formData.get('code') as string;
+ const password = formData.get('password') as string;
+
+ if (selectedStrategy === 'email_code') {
+ await signIn.emailCode.verifyCode({ code: code });
+ } else if (selectedStrategy === 'phone_code') {
+ await signIn.phoneCode.verifyCode({ code: code });
+ } else if (selectedStrategy === 'password') {
+ await signIn.password({ password: password });
+ } else if (selectedStrategy === 'reset_password_email_code') {
+ await signIn.resetPasswordEmailCode.verifyCode({
+ code: code,
+ });
+ }
+
+ if (signIn.status === 'complete') {
+ await signIn.finalize({
+ navigate: async () => {
+ navigate('/protected');
+ },
+ });
+ }
+ };
+
+ const handleStrategyChange = async (strategy: AvailableStrategy) => {
+ if (strategy === 'email_code') {
+ // TODO @revamp-hooks: Allow calling sendCode without an argument
+ await signIn.emailCode.sendCode({});
+ } else if (strategy === 'phone_code') {
+ await signIn.phoneCode.sendCode({});
+ } else if (strategy === 'reset_password_email_code') {
+ await signIn.resetPasswordEmailCode.sendCode();
+ }
+
+ setSelectedStrategy(strategy);
+ };
+
+ if (signIn.status === 'needs_first_factor' && !selectedStrategy) {
+ return (
+
+
+
+ Choose a sign in method
+
+
+
+ {signIn.availableStrategies
+ .filter(({ strategy }) => strategy !== 'reset_password_email_code')
+ .map(({ strategy }) => (
+
+ ))}
+
+
+
+
+ );
+ }
+
+ if (signIn.status === 'needs_first_factor' && selectedStrategy === 'password') {
+ return (
+
+
+
+ Sign in with password
+ Enter your password below
+
+
+
+
+
+
+ );
+ }
+
+ if (
+ signIn.status === 'needs_first_factor' &&
+ (selectedStrategy === 'email_code' ||
+ selectedStrategy === 'phone_code' ||
+ selectedStrategy === 'reset_password_email_code')
+ ) {
+ return (
+
+
+
+ Sign in with code
+ Enter the code sent to your phone number or email below
+
+
+
+
+
+
+ );
+ }
+
+ if (signIn.status === 'needs_new_password') {
+ return (
+
+
+
+ Set new password
+
+
+
+
+
+
+ );
+ }
+
+ // Prevent showing the sign-in form if the sign-in is complete.
+ if (signIn.status === 'complete' || isSignedIn) {
+ return null;
+ }
+
+ return (
+
+
+
+ Sign in
+
+
+
+
+
+
+ );
+}
diff --git a/integration/templates/custom-flows-react-vite/src/routes/SignUp.tsx b/integration/templates/custom-flows-react-vite/src/routes/SignUp.tsx
new file mode 100644
index 00000000000..ef74268e35a
--- /dev/null
+++ b/integration/templates/custom-flows-react-vite/src/routes/SignUp.tsx
@@ -0,0 +1,195 @@
+'use client';
+
+import { cn } from '@/lib/utils';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { useSignUpSignal } from '@clerk/clerk-react/experimental';
+import { NavLink, useNavigate } from 'react-router';
+
+export function SignUp({ className, ...props }: React.ComponentProps<'div'>) {
+ const { signUp, errors, fetchStatus } = useSignUpSignal();
+ const navigate = useNavigate();
+
+ const handleSubmit = async (formData: FormData) => {
+ const username = formData.get('username') as string | null;
+ const emailAddress = formData.get('emailAddress') as string | null;
+ const phoneNumber = formData.get('phoneNumber') as string | null;
+ const password = formData.get('password') as string | null;
+
+ if (!emailAddress || !password) {
+ return;
+ }
+
+ if (phoneNumber) {
+ await signUp.password({ phoneNumber, password });
+ } else {
+ await signUp.password({ emailAddress, password });
+ }
+
+ if (signUp.status === 'missing_requirements') {
+ if (signUp.unverifiedFields.includes('email_address')) {
+ await signUp.verifications.sendEmailCode({ emailAddress });
+ } else if (signUp.unverifiedFields.includes('phone_number')) {
+ await signUp.verifications.sendPhoneCode({ phoneNumber });
+ }
+ }
+ };
+
+ const handleVerify = async (formData: FormData) => {
+ const code = formData.get('code') as string | null;
+
+ if (!code) {
+ return;
+ }
+
+ if (signUp.unverifiedFields.includes('email_address')) {
+ await signUp.verifications.verifyEmailCode({ code });
+ } else if (signUp.unverifiedFields.includes('phone_number')) {
+ await signUp.verifications.verifyPhoneCode({ code });
+ }
+
+ if (signUp.status === 'complete') {
+ await signUp.finalize({
+ navigate: async () => {
+ navigate('/protected');
+ },
+ });
+ }
+ };
+
+ if (
+ signUp.status === 'missing_requirements' &&
+ (signUp.unverifiedFields.includes('email_address') || signUp.unverifiedFields.includes('phone_number'))
+ ) {
+ return (
+
+
+
+ Sign up with code
+ Enter the code sent to your email or phone number below
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ Sign up
+ Enter your email or phone number below to create an account
+
+
+
+
+
+
+ );
+}
diff --git a/integration/templates/custom-flows-react-vite/src/vite-env.d.ts b/integration/templates/custom-flows-react-vite/src/vite-env.d.ts
new file mode 100644
index 00000000000..11f02fe2a00
--- /dev/null
+++ b/integration/templates/custom-flows-react-vite/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/integration/templates/custom-flows-react-vite/tsconfig.app.json b/integration/templates/custom-flows-react-vite/tsconfig.app.json
new file mode 100644
index 00000000000..d362bf629d7
--- /dev/null
+++ b/integration/templates/custom-flows-react-vite/tsconfig.app.json
@@ -0,0 +1,31 @@
+{
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ },
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["src"]
+}
diff --git a/integration/templates/custom-flows-react-vite/tsconfig.json b/integration/templates/custom-flows-react-vite/tsconfig.json
new file mode 100644
index 00000000000..2b78387c740
--- /dev/null
+++ b/integration/templates/custom-flows-react-vite/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "files": [],
+ "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }],
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ }
+}
diff --git a/integration/templates/custom-flows-react-vite/tsconfig.node.json b/integration/templates/custom-flows-react-vite/tsconfig.node.json
new file mode 100644
index 00000000000..f85a39906e5
--- /dev/null
+++ b/integration/templates/custom-flows-react-vite/tsconfig.node.json
@@ -0,0 +1,25 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2023",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/integration/templates/custom-flows-react-vite/vite.config.ts b/integration/templates/custom-flows-react-vite/vite.config.ts
new file mode 100644
index 00000000000..22f2fb044c3
--- /dev/null
+++ b/integration/templates/custom-flows-react-vite/vite.config.ts
@@ -0,0 +1,14 @@
+import path from 'node:path';
+import tailwindcss from '@tailwindcss/vite';
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
+});
diff --git a/integration/templates/index.ts b/integration/templates/index.ts
index ea75de29bc9..5c117a1f523 100644
--- a/integration/templates/index.ts
+++ b/integration/templates/index.ts
@@ -19,6 +19,7 @@ export const templates = {
'nuxt-node': resolve(__dirname, './nuxt-node'),
'react-router-node': resolve(__dirname, './react-router-node'),
'react-router-library': resolve(__dirname, './react-router-library'),
+ 'custom-flows-react-vite': resolve(__dirname, './custom-flows-react-vite'),
} as const;
if (new Set([...Object.values(templates)]).size !== Object.values(templates).length) {
diff --git a/integration/tests/custom-flows/sign-in.test.ts b/integration/tests/custom-flows/sign-in.test.ts
new file mode 100644
index 00000000000..bc14d123e36
--- /dev/null
+++ b/integration/tests/custom-flows/sign-in.test.ts
@@ -0,0 +1,104 @@
+import { expect, test } from '@playwright/test';
+import { parsePublishableKey } from '@clerk/shared/keys';
+import { clerkSetup } from '@clerk/testing/playwright';
+
+import type { Application } from '../../models/application';
+import { appConfigs } from '../../presets';
+import { createTestUtils, FakeUser } from '../../testUtils';
+
+test.describe('Custom Flows Sign In @custom', () => {
+ test.describe.configure({ mode: 'parallel' });
+ let app: Application;
+ let fakeUser: FakeUser;
+
+ test.beforeAll(async () => {
+ test.setTimeout(150_000);
+ app = await appConfigs.customFlows.reactVite.clone().commit();
+ await app.setup();
+ await app.withEnv(appConfigs.envs.withEmailCodes);
+ await app.dev();
+
+ const publishableKey = appConfigs.envs.withEmailCodes.publicVariables.get('CLERK_PUBLISHABLE_KEY');
+ const secretKey = appConfigs.envs.withEmailCodes.privateVariables.get('CLERK_SECRET_KEY');
+ const apiUrl = appConfigs.envs.withEmailCodes.privateVariables.get('CLERK_API_URL');
+ const { frontendApi: frontendApiUrl } = parsePublishableKey(publishableKey);
+
+ await clerkSetup({
+ publishableKey,
+ frontendApiUrl,
+ secretKey,
+ // @ts-expect-error
+ apiUrl,
+ dotenv: false,
+ });
+
+ const u = createTestUtils({ app });
+ fakeUser = u.services.users.createFakeUser({
+ fictionalEmail: true,
+ withPassword: true,
+ withPhoneNumber: true,
+ withUsername: true,
+ });
+ await u.services.users.createBapiUser(fakeUser);
+ });
+
+ test.afterAll(async () => {
+ await fakeUser.deleteIfExists();
+ await app.teardown();
+ });
+
+ test('can sign in with email code', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ await u.page.goToRelative('/sign-in');
+ await expect(u.page.getByText('Sign in', { exact: true })).toBeVisible();
+
+ await u.po.signIn.setIdentifier(fakeUser.email);
+ await u.po.signIn.continue();
+ await u.page.getByRole('button', { name: 'email_code', exact: true }).click();
+ await u.page.getByRole('textbox', { name: 'code' }).fill('424242');
+ await u.po.signIn.continue();
+ await u.page.waitForURL(/protected/);
+ await u.po.expect.toBeSignedIn();
+ });
+
+ test('renders error with invalid email code', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ await u.page.goToRelative('/sign-in');
+ await expect(u.page.getByText('Sign in', { exact: true })).toBeVisible();
+
+ await u.po.signIn.setIdentifier(fakeUser.email);
+ await u.po.signIn.continue();
+ await u.page.getByRole('button', { name: 'email_code', exact: true }).click();
+ await u.page.getByRole('textbox', { name: 'code' }).fill('000000');
+ await u.po.signIn.continue();
+ await expect(u.page.getByText('is incorrect')).toBeVisible();
+ });
+
+ test('can sign in with phone code', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ await u.page.goToRelative('/sign-in');
+ await expect(u.page.getByText('Sign in', { exact: true })).toBeVisible();
+
+ await u.po.signIn.setIdentifier(fakeUser.email);
+ await u.po.signIn.continue();
+ await u.page.getByRole('button', { name: 'phone_code', exact: true }).click();
+ await u.page.getByRole('textbox', { name: 'code' }).fill('424242');
+ await u.po.signIn.continue();
+ await u.page.waitForURL(/protected/);
+ await u.po.expect.toBeSignedIn();
+ });
+
+ test('can sign in with password', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ await u.page.goToRelative('/sign-in');
+ await expect(u.page.getByText('Sign in', { exact: true })).toBeVisible();
+
+ await u.po.signIn.setIdentifier(fakeUser.email);
+ await u.po.signIn.continue();
+ await u.page.getByRole('button', { name: 'password', exact: true }).click();
+ await u.page.getByRole('textbox', { name: 'password' }).fill(fakeUser.password);
+ await u.po.signIn.continue();
+ await u.page.waitForURL(/protected/);
+ await u.po.expect.toBeSignedIn();
+ });
+});
diff --git a/integration/tests/custom-flows/sign-up.test.ts b/integration/tests/custom-flows/sign-up.test.ts
new file mode 100644
index 00000000000..55e224121dd
--- /dev/null
+++ b/integration/tests/custom-flows/sign-up.test.ts
@@ -0,0 +1,69 @@
+import { expect, test } from '@playwright/test';
+import { parsePublishableKey } from '@clerk/shared/keys';
+import { clerkSetup } from '@clerk/testing/playwright';
+
+import type { Application } from '../../models/application';
+import { appConfigs } from '../../presets';
+import { createTestUtils, FakeUser } from '../../testUtils';
+
+test.describe('Custom Flows Sign Up @custom', () => {
+ test.describe.configure({ mode: 'parallel' });
+ let app: Application;
+ let fakeUser: FakeUser;
+
+ test.beforeAll(async () => {
+ test.setTimeout(150_000);
+ app = await appConfigs.customFlows.reactVite.clone().commit();
+ await app.setup();
+ await app.withEnv(appConfigs.envs.withEmailCodes);
+ await app.dev();
+
+ const publishableKey = appConfigs.envs.withEmailCodes.publicVariables.get('CLERK_PUBLISHABLE_KEY');
+ const secretKey = appConfigs.envs.withEmailCodes.privateVariables.get('CLERK_SECRET_KEY');
+ const apiUrl = appConfigs.envs.withEmailCodes.privateVariables.get('CLERK_API_URL');
+ const { frontendApi: frontendApiUrl } = parsePublishableKey(publishableKey);
+
+ await clerkSetup({
+ publishableKey,
+ frontendApiUrl,
+ secretKey,
+ // @ts-expect-error
+ apiUrl,
+ dotenv: false,
+ });
+
+ const u = createTestUtils({ app });
+ fakeUser = u.services.users.createFakeUser({
+ fictionalEmail: true,
+ withPassword: true,
+ withPhoneNumber: true,
+ withUsername: true,
+ });
+ });
+
+ test.afterEach(async () => {
+ await fakeUser.deleteIfExists();
+ });
+
+ test.afterAll(async () => {
+ await app.teardown();
+ });
+
+ test('can sign up with email and password', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ await u.page.goToRelative('/sign-up');
+ await expect(u.page.getByText('Sign up', { exact: true })).toBeVisible();
+
+ await u.po.signUp.signUp({ email: fakeUser.email, password: fakeUser.password });
+ // wait for the prepare call to complete
+ await u.page.waitForResponse(
+ response =>
+ response.request().method() === 'POST' &&
+ (response.url().includes('prepare_verification') || response.url().includes('prepare_first_factor')),
+ );
+ await u.page.getByRole('textbox', { name: 'code' }).fill('424242');
+ await u.po.signUp.continue();
+ await u.page.waitForURL(/protected/);
+ await u.po.expect.toBeSignedIn();
+ });
+});
diff --git a/package.json b/package.json
index 4390a3e067f..f24ea5fddd1 100644
--- a/package.json
+++ b/package.json
@@ -35,6 +35,7 @@
"test:integration:base": "pnpm playwright test --config integration/playwright.config.ts",
"test:integration:billing": "E2E_APP_ID=withBilling.* pnpm test:integration:base --grep @billing",
"test:integration:cleanup": "pnpm playwright test --config integration/playwright.cleanup.config.ts",
+ "test:integration:custom": "pnpm test:integration:base --grep @custom",
"test:integration:deployment:nextjs": "pnpm playwright test --config integration/playwright.deployments.config.ts",
"test:integration:elements": "E2E_APP_ID=elements.* pnpm test:integration:base --grep @elements",
"test:integration:expo-web": "E2E_APP_ID=expo.expo-web pnpm test:integration:base --grep @expo-web",
diff --git a/packages/react/src/stateProxy.ts b/packages/react/src/stateProxy.ts
index 9d2f150b451..cb18c479600 100644
--- a/packages/react/src/stateProxy.ts
+++ b/packages/react/src/stateProxy.ts
@@ -68,22 +68,31 @@ export class StateProxy implements State {
}
private buildSignUpProxy() {
+ const gateProperty = this.gateProperty.bind(this);
+ const gateMethod = this.gateMethod.bind(this);
+ const wrapMethods = this.wrapMethods.bind(this);
const target = () => this.client.signUp.__internal_future;
return {
errors: defaultErrors(),
fetchStatus: 'idle' as const,
signUp: {
- status: 'missing_requirements' as const,
- unverifiedFields: [],
- isTransferable: false,
-
- create: this.gateMethod(target, 'create'),
- sso: this.gateMethod(target, 'sso'),
- password: this.gateMethod(target, 'password'),
- finalize: this.gateMethod(target, 'finalize'),
-
- verifications: this.wrapMethods(() => target().verifications, [
+ get status() {
+ return gateProperty(target, 'status', 'missing_requirements');
+ },
+ get unverifiedFields() {
+ return gateProperty(target, 'unverifiedFields', []);
+ },
+ get isTransferable() {
+ return gateProperty(target, 'isTransferable', false);
+ },
+
+ create: gateMethod(target, 'create'),
+ sso: gateMethod(target, 'sso'),
+ password: gateMethod(target, 'password'),
+ finalize: gateMethod(target, 'finalize'),
+
+ verifications: wrapMethods(() => target().verifications, [
'sendEmailCode',
'verifyEmailCode',
'sendPhoneCode',
@@ -106,6 +115,16 @@ export class StateProxy implements State {
return c;
}
+ private gateProperty(getTarget: () => T, key: K, defaultValue: T[K]) {
+ return (() => {
+ if (!inBrowser() || !this.isomorphicClerk.loaded) {
+ return defaultValue;
+ }
+ const t = getTarget();
+ return t[key];
+ })() as T[K];
+ }
+
private gateMethod(getTarget: () => T, key: K) {
type F = Extract unknown>;
return (async (...args: Parameters): Promise> => {
diff --git a/turbo.json b/turbo.json
index 46fba8aadaf..2a410faebd0 100644
--- a/turbo.json
+++ b/turbo.json
@@ -356,6 +356,17 @@
"env": ["CLEANUP", "DEBUG", "E2E_*", "INTEGRATION_INSTANCE_KEYS"],
"inputs": ["integration/**"],
"outputLogs": "new-only"
+ },
+ "//#test:integration:custom": {
+ "dependsOn": [
+ "@clerk/testing#build",
+ "@clerk/clerk-js#build",
+ "@clerk/backend#build",
+ "@clerk/clerk-react#build"
+ ],
+ "env": ["CLEANUP", "DEBUG", "E2E_*", "INTEGRATION_INSTANCE_KEYS"],
+ "inputs": ["integration/**"],
+ "outputLogs": "new-only"
}
}
}