docs(cache): 同步 cacheFn 架构图与已知问题速查

This commit is contained in:
SpecialX
2026-07-05 18:07:18 +08:00
parent d6227d6e6c
commit 0f9d8825e7
3 changed files with 551 additions and 17 deletions

View File

@@ -955,3 +955,284 @@ Zod schema 中存储的 i18n 键(如 `"error.titleRequired"`)在服务端翻
- `.trae/rules/project_rules.md` — 「Tailwind 规范」+ 「设计令牌规范(强制)」章节
- `docs/architecture/004_architecture_impact_map.md` — 1.1.2 设计令牌体系章节
- `docs/architecture/005_architecture_data.json` — `modules.shared.exports.designTokens` 节点
## Phase 4.2 + Phase 4.5 状态管理优化规则2026-07-05
### Phase 4.2Zustand selector 细粒度拆分规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 单字段订阅用细粒度 selector | `const title = useStore((s) => s.title)` | `const { title } = useStore()` (整体订阅,任一字段变化都触发 re-render) |
| 派生 boolean 用 selector 调用 | `const canUndo = useStore((s) => s.canUndo())` | `const editor = useStore(); const canUndo = editor.canUndo()` (每次渲染都调用函数) |
| 多字段订阅用 useShallow | `const { a, b } = useStore(useShallow((s) => ({ a: s.a, b: s.b })))` | `const { a, b } = useStore((s) => ({ a: s.a, b: s.b }))` (每次渲染都创建新对象) |
| 函数引用天然稳定,无需 useShallow | `const setTitle = useStore((s) => s.setTitle)` | `const setTitle = useStore(useShallow((s) => s.setTitle))` (多余浅比较开销) |
| 选择最精细的字段订阅 | `const nodes = useStore((s) => s.doc.nodes)` | `const doc = useStore((s) => s.doc)` (如果只用到 nodes 字段,订阅整个 doc 会在 anchors 变化时也触发 re-render) |
### Phase 4.5useOptimistic + useTransition 乐观更新规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 单字段乐观切换用 useOptimistic + useTransition | `const [optimisticIsStarred, addOptimisticStarred] = useOptimistic(isStarred, (_, next) => next); const [, startTransition] = useTransition(); startTransition(async () => { addOptimisticStarred(next); await action(); router.refresh(); })` | `const [isStarred, setIsStarred] = useState(initial); setIsStarred(next); await action(); setIsStarred(serverValue)` (手动同步易出错) |
| useOptimistic 必须在 transition 或 action 内调用 | `startTransition(async () => { addOptimistic(value); await serverAction(); }); ` 或在 `<form action={fn}>` 的 fn 内调用 | 在普通事件处理函数外直接调用 `addOptimistic(value)` (无效) |
| 成功后调用 router.refresh() 同步数据源 | `await serverAction(); router.refresh();` (useOptimistic 自动回滚到最新服务端值) | 只调用 `await serverAction()` 不 refresh (useOptimistic 状态回滚到原值UI 不更新) |
| form action 模式下 useOptimistic 自动管理 | `<form action={async (fd) => { setOptimisticSubmitting(true); await action(fd); }}>` (action 完成后自动回滚) | 在 form action 内用 `useState` + `setIsSubmitting(false)` (多余的 finally 块) |
| form action 模式不要用 useTransition 包裹 | `<form action={handleSubmit}>` (useFormStatus 自动跟踪 pending) | `const [, startTransition] = useTransition(); <form action={(fd) => { startTransition(async () => { await handleSubmit(fd); }) }}>` (form action 立即返回useFormStatus 不可靠) |
| Map 形式乐观更新用 useOptimistic + reducer | `const [map, addOptimistic] = useOptimistic(new Map(), (state, { id, value }) => { const next = new Map(state); next.set(id, value); return next; })` | `const [override, setOverride] = useState({}); setOverride({ ...override, [id]: value })` (手动管理回滚复杂) |
| 不需要乐观更新的操作保留 useState | 撤回操作 `const [isRecalled, setIsRecalled] = useState(false)` (需要根据服务端返回判断是否成功) | 所有操作都用 useOptimistic (失败时乐观状态错误显示) |
### paper-context-menu.tsx duplicateNode 调用签名不匹配pre-existing tsc 错误)
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| `duplicateNode` 函数签名只接受 1 个参数 | `const newId = duplicateNode(nodeId)` (后缀在函数内硬编码为 `(副本)`) | `const newId = duplicateNode(nodeId, t("v4.contextMenu.copySuffix"))` (TS2554: Expected 1 arguments, but got 2) |
> 此为 pre-existing bug`hooks/editor-slice.ts` 的 `duplicateNode(id: string) => string \| null` 签名只接受 1 参数,但 `paper-context-menu.tsx` 第 58 行传了 2 个参数。Phase 4.2 修改 selector 时未引入此错误,但需记录待修复。
---
## Phase 3.6 + Phase 3.7 + Phase 3.8 DB 性能优化规则2026-07-05
### 24.1 FULLTEXT 索引声明规则drizzle-kit 限制)
| 规则 | 正确做法 | 错误做法 |
|------|---------|---------|
| drizzle-kit 无法生成 FULLTEXT 索引声明 | 迁移文件中手动追加 `CREATE FULLTEXT INDEX \`idx_name\` ON \`table\` (\`col\`);` | 仅在 schema.ts 中声明索引期望 drizzle-kit 自动生成 |
| 手动追加后必须同步 snapshot 的 indexes 节点 | 在 `meta/00XX_snapshot.json` 对应表 indexes 中加 `"type": "fulltext"` | 仅改 .sql 不改 snapshot下次 drizzle-kit generate 会重复生成 |
| MySQL FULLTEXT 要求 InnoDB + utf8mb4 | 建表时 `engine=InnoDB` + `charset=utf8mb4`MySQL 5.7+ | 在 MyISAM 表或 utf8 字符集上创建 FULLTEXT |
| JSON 字段不能直接创建 FULLTEXT 索引 | 添加 STORED 生成列 `content_text` 用 `CAST(content AS CHAR)` 镜像为纯文本,对生成列创建 FULLTEXT | `CREATE FULLTEXT INDEX ON questions (content)`JSON 列不支持) |
| 生成列必须为 STORED 模式 | `text("content_text").generatedAlwaysAs(sql\`CAST(content AS CHAR)\`, { mode: "stored" })` | VIRTUAL 生成列不支持 FULLTEXT 索引 |
| schema.ts 顶部需导入 `sql` | `import { sql } from "drizzle-orm";` | 直接使用 `sql` 标识符ReferenceError |
**示例**`src/shared/db/schema.ts`
```typescript
contentText: text("content_text").generatedAlwaysAs(
sql`CAST(content AS CHAR)`,
{ mode: "stored" },
),
```
**示例**(迁移文件 `drizzle/0001_questions_fulltext_search.sql` 末尾手动追加):
```sql
-- P3-6: questions.content_text FULLTEXT 索引drizzle-kit 无法生成 FULLTEXT 声明,需手动追加)
-- 要求InnoDB 引擎 + utf8mb4 字符集MySQL 5.7+
CREATE FULLTEXT INDEX `questions_content_text_ft_idx` ON `questions` (`content_text`);
```
**示例**`drizzle/meta/0001_snapshot.json` 中 questions 表 indexes 节点):
```json
"questions_content_text_ft_idx": {
"name": "questions_content_text_ft_idx",
"columns": ["content_text"],
"isUnique": false,
"type": "fulltext"
}
```
### 24.2 MATCH AGAINST BOOLEAN MODE 查询转换规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 调用方传原始查询字符串 `q` | `searchQuestions(q, limit)`q 为用户输入原文) | `searchQuestions("%keyword%", limit)`LIKE 格式传给 MATCH |
| 工具函数转换 `q` 为 `+word1* +word2*` | `toBooleanModeQuery("数学 函数")` → `"+数学* +函数*"` | 直接传 `"数学 函数"` 给 AGAINST无前缀仅完整词匹配 |
| 转义 BOOLEAN MODE 特殊字符 | `word.replace(/[+\-<>()~*"']/g, " ").trim()` | 保留 `+`/`-`/`(` 等字符(被 MySQL 解释为操作符) |
| 单词后加 `*` 启用前缀匹配 | `+word*`(匹配 word 开头的所有词) | `+word`(仅匹配完全相同的词) |
| 空查询需短路返回空数组 | `if (words.length === 0) return []` | 空 `+*` 传给 AGAINSTMySQL 报语法错误) |
**示例**`src/modules/search/data-access.ts`
```typescript
function toBooleanModeQuery(q: string): string {
const words = q.trim().split(/\s+/).filter(Boolean)
if (words.length === 0) return ""
const escapeWord = (w: string): string => `+${w.replace(/[+\-<>()~*"']/g, " ").trim()}*`
return words.map(escapeWord).filter((w) => w !== "+*").join(" ")
}
// 使用
const booleanQuery = toBooleanModeQuery(q)
const rows = await db
.select({ id: questions.id })
.from(questions)
.where(sql`MATCH(${questions.contentText}) AGAINST(${booleanQuery} IN BOOLEAN MODE)`)
```
### 24.3 批量 INSERT 规则fan-out 场景)
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| fan-out 批量通知用单次多行 INSERT | `db.insert(messageNotifications).values(rows)`rows 为数组) | `for (const p of payloads) { await insert(p) }`N 次 INSERT |
| 批量发短信/邮件用 `Promise.all(map(send))` | `await Promise.all(valid.map((item) => sendSms(item)))` | `for (const item of valid) { await sendSms(item) }`(串行等待) |
| 批量 INSERT 失败时整体回滚 | `try { const ids = await createNotifications(inputs); ... } catch { // 所有项标记失败 }` | 单条 try/catch 部分成功部分失败(数据一致性破坏) |
| in_app 渠道单次批量,其他渠道并行 | `inAppSender.sendBatch(inAppPayloads)` + `Promise.all(otherChannels.map(send))` | 所有渠道统一 `Promise.all(map(sendNotification))`in_app 实际是 N 次单条 INSERT |
| 批量入口接收 `items: Array<{...}>` 数组 | `async sendBatch(items: Array<{ payload; recipient }>): Promise<ChannelSendResult[]>` | 暴露单条 `send(payload, recipient)` 由上层 `Promise.all` 调用 |
| 预校验数据一致性后再批量 INSERT | `if (item.recipient.userId !== item.payload.userId) continue`(构造 inputs 前过滤) | INSERT 后才发现 userId 不一致(已写入脏数据) |
**示例**`src/modules/notifications/data-access.ts`
```typescript
export async function createNotifications(items: CreateNotificationInput[]): Promise<string[]> {
if (items.length === 0) return []
const rows = items.map((data) => ({
id: createId(),
userId: data.userId,
type: data.type,
title: data.title,
content: data.content ?? null,
link: data.link ?? null,
priority: data.priority ?? "normal",
}))
await db.insert(messageNotifications).values(rows)
return rows.map((r) => r.id)
}
```
### 24.4 getAllUserIds 分页规则
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 全量查询必须加 LIMIT 默认值 | `getAllUserIds(limit = 1000, offset = 0)` | `getAllUserIds()`(无 LIMIT超大学校 OOM |
| 暴露 offset 分页参数 | `getAllUserIds(limit, offset)` | 仅 LIMIT 无 offset无法翻页 |
| 调用方循环遍历全部数据 | `while (true) { const page = await getAllUserIds(PAGE_SIZE, offset); if (page.length === 0) break; allIds.push(...page); offset += PAGE_SIZE }` | 假设单次 `getAllUserIds()` 能返回全部用户 |
| 默认 PAGE_SIZE = 1000 | `const PAGE_SIZE = 1000`(与默认 LIMIT 一致) | `PAGE_SIZE = 10000`(单次查询过大) |
| 不足一页时退出循环 | `if (page.length < PAGE_SIZE) break` | 仅判断 `page.length === 0`(最后一页恰好等于 PAGE_SIZE 时多一次空查询) |
**示例**`src/modules/announcements/data-access.ts` school 公告 fan-out
```typescript
if (announcement.type === "school") {
const { getAllUserIds } = await import("@/modules/users/data-access")
const PAGE_SIZE = 1000
const allIds: string[] = []
let offset = 0
while (true) {
const page = await getAllUserIds(PAGE_SIZE, offset)
if (page.length === 0) break
allIds.push(...page)
if (page.length < PAGE_SIZE) break
offset += PAGE_SIZE
}
return allIds
}
```
### 24.5 默认 LIMIT 控制规则(非分页场景)
| 规则 | 正确做法 | 错误做法 |
|------|---------|---------|
| 非分页查询默认 LIMIT ≤ 100 | `limit(100)` 或 `limit: 100` | `limit: 5000` / `limit: 1000`(无业务理由的高默认值) |
| 分页场景保留显式分页参数 | `getQuestions({ page, pageSize = 50 })`(用户可控) | 内部硬编码 `limit: 5000`(用户无法翻页) |
| 跨模块辅助查询 LIMIT ≤ 100 | `getStudentAnsweredQuestionIds(studentId)` 内部 `.limit(100)`(仅查最近 100 条避免重复) | `.limit(1000)`(辅助查询不需要全量历史) |
| 全量聚合场景用 GROUP BY + 聚合函数而非拉全表 | `db.select({ count: count() }).from(table)` | `db.query.table.findMany({ limit: 5000 })` 后在内存 reduce |
**Phase 3.8 调整文件清单**
- `src/modules/attendance/data-access-correlation.ts:127``limit: 5000` → `limit: 100`(相关性分析辅助查询)
- `src/modules/adaptive-practice/data-access-strategy.ts:293``.limit(1000)` → `.limit(100)`(学生已答题目去重辅助查询)
- `src/modules/questions/data-access.ts:570``limit: 1000` → `limit: 100`(跨模块知识点查询辅助)
涉及文件Phase 3.6/3.7/3.8 全部改动):
- `src/shared/db/schema.ts` — questions 表新增 `contentText` STORED 生成列 + 5 个补齐索引
- `drizzle/0001_questions_fulltext_search.sql` — 新建迁移(含手动追加的 `CREATE FULLTEXT INDEX`
- `drizzle/meta/0001_snapshot.json` — questions 表 indexes 节点添加 fulltext 声明
- `drizzle/meta/_journal.json` — 添加 0001 迁移条目
- `src/modules/search/data-access.ts` — searchQuestions 改 MATCH AGAINST + `toBooleanModeQuery` 工具函数
- `src/app/api/search/route.ts` — 同步 searchQuestions 签名 + `toBooleanModeQuery` 辅助
- `src/modules/users/data-access.ts` — `getAllUserIds(limit=1000, offset=0)` 分页参数
- `src/modules/announcements/data-access.ts` — school 公告 fan-out 分页循环遍历
- `src/modules/notifications/data-access.ts` — 新增 `createNotifications(items)` 批量 INSERT
- `src/modules/notifications/index.ts` — 导出 `createNotifications`
- `src/modules/notifications/channels/in-app-channel.ts` — `sendBatch` 重写为单次批量 INSERT
- `src/modules/notifications/dispatcher.ts` — `sendBatchNotifications` 重构为 in_app 批量 + 其他渠道并行
## Phase 2 Bundle 预算优化规则2026-07-05
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 重型客户端库用 next/dynamic ssr:false | `dynamic(() => import("./xxx-inner").then(m => m.XxxInner), { ssr: false, loading: () => <Skeleton/> })` | 顶层静态 import 整个 TipTap/ReactFlow |
| Provider 上下文与懒加载实现分离 | 同步壳保留 `<ReactFlowProvider>`inner 用 `useReactFlow()` 钩子 | inner 直接调用 `useReactFlow()` 但未在同层 Provider 内 |
| 服务端消费方仅需类型时拆 types barrel | `import type { EditorDoc } from "@/modules/exams/editor/types"`(纯类型入口) | `import { EditorDoc } from "@/modules/exams/editor/exam-rich-editor-types"`(拉入 @tiptap/* 运行时) |
| 纯展示组件无需 "use client" | `function StatusBadge({ status }) { return <span>...</span> }` | 在文件首行加 `"use client"` 但未使用任何客户端 hook |
| 角色路由必须配 loading.tsx | `app/(dashboard)/teacher/loading.tsx` 提供 Skeleton fallback | 仅依赖默认 Next.js 加载状态导致页面切换白屏 |
**Phase 2 涉及文件**
- `src/modules/exams/editor/exam-rich-editor.tsx` — 动态 import 化
- `src/modules/questions/components/question-rich-editor.tsx` — 动态 import 化
- `src/modules/lesson-preparation/components/paper-editor/paper-rich-editor.tsx` — 动态 import 化
- `src/modules/ai/components/ai-assistant-widget.tsx` — lazy wrapper + ai-assistant-widget-inner.tsx 拆分
- `src/modules/textbooks/components/knowledge-graph.tsx` + `knowledge-graph-inner.tsx` — ReactFlowProvider 同步壳 + lazy inner
- `src/modules/exams/editor/types.ts` — 新建类型 barrel
- `src/shared/components/ui/status-badge.tsx` — 移除 "use client"
- `src/app/(dashboard)/teacher/loading.tsx`、`student/loading.tsx`、`parent/loading.tsx` — 新建 Skeleton fallback
## Phase 3.1-3.5 数据层优化规则2026-07-05
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 请求级去重用 React cache() | `export const getSession = cache(async () => { ... })`(同请求多次调用复用结果) | 在 Server Action 内用全局变量缓存(跨请求共享有用户隔离风险) |
| 跨请求缓存才用 unstable_cache | 仅对真正稳定数据(如权限配置)使用 `unstable_cache` + revalidateTags | 用 `unstable_cache` 缓存 session用户切换后无法即时刷新 |
| Auth 链路统一通过 getAuthContext | `const { userId, dataScope } = await getAuthContext()`(自动 cache 去重) | data-access 内直接 `auth()` + 重复 resolveDataScope每条记录都解析一次 |
| 批量 INSERT 用单次 values([...]) | `await tx.insert(table).values(items.map(...))`(单次 SQL | `for (const item of items) { await db.insert(table).values(item) }`N 次往返) |
| 批量 UPDATE 用 CASE WHEN | `UPDATE t SET score = CASE id WHEN 'a' THEN 1 WHEN 'b' THEN 2 ELSE score END WHERE id IN (...)` | 循环执行 `UPDATE t SET score = ? WHERE id = ?` |
| 事务批量操作用 db.transaction | `await db.transaction(async (tx) => { await tx.insert(...).values(plans); await tx.insert(...).values(items); })` | 多次独立 `db.insert`(无原子性保证) |
| MySQL FULLTEXT 不支持 JSON 列 | 添加 `content_text STORED` 生成列 `CAST(content AS CHAR)` 后创建索引 | 直接 `CREATE FULLTEXT INDEX ON questions (content)`JSON 列报错) |
| BOOLEAN MODE 特殊字符需转义 | `+word1* +word2*`(用 `toBooleanModeQuery` 工具函数处理 `+-<>()*~"` | 直接拼接用户输入到 `MATCH AGAINST`(特殊字符导致语法错误) |
| 高 LIMIT 默认值不超过 100 | `.limit(100)`(辅助查询足够) | `.limit(5000)`OOM 风险) |
| 分页参数显式暴露 | `getAllUserIds(limit=1000, offset=0)` | `getAllUserIds()`(无限制拉全表) |
**Phase 3.1-3.5 涉及文件**
- `src/shared/lib/session.ts` — `fetchSession` + `getSession = cache(fetchSession)`
- `src/shared/lib/auth-guard.ts` — `resolveDataScope = cache(...)` + `getAuthContext = cache(...)`
- `src/modules/classes/data-access.ts` — `getSessionTeacherId = cache(...)`
- `src/modules/course-plans/data-access.ts` — `copyCoursePlanToClasses` 单事务 + 2 次 batch INSERT
- `src/modules/homework/data-access-write.ts` — `gradeHomeworkAnswers` UPDATE CASE WHEN + `tx.execute(sql\`...\`)`
## Phase 4.1/4.3/4.4/4.6-4.9 组件渲染优化规则2026-07-05
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 高频列表项用 React.memo | `export const AssignmentCard = memo(function AssignmentCard(props) { ... })` | 内联匿名组件(每次父渲染都重建实例) |
| 大列表用虚拟化 | `const { virtualItems } = useVirtualizer({ count, estimateSize: () => 56, getScrollElement })` | `{items.map(item => <Row key={item.id} {...item} />)}`5000+ 行卡顿) |
| 高频事件用 debounce + useMemo | `const debounced = useMemo(() => debounce(handler, 200), [deps])` | 直接 `onChange={e => setWidth(e.target.value)}`(每像素触发 setState |
| recharts 配置 props 提取到模块级 | `const CHART_MARGIN = { top: 5, right: 5 } ` + `<BarChart margin={CHART_MARGIN}>` | `<BarChart margin={{ top: 5, right: 5 }}>`(每次渲染新对象引用,子组件 memo 失效) |
| 图片容器用 aspect-ratio | `<div className="aspect-[4/3]"><img className="h-full w-full object-cover"/></div>` | `<img style={{ height: 'auto', width: '100%' }}/>`(加载时布局抖动 CLS |
| next/web-vitals Metric 类型从签名推导 | `type M = Parameters<Parameters<typeof useReportWebVitals>[0]>[0]` | `import type { Metric } from 'next/web-vitals'`v15+ 已不导出) |
| PerformanceEntry 显式标注类型 | `metric.entries.map((entry: PerformanceEntry) => ({...}))` | `(entry) => ({...})`implicit any |
| sendBeacon 失败时回退 fetch keepalive | `if (!navigator.sendBeacon(url, blob)) { await fetch(url, { keepalive: true }) }` | 仅用 fetch页面卸载时可能被取消 |
**Phase 4.1/4.3/4.4/4.6-4.9 涉及文件**
- `src/shared/components/ui/empty-state.tsx` — React.memo
- `src/shared/components/ui/status-badge.tsx` — React.memo
- `src/app/(dashboard)/student/learning/assignments/page.tsx` — AssignmentCard memo
- `src/modules/questions/components/question-data-table.tsx` — useVirtualizer
- `src/modules/exams/components/exam-data-table.tsx` — useVirtualizer
- `src/modules/layout/components/sidebar-provider.tsx` — resize debounce + useMemo
- `src/modules/homework/components/scan-image-viewer.tsx` — aspect-ratio
- 14 个 chart 组件(`src/modules/*/components/*-chart.tsx` — recharts props 提取模块级常量
- `src/app/web-vitals-reporter.tsx` — Metric 类型推导修复 + PerformanceEntry 注解
- `package.json` — 新增 `@tanstack/react-virtual`、`@tanstack/react-query-devtools`
## Phase 4.3 useOptimistic 乐观更新应用规则(补充)
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| 单字段乐观切换 | `const [starred, addStarred] = useOptimistic(isStarred, (_, v) => v)` | `const [starred, setStarred] = useState(isStarred); setStarred(v); await action(); setStarred(serverVal)` |
| Map 形式乐观更新 | `const [override, addOverride] = useOptimistic(new Map(), (s, { id, v }) => new Map(s).set(id, v))` | `const [override, setOverride] = useState({}); setOverride({ ...override, [id]: v })` |
| form action 模式 useOptimistic 自动回滚 | `<form action={async (fd) => { setOptimistic(true); await action(fd); }}>` | `useEffect(() => setIsSubmitting(false), [result])`(多余 effect |
| 不需要乐观的保留 useState | 撤回操作 `const [recalled, setRecalled] = useState(false)`(依赖服务端返回) | 强行用 useOptimistic失败时乐观状态错误 |
**Phase 4.3 涉及文件**
- `src/modules/messaging/components/message-detail.tsx` — 星标 useOptimistic + useTransition
- `src/modules/messaging/components/message-list.tsx` — 星标 Map useOptimistic
- `src/modules/grades/components/grade-record-list.tsx` — 编辑保存 useOptimistic + useTransition
- `src/modules/attendance/components/attendance-sheet.tsx` — 提交中 useOptimistic保留 form action
## Vitest + Next.js 16 缓存测试规则2026-07-05
| 规则 | 正确写法 | 错误写法 |
|------|---------|---------|
| `vi.mock` 工厂内引用变量必须用 `vi.hoisted` 提升 | `const { mockStore } = vi.hoisted(() => ({ mockStore: {...} }))` 后 `vi.mock("./x", () => ({ f: vi.fn().mockResolvedValue(mockStore) }))` | `const mockStore = {...}` 后 `vi.mock("./x", () => ({ f: vi.fn().mockResolvedValue(mockStore) }))`TDZCannot access 'mockStore' before initialization |
| `vi.mock` 工厂也可用箭头函数延迟绑定(不需 `vi.hoisted` | `vi.mock("./x", () => ({ f: () => mockFn() }))`(每次调用时才读取顶层 `mockFn` | `vi.mock("./x", () => ({ f: mockFn }))`(工厂执行时 `mockFn` 未初始化) |
| `vitest.unit.config.ts` 已设 `mockReset: true`,每个测试前 mock 返回值被清空 | `beforeEach(() => { vi.mocked(getCacheStore).mockResolvedValue(mockStore) })` | `beforeEach(() => { vi.clearAllMocks() })`mockResolvedValue 已被 reset调用时报 `Cannot read properties of undefined` |
| React 19 `cache()` 在 vitest jsdom 环境是 no-op不去重 | 测试用真正缓存的 mock 实现(`mockStore.getOrSet` 实现命中缓存逻辑)验证去重,不依赖 react.cache 行为 | 期望 `react.cache` 在 jsdom 中去重producer 仅调 1 次),实际不去重(调用 N 次) |
| cacheFn 双层缓存测试 mock store-factory 时需保持引用稳定 | 顶层 `const mockStore = { getOrSet: vi.fn() }` + `const getCacheStoreMock = vi.fn().mockResolvedValue(mockStore)` + `vi.mock("./store-factory", () => ({ getCacheStore: () => getCacheStoreMock() }))` | `vi.mock("./store-factory", () => ({ getCacheStore: vi.fn().mockResolvedValue(mockStore) }))`mockReset 后丢失实现) |
| Next.js 16 起 `revalidateTag` 类型签名要求第二参数 `profile`(必填) | `revalidateTag(tag, "default")` | `revalidateTag(tag)`tsc TS2554: Expected 2 arguments, but got 1 |
| `revalidatePath` 第二参数 `type` 仍可选 | `revalidatePath(path)` / `revalidatePath("/", "layout")` | — |
**涉及文件**`src/shared/lib/cache/invalidate.ts`、`src/shared/lib/cache/invalidate.test.ts`、`src/shared/lib/cache/cache-fn.ts`、`src/shared/lib/cache/cache-fn.test.ts`