跨 pnpm workspace 消费 Tailwind v4 设计系统包的三连坑
想做一个「改组件库源码 → app 热更新即见效」的即时 dogfood 循环:app 独立 repo,设计系统包(含 UI 组件 + tokens)在旁边的 monorepo 里发布为 TS source。用 symlink 一接,三个坑连环爆,踩完一个下一个在等你。
现象
错误按顺序出现,每解决一个就冒出下一个:
Module not found: Can't resolve '@scope/ui'(Turbopack 下,明明 symlink 存在)CssSyntaxError: Can't resolve 'tailwindcss' in '.../packages/tokens/src'- (潜伏)运行时 "invalid hook call"——重复 React 实例
根因
坑一:手动 symlink 不够,Turbopack 看的是 pnpm 注册表
Turbopack 解析 node_modules 依赖时,不只检查文件系统路径是否存在,还要求 pnpm 已经把该包注册进依赖图。手动 ln -s 进 node_modules/@scope/ui 绕过了 pnpm,Turbopack 找不到。
此外,即使用 pnpm 的 file: 协议,也会把包复制/hardlink 进 pnpm store,本地编辑无法实时反映——热更新断了。
ln -s 手动链接或 file: 协议均无法实现「改库源码 → app 即生效」的 dogfood 循环。file: 是快照,ln -s 绕过了 pnpm 注册,两者都被 Turbopack 拒绝或失效。
坑二:Turbopack 把 symlink 解析为真实路径,然后拒绝"越界"
link: 注册后,Turbopack 把 symlink 解析成 real path(如 ../ds-monorepo/packages/ui),发现它在 app 根目录之外,直接拒绝。
同时,webpack: { resolve: { alias } } 配置在 Turbopack 下完全无效,不要浪费时间在这里。
坑三:tokens 包的 preset.css 里有 @import "tailwindcss",但包本身没声明这个依赖
在 monorepo 内部,tailwindcss 通过 hoisting 被透明地找到。一旦跨 repo 消费,Tailwind PostCSS 插件处理 preset.css 时,从 preset.css 所在目录(packages/tokens/src/)往上找 tailwindcss,找不到,直接报 CSS 语法错误。这是库的打包缺口,不是 app 的问题。
正解
1. 用 pnpm link: 协议,让 pnpm 正式注册
// app 的 package.json
"dependencies": {
"@scope/ui": "link:../ds-monorepo/packages/ui",
"@scope/tokens": "link:../ds-monorepo/packages/tokens"
}pnpm install 后,pnpm 既建了 symlink 又完成了注册,Turbopack 能找到它。编辑库源码 → app HMR 即刷,dogfood 闭环。
2. 把 Turbopack root 提升到公共祖先目录
// next.config.mjs
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default {
transpilePackages: ["@scope/ui", "@scope/tokens"],
turbopack: {
// 把 root 提升到 app 和 ds-monorepo 的公共父目录
root: path.resolve(__dirname, ".."),
},
};这样 Turbopack 的「越界检查」基准变宽,sibling 路径不再被拒绝。
如果需要强制单 React,turbopack.resolveAlias 里要填 root 相对路径(如 "./app-site/node_modules/react"),填绝对路径会变成一个莫名其妙的"server relative import"而失效。
3. 在库的 tokens 包里显式声明 tailwindcss 依赖
// packages/tokens/package.json
"peerDependencies": { "tailwindcss": ">=4" },
"devDependencies": { "tailwindcss": "^4.3.0" }pnpm install --filter @scope/tokens 后,packages/tokens/node_modules/tailwindcss 物理存在,@import "tailwindcss" 从 preset.css 所在目录就能解析到。
app 侧的 globals.css 保持干净,再加一行 @source 指向库的 TS 源码,让 Tailwind 扫到类名:
@import "@scope/tokens/tokens.css";
@import "@scope/tokens/preset.css";
@source "../../ds-monorepo/packages/ui/src/**/*.{ts,tsx}";package.json用link:协议 +pnpm install,让 Turbopack 认识这个包。next.config.mjs里turbopack.root提升到公共祖先,解除"越界"限制。- tokens 包补声明
tailwindcssdevDep,让@import "tailwindcss"在包内就地可解析。
附:Vite SPA 消费同款库的额外注意点
若 app 是 Vite 而非 Next.js,需要额外处理:
- 重复 React:Vite 不像 Turbopack 自动去重,必须在
resolve.dedupe里列出["react", "react-dom", "@emotion/react", "motion"]。 - 文件系统拒绝:链接目标在 project root 外,须在
server.fs.allow里加上 sibling 的真实路径。 @source指向真实路径:Tailwind 的 fast-glob 不跟随 symlink,@source要写 real path,不能写 symlink 路径。- 跨 repo
tsc --noEmit是死胡同:会拉入库的 TS 源码,爆出重复@types/react、noUnusedLocals等库内部错误,无法从 app 侧修复。改用vite build(esbuild 转译,只报真实解析错误)作为类型安全门禁。
一句话外卖
pnpm
link:是跨 repo dogfood 的唯一正路;Turbopack root 要提升到公共祖先;库里@import "tailwindcss"就必须在库里声明tailwindcss依赖,不能靠 monorepo hoisting 隐式解决。