Skip to content

Commit 16d50d0

Browse files
committed
fix(islands): stop wrapping Vue sub-requests so .client.vue survives a production build
补 Server / Client Components(Task 9.1 / 9.2)的示例时暴露的缺陷:dev 下 `.server.vue` / `.client.vue` 一切正常(服务端组件出内容、客户端组件出 `<div data-client-only>` 占位符、客户端模块 解析到通用 stub),但 **`ubean build` 在 `[plugin vite:vue]` 阶段直接中断**: ``` The argument 'path' must be a string, Uint8Array, or URL without null bytes. Received '\x00virtual:ubean-client-component:/…/BrowserClock.client.vue?vue&type=script&setup=true&lang.ts' ``` 两层根因: 1. **包装虚拟模块 ID 直接拼真实路径、以 `.vue` 结尾** → `@vitejs/plugin-vue` 的 `include: /\.vue$/` 命中,把它当真实 SFC 去读盘。修法:ID 末尾加尾标 `.ubean-wrapper`,文件名不再匹配。 2. **子请求也被包装**(更隐蔽):Vue 会为每个 SFC 生成 `Foo.vue?vue&type=script&lang.ts` 这类内部 模块,而 `isClientComponentFile()` 用 `id.split('?')[0]` 判定 → 子请求同样命中,拼出 `\0virtual:…:<真实路径>?vue&type=script…` 这种四不像 ID。修法:三个分支(`.server.vue` 重定向 / `.client.vue` 包装 / 配对组件解析)都先排除子请求(新增 `isVueSubRequest()`)。 验证(dev 与生产两侧都过):SSR 出服务端内容 + 客户端占位符、**不出**客户端组件真实内容; 浏览器水合后占位符被真实内容替换,无 error、无 hydration 告警;**产物隔离** —— 客户端产物里没有 服务端组件的文案,只有 stub 元素名 `ubean-server-only`。断言落在 `dev-topology.test.ts` / `preview-cli.test.ts`(SSR 三态)与 `build-contracts.test.ts`(隔离,矩阵基线格)。 顺带:矩阵基线格新增的两条隔离断言同时守住「服务端实现不进客户端产物」这条契约。 验证:全量 `pnpm test` 绿(cli 178 / builder 391+2 skipped / example 783 / server 385 / vue 211 / preset 89 …);`pnpm typecheck` 0 error;`pnpm lint` 0 error;`analyze:check` total +4.1% / entry -10.4%(entry 变小是因为新页面替换了部分既有 chunk 划分,仍在门禁内)。
1 parent f4f0af8 commit 16d50d0

8 files changed

Lines changed: 110 additions & 6 deletions

File tree

docs/vite-plugin-migration.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,17 @@ Failed to resolve import "virtual:ubean-app" from "…/.ubean/virtual/server-ent
334334

335335
修法:dev 的 `buildDevSsrRoutes()` 与产物入口的 `buildRendererSetup()` 都改为按 route 分组、把插槽写成命名视图(`components: { default, <slot> }`,与 `component` 互斥),拦截路由与客户端一致地单独注册(名字加 `__intercept_` 前缀)。`SlotView` 本身已支持懒加载组件(`defineAsyncComponent` 包装),因此分组后 SSR 也能渲染插槽。断言:`dev-topology.test.ts``preview-cli.test.ts` 各一条(首屏 HTML 同时含默认视图与插槽标记)。
336336

337+
**K. `.client.vue` 让生产构建直接失败(dev 侥幸不挂)。** 补 Server/Client Components 的示例时暴露:dev 下 `.server.vue` / `.client.vue` 一切正常(服务端组件出内容、客户端组件出占位符、客户端模块解析到通用 stub),但 `ubean build``[plugin vite:vue]` 阶段中断:
338+
339+
```
340+
The argument 'path' must be a string, Uint8Array, or URL without null bytes.
341+
Received '\x00virtual:ubean-client-component:/…/BrowserClock.client.vue?vue&type=script&setup=true&lang.ts'
342+
```
343+
344+
根因两层:① 包装虚拟模块 ID 直接把**真实路径**拼在前缀后面,于是它以 `.vue` 结尾 —— `@vitejs/plugin-vue``include: /\.vue$/` 命中,把它当真实 SFC 去读盘(新增尾标 `.ubean-wrapper` 让文件名不再匹配);② 更隐蔽的是**子请求**:Vue 会为 SFC 生成 `Foo.vue?vue&type=script&lang.ts` 这类内部模块,而 `isClientComponentFile()``id.split('?')[0]` 判定,于是**子请求也被包装**,拼出 `\0virtual:…:<真实路径>?vue&type=script…` 这种产物。修法:三个分支(`.server.vue` 重定向、`.client.vue` 包装、配对组件解析)都先排除子请求(新增 `isVueSubRequest()`)。
345+
346+
验证:dev 与生产两侧行为一致(服务端组件内容 + 客户端占位符),浏览器水合后占位符被真实内容替换且无 error / 无 hydration 告警;**产物隔离**由矩阵基线格守着 —— 客户端产物里不得出现服务端组件的文案,且必须带 stub 元素名 `ubean-server-only`。断言:`dev-topology.test.ts` / `preview-cli.test.ts`(SSR 三态)与 `build-contracts.test.ts`(隔离)。
347+
337348
**同批查实的另一条,登记为待决(语义有歧义,需 owner 定)****拦截路由只有元数据、没有运行时**。生成器会为 `(..)target/` 之类的文件注册带 `meta.interceptFrom` / `interceptTarget` / `isIntercepting` 的路由,但全仓没有任何**消费者** —— 「从 X 导航到 Y 时渲染拦截页」这件事不会发生,拦截页只能靠它自己被清理后的路径访问。实现它需要先定语义(本仓当前把 `(.)target` 段从路径里剥掉、只把 `target` 记进元数据,与 Next 的「拦截页自身路径 = 目标路径」不同),因此不在本轮擅自落地。
338349

339350
**顺带记下一条使用约束**(不是框架缺陷):运行时路由 import `ubean/build`(示例里那条 `prerender-test.ts`,为 HTTP 集成测试暴露预渲染 API)会把整条构建工具链打进服务端产物 —— Node 上只是体积浪费,worker 上会在**构建期**失败(工具链的可选依赖 `velocityjs` / `atpl` … 无法打包)。因此**矩阵的 cloudflare 格改用 builder 的最小 fixture** 构建,其余格仍用示例项目;这条约束写进了迁移指南。
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<script setup lang="ts">
2+
import { onMounted, ref } from 'vue';
3+
4+
// Client Component(Task 9.2):`.client.vue` 在 SSR 输出 `<div data-client-only>` 占位符,
5+
// 客户端挂载后才渲染真实内容(依赖浏览器 API 的组件适用)。
6+
const ticks = ref(0);
7+
onMounted(() => {
8+
ticks.value = 1;
9+
});
10+
</script>
11+
12+
<template>
13+
<p class="sc-client">Client Component 已挂载(ticks={{ ticks }})</p>
14+
</template>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<script setup lang="ts">
2+
// Server Component(Task 9.1):`.server.vue` **只在服务端渲染**,客户端构建里被换成通用 stub,
3+
// 组件实现不会进入客户端产物(可以用只存在于服务端的东西,如读环境变量)。
4+
const renderedAt = 'server-only';
5+
</script>
6+
7+
<template>
8+
<p class="sc-server">来自 Server Component(仅服务端渲染):{{ renderedAt }}</p>
9+
</template>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<script setup lang="ts">
2+
import BrowserClock from '../components/sc/BrowserClock.client.vue';
3+
import ServerGreeting from '../components/sc/ServerGreeting.server.vue';
4+
5+
definePage({
6+
name: 'ServerComponents',
7+
meta: { title: 'Server / Client Components' }
8+
});
9+
</script>
10+
11+
<template>
12+
<section class="server-components">
13+
<h1>Server / Client Components</h1>
14+
<ServerGreeting />
15+
<BrowserClock />
16+
</section>
17+
</template>

packages/cli/test/build-contracts.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,12 @@ describe('构建产物契约(RM-V23 矩阵 / RM-V36 重写)', () => {
191191
const serverText = readArtifactText(join(fixtureDir, outDir, 'server'));
192192
expect(serverText, '服务端入口的页面表应当带 matcher 映射').toContain('"matchers"');
193193
expect(serverText).toContain('numeric');
194+
195+
// Server Components 的隔离(Task 9.1):`.server.vue` 的实现只能进服务端产物 —— 客户端
196+
// 只应有通用 stub(元素名 `ubean-server-only`),组件的文案不得出现。
197+
const clientText = readArtifactText(join(fixtureDir, outDir, 'public'));
198+
expect(clientText, '服务端组件的实现不应进客户端产物').not.toContain('仅服务端渲染');
199+
expect(clientText, '客户端产物应当带服务端组件 stub').toContain('ubean-server-only');
194200
}, 600_000);
195201

196202
it.each(PRESET_CONTRACTS)(

packages/cli/test/dev-topology.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,21 @@ describe('dev 请求拓扑(RM-V05 基线)', () => {
151151
});
152152
});
153153

154+
/**
155+
* Server / Client Components(Task 9.1 / 9.2):
156+
* `.server.vue` 只在服务端渲染,`.client.vue` 在 SSR 只输出 `<div data-client-only>` 占位符。
157+
*/
158+
describe('Server / Client Components', () => {
159+
it('SSR:服务端组件出内容,客户端组件出占位符(不出真实内容)', async () => {
160+
const res = await probe('/server-components');
161+
expect(res.status).toBe(200);
162+
expect(res.body).toContain('class="sc-server"');
163+
expect(res.body).toContain('data-client-only');
164+
// 客户端组件的真实内容必须**不在**首屏里(否则就是 SSR 执行了浏览器侧代码)
165+
expect(res.body).not.toContain('class="sc-client"');
166+
});
167+
});
168+
154169
describe('页面 404 与 API 404 的分野', () => {
155170
it('未知页面路径返回 404 + HTML(走页面兜底而非 JSON)', async () => {
156171
const res = await probe('/definitely-missing-page');

packages/cli/test/preview-cli.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,15 @@ describe('ubean preview(fullstack,委托 vite preview + 生产 handler)',
123123
expect(html).toContain('class="slot-aside"');
124124
});
125125

126+
it('Server / Client Components 在生产 SSR 里同形(内容 + 占位符)', async () => {
127+
const res = await fetch(`${running!.baseUrl}/server-components`);
128+
expect(res.status).toBe(200);
129+
const html = await res.text();
130+
expect(html).toContain('class="sc-server"');
131+
expect(html).toContain('data-client-only');
132+
expect(html).not.toContain('class="sc-client"');
133+
});
134+
126135
it('API 与 404 走生产 handler(静态目录里没有这两个路径)', async () => {
127136
const api = await fetch(`${running!.baseUrl}/api/hello`);
128137
expect(api.status).toBe(200);

packages/islands/src/vite.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,30 @@ const SERVER_COMPONENT_STUB_RESOLVED_ID = `\0${SERVER_COMPONENT_STUB_VIRTUAL_ID}
6161
export const CLIENT_COMPONENT_PLACEHOLDER_VIRTUAL_ID = 'virtual:ubean-client-component-placeholder';
6262
const CLIENT_COMPONENT_PLACEHOLDER_RESOLVED_ID = `\0${CLIENT_COMPONENT_PLACEHOLDER_VIRTUAL_ID}`;
6363

64-
/** `.client.vue` 在 client 构建中文件级包装虚拟模块 ID 前缀 */
64+
/**
65+
* `.client.vue` 在 client 构建中文件级包装虚拟模块 ID 前缀。
66+
*
67+
* ID 形如 `\0virtual:ubean-client-component:<真实路径><WRAPPER_TAIL>` —— **尾标不能省**:
68+
* 若 ID 直接以 `.vue` 结尾,`@vitejs/plugin-vue` 会把它当作真实 SFC 去读盘,报
69+
* `The argument 'path' must be a string … without null bytes`(实测:dev 走不到这条路径,
70+
* 生产构建必挂)。尾标让文件名不再匹配 `/`\.vue$/`,Vue 插件自然跳过。
71+
*/
6572
const CLIENT_COMPONENT_WRAPPER_PREFIX = '\0virtual:ubean-client-component:';
6673

74+
/** 包装虚拟模块 ID 的尾标(见 `CLIENT_COMPONENT_WRAPPER_PREFIX` 的说明)。 */
75+
const WRAPPER_VIRTUAL_TAIL = '.ubean-wrapper';
76+
77+
/**
78+
* 是否是 `@vitejs/plugin-vue` 为 SFC 生成的**子请求**(`Foo.vue?vue&type=script&lang.ts` 等)。
79+
*
80+
* 包装/重定向**只对主文件 id 生效**:子请求是 Vue 插件自己的内部模块,重定向它们会得到
81+
* `\0virtual:…:<真实路径>?vue&type=script…` 这种拼接产物 —— Vue 插件随后把它当真实文件读盘,
82+
* 报 `The argument 'path' must be a string … without null bytes`(实测生产构建必挂、dev 侥幸不挂)。
83+
*/
84+
function isVueSubRequest(id: string): boolean {
85+
return id.includes('?');
86+
}
87+
6788
/**
6889
* Task 9.3: 配对组件 (`Foo.vue` 同时存在 `.server.vue` + `.client.vue`) 在
6990
* SSR / client 构建中生成的虚拟包装模块 ID 前缀。
@@ -1141,7 +1162,7 @@ export function ubeanIslandsPlugin(_options: UbeanIslandsPluginOptions = {}): Pl
11411162

11421163
// --- Task 9.1: .server.vue → client 构建重定向到通用 stub ---
11431164
// SSR 构建时正常解析到真实文件 (return undefined 走默认解析)
1144-
if (!options?.ssr && isServerComponentFile(id)) {
1165+
if (!options?.ssr && !isVueSubRequest(id) && isServerComponentFile(id)) {
11451166
return SERVER_COMPONENT_STUB_RESOLVED_ID;
11461167
}
11471168

@@ -1151,6 +1172,7 @@ export function ubeanIslandsPlugin(_options: UbeanIslandsPluginOptions = {}): Pl
11511172
// Task 9.3: 同样排除从配对组件包装模块内部的 import — 配对 wrapper 直接
11521173
// 导入真实 `.client.vue`,不需要 `defineClientComponent` 二次包装。
11531174
if (
1175+
!isVueSubRequest(id) &&
11541176
isClientComponentFile(id) &&
11551177
!importer?.startsWith(CLIENT_COMPONENT_WRAPPER_PREFIX) &&
11561178
!importer?.startsWith(PAIRED_COMPONENT_WRAPPER_PREFIX)
@@ -1162,7 +1184,7 @@ export function ubeanIslandsPlugin(_options: UbeanIslandsPluginOptions = {}): Pl
11621184
// client: 解析真实路径,生成文件级包装虚拟模块
11631185
const resolved = await this.resolve(id, importer, { skipSelf: true });
11641186
if (!resolved) return undefined;
1165-
return `${CLIENT_COMPONENT_WRAPPER_PREFIX}${resolved.id}`;
1187+
return `${CLIENT_COMPONENT_WRAPPER_PREFIX}${resolved.id}${WRAPPER_VIRTUAL_TAIL}`;
11661188
}
11671189

11681190
// --- Task 9.3: 配对组件解析 — 普通 .vue 导入检查 .server.vue / .client.vue 兄弟文件 ---
@@ -1172,6 +1194,7 @@ export function ubeanIslandsPlugin(_options: UbeanIslandsPluginOptions = {}): Pl
11721194
if (
11731195
importer &&
11741196
!importer.startsWith('\0') &&
1197+
!isVueSubRequest(id) &&
11751198
id.endsWith('.vue') &&
11761199
!isServerComponentFile(id) &&
11771200
!isClientComponentFile(id) &&
@@ -1188,7 +1211,7 @@ export function ubeanIslandsPlugin(_options: UbeanIslandsPluginOptions = {}): Pl
11881211

11891212
if (hasServer && hasClient) {
11901213
// 配对:重定向到虚拟 wrapper 模块 (load 钩子根据 ssr 选项生成不同内容)
1191-
return `${PAIRED_COMPONENT_WRAPPER_PREFIX}${serverSibling}${PAIRED_PATH_SEPARATOR}${clientSibling}`;
1214+
return `${PAIRED_COMPONENT_WRAPPER_PREFIX}${serverSibling}${PAIRED_PATH_SEPARATOR}${clientSibling}${WRAPPER_VIRTUAL_TAIL}`;
11921215
}
11931216
if (hasServer) {
11941217
// 仅存在 .server.vue:重定向 (SSR=真实文件, client=stub)
@@ -1222,7 +1245,7 @@ export function ubeanIslandsPlugin(_options: UbeanIslandsPluginOptions = {}): Pl
12221245

12231246
// Task 9.2: .client.vue client wrapper — import 真实组件 + defineClientComponent
12241247
if (id.startsWith(CLIENT_COMPONENT_WRAPPER_PREFIX)) {
1225-
const realPath = id.slice(CLIENT_COMPONENT_WRAPPER_PREFIX.length);
1248+
const realPath = id.slice(CLIENT_COMPONENT_WRAPPER_PREFIX.length).replace(WRAPPER_VIRTUAL_TAIL, '');
12261249
return (
12271250
`import RealComp from ${JSON.stringify(realPath)};\n` +
12281251
`import { defineClientComponent } from '@ubean/islands/runtime';\n` +
@@ -1234,7 +1257,7 @@ export function ubeanIslandsPlugin(_options: UbeanIslandsPluginOptions = {}): Pl
12341257
// client 同时导入 .server.vue (→ stub) 与 .client.vue (→ 真实文件,通过 importer
12351258
// 检查跳过 defineClientComponent 包装),调用 definePairedComponent 切换。
12361259
if (id.startsWith(PAIRED_COMPONENT_WRAPPER_PREFIX)) {
1237-
const payload = id.slice(PAIRED_COMPONENT_WRAPPER_PREFIX.length);
1260+
const payload = id.slice(PAIRED_COMPONENT_WRAPPER_PREFIX.length).replace(WRAPPER_VIRTUAL_TAIL, '');
12381261
const sepIdx = payload.indexOf(PAIRED_PATH_SEPARATOR);
12391262
if (sepIdx === -1) return undefined;
12401263
const serverPath = payload.slice(0, sepIdx);

0 commit comments

Comments
 (0)