diff --git a/packages/runtime-core/src/componentRenderUtils.ts b/packages/runtime-core/src/componentRenderUtils.ts index a62b5cf4a82..d0c15a59d5d 100644 --- a/packages/runtime-core/src/componentRenderUtils.ts +++ b/packages/runtime-core/src/componentRenderUtils.ts @@ -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): 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)) { diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 6c968905a67..58f6f40b9f0 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -674,3 +674,11 @@ export { * @internal */ export type { GenericComponent } from './component' + +/** + * @internal + */ +export { + warnExtraneousAttributes, + getFunctionalFallthrough, +} from './componentRenderUtils' diff --git a/packages/runtime-vapor/__tests__/componentAttrs.spec.ts b/packages/runtime-vapor/__tests__/componentAttrs.spec.ts index 7fd99b88fad..d82837ffa7e 100644 --- a/packages/runtime-vapor/__tests__/componentAttrs.spec.ts +++ b/packages/runtime-vapor/__tests__/componentAttrs.spec.ts @@ -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() - -// 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('
2
') }) + 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( + '
', + true, + )() as Element + renderEffect(() => setElementText(n0, props.foo)) + return n0 + }) + + const { host } = define(Hello).render() + expect(host.innerHTML).toBe( + '
1
', + ) + + 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( + '
', + 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( + '
', + true, + )() as Element + renderEffect(() => { + setProp(n0, 'id', props.id) + setElementText(n0, props.foo) + }) + return n0 + }, + }) + + const { host } = define(Hello).render() + expect(host.innerHTML).toBe( + '
1
', + ) + + 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('
', true)() as Element + renderEffect(() => setElementText(n0, props.foo)) + return n0 + }, + }) + + const { html } = define(Parent).render() + + // should not contain class + expect(html()).toMatch(`
1
`) + }) + + 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('
', 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(`
1
`) + }) + + 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('
')(), template('
')()] + }, + }) + + 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('
')(), + }, + ) + }, + }) + + 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('
', 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('
')() as Element + const n1 = template('
')() 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(`
`) + }) + + 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('
')() as Element + const n1 = template('
')() 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(`
`) + }) + + 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('
')() as Element + const n1 = template('
')() 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(`
`) + }) + + 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('
')(), + template('
')(), + ]) + + Child.props = ['foo'] + + const { html } = define(Parent).render() + + expect(`Extraneous non-props attributes`).toHaveBeenWarned() + expect(`Extraneous non-emits event listeners`).toHaveBeenWarned() + expect(html()).toBe(`
`) + }) + + 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('')() 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('')() 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('')(), + template('')(), + template('')(), + ] + }, + }) + + const { host } = define(Hello).render() + + expect(host.innerHTML).toBe( + ``, + ) + 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('')() as any + const n1 = createIf( + () => toggle.value, + () => template('Foo')(), + () => { + const n2 = template('')() as any + const n3 = template('
Bar
')() 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( + `
Bar
`, + ) + + toggle.value = true + await nextTick() + expect(host.innerHTML).toBe( + `Foo`, + ) + }) + + 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('')() 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('
')() 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('
1
') + + obj.value = 2 + await nextTick() + expect(html()).toBe('
2
') + }) + it('should allow attrs to fallthrough on component with comment at root', async () => { const t0 = template('') const t1 = template('
') @@ -172,6 +836,7 @@ describe('attribute fallthrough', () => { }, }).render() expect(host.innerHTML).toBe('
1
') + 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('') + expect(`Extraneous non-props attributes (id)`).toHaveBeenWarned() }) it('should not fallthrough if explicitly pass inheritAttrs: false', async () => { diff --git a/packages/runtime-vapor/__tests__/customElement.spec.ts b/packages/runtime-vapor/__tests__/customElement.spec.ts index 198ece168ca..faad57aa52b 100644 --- a/packages/runtime-vapor/__tests__/customElement.spec.ts +++ b/packages/runtime-vapor/__tests__/customElement.spec.ts @@ -580,10 +580,10 @@ describe('defineVaporCustomElement', () => { describe('attrs', () => { const E = defineVaporCustomElement({ setup(_: any, { attrs }: any) { - const n0 = template('
')() as any + const n0 = template('
', 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 = `` const e = container.childNodes[0] as VaporElement - expect(e.shadowRoot!.innerHTML).toBe('
hello
') + expect(e.shadowRoot!.innerHTML).toBe('
hello
') e.setAttribute('foo', 'changed') await nextTick() - expect(e.shadowRoot!.innerHTML).toBe('
changed
') + expect(e.shadowRoot!.innerHTML).toBe('
changed
') }) test('non-declared properties should not show up in $attrs', () => { diff --git a/packages/runtime-vapor/src/apiCreateFor.ts b/packages/runtime-vapor/src/apiCreateFor.ts index b1841f6dcb6..1ef4d5d8bec 100644 --- a/packages/runtime-vapor/src/apiCreateFor.ts +++ b/packages/runtime-vapor/src/apiCreateFor.ts @@ -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) diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index ac37a632284..e79683ba738 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -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, ): 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' +} diff --git a/packages/runtime-vapor/src/components/Teleport.ts b/packages/runtime-vapor/src/components/Teleport.ts index 1c5f783655f..ce1e65efe46 100644 --- a/packages/runtime-vapor/src/components/Teleport.ts +++ b/packages/runtime-vapor/src/components/Teleport.ts @@ -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__ diff --git a/packages/runtime-vapor/src/components/Transition.ts b/packages/runtime-vapor/src/components/Transition.ts index 3f945e838f5..d6666e81925 100644 --- a/packages/runtime-vapor/src/components/Transition.ts +++ b/packages/runtime-vapor/src/components/Transition.ts @@ -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 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 } diff --git a/packages/runtime-vapor/src/components/TransitionGroup.ts b/packages/runtime-vapor/src/components/TransitionGroup.ts index af3b8abf751..10349b15870 100644 --- a/packages/runtime-vapor/src/components/TransitionGroup.ts +++ b/packages/runtime-vapor/src/components/TransitionGroup.ts @@ -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 diff --git a/packages/runtime-vapor/src/dom/event.ts b/packages/runtime-vapor/src/dom/event.ts index 9dc2c8d0e7e..d5ba251b8c9 100644 --- a/packages/runtime-vapor/src/dom/event.ts +++ b/packages/runtime-vapor/src/dom/event.ts @@ -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(() => { diff --git a/packages/runtime-vapor/src/fragment.ts b/packages/runtime-vapor/src/fragment.ts index 96dcf6e7dda..287980ed4b0 100644 --- a/packages/runtime-vapor/src/fragment.ts +++ b/packages/runtime-vapor/src/fragment.ts @@ -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 implements TransitionOptions @@ -37,6 +43,7 @@ export class VaporFragment 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 + // 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)) } }