Vapor: hydration throws for a custom element or a nested <template> with children
Vue version
3.6.0-rc.9 (5be279570), unchanged on minor head 8faa3006c
Link to minimal reproduction
Needs server output plus client hydration, so not the playground. A failing test, no compiler options required:
test('a nested template element with children', async () => {
const data = reactive({ t: 'foo' })
const { container, html } = await testHydration(
`<template><div><template><span>{{ data.t }}</span></template></div></template>`,
{},
data,
)
expect(container.innerHTML).toBe(html)
})Steps to reproduce
Hydrate the server output of an element the compiler routes through createPlainElement — a tag matched by isCustomElement, or a literal <template> — that has children:
<template><my-ce><span>hi</span></my-ce></template> // isCustomElement: t => t.startsWith('my-')
server: <my-ce><span>hi</span></my-ce>
TypeError: Cannot read properties of null (reading 'nodeType')
at isComment (packages/runtime-vapor/src/dom/hydration.ts:201)
at packages/runtime-vapor/src/dom/template.ts:50What is expected?
vdom hydrates the same server string without complaint, so vapor should too.
What is actually happening?
Hydration throws inside setup, leaving the server markup on screen with no bindings. The message depends on which template the child compiles to — nodeType, $vha (dom/hydration.ts:879) or parentNode (dom/node.ts:40) — but the cause is one. By the code it reaches production too: none of the three sites is behind __DEV__, and logError rethrows in dev only.
One shape does not throw and rearranges the DOM instead:
<div><my-ce v-for="i in 2"><span>{{ t }}</span></my-ce></div>
server: <div><!--[--><my-ce><span>foo</span></my-ce><my-ce><span>foo</span></my-ce><!--]--></div>
client: <div><!--[--><my-ce><span>foo</span></my-ce><span>foo</span><my-ce></my-ce><span>foo</span><!--]--></div>Measured identically on rc.9 and on 8faa3006c. The element survives with no children, with props only, and with a v-if child — that one compiles to a DynamicFragment and takes the slot path instead.
Where it diverges
createPlainElement moves the hydration cursor into its own subtree only inside if (rawSlots) (packages/runtime-vapor/src/component.ts:1290-1329). Compiled children arrive with rawSlots === null, so the cursor is never entered, while codegen emits them as templates of their own, inserted after the element exists:
const t0 = _template("<span>hi", 2)
const n1 = _createPlainElement("my-ce", null, null, true) // rawSlots === null
const n0 = t0() // throws here
_insert(n0, n1)template() reads currentHydrationNode directly and does not consult insertion state, so the child template runs against whatever the enclosing scope left behind — null for a root element. Block children (createIf, createFor, createComponent) survive because codegen gives them setInsertionState(el).
Source: vuejs/core