wip(vapor): component hydration

This commit is contained in:
Evan You 2025-03-10 16:18:02 +08:00
parent a2415de7bf
commit e3a33e6092
No known key found for this signature in database
GPG Key ID: 00E9AB7A6704CE0A
14 changed files with 333 additions and 118 deletions

View File

@ -26,7 +26,7 @@ export function render(_ctx) {
`;
exports[`compile > custom directive > component 1`] = `
"import { resolveComponent as _resolveComponent, resolveDirective as _resolveDirective, createComponentWithFallback as _createComponentWithFallback, withVaporDirectives as _withVaporDirectives, insert as _insert, createIf as _createIf, template as _template } from 'vue';
"import { resolveComponent as _resolveComponent, resolveDirective as _resolveDirective, setInsertionState as _setInsertionState, createComponentWithFallback as _createComponentWithFallback, withVaporDirectives as _withVaporDirectives, createIf as _createIf, template as _template } from 'vue';
const t0 = _template("<div></div>")
export function render(_ctx) {
@ -38,9 +38,9 @@ export function render(_ctx) {
"default": () => {
const n0 = _createIf(() => (true), () => {
const n3 = t0()
_setInsertionState(n3)
const n2 = _createComponentWithFallback(_component_Bar)
_withVaporDirectives(n2, [[_directive_hello, void 0, void 0, { world: true }]])
_insert(n2, n3)
return n3
})
return n0
@ -149,7 +149,7 @@ export function render(_ctx, $props, $emit, $attrs, $slots) {
`;
exports[`compile > directives > v-pre > should not affect siblings after it 1`] = `
"import { resolveComponent as _resolveComponent, child as _child, createComponentWithFallback as _createComponentWithFallback, prepend as _prepend, toDisplayString as _toDisplayString, setText as _setText, setProp as _setProp, renderEffect as _renderEffect, template as _template } from 'vue';
"import { resolveComponent as _resolveComponent, child as _child, setInsertionState as _setInsertionState, createComponentWithFallback as _createComponentWithFallback, toDisplayString as _toDisplayString, setText as _setText, setProp as _setProp, renderEffect as _renderEffect, template as _template } from 'vue';
const t0 = _template("<div :id=\\"foo\\"><Comp></Comp>{{ bar }}</div>")
const t1 = _template("<div> </div>")
@ -158,8 +158,8 @@ export function render(_ctx, $props, $emit, $attrs, $slots) {
const n0 = t0()
const n3 = t1()
const n2 = _child(n3)
_setInsertionState(n3, 0)
const n1 = _createComponentWithFallback(_component_Comp)
_prepend(n3, n1)
_renderEffect(() => {
_setText(n2, _toDisplayString(_ctx.bar))
_setProp(n3, "id", _ctx.foo)

View File

@ -65,20 +65,20 @@ export function render(_ctx) {
`;
exports[`compiler: v-for > nested v-for 1`] = `
"import { child as _child, toDisplayString as _toDisplayString, setText as _setText, renderEffect as _renderEffect, createFor as _createFor, insert as _insert, template as _template } from 'vue';
"import { setInsertionState as _setInsertionState, child as _child, toDisplayString as _toDisplayString, setText as _setText, renderEffect as _renderEffect, createFor as _createFor, template as _template } from 'vue';
const t0 = _template("<span> </span>")
const t1 = _template("<div></div>", true)
export function render(_ctx) {
const n0 = _createFor(() => (_ctx.list), (_for_item0) => {
const n5 = t1()
_setInsertionState(n5)
const n2 = _createFor(() => (_for_item0.value), (_for_item1) => {
const n4 = t0()
const x4 = _child(n4)
_renderEffect(() => _setText(x4, _toDisplayString(_for_item1.value+_for_item0.value)))
return n4
}, null, 1)
_insert(n2, n5)
return n5
})
return n0

View File

@ -36,14 +36,14 @@ export function render(_ctx) {
`;
exports[`compiler: v-once > on component 1`] = `
"import { resolveComponent as _resolveComponent, createComponentWithFallback as _createComponentWithFallback, insert as _insert, template as _template } from 'vue';
"import { resolveComponent as _resolveComponent, setInsertionState as _setInsertionState, createComponentWithFallback as _createComponentWithFallback, template as _template } from 'vue';
const t0 = _template("<div></div>", true)
export function render(_ctx) {
const _component_Comp = _resolveComponent("Comp")
const n1 = t0()
_setInsertionState(n1)
const n0 = _createComponentWithFallback(_component_Comp, { id: () => (_ctx.foo) }, null, null, true)
_insert(n0, n1)
return n1
}"
`;

View File

@ -132,10 +132,6 @@ describe('compiler: v-once', () => {
id: 0,
tag: 'Comp',
once: true,
},
{
type: IRNodeTypes.INSERT_NODE,
elements: [0],
parent: 1,
},
])

View File

@ -51,6 +51,7 @@ export function genCreateComponent(
const rawSlots = genRawSlots(slots, context)
const [ids, handlers] = processInlineHandlers(props, context)
const rawProps = context.withId(() => genRawProps(props, context), ids)
const inlineHandlers: CodeFragment[] = handlers.reduce<CodeFragment[]>(
(acc, { name, value }) => {
const handler = genEventHandler(context, value, undefined, false)

View File

@ -1,4 +1,10 @@
import { type IREffect, IRNodeTypes, type OperationNode } from '../ir'
import {
type IREffect,
IRNodeTypes,
type InsertionStateTypes,
type OperationNode,
isTypeThatNeedsInsertionState,
} from '../ir'
import type { CodegenContext } from '../generate'
import { genInsertNode, genPrependNode } from './dom'
import { genSetDynamicEvents, genSetEvent } from './event'
@ -14,6 +20,7 @@ import {
INDENT_START,
NEWLINE,
buildCodeFragment,
genCall,
} from './utils'
import { genCreateComponent } from './component'
import { genSlotOutlet } from './slotOutlet'
@ -26,6 +33,9 @@ export function genOperations(
): CodeFragment[] {
const [frag, push] = buildCodeFragment()
for (const operation of opers) {
if (isTypeThatNeedsInsertionState(operation) && operation.parent) {
push(...genInsertionstate(operation, context))
}
push(...genOperation(operation, context))
}
return frag
@ -134,3 +144,21 @@ export function genEffect(
return frag
}
function genInsertionstate(
operation: InsertionStateTypes,
context: CodegenContext,
): CodeFragment[] {
return [
NEWLINE,
...genCall(
context.helper('setInsertionState'),
`n${operation.parent}`,
operation.anchor == null
? undefined
: operation.anchor === -1 // -1 indicates prepend
? `0` // runtime anchor value for prepend
: `n${operation.anchor}`,
),
]
}

View File

@ -76,6 +76,8 @@ export interface IfIRNode extends BaseIRNode {
positive: BlockIRNode
negative?: BlockIRNode | IfIRNode
once?: boolean
parent?: number
anchor?: number
}
export interface IRFor {
@ -93,6 +95,8 @@ export interface ForIRNode extends BaseIRNode, IRFor {
once: boolean
component: boolean
onlyChild: boolean
parent?: number
anchor?: number
}
export interface SetPropIRNode extends BaseIRNode {
@ -158,6 +162,7 @@ export interface SetTemplateRefIRNode extends BaseIRNode {
effect: boolean
}
// TODO remove, no longer needed
export interface CreateTextNodeIRNode extends BaseIRNode {
type: IRNodeTypes.CREATE_TEXT_NODE
id: number
@ -198,6 +203,8 @@ export interface CreateComponentIRNode extends BaseIRNode {
root: boolean
once: boolean
dynamic?: SimpleExpressionNode
parent?: number
anchor?: number
}
export interface DeclareOldRefIRNode extends BaseIRNode {
@ -211,6 +218,8 @@ export interface SlotOutletIRNode extends BaseIRNode {
name: SimpleExpressionNode
props: IRProps[]
fallback?: BlockIRNode
parent?: number
anchor?: number
}
export interface GetTextChildIRNode extends BaseIRNode {
@ -288,3 +297,21 @@ export type VaporDirectiveNode = Overwrite<
arg: Exclude<DirectiveNode['arg'], CompoundExpressionNode>
}
>
export type InsertionStateTypes =
| IfIRNode
| ForIRNode
| SlotOutletIRNode
| CreateComponentIRNode
export function isTypeThatNeedsInsertionState(
op: OperationNode,
): op is InsertionStateTypes {
const type = op.type
return (
type === IRNodeTypes.CREATE_COMPONENT_NODE ||
type === IRNodeTypes.SLOT_OUTLET_NODE ||
type === IRNodeTypes.IF ||
type === IRNodeTypes.FOR
)
}

View File

@ -4,7 +4,12 @@ import {
type TransformContext,
transformNode,
} from '../transform'
import { DynamicFlag, type IRDynamicInfo, IRNodeTypes } from '../ir'
import {
DynamicFlag,
type IRDynamicInfo,
IRNodeTypes,
isTypeThatNeedsInsertionState as isBlockOperation,
} from '../ir'
export const transformChildren: NodeTransform = (node, context) => {
const isFragment =
@ -66,21 +71,11 @@ function processDynamicChildren(context: TransformContext<ElementNode>) {
if (prevDynamics.length) {
if (hasStaticTemplate) {
context.childrenTemplate[index - prevDynamics.length] = `<!>`
prevDynamics[0].flags -= DynamicFlag.NON_TEMPLATE
const anchor = (prevDynamics[0].anchor = context.increaseId())
context.registerOperation({
type: IRNodeTypes.INSERT_NODE,
elements: prevDynamics.map(child => child.id!),
parent: context.reference(),
anchor,
})
registerInsertion(prevDynamics, context, anchor)
} else {
context.registerOperation({
type: IRNodeTypes.PREPEND_NODE,
elements: prevDynamics.map(child => child.id!),
parent: context.reference(),
})
registerInsertion(prevDynamics, context, -1 /* prepend */)
}
prevDynamics = []
}
@ -89,10 +84,32 @@ function processDynamicChildren(context: TransformContext<ElementNode>) {
}
if (prevDynamics.length) {
context.registerOperation({
type: IRNodeTypes.INSERT_NODE,
elements: prevDynamics.map(child => child.id!),
parent: context.reference(),
})
registerInsertion(prevDynamics, context)
}
}
function registerInsertion(
dynamics: IRDynamicInfo[],
context: TransformContext,
anchor?: number,
) {
for (const child of dynamics) {
if (child.template != null) {
// template node due to invalid nesting - generate actual insertion
context.registerOperation({
type: IRNodeTypes.INSERT_NODE,
elements: dynamics.map(child => child.id!),
parent: context.reference(),
anchor,
})
} else {
// block types
for (const op of context.block.operation) {
if (isBlockOperation(op) && op.id === child.id) {
op.parent = context.reference()
op.anchor = anchor
}
}
}
}
}

View File

@ -1,11 +1,13 @@
// import { type SSRContext, renderToString } from '@vue/server-renderer'
import {
child,
createComponent,
createVaporSSRApp,
delegateEvents,
next,
renderEffect,
setClass,
setInsertionState,
setText,
template,
} from '../src'
@ -144,6 +146,117 @@ describe('SSR hydration', () => {
)
})
test('basic component', async () => {
const t0 = template(' ')
const msg = ref('foo')
const Comp = {
setup() {
const n0 = t0() as Text
renderEffect(() => setText(n0, toDisplayString(msg.value)))
return n0
},
}
const t1 = template('<div><span></span></div>', true)
const { container } = mountWithHydration(
'<div><span></span>foo</div>',
() => {
const n1 = t1() as Element
setInsertionState(n1)
createComponent(Comp)
return n1
},
)
expect(container.innerHTML).toBe(`<div><span></span>foo</div>`)
msg.value = 'bar'
await nextTick()
expect(container.innerHTML).toBe(`<div><span></span>bar</div>`)
})
test('fragment component', async () => {
const t0 = template('<div> </div>')
const t1 = template(' ')
const msg = ref('foo')
const Comp = {
setup() {
const n0 = t0() as Element
const n1 = t1() as Text
const x0 = child(n0) as Text
renderEffect(() => {
const _msg = msg.value
setText(x0, toDisplayString(_msg))
setText(n1, toDisplayString(_msg))
})
return [n0, n1]
},
}
const t2 = template('<div><span></span></div>', true)
const { container } = mountWithHydration(
'<div><span></span><!--[--><div>foo</div>foo<!--]--></div>',
() => {
const n1 = t2() as Element
setInsertionState(n1)
createComponent(Comp)
return n1
},
)
expect(container.innerHTML).toBe(
`<div><span></span><!--[--><div>foo</div>foo<!--]--></div>`,
)
msg.value = 'bar'
await nextTick()
expect(container.innerHTML).toBe(
`<div><span></span><!--[--><div>bar</div>bar<!--]--></div>`,
)
})
test('fragment component with prepend', async () => {
const t0 = template('<div> </div>')
const t1 = template(' ')
const msg = ref('foo')
const Comp = {
setup() {
const n0 = t0() as Element
const n1 = t1() as Text
const x0 = child(n0) as Text
renderEffect(() => {
const _msg = msg.value
setText(x0, toDisplayString(_msg))
setText(n1, toDisplayString(_msg))
})
return [n0, n1]
},
}
const t2 = template('<div><span></span></div>', true)
const { container } = mountWithHydration(
'<div><!--[--><div>foo</div>foo<!--]--><span></span></div>',
() => {
const n1 = t2() as Element
setInsertionState(n1, 0)
createComponent(Comp)
return n1
},
)
expect(container.innerHTML).toBe(
`<div><!--[--><div>foo</div>foo<!--]--><span></span></div>`,
)
msg.value = 'bar'
await nextTick()
expect(container.innerHTML).toBe(
`<div><!--[--><div>bar</div>bar<!--]--><span></span></div>`,
)
})
// test('element with ref', () => {
// const el = ref()
// const { vnode, container } = mountWithHydration('<div></div>', () =>

View File

@ -58,6 +58,12 @@ import {
getSlot,
} from './componentSlots'
import { hmrReload, hmrRerender } from './hmr'
import { isHydrating, locateHydrationNode } from './dom/hydration'
import {
insertionAnchor,
insertionParent,
resetInsertionState,
} from './insertionState'
export { currentInstance } from '@vue/runtime-dom'
@ -136,6 +142,10 @@ export function createComponent(
currentInstance.appContext) ||
emptyContext,
): VaporComponentInstance {
if (isHydrating) {
locateHydrationNode()
}
// vdom interop enabled and component is not an explicit vapor component
if (appContext.vapor && !component.__vapor) {
return appContext.vapor.vdomMount(component as any, rawProps, rawSlots)
@ -253,6 +263,11 @@ export function createComponent(
onScopeDispose(() => unmountComponent(instance), true)
if (!isHydrating && insertionParent) {
insert(instance.block, insertionParent, insertionAnchor)
resetInsertionState()
}
return instance
}

View File

@ -1,3 +1,10 @@
import { warn } from '@vue/runtime-dom'
import {
insertionAnchor,
insertionParent,
resetInsertionState,
setInsertionState,
} from '../insertionState'
import { child, next } from './node'
export let isHydrating = false
@ -10,31 +17,28 @@ export function setCurrentHydrationNode(node: Node | null): void {
let isOptimized = false
export function withHydration(container: ParentNode, fn: () => void): void {
adoptHydrationNode = adoptHydrationNodeImpl
adoptTemplate = adoptTemplateImpl
locateHydrationNode = locateHydrationNodeImpl
if (!isOptimized) {
// optimize anchor cache lookup
const proto = Comment.prototype as any
proto.$p = proto.$e = undefined
;(Comment.prototype as any).$fs = undefined
isOptimized = true
}
isHydrating = true
currentHydrationNode = child(container)
setInsertionState(container, 0)
const res = fn()
resetInsertionState()
isHydrating = false
currentHydrationNode = null
return res
}
export let adoptHydrationNode: (
node: Node | null,
template?: string,
) => Node | null
export let adoptTemplate: (node: Node, template: string) => Node | null
export let locateHydrationNode: () => void
type Anchor = Comment & {
// previous open anchor
$p?: Anchor
// matching end anchor
$e?: Anchor
// cached matching fragment start to avoid repeated traversal
// on nested fragments
$fs?: Anchor
}
const isComment = (node: Node, data: string): node is Anchor =>
@ -44,84 +48,82 @@ const isComment = (node: Node, data: string): node is Anchor =>
* Locate the first non-fragment-comment node and locate the next node
* while handling potential fragments.
*/
function adoptHydrationNodeImpl(
node: Node | null,
template?: string,
): Node | null {
if (!isHydrating || !node) {
return node
function adoptTemplateImpl(node: Node, template: string): Node | null {
if (!(template[0] === '<' && template[1] === '!')) {
while (node.nodeType === 8) node = next(node)
}
let adopted: Node | undefined
let end: Node | undefined | null
if (template) {
if (template[0] !== '<' && template[1] !== '!') {
while (node.nodeType === 8) node = next(node)
}
adopted = end = node
} else if (isComment(node, '[')) {
// fragment
let start = node
let cur: Node = node
let fragmentDepth = 1
// previously recorded fragment end
if (!end && node.$e) {
end = node.$e
}
while (true) {
cur = next(cur)
if (isComment(cur, '[')) {
// previously recorded fragment end
if (!end && node.$e) {
end = node.$e
}
fragmentDepth++
cur.$p = start
start = cur
} else if (isComment(cur, ']')) {
fragmentDepth--
// record fragment end on start node for later traversal
start.$e = cur
start = start.$p!
if (!fragmentDepth) {
// fragment end
end = cur
break
}
} else if (!adopted) {
adopted = cur
if (end) {
break
}
}
}
if (!adopted) {
throw new Error('hydration mismatch')
}
} else {
adopted = end = node
}
if (__DEV__ && template) {
const type = adopted.nodeType
if (__DEV__) {
const type = node.nodeType
if (
(type === 8 && !template.startsWith('<!')) ||
(type === 1 &&
!template.startsWith(
`<` + (adopted as Element).tagName.toLowerCase(),
)) ||
!template.startsWith(`<` + (node as Element).tagName.toLowerCase())) ||
(type === 3 &&
template.trim() &&
!template.startsWith((adopted as Text).data))
!template.startsWith((node as Text).data))
) {
// TODO recover and provide more info
console.error(`adopted: `, adopted)
console.error(`template: ${template}`)
throw new Error('hydration mismatch!')
warn(`adopted: `, node)
warn(`template: ${template}`)
warn('hydration mismatch!')
}
}
currentHydrationNode = next(end!)
return adopted
currentHydrationNode = next(node)
return node
}
function locateHydrationNodeImpl() {
if (__DEV__ && !insertionParent) {
warn('Hydration error: missing insertion state.')
}
let node: Node | null
// prepend / firstChild
if (insertionAnchor === 0) {
node = child(insertionParent!)
} else {
node = insertionAnchor
? insertionAnchor.previousSibling
: insertionParent!.lastChild
if (node && isComment(node, ']')) {
// fragment backward search
if (node.$fs) {
// already cached matching fragment start
node = node.$fs
} else {
let cur: Node | null = node
let curFragEnd = node
let fragDepth = 0
node = null
while (cur) {
cur = cur.previousSibling
if (cur) {
if (isComment(cur, '[')) {
curFragEnd.$fs = cur
if (!fragDepth) {
node = cur
break
} else {
fragDepth--
}
} else if (isComment(cur, ']')) {
curFragEnd = cur
fragDepth++
}
}
}
}
}
}
currentHydrationNode = node
if (__DEV__ && !currentHydrationNode) {
// TODO more info
warn('Hydration mismatch in ', insertionParent)
}
}

View File

@ -1,8 +1,4 @@
import {
adoptHydrationNode,
currentHydrationNode,
isHydrating,
} from './hydration'
import { adoptTemplate, currentHydrationNode, isHydrating } from './hydration'
import { child, createTextNode } from './node'
let t: HTMLTemplateElement
@ -16,7 +12,7 @@ export function template(html: string, root?: boolean) {
// TODO this should not happen
throw new Error('No current hydration node')
}
return adoptHydrationNode(currentHydrationNode, html)!
return adoptTemplate(currentHydrationNode!, html)!
}
// fast path for text nodes
if (html[0] !== '<') {

View File

@ -6,6 +6,7 @@ export type { VaporDirective } from './directives/custom'
// compiler-use only
export { insert, prepend, remove, isFragment, VaporFragment } from './block'
export { setInsertionState } from './insertionState'
export { createComponent, createComponentWithFallback } from './component'
export { renderEffect } from './renderEffect'
export { createSlot } from './componentSlots'

View File

@ -0,0 +1,19 @@
import { setCurrentHydrationNode } from './dom/hydration'
export let insertionParent: ParentNode | undefined
export let insertionAnchor: Node | 0 | undefined
/**
* This function is called before a block type that requires insertion
* (component, slot outlet, if, for) is created. The state is used for actual
* insertion on client-side render, and used for node adoption during hydration.
*/
export function setInsertionState(parent: ParentNode, anchor?: Node | 0): void {
insertionParent = parent
insertionAnchor = anchor
}
export function resetInsertionState(): void {
insertionParent = insertionAnchor = undefined
setCurrentHydrationNode(null)
}