Browse Source

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

daiwei 6 hours ago
parent
commit
b6d70cd2da

+ 41 - 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,33 @@ 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 > props + child 1`] = `
 "import { template as _template } from 'vue';
 const t0 = _template("<div id=foo><span>", true)

+ 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
+}"
+`;

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

@@ -1151,6 +1151,37 @@ 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 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)

+ 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)
     }

+ 66 - 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,57 @@ function mergePropValues(existing: IRProp, incoming: IRProp) {
 function isComponentTag(tag: string) {
   return tag === 'component' || tag === 'Component'
 }
+
+function hasTemplateContentDirective(node: ElementNode): boolean {
+  return node.props.some(
+    prop =>
+      prop.type === NodeTypes.DIRECTIVE &&
+      (prop.name === 'text' || prop.name === 'html'),
+  )
+}
+
+function hasDynamicTemplateContent(
+  node: RootNode | TemplateChildNode,
+  context: TransformContext,
+): boolean {
+  switch (node.type) {
+    case NodeTypes.INTERPOLATION:
+      return true
+    case NodeTypes.ELEMENT:
+      if (
+        node.tagType === ElementTypes.COMPONENT ||
+        context.options.isCustomElement(node.tag)
+      ) {
+        return true
+      }
+
+      if (node.props.some(prop => prop.type === NodeTypes.DIRECTIVE)) {
+        return true
+      }
+
+      return node.children.some(child =>
+        hasDynamicTemplateContent(child, context),
+      )
+    default:
+      return false
+  }
+}
+
+export function shouldUseCreateElement(
+  node: ElementNode,
+  context: TransformContext<ElementNode>,
+): boolean {
+  if (context.options.isCustomElement(node.tag)) {
+    return true
+  }
+
+  if (node.tagType !== ElementTypes.ELEMENT || node.tag !== 'template') {
+    return false
+  }
+
+  if (hasTemplateContentDirective(node)) {
+    return true
+  }
+
+  return node.children.some(child => hasDynamicTemplateContent(child, context))
+}

+ 61 - 7
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++) {
@@ -88,7 +99,11 @@ export const transformText: NodeTransform = (node, context) => {
       parent.type === NodeTypes.ROOT ||
       (parent.type === NodeTypes.ELEMENT &&
         (parent.tagType === ElementTypes.TEMPLATE ||
-          parent.tagType === ElementTypes.COMPONENT))
+          parent.tagType === ElementTypes.COMPONENT ||
+          shouldUseCreateElement(
+            parent,
+            context.parent as TransformContext<ElementNode>,
+          )))
 
     context.template += isRootText ? node.content : escapeHtml(node.content)
   }
@@ -118,7 +133,11 @@ function processInterpolation(context: TransformContext<InterpolationNode>) {
     const text = literalValues.join('')
     const isElementChild =
       parentNode.type === NodeTypes.ELEMENT &&
-      parentNode.tagType === ElementTypes.ELEMENT
+      parentNode.tagType === ElementTypes.ELEMENT &&
+      !shouldUseCreateElement(
+        parentNode,
+        context.parent as TransformContext<ElementNode>,
+      )
     context.template += isElementChild ? escapeHtml(text) : text
     return
   }
@@ -163,6 +182,41 @@ 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 processTextLikeChildren(nodes: TextLike[], context: TransformContext) {
   const exps: SimpleExpressionNode[] = []
   for (const node of nodes) {

+ 29 - 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,29 @@ 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) {
+    if (useCreateElement) {
+      const 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 +65,11 @@ export const transformVText: DirectiveTransform = (dir, node, context) => {
     }
     context.registerEffect([exp], {
       type: IRNodeTypes.SET_TEXT,
-      element: context.reference(),
+      element: useCreateElement
+        ? context.dynamic.children[node.children.length]!.id!
+        : context.reference(),
       values: [exp],
-      generated: true,
+      generated: !useCreateElement,
       isComponent,
     })
   }

+ 105 - 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,110 @@ 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 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 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)