列表筛选、笔记详情和后台总结会在不同时间改变页面数据。查询库能管理服务端状态,但只有缓存身份和完成事件都接对了,界面才会在需要时刷新。本文阅读 ChatCrystal 当前的查询路径,再用没有 React、HTTP 或真实数据的实验检查缓存行为。
先区分查询缓存和页面状态
App.tsx在顶层创建 QueryClient,通过 QueryClientProvider 提供给页面。当前默认 staleTime 为 30,000ms,retry 为 1;这不是适合任何应用的通用最佳值,也不表示 30 秒内服务器数据不会改变。
查询结果是服务端状态。搜索输入、选中标签、页码、弹窗是否打开则是界面状态,不必为了统一而全部放进查询缓存。当前笔记页把这些筛选参数传给 useNotes;改动筛选后重置页码,避免沿用上一条件的偏移量。
缓存键必须覆盖请求参数
use-notes.ts使用 ['notes', params] 标识列表,['note', id] 标识详情。标签、搜索、来源类型、offset 和 limit 改变时应对应不同请求身份。只用 ['notes'] 缓存所有分页,会把不同页面的数据放进同一条记录。
对象字段顺序不等于键不同;数组元素的顺序仍有意义。详情钩子仅在 id > 0 时启用自动查询,小数或 Infinity 也可能满足这个条件,因此它不是完整的 ID 校验,也不能据此认为 TypeScript 已把任意数据或 undefined 都收窄成有效笔记。
下面使用与当前 React Query 相同的 query-core 5.102.8。在新临时目录安装,保存 cache.mjs,用 Node 24+ 运行。这是没有观察者的离线缓存实验,不是 React 页面或 HTTP 请求。为确定性关闭重试、禁用自动回收,和应用默认配置不同。
npm init -y
npm install --ignore-scripts --no-audit --no-fund @tanstack/query-core@5.102.8
import assert from 'node:assert/strict';
import { QueryClient } from '@tanstack/query-core';
const client = new QueryClient({
defaultOptions: { queries: { staleTime: 30_000, retry: false, gcTime: Infinity } },
});
let reads = 0;
const queryFn = async () => ({ revision: ++reads });
const first = ['notes', { tag: 'mcp', offset: 0, limit: 20 }];
const reordered = ['notes', { limit: 20, offset: 0, tag: 'mcp' }];
const second = ['notes', { tag: 'mcp', offset: 20, limit: 20 }];
try {
assert.equal((await client.fetchQuery({ queryKey: first, queryFn })).revision, 1);
assert.equal((await client.fetchQuery({ queryKey: reordered, queryFn })).revision, 1);
await client.fetchQuery({ queryKey: second, queryFn });
assert.equal(reads, 2);
client.setQueryData(['note', 7], { title: 'separate detail' });
await client.invalidateQueries({ queryKey: ['notes'] });
assert.equal(reads, 2);
assert.equal(client.getQueryState(first).isInvalidated, true);
assert.equal(client.getQueryState(second).isInvalidated, true);
assert.equal(client.getQueryState(['note', 7]).isInvalidated, false);
assert.equal((await client.fetchQuery({ queryKey: first, queryFn })).revision, 3);
console.log('keys, cached reads, and inactive invalidation pass');
} finally {
client.clear();
}
将 invalidateQueries 的前缀从 notes 改为 note,列表失效断言应失败;恢复后通过。两个不同页码都匹配列表前缀,详情不匹配。这里没有活动观察者,失效没有马上增加读取次数;下一次 fetchQuery 才重新读取。真实页面的活动查询通常还会在失效时后台重取,见官方失效说明。
API 失败需要成为查询失败
当前 api.ts封装请求:按需设置 Content-Type,带上已配置 token 的 Bearer 头,并将 HTTP 错误和业务 success:false 变成异常。HTTP 401 还会清除 token 并通知认证状态变更。泛型返回类型只是类型断言,不是对任意响应 JSON 的运行时验证。
这类封装让查询层看到失败,但页面仍须显示失败。比如 SearchPage处理 isError;笔记列表当前主要取 data 和 isLoading,不能因此把所有没有数据的显示都理解成已确认的空列表。排查时应分别检查首次加载、后台刷新、失败和真正零结果。
useMutation 也不按 HTTP 方法自动分配。SearchPage 使用它触发按钮提交的 GET 搜索,所以“查询都必须用 useQuery,所有 mutation 都写服务器”不符合现有代码。选择应依据自动缓存和触发方式,而不是只看函数名称。
提交成功与任务完成分开处理
当前总结 mutation 在请求成功后使 queue-status、conversation、conversations、notes、status 等查询失效;批量总结和取消有各自的键集合。但后端接受排队请求后,AI 工作仍可能在运行。这时刷新笔记列表,不保证已经能读到新笔记。
use-queue.ts在有活动任务时每 2 秒查询状态,否则每 10 秒;StatusBar显示任务进展与完成提示。这个完成提示没有同时让 notes 查询失效,不能把它描述为“后台结束后所有列表自动同步”。队列自身的边界见任务队列设计。
导入的 SSE 钩子又是一条独立 fetch 路径,在 done 时更新导入状态并使 conversations、status 失效。SSE 完成、队列完成和普通请求成功是不同事件,应逐条确认需要刷新哪些缓存。
把刷新约定写成可验证的行为
如果要补充后台完成后的刷新,可以让明确的完成事件按任务结果使相关键失效,再用延迟任务样本验证列表确实出现新数据。这是改进建议,当前代码并未在此文章中实现。同样,乐观更新需要回滚和并发策略,不能因为有 invalidateQueries 就宣布已有。
验收时至少区分:筛选改变是否使用新键,提交成功时任务是否仍在运行,完成后是否重新请求,失败是否显示错误而不是空状态。staleTime 与轮询间隔应结合这些事件选择;本文的离线调用次数不是网络性能或页面响应时间测量。