]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat: attrs fallthrough (#14144)
authoredison <daiwei521@126.com>
Mon, 1 Dec 2025 01:13:49 +0000 (09:13 +0800)
committerGitHub <noreply@github.com>
Mon, 1 Dec 2025 01:13:49 +0000 (09:13 +0800)
packages/runtime-core/src/componentRenderUtils.ts
packages/runtime-core/src/index.ts
packages/runtime-vapor/__tests__/componentAttrs.spec.ts
packages/runtime-vapor/__tests__/customElement.spec.ts
packages/runtime-vapor/src/apiCreateFor.ts
packages/runtime-vapor/src/component.ts
packages/runtime-vapor/src/components/Teleport.ts
packages/runtime-vapor/src/components/Transition.ts
packages/runtime-vapor/src/components/TransitionGroup.ts
packages/runtime-vapor/src/dom/event.ts
packages/runtime-vapor/src/fragment.ts

index a62b5cf4a82a37a4f726f750c6baa363df3ca9c2..d0c15a59d5dc7a1a85c2bff084de41b0dd239d63 100644 (file)
@@ -169,40 +169,7 @@ export function renderComponentRoot(
         }
         root = cloneVNode(root, fallthroughAttrs, false, true)
       } else if (__DEV__ && !accessedAttrs && root.type !== Comment) {
-        const allAttrs = Object.keys(attrs)
-        const eventAttrs: string[] = []
-        const extraAttrs: string[] = []
-        for (let i = 0, l = allAttrs.length; i < l; i++) {
-          const key = allAttrs[i]
-          if (isOn(key)) {
-            // ignore v-model handlers when they fail to fallthrough
-            if (!isModelListener(key)) {
-              // remove `on`, lowercase first letter to reflect event casing
-              // accurately
-              eventAttrs.push(key[2].toLowerCase() + key.slice(3))
-            }
-          } else {
-            extraAttrs.push(key)
-          }
-        }
-        if (extraAttrs.length) {
-          warn(
-            `Extraneous non-props attributes (` +
-              `${extraAttrs.join(', ')}) ` +
-              `were passed to component but could not be automatically inherited ` +
-              `because component renders fragment or text or teleport root nodes.`,
-          )
-        }
-        if (eventAttrs.length) {
-          warn(
-            `Extraneous non-emits event listeners (` +
-              `${eventAttrs.join(', ')}) ` +
-              `were passed to component but could not be automatically inherited ` +
-              `because component renders fragment or text root nodes. ` +
-              `If the listener is intended to be a component custom event listener only, ` +
-              `declare it using the "emits" option.`,
-          )
-        }
+        warnExtraneousAttributes(attrs)
       }
     }
   }
@@ -302,6 +269,46 @@ const getChildRoot = (vnode: VNode): [VNode, SetRootFn] => {
   return [normalizeVNode(childRoot), setRoot]
 }
 
+/**
+ * Dev only
+ */
+export function warnExtraneousAttributes(attrs: Record<string, any>): void {
+  const allAttrs = Object.keys(attrs)
+  const eventAttrs: string[] = []
+  const extraAttrs: string[] = []
+  for (let i = 0, l = allAttrs.length; i < l; i++) {
+    const key = allAttrs[i]
+    if (isOn(key)) {
+      // ignore v-model handlers when they fail to fallthrough
+      if (!isModelListener(key)) {
+        // remove `on`, lowercase first letter to reflect event casing
+        // accurately
+        eventAttrs.push(key[2].toLowerCase() + key.slice(3))
+      }
+    } else {
+      extraAttrs.push(key)
+    }
+  }
+  if (extraAttrs.length) {
+    warn(
+      `Extraneous non-props attributes (` +
+        `${extraAttrs.join(', ')}) ` +
+        `were passed to component but could not be automatically inherited ` +
+        `because component renders fragment or text or teleport root nodes.`,
+    )
+  }
+  if (eventAttrs.length) {
+    warn(
+      `Extraneous non-emits event listeners (` +
+        `${eventAttrs.join(', ')}) ` +
+        `were passed to component but could not be automatically inherited ` +
+        `because component renders fragment or text root nodes. ` +
+        `If the listener is intended to be a component custom event listener only, ` +
+        `declare it using the "emits" option.`,
+    )
+  }
+}
+
 export function filterSingleRoot(
   children: VNodeArrayChildren,
   recurse = true,
@@ -334,7 +341,7 @@ export function filterSingleRoot(
   return singleRoot
 }
 
-const getFunctionalFallthrough = (attrs: Data): Data | undefined => {
+export const getFunctionalFallthrough = (attrs: Data): Data | undefined => {
   let res: Data | undefined
   for (const key in attrs) {
     if (key === 'class' || key === 'style' || isOn(key)) {
index 6c968905a6700cf7c818216062d3108934e9a1ab..58f6f40b9f0190c0a082531d228ef1c1dc1dfb73 100644 (file)
@@ -674,3 +674,11 @@ export {
  * @internal
  */
 export type { GenericComponent } from './component'
+
+/**
+ * @internal
+ */
+export {
+  warnExtraneousAttributes,
+  getFunctionalFallthrough,
+} from './componentRenderUtils'
index 7fd99b88fadf162914b8206989c5dc551935cbcb..d82837ffa7e8155c7ccc9e57de6849474dcd3df3 100644 (file)
@@ -1,13 +1,22 @@
-import { type Ref, nextTick, ref } from '@vue/runtime-dom'
 import {
+  type Ref,
+  nextTick,
+  onUpdated,
+  ref,
+  withModifiers,
+} from '@vue/runtime-dom'
+import {
+  VaporTeleport,
   createComponent,
   createDynamicComponent,
   createIf,
   createSlot,
   defineVaporComponent,
+  delegateEvents,
   renderEffect,
   setClass,
   setDynamicProps,
+  setInsertionState,
   setProp,
   setStyle,
   template,
@@ -18,8 +27,7 @@ import { stringifyStyle } from '@vue/shared'
 import { setElementText } from '../src/dom/prop'
 
 const define = makeRender<any>()
-
-// TODO: port more tests from rendererAttrsFallthrough.spec.ts
+delegateEvents('click')
 
 describe('attribute fallthrough', () => {
   it('should allow attrs to fallthrough', async () => {
@@ -59,6 +67,662 @@ describe('attribute fallthrough', () => {
     expect(host.innerHTML).toBe('<div id="b">2</div>')
   })
 
+  it('should only allow whitelisted fallthrough on functional component with optional props', async () => {
+    const click = vi.fn()
+    const childUpdated = vi.fn()
+
+    const count = ref(0)
+
+    function inc() {
+      count.value++
+      click()
+    }
+
+    const Hello = () =>
+      createComponent(Child, {
+        foo: () => count.value + 1,
+        id: () => 'test',
+        class: () => 'c' + count.value,
+        style: () => ({
+          color: count.value ? 'red' : 'green',
+        }),
+        onClick: () => inc,
+      })
+
+    const { component: Child } = define((props: any) => {
+      childUpdated()
+      const n0 = template(
+        '<div class="c2" style="font-weight: bold"></div>',
+        true,
+      )() as Element
+      renderEffect(() => setElementText(n0, props.foo))
+      return n0
+    })
+
+    const { host } = define(Hello).render()
+    expect(host.innerHTML).toBe(
+      '<div class="c2 c0" style="font-weight: bold; color: green;">1</div>',
+    )
+
+    const node = host.children[0] as HTMLElement
+
+    // not whitelisted
+    expect(node.getAttribute('id')).toBe(null)
+    expect(node.getAttribute('foo')).toBe(null)
+
+    // whitelisted: style, class, event listeners
+    expect(node.getAttribute('class')).toBe('c2 c0')
+    expect(node.style.color).toBe('green')
+    expect(node.style.fontWeight).toBe('bold')
+    node.dispatchEvent(new CustomEvent('click'))
+    expect(click).toHaveBeenCalled()
+
+    await nextTick()
+    expect(childUpdated).toHaveBeenCalled()
+    expect(node.getAttribute('id')).toBe(null)
+    expect(node.getAttribute('foo')).toBe(null)
+    expect(node.getAttribute('class')).toBe('c2 c1')
+    expect(node.style.color).toBe('red')
+    expect(node.style.fontWeight).toBe('bold')
+  })
+
+  it('should allow all attrs on functional component with declared props', async () => {
+    const click = vi.fn()
+    const childUpdated = vi.fn()
+    const count = ref(0)
+
+    function inc() {
+      count.value++
+      click()
+    }
+
+    const Hello = () =>
+      createComponent(Child, {
+        foo: () => count.value + 1,
+        id: () => 'test',
+        class: () => 'c' + count.value,
+        style: () => ({ color: count.value ? 'red' : 'green' }),
+        onClick: () => inc,
+      })
+
+    const Child = defineVaporComponent((props: any) => {
+      childUpdated()
+      const n0 = template(
+        '<div class="c2" style="font-weight: bold"></div>',
+        true,
+      )() as Element
+      renderEffect(() => setElementText(n0, props.foo))
+      return n0
+    })
+
+    Child.props = ['foo']
+
+    const { host } = define(Hello).render()
+    const node = host.children[0] as HTMLElement
+
+    expect(node.getAttribute('id')).toBe('test')
+    expect(node.getAttribute('foo')).toBe(null) // declared as prop
+    expect(node.getAttribute('class')).toBe('c2 c0')
+    expect(node.style.color).toBe('green')
+    expect(node.style.fontWeight).toBe('bold')
+    node.dispatchEvent(new CustomEvent('click'))
+    expect(click).toHaveBeenCalled()
+
+    await nextTick()
+    expect(childUpdated).toHaveBeenCalled()
+    expect(node.getAttribute('id')).toBe('test')
+    expect(node.getAttribute('foo')).toBe(null)
+    expect(node.getAttribute('class')).toBe('c2 c1')
+    expect(node.style.color).toBe('red')
+    expect(node.style.fontWeight).toBe('bold')
+  })
+
+  it('should fallthrough for nested components', async () => {
+    const click = vi.fn()
+    const childUpdated = vi.fn()
+    const grandChildUpdated = vi.fn()
+
+    const Hello = {
+      setup() {
+        const count = ref(0)
+
+        function inc() {
+          count.value++
+          click()
+        }
+
+        return createComponent(Child, {
+          foo: () => count.value + 1,
+          id: () => 'test',
+          class: () => 'c' + count.value,
+          style: () => ({
+            color: count.value ? 'red' : 'green',
+          }),
+          onClick: () => inc,
+        })
+      },
+    }
+
+    const Child = defineVaporComponent({
+      setup(props: any) {
+        onUpdated(childUpdated)
+        // HOC simply passing props down.
+        // this will result in merging the same attrs, but should be deduped by
+        // `mergeProps`.
+        return createComponent(GrandChild, props, null, true)
+      },
+    })
+
+    const GrandChild = defineVaporComponent({
+      props: {
+        id: String,
+        foo: Number,
+      },
+      setup(props) {
+        onUpdated(grandChildUpdated)
+        const n0 = template(
+          '<div class="c2" style="font-weight: bold"></div>',
+          true,
+        )() as Element
+        renderEffect(() => {
+          setProp(n0, 'id', props.id)
+          setElementText(n0, props.foo)
+        })
+        return n0
+      },
+    })
+
+    const { host } = define(Hello).render()
+    expect(host.innerHTML).toBe(
+      '<div class="c2 c0" style="font-weight: bold; color: green;" id="test">1</div>',
+    )
+
+    const node = host.children[0] as HTMLElement
+
+    // with declared props, any parent attr that isn't a prop falls through
+    expect(node.getAttribute('id')).toBe('test')
+    expect(node.getAttribute('class')).toBe('c2 c0')
+    expect(node.style.color).toBe('green')
+    expect(node.style.fontWeight).toBe('bold')
+    node.dispatchEvent(new CustomEvent('click'))
+    expect(click).toHaveBeenCalled()
+
+    // ...while declared ones remain props
+    expect(node.hasAttribute('foo')).toBe(false)
+
+    await nextTick()
+    // child should not update, due to it not accessing props
+    // this is a optimization in vapor mode
+    expect(childUpdated).not.toHaveBeenCalled()
+    expect(grandChildUpdated).toHaveBeenCalled()
+    expect(node.getAttribute('id')).toBe('test')
+    expect(node.getAttribute('class')).toBe('c2 c1')
+    expect(node.style.color).toBe('red')
+    expect(node.style.fontWeight).toBe('bold')
+
+    expect(node.hasAttribute('foo')).toBe(false)
+  })
+
+  it('should not fallthrough with inheritAttrs: false', () => {
+    const Parent = defineVaporComponent({
+      setup() {
+        return createComponent(Child, { foo: () => 1, class: () => 'parent' })
+      },
+    })
+
+    const Child = defineVaporComponent({
+      props: ['foo'],
+      inheritAttrs: false,
+      setup(props) {
+        const n0 = template('<div></div>', true)() as Element
+        renderEffect(() => setElementText(n0, props.foo))
+        return n0
+      },
+    })
+
+    const { html } = define(Parent).render()
+
+    // should not contain class
+    expect(html()).toMatch(`<div>1</div>`)
+  })
+
+  it('explicit spreading with inheritAttrs: false', () => {
+    const Parent = defineVaporComponent({
+      setup() {
+        return createComponent(Child, { foo: () => 1, class: () => 'parent' })
+      },
+    })
+
+    const Child = defineVaporComponent({
+      props: ['foo'],
+      inheritAttrs: false,
+      setup(props, { attrs }) {
+        const n0 = template('<div>', true)() as Element
+        renderEffect(() => {
+          setElementText(n0, props.foo)
+          setDynamicProps(n0, [{ class: 'child' }, attrs])
+        })
+        return n0
+      },
+    })
+
+    const { html } = define(Parent).render()
+
+    // should merge parent/child classes
+    expect(html()).toMatch(`<div class="child parent">1</div>`)
+  })
+
+  it('should warn when fallthrough fails on non-single-root', () => {
+    const Parent = {
+      setup() {
+        return createComponent(Child, {
+          foo: () => 1,
+          class: () => 'parent',
+          onBar: () => () => {},
+        })
+      },
+    }
+
+    const Child = defineVaporComponent({
+      props: ['foo'],
+      render() {
+        return [template('<div></div>')(), template('<div></div>')()]
+      },
+    })
+
+    define(Parent).render()
+
+    expect(`Extraneous non-props attributes (class)`).toHaveBeenWarned()
+    expect(`Extraneous non-emits event listeners`).toHaveBeenWarned()
+  })
+
+  it('should warn when fallthrough fails on teleport root node', () => {
+    const Parent = {
+      render() {
+        return createComponent(Child, { class: () => 'parent' })
+      },
+    }
+
+    const target = document.createElement('div')
+    const Child = defineVaporComponent({
+      render() {
+        return createComponent(
+          VaporTeleport,
+          { to: () => target },
+          {
+            default: () => template('<div></div>')(),
+          },
+        )
+      },
+    })
+
+    document.body.appendChild(target)
+    define(Parent).render()
+
+    expect(`Extraneous non-props attributes (class)`).toHaveBeenWarned()
+  })
+
+  it('should dedupe same listeners when $attrs is used during render', () => {
+    const click = vi.fn()
+    const count = ref(0)
+
+    function inc() {
+      count.value++
+      click()
+    }
+
+    const Parent = {
+      render() {
+        return createComponent(Child, { onClick: () => inc })
+      },
+    }
+
+    const Child = defineVaporComponent({
+      setup(_, { attrs }) {
+        const n0 = template('<div></div>', true)() as any
+        n0.$evtclick = withModifiers(() => {}, ['prevent', 'stop'])
+        renderEffect(() => setDynamicProps(n0, [attrs]))
+        return n0
+      },
+    })
+
+    const { host } = define(Parent).render()
+    const node = host.children[0] as HTMLElement
+    node.dispatchEvent(new CustomEvent('click'))
+    expect(click).toHaveBeenCalledTimes(1)
+    expect(count.value).toBe(1)
+  })
+
+  it('should not warn when context.attrs is used during render', () => {
+    const Parent = {
+      render() {
+        return createComponent(Child, {
+          foo: () => 1,
+          class: () => 'parent',
+          onBar: () => () => {},
+        })
+      },
+    }
+
+    const Child = defineVaporComponent({
+      props: ['foo'],
+      render(_ctx, $props, $emit, $attrs, $slots) {
+        const n0 = template('<div></div>')() as Element
+        const n1 = template('<div></div>')() as Element
+        renderEffect(() => {
+          setDynamicProps(n1, [$attrs])
+        })
+        return [n0, n1]
+      },
+    })
+
+    const { html } = define(Parent).render()
+
+    expect(`Extraneous non-props attributes`).not.toHaveBeenWarned()
+    expect(`Extraneous non-emits event listeners`).not.toHaveBeenWarned()
+
+    expect(html()).toBe(`<div></div><div class="parent"></div>`)
+  })
+
+  it('should not warn when context.attrs is used during render (functional)', () => {
+    const Parent = {
+      render() {
+        return createComponent(Child, {
+          foo: () => 1,
+          class: () => 'parent',
+          onBar: () => () => {},
+        })
+      },
+    }
+
+    const { component: Child } = define((_: any, { attrs }: any) => {
+      const n0 = template('<div></div>')() as Element
+      const n1 = template('<div></div>')() as Element
+      renderEffect(() => {
+        setDynamicProps(n1, [attrs])
+      })
+      return [n0, n1]
+    })
+
+    Child.props = ['foo']
+
+    const { html } = define(Parent).render()
+
+    expect(`Extraneous non-props attributes`).not.toHaveBeenWarned()
+    expect(`Extraneous non-emits event listeners`).not.toHaveBeenWarned()
+    expect(html()).toBe(`<div></div><div class="parent"></div>`)
+  })
+
+  it('should not warn when functional component has optional props', () => {
+    const Parent = {
+      render() {
+        return createComponent(Child, {
+          foo: () => 1,
+          class: () => 'parent',
+          onBar: () => () => {},
+        })
+      },
+    }
+
+    const { component: Child } = define((props: any) => {
+      const n0 = template('<div></div>')() as Element
+      const n1 = template('<div></div>')() as Element
+      renderEffect(() => {
+        setClass(n1, props.class)
+      })
+      return [n0, n1]
+    })
+
+    const { html } = define(Parent).render()
+
+    expect(`Extraneous non-props attributes`).not.toHaveBeenWarned()
+    expect(`Extraneous non-emits event listeners`).not.toHaveBeenWarned()
+    expect(html()).toBe(`<div></div><div class="parent"></div>`)
+  })
+
+  it('should warn when functional component has props and does not use attrs', () => {
+    const Parent = {
+      render() {
+        return createComponent(Child, {
+          foo: () => 1,
+          class: () => 'parent',
+          onBar: () => () => {},
+        })
+      },
+    }
+
+    const { component: Child } = define(() => [
+      template('<div></div>')(),
+      template('<div></div>')(),
+    ])
+
+    Child.props = ['foo']
+
+    const { html } = define(Parent).render()
+
+    expect(`Extraneous non-props attributes`).toHaveBeenWarned()
+    expect(`Extraneous non-emits event listeners`).toHaveBeenWarned()
+    expect(html()).toBe(`<div></div><div></div>`)
+  })
+
+  it('should not let listener fallthrough when declared in emits (stateful)', () => {
+    const Child = defineVaporComponent({
+      emits: ['click'],
+      render(_ctx, $props, $emit, $attrs, $slots) {
+        const n0 = template('<button>hello</button>')() as any
+        n0.$evtclick = () => {
+          // @ts-expect-error
+          $emit('click', 'custom')
+        }
+        return n0
+      },
+    })
+
+    const onClick = vi.fn()
+    const App = defineVaporComponent({
+      render() {
+        return createComponent(
+          Child,
+          {
+            onClick: () => onClick,
+          },
+          null,
+          true,
+        )
+      },
+    })
+
+    const { host } = define(App).render()
+    const node = host.children[0] as HTMLElement
+    node.click()
+    expect(onClick).toHaveBeenCalledTimes(1)
+    expect(onClick).toHaveBeenCalledWith('custom')
+  })
+
+  it('should not let listener fallthrough when declared in emits (functional)', () => {
+    const { component: Child } = define((_: any, { emit }: any) => {
+      // should not be in props
+      expect((_ as any).onClick).toBeUndefined()
+      const n0 = template('<button></button>')() as any
+      n0.$evtclick = () => {
+        emit('click', 'custom')
+      }
+      return n0
+    })
+    Child.emits = ['click']
+
+    const onClick = vi.fn()
+    const App = defineVaporComponent({
+      render() {
+        return createComponent(Child, {
+          onClick: () => onClick,
+        })
+      },
+    })
+
+    const { host } = define(App).render()
+    const node = host.children[0] as HTMLElement
+    node.click()
+    expect(onClick).toHaveBeenCalledTimes(1)
+    expect(onClick).toHaveBeenCalledWith('custom')
+  })
+
+  it('should support fallthrough for single element + comments', () => {
+    const click = vi.fn()
+
+    const Hello = defineVaporComponent({
+      render() {
+        return createComponent(Child, {
+          class: () => 'foo',
+          onClick: () => click,
+        })
+      },
+    })
+
+    const Child = defineVaporComponent({
+      render() {
+        return [
+          template('<!--hello-->')(),
+          template('<button></button>')(),
+          template('<!--world-->')(),
+        ]
+      },
+    })
+
+    const { host } = define(Hello).render()
+
+    expect(host.innerHTML).toBe(
+      `<!--hello--><button class="foo"></button><!--world-->`,
+    )
+    const button = host.children[0] as HTMLElement
+    button.dispatchEvent(new CustomEvent('click'))
+    expect(click).toHaveBeenCalled()
+  })
+
+  it('should support fallthrough for nested element + comments', async () => {
+    const toggle = ref(false)
+    const Child = defineVaporComponent({
+      setup() {
+        const n0 = template('<!-- comment A -->')() as any
+        const n1 = createIf(
+          () => toggle.value,
+          () => template('<span>Foo</span>')(),
+          () => {
+            const n2 = template('<!-- comment B -->')() as any
+            const n3 = template('<div>Bar</div>')() as any
+            return [n2, n3]
+          },
+        )
+        return [n0, n1]
+      },
+    })
+
+    const Root = defineVaporComponent({
+      setup() {
+        return createComponent(Child, { class: () => 'red' })
+      },
+    })
+
+    const { host } = define(Root).render()
+
+    expect(host.innerHTML).toBe(
+      `<!-- comment A --><!-- comment B --><div class="red">Bar</div><!--if-->`,
+    )
+
+    toggle.value = true
+    await nextTick()
+    expect(host.innerHTML).toBe(
+      `<!-- comment A --><span class=\"red\">Foo</span><!--if-->`,
+    )
+  })
+
+  it('should not fallthrough v-model listeners with corresponding declared prop', () => {
+    let textFoo = ''
+    let textBar = ''
+    const click = vi.fn()
+
+    const App = defineVaporComponent({
+      render() {
+        return createComponent(Child, {
+          modelValue: () => textFoo,
+          'onUpdate:modelValue': () => (val: string) => {
+            textFoo = val
+          },
+        })
+      },
+    })
+
+    const Child = defineVaporComponent({
+      props: ['modelValue'],
+      setup(_props, { emit }) {
+        return createComponent(GrandChild, {
+          modelValue: () => textBar,
+          'onUpdate:modelValue': () => (val: string) => {
+            textBar = val
+            emit('update:modelValue', 'from Child')
+          },
+        })
+      },
+    })
+
+    const GrandChild = defineVaporComponent({
+      props: ['modelValue'],
+      setup(_props, { emit }) {
+        const n0 = template('<button></button>')() as any
+        n0.$evtclick = () => {
+          click()
+          emit('update:modelValue', 'from GrandChild')
+        }
+        return n0
+      },
+    })
+
+    const { host } = define(App).render()
+    const node = host.children[0] as HTMLElement
+    node.click()
+    expect(click).toHaveBeenCalled()
+    expect(textBar).toBe('from GrandChild')
+    expect(textFoo).toBe('from Child')
+  })
+
+  it('should track this.$attrs access in slots', async () => {
+    const GrandChild = defineVaporComponent({
+      render() {
+        return createSlot('default')
+      },
+    })
+    const Child = defineVaporComponent({
+      // @ts-expect-error
+      components: { GrandChild },
+      render(_ctx, $props, $emit, $attrs, $slots) {
+        const n0 = template('<div></div>')() as any
+        setInsertionState(n0)
+        createComponent(GrandChild, null, {
+          default: () => {
+            const n1 = template(' ')()
+            renderEffect(() => setElementText(n1, $attrs.foo))
+            return n1
+          },
+        })
+        return n0
+      },
+    })
+
+    const obj = ref(1)
+    const App = defineVaporComponent({
+      render() {
+        return createComponent(Child, { foo: () => obj.value })
+      },
+    })
+
+    const { html } = define(App).render()
+    expect(html()).toBe('<div foo="1">1<!--slot--></div>')
+
+    obj.value = 2
+    await nextTick()
+    expect(html()).toBe('<div foo="2">2<!--slot--></div>')
+  })
+
   it('should allow attrs to fallthrough on component with comment at root', async () => {
     const t0 = template('<!--comment-->')
     const t1 = template('<div>')
@@ -172,6 +836,7 @@ describe('attribute fallthrough', () => {
       },
     }).render()
     expect(host.innerHTML).toBe('<span></span><div>1</div>')
+    expect(`Extraneous non-props attributes (id)`).toHaveBeenWarned()
   })
 
   it('should not allow attrs to fallthrough on component with single comment root', async () => {
@@ -190,6 +855,7 @@ describe('attribute fallthrough', () => {
       },
     }).render()
     expect(host.innerHTML).toBe('<!--comment-->')
+    expect(`Extraneous non-props attributes (id)`).toHaveBeenWarned()
   })
 
   it('should not fallthrough if explicitly pass inheritAttrs: false', async () => {
index 198ece168cae77d3bd37061556846a3cd69d1292..faad57aa52b9ed40a76ccc95b2f602f246071276 100644 (file)
@@ -580,10 +580,10 @@ describe('defineVaporCustomElement', () => {
   describe('attrs', () => {
     const E = defineVaporCustomElement({
       setup(_: any, { attrs }: any) {
-        const n0 = template('<div> </div>')() as any
+        const n0 = template('<div> </div>', true)() as any
         const x0 = txt(n0) as any
         renderEffect(() => setText(x0, toDisplayString(attrs.foo)))
-        return [n0]
+        return n0
       },
     })
     customElements.define('my-el-attrs', E)
@@ -591,11 +591,11 @@ describe('defineVaporCustomElement', () => {
     test('attrs via attribute', async () => {
       container.innerHTML = `<my-el-attrs foo="hello"></my-el-attrs>`
       const e = container.childNodes[0] as VaporElement
-      expect(e.shadowRoot!.innerHTML).toBe('<div>hello</div>')
+      expect(e.shadowRoot!.innerHTML).toBe('<div foo="hello">hello</div>')
 
       e.setAttribute('foo', 'changed')
       await nextTick()
-      expect(e.shadowRoot!.innerHTML).toBe('<div>changed</div>')
+      expect(e.shadowRoot!.innerHTML).toBe('<div foo="changed">changed</div>')
     })
 
     test('non-declared properties should not show up in $attrs', () => {
index b1841f6dcb67d5604849ebe3a07063470cb5932e..1ef4d5d8bec39955809e7ba471a3b1b757f9d9cd 100644 (file)
@@ -446,7 +446,7 @@ export const createFor = (
 
     // apply transition for new nodes
     if (frag.$transition) {
-      applyTransitionHooks(block.nodes, frag.$transition, false)
+      applyTransitionHooks(block.nodes, frag.$transition)
     }
 
     if (parent) insert(block.nodes, parent, anchor)
index ac37a632284629b7fd990091b6fe91d73d8f8202..e79683ba738cf5e62b842c18326571402fdd8180 100644 (file)
@@ -16,6 +16,8 @@ import {
   currentInstance,
   endMeasure,
   expose,
+  getComponentName,
+  getFunctionalFallthrough,
   isAsyncWrapper,
   isKeepAlive,
   nextUid,
@@ -27,6 +29,7 @@ import {
   startMeasure,
   unregisterHMR,
   warn,
+  warnExtraneousAttributes,
 } from '@vue/runtime-dom'
 import {
   type Block,
@@ -88,7 +91,7 @@ import {
   setCurrentHydrationNode,
 } from './dom/hydration'
 import { _next, createElement } from './dom/node'
-import { type TeleportFragment, isVaporTeleport } from './components/Teleport'
+import { TeleportFragment, isVaporTeleport } from './components/Teleport'
 import {
   type KeepAliveInstance,
   findParentKeepAlive,
@@ -197,7 +200,9 @@ export function createComponent(
   const parentInstance = getParentInstance()
 
   if (
-    isSingleRoot &&
+    (isSingleRoot ||
+      // transition has attrs fallthrough
+      (parentInstance && isVaporTransition(parentInstance!.type))) &&
     component.inheritAttrs !== false &&
     isVaporComponent(parentInstance) &&
     parentInstance.hasFallthrough
@@ -205,7 +210,7 @@ export function createComponent(
     // check if we are the single root of the parent
     // if yes, inject parent attrs as dynamic props source
     const attrs = parentInstance.attrs
-    if (rawProps) {
+    if (rawProps && rawProps !== EMPTY_OBJ) {
       ;((rawProps as RawProps).$ || ((rawProps as RawProps).$ = [])).push(
         () => attrs,
       )
@@ -397,7 +402,31 @@ export function setupComponent(
     component.inheritAttrs !== false &&
     Object.keys(instance.attrs).length
   ) {
-    renderEffect(() => applyFallthroughProps(instance.block, instance.attrs))
+    const root = getRootElement(
+      instance.block,
+      // attach attrs to root dynamic fragments for applying during each update
+      frag => (frag.attrs = instance.attrs),
+      false,
+    )
+    if (root) {
+      renderEffect(() => {
+        const attrs =
+          isFunction(component) && !isVaporTransition(component)
+            ? getFunctionalFallthrough(instance.attrs)
+            : instance.attrs
+        if (attrs) applyFallthroughProps(root, attrs)
+      })
+    } else if (
+      __DEV__ &&
+      ((!instance.accessedAttrs &&
+        isArray(instance.block) &&
+        instance.block.length) ||
+        // preventing attrs fallthrough on Teleport
+        // consistent with VDOM Teleport behavior
+        instance.block instanceof TeleportFragment)
+    ) {
+      warnExtraneousAttributes(instance.attrs)
+    }
   }
 
   setActiveSub(prevSub)
@@ -412,15 +441,12 @@ export function setupComponent(
 export let isApplyingFallthroughProps = false
 
 export function applyFallthroughProps(
-  block: Block,
+  el: Element,
   attrs: Record<string, any>,
 ): void {
-  const el = getRootElement(block, false)
-  if (el) {
-    isApplyingFallthroughProps = true
-    setDynamicProps(el, [attrs])
-    isApplyingFallthroughProps = false
-  }
+  isApplyingFallthroughProps = true
+  setDynamicProps(el, [attrs])
+  isApplyingFallthroughProps = false
 }
 
 /**
@@ -545,6 +571,13 @@ export class VaporComponentInstance implements GenericComponentInstance {
   emitsOptions?: ObjectEmitsOptions | null
   isSingleRoot?: boolean
 
+  /**
+   * dev only flag to track whether $attrs was used during render.
+   * If $attrs was used during render then the warning for failed attrs
+   * fallthrough can be suppressed.
+   */
+  accessedAttrs: boolean = false
+
   constructor(
     comp: VaporComponent,
     rawProps?: RawProps | null,
@@ -617,6 +650,22 @@ export class VaporComponentInstance implements GenericComponentInstance {
     if (comp.ce) {
       comp.ce(this)
     }
+
+    if (__DEV__) {
+      // in dev, mark attrs accessed if optional props (attrs === props)
+      if (this.props === this.attrs) {
+        this.accessedAttrs = true
+      } else {
+        const attrs = this.attrs
+        const instance = this
+        this.attrs = new Proxy(attrs, {
+          get(target, key, receiver) {
+            instance.accessedAttrs = true
+            return Reflect.get(target, key, receiver)
+          },
+        })
+      }
+    }
   }
 
   /**
@@ -822,6 +871,7 @@ export function getExposed(
 
 export function getRootElement(
   block: Block,
+  onDynamicFragment?: (frag: DynamicFragment) => void,
   recurse: boolean = true,
 ): Element | undefined {
   if (block instanceof Element) {
@@ -829,15 +879,18 @@ export function getRootElement(
   }
 
   if (recurse && isVaporComponent(block)) {
-    return getRootElement(block.block, recurse)
+    return getRootElement(block.block, onDynamicFragment, recurse)
   }
 
-  if (isFragment(block)) {
+  if (isFragment(block) && !(block instanceof TeleportFragment)) {
+    if (block instanceof DynamicFragment && onDynamicFragment) {
+      onDynamicFragment(block)
+    }
     const { nodes } = block
     if (nodes instanceof Element && (nodes as any).$root) {
       return nodes
     }
-    return getRootElement(nodes, recurse)
+    return getRootElement(nodes, onDynamicFragment, recurse)
   }
 
   // The root node contains comments. It is necessary to filter out
@@ -851,7 +904,7 @@ export function getRootElement(
         hasComment = true
         continue
       }
-      const thisRoot = getRootElement(b, recurse)
+      const thisRoot = getRootElement(b, onDynamicFragment, recurse)
       // only return root if there is exactly one eligible root in the array
       if (!thisRoot || singleRoot) {
         return
@@ -861,3 +914,7 @@ export function getRootElement(
     return hasComment ? singleRoot : undefined
   }
 }
+
+function isVaporTransition(component: VaporComponent): boolean {
+  return getComponentName(component) === 'VaporTransition'
+}
index 1c5f783655f3129c3836e0797306fb6e70936787..ce1e65efe46056f1e0f11161267d4c664918f52a 100644 (file)
@@ -3,7 +3,6 @@ import {
   MismatchTypes,
   type TeleportProps,
   type TeleportTargetElement,
-  currentInstance,
   isMismatchAllowed,
   isTeleportDeferred,
   isTeleportDisabled,
@@ -32,6 +31,7 @@ import {
   setCurrentHydrationNode,
 } from '../dom/hydration'
 import { applyTransitionHooks } from './Transition'
+import { getParentInstance } from '../componentSlots'
 
 export const VaporTeleportImpl = {
   name: 'VaporTeleport',
@@ -57,13 +57,12 @@ export class TeleportFragment extends VaporFragment {
   placeholder?: Node
   mountContainer?: ParentNode | null
   mountAnchor?: Node | null
-  parentComponent: GenericComponentInstance
 
   constructor(props: LooseRawProps, slots: LooseRawSlots) {
     super([])
     this.rawProps = props
     this.rawSlots = slots
-    this.parentComponent = currentInstance as GenericComponentInstance
+    this.parentComponent = getParentInstance()
     this.anchor = isHydrating
       ? undefined
       : __DEV__
index 3f945e838f5b1f5b0eac04cc91e6ba9302a47d01..d6666e8192535e89284377cbded059dceb3a7494 100644 (file)
@@ -23,10 +23,9 @@ import type { Block, TransitionBlock, VaporTransitionHooks } from '../block'
 import {
   type FunctionalVaporComponent,
   type VaporComponentInstance,
-  applyFallthroughProps,
   isVaporComponent,
 } from '../component'
-import { extend, isArray } from '@vue/shared'
+import { isArray } from '@vue/shared'
 import { renderEffect } from '../renderEffect'
 import { isFragment } from '../fragment'
 import {
@@ -45,7 +44,7 @@ const decorate = (t: typeof VaporTransition) => {
 }
 
 export const VaporTransition: FunctionalVaporComponent = /*@__PURE__*/ decorate(
-  (props, { slots, attrs }) => {
+  (props, { slots }) => {
     // wrapped <transition appear>
     let resetDisplay: Function | undefined
     if (
@@ -85,7 +84,7 @@ export const VaporTransition: FunctionalVaporComponent = /*@__PURE__*/ decorate(
     renderEffect(() => {
       resolvedProps = resolveTransitionProps(props)
       if (isMounted) {
-        // only update props for Fragment block, for later reusing
+        // only update props for Fragment transition, for later reusing
         if (isFragment(children)) {
           children.$transition!.props = resolvedProps
         } else {
@@ -93,7 +92,7 @@ export const VaporTransition: FunctionalVaporComponent = /*@__PURE__*/ decorate(
           if (child) {
             // replace existing transition hooks
             child.$transition!.props = resolvedProps
-            applyTransitionHooks(child, child.$transition!, undefined, true)
+            applyTransitionHooks(child, child.$transition!, true)
           }
         }
       } else {
@@ -101,34 +100,11 @@ export const VaporTransition: FunctionalVaporComponent = /*@__PURE__*/ decorate(
       }
     })
 
-    // fallthrough attrs
-    let fallthroughAttrs = true
-    if (instance.hasFallthrough) {
-      renderEffect(() => {
-        // attrs are accessed in advance
-        const resolvedAttrs = extend({}, attrs)
-        const child = findTransitionBlock(children)
-        if (child) {
-          // mark single root
-          ;(child as any).$root = true
-
-          applyFallthroughProps(child, resolvedAttrs)
-          // ensure fallthrough attrs are not happened again in
-          // applyTransitionHooks
-          fallthroughAttrs = false
-        }
-      })
-    }
-
-    const hooks = applyTransitionHooks(
-      children,
-      {
-        state: useTransitionState(),
-        props: resolvedProps!,
-        instance: instance,
-      } as VaporTransitionHooks,
-      fallthroughAttrs,
-    )
+    const hooks = applyTransitionHooks(children, {
+      state: useTransitionState(),
+      props: resolvedProps!,
+      instance: instance,
+    } as VaporTransitionHooks)
 
     if (resetDisplay && resolvedProps!.appear) {
       const child = findTransitionBlock(children)!
@@ -210,7 +186,6 @@ export function resolveTransitionHooks(
 export function applyTransitionHooks(
   block: Block,
   hooks: VaporTransitionHooks,
-  fallthroughAttrs: boolean = true,
   isResolved: boolean = false,
 ): VaporTransitionHooks {
   // filter out comment nodes
@@ -246,13 +221,6 @@ export function applyTransitionHooks(
   child.$transition = resolvedHooks
   if (isFrag) setTransitionHooksOnFragment(block, resolvedHooks)
 
-  // fallthrough attrs
-  if (fallthroughAttrs && instance.hasFallthrough) {
-    // mark single root
-    ;(child as any).$root = true
-    applyFallthroughProps(child, instance.attrs)
-  }
-
   return resolvedHooks
 }
 
index af3b8abf7516534fe4a39242c31d34d1ffb3c892..10349b15870092c1f36c99ebdf289d8ad78d7fff 100644 (file)
@@ -30,11 +30,9 @@ import {
 import {
   type ObjectVaporComponent,
   type VaporComponentInstance,
-  applyFallthroughProps,
   isVaporComponent,
 } from '../component'
 import { isForBlock } from '../apiCreateFor'
-import { renderEffect } from '../renderEffect'
 import { createElement } from '../dom/node'
 import { isFragment } from '../fragment'
 
@@ -153,11 +151,6 @@ export const VaporTransitionGroup: ObjectVaporComponent = decorate({
     if (tag) {
       const container = createElement(tag)
       insert(slottedBlock, container)
-      // fallthrough attrs
-      if (instance!.hasFallthrough) {
-        ;(container as any).$root = true
-        renderEffect(() => applyFallthroughProps(container, instance!.attrs))
-      }
       return container
     } else {
       return slottedBlock
index 9dc2c8d0e7e1749c5640f6c65bf914dbc98c074a..d5ba251b8c9ea64b391a121190985732b82e7b3c 100644 (file)
@@ -21,6 +21,7 @@ export function on(
   if (isArray(handler)) {
     handler.forEach(fn => on(el, event, fn, options))
   } else {
+    if (!handler) return
     addEventListener(el, event, handler, options)
     if (options.effect) {
       onEffectCleanup(() => {
index 96dcf6e7ddae7638802080dc655e929fa3e014c9..287980ed4b0b37b79d273da148ed68ec49f927e3 100644 (file)
@@ -11,11 +11,14 @@ import {
   remove,
 } from './block'
 import {
+  type GenericComponentInstance,
   type TransitionHooks,
   type VNode,
   queuePostFlushCb,
+  setCurrentInstance,
+  warnExtraneousAttributes,
 } from '@vue/runtime-dom'
-import type { VaporComponentInstance } from './component'
+import { type VaporComponentInstance, applyFallthroughProps } from './component'
 import type { NodeRef } from './apiTemplateRef'
 import {
   applyTransitionHooks,
@@ -28,6 +31,9 @@ import {
   locateFragmentEndAnchor,
   locateHydrationNode,
 } from './dom/hydration'
+import { getParentInstance } from './componentSlots'
+import { isArray } from '@vue/shared'
+import { renderEffect } from './renderEffect'
 
 export class VaporFragment<T extends Block = Block>
   implements TransitionOptions
@@ -37,6 +43,7 @@ export class VaporFragment<T extends Block = Block>
   nodes: T
   vnode?: VNode | null = null
   anchor?: Node
+  parentComponent?: GenericComponentInstance | null
   fallback?: BlockFn
   insert?: (
     parent: ParentNode,
@@ -73,6 +80,9 @@ export class DynamicFragment extends VaporFragment {
   fallback?: BlockFn
   anchorLabel?: string
 
+  // fallthrough attrs
+  attrs?: Record<string, any>
+
   // get the kept-alive scope when used in keep-alive
   getScope?: (key: any) => EffectScope | undefined
 
@@ -86,12 +96,14 @@ export class DynamicFragment extends VaporFragment {
 
   constructor(anchorLabel?: string) {
     super([])
+    this.parentComponent = getParentInstance()
     if (isHydrating) {
       this.anchorLabel = anchorLabel
       locateHydrationNode()
     } else {
       this.anchor =
         __DEV__ && anchorLabel ? createComment(anchorLabel) : createTextNode()
+      if (__DEV__) this.anchorLabel = anchorLabel
     }
   }
 
@@ -177,7 +189,13 @@ export class DynamicFragment extends VaporFragment {
         this.scope = new EffectScope()
       }
 
+      // switch current instance to parent instance during update
+      // ensure that the parent instance is correct for nested components
+      let prev
+      if (this.parentComponent && parent)
+        prev = setCurrentInstance(this.parentComponent)
       this.nodes = this.scope.run(render) || []
+      if (this.parentComponent && parent) setCurrentInstance(...prev!)
 
       if (transition) {
         this.$transition = applyTransitionHooks(this.nodes, transition)
@@ -190,9 +208,25 @@ export class DynamicFragment extends VaporFragment {
       }
 
       if (parent) {
+        // apply fallthrough props during update
+        if (this.attrs) {
+          if (this.nodes instanceof Element) {
+            renderEffect(() =>
+              applyFallthroughProps(this.nodes as Element, this.attrs!),
+            )
+          } else if (
+            __DEV__ &&
+            // preventing attrs fallthrough on slots
+            // consistent with VDOM slots behavior
+            (this.anchorLabel === 'slot' ||
+              (isArray(this.nodes) && this.nodes.length))
+          ) {
+            warnExtraneousAttributes(this.attrs)
+          }
+        }
+
         insert(this.nodes, parent, this.anchor)
-        // anchor isConnected indicates the this render is updated
-        if (this.anchor.isConnected && this.updated) {
+        if (this.updated) {
           this.updated.forEach(hook => hook(this.nodes))
         }
       }