@vobs/runtime 1.3.6 → 1.4.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.
- package/dist/async-boundary.cjs +1 -0
- package/dist/async-boundary.cjs.map +1 -1
- package/dist/async-boundary.js +1 -0
- package/dist/async-boundary.js.map +1 -1
- package/dist/bind.cjs.map +1 -1
- package/dist/bind.js.map +1 -1
- package/dist/boundary.cjs +1 -0
- package/dist/boundary.cjs.map +1 -1
- package/dist/boundary.js +1 -0
- package/dist/boundary.js.map +1 -1
- package/dist/dynamic.cjs +1 -0
- package/dist/dynamic.cjs.map +1 -1
- package/dist/dynamic.js +1 -0
- package/dist/dynamic.js.map +1 -1
- package/dist/error-boundary.cjs +1 -0
- package/dist/error-boundary.cjs.map +1 -1
- package/dist/error-boundary.js +1 -0
- package/dist/error-boundary.js.map +1 -1
- package/dist/fragment.cjs.map +1 -1
- package/dist/fragment.js.map +1 -1
- package/dist/hmr.cjs +18 -18
- package/dist/hmr.cjs.map +1 -1
- package/dist/hmr.d.cts +1 -1
- package/dist/hmr.d.ts +1 -1
- package/dist/hmr.js +18 -18
- package/dist/hmr.js.map +1 -1
- package/dist/index.cjs +50 -40
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +50 -40
- package/dist/index.js.map +1 -1
- package/dist/ops.cjs +32 -22
- package/dist/ops.cjs.map +1 -1
- package/dist/ops.js +32 -22
- package/dist/ops.js.map +1 -1
- package/dist/profiler.cjs +1 -0
- package/dist/profiler.cjs.map +1 -1
- package/dist/profiler.js +1 -0
- package/dist/profiler.js.map +1 -1
- package/package.json +2 -2
- package/src/events.test.ts +38 -0
- package/src/hmr.test.ts +155 -20
- package/src/hmr.ts +23 -19
- package/src/ops.ts +41 -22
package/src/hmr.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { effect, onDispose, scheduler, state } from '@vobs/reactivity'
|
|
3
4
|
import {
|
|
4
5
|
createHmrStateStore,
|
|
5
6
|
disposeHmrModule,
|
|
@@ -7,29 +8,34 @@ import {
|
|
|
7
8
|
resolveComponent,
|
|
8
9
|
updateHmrModule
|
|
9
10
|
} from './hmr'
|
|
10
|
-
import { createComponent, insertBefore, setRenderer } from './ops'
|
|
11
|
+
import { createComponent, insertBefore, removeChild, setRenderer } from './ops'
|
|
12
|
+
import { createFragment, type VobsNode } from './fragment'
|
|
13
|
+
|
|
14
|
+
function setupRenderer(): void {
|
|
15
|
+
setRenderer<Node, Text, Element, Comment>({
|
|
16
|
+
createText: content => document.createTextNode(content),
|
|
17
|
+
createElement: tag => document.createElement(tag),
|
|
18
|
+
createComment: content => document.createComment(content),
|
|
19
|
+
insertBefore: (parent, child, anchor) => { parent.insertBefore(child, anchor) },
|
|
20
|
+
removeChild: (parent, child) => { parent.removeChild(child) },
|
|
21
|
+
setTextContent: (node, content) => { node.textContent = content },
|
|
22
|
+
setProperty: (node, key, value) => { (node as unknown as Record<string, unknown>)[key] = value },
|
|
23
|
+
setAttribute: (node, key, value) => { node.setAttribute(key, value) },
|
|
24
|
+
addEventListener: (node, event, handler) => { node.addEventListener(event, handler) },
|
|
25
|
+
removeEventListener: (node, event, handler) => { node.removeEventListener(event, handler) },
|
|
26
|
+
nextSibling: node => node.nextSibling,
|
|
27
|
+
clear: container => {
|
|
28
|
+
while (container.childNodes.length > 0) {
|
|
29
|
+
const child = container.childNodes[0]
|
|
30
|
+
if (child) container.removeChild(child)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
}
|
|
11
35
|
|
|
12
36
|
describe('runtime HMR', () => {
|
|
13
37
|
it('刷新已挂载的组件节点时复用原 Owner', () => {
|
|
14
|
-
|
|
15
|
-
createText: content => document.createTextNode(content),
|
|
16
|
-
createElement: tag => document.createElement(tag),
|
|
17
|
-
createComment: content => document.createComment(content),
|
|
18
|
-
insertBefore: (parent, child, anchor) => { parent.insertBefore(child, anchor) },
|
|
19
|
-
removeChild: (parent, child) => { parent.removeChild(child) },
|
|
20
|
-
setTextContent: (node, content) => { node.textContent = content },
|
|
21
|
-
setProperty: (node, key, value) => { (node as unknown as Record<string, unknown>)[key] = value },
|
|
22
|
-
setAttribute: (node, key, value) => { node.setAttribute(key, value) },
|
|
23
|
-
addEventListener: (node, event, handler) => { node.addEventListener(event, handler) },
|
|
24
|
-
removeEventListener: (node, event, handler) => { node.removeEventListener(event, handler) },
|
|
25
|
-
nextSibling: node => node.nextSibling,
|
|
26
|
-
clear: container => {
|
|
27
|
-
while (container.childNodes.length > 0) {
|
|
28
|
-
const child = container.childNodes[0]
|
|
29
|
-
if (child) container.removeChild(child)
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
})
|
|
38
|
+
setupRenderer()
|
|
33
39
|
const moduleId = `hmr-mounted-${Date.now()}-${Math.random()}`
|
|
34
40
|
const first = resolveComponent(() => document.createTextNode('first'), moduleId, 'Panel')
|
|
35
41
|
const node = createComponent(first, {})
|
|
@@ -41,6 +47,135 @@ describe('runtime HMR', () => {
|
|
|
41
47
|
expect(parent.textContent).toBe('second')
|
|
42
48
|
})
|
|
43
49
|
|
|
50
|
+
it('刷新 fragment 根组件时替换整个 DOM 区间', () => {
|
|
51
|
+
setupRenderer()
|
|
52
|
+
const moduleId = `hmr-fragment-${Date.now()}-${Math.random()}`
|
|
53
|
+
const renderFragment = (label: string): VobsNode => createFragment((parent, anchor) => {
|
|
54
|
+
insertBefore(parent, document.createTextNode(label), anchor)
|
|
55
|
+
})
|
|
56
|
+
const first = resolveComponent(() => renderFragment('first'), moduleId, 'Panel')
|
|
57
|
+
const node = createComponent(first, {})
|
|
58
|
+
const parent = document.createElement('div')
|
|
59
|
+
insertBefore(parent, node, null)
|
|
60
|
+
expect(parent.textContent).toBe('first')
|
|
61
|
+
|
|
62
|
+
updateHmrModule(moduleId, { Panel: () => renderFragment('second') })
|
|
63
|
+
|
|
64
|
+
expect(parent.textContent).toBe('second')
|
|
65
|
+
// 旧 fragment 区间(start 注释 + 文本 + end 注释)整体移除,只余新区间
|
|
66
|
+
expect(parent.childNodes.length).toBe(3)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('普通节点与 fragment 根组件之间互相刷新替换', () => {
|
|
70
|
+
setupRenderer()
|
|
71
|
+
const moduleId = `hmr-mixed-${Date.now()}-${Math.random()}`
|
|
72
|
+
const first = resolveComponent(() => document.createTextNode('first'), moduleId, 'Panel')
|
|
73
|
+
const node = createComponent(first, {})
|
|
74
|
+
const parent = document.createElement('div')
|
|
75
|
+
insertBefore(parent, node, null)
|
|
76
|
+
|
|
77
|
+
updateHmrModule(moduleId, {
|
|
78
|
+
Panel: () => createFragment((parent, anchor) => {
|
|
79
|
+
insertBefore(parent, document.createTextNode('second'), anchor)
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
expect(parent.textContent).toBe('second')
|
|
83
|
+
|
|
84
|
+
updateHmrModule(moduleId, { Panel: () => document.createTextNode('third') })
|
|
85
|
+
expect(parent.textContent).toBe('third')
|
|
86
|
+
expect(parent.childNodes.length).toBe(1)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('刷新时释放上一轮渲染作用域:body 清理回调执行、旧 effect 失效', () => {
|
|
90
|
+
setupRenderer()
|
|
91
|
+
const moduleId = `hmr-dispose-${Date.now()}-${Math.random()}`
|
|
92
|
+
const counter = state(0)
|
|
93
|
+
let renderRuns = 0
|
|
94
|
+
let effectRuns = 0
|
|
95
|
+
let disposed = 0
|
|
96
|
+
const first = resolveComponent(() => {
|
|
97
|
+
renderRuns++
|
|
98
|
+
effect(() => {
|
|
99
|
+
void counter.value
|
|
100
|
+
effectRuns++
|
|
101
|
+
})
|
|
102
|
+
onDispose(() => { disposed++ })
|
|
103
|
+
return document.createTextNode('first')
|
|
104
|
+
}, moduleId, 'Panel')
|
|
105
|
+
const node = createComponent(first, {})
|
|
106
|
+
const parent = document.createElement('div')
|
|
107
|
+
insertBefore(parent, node, null)
|
|
108
|
+
expect(renderRuns).toBe(1)
|
|
109
|
+
|
|
110
|
+
updateHmrModule(moduleId, {
|
|
111
|
+
Panel: () => {
|
|
112
|
+
renderRuns++
|
|
113
|
+
effect(() => {
|
|
114
|
+
void counter.value
|
|
115
|
+
effectRuns++
|
|
116
|
+
})
|
|
117
|
+
return document.createTextNode('second')
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
expect(parent.textContent).toBe('second')
|
|
122
|
+
expect(renderRuns).toBe(2)
|
|
123
|
+
expect(effectRuns).toBe(2)
|
|
124
|
+
expect(disposed).toBe(1)
|
|
125
|
+
|
|
126
|
+
// 旧渲染的 effect 已随渲染作用域释放:信号变化只触发新一轮的 effect
|
|
127
|
+
counter.value = 99
|
|
128
|
+
scheduler.flush()
|
|
129
|
+
expect(effectRuns).toBe(3)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('一次热更新中每个组件实例只渲染一次(refresh 期间新建的实例不再刷新)', () => {
|
|
133
|
+
setupRenderer()
|
|
134
|
+
const moduleId = `hmr-once-${Date.now()}-${Math.random()}`
|
|
135
|
+
let childRuns = 0
|
|
136
|
+
const child = resolveComponent(() => {
|
|
137
|
+
childRuns++
|
|
138
|
+
return document.createTextNode('child')
|
|
139
|
+
}, moduleId, 'Child')
|
|
140
|
+
const panel = resolveComponent(() => {
|
|
141
|
+
const wrapper = document.createElement('div')
|
|
142
|
+
insertBefore(wrapper, createComponent(child, {}), null)
|
|
143
|
+
return wrapper
|
|
144
|
+
}, moduleId, 'Panel')
|
|
145
|
+
const node = createComponent(panel, {})
|
|
146
|
+
const parent = document.createElement('div')
|
|
147
|
+
insertBefore(parent, node, null)
|
|
148
|
+
expect(childRuns).toBe(1)
|
|
149
|
+
|
|
150
|
+
updateHmrModule(moduleId, {
|
|
151
|
+
Panel: () => createComponent(child, {}),
|
|
152
|
+
Child: () => {
|
|
153
|
+
childRuns++
|
|
154
|
+
return document.createTextNode('child-next')
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// Panel 刷新重渲染 Child 一次;refresh 期间新建的 Child 实例已在快照外,不再刷新
|
|
159
|
+
expect(childRuns).toBe(2)
|
|
160
|
+
expect(parent.textContent).toBe('child-next')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('普通卸载仍释放整个渲染作用域', () => {
|
|
164
|
+
setupRenderer()
|
|
165
|
+
const moduleId = `hmr-unmount-${Date.now()}-${Math.random()}`
|
|
166
|
+
let disposed = 0
|
|
167
|
+
const first = resolveComponent(() => {
|
|
168
|
+
onDispose(() => { disposed++ })
|
|
169
|
+
return document.createTextNode('first')
|
|
170
|
+
}, moduleId, 'Panel')
|
|
171
|
+
const node = createComponent(first, {})
|
|
172
|
+
const parent = document.createElement('div')
|
|
173
|
+
insertBefore(parent, node, null)
|
|
174
|
+
|
|
175
|
+
removeChild(parent, node)
|
|
176
|
+
expect(disposed).toBe(1)
|
|
177
|
+
})
|
|
178
|
+
|
|
44
179
|
it('更新组件代理时保留已有引用,并保留模块状态', () => {
|
|
45
180
|
const moduleId = `hmr-test-${Date.now()}-${Math.random()}`
|
|
46
181
|
const first = resolveComponent(() => ({ textContent: 'first' } as unknown as Node), moduleId, 'Panel')
|
package/src/hmr.ts
CHANGED
|
@@ -55,25 +55,29 @@ export function resolveComponent<Props extends object>(
|
|
|
55
55
|
return proxy
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
export function updateHmrModule(
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
58
|
+
export function updateHmrModule(moduleId: string, nextModule: Record<string, unknown>): void {
|
|
59
|
+
const module = hmrGlobal.modules.get(moduleId)
|
|
60
|
+
if (!module) return
|
|
61
|
+
let changed = false
|
|
62
|
+
for (const [name, proxy] of module.components) {
|
|
63
|
+
const next = nextModule[name]
|
|
64
|
+
if (typeof next !== 'function') continue
|
|
65
|
+
const hmrProxy = proxy as HmrComponent & { current: HmrComponent; displayName?: string }
|
|
66
|
+
hmrProxy.current = next as HmrComponent
|
|
67
|
+
Object.defineProperty(hmrProxy, 'displayName', { configurable: true, value: next.name || name })
|
|
68
|
+
changed = true
|
|
69
|
+
}
|
|
70
|
+
if (!changed) return
|
|
71
|
+
// 快照后正序遍历:实例在自身渲染开始前注册,祖先必然先于后代入列——
|
|
72
|
+
// 祖先先刷新会 dispose 上一轮渲染作用域,其树内旧后代实例的 refresh
|
|
73
|
+
// 命中已销毁守卫被跳过,避免同一组件在一次热更新中重渲染两次。
|
|
74
|
+
// 同时 refresh 重渲染以新代码创建的全新实例不在快照中,天然不会再刷新。
|
|
75
|
+
const instances = [...module.instances]
|
|
76
|
+
for (const instance of instances) {
|
|
77
|
+
try {
|
|
78
|
+
instance.refresh()
|
|
79
|
+
} catch {
|
|
80
|
+
// HMR failures remain application errors on the next normal render.
|
|
77
81
|
}
|
|
78
82
|
}
|
|
79
83
|
}
|
package/src/ops.ts
CHANGED
|
@@ -59,6 +59,7 @@ export function insertBefore(
|
|
|
59
59
|
): void {
|
|
60
60
|
if (isVobsFragment(child)) {
|
|
61
61
|
child.mount(parent, isVobsFragment(anchor) ? anchor.start : anchor)
|
|
62
|
+
markHmrInstanceMounted(child, parent)
|
|
62
63
|
return
|
|
63
64
|
}
|
|
64
65
|
getRenderer().insertBefore(parent, child, isVobsFragment(anchor) ? anchor.start : anchor)
|
|
@@ -301,6 +302,22 @@ export function createComponent<Component extends VobsComponent>(
|
|
|
301
302
|
attachComponentContext(reason, componentName, owner.id)
|
|
302
303
|
throw reason
|
|
303
304
|
})
|
|
305
|
+
// HMR 注册先于渲染作用域标记:注册清理必须跨热更新保活,不能被
|
|
306
|
+
// disposeSince 当作上一轮渲染的清理释放掉。
|
|
307
|
+
const hmrKey = (component as typeof component & { hmrKey?: string }).hmrKey
|
|
308
|
+
let instance: HmrInstance | null = null
|
|
309
|
+
if (hmrKey) {
|
|
310
|
+
const separator = hmrKey.lastIndexOf(':')
|
|
311
|
+
const moduleId = separator < 0 ? hmrKey : hmrKey.slice(0, separator)
|
|
312
|
+
instance = { node: null as unknown as VobsNode, parent: null, refresh: () => refreshInstance() }
|
|
313
|
+
const cleanup = registerHmrInstance(moduleId, instance)
|
|
314
|
+
owner.onDispose(cleanup)
|
|
315
|
+
}
|
|
316
|
+
// 渲染作用域:组件 Owner 只承载 HMR 注册与错误处理,每轮渲染注册的 effect、
|
|
317
|
+
// onDispose(含 portal 清理)与嵌套组件 Owner 都归属该轮作用域。HMR refresh
|
|
318
|
+
// 重渲染前释放上一轮作用域,旧实例的 effect 不再订阅信号、portal 节点不再
|
|
319
|
+
// 残留在 body 中每轮热更新叠加;组件 Owner 与 HMR 注册保活。
|
|
320
|
+
const renderScope = owner.mark()
|
|
304
321
|
let node: VobsNode
|
|
305
322
|
try {
|
|
306
323
|
// 组件渲染必须 untrack:组件是 run-once 的,其渲染发生在某次 effect 求值
|
|
@@ -316,29 +333,31 @@ export function createComponent<Component extends VobsComponent>(
|
|
|
316
333
|
throw error
|
|
317
334
|
}
|
|
318
335
|
associateNodeOwner(node, owner)
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const instance: HmrInstance = {
|
|
322
|
-
node,
|
|
323
|
-
parent: null,
|
|
324
|
-
refresh(): void {
|
|
325
|
-
const previous = instance.node
|
|
326
|
-
const next = owner.run(() => untrack(() => component(props)))
|
|
327
|
-
if (instance.parent && !isVobsFragment(previous) && !isVobsFragment(next)) {
|
|
328
|
-
getRenderer().insertBefore(instance.parent, next, previous)
|
|
329
|
-
getRenderer().removeChild(instance.parent, previous)
|
|
330
|
-
}
|
|
331
|
-
nodeOwners.delete(previous as object)
|
|
332
|
-
nodeOwners.set(next as object, owner)
|
|
333
|
-
associateHmrInstance(next, instance)
|
|
334
|
-
instance.node = next
|
|
335
|
-
}
|
|
336
|
-
}
|
|
336
|
+
if (instance) {
|
|
337
|
+
instance.node = node
|
|
337
338
|
associateHmrInstance(node, instance)
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
339
|
+
}
|
|
340
|
+
function refreshInstance(): void {
|
|
341
|
+
// 组件已随旧渲染树一起卸载(如父级在同一次热更新中先完成刷新),
|
|
342
|
+
// 无法也无需再刷新。
|
|
343
|
+
if (owner.disposed) return
|
|
344
|
+
const previous = instance!.node
|
|
345
|
+
owner.disposeSince(renderScope)
|
|
346
|
+
const next = owner.run(() => untrack(() => component(props)))
|
|
347
|
+
const parent = instance!.parent
|
|
348
|
+
if (parent) {
|
|
349
|
+
// 先插入新树再卸载旧树:替换锚点始终取自仍在文档中的旧树,
|
|
350
|
+
// fragment 与普通节点两种形态任意组合都能正确定位插入点。
|
|
351
|
+
const anchor = isVobsFragment(previous) ? previous.start : previous
|
|
352
|
+
if (isVobsFragment(next)) next.mount(parent, anchor)
|
|
353
|
+
else getRenderer().insertBefore(parent, next, anchor)
|
|
354
|
+
if (isVobsFragment(previous)) previous.unmount(parent)
|
|
355
|
+
else getRenderer().removeChild(parent, previous)
|
|
356
|
+
}
|
|
357
|
+
nodeOwners.delete(previous as object)
|
|
358
|
+
nodeOwners.set(next as object, owner)
|
|
359
|
+
associateHmrInstance(next, instance!)
|
|
360
|
+
instance!.node = next
|
|
342
361
|
}
|
|
343
362
|
return node
|
|
344
363
|
}
|