小类随手记

TanStack Router Generator 自动脚手架与 AI 开发的冲突

排查 TanStack Router Generator 在 AI 批量编辑文件时自动将路由文件覆盖为 Hello 模板的根因,以及其设计理念与 AI 辅助开发工作流之间的根本矛盾。

现象

AI 在批量修改前端路由文件时,文件内容被替换为一个标准的 TanStack Router “Hello” 模板:

1
2
3
4
5
6
7
8
9
import { createFileRoute } from '@tanstack/react-router'

export const Route = createFileRoute('/some/path/')({
  component: RouteComponent,
})

function RouteComponent() {
  return <div>Hello "/some/path/"!</div>
}

每次出现时内容高度一致,不是乱码也不是局部损坏,而是一个语法完整、格式规范的路由骨架页。现象可稳定复现。

询问不同的ai大模型和询问朋友 给答案都不同。于是开始在本地ai的帮助下开始排查。

排查过程

方向一:编辑器或 AI 工具的 bug

最初怀疑是 AI 生成代码时出了偏差。但排查后发现:

  • AI 工具的编辑操作是精确的文本替换(oldTextnewText),没有写入模板的逻辑
  • 模板内容和 AI 的本次编辑任务无关
  • 单独提交单处编辑时不会触发,只有批量编辑时才出现

这说明模板不是 AI 工具的产出,而是某个中间环节注入的。

方向二:git 操作或自动提交

检查了 git 的 hooks 目录,所有 hook 都是 .sample 后缀,没有启用的 hook。git config 中也没有关联任何 hook 或自动化脚本。排除。

方向三:框架的自动脚手架

查看项目的依赖,发现使用了 @tanstack/router-plugin + @tanstack/router-generator。前者是 Vite 插件,后者负责文件路由的代码生成。

关键线索在这个文件:

1
node_modules/@tanstack/router-generator/dist/esm/template.js
1
2
3
4
5
6
7
8
route: {
    template: () => [
        "%%tsrImports%%",
        "\n\n",
        "%%tsrExportStart%%{\n component: RouteComponent\n }%%tsrExportEnd%%\n\n",
        "function RouteComponent() { return <div>Hello \"%%tsrPath%%\"!</div> };\n"
    ].join(""),
}

这正是出现在被覆盖文件中的模板。填入路径参数后生成的内容完全匹配。

触发条件

generator.js 中找到脚手架的执行入口:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// processRouteNodeFile 方法中
if (!existingRouteFile.fileContent) {
    shouldWriteRouteFile = true;
    shouldWriteTree = true;
    // ...
    const tRouteTemplate = this.targetTemplate.route;
    updatedCacheEntry.fileContent = await fillTemplate(
        this.config,
        this.config.customScaffolding?.routeTemplate ?? tRouteTemplate.template(),
        { /* ... */ }
    );
}
// ...
if (shouldWriteRouteFile) {
    await this.safeFileWrite({
        filePath: node.fullPath,
        newContent: updatedCacheEntry.fileContent,
        // ...
    });
}

逻辑很直接:如果文件内容为空,就用模板填充并写回。这是一个为"用户新建空路由文件时自动填充骨架"而设计的特性。

同时 router-generator-plugin.js 注册了文件变更监听:

1
2
3
async watchChange(id, { event }) {
    await generate({ file: id, event });
}

Vite dev server 运行时,Generator 监听 ./src/routes/ 目录下所有文件的变动,一旦触发就读取文件、判断是否为空、决定要不要脚手架覆盖。

竞态条件

结合 AI 工具的工作方式,完整的发生链路是:

  1. AI 工具对路由文件执行批量文本编辑(多处替换)
  2. 文件系统层面上,写入不是原子的——存在一个时间窗口文件内容为空或为部分内容
  3. Generator 的 watchChange 在此时触发,读取文件发现为空
  4. if (!existingRouteFile.fileContent) 判断为真
  5. Generator 生成模板并通过 safeFileWrite 写入(写到临时文件后 rename,原子操作)
  6. 模板覆盖了 AI 工具的编辑结果

safeFileWrite 的实现使用了写临时文件再 rename 的策略:

1
2
3
4
5
6
7
async safeFileWrite(opts) {
    const tmpPath = this.getTempFileName(opts.filePath);
    await this.fs.writeFile(tmpPath, opts.newContent);
    // ... 权限校验 ...
    await this.fs.rename(tmpPath, opts.filePath);
    return await this.fs.stat(opts.filePath);
}

rename 是 POSIX 原子操作,一旦 Generator 率先完成,模板就固化了。

设计理念的矛盾

这是一个设计理念层面的冲突,不是简单的 bug。

TanStack Router Generator 做了一个假设:空的路由文件 = 用户刚创建的文件,需要脚手架帮填骨架。这个假设在以下场景成立:

  • 开发者手动 touch 一个新路由文件
  • 开发者用 IDE 新建文件后尚未输入内容

但在 AI 辅助开发的工作流中,这个假设不成立:

  • AI 编辑文件时,文件内容在短时间内经历"完整 → 空 → 完整"的状态变化
  • AI 有时会删除文件后重新创建(而不是原地编辑),这同样会产生空文件窗口
  • 批量操作涉及多个文件时,时间窗口被放大

问题的本质是:Generator 把"文件内容为空"等同于"文件需要脚手架",但"空"也可能是一个正在被写入的中间状态。 对于人工操作,这个时间窗口短到几乎不存在(毫秒级),但在自动化工具的操作下,窗口被显著放大。

为什么关不掉

查看了完整的配置接口:

  • enableRouteGeneration: false 可以关闭整个 Generator,但也会禁用路由树生成(routeTree.gen.ts),加新路由后需要手动维护
  • customScaffolding.routeTemplate 只能替换模板内容,不能禁止脚手架行为
  • 没有独立的选项来关闭"空文件自动填充"这个特性

唯一的官方配置路径是 routeFileIgnorePattern 来忽略特定文件,但这需要预先知道哪些文件会被编辑,对 AI 工作流不适用。

代码证据汇总

三个关键文件(版本 [email protected][email protected]):

文件行号作用
router-generator/dist/esm/template.jsroute.template定义脚手架模板(Hello 页面的来源)
router-generator/dist/esm/generator.js471if (!existingRouteFile.fileContent) 空文件判定
router-plugin/dist/esm/router-generator-plugin.js48watchChange 文件变更监听入口

后续

截至本文,上游 npm 包的最新版本与本地版本一致,没有改善和修复。

最后的解决方案 就是迁移到 更为成熟稳健的 React Router v7 .但是考虑到 TanStack Query 还是较为稳健的,暂时保留。 SWR 还需要观望。

comments powered by Disqus
Theme Stack