mcpField
T
tanstack-frontend

tanstack-frontend

github.com/alexander-zuev开发0439 次安装今天更新
代码仓库

关于 tanstack-frontend

面向使用 React 与 TanStack Start 的前端开发者,提供代码组织和实现约定。内容涵盖 FSD 分层、组件与数据层职责,以及 TanStack Query 的查询和变更模式。

主要功能

  1. 01TanStack Start 路由与服务端函数规范
  2. 02FSD 分层与目录约定
  3. 03组件、Hook 与模型职责划分
  4. 04查询和变更工厂模式
  5. 05页面状态与展示模型指导

适用场景

  1. 01规划新页面的路由、页面与功能层边界
  2. 02实现基于 TanStack Query 的数据读取与变更
  3. 03将请求状态整理为页面可用的展示模型

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/alexander-zuev/agent-skills --skill tanstack-frontend

安装后在 Claude Code 中输入/tanstack-frontend确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
data-access.md
performance.md
phone-surfaces.md
tanstack-router-query.md
visual-design.md

详细介绍

前端 — React + TanStack Start

本技能负责前端布局、React、TanStack Query、状态和 UI 约定。 类型、架构、模块、注释和通用测试请使用 typescript-standards。 添加服务端函数(包括存根)时,请使用 cloudflare-backend。

标记为 [detect: …] 的部分指出需要阅读的证据。根据示例展示的关注点选择参考代码。 不要根据仓库的历史年限推断兼容性。在处理实体、查询或变更之前,请阅读 data access。 编辑前,追踪目标项目的工厂函数、服务端函数、中间件注册和错误载荷。 说明所选层、成功/错误契约、源文件和重大冲突。

TanStack Start

  • 使用 TanStack Start,绝不使用 Next.js。采用传统 SSR + 水合,不使用 RSC,也不使用 'use client'。
  • 仅服务端代码:使用 createServerFn() — 在 src/server/entrypoints/functions/ 中定义 RPC 风格的服务端函数。
  • Loader 可同构运行 — 在服务端和客户端都会执行。机密信息和数据库访问必须通过服务端函数完成。
  • 需要时调整待处理状态的计时参数 — 使用原生路由选项,绝不使用人为延迟:pendingMs: 0(立即显示待处理状态;默认值为 1000ms),pendingMinMs: 500(避免闪烁)。大多数路由则依赖由 loader 注入的数据和 pendingComponent。

架构

心智模型

FSD 指 Feature-Sliced Design(特性切片设计)。实体表示用户、通话或余额等业务概念。 特性表示协调这些概念的用户操作。实体切片不负责屏幕工作流。

Route → Page → Feature → Entity → Shared
                       ↘ Shared UI

导入依赖指向更底层的层。没有可复用特性时,页面可以组合共享 UI 和实体。 项目将 Shared 调整为 ui/、lib/ 和可在浏览器安全使用的工作区包。

目录

apps/<app>/src/ 下的共享核心:

src/
├── routes/            # TanStack Router files — thin, delegate to pages/
├── pages/             # orchestrators
├── entities/          # business concepts: types, factories, pure derivations
│   └── <entity>/       # <entity>-queries.ts, <entity>-mutations.ts, domain files
├── features/          # user actions and reusable workflows
│   └── <feature>/
│       ├── components/
│       ├── hooks/
│       ├── queries/   # feature-owned data, when no entity owns it
│       └── models/    # or store/, schema/ as needed
├── ui/                # app-specific presentation + stylesheets
├── lib/               # clients (rpc, auth, sentry), router, shared hooks
└── server/            # backend half (see cloudflare-backend skill)

[detect] 现有实体切片可能采用扁平结构,也可能使用 model/ 和 api/ 等 FSD 分段。 遵循项目现有布局。不要仅为匹配 FSD 图示而引入分段或 barrel 文件。 较早的项目会将模型放在特性或 lib 下。不要重构无关代码。

[detect] 设计系统位置:共享的 packages/ui 工作区包(纯基础组件 + token 样式表,由多个应用使用)和/或应用本地的 src/ui/components/。应用本地组件可以按领域(auth/、media/)或类型(controls/、overlays/、feedback/ 等)组织 — 遵循现有目录,不要自行设计分类体系。

层级规则
  1. 路由是精简的适配器 — 配置路由并渲染页面。
  2. 页面组合特性 — 不内联服务;营销/落地页可以拥有本地的 sections/ 和 components/ 子目录树。
  3. 特性负责操作工作流、组件和钩子。实体负责可复用业务数据及其查询/变更工厂函数。
  4. UI 组件应保持纯粹 — 不调用 API、不包含业务逻辑、不导入特性。
  5. 按实体归属放置业务数据。根据复用次数决定展示代码保留在本地还是提取为共享代码。
✅ page → feature · feature → entity/model · feature → ui · entity → lib · hook → query factory
❌ ui component → feature · entity → feature · page → direct API call · circular feature pairs

实体切片不得导入同级实体切片。特性切片不得导入同级特性切片。 在使用方特性或页面中协调关系。适当时,将共享契约放入可在浏览器安全使用的核心包。 只有当项目已采用 FSD 显式的 @x 实体 API,且领域关系确实需要时,才使用该 API。 在添加或更改实体边界前,请阅读官方的 FSD layers 指南。

应用页面保持在 100 行以内。营销页面可以更长;除非任务范围要求,否则不要重构。


组件架构(Fowler 分层)

  • 组件 — 精简的 JSX,仅包含 props 和处理函数。少于 100 行(目标为 50 行)。
  • Hooks — 状态/副作用、合并两个或更多查询的领域对象,或表现模型(见下文)。仅转发一个查询及其调用处选项的 Hook 属于透传:直接在工厂函数或绑定的路由上下文对象上调用 useSuspenseQuery / useQuery,并在调用处添加额外选项(select、staleTime)。当一个 Hook 管理多个调用方共享的规则(例如何时轮询)时,封装一个查询是合理的;按该用途命名,并原样返回查询结果,让调用方保留 isPending、isError 和 data。不要将 UseQueryOptions 作为 Hook 参数——这会破坏类型推断。来源:The Query Options API——如果包装 Hook「所做的只是」转发查询,那它「似乎有点多余」。
// ✅ extra options at the call site
const { data } = useQuery({
  ...productQueries.detail({ productId: id }),
  select: (product) => product.title,
  staleTime: 20_000,
})

// ❌ do not wrap the factory and forward UseQueryOptions
function useProduct(id: ProductId, options?: UseQueryOptions<Product>) {
  return useQuery({
    ...productQueries.detail({ productId: id }),
    ...options,
  })
}
  • Models — 与框架无关的业务逻辑。不依赖 React。
  • Services/queries — 获取数据、进行 API ↔ 领域转换。领域映射放在 queryFn 中。视图映射是另一项工作——见「表现模型」。
表现模型

路由读取一个 Hook,并向下传递其结果。将查询状态映射为 props 不属于连接工作——这应放在负责屏幕视图模型的 Hook 中:如果只有一个屏幕读取它,则放在 pages/<page>/use-<thing>.ts;如果有两个或更多屏幕读取,则放在 features/<feature>/hooks/。entities/ 存放类型、查询工厂函数和纯派生逻辑,且不会导入 React。

领域模型、数据层与 Hook

来源:Modularizing React Applications(Fowler 和 Qiu)。每一层各司其职:

层职责本项目中的位置
视图负责渲染并调用回调。它不知道数据来自哪里。components
Hook“视图背后的状态机。”它持有状态、接收事件、调用数据层,并向视图提供领域对象和回调。features/*/hooks/、pages/*/use-*.ts
领域模型处理领域数据的业务规则,可表现为类型、纯函数或带 getter 的类。它“不知道视图的存在”,也可以在服务端运行。entities/
数据层获取响应并将其转换为领域对象(“获取并转换”),这样外部数据结构发生变化时,改动就只需落在一个文件中。查询和变更工厂(queryFn、mutationFn)

由此得出的规则。文章支持使用获取的数据构建不含 UI 数据的领域对象。文章没有涵盖加载或错误状态,因此关于请求状态的规则 1 和 2 是本项目的约定。

只显示一个查询的组件会直接读取该查询,并根据 isPending、isError 和 data 进行分支(参见异步状态渲染)。只有当请求状态成为实体的输入时,它才会成为领域事实。

  1. 领域模型只接收领域值。 实体类型或类绝不能接收查询或变更的结构,无论是导入还是重新定义都不行:不能包含 status、variables、data、error、isPending 或 refetch。这些描述的是数据如何传递,而不是业务含义。
  2. 数据层转换负载;Hook 转换请求状态。 queryFn 将响应转换为领域对象。Hook 在构建实体之前,将 TanStack 状态转换为领域事实:data → AdmittedCall、error → refused、status === "pending" → 尚无已接纳的调用。
  3. Hook 负责汇合;领域模型负责决策。 当多个来源(查询、变更、SDK、本地状态)共同构成一个概念时,Hook 会将它们的领域值传给一个实体。实体推导出状态或权限等规则。Hook 自身不包含任何业务规则。
  4. 领域类是不可变的。 Hook 会在渲染期间根据当前值构建它。它不会被原地更新,也不会被订阅。
// ❌ mapping in the route — 20 lines, still wrong
const { data, error, hasNextPage, fetchNextPage, refetch } = useInfiniteQuery(conversationQueries.list())
const conversationList = data
  ? { status: 'ready', conversations: data.pages.flatMap((p) => p.conversations), hasMore: hasNextPage, … }
  : { status: 'failed', error, onRetry: () => void refetch() }
// ✅ pages/conversations/use-conversation-list.ts returns the view-ready shape
export function useConversationList(): ConversationList {
  const query = useInfiniteQuery(conversationQueries.list())

  if (query.status === 'pending') return { status: 'pending' }
  if (query.status === 'error') {
    return { status: 'failed', error: query.error, onRetry: () => void query.refetch() }
  }
  return { status: 'ready', conversations: query.data.pages.flatMap((p) => p.conversations), … }
}

这里有两个具体原因,与风格无关:

  1. 根据 data/error 而不是 status 分支会遗漏 pending。 ❌ 版本会在正常加载时渲染“失败”。只有 status 能区分全部三种状态。
  2. 页面可以使用 fixture 渲染。 每种状态都会成为故事可以传入的值——包括“失败”状态,否则就必须通过模拟一个会拒绝的 fetch 来诱发它。

返回的结构是一个判别联合类型,并为每个分支定义字段,因此每个分支都不会携带自身无法使用的内容:

export type ConversationList =
  | { readonly status: 'pending' }
  | { readonly status: 'ready'; conversations: readonly ConversationSummary[]; hasMore: boolean; isLoadingMore: boolean; onLoadMore: () => void }
  | { readonly status: 'failed'; error: unknown; onRetry: () => void }

可复用的实体状态类型放在实体切片中。特定屏幕的展示类型放在对应页面 Hook 旁边。 绝不要在实体中导入页面类型。本项目保持实体模块不导入 React;FSD 本身允许实体包含 UI。

页面 Props
  • 将处理函数归入一个 actions 对象,而不是零散地传入一堆 onX Props——之后添加操作时,只需修改一个调用点,而不必改动每个 story 和测试框架。
  • 传递数据,而不是 ReactNode 插槽,前提是页面已经拥有插槽所需的数据。只用于包裹 <Header host={host} relay={relay} /> 的 header: ReactNode Prop,应该改为传入 host,并由页面自行渲染 Header。
  • 导航应使用 Link,绝不能使用操作回调。 onOpenThing: (id) => navigate(...) 会失去 cmd/中键点击、“在新标签页中打开”、右键 → 复制地址,以及悬停时显示 URL 的功能。useNavigate 用于命令式场景——例如变更后导航,而不是用于前往某处。
指标限制操作
行数<100(目标 50)提取组件/Hook
使用的 Hooks≤5提取自定义 Hook
Props≤7组合或上下文
三元表达式嵌套深度1提前返回

重构方式:提取 Hook(移出状态/副作用)、提取 Model(将计算移至纯 TS)、提取 Service(移出 API 调用)。反模式:臃肿的组件、万能 Hook、在 JSX 中写逻辑、与框架耦合、将 Store 当作 Service。


数据层——查询与变更工厂

每个实体都提供可复用的 queryOptions、infiniteQueryOptions 和 mutationOptions 构造器。 工厂负责键、类型化请求、领域转换和缓存效果。消费者 Hook 负责页面状态和操作顺序。 示例、参考文件、API 检查和验证,请参阅数据访问。

在当前传输模式下,直接返回服务端函数的值。 只有在检查确认服务端函数返回封装对象时,才使用现有的解包辅助函数。 序列化和错误转换由后端技能负责。

对于路由预取查询,路由只绑定一次 URL 输入。 Loader 和页面使用相同的查询选项。

查询键
  • 键必须包含 queryFn 读取的每个输入。更改键即可重新获取数据。不要调用 refetch({ newId })。
  • 层级:['products'] → ['products', 'list'] → ['products', 'list', filters] → ['products', 'detail', input]。
  • 使用前缀进行失效处理:invalidateQueries({ queryKey: ['products', 'list'] })。
  • 绝不要让 useQuery 和 useInfiniteQuery 共用同一个键。
  • 使用 productQueries.detail({ productId: id }).queryKey,以类型安全的方式调用 getQueryData / setQueryData。
转换(select)
位置用途
queryFn在需要转换时,将传输值转换为领域值。缓存存储此值。
调用处的 select选择单个字段或派生值。不要将 select 放入工厂中。
渲染仅进行开销较小的派生计算。

默认不要使用 select。当较大的负载会导致小组件重新渲染时,才使用它。

变更

变更放在 <entity>-mutations.ts 中。

  • 成功后,默认使用 invalidateQueries。
  • 如果按钮必须保持待处理状态,直到列表刷新完成,则返回该 Promise。
  • 优先使用 mutate。只有调用方必须组合或等待返回的 Promise 时,才使用 mutateAsync。
  • 工厂的 onSuccess 用于缓存操作。调用处的 mutate(variables, { onSuccess }) 用于提示消息/重定向。
  • 使用一个变量对象:mutate({ title, body })。
  • 不要为表单、对话框或重定向编写乐观缓存更新。仅在即时切换时使用。变更后优先使用失效处理。
  • setQueryData 是例外:用于即时切换,或变更恰好返回缓存中的完整行数据时。
无限查询

使用 infiniteQueryOptions。为其指定一个不同于常规列表键的键。设置 initialPageParam。

Router + Query 所有权
  • Entity:拥有查询工厂、查询键、请求和领域转换。不导入 Router API。
  • Route:验证 URL 状态,并绑定当前路由匹配的所有查询输入。
  • Loader:使用绑定的查询选项启动缓存工作。**抛出还是吞掉错误是 UX 决策,而非偏好。**等待 queryClient.query(options)(或者在必须优先使用缓存数据、即使数据已过期时,使用 query({ ...options, staleTime: 'static' }))。抛出错误会通过 errorComponent 替换整个路由。使用 .catch(noop) 吞掉错误,让失败留在页面布局中——此时组件也负责处理 pending,因为 suspense 不再覆盖它。当读取失败时需要保留外壳(页眉、导航、操作),就选择吞掉错误。ensureQueryData / prefetchQuery / fetchQuery 是已弃用的别名,将在 Query v6 中移除。
  • Page:使用 useQuery 或 useSuspenseQuery 观察绑定的查询。
  • Feature:使用领域数据,并负责用户操作。

Router 和 React 使用同一个 QueryClient。当 Query 负责数据新鲜度时,设置 defaultPreloadStaleTime: 0。

为每个 SSR 请求创建一个新的 QueryClient。配置 setupRouterSsrQueryIntegration 以支持反序列化、水合和流式传输。

通过 Query hook 读取由 Query 管理的数据。不要通过 useLoaderData 读取。

有关绑定、Loader 时序、边界、订阅、示例和来源,请参阅 TanStack Router + Query。

页面中的路由参数 / 上下文

当路由需要验证、细化或转换时,在路由定义中使用 params.parse 解析路径参数。返回解析后的值,以便子路由继承这些值。解析器应保持确定性且无副作用。

页面通过 getRouteApi('/_authed/products/$productId') 读取参数和由 Loader 注入的上下文——无需从路由文件逐层传递属性。搜索参数通过 validateSearch 使用 Zod 验证(以及 @tanstack/zod-adapter)。


状态管理

状态类型解决方案适用场景
服务端数据TanStack Query始终使用,没有例外。
本地 UIuseState单个组件
全局客户端状态Zustand满足以下条件时
派生状态就地计算永远不要存储可以计算得出的内容
服务端状态

所有服务端数据都通过 Query 获取——不使用 useEffect+fetch,也不把查询结果复制到存储中。客户端缓存写入(queryClient.setQueryData)仅用于上述 mutation 例外,不用于本地 UI 状态。

**避免瀑布式请求:**设计服务端函数,使其返回页面所需的数据(在服务端进行联接,避免 N+1);无法避免多个请求时,使用 Promise.all。提高 staleTime 以减少重复获取。默认保留焦点变化时重新获取。只有在产品行为有明确记录时才更改。过期查询会在 refetchOnMount 允许时于挂载时重新获取。默认值为 staleTime: 0 和 refetchOnMount: true。

Zustand

仅在以下情况下使用:(1) 多个组件需要相同的客户端状态;(2) 需要持久化;(3) 需要在 React 树之外访问。否则使用 useState 或提升状态。实际应用只需要很少的存储(当前仓库:0–2 个)——应将新增存储视为设计决策,而非默认选择。

规则:

  • 使用原子选择器(不要在每次渲染时创建新的 {},除非使用 useShallow)
  • 查找操作应为 O(1)——使用查找映射,而非在选择器中调用 .find()
  • 将 actions 子对象与数据分开
  • 永远不要存储服务端数据
// ❌ new object every render → re-renders on ANY change
const { x, y } = useStore((s) => ({ x: s.x, y: s.y }))

// ✅ atomic
const x = useStore((s) => s.x)
const y = useStore((s) => s.y)

// ❌ O(n) per render          // ✅ O(1)
useStore((s) => s.nodes.find((n) => n.id === id))
useStore((s) => s.nodeLookup[id])

原生存储变体——当非 React 代码需要访问存储(上传引擎、后台任务)时:使用 createStore(vanilla)+ zustand/react 中的 useStore + useShallow、devtools 中间件和 actions 子对象;非响应式句柄(例如引擎实例)应放在与存储并列的模块级 Map 中,而不是放在状态里。

Effects

Effect 必须在 setup → cleanup → setup 流程中保持正确。 绝不要使用 ref 或标记来抑制 Strict Mode 重放。请参阅 React 的重放指南。 用户操作(包括购买)应放在其发起事件对应的事件处理程序中。 若要在身份验证或外部重定向后自动继续,请在重定向前持久化一个稳定的尝试 ID。 在服务端或外部边界实施幂等性,确保重放仍然安全。

Lint 会强制执行这些规则:react/set-state-in-effect、react/no-deriving-state-in-effects 以及九条 react-you-might-not-need-an-effect/* 规则均为 error。每一行对应 You Might Not Need an Effect 的一个小节;中间列引用了该小节的总结。

Effect 的用途react.dev 的建议使用方式
根据 props/state 计算值“如果可以在渲染期间计算某个值,就不需要 Effect。”在渲染期间计算
缓存开销较大的值“要缓存开销较大的计算结果,请使用 useMemo,而不是 useEffect。”useMemo
根据 prop 重置所有状态“要重置整个组件树的状态,请为其传入不同的 key。”key
根据 prop 调整部分状态“要根据 prop 变更重置某个特定状态,请在渲染期间设置它。”在渲染期间使用 if (prev !== next) setState(...)
响应用户操作“因组件被显示而运行的代码应放在 Effect 中,其余代码应放在事件中。”事件处理程序
在 setState 后接着调用 setState“如果需要更新多个组件的状态,最好在单个事件中完成。”单个处理程序或 reducer 事件
仅运行一次的应用初始化在模块级别运行,而不是在每个组件中运行模块作用域
向父组件报告状态“每当尝试同步不同组件中的状态变量时,请考虑将状态提升到更高层。”提升状态;在处理程序中调用 onChange
订阅 store 或浏览器 API有专门用于此用途的内置 HookuseSyncExternalStore
获取服务端数据“可以使用 Effect 获取数据,但必须实现清理,以避免竞态条件。”TanStack Query(参见服务端状态)
测量 DOM 节点回调 ref 在挂载时运行,在卸载时清理ref={useCallback((node) => {...; return cleanup}, [])}
与外部系统同步,并进行清理这正是 Effect 的用途useEffect,且仅在其异步回调中调用 setState
// ❌ redundant state + sync effect
const [filtered, setFiltered] = useState([])
useEffect(() => { setFiltered(items.filter((i) => i.active)) }, [items])

// ✅ derive inline
const filtered = items.filter((i) => i.active)

异步状态渲染

互斥状态 = 独立分支。绝不使用嵌套三元表达式。

数据来源模式
路由 loader(关键数据)pendingComponent / errorComponent 配置
组件中的 useQuery提前返回
多个异步数据源组合 Suspense + ErrorBoundary
function Component() {
  const { data, isPending, isError } = useQuery(...)
  if (isPending) return <Layout><Skeleton /></Layout>
  if (isError) return <Layout><Error /></Layout>
  return <Layout><Content data={data} /></Layout>
}
查询渲染与边界

等待加载器查询会阻塞路由,直到关键数据就绪。未等待的查询可以通过 Suspense 流式传输,也可以通过 useQuery 保持非阻塞。

路由查询使用 Router 的待处理状态和错误边界。独立小组件可以拥有本地 Suspense 和错误边界。

使用 getRouteApi 或严格的 from 路由。使用 select 订阅所需范围最小的 Router 状态。

更改路由预取或 Query SSR 行为前,请阅读 TanStack Router + Query。

轮询

使用 TanStack Query 的 refetchInterval;不要另外添加间隔循环。 对于有限时长的作业,在其进入终止状态时停止轮询。如果作业有完成期限,请使用项目的超时策略。 对于通话期间的余额等实时数据,由使用方功能控制何时启用轮询。 持续读取没有终止状态,因此无需自行设定卡住作业的超时。 根据产品需求选择轮询间隔。除非产品要求更改,否则保留 Query 的后台和焦点行为。


按钮中的待处理/加载状态

在不同状态下保持按钮尺寸和标签位置稳定。使用以下三种模式之一:

  • 仅显示微调器,同时保留标签所占空间和无障碍名称
  • 保持文本不变,并在左侧预留的空间中显示微调器
  • 保持文本不变,仅禁用按钮

微调器不得移动标签或改变按钮尺寸。


列表与重复元素性能

较新的应用已启用 React Compiler(babel-plugin-react-compiler)[detect vite.config.ts]——它会处理记忆化,因此对于编译器覆盖的情况,不要手动编写 useMemo/useCallback。结构性开销仍会叠加;渲染 10 个或更多重复项时:

  • 尽量减少每项的开销——每行使用 1-2 个组件,检查 DOM 节点,并移除多余的包装 <div>。
  • 事件委托:达到 100 项或每项有 3 个以上处理器时,改为在容器上设置一个处理器并读取 data-item-id,而不是为每行创建闭包。
  • 将复杂逻辑延迟到交互时——仅为当前活动行按需渲染一个菜单/工具提示,而不是为每行都挂载一个:
const [menuTarget, setMenuTarget] = useState<string | null>(null)
{items.map((item) => (
  <Row key={item.id} onContextMenu={() => setMenuTarget(item.id)} />
))}
{menuTarget && <ContextMenu itemId={menuTarget} />}
  • 不要在列表项中使用 useEffect——副作用会随项数增加;应提升到容器中。
  • 达到 200 项或更多时使用虚拟化(TanStack Virtual)。少于 200 项时不要使用。
  • CSS:重复元素使用扁平的基于类名的选择器;避免使用 :has()(会逐个父元素评估子元素)。

设计系统

技术栈:Tailwind CSS v4(@theme)+ @radix-ui/colors 色阶 + shadcn 风格组件(cva variants、tailwind-merge),基于 Base UI / Radix 原语。

  • 设计令牌集中定义在样式表中:ui/stylesheets/{colors,typography,radius,shadows,utilities}.css(跨应用共享时位于 packages/ui)。colors.css 导入 Radix 色阶,并将其映射为兼容 shadcn 的变量名——组件依赖这些名称。
  • [detect imports in ui components] 原语库混用:较新的组件使用 @base-ui/react,较旧的组件使用 @radix-ui/react-*。遵循当前文件的用法;创建新组件时,遵循仓库中的主流做法。
  • 不要在 CSS 文件中编写组件样式——组件使用设计令牌,以 TSX 编写。
  • 缺少设计令牌? 先检查样式表,再扩展设计系统——不要使用一次性的值或类。
  • 切勿重新实现设计系统中已有的组件、图标或模式。
UI 文案与图标

UI 文案末尾不加句号:标签、按钮、提示、验证错误、Toast、引导文案和空状态。 用逗号连接两个分句,或只使用一个分句。电子邮件、法律页面、文章和 SEO 描述使用句末标点。 遵循项目的本地化系统。 导入 Phosphor 图标时使用带 Icon 后缀的名称,例如 PlusIcon 和 MagnifyingGlassIcon。

不要使用 || 隐藏缺失数据或错误状态。应完善类型或解析输入。 允许使用合理的产品默认值。

手机界面

对于手机产品、PWA 和手机流程,请阅读 手机界面。 这些规则不会自动应用于桌面界面或混合界面。

浏览器支持

最低支持版本是构建技术栈所针对的最旧浏览器。Tailwind CSS 4 和 Vite 8 决定了这一版本:Safari 和 iOS 16.4、Chrome 111、Firefox 128。项目的 .browserslistrc 中记录了该版本。不要面向低于此版本的浏览器构建。

代码必须在最低支持版本上正常运行:

  1. 每个新增的平台 API 都要查阅 MDN browser-compat-data,不能凭记忆判断。URL.parse 需要 Safari 18,因此在 iOS 16 和 17 上导致了登录崩溃。
  2. 高于最低支持版本的 API 必须配备 polyfill,或添加标记为 // FALLBACK: <who gets what> 的回退分支。
  3. 低于最低支持版本时,应用应显示更新界面,不得启动。

[detect] 项目具备 browser-support/no-unsupported-api lint 规则时,该规则会强制检查第 1 和第 2 项。拒绝要求支持低于最低支持版本浏览器的审查意见。

视觉设计

关于字体排印、间距、界面层次和交互规则,请阅读 视觉设计。

Storybook
  • 框架包:@storybook/tanstack-react — 从中导入 Meta/StoryObj;类型化 meta:const meta: Meta<typeof Component> = {...}。
  • Story 集中放在 .storybook/ 下(design-system/、components/、features/、pages/),相对于 .storybook/ 发现,并在 .storybook/story-paths.ts 中定义共享标题常量。涉及数据的 story 使用 msw-storybook-addon 配合 MSW。
  • 以 Storybook 为先的 UI 工作流:先在 story 中试做新 UI,再编写应用代码;story 是 UI 规范——每次 UI 变更都要同步更新,并在完成后提供 story URL。
  • Story 绝不使用本地化系统。Story 文件和 story 数据中的所有文案都使用纯文本。不要在 story 中导入消息函数,也不要为 story 添加消息。story 所渲染的生产组件仍使用各自的消息。唯一例外是展示国际化本身的 story,例如语言切换器。
查询测试

每项测试都使用新的 QueryClient。在测试默认配置中设置 retry: false。 测试网络边界时使用 MSW。在可行的情况下,通过缓存测试工厂行为。 请阅读 数据访问,根据变更的行为执行相应检查。

性能目标

关于性能工作,请阅读 性能目标。


表单

[detect package.json]:

  • 较新的应用使用 TanStack Form,并搭配设计系统的 field.tsx 原语。
  • 较旧的应用使用 react-hook-form + @hookform/resolvers,并搭配 shadcn 的 form.tsx 封装。

无论使用哪种方式:将 Zod schema 作为唯一验证来源(与服务端函数的 .validator 共享);使用设计系统中的字段原语;不使用临时拼凑的表单状态。

有意将服务端数据复制到表单状态,然后选择一种方式:

  • 单人编辑:在查询上设置 staleTime: Infinity。使用快照,不进行后台覆盖。
  • 多人协同编辑:保持 Query 实时更新。显示 field.value ?? serverValue,以便未触碰的字段仍能更新。

仅当数据存在时才渲染表单(必须定义 defaultValues)。保存后,等待失效处理完成并获取权威的已保存值。

  • TanStack Form:form.reset(savedValues) 会重置表单并更新其默认值。
  • React Hook Form:reset(savedValues) 会根据提供的值重置表单。实际可行时,请提供完整的值。
TanStack Form 规则

来源:shadcn TanStack Form、提交处理、表单组合。

  1. 在表单级别使用一个 Schema 进行校验:validationLogic: revalidateLogic({ mode, modeAfterSubmission }) 和 validators: { onDynamic: Schema }。根据表单选择校验时机,并在注释中说明原因。不要添加字段级校验器,也不要在 onSubmit 中手写校验。tanstack lint 插件会强制执行这些规则。
  2. 将 defaultValues 类型标注为 z.input<typeof Schema>。onSubmit 接收输入值。如果输入类型和输出类型不同,请调用 Schema.parse(value);否则直接使用 value。
  3. 使用 useState(() => …) 或模块常量,只计算一次时间或随机默认值。TanStack Form 会在不同渲染之间 defaultValues 发生变化时重置未操作过的表单。
  4. 为每个字段设置 id={field.name}、name={field.name} 和 onBlur={field.handleBlur}。isInvalid = field.state.meta.isTouched && !field.state.meta.isValid 用于控制 data-invalid、aria-invalid 和错误提示。
  5. 表单元素使用 noValidate 并调用 event.preventDefault(),然后调用 form.handleSubmit()。
  6. 表单组件接收 pending 和 onSubmit(output)。由调用方负责 mutation、toast 和关闭对话框。
  7. 异步校验器使用 onChangeAsync 或 onSubmitAsync。同步校验器不会等待 Promise。
  8. 服务端函数只接收 FormData 格式的文件。在 mutation 工厂中构建它。

Auth(客户端)

lib/clients/auth-client 中的 better-auth 客户端由 lib/auth/ 层(hooks、models、services)封装——组件使用 useSession 风格的 hooks,绝不直接使用原始客户端。通过路由组(_auth/_authed layouts)保护路由,并由其 beforeLoad 重定向未认证用户。

评论

支持 Markdown