diff --git a/apps/react-example/src/App.tsx b/apps/react-example/src/App.tsx
index ebd18cac..079f284c 100644
--- a/apps/react-example/src/App.tsx
+++ b/apps/react-example/src/App.tsx
@@ -1,12 +1,14 @@
+import { useSendMagicLink, useVerifyMagicLink } from '@zerodev/wallet-react'
import {
AuthFlow,
+ type AuthFlowRenderArgs,
Button,
type PendingRequest,
type Request,
SignatureRequest,
usePendingRequest,
} from '@zerodev/wallet-react-kit'
-import { useId, useState } from 'react'
+import { useEffect, useId, useRef, useState } from 'react'
import { encodeFunctionData, erc20Abi, parseEther } from 'viem'
import {
useAccount,
@@ -19,6 +21,135 @@ import {
} from 'wagmi'
type SignatureRequestMode = 'default' | 'children' | 'controlled'
+type AuthFlowMode = 'default' | 'children'
+
+const AUTH_MODE_STORAGE_KEY = 'react-example:authMode'
+
+function readPersistedAuthMode(): AuthFlowMode {
+ if (typeof window === 'undefined') return 'default'
+ const value = window.localStorage.getItem(AUTH_MODE_STORAGE_KEY)
+ return value === 'children' ? 'children' : 'default'
+}
+
+function getCodeFromUrl(): string | null {
+ if (typeof window === 'undefined') return null
+ return new URLSearchParams(window.location.search).get('code')
+}
+
+function stripCodeFromUrl(): void {
+ if (typeof window === 'undefined') return
+ const url = new URL(window.location.href)
+ if (!url.searchParams.has('code')) return
+ url.searchParams.delete('code')
+ window.history.replaceState({}, '', url.toString())
+}
+
+// Module-scope guard: survives React 18 strict-mode remounts of the component,
+// which a useRef inside the component would not.
+let didVerifyForThisLoad = false
+
+function CustomAuthUI({ auth }: { auth: AuthFlowRenderArgs }) {
+ const {
+ step,
+ email,
+ otpId,
+ otpEncryptionTargetBundle,
+ config,
+ goToStep,
+ setEmail,
+ setOtpSession,
+ clearOtpSession,
+ } = auth
+ const [emailInput, setEmailInput] = useState('')
+ const { mutateAsync: sendMagicLink, isPending: isSending } =
+ useSendMagicLink()
+ const {
+ mutate: verifyMagicLink,
+ isPending: isVerifying,
+ error: verifyError,
+ } = useVerifyMagicLink({
+ mutation: {
+ onSuccess: () => {
+ clearOtpSession()
+ stripCodeFromUrl()
+ goToStep('authenticated')
+ config?.onSuccess?.()
+ },
+ onError: (err) => config?.onError?.(err),
+ },
+ })
+
+ useEffect(() => {
+ if (step !== 'verifying-otp' || didVerifyForThisLoad) return
+ const code = getCodeFromUrl()
+ if (!code || !otpId || !otpEncryptionTargetBundle) return
+ didVerifyForThisLoad = true
+ verifyMagicLink({ otpId, code, otpEncryptionTargetBundle })
+ }, [step, otpId, otpEncryptionTargetBundle, verifyMagicLink])
+
+ async function handleSend() {
+ if (!emailInput.trim() || !config) return
+ const result = await sendMagicLink({
+ email: emailInput,
+ redirectURL: config.magicLinkBaseUrl,
+ })
+ setEmail(emailInput)
+ setOtpSession(result)
+ goToStep('email-verification')
+ }
+
+ if (step === 'sign-up' || step === 'initializing') {
+ return (
+
+
+ Custom Auth UI
+
+ setEmailInput(e.target.value)}
+ className="w-full rounded-md border border-emerald-300 bg-white px-2 py-1 text-sm mb-2"
+ />
+
+
+ )
+ }
+
+ if (step === 'email-verification') {
+ return (
+
+ Magic link sent to {email}. Check
+ your inbox.
+
+ )
+ }
+
+ if (step === 'verifying-otp') {
+ return (
+
+ {verifyError ? (
+
+ Verify failed: {verifyError.message}
+
+ ) : isVerifying ? (
+ 'Verifying...'
+ ) : (
+ 'Preparing verification...'
+ )}
+
+ )
+ }
+
+ return null
+}
function CustomConfirmUI({
pendingRequest,
@@ -355,11 +486,37 @@ function WalletPanel() {
export function App() {
const { connect, connectors } = useConnect()
const { isConnected } = useAccount()
+ const [authMode, setAuthMode] = useState(readPersistedAuthMode)
+ const authModeSelectId = useId()
return (
{!isConnected ? (
<>
+
+
+
+
+
-
+
+ {authMode === 'default' &&
}
+ {authMode === 'children' && (
+
{(auth) => }
+ )}
>
) : (
diff --git a/packages/react-kit/src/auth/index.tsx b/packages/react-kit/src/auth/index.tsx
index 510db68a..896b8e52 100644
--- a/packages/react-kit/src/auth/index.tsx
+++ b/packages/react-kit/src/auth/index.tsx
@@ -1,4 +1,4 @@
-import { useEffect } from 'react'
+import { type ReactNode, useEffect } from 'react'
import { StatusView } from '../shared/components/StatusView'
import { useAuth } from './hooks/useAuth'
import { EmailVerification } from './pages/EmailVerification'
@@ -28,8 +28,15 @@ function WalletSelection() {
)
}
-export function AuthFlow() {
- const { step, goToStep } = useAuth()
+export type AuthFlowRenderArgs = ReturnType
+
+export interface AuthFlowProps {
+ children?: (args: AuthFlowRenderArgs) => ReactNode
+}
+
+export function AuthFlow({ children }: AuthFlowProps = {}) {
+ const auth = useAuth()
+ const { step, goToStep } = auth
useEffect(() => {
if (step === 'initializing' && hasMagicLinkCodeInUrl()) {
@@ -37,6 +44,10 @@ export function AuthFlow() {
}
}, [step, goToStep])
+ if (typeof children === 'function') {
+ return children(auth)
+ }
+
switch (step) {
case 'initializing':
return null
diff --git a/packages/react-kit/src/auth/pages/Verifying.tsx b/packages/react-kit/src/auth/pages/Verifying.tsx
index 0084df79..24dcea14 100644
--- a/packages/react-kit/src/auth/pages/Verifying.tsx
+++ b/packages/react-kit/src/auth/pages/Verifying.tsx
@@ -1,5 +1,5 @@
import { useVerifyMagicLink } from '@zerodev/wallet-react'
-import { useEffect, useRef, useState } from 'react'
+import { useEffect, useState } from 'react'
import { AppLogo } from '../../shared/components/AppLogo'
import { Button } from '../../shared/components/Button'
import { ScreenWrapper } from '../../shared/components/ScreenWrapper'
@@ -12,6 +12,18 @@ function getCodeFromUrl(): string | null {
return new URLSearchParams(window.location.search).get('code')
}
+function stripCodeFromUrl(): void {
+ if (typeof window === 'undefined') return
+ const url = new URL(window.location.href)
+ if (!url.searchParams.has('code')) return
+ url.searchParams.delete('code')
+ window.history.replaceState({}, '', url.toString())
+}
+
+// Module-scope guard: survives React 18 strict-mode remounts, which would
+// reset a per-component useRef.
+let didVerifyForThisLoad = false
+
export function Verifying() {
const {
otpId,
@@ -22,8 +34,6 @@ export function Verifying() {
} = useAuth()
const [code] = useState(getCodeFromUrl)
- // ref to prevent useEffect firing twice in dev's StrictMode
- const hasVerifiedRef = useRef(false)
const {
mutate: verifyMagicLink,
error: verificationError,
@@ -32,6 +42,7 @@ export function Verifying() {
mutation: {
onSuccess: async () => {
clearOtpSession()
+ stripCodeFromUrl()
goToStep('authenticated')
config?.onSuccess?.()
},
@@ -42,10 +53,10 @@ export function Verifying() {
})
useEffect(() => {
- if (hasVerifiedRef.current || !otpId || !otpEncryptionTargetBundle || !code)
+ if (didVerifyForThisLoad || !otpId || !otpEncryptionTargetBundle || !code)
return
- hasVerifiedRef.current = true
+ didVerifyForThisLoad = true
verifyMagicLink({ otpId, code, otpEncryptionTargetBundle })
}, [otpId, otpEncryptionTargetBundle, code, verifyMagicLink])
diff --git a/packages/react-kit/src/index.ts b/packages/react-kit/src/index.ts
index 07477b1c..1366167c 100644
--- a/packages/react-kit/src/index.ts
+++ b/packages/react-kit/src/index.ts
@@ -3,6 +3,7 @@
* React UI components and enhanced connector for ZeroDev Wallet SDK
*/
+export type { AuthFlowProps, AuthFlowRenderArgs } from './auth'
export { AuthFlow } from './auth'
// Auth