tRPC SSG 预取无限滚动查询:defaultPageParam 的 undefined 序列化陷阱与解法
本篇围绕 tRPC 仓库中的 SSG 无限查询 E2E 测试示例(examples/.test/ssg-infinite-serialization)展开,讲解如何在 Next.js Pages Router 的 getStaticProps 中通过 tRPC 服务端助手(Server Side Helpers)预取无限滚动(Infinite Query)数据并脱水(dehydrate)注入页面,以及 tRPC 如何在源码层面保证 initialPageParam(旧版 TanStack Query 中叫 defaultPageParam)不会被序列化成 undefined 而破坏 SSG 产物。读完后你能掌握:SSG 场景下无限查询的完整落地链路、prefetchInfinite 的底层实现,以及用 Playwright 无 JS 渲染验证静态数据真实落盘的测试方法。
1. 示例要解决的问题
该示例的 README 只陈述了一个非常具体的回归测试目标:
This test shows prefetching infinite queries without a transformer.
tRPC doesn't require
defaultPageParamto be set, which can be problematic since undefined cannot be serialized which leads to invalid page params. This test should test that we don't mess this up.
翻译过来有两层含义:
- 在无 transformer(纯 JSON 序列化)的情况下,
getStaticProps里预取一个 infinite query,把ssg.dehydrate()的结果塞进页面props。这些 props 会被 Next.js 走 JSON 序列化写入静态 HTML/JSON,因此其中任何undefined值都会导致序列化失败或产出无效的页面参数; - tRPC 允许开发者不显式传入
defaultPageParam(v5 中已更名为initialPageParam),如果框架内部没有把它兜底成一个可序列化的值(null),第一页请求的游标就会以undefined的形式进入 query key / page params,SSG 脱水产物随之损坏。
这个示例就是用来守住这条回归线的:只要 tRPC 的 SSG helpers 对 initialPageParam 的兜底逻辑被改坏,这个 E2E 测试就会失败。
2. 示例工程全景与运行方式
示例是一个最小的 Next.js Pages Router 应用,关键文件如下:
| 文件 | 职责 |
|---|---|
| src/server/trpc.ts | 服务端 tRPC 初始化入口 |
| src/server/routers/_app.ts | 根路由,含游标分页的 getPosts 查询 |
| src/pages/api/trpc/[trpc].ts | Next.js API 路由适配 tRPC |
| src/pages/index.tsx | SSG 页面:getStaticProps 预取 + 无限滚动 |
| src/pages/_app.tsx | 用 trpc.withTRPC 包裹应用 |
| src/utils/trpc.ts | 客户端 createTRPCNext 配置 |
| test/smoke.test.ts | Playwright E2E 测试 |
package.json 声明的版本环境为:@trpc/server、@trpc/client、@trpc/next ^11.16.0,next ^15.3.8,react ^19.1.0,@tanstack/react-query ^5.80.3,zod ^4.2.1。提供以下脚本:
"scripts": {
"dev": "next dev",
"build": "next build",
"test:e2e": "playwright test",
"test-dev": "start-server-and-test dev http://127.0.0.1:3000 test:e2e",
"test-start": "start-server-and-test start http://127.0.0.1:3000 test:e2e"
}
即先用 start-server-and-test 拉起 dev 或 production 服务器,等 3000 端口就绪后跑 Playwright。
2.1 服务端:不挂 transformer 的 tRPC 初始化
trpc.ts 中 transformer 被刻意注释掉:
import { initTRPC } from '@trpc/server';
const t = initTRPC.create({
// transformer: superjson,
});
export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;
这正对应 README 中 “without a transformer” 的测试前提——所有请求/响应都走 tRPC 默认的 JSON 序列化路径,从而把 undefined 序列化的问题暴露在最严格的环境下。
2.2 游标分页的 getPosts 查询
_app.ts 用一个内存数组模拟 15 篇文章,查询的 input schema 是理解本例的关键:
export const appRouter = router({
getPosts: publicProcedure
.input(
z
.object({
limit: z.number().default(3),
cursor: z.number().nullish(), // 注意:nullish 而非 optional
})
.default({}),
)
.query(({ input }) => {
const start = input.cursor ?? 0;
const items = posts.slice(start, start + input.limit);
const nextCursor =
start + input.limit < posts.length ? start + input.limit : null;
return {
items,
nextCursor, // 没有下一页时返回 null 而不是 undefined
};
}),
});
两个细节值得注意:cursor 使用 nullish()(null/undefined 均合法),整个 input 又套了 .default({})(允许不传 input);返回值的 nextCursor 在到达末页时显式给出 null。服务端刻意只产出 null 而不产出 undefined,与客户端/SSG 的序列化安全要求保持一致。
2.3 API 路由与客户端配置
API 路由 是标准写法:
export default createNextApiHandler({
router: appRouter,
createContext: () => ({}),
});
客户端 使用 createTRPCNext,且 ssr: false——运行期不做 SSR,页面数据完全依赖构建期的 SSG 预取与客户端后续请求;links 同样是裸 httpBatchLink,无 transformer:
export const trpc = createTRPCNext<AppRouter>({
config() {
return {
links: [
httpBatchLink({
url: getBaseUrl() + '/api/trpc',
}),
],
};
},
ssr: false,
});
3. SSG 页面:getStaticProps + prefetchInfinite 的完整链路
index.tsx 是整个示例的核心,完整继承如下(注释已结合仓库内实际代码补全):
import { createServerSideHelpers } from '@trpc/react-query/server';
import { appRouter } from '~/server/routers/_app';
import { trpc } from '~/utils/trpc';
/**
* This page will be served statically
*/
export const getStaticProps = async () => {
const ssg = createServerSideHelpers({
router: appRouter,
ctx: {},
// transformer: superjson, // 刻意不启用,与运行时保持一致
});
// 服务端直接调用 router 预取第一页(走 callRouter,不经过 HTTP)
await ssg.getPosts.prefetchInfinite({});
return {
props: {
trpcState: ssg.dehydrate(), // 脱水状态注入 props,必须可 JSON 序列化
},
revalidate: 1, // ISR:1 秒后重新校验
};
};
export default function IndexPage() {
const result = trpc.getPosts.useInfiniteQuery(
{},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
},
);
if (!result.data) {
// unreachable, page is served statically
return <div>Loading...</div>;
}
return (
<div>
<h1>Posts</h1>
{result.data.pages.map((page, i) => (
<div key={i}>
{page.items.map((post) => (
<div key={post.id}>{post.title}</div>
))}
</div>
))}
<button
data-testid="load-more"
onClick={() => result.fetchNextPage()}
disabled={result.isFetchingNextPage || !result.hasNextPage}
>
{result.hasNextPage ? 'Load more' : 'No more posts to fetch'}
</button>
</div>
);
}
流程拆解:
createServerSideHelpers创建服务端助手,ctx传空对象(本例路由不依赖 context);ssg.getPosts.prefetchInfinite({})在 Node 端直接以null作为初始游标预取第一页(3 篇),写入内部QueryClient的缓存;ssg.dehydrate()将缓存脱水成普通对象放入props.trpcState,[trpcState]会被withTRPC在客户端hydrate回QueryClient;- 客户端
useInfiniteQuery命中缓存后,首屏无需任何请求即可渲染第一页;fetchNextPage()通过getNextPageParam从上一页的nextCursor计算下一游标,逐页拉取。
这个模式与 tRPC 官方文档 Static Site Generation (SSG) 讲解的 prefetch + dehydrate 是同一套机制,官方文档还特别提示:TanStack Query 默认在客户端挂载时会重新拉取数据,如果希望完全依赖 getStaticProps,需要把 refetchOnMount、refetchOnWindowFocus 设为 false(本示例的 E2E 测试并未关闭这两项,加载更多依赖的是缓存中的游标状态,两者互不冲突)。
4. 源码级解析:tRPC 如何保证 initialPageParam 可序列化
README 所说的“defaultPageParam 可以不设”背后的真正防线,在 @trpc/react-query 的 SSG helpers 实现里。
查看 packages/react-query/src/server/ssgProxy.ts,prefetchInfinite 的生成逻辑是:
prefetchInfinite: () => {
const args1 = args[1] as Maybe<TRPCFetchInfiniteQueryOptions<any, any, any>>;
return queryClient.prefetchInfiniteQuery({
...args1,
queryKey,
queryFn,
initialPageParam: args1?.initialCursor ?? null, // 关键兜底
});
},
同样的兜底也出现在客户端工具函数 createUtilityFunctions.ts 的 fetchInfiniteQuery / prefetchInfiniteQuery 中:
fetchInfiniteQuery: (queryKey, opts) => {
return queryClient.fetchInfiniteQuery({
...opts,
queryKey,
queryFn: ({ pageParam, direction }) => {
return untypedClient.query(
...getClientArgs(queryKey, opts, { pageParam, direction }),
);
},
initialPageParam: opts?.initialCursor ?? null,
});
},
从源码结构看,这条链路的设计意图是清晰的:
- TanStack Query v5 的无限查询用
initialPageParam作为第一页的pageParam(v4 时代叫defaultPageParam,v5 更名);如果调用方不传,框架默认值是undefined。 - tRPC 把它映射为自己的
initialCursor:用户不写initialCursor时,tRPC 统一用?? null兜底成null。第一页的queryFn因此以pageParam = null调用 procedure,对应 input 中的cursor: null——null是合法的 JSON 值,脱水后的 query key、page params 全程可序列化。 - 若这里不兜底(即
initialPageParam保持undefined),SSG 场景下dehydrate()产物中会残留undefined的游标占位,写入页面 props 后要么被 JSON 序列化丢弃、要么触发 Next.js 的非可序列化 props 报错,客户端 hydrate 后得到的就是无效的页面参数,fetchNextPage无从算出下一个游标——这正是 README 所说 “undefined cannot be serialized which leads to invalid page params” 的具体含义。
此外,代理层的类型定义 utilsProxy.ts 声明了 prefetchInfinite 的完整签名(input + 可选 TRPCFetchInfiniteQueryOptions),并且从源码结构看,查询 key 的推导逻辑还会根据 input 是否含有 cursor 字段自动把该 procedure 归类为 infinite 类型,使 invalidate/refetch 等批量操作能正确作用于无限查询——这些都可以从 utilsProxy.ts 中 QueryKeyKnown 的条件类型里找到依据。
5. E2E 测试:如何证明“没弄坏”
smoke.test.ts 用 Playwright 从两个维度验证整条链路(浏览器端使用 playwright.config.ts 配置):
test('query should be prefetched', async ({ page, javaScriptEnabled }) => {
javaScriptEnabled = false; // 关闭 JS
await page.goto('/');
// Since we're prefetching the query the data should be available immediately
expect(await page.textContent('text=First Post')).toBeTruthy();
});
test('can fetch more data 4 times', async ({ page }) => {
await page.goto('/');
const loadMore = page.getByTestId('load-more');
expect(await page.textContent('text=First Post')).toBeTruthy();
await loadMore.click();
expect(await page.textContent('text=Fourth Post')).toBeTruthy();
await loadMore.click();
expect(await page.textContent('text=Seventh Post')).toBeTruthy();
await loadMore.click();
expect(await page.textContent('text=Tenth Post')).toBeTruthy();
await loadMore.click();
expect(await page.textContent('text=Thirteenth Post')).toBeTruthy();
expect(await loadMore.textContent()).toBe('No more posts to fetch');
expect(await loadMore.isDisabled()).toBeTruthy();
});
两个测试各有其验证目标:
- 禁用 JavaScript 也能看到 “First Post”:SSG 页面在没有客户端 JS 参与的情况下渲染出了第一页数据,证明
dehydrate()的数据确实被烘焙进了静态 HTML,而不是依赖运行时fetch。这是验证 SSG 是否真正生效的最硬核手段。 - 恰好可以点 4 次 “Load more”:15 篇文章、
limit默认 3,共 5 页。SSG 已预取第 1 页,剩余 4 次点击依次拉取第 2–5 页(第 4、7、10、13 篇文章可见),末页后nextCursor为null、hasNextPage变false,按钮文案变为 “No more posts to fetch” 且被禁用。这同时验证了getNextPageParam与null游标的往返序列化在客户端增量请求中也是干净的。
跑一次 pnpm test:e2e(或经由 monorepo 的 turbo 流水线 test-start)即可复现:任何让 initialPageParam 退化为 undefined 的改动,都会在这两个断言之一上露馅。
6. 实战要点小结
把该示例与 SSG 官方文档 结合,在真实项目里做 SSG + 无限滚动时值得注意的几点:
- 服务端与客户端的 transformer 配置必须一致。本示例两边都注释掉了
superjson;如果启用 transformer,createServerSideHelpers的transformer选项要与initTRPC、links 配置匹配,否则脱水/注水会出现格式错位。 - 游标 schema 用
nullish()并让服务端只在有下一页时返回真实游标、否则返回null,从数据源头消灭undefined。 - 不要依赖调用方记得传
initialPageParam:tRPC 的prefetchInfinite已用initialCursor ?? null兜底(见 ssgProxy.ts),自定义封装 SSG 逻辑时也要保留这层保护。 revalidate控制 ISR 节奏:示例中revalidate: 1仅用于测试加速,生产环境按需设置;需要页面内容严格新鲜时可配合 TanStack Query 的refetchOnMount选项,或参考官方文档全局关闭重取以节省 API 配额。- 用“禁用 JS 断言”回归 SSG 正确性,比单纯断言页面文本更能暴露“数据其实来自运行时请求而非静态 HTML”的隐蔽 bug。
这一示例体量虽小,但完整覆盖了 tRPC v11 + TanStack Query v5 下 SSG 预取无限查询的序列化边界:null 游标、initialPageParam 兜底、脱水注水往返、ISR 重校验,四者共同保证了“Move Fast and Break Nothing”中那个 “Nothing” 不会在静态页面上被 break。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00