Просмотр исходного кода

fix(compiler-vapor): use createElement path for plain template (#14744)

edison 5 дней назад
Родитель
Сommit
19a870d0f8

+ 11 - 11
packages/compiler-vapor/__tests__/abbreviation.spec.ts

@@ -179,17 +179,17 @@ test('always close tags', () => {
     '<div><textarea></textarea><span>sibling</span></div>',
   )
 
-  // template always needs closing tag unless rightmost
-  checkAbbr(
-    '<div><template></template></div>',
-    '<div><template>',
-    '<div><template></template></div>',
-  )
-  checkAbbr(
-    '<div><template></template><span>sibling</span></div>',
-    '<div><template></template><span>sibling',
-    '<div><template></template><span>sibling</span></div>',
-  )
+  // native <template> now always goes through createElement path
+  // checkAbbr(
+  //   '<div><template></template></div>',
+  //   '<div><template>',
+  //   '<div><template></template></div>',
+  // )
+  // checkAbbr(
+  //   '<div><template></template><span>sibling</span></div>',
+  //   '<div><template></template><span>sibling',
+  //   '<div><template></template><span>sibling</span></div>',
+  // )
 
   // script always needs closing tag unless rightmost
   checkAbbr(

+ 65 - 0
packages/compiler-vapor/__tests__/transforms/__snapshots__/transformElement.spec.ts.snap

@@ -400,6 +400,20 @@ export function render(_ctx) {
 }"
 `;
 
+exports[`compiler: element transform > custom element with dynamic child 1`] = `
+"import { createPlainElement as _createPlainElement, txt as _txt, insert as _insert, toDisplayString as _toDisplayString, setText as _setText, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<div> ")
+
+export function render(_ctx) {
+  const n1 = _createPlainElement("my-custom-element", null, null, true)
+  const n0 = t0()
+  const x0 = _txt(n0)
+  _insert(n0, n1)
+  _renderEffect(() => _setText(x0, _toDisplayString(_ctx.msg)))
+  return n1
+}"
+`;
+
 exports[`compiler: element transform > dynamic component > capitalized version w/ static binding 1`] = `
 "import { resolveDynamicComponent as _resolveDynamicComponent, createComponentWithFallback as _createComponentWithFallback } from 'vue';
 
@@ -519,6 +533,57 @@ export function render(_ctx) {
 }"
 `;
 
+exports[`compiler: element transform > plain template element 1`] = `
+"import { createPlainElement as _createPlainElement, txt as _txt, insert as _insert, toDisplayString as _toDisplayString, setText as _setText, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("<div> ")
+
+export function render(_ctx) {
+  const n1 = _createPlainElement("template", null, null, true)
+  const n0 = t0()
+  const x0 = _txt(n0)
+  _insert(n0, n1)
+  _renderEffect(() => _setText(x0, _toDisplayString(_ctx.msg)))
+  return n1
+}"
+`;
+
+exports[`compiler: element transform > plain template element with dynamic text child 1`] = `
+"import { createPlainElement as _createPlainElement, insert as _insert, toDisplayString as _toDisplayString, setText as _setText, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("")
+
+export function render(_ctx) {
+  const n1 = _createPlainElement("template", null, null, true)
+  const n0 = t0()
+  _insert(n0, n1)
+  _renderEffect(() => _setText(n0, _toDisplayString(_ctx.msg)))
+  return n1
+}"
+`;
+
+exports[`compiler: element transform > plain template element with static child 1`] = `
+"import { createPlainElement as _createPlainElement, insert as _insert, template as _template } from 'vue';
+const t0 = _template("<div>hi")
+
+export function render(_ctx) {
+  const n1 = _createPlainElement("template", null, null, true)
+  const n0 = t0()
+  _insert(n0, n1)
+  return n1
+}"
+`;
+
+exports[`compiler: element transform > plain template element with static text child 1`] = `
+"import { createPlainElement as _createPlainElement, insert as _insert, template as _template } from 'vue';
+const t0 = _template("hello")
+
+export function render(_ctx) {
+  const n1 = _createPlainElement("template", null, null, true)
+  const n0 = t0()
+  _insert(n0, n1)
+  return n1
+}"
+`;
+
 exports[`compiler: element transform > props + child 1`] = `
 "import { template as _template } from 'vue';
 const t0 = _template("<div id=foo><span>", true)

+ 42 - 0
packages/compiler-vapor/__tests__/transforms/__snapshots__/transformText.spec.ts.snap

@@ -21,6 +21,48 @@ export function render(_ctx) {
 }"
 `;
 
+exports[`compiler: text transform > escapes raw static text for custom element createElement path 1`] = `
+"import { createPlainElement as _createPlainElement, setText as _setText, insert as _insert, template as _template } from 'vue';
+const t0 = _template("")
+
+export function render(_ctx) {
+  const n1 = _createPlainElement("my-el", null, null, true)
+  const n0 = t0()
+  _setText(n0, "<b>foo</b>")
+  _insert(n0, n1)
+  return n1
+}"
+`;
+
+exports[`compiler: text transform > escapes raw static text for plain template createElement path 1`] = `
+"import { createPlainElement as _createPlainElement, setText as _setText, insert as _insert, template as _template } from 'vue';
+const t0 = _template("")
+
+export function render(_ctx) {
+  const n1 = _createPlainElement("template", null, null, true)
+  const n0 = t0()
+  _setText(n0, "<b>foo</b>")
+  _insert(n0, n1)
+  return n1
+}"
+`;
+
+exports[`compiler: text transform > materializes literal interpolation text for mixed plain template children 1`] = `
+"import { createPlainElement as _createPlainElement, setText as _setText, insert as _insert, template as _template } from 'vue';
+const t0 = _template("<span></span>")
+const t1 = _template("")
+
+export function render(_ctx) {
+  const n2 = _createPlainElement("template", null, null, true)
+  const n0 = t0()
+  const n1 = t1()
+  _setText(n1, "<b>foo</b>")
+  _insert([n0, n1], n2)
+  _insert([n0, n1], n2)
+  return n2
+}"
+`;
+
 exports[`compiler: text transform > no consecutive text 1`] = `
 "import { setText as _setText, template as _template } from 'vue';
 const t0 = _template(" ")

+ 13 - 0
packages/compiler-vapor/__tests__/transforms/__snapshots__/vText.spec.ts.snap

@@ -54,3 +54,16 @@ export function render(_ctx) {
   return n0
 }"
 `;
+
+exports[`v-text > work with plain template createElement path 1`] = `
+"import { createPlainElement as _createPlainElement, insert as _insert, toDisplayString as _toDisplayString, setText as _setText, renderEffect as _renderEffect, template as _template } from 'vue';
+const t0 = _template("")
+
+export function render(_ctx) {
+  const n1 = _createPlainElement("template", null, null, true)
+  const n0 = t0()
+  _insert(n0, n1)
+  _renderEffect(() => _setText(n0, _toDisplayString(_ctx.foo)))
+  return n1
+}"
+`;

+ 47 - 0
packages/compiler-vapor/__tests__/transforms/transformElement.spec.ts

@@ -1151,6 +1151,53 @@ describe('compiler: element transform', () => {
     expect(code).toContain('createPlainElement')
   })
 
+  test('custom element with dynamic child', () => {
+    const { code } = compileWithElementTransform(
+      '<my-custom-element><div>{{ msg }}</div></my-custom-element>',
+      {
+        isCustomElement: tag => tag === 'my-custom-element',
+      },
+    )
+    expect(code).toMatchSnapshot()
+    expect(code).toContain('createPlainElement')
+    expect(code).toContain('_insert(')
+  })
+
+  test('plain template element', () => {
+    const { code } = compileWithElementTransform(
+      '<template><div>{{ msg }}</div></template>',
+    )
+    expect(code).toMatchSnapshot()
+    expect(code).toContain('createPlainElement')
+    expect(code).not.toContain('_template("<template>')
+  })
+
+  test('plain template element with static child', () => {
+    const { code } = compileWithElementTransform(
+      '<template><div>hi</div></template>',
+    )
+    expect(code).toMatchSnapshot()
+    expect(code).toContain('createPlainElement')
+    expect(code).not.toContain('_template("<template>')
+  })
+
+  test('plain template element with static text child', () => {
+    const { code } = compileWithElementTransform('<template>hello</template>')
+    expect(code).toMatchSnapshot()
+    expect(code).toContain('createPlainElement')
+    expect(code).not.toContain('_template("<template>')
+  })
+
+  test('plain template element with dynamic text child', () => {
+    const { code } = compileWithElementTransform(
+      '<template>{{ msg }}</template>',
+    )
+    expect(code).toMatchSnapshot()
+    expect(code).toContain('createPlainElement')
+    expect(code).toContain('_insert(')
+    expect(code).not.toContain('_txt(n0)')
+  })
+
   test('svg', () => {
     const t = `<svg><circle r="40"></circle></svg>`
     const { code, ir } = compileWithElementTransform(t)

+ 33 - 0
packages/compiler-vapor/__tests__/transforms/transformText.spec.ts

@@ -62,6 +62,39 @@ describe('compiler: text transform', () => {
     expect([...ir.template.keys()]).not.toContain('<code><script>')
   })
 
+  it('escapes raw static text for plain template createElement path', () => {
+    const { code } = compileWithTextTransform(
+      '<template>&lt;b&gt;foo&lt;/b&gt;</template>',
+    )
+    expect(code).toMatchSnapshot()
+    expect(code).toContain('const t0 = _template("")')
+    expect(code).toContain('_setText(n0, "<b>foo</b>")')
+    expect(code).not.toContain('_template("<b>foo</b>")')
+  })
+
+  it('escapes raw static text for custom element createElement path', () => {
+    const { code } = compileWithTextTransform(
+      '<my-el>&lt;b&gt;foo&lt;/b&gt;</my-el>',
+      {
+        isCustomElement: tag => tag === 'my-el',
+      },
+    )
+    expect(code).toMatchSnapshot()
+    expect(code).toContain('const t0 = _template("")')
+    expect(code).toContain('_setText(n0, "<b>foo</b>")')
+    expect(code).not.toContain('_template("<b>foo</b>")')
+  })
+
+  it('materializes literal interpolation text for mixed plain template children', () => {
+    const { code } = compileWithTextTransform(
+      '<template><span></span>{{ "<b>foo</b>" }}</template>',
+    )
+    expect(code).toMatchSnapshot()
+    expect(code).toContain('const t1 = _template("")')
+    expect(code).toContain('_setText(n1, "<b>foo</b>")')
+    expect(code).not.toContain('_template("<b>foo</b>")')
+  })
+
   it('should not escape quotes in root-level text nodes', () => {
     // Root-level text goes through createTextNode() which doesn't need escaping
     const { ir } = compileWithTextTransform(`Howdy y'all`)

+ 8 - 0
packages/compiler-vapor/__tests__/transforms/vText.spec.ts

@@ -70,6 +70,14 @@ describe('v-text', () => {
     expect(code).contains('setBlockText(n0, _toDisplayString(_ctx.foo))')
   })
 
+  test('work with plain template createElement path', () => {
+    const { code } = compileWithVText(`<template v-text="foo"></template>`)
+    expect(code).matchSnapshot()
+    expect(code).toContain('createPlainElement')
+    expect(code).toContain('_insert(')
+    expect(code).not.toContain('_txt(n0)')
+  })
+
   test('should raise error and ignore children when v-text is present', () => {
     const onError = vi.fn()
     const { code, ir } = compileWithVText(`<div v-text="test">hello</div>`, {

+ 2 - 2
packages/compiler-vapor/src/generators/component.ts

@@ -85,7 +85,7 @@ export function genCreateComponent(
     ...genCall(
       operation.dynamic && !operation.dynamic.isStatic
         ? helper('createDynamicComponent')
-        : operation.isCustomElement
+        : operation.useCreateElement
           ? helper('createPlainElement')
           : operation.asset
             ? helper('createComponentWithFallback')
@@ -100,7 +100,7 @@ export function genCreateComponent(
   ]
 
   function genTag() {
-    if (operation.isCustomElement) {
+    if (operation.useCreateElement) {
       return JSON.stringify(operation.tag)
     } else if (operation.dynamic) {
       if (operation.dynamic.isStatic) {

+ 1 - 1
packages/compiler-vapor/src/ir/index.ts

@@ -221,7 +221,7 @@ export interface CreateComponentIRNode extends BaseIRNode {
   root: boolean
   once: boolean
   dynamic?: SimpleExpressionNode
-  isCustomElement: boolean
+  useCreateElement: boolean
   parent?: number
   anchor?: number
   logicalIndex?: number

+ 20 - 0
packages/compiler-vapor/src/transforms/transformChildren.ts

@@ -11,6 +11,7 @@ import {
   type InsertionStateTypes,
   isBlockOperation,
 } from '../ir'
+import { shouldUseCreateElement } from './transformElement'
 
 export const transformChildren: NodeTransform = (node, context) => {
   const isFragment =
@@ -21,6 +22,10 @@ export const transformChildren: NodeTransform = (node, context) => {
 
   if (!isFragment && node.type !== NodeTypes.ELEMENT) return
 
+  const useCreateElement =
+    node.type === NodeTypes.ELEMENT &&
+    shouldUseCreateElement(node, context as TransformContext<ElementNode>)
+
   for (const [i, child] of node.children.entries()) {
     const childContext = context.create(child, i)
     transformNode(childContext)
@@ -37,6 +42,21 @@ export const transformChildren: NodeTransform = (node, context) => {
       ) {
         context.block.returns.push(childContext.dynamic.id!)
       }
+    } else if (useCreateElement) {
+      const createsNode =
+        childContext.template !== '' ||
+        childDynamic.template != null ||
+        childDynamic.id !== undefined ||
+        childDynamic.operation !== undefined ||
+        childDynamic.hasDynamicChild === true
+
+      if (createsNode) {
+        // createElement-backed parents don't materialize childNodes from a
+        // static HTML string, so every real child node must be inserted.
+        childContext.reference()
+        childContext.registerTemplate()
+        childDynamic.flags |= DynamicFlag.INSERT | DynamicFlag.NON_TEMPLATE
+      }
     } else {
       context.childrenTemplate.push(childContext.template)
     }

+ 22 - 7
packages/compiler-vapor/src/transforms/transformElement.ts

@@ -88,10 +88,15 @@ export const transformElement: NodeTransform = (node, context) => {
       return
 
     // treat custom elements as components because the template helper cannot
-    // resolve them properly; they require creation via createElement
-    const isCustomElement = !!context.options.isCustomElement(node.tag)
+    // resolve them properly; they require creation via createElement.
+    // Native <template> has the same constraint: when created from an HTML
+    // string, its parsed children live in .content instead of childNodes.
+    const useCreateElement = shouldUseCreateElement(
+      node,
+      context as TransformContext<ElementNode>,
+    )
     const isComponent =
-      node.tagType === ElementTypes.COMPONENT || isCustomElement
+      node.tagType === ElementTypes.COMPONENT || useCreateElement
 
     const isDynamicComponent = isComponentTag(node.tag)
     const staticKey = resolveStaticKey(
@@ -118,7 +123,7 @@ export const transformElement: NodeTransform = (node, context) => {
         singleRoot,
         context,
         isDynamicComponent,
-        isCustomElement,
+        useCreateElement,
       )
     } else {
       transformNativeElement(
@@ -217,7 +222,7 @@ function transformComponentElement(
   singleRoot: boolean,
   context: TransformContext,
   isDynamicComponent: boolean,
-  isCustomElement: boolean,
+  useCreateElement: boolean,
 ) {
   const dynamicComponent = isDynamicComponent
     ? resolveDynamicComponent(node)
@@ -226,7 +231,7 @@ function transformComponentElement(
   let { tag } = node
   let asset = true
 
-  if (!dynamicComponent && !isCustomElement) {
+  if (!dynamicComponent && !useCreateElement) {
     const fromSetup = resolveSetupReference(tag, context)
     if (fromSetup) {
       tag = fromSetup
@@ -272,7 +277,7 @@ function transformComponentElement(
     slots: [...context.slots],
     once: context.inVOnce,
     dynamic: dynamicComponent,
-    isCustomElement,
+    useCreateElement,
   }
   if (staticKey) {
     context.registerOperation(createSetBlockKey(id, staticKey))
@@ -659,3 +664,13 @@ function mergePropValues(existing: IRProp, incoming: IRProp) {
 function isComponentTag(tag: string) {
   return tag === 'component' || tag === 'Component'
 }
+
+export function shouldUseCreateElement(
+  node: ElementNode,
+  context: TransformContext<ElementNode>,
+): boolean {
+  return (
+    context.options.isCustomElement(node.tag) ||
+    (node.tagType === ElementTypes.ELEMENT && node.tag === 'template')
+  )
+}

+ 93 - 5
packages/compiler-vapor/src/transforms/transformText.ts

@@ -13,6 +13,7 @@ import type { NodeTransform, TransformContext } from '../transform'
 import { DynamicFlag, IRNodeTypes } from '../ir'
 import { getLiteralExpressionValue } from '../utils'
 import { escapeHtml } from '@vue/shared'
+import { shouldUseCreateElement } from './transformElement'
 
 type TextLike = TextNode | InterpolationNode
 const seen = new WeakMap<
@@ -24,7 +25,12 @@ export function markNonTemplate(
   node: TemplateChildNode,
   context: TransformContext,
 ): void {
-  seen.get(context.root)!.add(node)
+  let seenNodes = seen.get(context.root)
+  if (!seenNodes) {
+    seenNodes = new WeakSet()
+    seen.set(context.root, seenNodes)
+  }
+  seenNodes.add(node)
 }
 
 export const transformText: NodeTransform = (node, context) => {
@@ -57,10 +63,15 @@ export const transformText: NodeTransform = (node, context) => {
     }
     // all text like with interpolation
     if (!isFragment && isAllTextLike && hasInterp) {
-      processTextContainer(
-        node.children as TextLike[],
-        context as TransformContext<ElementNode>,
-      )
+      const elementContext = context as TransformContext<ElementNode>
+      if (shouldUseCreateElement(node, elementContext)) {
+        processCreateElementTextContainer(
+          node.children as TextLike[],
+          elementContext,
+        )
+      } else {
+        processTextContainer(node.children as TextLike[], elementContext)
+      }
     } else if (hasInterp) {
       // check if there's any text before interpolation, it needs to be merged
       for (let i = 0; i < node.children.length; i++) {
@@ -83,6 +94,20 @@ export const transformText: NodeTransform = (node, context) => {
     // Root-level text nodes go through createTextNode() which doesn't need escaping
     // Element children go through innerHTML which needs escaping
     const parent = context.parent?.node
+    const createElementParent =
+      parent &&
+      parent.type === NodeTypes.ELEMENT &&
+      shouldUseCreateElement(
+        parent,
+        context.parent as TransformContext<ElementNode>,
+      )
+    if (createElementParent && node.content[0] === '<') {
+      materializeLiteralTextNode(
+        createSimpleExpression(node.content, true, node.loc),
+        context as TransformContext<TextNode>,
+      )
+      return
+    }
     const isRootText =
       !parent ||
       parent.type === NodeTypes.ROOT ||
@@ -116,6 +141,20 @@ function processInterpolation(context: TransformContext<InterpolationNode>) {
   const allLiteral = literalValues.every(v => v != null)
   if (allLiteral && parentNode.type !== NodeTypes.ROOT) {
     const text = literalValues.join('')
+    if (
+      parentNode.type === NodeTypes.ELEMENT &&
+      shouldUseCreateElement(
+        parentNode,
+        context.parent as TransformContext<ElementNode>,
+      ) &&
+      text[0] === '<'
+    ) {
+      materializeLiteralTextNode(
+        createSimpleExpression(text, true, context.node.loc),
+        context,
+      )
+      return
+    }
     const isElementChild =
       parentNode.type === NodeTypes.ELEMENT &&
       parentNode.tagType === ElementTypes.ELEMENT
@@ -163,6 +202,55 @@ function processTextContainer(
   }
 }
 
+export function registerSyntheticTextChild(
+  context: TransformContext<ElementNode>,
+  template: string,
+  values?: SimpleExpressionNode[],
+): number {
+  const id = context.increaseId()
+  context.dynamic.children[context.node.children.length] = {
+    id,
+    flags: DynamicFlag.INSERT | DynamicFlag.NON_TEMPLATE,
+    children: [],
+    template: context.pushTemplate(template),
+  }
+  context.dynamic.hasDynamicChild = true
+
+  if (values && values.length) {
+    context.registerEffect(values, {
+      type: IRNodeTypes.SET_TEXT,
+      element: id,
+      values,
+    })
+  }
+
+  return id
+}
+
+function processCreateElementTextContainer(
+  children: TextLike[],
+  context: TransformContext<ElementNode>,
+) {
+  const values = processTextLikeChildren(children, context)
+  // createElement-backed parents must materialize text nodes imperatively so
+  // text that starts with "<" remains text instead of being parsed as HTML.
+  registerSyntheticTextChild(context, '', values)
+}
+
+function materializeLiteralTextNode(
+  value: SimpleExpressionNode,
+  context: TransformContext<TextNode | InterpolationNode>,
+) {
+  const id = context.reference()
+  context.dynamic.flags |= DynamicFlag.INSERT | DynamicFlag.NON_TEMPLATE
+  context.dynamic.template = context.pushTemplate('')
+  context.registerEffect([value], {
+    type: IRNodeTypes.SET_TEXT,
+    element: id,
+    values: [value],
+  })
+}
+
 function processTextLikeChildren(nodes: TextLike[], context: TransformContext) {
   const exps: SimpleExpressionNode[] = []
   for (const node of nodes) {

+ 28 - 5
packages/compiler-vapor/src/transforms/vText.ts

@@ -8,6 +8,8 @@ import { EMPTY_EXPRESSION } from './utils'
 import type { DirectiveTransform } from '../transform'
 import { getLiteralExpressionValue } from '../utils'
 import { isVoidTag } from '../../../shared/src'
+import { markNonTemplate, registerSyntheticTextChild } from './transformText'
+import { shouldUseCreateElement } from './transformElement'
 
 export const transformVText: DirectiveTransform = (dir, node, context) => {
   let { exp, loc } = dir
@@ -22,6 +24,9 @@ export const transformVText: DirectiveTransform = (dir, node, context) => {
       createDOMCompilerError(DOMErrorCodes.X_V_TEXT_WITH_CHILDREN, loc),
     )
     context.childrenTemplate.length = 0
+    for (const child of node.children) {
+      markNonTemplate(child, context)
+    }
   }
 
   // v-text on void tags do nothing
@@ -30,12 +35,30 @@ export const transformVText: DirectiveTransform = (dir, node, context) => {
   }
 
   const literal = getLiteralExpressionValue(exp)
+  const useCreateElement = shouldUseCreateElement(context.node, context)
   if (literal != null) {
-    context.childrenTemplate = [String(literal)]
+    if (useCreateElement) {
+      const id = registerSyntheticTextChild(context, '', [exp])
+      context.registerOperation({
+        type: IRNodeTypes.INSERT_NODE,
+        elements: [id],
+        parent: context.reference(),
+      })
+    } else {
+      context.childrenTemplate = [String(literal)]
+    }
   } else {
-    context.childrenTemplate = [' ']
     const isComponent = node.tagType === ElementTypes.COMPONENT
-    if (!isComponent) {
+    let id: number | undefined
+    if (useCreateElement) {
+      id = registerSyntheticTextChild(context, '')
+      context.registerOperation({
+        type: IRNodeTypes.INSERT_NODE,
+        elements: [id],
+        parent: context.reference(),
+      })
+    } else if (!isComponent) {
+      context.childrenTemplate = [' ']
       context.registerOperation({
         type: IRNodeTypes.GET_TEXT_CHILD,
         parent: context.reference(),
@@ -43,9 +66,9 @@ export const transformVText: DirectiveTransform = (dir, node, context) => {
     }
     context.registerEffect([exp], {
       type: IRNodeTypes.SET_TEXT,
-      element: context.reference(),
+      element: useCreateElement ? id! : context.reference(),
       values: [exp],
-      generated: true,
+      generated: !useCreateElement,
       isComponent,
     })
   }

+ 161 - 1
packages/runtime-vapor/__tests__/component.spec.ts

@@ -24,7 +24,7 @@ import {
   template,
   txt,
 } from '../src'
-import { compileToVaporRender, makeRender } from './_utils'
+import { compile, compileToVaporRender, makeRender } from './_utils'
 import type { VaporComponentInstance } from '../src/component'
 import { setElementText, setText } from '../src/dom/prop'
 
@@ -588,6 +588,166 @@ describe('component', () => {
     ).not.toHaveBeenWarned()
   })
 
+  it('mounts plain template elements with dynamic descendants', async () => {
+    const msg = ref('12')
+    const Comp = compile(
+      `<script setup vapor>
+        const msg = _data
+      </script>
+      <template>
+        <template>
+          <div>{{ msg }}</div>
+        </template>
+      </template>`,
+      msg,
+    )
+
+    const { host } = define(Comp).render()
+    const templateEl = host.firstChild as HTMLTemplateElement
+    expect(templateEl.tagName).toBe('TEMPLATE')
+
+    const div = templateEl.firstChild as HTMLDivElement
+    expect(div.tagName).toBe('DIV')
+    expect(div.textContent).toBe('12')
+
+    msg.value = '34'
+    await nextTick()
+    expect(div.textContent).toBe('34')
+  })
+
+  it('mounts plain template elements with dynamic text children', async () => {
+    const msg = ref('12')
+    const Comp = compile(
+      `<script setup vapor>
+        const msg = _data
+      </script>
+      <template>
+        <template>
+          {{ msg }}
+        </template>
+      </template>`,
+      msg,
+    )
+
+    const { host } = define(Comp).render()
+    const templateEl = host.firstChild as HTMLTemplateElement
+    expect(templateEl.tagName).toBe('TEMPLATE')
+    expect(templateEl.firstChild!.nodeType).toBe(Node.TEXT_NODE)
+    expect(templateEl.textContent).toBe('12')
+
+    msg.value = '34'
+    await nextTick()
+    expect(templateEl.textContent).toBe('34')
+  })
+
+  it('mounts plain template literal interpolation as text', () => {
+    const Comp = compile(
+      `<template>
+        <template>{{ "<b>foo</b>" }}</template>
+      </template>`,
+      ref('unused'),
+    )
+
+    const { host } = define(Comp).render()
+    const templateEl = host.firstChild as HTMLTemplateElement
+    expect(templateEl.tagName).toBe('TEMPLATE')
+    expect(templateEl.firstChild!.nodeType).toBe(Node.TEXT_NODE)
+    expect(templateEl.textContent).toBe('<b>foo</b>')
+  })
+
+  it('mounts plain template escaped static text as text', () => {
+    const Comp = compile(
+      `<template>
+        <template>&lt;b&gt;foo&lt;/b&gt;</template>
+      </template>`,
+      ref('unused'),
+    )
+
+    const { host } = define(Comp).render()
+    const templateEl = host.firstChild as HTMLTemplateElement
+    expect(templateEl.tagName).toBe('TEMPLATE')
+    expect(templateEl.firstChild!.nodeType).toBe(Node.TEXT_NODE)
+    expect(templateEl.textContent).toBe('<b>foo</b>')
+  })
+
+  it('mounts plain template literal v-text as text', () => {
+    const Comp = compile(
+      `<template>
+        <template v-text="'<b>foo</b>'"></template>
+      </template>`,
+      ref('unused'),
+    )
+
+    const { host } = define(Comp).render()
+    const templateEl = host.firstChild as HTMLTemplateElement
+    expect(templateEl.tagName).toBe('TEMPLATE')
+    expect(templateEl.firstChild!.nodeType).toBe(Node.TEXT_NODE)
+    expect(templateEl.textContent).toBe('<b>foo</b>')
+  })
+
+  it('mounts plain template elements with slot content', () => {
+    const data = ref('unused')
+    const Child = compile(
+      `<template>
+        <template><slot /></template>
+      </template>`,
+      data,
+    )
+    const Parent = compile(
+      `<template><components.Child><div>slot child</div></components.Child></template>`,
+      data,
+      { Child },
+    )
+
+    const { host } = define(Parent).render()
+    const templateEl = host.firstChild as HTMLTemplateElement
+    expect(templateEl.tagName).toBe('TEMPLATE')
+    expect(templateEl.firstChild).toBeInstanceOf(HTMLDivElement)
+    expect(templateEl.firstChild!.textContent).toBe('slot child')
+  })
+
+  it('mounts plain template elements with v-html', () => {
+    const msg = ref('<b>foo</b>')
+    const Comp = compile(
+      `<script setup vapor>
+        const msg = _data
+      </script>
+      <template>
+        <template v-html="msg"></template>
+      </template>`,
+      msg,
+    )
+
+    const { host } = define(Comp).render()
+    const templateEl = host.firstChild as HTMLTemplateElement
+    expect(templateEl.tagName).toBe('TEMPLATE')
+    expect(templateEl.innerHTML.toLowerCase()).toBe('<b>foo</b>')
+    expect(templateEl.content.firstChild).toBeInstanceOf(HTMLElement)
+    expect((templateEl.content.firstChild as HTMLElement).tagName).toBe('B')
+  })
+
+  it('mounts plain template elements with v-text', async () => {
+    const msg = ref('12')
+    const Comp = compile(
+      `<script setup vapor>
+        const msg = _data
+      </script>
+      <template>
+        <template v-text="msg"></template>
+      </template>`,
+      msg,
+    )
+
+    const { host } = define(Comp).render()
+    const templateEl = host.firstChild as HTMLTemplateElement
+    expect(templateEl.tagName).toBe('TEMPLATE')
+    expect(templateEl.textContent).toBe('12')
+
+    msg.value = '34'
+    await nextTick()
+    expect(templateEl.textContent).toBe('34')
+  })
+
   it('should invalidate pending mounted hooks when unmounted before flush', async () => {
     const mountedSpy = vi.fn()
     const show = ref(false)