@vobs/runtime 1.7.8 → 1.8.0

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.
Files changed (127) hide show
  1. package/README.md +1 -1
  2. package/dist/async-boundary.cjs +14 -4
  3. package/dist/async-boundary.cjs.map +1 -1
  4. package/dist/async-boundary.js +17 -7
  5. package/dist/async-boundary.js.map +1 -1
  6. package/dist/bind.cjs +43 -3
  7. package/dist/bind.cjs.map +1 -1
  8. package/dist/bind.js +48 -8
  9. package/dist/bind.js.map +1 -1
  10. package/dist/boundary.cjs +159 -84
  11. package/dist/boundary.cjs.map +1 -1
  12. package/dist/boundary.js +162 -87
  13. package/dist/boundary.js.map +1 -1
  14. package/dist/client-only.cjs +323 -0
  15. package/dist/client-only.cjs.map +1 -0
  16. package/dist/client-only.d.cts +42 -0
  17. package/dist/client-only.d.ts +42 -0
  18. package/dist/client-only.js +299 -0
  19. package/dist/client-only.js.map +1 -0
  20. package/dist/debug.cjs.map +1 -1
  21. package/dist/debug.d.cts +12 -2
  22. package/dist/debug.d.ts +12 -2
  23. package/dist/debug.js.map +1 -1
  24. package/dist/dom-events.cjs +46 -0
  25. package/dist/dom-events.cjs.map +1 -0
  26. package/dist/dom-events.d.cts +23 -0
  27. package/dist/dom-events.d.ts +23 -0
  28. package/dist/dom-events.js +21 -0
  29. package/dist/dom-events.js.map +1 -0
  30. package/dist/dom-props.cjs +154 -0
  31. package/dist/dom-props.cjs.map +1 -0
  32. package/dist/dom-props.d.cts +21 -0
  33. package/dist/dom-props.d.ts +21 -0
  34. package/dist/dom-props.js +128 -0
  35. package/dist/dom-props.js.map +1 -0
  36. package/dist/dynamic.cjs +68 -14
  37. package/dist/dynamic.cjs.map +1 -1
  38. package/dist/dynamic.js +73 -19
  39. package/dist/dynamic.js.map +1 -1
  40. package/dist/error-boundary.cjs +159 -84
  41. package/dist/error-boundary.cjs.map +1 -1
  42. package/dist/error-boundary.js +162 -87
  43. package/dist/error-boundary.js.map +1 -1
  44. package/dist/error.cjs +52 -20
  45. package/dist/error.cjs.map +1 -1
  46. package/dist/error.d.cts +8 -0
  47. package/dist/error.d.ts +8 -0
  48. package/dist/error.js +52 -20
  49. package/dist/error.js.map +1 -1
  50. package/dist/fragment.cjs.map +1 -1
  51. package/dist/fragment.js +1 -1
  52. package/dist/fragment.js.map +1 -1
  53. package/dist/hmr.cjs +137 -1
  54. package/dist/hmr.cjs.map +1 -1
  55. package/dist/hmr.js +135 -1
  56. package/dist/hmr.js.map +1 -1
  57. package/dist/index.cjs +713 -236
  58. package/dist/index.cjs.map +1 -1
  59. package/dist/index.d.cts +5 -2
  60. package/dist/index.d.ts +5 -2
  61. package/dist/index.js +715 -245
  62. package/dist/index.js.map +1 -1
  63. package/dist/ops.cjs +561 -107
  64. package/dist/ops.cjs.map +1 -1
  65. package/dist/ops.d.cts +49 -4
  66. package/dist/ops.d.ts +49 -4
  67. package/dist/ops.js +557 -108
  68. package/dist/ops.js.map +1 -1
  69. package/dist/profiler.cjs +14 -4
  70. package/dist/profiler.cjs.map +1 -1
  71. package/dist/profiler.js +17 -7
  72. package/dist/profiler.js.map +1 -1
  73. package/dist/ref.cjs +160 -5
  74. package/dist/ref.cjs.map +1 -1
  75. package/dist/ref.js +160 -5
  76. package/dist/ref.js.map +1 -1
  77. package/dist/renderer.cjs.map +1 -1
  78. package/dist/renderer.d.cts +2 -0
  79. package/dist/renderer.d.ts +2 -0
  80. package/dist/show.cjs +196 -0
  81. package/dist/show.cjs.map +1 -0
  82. package/dist/show.d.cts +54 -0
  83. package/dist/show.d.ts +54 -0
  84. package/dist/show.js +171 -0
  85. package/dist/show.js.map +1 -0
  86. package/dist/svg.cjs +102 -0
  87. package/dist/svg.cjs.map +1 -0
  88. package/dist/svg.d.cts +21 -0
  89. package/dist/svg.d.ts +21 -0
  90. package/dist/svg.js +76 -0
  91. package/dist/svg.js.map +1 -0
  92. package/package.json +20 -2
  93. package/src/bind.ts +30 -2
  94. package/src/boundaries.test.ts +54 -1
  95. package/src/boundary.ts +24 -8
  96. package/src/clear-remove.test.ts +85 -0
  97. package/src/client-only.test.ts +140 -0
  98. package/src/client-only.ts +65 -0
  99. package/src/component-empty-return.test.ts +123 -0
  100. package/src/debug.ts +12 -1
  101. package/src/dom-events.ts +32 -0
  102. package/src/dom-props-consistency.test.ts +149 -0
  103. package/src/dom-props.test.ts +70 -0
  104. package/src/dom-props.ts +107 -0
  105. package/src/dynamic.ts +91 -14
  106. package/src/error.test.ts +76 -1
  107. package/src/error.ts +87 -22
  108. package/src/event-cleanup-growth.test.ts +128 -0
  109. package/src/events.test.ts +16 -3
  110. package/src/hmr.ts +8 -2
  111. package/src/index.ts +13 -1
  112. package/src/invariants.test.ts +252 -0
  113. package/src/list-fast-path.test.ts +83 -0
  114. package/src/list-reactive-item.test.ts +77 -0
  115. package/src/ops.ts +419 -60
  116. package/src/props.test.ts +61 -1
  117. package/src/ref-cleanup.test.ts +144 -0
  118. package/src/ref.ts +57 -8
  119. package/src/remove-listener-identity.test.ts +97 -0
  120. package/src/renderer.ts +3 -1
  121. package/src/select-hidden-subscription.test.ts +104 -0
  122. package/src/show-classlist.test.ts +205 -0
  123. package/src/show.ts +107 -0
  124. package/src/spread-stable-shape.test.ts +109 -0
  125. package/src/spread.test.ts +86 -0
  126. package/src/svg.test.ts +28 -1
  127. package/src/svg.ts +34 -0
package/dist/svg.js ADDED
@@ -0,0 +1,76 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
+
4
+ // packages/runtime/src/svg.ts
5
+ var SVG_NAMESPACE = "http://www.w3.org/2000/svg";
6
+ var SVG_TAGS = /* @__PURE__ */ new Set([
7
+ "animate",
8
+ "animateMotion",
9
+ "animateTransform",
10
+ "circle",
11
+ "clipPath",
12
+ "defs",
13
+ "desc",
14
+ "ellipse",
15
+ "feBlend",
16
+ "feColorMatrix",
17
+ "feComponentTransfer",
18
+ "feComposite",
19
+ "feConvolveMatrix",
20
+ "feDiffuseLighting",
21
+ "feDisplacementMap",
22
+ "feDistantLight",
23
+ "feDropShadow",
24
+ "feFlood",
25
+ "feFuncA",
26
+ "feFuncB",
27
+ "feFuncG",
28
+ "feFuncR",
29
+ "feGaussianBlur",
30
+ "feImage",
31
+ "feMerge",
32
+ "feMergeNode",
33
+ "feMorphology",
34
+ "feOffset",
35
+ "fePointLight",
36
+ "feSpecularLighting",
37
+ "feSpotLight",
38
+ "feTile",
39
+ "feTurbulence",
40
+ "filter",
41
+ "foreignObject",
42
+ "g",
43
+ "image",
44
+ "line",
45
+ "linearGradient",
46
+ "marker",
47
+ "mask",
48
+ "metadata",
49
+ "mpath",
50
+ "path",
51
+ "pattern",
52
+ "polygon",
53
+ "polyline",
54
+ "radialGradient",
55
+ "rect",
56
+ "set",
57
+ "stop",
58
+ "svg",
59
+ "switch",
60
+ "symbol",
61
+ "text",
62
+ "textPath",
63
+ "tspan",
64
+ "use",
65
+ "view"
66
+ ]);
67
+ function isSvgTag(tag) {
68
+ return SVG_TAGS.has(tag);
69
+ }
70
+ __name(isSvgTag, "isSvgTag");
71
+ export {
72
+ SVG_NAMESPACE,
73
+ SVG_TAGS,
74
+ isSvgTag
75
+ };
76
+ //# sourceMappingURL=svg.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/svg.ts"],"sourcesContent":["/**\n * SVG 标签与命名空间 —— 编译期判定提升、运行期决定怎么建节点的**同一份依据**。\n *\n * 两处都需要知道「这个名字是不是 SVG 标签」:\n * - 运行期:`document.createElement('rect')` 得到的是 HTMLUnknownElement,\n * 整棵 SVG 子树都不渲染(且没有报错),所以必须走 createElementNS\n * - 编译期:静态子树被提升成 HTML 模板串再 cloneNode,而模板串是**用 HTML 解析器**\n * 解析的 —— `<g>`、`<rect>` 同样变成 HTMLUnknownElement。这条路径漏了整整一个大版本\n * (1.7.4 只修了 createElement 那条入口,svg.test.ts 也只测那条)。\n */\nexport const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'\n\n/**\n * SVG 标签全集(= TypeScript SVGElementTagNameMap 的键,运行时分发依据)。\n * 与 React 的运行时清单同思路:只含纯 SVG 标签,刻意排除与 HTML 同名的\n * a/script/style/title(它们在 JSX 类型里归 HTMLElement,按 HTML 创建)。\n */\nexport const SVG_TAGS: ReadonlySet<string> = new Set([\n 'animate', 'animateMotion', 'animateTransform', 'circle', 'clipPath', 'defs', 'desc',\n 'ellipse', 'feBlend', 'feColorMatrix', 'feComponentTransfer', 'feComposite',\n 'feConvolveMatrix', 'feDiffuseLighting', 'feDisplacementMap', 'feDistantLight',\n 'feDropShadow', 'feFlood', 'feFuncA', 'feFuncB', 'feFuncG', 'feFuncR',\n 'feGaussianBlur', 'feImage', 'feMerge', 'feMergeNode', 'feMorphology', 'feOffset',\n 'fePointLight', 'feSpecularLighting', 'feSpotLight', 'feTile', 'feTurbulence',\n 'filter', 'foreignObject', 'g', 'image', 'line', 'linearGradient', 'marker', 'mask',\n 'metadata', 'mpath', 'path', 'pattern', 'polygon', 'polyline', 'radialGradient',\n 'rect', 'set', 'stop', 'svg', 'switch', 'symbol', 'text', 'textPath', 'tspan',\n 'use', 'view'\n])\n\n/** 是否是 SVG 专属标签(按名字判定,不看父级)。 */\nexport function isSvgTag(tag: string): boolean {\n return SVG_TAGS.has(tag)\n}\n"],"mappings":";;;;AAUO,IAAM,gBAAgB;AAOtB,IAAM,WAAgC,oBAAI,IAAI;AAAA,EACnD;AAAA,EAAW;AAAA,EAAiB;AAAA,EAAoB;AAAA,EAAU;AAAA,EAAY;AAAA,EAAQ;AAAA,EAC9E;AAAA,EAAW;AAAA,EAAW;AAAA,EAAiB;AAAA,EAAuB;AAAA,EAC9D;AAAA,EAAoB;AAAA,EAAqB;AAAA,EAAqB;AAAA,EAC9D;AAAA,EAAgB;AAAA,EAAW;AAAA,EAAW;AAAA,EAAW;AAAA,EAAW;AAAA,EAC5D;AAAA,EAAkB;AAAA,EAAW;AAAA,EAAW;AAAA,EAAe;AAAA,EAAgB;AAAA,EACvE;AAAA,EAAgB;AAAA,EAAsB;AAAA,EAAe;AAAA,EAAU;AAAA,EAC/D;AAAA,EAAU;AAAA,EAAiB;AAAA,EAAK;AAAA,EAAS;AAAA,EAAQ;AAAA,EAAkB;AAAA,EAAU;AAAA,EAC7E;AAAA,EAAY;AAAA,EAAS;AAAA,EAAQ;AAAA,EAAW;AAAA,EAAW;AAAA,EAAY;AAAA,EAC/D;AAAA,EAAQ;AAAA,EAAO;AAAA,EAAQ;AAAA,EAAO;AAAA,EAAU;AAAA,EAAU;AAAA,EAAQ;AAAA,EAAY;AAAA,EACtE;AAAA,EAAO;AACT,CAAC;AAGM,SAAS,SAAS,KAAsB;AAC7C,SAAO,SAAS,IAAI,GAAG;AACzB;AAFgB;","names":[]}
package/package.json CHANGED
@@ -11,7 +11,7 @@
11
11
  "type": "git",
12
12
  "url": "git+https://github.com/vobsjs/vobs.git"
13
13
  },
14
- "version": "1.7.8",
14
+ "version": "1.8.0",
15
15
  "publishConfig": {
16
16
  "access": "public"
17
17
  },
@@ -32,6 +32,24 @@
32
32
  "require": "./dist/error.cjs",
33
33
  "default": "./dist/error.js"
34
34
  },
35
+ "./dom-props": {
36
+ "types": "./dist/dom-props.d.ts",
37
+ "import": "./dist/dom-props.js",
38
+ "require": "./dist/dom-props.cjs",
39
+ "default": "./dist/dom-props.js"
40
+ },
41
+ "./dom-events": {
42
+ "types": "./dist/dom-events.d.ts",
43
+ "import": "./dist/dom-events.js",
44
+ "require": "./dist/dom-events.cjs",
45
+ "default": "./dist/dom-events.js"
46
+ },
47
+ "./svg": {
48
+ "types": "./dist/svg.d.ts",
49
+ "import": "./dist/svg.js",
50
+ "require": "./dist/svg.cjs",
51
+ "default": "./dist/svg.js"
52
+ },
35
53
  "./source": {
36
54
  "types": "./src/index.ts",
37
55
  "default": "./src/index.ts"
@@ -39,6 +57,6 @@
39
57
  "./source/*": "./src/*"
40
58
  },
41
59
  "dependencies": {
42
- "@vobs/reactivity": "1.7.8"
60
+ "@vobs/reactivity": "1.8.0"
43
61
  }
44
62
  }
package/src/bind.ts CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { effect } from '@vobs/reactivity'
4
4
  import type { Signal } from '@vobs/reactivity'
5
- import { registerSelectValueBinding, setAttribute, setProperty, setTextContent } from './ops'
5
+ import { applyClassList, registerSelectValueBinding, setAttribute, setProperty, setTextContent } from './ops'
6
6
 
7
7
  export type ValueSource<T> = Signal<T> | (() => T)
8
8
 
@@ -27,6 +27,30 @@ export function bindAttribute(
27
27
  ): void {
28
28
  effect(() => {
29
29
  const value = readSource(source)
30
+ /*
31
+ * 只挡 null/undefined:`String(undefined)` 会把字面量 "undefined" 写进 placeholder 等属性。
32
+ *
33
+ * ⚠️ **`false` 必须透传**,不能在这里当成"不设置" —— HTML 的属性分两类:
34
+ * - **枚举属性**(spellcheck / contenteditable / draggable / autocomplete…)**值有语义**:
35
+ * `spellcheck="false"` 与缺省(= true)意图**相反**,所以 `spellCheck={false}`
36
+ * 必须真的写出 `spellcheck="false"`。这一条有测试钉住(jsx-integration ⑤)。
37
+ * - **布尔属性**(disabled / checked / autofocus…)只看"存在与否"、与值无关 ——
38
+ * 那类必须走 **property 通道**(`bindProperty` + 布尔清除值 false)才能正确移除。
39
+ *
40
+ * 曾经试过在这里把 `false` 一律当"移除"来修 `autoFocus={false}`,结果把 spellCheck
41
+ * 的语义弄反了(`false` 变成"移除属性" = 回到缺省 true = 打开拼写检查,与意图相反)。
42
+ * 正确做法是让**布尔属性走 property 通道**,见 `dom-props.ts` 的 PROPERTY_NAMES。
43
+ */
44
+ if (value === null || value === undefined) return
45
+ /*
46
+ * `classList` 走专用通道:它与 `class` 写的是同一个 HTML 属性,
47
+ * 需要"记住上次贡献、本次替换"的合并语义(见 ops.ts 的 applyClassList)。
48
+ * 直接 `setAttribute(node,'classList', …)` 只会写出一个浏览器不认识的 `classlist` 属性。
49
+ */
50
+ if (key === 'classList') {
51
+ applyClassList(node, value)
52
+ return
53
+ }
30
54
  setAttribute(node, key, key === 'style' && value && typeof value === 'object' && !Array.isArray(value)
31
55
  ? Object.entries(value as Record<string, unknown>)
32
56
  .filter(([, entry]) => entry !== null && entry !== undefined && entry !== false)
@@ -47,6 +71,10 @@ export function bindProperty(
47
71
  registerSelectValueBinding(node, () => readSource(source))
48
72
  }
49
73
  effect(() => {
50
- setProperty(node, key, readSource(source))
74
+ const value = readSource(source)
75
+ // property 键的 false 有语义(如 disabled={false} 必须清除)必须透传;
76
+ // 但 null/undefined 跳过——DOM 会把 undefined 强转成字符串 "undefined"(如 input.value)
77
+ if (value === null || value === undefined) return
78
+ setProperty(node, key, value)
51
79
  })
52
80
  }
@@ -1,6 +1,6 @@
1
1
  // @vitest-environment jsdom
2
2
  import { describe, expect, it, vi } from 'vitest'
3
- import { createDOMRenderer, createText, createVobs, Profiler, AsyncBoundary, state } from '@vobs/vobs'
3
+ import { createDOMRenderer, createElement, createText, createVobs, insertErrorBoundary, Profiler, AsyncBoundary, setRuntimeDebugHooks, state } from '@vobs/vobs'
4
4
 
5
5
  describe('AsyncBoundary and Profiler', () => {
6
6
  it('renders loading then resolved content', async () => {
@@ -58,3 +58,56 @@ describe('AsyncBoundary and Profiler', () => {
58
58
  vi.restoreAllMocks()
59
59
  })
60
60
  })
61
+
62
+ /*
63
+ * 边界捕获错误之后,原来只调 invokeRuntimeDebug('error', …) ——
64
+ * 没装 DevTools(也就没有 error 钩子)时控制台一个字都不输出:
65
+ * 错误被替换成 fallback,现场再无痕迹。
66
+ */
67
+ describe('边界错误不再静默', () => {
68
+ const mountFailing = () => {
69
+ const host = document.createElement('div')
70
+ const app = createVobs({
71
+ renderer: createDOMRenderer(),
72
+ render: () => {
73
+ const root = createElement('div')
74
+ insertErrorBoundary(root, null, {
75
+ children: () => { throw new Error('boom') },
76
+ fallback: () => createText('failed')
77
+ })
78
+ return root
79
+ }
80
+ })
81
+ app.mount(host)
82
+ app.update()
83
+ return { host, app }
84
+ }
85
+
86
+ it('没有 error 钩子时写进控制台', () => {
87
+ setRuntimeDebugHooks(null)
88
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
89
+ const { host, app } = mountFailing()
90
+
91
+ expect(host.textContent).toBe('failed')
92
+ expect(spy).toHaveBeenCalled()
93
+ expect(String(spy.mock.calls[0][0])).toContain('boom')
94
+
95
+ app.destroy()
96
+ spy.mockRestore()
97
+ })
98
+
99
+ it('装了 error 钩子时交给钩子,不重复输出', () => {
100
+ const events: unknown[] = []
101
+ const restore = setRuntimeDebugHooks({ error: event => events.push(event) })
102
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
103
+ const { host, app } = mountFailing()
104
+
105
+ expect(host.textContent).toBe('failed')
106
+ expect(events.length).toBeGreaterThan(0)
107
+ expect(spy).not.toHaveBeenCalled()
108
+
109
+ app.destroy()
110
+ setRuntimeDebugHooks(restore)
111
+ spy.mockRestore()
112
+ })
113
+ })
package/src/boundary.ts CHANGED
@@ -1,11 +1,27 @@
1
1
  import { createOwner, state } from '@vobs/reactivity'
2
- import { invokeRuntimeDebug } from './debug'
2
+ import { getRuntimeDebugHooks, invokeRuntimeDebug, type RuntimeErrorEvent } from './debug'
3
3
  import { insertDynamic, type NodeFactory } from './dynamic'
4
- import { normalizeVobsError } from './error'
4
+ import { formatVobsError, normalizeVobsError } from './error'
5
5
 
6
6
  export type BoundaryRetry = () => void | Promise<unknown>
7
7
  export type BoundaryFallback = (error: Error, retry: BoundaryRetry) => ReturnType<NodeFactory>
8
8
 
9
+ /**
10
+ * 边界捕获到的错误必须有人看得见。
11
+ *
12
+ * 原来只调 `invokeRuntimeDebug('error', …)` —— 没装 DevTools(也就没有 error 钩子)时
13
+ * 控制台**一个字都不输出**:错误被替换成 fallback,现场再无痕迹。这是排查成本最高的一种
14
+ * 静默。装了钩子就交给它(避免重复输出),没装就打到控制台。
15
+ *
16
+ * @param loud 只有「真的吞下了错误」才需要吵;retrying / recovered 属于信息,不进控制台。
17
+ */
18
+ function reportBoundaryError(event: RuntimeErrorEvent, loud: boolean): void {
19
+ invokeRuntimeDebug('error', event)
20
+ if (loud && getRuntimeDebugHooks()?.error === undefined) {
21
+ console.error(formatVobsError(event.error, { includeStack: true }))
22
+ }
23
+ }
24
+
9
25
  export interface BoundaryOptions {
10
26
  children: NodeFactory
11
27
  fallback: BoundaryFallback
@@ -38,25 +54,25 @@ export function insertBoundary(
38
54
  fix: '检查组件渲染逻辑,或在边界 fallback 中提供恢复操作。'
39
55
  })
40
56
  lastError = normalized
41
- invokeRuntimeDebug('error', {
57
+ reportBoundaryError({
42
58
  error: normalized,
43
59
  owner: boundary,
44
60
  phase: 'boundary',
45
61
  handled: true,
46
62
  recovery: 'fallback'
47
- })
63
+ }, true)
48
64
  error.value = normalized
49
65
  })
50
66
 
51
67
  const retry = (): void | Promise<unknown> => {
52
68
  if (error.value) {
53
- invokeRuntimeDebug('error', {
69
+ reportBoundaryError({
54
70
  error: error.value,
55
71
  owner: boundary,
56
72
  phase: 'boundary',
57
73
  handled: true,
58
74
  recovery: 'retrying'
59
- })
75
+ }, false)
60
76
  }
61
77
  error.value = null
62
78
  return options.onRetry?.()
@@ -73,13 +89,13 @@ export function insertBoundary(
73
89
  const currentError = error.value
74
90
  if (!currentError) {
75
91
  if (fallbackActive && lastError) {
76
- invokeRuntimeDebug('error', {
92
+ reportBoundaryError({
77
93
  error: lastError,
78
94
  owner: boundary,
79
95
  phase: 'boundary',
80
96
  handled: true,
81
97
  recovery: 'recovered'
82
- })
98
+ }, false)
83
99
  lastError = null
84
100
  }
85
101
  fallbackActive = false
@@ -0,0 +1,85 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from 'vitest'
3
+ import { onDispose, state } from '@vobs/reactivity'
4
+ import { createDOMRenderer, setRenderer } from '@vobs/vobs'
5
+ import {
6
+ bindText,
7
+ clear,
8
+ createComponent,
9
+ createElement,
10
+ createText,
11
+ insertBefore,
12
+ removeChild,
13
+ type VobsNode
14
+ } from './index'
15
+
16
+ setRenderer(createDOMRenderer())
17
+
18
+ function Item(props: { readonly label: () => string }): VobsNode {
19
+ const el = createElement('div')
20
+ const text = createText('')
21
+ insertBefore(el, text, null)
22
+ bindText(text, () => props.label())
23
+ return el
24
+ }
25
+
26
+ /** 组件清理时抛错的组件(用于验证顺序:DOM 先摘、Owner 后释放)。 */
27
+ function Boom(): VobsNode {
28
+ const el = createElement('div')
29
+ onDispose(() => { throw new Error('cleanup boom') })
30
+ return el
31
+ }
32
+
33
+ /*
34
+ * `removeChild` 原来是「先释放 Owner 再摘 DOM」:cleanup 抛错就永远走不到摘除,
35
+ * DOM 里留着旧节点、它的 effect 却已经没了 —— 屏幕上两棵树且没有任何提示。
36
+ */
37
+ describe('removeChild 的顺序', () => {
38
+ it('cleanup 抛错也仍然把节点从 DOM 摘掉', () => {
39
+ const parent = document.createElement('div')
40
+ const child = createComponent(Boom, {})
41
+ insertBefore(parent as unknown as Node, child, null)
42
+ expect((parent as unknown as Node).contains(child as unknown as Node)).toBe(true)
43
+
44
+ expect(() => removeChild(parent as unknown as Node, child)).toThrow('cleanup boom')
45
+ // 关键:DOM 已经一致了
46
+ expect((parent as unknown as Node).contains(child as unknown as Node)).toBe(false)
47
+ })
48
+ })
49
+
50
+ /*
51
+ * `clear` 原来只调渲染器(`textContent = ''`)——被清掉子树的 Owner 一个都不释放:
52
+ * 组件里的 effect 继续订阅信号(幽灵更新)、监听不解绑、onDispose 不跑。
53
+ */
54
+ describe('clear 释放 Owner', () => {
55
+ it('组件输出节点上的 Owner 被释放,清空后不再幽灵更新', async () => {
56
+ const label = state('a')
57
+ const host = document.createElement('div')
58
+ for (let i = 0; i < 3; i++) {
59
+ insertBefore(host, createComponent(Item, { label: () => label.value }), null)
60
+ }
61
+ const flush = async () => { await Promise.resolve(); await Promise.resolve() }
62
+
63
+ await flush()
64
+ expect(host.textContent).toBe('aaa')
65
+
66
+ clear(host)
67
+ expect(host.textContent).toBe('')
68
+
69
+ // 幽灵更新:如果 Owner 没释放,这里会重新写回文本
70
+ label.value = 'b'
71
+ await flush()
72
+ expect(host.textContent).toBe('')
73
+ })
74
+
75
+ it('清理抛错也不影响容器被清空,错误照旧抛出', () => {
76
+ const host = document.createElement('div')
77
+ // 用组件(输出节点会登记 Owner)—— 手工 createOwner 不登记节点,walk 找不到它,
78
+ // 这正是 clear 的覆盖边界:组件边界内的清理是完整的,组件外自己建的 effect 归它的 Owner 管。
79
+ insertBefore(host as unknown as Node, createComponent(Boom, {}), null)
80
+ insertBefore(host as unknown as Node, createComponent(Item, { label: () => 'x' }), null)
81
+
82
+ expect(() => clear(host as unknown as Node)).toThrow('cleanup boom')
83
+ expect((host as unknown as Node).childNodes.length).toBe(0)
84
+ })
85
+ })
@@ -0,0 +1,140 @@
1
+ // @vitest-environment jsdom
2
+ /*
3
+ * `<ClientOnly>` —— 只在客户端渲染的子树(外部踩坑文档 D 条的后一半)。
4
+ *
5
+ * 这个组件唯一需要想清楚的地方是**两阶段**:朴素实现(服务端渲染空、客户端渲染 children)
6
+ * 会造成**水合不匹配**。所以首轮两侧都渲染 fallback,挂载后才换入 children。
7
+ *
8
+ * 本文件的核心判据就是**水合能对上**(`strictHydration` 不报差异),
9
+ * 以及换入之后内容真的出现了。
10
+ */
11
+ import { describe, expect, it } from 'vitest'
12
+ import { scheduler } from '@vobs/reactivity'
13
+ import { createComponent, createDOMRenderer, createElement, createText, createVobs, setRenderer } from '@vobs/vobs'
14
+ import type { VobsNode } from '@vobs/vobs'
15
+ import { renderToString, hydrate } from '@vobs/ssr'
16
+ import { ClientOnly, insertBefore } from './index'
17
+
18
+ setRenderer(createDOMRenderer())
19
+ const flush = (): void => { scheduler.flush() }
20
+ const microtasks = async (): Promise<void> => {
21
+ await Promise.resolve(); await Promise.resolve(); await Promise.resolve()
22
+ }
23
+
24
+ function mountClientOnly(extra: Record<string, unknown> = {}) {
25
+ const host = document.createElement('main')
26
+ document.body.appendChild(host)
27
+ const app = createVobs({
28
+ render: () => {
29
+ const wrap = createElement('div')
30
+ const child = createComponent(ClientOnly, {
31
+ children: () => {
32
+ const widget = createElement('span')
33
+ widget.textContent = 'CLIENT'
34
+ return widget
35
+ },
36
+ ...extra
37
+ } as never)
38
+ // ClientOnly 返回的是 fragment,必须由 insertBefore 挂载(它会处理 fragment)
39
+ insertBefore(wrap, child, null)
40
+ return wrap as unknown as VobsNode
41
+ }
42
+ })
43
+ app.mount(host)
44
+ return { host, app, cleanup: () => { app.destroy(); host.remove() } }
45
+ }
46
+
47
+ describe('ClientOnly', () => {
48
+ it('挂载后换入 children', async () => {
49
+ const view = mountClientOnly()
50
+ flush()
51
+ // 首轮:还没有
52
+ expect(view.host.textContent).toBe('')
53
+ await microtasks()
54
+ flush()
55
+ expect(view.host.textContent, '挂载后没有换入 children').toBe('CLIENT')
56
+ view.cleanup()
57
+ })
58
+
59
+ it('服务端渲染不产出 children(SSG 阶段不会碰浏览器 API)', () => {
60
+ const tree = () => {
61
+ const wrap = createElement('div')
62
+ const child = createComponent(ClientOnly, {
63
+ children: () => {
64
+ const el = createElement('span')
65
+ el.textContent = 'CLIENT'
66
+ return el
67
+ }
68
+ } as never)
69
+ insertBefore(wrap, child, null)
70
+ return wrap as unknown as VobsNode
71
+ }
72
+ const html = renderToString(tree)
73
+ expect(html, 'SSR 产物里出现了只在客户端才该有的内容').not.toContain('CLIENT')
74
+ })
75
+
76
+ it('**水合对得上**(strictHydration 不报差异)—— 这是两阶段设计的理由', async () => {
77
+ const tree = () => {
78
+ const wrap = createElement('div')
79
+ const child = createComponent(ClientOnly, {
80
+ children: () => {
81
+ const el = createElement('span')
82
+ el.textContent = 'CLIENT'
83
+ return el
84
+ },
85
+ fallback: () => createText('placeholder')
86
+ } as never)
87
+ insertBefore(wrap, child, null)
88
+ return wrap as unknown as VobsNode
89
+ }
90
+ document.body.innerHTML = renderToString(tree)
91
+ // 服务端产物里是 fallback
92
+ expect(document.body.textContent).toBe('placeholder')
93
+
94
+ let error: unknown
95
+ let app: { destroy(): void } | undefined
96
+ try {
97
+ app = hydrate(tree, document.body, { strictHydration: true })
98
+ } catch (reason) {
99
+ error = reason
100
+ }
101
+ expect(
102
+ error,
103
+ `水合报出了差异:${JSON.stringify((error as { vobsHydration?: unknown })?.vobsHydration)}`
104
+ ).toBeUndefined()
105
+
106
+ // 水合首轮仍是 fallback(与产物一致),之后才换成 children
107
+ expect(document.body.textContent).toBe('placeholder')
108
+ await microtasks()
109
+ flush()
110
+ expect(document.body.textContent, '水合后没有换入 children').toBe('CLIENT')
111
+
112
+ app?.destroy()
113
+ document.body.innerHTML = ''
114
+ })
115
+
116
+ it('fallback 在首轮渲染(服务端与客户端一致)', () => {
117
+ const view = mountClientOnly({ fallback: () => createText('loading') })
118
+ flush()
119
+ expect(view.host.textContent).toBe('loading')
120
+ view.cleanup()
121
+ })
122
+
123
+ it('组件在微任务之前被卸载 → 不翻转、不抛错', async () => {
124
+ const view = mountClientOnly()
125
+ view.cleanup()
126
+ await microtasks()
127
+ flush()
128
+ expect(view.host.textContent).toBe('')
129
+ expect(view.host.isConnected).toBe(false)
130
+ })
131
+
132
+ it('卸载后没有残留(fragment 节点被清掉)', async () => {
133
+ const view = mountClientOnly()
134
+ await microtasks()
135
+ flush()
136
+ expect(view.host.textContent).toBe('CLIENT')
137
+ view.cleanup()
138
+ expect(view.host.innerHTML).toBe('')
139
+ })
140
+ })
@@ -0,0 +1,65 @@
1
+ import { getCurrentOwner, state } from '@vobs/reactivity'
2
+ import { insertDynamic } from './dynamic'
3
+ import { createFragment, type VobsNode } from './fragment'
4
+
5
+ export interface ClientOnlyProps {
6
+ /** 只在客户端渲染的内容。 */
7
+ readonly children?: VobsNode | (() => VobsNode | null | undefined)
8
+ /** 服务端(以及水合首轮)渲染的占位。 */
9
+ readonly fallback?: VobsNode | (() => VobsNode | null | undefined)
10
+ }
11
+
12
+ /**
13
+ * `<ClientOnly>` —— 只在客户端渲染的子树。
14
+ *
15
+ * 补的是外部踩坑文档 D 条的后一半:`onMount`(`39c155b`)管**副作用**
16
+ * (定时器 / matchMedia / localStorage),`ClientOnly` 管**渲染** ——
17
+ * 某些子树在服务端根本渲染不出来(依赖 `window`、第三方 widget、需要真实布局测量)。
18
+ *
19
+ * ## 为什么必须是**两阶段**(这是这个组件唯一需要想清楚的地方)
20
+ *
21
+ * 朴素实现是"服务端渲染空、客户端渲染 children" —— **那会造成水合不匹配**:
22
+ * 服务端产物里没有那棵子树,客户端却期望认领它(本仓库严格水合会报 `missing-node`,
23
+ * 非严格模式也会静默重建,表现为内容闪烁)。
24
+ *
25
+ * 所以这里:
26
+ * 1. **首轮**:服务端与客户端**都**渲染 `fallback`(默认什么都不渲染)—— 对得上
27
+ * 2. **挂载之后**(微任务):翻成"已就绪",`insertDynamic` 换入 `children`
28
+ *
29
+ * ## 父节点从哪来
30
+ *
31
+ * 用 `createFragment` —— 它的工厂签名是 `(parent, anchor) => void`,
32
+ * **框架在挂载时把父节点与锚点交进来**,于是可以直接用 `insertDynamic` 换内容,
33
+ * 不需要包裹元素(包裹层会改 flex/grid 布局)。
34
+ *
35
+ * ## 边界(如实说明)
36
+ *
37
+ * 客户端判定用的是 `typeof document === 'undefined'`,与 `onMount`(`39c155b`)
38
+ * 以及 `@vobs/ssr` 的 `prerender.ts` **同一套判据**。其已知局限也一样:
39
+ * **在 jsdom 这类带 DOM 的环境里做预渲染时会被判成客户端**(框架目前没有"正在预渲染"标志)。
40
+ * 那种环境下 `ClientOnly` 的首轮会渲染 `fallback`、微任务里再换成 `children`,
41
+ * 水合仍然是对得上的(两侧首轮一致),只是"跳过客户端渲染"这件事没生效。
42
+ */
43
+ export function ClientOnly(props: ClientOnlyProps = {}): VobsNode {
44
+ return createFragment((parent, anchor) => {
45
+ const ready = state(false)
46
+ /*
47
+ * 只有存在 `document` 才排这个微任务 —— Node 端预渲染时不应留下会稍后执行的副作用
48
+ * (那会让"服务端渲染"在 renderToString 返回之后还动到已完成的输出)。
49
+ */
50
+ if (typeof document !== 'undefined') {
51
+ const owner = getCurrentOwner()
52
+ queueMicrotask(() => {
53
+ // 组件在这之前被卸载 → 不再翻转(与 onMount 同一防御)
54
+ if (owner?.disposed) return
55
+ ready.value = true
56
+ })
57
+ }
58
+ insertDynamic(parent, anchor, () => {
59
+ const slot = ready.value ? props.children : props.fallback
60
+ // 用 unknown 承接:声明类型不含 false,但 `cond && <X/>` 会给出 false
61
+ const value: unknown = typeof slot === 'function' ? slot() : slot
62
+ return value === undefined || value === null || value === false ? null : (value as VobsNode)
63
+ })
64
+ })
65
+ }