#15593·Vue.js

Vapor: hydration throws for a custom element or a nested <template> with children

Author: ValentinYoushkevichCreated Sep 21, 2026Updated Sep 24, 2026
Labelsscope: vapor

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:

typescript
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:50

What 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:

javascript
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).