跳到主要内容

5 篇博文 含有标签「React」

查看所有标签

绕过 Supabase Auth 实现 Playwright E2E 测试免登录

· 阅读需 4 分钟

在为客户构建 AI Agent SaaS 平台时遇到此问题,记录根因与解法。

TL;DR

E2E 测试不应该依赖真实的 OAuth 登录流程。通过在 useAuth hook 中检测 localStorage 的测试标记,直接注入 mock 认证状态,跳过 Supabase 初始化。同时将 Zustand store 的 loading 默认值改为 false,避免 AuthGuard 卡在无限 spinner。

问题现象

使用 Playwright 测试 React SPA 时,页面被 AuthGuard 组件保护,需要 Supabase 认证才能访问。测试启动后,页面一直显示 loading spinner,无法进入业务流程。

// AuthGuard 组件 - 测试时卡在这里
export function AuthGuard({ children }: AuthGuardProps) {
const { isAuthenticated, loading } = useAuth()

if (loading) {
return <Spinner /> // 永远显示 spinner
}

if (!isAuthenticated) {
return <Navigate to="/login" />
}

return <>{children}</>
}

测试代码尝试模拟登录,但 Supabase Auth SDK 内部状态无法通过简单的 API mock 控制。

根因

1. Supabase Auth 是异步初始化的

useAuth hook 在 useEffect 中调用 supabase.auth.getSession(),这是异步操作。测试环境下网络请求可能失败或超时,导致状态永远停留在 loading: true

2. Zustand Store 的默认值问题

// authStore.ts - 问题代码
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
loading: true, // 👈 默认值是 true
// ...
}),
{ name: 'auth-storage' }
)
)

测试启动时,loading: true + 异步初始化失败 = 永远 loading。

3. OAuth 流程无法自动化

即使能 mock API,OAuth 的重定向流程需要真实浏览器交互,E2E 测试无法可靠模拟。

解决方案

步骤 1:在 useAuth hook 中添加测试模式检测

// hooks/useAuth.ts
export function useAuth() {
const { user, token, loading, setUser, setToken, setLoading } = useAuthStore()

useEffect(() => {
const initAuth = async () => {
// 👇 优先检测测试模式
const testAuthUser = localStorage.getItem('test-auth-user')
const testAuthToken = localStorage.getItem('test-auth-token')

if (testAuthUser && testAuthToken) {
try {
const userData = JSON.parse(testAuthUser) as User
setUser(userData)
setToken(testAuthToken)
setLoading(false)
console.log('[useAuth] Using test mode auth')
return // 👈 直接返回,跳过 Supabase 初始化
} catch (e) {
console.error('Failed to parse test auth user:', e)
}
}

// 👇 正常模式:走 Supabase Auth
try {
const { data: { session } } = await supabase.auth.getSession()
if (session) {
setUser(session.user as User)
setToken(session.access_token)
}
} catch (error) {
console.error('Auth init failed:', error)
} finally {
setLoading(false)
}
}

initAuth()

// 👇 测试模式下跳过 auth state listener
if (localStorage.getItem('test-auth-user')) {
return
}

const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event, session) => {
// ... 正常的 auth state 处理
}
)

return () => subscription.unsubscribe()
}, [])
}

步骤 2:修改 Zustand Store 默认值

// stores/authStore.ts
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
loading: false, // 👈 改为 false,让 useAuth hook 控制状态
setUser: (user) => set({ user }),
setToken: (token) => set({ token }),
setLoading: (loading) => set({ loading }),
logout: () => set({ user: null, token: null, loading: false }),
}),
{
name: 'auth-storage',
partialize: (state) => ({ user: state.user, token: state.token }),
}
)
)

步骤 3:在 Playwright Fixture 中注入测试认证

// e2e/fixtures.ts
import { test as base } from '@playwright/test'

export const mockUser = {
id: 'test-user-id',
email: 'test@example.com',
created_at: '2024-01-01T00:00:00Z',
}

export const test = base.extend({
authenticatedPage: async ({ page }, use) => {
// 先访问页面以设置 localStorage 的 origin
await page.goto('/login')

// 👇 注入测试认证状态到 localStorage
await page.evaluate(
({ user, token }) => {
localStorage.setItem('test-auth-user', JSON.stringify(user))
localStorage.setItem('test-auth-token', token)
},
{ user: mockUser, token: 'mock-access-token' }
)

// 导航到受保护页面,useAuth 会检测到测试模式
await page.goto('/dashboard')

await use(page)
},
})

步骤 4:在测试中使用

// e2e/dashboard.spec.ts
import { test, expect } from './fixtures'

test('dashboard shows user agents', async ({ authenticatedPage }) => {
// authenticatedPage 已经通过认证,无需登录
await expect(authenticatedPage.getByText('Test Agent')).toBeVisible()
})

完整代码结构

agent-frontend/
├── e2e/
│ ├── fixtures.ts # Playwright fixture + mock 数据
│ ├── dashboard.spec.ts # 测试用例
│ └── ...
├── src/
│ ├── hooks/
│ │ └── useAuth.ts # 测试模式检测
│ └── stores/
│ └── authStore.ts # loading: false 默认值
└── playwright.config.ts

关键要点

  1. 测试模式 key 使用特殊前缀test-auth-* 不会在生产环境中出现
  2. 检测优先于初始化:先检查 localStorage,再走 Supabase Auth
  3. 跳过 auth listener:测试模式下不需要监听 auth state 变化
  4. loading 默认值改为 false:让 hook 显式控制 loading 状态

对类似需求感兴趣?联系合作

修复 React 列表 key 重复导致的 DOM 报错

· 阅读需 3 分钟

在开发 AI Agent 对话界面时遇到此问题,记录根因与解法。

TL;DR

Date.now() 毫秒级时间戳可能在同一毫秒内重复,作为 React 列表 key 会导致 DOM 报错。解决方案是添加随机后缀,或使用 crypto.randomUUID()

问题现象

聊天界面快速发送消息时,控制台报错:

Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.

消息列表渲染异常,部分消息消失或错位。

根因

原代码使用 Date.now() 生成消息 ID:

// ❌ 问题代码
const id = `msg-${Date.now()}-user`

Date.now() 返回毫秒级时间戳(如 1742345678001)。问题在于:

  1. 同一毫秒内多次调用返回相同值 — JavaScript 事件循环中,同步代码执行速度远快于 1ms
  2. 快速操作触发多次调用 — 用户快速发送消息、SSE 流式响应同时创建多条消息
  3. key 重复破坏 reconciliation — React 认为 key 相同的是同一元素,导致 DOM 操作错乱

示例:用户在 1ms 内发送两条消息,两条消息的 key 都是 msg-1742345678001-user

解决方案

方案一:添加随机后缀(推荐)

// ✅ 修复后
const id = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}-user`
  • Math.random().toString(36) 生成 36 进制随机字符串
  • .slice(2, 9) 截取 7 位,提供足够的唯一性
  • 时间戳 + 随机串的组合,碰撞概率极低

方案二:使用 crypto.randomUUID()

// ✅ 更严格方案(需要现代浏览器或 Node 15.6+)
const id = crypto.randomUUID() // 如 "550e8400-e29b-41d4-a716-446655440000"
  • 密码学安全的唯一标识符
  • 无碰撞保证
  • 兼容性:Chrome 92+、Firefox 95+、Safari 15.4+

方案三:计数器 + 时间戳

let counter = 0
const id = `msg-${Date.now()}-${counter++}-user`
  • 简单可靠
  • 需要维护计数器状态

完整示例

// Zustand store 中的消息创建
interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
timestamp: number
}

export const useChatStore = create<ChatState>((set) => ({
messages: [],

addUserMessage: (content: string) => {
// ✅ 时间戳 + 随机后缀
const id = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}-user`
const message: ChatMessage = {
id,
role: 'user',
content,
timestamp: Date.now(),
}
set((state) => ({
messages: [...state.messages, message],
}))
return id
},
}))

关键原则

  1. key 必须唯一且稳定 — 同一元素在兄弟节点间 key 不能重复
  2. 避免使用 index 作为 key — 列表顺序变化时会出问题
  3. 避免仅用时间戳 — 毫秒级不够精确,微秒级(performance.now())也不可靠

对类似需求感兴趣?联系合作

在 Zustand Store 中实现数据缓存

· 阅读需 3 分钟

在构建 AI Agent 平台时遇到此问题,记录根因与解法。

TL;DR

在 Zustand store 中添加 lastFetchTime 字段和 TTL 常量,请求前检查缓存是否过期。10 行代码实现简单有效的数据缓存,避免多页面重复请求同一数据。

问题现象

MCP 工具列表被多个页面使用(Agent 设置页、工具市场页、聊天页),每次进入页面都触发 API 请求:

GET /api/mcp-tools  → 200  (AgentSettingsPage)
GET /api/mcp-tools → 200 (McpToolsPage)
GET /api/mcp-tools → 200 (ChatPage tool selector)

工具列表变化频率很低(管理员手动配置),频繁请求浪费带宽且影响页面加载速度。

根因

直接在组件中调用 API,没有缓存层:

// ❌ 无缓存:每次挂载都请求
function McpToolsPage() {
const [tools, setTools] = useState([])

useEffect(() => {
mcpToolsApi.list().then(setTools)
}, [])

return <ToolList tools={tools} />
}

问题:

  1. 每个页面独立请求 — 没有全局状态共享
  2. 短时间内重复请求 — 用户在页面间跳转时触发多次
  3. 无法控制刷新频率 — 即使数据未变化也重新获取

解决方案

Zustand Store + TTL 缓存

// src/stores/mcpToolsStore.ts
import { create } from 'zustand'
import { mcpToolsApi, type McpTool } from '@/services/api'

const CACHE_TTL = 10 * 60 * 1000 // 10 分钟

interface McpToolsState {
tools: McpTool[]
lastFetchTime: number | null
loading: boolean
error: string | null

fetchTools: (force?: boolean) => Promise<void>
clearError: () => void
}

export const useMcpToolsStore = create<McpToolsState>((set, get) => ({
tools: [],
lastFetchTime: null,
loading: false,
error: null,

fetchTools: async (force = false) => {
const { tools, lastFetchTime } = get()

// 有缓存且未过期且非强制 → 跳过
if (tools.length && lastFetchTime && !force) {
if (Date.now() - lastFetchTime < CACHE_TTL) {
return // 缓存命中,直接返回
}
}

set({ loading: true, error: null })
try {
const data = await mcpToolsApi.list()
set({ tools: data, lastFetchTime: Date.now(), loading: false })
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch tools'
set({ error: message, loading: false })
}
},

clearError: () => set({ error: null }),
}))

组件中使用

// ✅ 使用 store 缓存
function McpToolsPage() {
const { tools, loading, fetchTools } = useMcpToolsStore()

useEffect(() => {
fetchTools() // 自动检查缓存
}, [fetchTools])

if (loading) return <Spinner />
return <ToolList tools={tools} />
}

// 强制刷新
function RefreshButton() {
const { fetchTools } = useMcpToolsStore()
return <button onClick={() => fetchTools(true)}>刷新</button>
}

核心逻辑解析

// 缓存检查逻辑
if (tools.length && lastFetchTime && !force) {
if (Date.now() - lastFetchTime < CACHE_TTL) {
return // 缓存有效,跳过请求
}
}
条件说明
tools.length已有数据(空数组不算有效缓存)
lastFetchTime记录了上次请求时间
!force非强制刷新
Date.now() - lastFetchTime < CACHE_TTL未超过过期时间

适用场景

场景是否适合原因
工具列表、配置字典✅ 适合变化频率低,多页面共享
用户权限、角色✅ 适合会话内基本不变
实时数据(消息、通知)❌ 不适合需要最新状态
分页列表❌ 不适合数据量大,缓存策略复杂

扩展:更精细的缓存控制

interface CacheOptions {
ttl: number // 过期时间
staleWhileRevalidate: boolean // 过期后先返回旧数据再更新
}

// 过期后后台刷新,先返回缓存数据
if (tools.length && lastFetchTime) {
const age = Date.now() - lastFetchTime
if (age < CACHE_TTL) {
return // 缓存新鲜
}
if (options.staleWhileRevalidate && age < CACHE_TTL * 2) {
// 缓存过期但可接受,后台刷新
mcpToolsApi.list().then(data => set({ tools: data, lastFetchTime: Date.now() }))
return
}
}

关键原则

  1. 缓存时间要合理 — 根据数据变化频率设置 TTL
  2. 提供强制刷新入口 — 用户可手动刷新最新数据
  3. 首次加载要有 loading 状态 — 空数据时不应跳过请求

对类似需求感兴趣?联系合作

React SPA 集成 Google Analytics 4 完整指南

· 阅读需 3 分钟

TL;DR

React SPA 集成 GA4 的关键点:1) 禁用 send_page_view: false 避免重复追踪;2) 用 useLocation 监听路由变化手动发送 pageview;3) 登录后设置 user_id 实现跨设备追踪。

问题现象

在 React SPA 中直接使用 GA4 默认配置会导致:

  1. 首次加载时 page_view 重复计数
  2. 路由切换时不触发 page_view
  3. 无法追踪登录用户的跨设备行为

根因

GA4 默认在脚本加载时自动发送一次 page_view 事件。但 SPA 的路由切换不刷新页面,GA4 无法感知 URL 变化。同时,User-ID 需要在用户登录后手动设置,默认配置无法关联用户身份。

解决方案

1. 禁用自动 page_view

index.html 中加载 GA4 时,设置 send_page_view: false

<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX', { send_page_view: false });
</script>

2. 创建 Analytics 组件追踪路由

// src/components/Analytics.tsx
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
import { useAuthStore } from '@/stores/authStore'

declare global {
interface Window {
gtag: (
command: 'config' | 'event' | 'js' | 'set',
targetIdOrDate: string | Date,
params?: Record<string, unknown>
) => void
}
}

export function Analytics() {
const location = useLocation()
const user = useAuthStore((state) => state.user)

useEffect(() => {
if (typeof window.gtag === 'function') {
const params: Record<string, unknown> = {
page_path: location.pathname + location.search,
}
// 已登录用户添加 user_id
if (user?.id) {
params.user_id = user.id
}
window.gtag('config', 'G-XXXXXXXXXX', params)
}
}, [location, user?.id])

return null
}

3. 包裹路由根节点

// src/app/routes.tsx
import { Outlet } from 'react-router-dom'
import { Analytics } from '@/components/Analytics'

function RootLayout() {
return (
<>
<Analytics />
<Outlet />
</>
)
}

export const router = createBrowserRouter([
{
element: <RootLayout />,
children: [
// 你的路由配置...
],
},
])

4. 登录时设置 User-ID(可选增强)

// src/hooks/useAuth.ts
import { supabase } from '@/services/supabase'

export function useAuth() {
useEffect(() => {
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(event, session) => {
if (event === 'SIGNED_IN' && session) {
// 设置 GA4 User-ID
if (typeof window.gtag === 'function') {
window.gtag('config', 'G-XXXXXXXXXX', {
user_id: session.user.id
})
}
}
}
)
return () => subscription.unsubscribe()
}, [])
}

FAQ

Q: React SPA 中 GA4 为什么不追踪路由变化?

A: GA4 默认只在页面加载时发送 page_view。SPA 路由切换不刷新页面,需要手动调用 gtag('config', ...) 发送 pageview。

Q: GA4 User-ID 有什么用?

A: User-ID 可以关联同一用户在不同设备上的行为,用于跨设备分析、用户留存分析等高级功能。需要在 GA4 后台开启 User-ID 功能视图。

Q: 如何验证 GA4 配置是否正确?

A: 使用 Chrome 扩展 "Google Tag Assistant" 或 GA4 DebugView(需开启 debug_mode)。检查每次路由切换是否触发 page_view 事件,以及 user_id 是否正确设置。

实现 React 级联选择下拉框

· 阅读需 4 分钟

TL;DR

级联选择的核心是:父级变化时,必须重置子级为有效值。使用 Record<string, Option[]> 类型映射数据,在 onValueChange 回调中同步更新子级状态。

问题现象

实现 Provider → Model 级联选择时,切换 Provider 后:

// 切换前:provider = "openai", model = "gpt-4o"
// 切换后:provider = "anthropic", model = "gpt-4o" ❌

// Model 下拉框显示为空,因为 "gpt-4o" 不在 anthropic 的模型列表中
<Select value={model}> // model 值不在 options 中,显示空白

或者提交表单时,Model 值是上一个 Provider 的模型,导致后端验证失败。

根因

React 受控组件的 value 必须存在于 options 中。当 Provider 变化时,Model 的 options 列表更新了,但 model state 仍保留旧值。如果旧值不在新的 options 中,Select 组件会显示为空。

关键问题:只更新了 options 数据,没有同步更新 state 值

解决方案

1. 定义数据结构

const AVAILABLE_PROVIDERS = [
{ value: 'deepseek', label: 'DeepSeek' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'anthropic', label: 'Anthropic' },
]

// 使用 Record 类型建立映射关系
const AVAILABLE_MODELS: Record<string, { value: string; label: string }[]> = {
deepseek: [
{ value: 'deepseek-chat', label: 'DeepSeek Chat' },
{ value: 'deepseek-reasoner', label: 'DeepSeek Reasoner' },
],
openai: [
{ value: 'gpt-4o', label: 'GPT-4o' },
{ value: 'gpt-4o-mini', label: 'GPT-4o Mini' },
],
anthropic: [
{ value: 'claude-sonnet-4-20250514', label: 'Claude Sonnet 4' },
{ value: 'claude-3-5-sonnet-20241022', label: 'Claude 3.5 Sonnet' },
],
}

2. State 初始化

const [provider, setProvider] = useState('deepseek')
const [model, setModel] = useState('deepseek-chat') // 初始值必须是 provider 对应的第一个模型

3. 关键:Provider 变化时重置 Model

const handleProviderChange = (value: string | null) => {
if (value) {
setProvider(value)
// 核心:重置 model 到新 provider 的第一个选项
const models = AVAILABLE_MODELS[value]
if (models && models.length > 0) {
setModel(models[0].value)
}
}
}

4. 完整组件示例

import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'

function CascadeSelect() {
const [provider, setProvider] = useState('deepseek')
const [model, setModel] = useState('deepseek-chat')

const handleProviderChange = (value: string | null) => {
if (value) {
setProvider(value)
const models = AVAILABLE_MODELS[value]
if (models && models.length > 0) {
setModel(models[0].value)
}
}
}

return (
<>
{/* Provider 选择 */}
<Select value={provider} onValueChange={handleProviderChange}>
<SelectTrigger>
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent>
{AVAILABLE_PROVIDERS.map((p) => (
<SelectItem key={p.value} value={p.value}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>

{/* Model 选择 - 动态根据 provider 显示选项 */}
<Select value={model} onValueChange={(v) => v && setModel(v)}>
<SelectTrigger>
<SelectValue placeholder="Select model" />
</SelectTrigger>
<SelectContent>
{(AVAILABLE_MODELS[provider] || []).map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)
}

5. 表单重置

关闭 Dialog 时重置表单,避免下次打开时保留旧状态:

const resetForm = () => {
setProvider('deepseek')
setModel('deepseek-chat') // 重置为 provider 对应的默认值
}

const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
resetForm()
}
onOpenChange(newOpen)
}

FAQ

Q: React 级联选择下拉框切换后子级显示为空怎么办?

A: 在父级 onValueChange 回调中,同步更新子级 state 为新选项列表的第一个值。受控组件的 value 必须存在于 options 中。

Q: 如何用 TypeScript 定义级联选择的数据类型?

A: 使用 Record<string, Option[]> 类型建立父级到子级的映射,例如 Record<string, { value: string; label: string }[]>,类型安全且易于扩展。

Q: Select 组件的 value 和 options 不匹配会怎样?

A: 大多数 UI 库(Radix、MUI、Ant Design)会显示空白或 placeholder,不会报错。这是受控组件的预期行为——确保 value 始终是有效的选项值。