@ubean/islands 0.6.0-beta.1 → 0.6.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/runtime.d.ts +6 -7
- package/dist/runtime.js +21 -7
- package/dist/vite.d.ts +28 -0
- package/dist/vite.js +140 -13
- package/package.json +8 -8
package/dist/runtime.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { App, Component } from "vue";
|
|
1
|
+
import { App, Component, VNode } from "vue";
|
|
2
2
|
//#region src/runtime.d.ts
|
|
3
3
|
interface DomElement {
|
|
4
4
|
getAttribute(name: string): string | null;
|
|
@@ -274,20 +274,20 @@ export declare function defineIsland(Component: Component, strategy: IslandStrat
|
|
|
274
274
|
*
|
|
275
275
|
* 这保证了 `.server.vue` 组件的 JS 不会发送到客户端 —— 客户端只导入此 stub。
|
|
276
276
|
*/
|
|
277
|
-
export declare const ServerComponentStub: import("vue").DefineComponent<{}, () =>
|
|
277
|
+
export declare const ServerComponentStub: import("vue").DefineComponent<{}, () => VNode<import("vue").RendererNode, import("vue").RendererElement, {
|
|
278
278
|
[key: string]: any;
|
|
279
279
|
}>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
280
280
|
/**
|
|
281
281
|
* `.client.vue` 组件在 SSR 构建中的通用占位符 (Task 9.2)。
|
|
282
282
|
*
|
|
283
283
|
* Vite 插件在 SSR 构建中将 `.client.vue` 的 import 重定向到虚拟模块,
|
|
284
|
-
* 该模块导出此组件。组件渲染
|
|
284
|
+
* 该模块导出此组件。组件渲染 {@link clientOnlyPlaceholder}(`<!--client-only-->`),
|
|
285
285
|
* 与客户端 `defineClientComponent` 初始渲染输出一致,确保水合无 mismatch。
|
|
286
286
|
*
|
|
287
287
|
* SSR 使用通用占位符(而非真实组件)避免了在服务端导入可能含浏览器 API
|
|
288
288
|
* 的 `.client.vue` 组件代码。
|
|
289
289
|
*/
|
|
290
|
-
export declare const ClientComponentPlaceholder: import("vue").DefineComponent<{}, () =>
|
|
290
|
+
export declare const ClientComponentPlaceholder: import("vue").DefineComponent<{}, () => VNode<import("vue").RendererNode, import("vue").RendererElement, {
|
|
291
291
|
[key: string]: any;
|
|
292
292
|
}>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
293
293
|
/**
|
|
@@ -302,9 +302,8 @@ export declare const ClientComponentPlaceholder: import("vue").DefineComponent<{
|
|
|
302
302
|
*
|
|
303
303
|
* ## 工作机制
|
|
304
304
|
*
|
|
305
|
-
* - **SSR**: 由 `ClientComponentPlaceholder` 替代,渲染
|
|
306
|
-
* - **客户端初始渲染**: `isClient` 为 false
|
|
307
|
-
* 与 SSR 输出匹配,水合无 mismatch
|
|
305
|
+
* - **SSR**: 由 `ClientComponentPlaceholder` 替代,渲染 `<!--client-only-->` 注释占位符
|
|
306
|
+
* - **客户端初始渲染**: `isClient` 为 false,渲染同一个注释占位符,与 SSR 输出匹配,水合无 mismatch
|
|
308
307
|
* - **客户端 `onMounted` 后**: `isClient` 变为 true,渲染真实组件,Vue 自动 patch 替换占位符
|
|
309
308
|
*
|
|
310
309
|
* 这种模式确保 `.client.vue` 组件只在客户端渲染,SSR 仅输出占位符,
|
package/dist/runtime.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Suspense, createApp, defineComponent, h, onMounted, ref, watch } from "vue";
|
|
1
|
+
import { Suspense, createApp, createCommentVNode, defineComponent, h, onMounted, ref, watch } from "vue";
|
|
2
2
|
//#region src/runtime.ts
|
|
3
3
|
const _global = globalThis;
|
|
4
4
|
function decodeProps(raw) {
|
|
@@ -420,10 +420,25 @@ const ServerComponentStub = defineComponent({
|
|
|
420
420
|
}
|
|
421
421
|
});
|
|
422
422
|
/**
|
|
423
|
+
* `.client.vue` 的占位符 vnode —— SSR 与客户端水合首帧共用同一形态。
|
|
424
|
+
*
|
|
425
|
+
* **是注释节点,不是元素**(这与 `@ubean/vue` 的 `<ClientOnly>` 一致,标记文本也相同)。
|
|
426
|
+
* 元素占位符(早先是 `<div data-client-only>`)在受限的父级里会产出**非法嵌套**:放进
|
|
427
|
+
* `<tr>` / `<table>` / `<ul>` / `<select>` 时会被 HTML 解析器提到容器外(表格里的 `<div>`
|
|
428
|
+
* 会被 foster-parent 到表格之前),DOM 与客户端 vnode 树随即错位 → 水合 mismatch;放进
|
|
429
|
+
* `<p>` 也会被自动闭合。注释在任何位置都合法、不占布局,因此没有这类上下文依赖。
|
|
430
|
+
*
|
|
431
|
+
* 代价是不能再对它写 CSS(`[data-client-only]` 选择器失效)。需要占位内容/撑高度时用
|
|
432
|
+
* `<ClientOnly>` 的 `#fallback` 插槽 —— 那是显式的、由使用者决定的占位内容。
|
|
433
|
+
*/
|
|
434
|
+
function clientOnlyPlaceholder() {
|
|
435
|
+
return createCommentVNode("client-only");
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
423
438
|
* `.client.vue` 组件在 SSR 构建中的通用占位符 (Task 9.2)。
|
|
424
439
|
*
|
|
425
440
|
* Vite 插件在 SSR 构建中将 `.client.vue` 的 import 重定向到虚拟模块,
|
|
426
|
-
* 该模块导出此组件。组件渲染
|
|
441
|
+
* 该模块导出此组件。组件渲染 {@link clientOnlyPlaceholder}(`<!--client-only-->`),
|
|
427
442
|
* 与客户端 `defineClientComponent` 初始渲染输出一致,确保水合无 mismatch。
|
|
428
443
|
*
|
|
429
444
|
* SSR 使用通用占位符(而非真实组件)避免了在服务端导入可能含浏览器 API
|
|
@@ -432,7 +447,7 @@ const ServerComponentStub = defineComponent({
|
|
|
432
447
|
const ClientComponentPlaceholder = defineComponent({
|
|
433
448
|
name: "ClientComponentPlaceholder",
|
|
434
449
|
setup() {
|
|
435
|
-
return () =>
|
|
450
|
+
return () => clientOnlyPlaceholder();
|
|
436
451
|
}
|
|
437
452
|
});
|
|
438
453
|
/**
|
|
@@ -447,9 +462,8 @@ const ClientComponentPlaceholder = defineComponent({
|
|
|
447
462
|
*
|
|
448
463
|
* ## 工作机制
|
|
449
464
|
*
|
|
450
|
-
* - **SSR**: 由 `ClientComponentPlaceholder` 替代,渲染
|
|
451
|
-
* - **客户端初始渲染**: `isClient` 为 false
|
|
452
|
-
* 与 SSR 输出匹配,水合无 mismatch
|
|
465
|
+
* - **SSR**: 由 `ClientComponentPlaceholder` 替代,渲染 `<!--client-only-->` 注释占位符
|
|
466
|
+
* - **客户端初始渲染**: `isClient` 为 false,渲染同一个注释占位符,与 SSR 输出匹配,水合无 mismatch
|
|
453
467
|
* - **客户端 `onMounted` 后**: `isClient` 变为 true,渲染真实组件,Vue 自动 patch 替换占位符
|
|
454
468
|
*
|
|
455
469
|
* 这种模式确保 `.client.vue` 组件只在客户端渲染,SSR 仅输出占位符,
|
|
@@ -474,7 +488,7 @@ function defineClientComponent(component) {
|
|
|
474
488
|
onMounted(() => {
|
|
475
489
|
isClient.value = true;
|
|
476
490
|
});
|
|
477
|
-
return () => isClient.value ? h(component, attrs, slots) :
|
|
491
|
+
return () => isClient.value ? h(component, attrs, slots) : clientOnlyPlaceholder();
|
|
478
492
|
}
|
|
479
493
|
});
|
|
480
494
|
}
|
package/dist/vite.d.ts
CHANGED
|
@@ -28,6 +28,34 @@ export declare const CLIENT_COMPONENT_PLACEHOLDER_VIRTUAL_ID = "virtual:ubean-cl
|
|
|
28
28
|
* 真实文件不会被加载/转换)。
|
|
29
29
|
*/
|
|
30
30
|
export declare function wrapServerComponentTemplate(code: string): string | null;
|
|
31
|
+
/**
|
|
32
|
+
* `.server.vue` 是否带 `<template>` 块。
|
|
33
|
+
*
|
|
34
|
+
* 用于把「包裹不了」的两种情况区分开:**没有 `<template>`**(render 函数 / `export { default }
|
|
35
|
+
* from …` 这类写法)必须报错,而**已经包裹过**(幂等)应当静默跳过。
|
|
36
|
+
*
|
|
37
|
+
* 为什么没有 `<template>` 就必须报错:客户端侧一律替换成 stub(渲染 `<ubean-server-only>`),
|
|
38
|
+
* 服务端侧的内容必须被包进**同名元素**才能在水合时对齐。没有 `<template>` 时服务端输出的是
|
|
39
|
+
* 组件自己的根元素,与 stub 的根标签不一致 —— 实测 Vue 报
|
|
40
|
+
* `Hydration node mismatch: rendered on server: JSHandle@node / expected on client: ubean-server-only`,
|
|
41
|
+
* 并且**把服务端渲染出来的内容清掉**(首屏有内容、水合后变空),比不渲染更糟。
|
|
42
|
+
*/
|
|
43
|
+
export declare function hasServerComponentTemplate(code: string): boolean;
|
|
44
|
+
export interface RestrictedServerComponentUsage {
|
|
45
|
+
/** 受限父级标签(小写)。 */
|
|
46
|
+
parent: string;
|
|
47
|
+
/** 组件在模板里的写法(原样)。 */
|
|
48
|
+
tag: string;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 找出「直接作为受限父级子元素」使用的 `.server.vue` 组件(见
|
|
52
|
+
* {@link RESTRICTED_SERVER_COMPONENT_PARENTS})。
|
|
53
|
+
*
|
|
54
|
+
* 判据只看**静态可解析**的部分:标签名能在 `<script setup>` 的 import 映射里找到,且 import 路径
|
|
55
|
+
* 以 `.server.vue` 结尾。驼峰/连字符两种写法都认(`<ServerGreeting />` 与 `<server-greeting />`)。
|
|
56
|
+
* 通过 barrel 再导出、或运行时动态解析的组件看不出来 —— 宁可漏报也不误报。
|
|
57
|
+
*/
|
|
58
|
+
export declare function findRestrictedServerComponentUsage(code: string, importMap: Map<string, string>): RestrictedServerComponentUsage[];
|
|
31
59
|
/**
|
|
32
60
|
* 一个 island 组件的注册条目。
|
|
33
61
|
*
|
package/dist/vite.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { legacyDirectiveToStrategy, strategyToLegacyDirective } from "./directive.js";
|
|
2
|
-
import {
|
|
2
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
//#region src/vite.ts
|
|
5
5
|
/**
|
|
@@ -46,6 +46,18 @@ function isClientComponentFile(id) {
|
|
|
46
46
|
/** `.server.vue` 在 client 构建中的通用虚拟 stub 模块 ID */
|
|
47
47
|
const SERVER_COMPONENT_STUB_VIRTUAL_ID = "virtual:ubean-server-component-stub";
|
|
48
48
|
const SERVER_COMPONENT_STUB_RESOLVED_ID = `\0${SERVER_COMPONENT_STUB_VIRTUAL_ID}`;
|
|
49
|
+
/**
|
|
50
|
+
* `.server.vue` 在 client 构建中的**文件级** stub 模块 ID 前缀。
|
|
51
|
+
*
|
|
52
|
+
* 早期所有 `.server.vue` 都重定向到上面那个**共用**的 stub 模块,于是它们在客户端图里成了
|
|
53
|
+
* 同一个组件对象 —— `name` 全是 `ServerComponentStub`,`<KeepAlive include>`、
|
|
54
|
+
* `<component :is>`、DevTools 台账都失去区分度。改为按文件生成(与 `.client.vue` 的文件级
|
|
55
|
+
* 包装模块对称)。
|
|
56
|
+
*
|
|
57
|
+
* 尾标同样不能省:ID 若以 `.vue` 结尾,`@vitejs/plugin-vue` 会把它当真实 SFC 读盘
|
|
58
|
+
* (见 `WRAPPER_VIRTUAL_TAIL` 的说明)。
|
|
59
|
+
*/
|
|
60
|
+
const SERVER_COMPONENT_STUB_PREFIX = "\0virtual:ubean-server-component-stub:";
|
|
49
61
|
/** `.client.vue` 在 SSR 构建中的通用占位符虚拟模块 ID */
|
|
50
62
|
const CLIENT_COMPONENT_PLACEHOLDER_VIRTUAL_ID = "virtual:ubean-client-component-placeholder";
|
|
51
63
|
const CLIENT_COMPONENT_PLACEHOLDER_RESOLVED_ID = `\0${CLIENT_COMPONENT_PLACEHOLDER_VIRTUAL_ID}`;
|
|
@@ -103,6 +115,104 @@ function wrapServerComponentTemplate(code) {
|
|
|
103
115
|
return `${code.slice(0, tpl.start)}<template${tpl.attrs}>${newContent}</template>${code.slice(tpl.end)}`;
|
|
104
116
|
}
|
|
105
117
|
/**
|
|
118
|
+
* `.server.vue` 是否带 `<template>` 块。
|
|
119
|
+
*
|
|
120
|
+
* 用于把「包裹不了」的两种情况区分开:**没有 `<template>`**(render 函数 / `export { default }
|
|
121
|
+
* from …` 这类写法)必须报错,而**已经包裹过**(幂等)应当静默跳过。
|
|
122
|
+
*
|
|
123
|
+
* 为什么没有 `<template>` 就必须报错:客户端侧一律替换成 stub(渲染 `<ubean-server-only>`),
|
|
124
|
+
* 服务端侧的内容必须被包进**同名元素**才能在水合时对齐。没有 `<template>` 时服务端输出的是
|
|
125
|
+
* 组件自己的根元素,与 stub 的根标签不一致 —— 实测 Vue 报
|
|
126
|
+
* `Hydration node mismatch: rendered on server: JSHandle@node / expected on client: ubean-server-only`,
|
|
127
|
+
* 并且**把服务端渲染出来的内容清掉**(首屏有内容、水合后变空),比不渲染更糟。
|
|
128
|
+
*/
|
|
129
|
+
function hasServerComponentTemplate(code) {
|
|
130
|
+
return extractTemplateBlock(code) !== null;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* 父级元素只允许特定子元素、且浏览器解析器会**重构 DOM** 的那些标签。
|
|
134
|
+
*
|
|
135
|
+
* `.server.vue` 在客户端图里会渲染一个 `<ubean-server-only>` 包裹元素(客户端记录
|
|
136
|
+
* `vdom` 里的节点,水合才能保住服务端内容),而普通元素不可能在所有上下文都合法:
|
|
137
|
+
*
|
|
138
|
+
* - **表格上下文**:包裹元素被 foster-parent 到表格**之外**(实测:内容出现在表格前、
|
|
139
|
+
* `<tr>` 变空),随后水合报 `Hydration node mismatch` / `Hydration children mismatch`
|
|
140
|
+
* 并移除内容;生产模式下这些告警被剥掉,表现为静默的错位 + 内容丢失。
|
|
141
|
+
* - **`select` / `optgroup`**:解析器处于 "in select" 插入模式,无法识别的标签被**直接丢弃**。
|
|
142
|
+
*
|
|
143
|
+
* 刻意**不含** `ul` / `ol` / `dl` / `menu`:那些上下文里非 `li` 子元素虽然是不合法 HTML,但解析器
|
|
144
|
+
* 不会搬移或丢弃它(两侧 DOM 一致,水合正常),报错会是误报。
|
|
145
|
+
*/
|
|
146
|
+
const RESTRICTED_SERVER_COMPONENT_PARENTS = /* @__PURE__ */ new Set([
|
|
147
|
+
"table",
|
|
148
|
+
"thead",
|
|
149
|
+
"tbody",
|
|
150
|
+
"tfoot",
|
|
151
|
+
"tr",
|
|
152
|
+
"colgroup",
|
|
153
|
+
"select",
|
|
154
|
+
"optgroup"
|
|
155
|
+
]);
|
|
156
|
+
/** 对 DOM 嵌套透明、不改变实际父级的标签(判断嵌套时向上穿透)。 */
|
|
157
|
+
const TRANSPARENT_NESTING_TAGS = /* @__PURE__ */ new Set(["template", "slot"]);
|
|
158
|
+
/**
|
|
159
|
+
* 找出「直接作为受限父级子元素」使用的 `.server.vue` 组件(见
|
|
160
|
+
* {@link RESTRICTED_SERVER_COMPONENT_PARENTS})。
|
|
161
|
+
*
|
|
162
|
+
* 判据只看**静态可解析**的部分:标签名能在 `<script setup>` 的 import 映射里找到,且 import 路径
|
|
163
|
+
* 以 `.server.vue` 结尾。驼峰/连字符两种写法都认(`<ServerGreeting />` 与 `<server-greeting />`)。
|
|
164
|
+
* 通过 barrel 再导出、或运行时动态解析的组件看不出来 —— 宁可漏报也不误报。
|
|
165
|
+
*/
|
|
166
|
+
function findRestrictedServerComponentUsage(code, importMap) {
|
|
167
|
+
const template = extractTemplateBlock(code)?.content ?? "";
|
|
168
|
+
if (!template) return [];
|
|
169
|
+
const usages = [];
|
|
170
|
+
const stack = [];
|
|
171
|
+
const tagRe = /<(\/?)([A-Za-z][\w.:-]*)((?:"[^"]*"|'[^']*'|[^>"'])*?)(\/?)>/g;
|
|
172
|
+
let match;
|
|
173
|
+
while ((match = tagRe.exec(template)) !== null) {
|
|
174
|
+
const [, closing, rawName, , selfClosing] = match;
|
|
175
|
+
const name = rawName;
|
|
176
|
+
if (closing) {
|
|
177
|
+
const idx = stack.lastIndexOf(name);
|
|
178
|
+
if (idx !== -1) stack.length = idx;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
let parent;
|
|
182
|
+
for (let i = stack.length - 1; i >= 0; i--) if (!TRANSPARENT_NESTING_TAGS.has(stack[i].toLowerCase())) {
|
|
183
|
+
parent = stack[i];
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
if (parent && RESTRICTED_SERVER_COMPONENT_PARENTS.has(parent.toLowerCase())) {
|
|
187
|
+
if ((importMap.get(name) ?? importMap.get(kebabToPascal(name)))?.endsWith(".server.vue")) usages.push({
|
|
188
|
+
parent: parent.toLowerCase(),
|
|
189
|
+
tag: name
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
if (!selfClosing && !VOID_TAGS.has(name.toLowerCase())) stack.push(name);
|
|
193
|
+
}
|
|
194
|
+
return usages;
|
|
195
|
+
}
|
|
196
|
+
/** 空元素(无闭合标签)——误当作父级会让后面的标签判错层级。 */
|
|
197
|
+
const VOID_TAGS = /* @__PURE__ */ new Set([
|
|
198
|
+
"area",
|
|
199
|
+
"base",
|
|
200
|
+
"br",
|
|
201
|
+
"col",
|
|
202
|
+
"embed",
|
|
203
|
+
"hr",
|
|
204
|
+
"img",
|
|
205
|
+
"input",
|
|
206
|
+
"link",
|
|
207
|
+
"meta",
|
|
208
|
+
"source",
|
|
209
|
+
"track",
|
|
210
|
+
"wbr"
|
|
211
|
+
]);
|
|
212
|
+
function kebabToPascal(name) {
|
|
213
|
+
return name.split("-").map((part) => part ? part[0].toUpperCase() + part.slice(1) : part).join("");
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
106
216
|
* 判断 id 是否为 SFC 主模块(非 `?vue&type=...` 子查询)。
|
|
107
217
|
*
|
|
108
218
|
* 收集 island 组件时需要同时访问 `<script setup>` 与 `<template>` 块,
|
|
@@ -936,28 +1046,33 @@ function ubeanIslandsPlugin(_options = {}) {
|
|
|
936
1046
|
async resolveId(id, importer, options) {
|
|
937
1047
|
if (id === "virtual:ubean-islands-registry") return ISLANDS_REGISTRY_RESOLVED_ID;
|
|
938
1048
|
if (!enabled) return void 0;
|
|
939
|
-
if (!options?.ssr && !isVueSubRequest(id) && isServerComponentFile(id))
|
|
1049
|
+
if (!options?.ssr && !isVueSubRequest(id) && isServerComponentFile(id)) {
|
|
1050
|
+
const resolved = await this.resolve(id, importer, { skipSelf: true });
|
|
1051
|
+
return `${SERVER_COMPONENT_STUB_PREFIX}${resolved?.id ?? id}${WRAPPER_VIRTUAL_TAIL}`;
|
|
1052
|
+
}
|
|
940
1053
|
if (!isVueSubRequest(id) && isClientComponentFile(id) && !importer?.startsWith(CLIENT_COMPONENT_WRAPPER_PREFIX) && !importer?.startsWith(PAIRED_COMPONENT_WRAPPER_PREFIX)) {
|
|
941
1054
|
if (options?.ssr) return CLIENT_COMPONENT_PLACEHOLDER_RESOLVED_ID;
|
|
942
1055
|
const resolved = await this.resolve(id, importer, { skipSelf: true });
|
|
943
1056
|
if (!resolved) return void 0;
|
|
944
1057
|
return `${CLIENT_COMPONENT_WRAPPER_PREFIX}${resolved.id}${WRAPPER_VIRTUAL_TAIL}`;
|
|
945
1058
|
}
|
|
946
|
-
if (importer && !importer.startsWith("\0") && !isVueSubRequest(id) && id.endsWith(".vue") && !isServerComponentFile(id) && !isClientComponentFile(id)
|
|
947
|
-
|
|
948
|
-
const
|
|
949
|
-
const serverSibling = `${
|
|
950
|
-
const clientSibling = `${
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
if (
|
|
954
|
-
if (hasServer) return serverSibling;
|
|
955
|
-
if (hasClient) return clientSibling;
|
|
1059
|
+
if (importer && !importer.startsWith("\0") && !isVueSubRequest(id) && id.endsWith(".vue") && !isServerComponentFile(id) && !isClientComponentFile(id)) {
|
|
1060
|
+
if (await this.resolve(id, importer, { skipSelf: true })) return void 0;
|
|
1061
|
+
const base = id.slice(0, -4);
|
|
1062
|
+
const serverSibling = await this.resolve(`${base}.server.vue`, importer, { skipSelf: true });
|
|
1063
|
+
const clientSibling = await this.resolve(`${base}.client.vue`, importer, { skipSelf: true });
|
|
1064
|
+
if (serverSibling && clientSibling) return `${PAIRED_COMPONENT_WRAPPER_PREFIX}${serverSibling.id}${PAIRED_PATH_SEPARATOR}${clientSibling.id}${WRAPPER_VIRTUAL_TAIL}`;
|
|
1065
|
+
if (serverSibling) return serverSibling.id;
|
|
1066
|
+
if (clientSibling) return clientSibling.id;
|
|
956
1067
|
}
|
|
957
1068
|
},
|
|
958
1069
|
load(id, options) {
|
|
959
1070
|
if (id === ISLANDS_REGISTRY_RESOLVED_ID) return generateRegistryModule(islandComponents);
|
|
960
1071
|
if (id === SERVER_COMPONENT_STUB_RESOLVED_ID) return `import { ServerComponentStub } from '@ubean/islands/runtime';\nexport default ServerComponentStub;`;
|
|
1072
|
+
if (id.startsWith(SERVER_COMPONENT_STUB_PREFIX)) {
|
|
1073
|
+
const name = id.slice(37).replace(WRAPPER_VIRTUAL_TAIL, "").split("/").pop().replace(/\.server\.vue$/, "");
|
|
1074
|
+
return `import { ServerComponentStub } from '@ubean/islands/runtime';\nexport default { ...ServerComponentStub, name: ${JSON.stringify(name)} };\n`;
|
|
1075
|
+
}
|
|
961
1076
|
if (id === CLIENT_COMPONENT_PLACEHOLDER_RESOLVED_ID) return `import { ClientComponentPlaceholder } from '@ubean/islands/runtime';\nexport default ClientComponentPlaceholder;`;
|
|
962
1077
|
if (id.startsWith(CLIENT_COMPONENT_WRAPPER_PREFIX)) {
|
|
963
1078
|
const realPath = id.slice(32).replace(WRAPPER_VIRTUAL_TAIL, "");
|
|
@@ -990,6 +1105,18 @@ function ubeanIslandsPlugin(_options = {}) {
|
|
|
990
1105
|
if (wrapped !== null) {
|
|
991
1106
|
code = wrapped;
|
|
992
1107
|
serverWrapped = true;
|
|
1108
|
+
} else if (!hasServerComponentTemplate(code)) throw new Error(`[ubean] \`${normalizedId}\` 是 \`.server.vue\`(Server Component),但没有 <template> 块。\n客户端侧会被替换成通用 stub,服务端内容必须包进 <ubean-server-only> 才能在水合时对齐;没有 <template> 时(render 函数、\`export { default } from …\` 等写法)两侧根元素不一致,水合会清掉服务端渲染的内容。
|
|
1109
|
+
修法:改成 <template> 形式的 SFC。`);
|
|
1110
|
+
}
|
|
1111
|
+
if (code.includes(".server.vue")) {
|
|
1112
|
+
const restricted = findRestrictedServerComponentUsage(code, parseScriptImports(extractScriptBlock(code)));
|
|
1113
|
+
if (restricted.length > 0) {
|
|
1114
|
+
const detail = restricted.map((u) => `\`<${u.parent}> > <${u.tag} />\``).join("、");
|
|
1115
|
+
throw new Error(`[ubean] \`${normalizedId}\` 在受限父级里直接使用了 Server Component:${detail}。\n\`.server.vue\` 在客户端图里会渲染一个 <ubean-server-only> 包裹元素(客户端 vdom 必须有对应节点,水合才能保住服务端内容),而普通元素不可能在所有上下文都合法:
|
|
1116
|
+
· 表格上下文(table/tr/tbody/…):包裹元素被解析器提到表格**之外** —— 实测内容出现在表格前、\`<tr>\` 变空,随后水合报 mismatch 并**移除内容**(生产模式静默);
|
|
1117
|
+
· select/optgroup:解析器直接**丢弃**不认识的标签。
|
|
1118
|
+
修法:让服务端组件自己渲染容器(如整个 <table>),或把它放进允许的子元素(<td> / <li>)。
|
|
1119
|
+
(\`.client.vue\` 没有这个限制:它的占位符是注释节点。)`);
|
|
993
1120
|
}
|
|
994
1121
|
}
|
|
995
1122
|
if (!ANY_DIRECTIVE_RE.test(code) && !serverWrapped) return code !== originalCode ? { code } : null;
|
|
@@ -1017,4 +1144,4 @@ function ubeanIslandsPlugin(_options = {}) {
|
|
|
1017
1144
|
};
|
|
1018
1145
|
}
|
|
1019
1146
|
//#endregion
|
|
1020
|
-
export { CLIENT_COMPONENT_PLACEHOLDER_VIRTUAL_ID, ISLANDS_REGISTRY_VIRTUAL_ID, SERVER_COMPONENT_STUB_VIRTUAL_ID, collectIslandComponents, generateRegistryModule, injectServerComponentPath, isClientComponentFile, isServerComponentFile, parseScriptImports, resolveIslandImportPath, scanIslandDirectiveNames, transformVueSfcIslands, ubeanIslandsPlugin, wrapServerComponentTemplate };
|
|
1147
|
+
export { CLIENT_COMPONENT_PLACEHOLDER_VIRTUAL_ID, ISLANDS_REGISTRY_VIRTUAL_ID, SERVER_COMPONENT_STUB_VIRTUAL_ID, collectIslandComponents, findRestrictedServerComponentUsage, generateRegistryModule, hasServerComponentTemplate, injectServerComponentPath, isClientComponentFile, isServerComponentFile, parseScriptImports, resolveIslandImportPath, scanIslandDirectiveNames, transformVueSfcIslands, ubeanIslandsPlugin, wrapServerComponentTemplate };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ubean/islands",
|
|
3
|
-
"version": "0.6.0-beta.
|
|
3
|
+
"version": "0.6.0-beta.3",
|
|
4
4
|
"description": "Islands architecture for ubean (Vite plugin for client:load/idle/visible/media/only directives, bootstrap script, client hydrate runtime)",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist"
|
|
@@ -36,18 +36,18 @@
|
|
|
36
36
|
}
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@ubean/shared": "0.6.0-beta.
|
|
40
|
-
"hono": "4.13.
|
|
41
|
-
"vue": "^3.5.
|
|
39
|
+
"@ubean/shared": "0.6.0-beta.3",
|
|
40
|
+
"hono": "4.13.9",
|
|
41
|
+
"vue": "^3.5.43"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@types/node": "^26.
|
|
44
|
+
"@types/node": "^26.6.2",
|
|
45
45
|
"typescript": "npm:typescript-native-bridge@latest",
|
|
46
|
-
"vite": "npm:@voidzero-dev/vite-plus-core@
|
|
47
|
-
"vite-plus": "
|
|
46
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
|
|
47
|
+
"vite-plus": "latest"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
|
-
"vite": "npm:@voidzero-dev/vite-plus-core@
|
|
50
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@latest"
|
|
51
51
|
},
|
|
52
52
|
"peerDependenciesMeta": {
|
|
53
53
|
"vite": {
|