Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 221 additions & 0 deletions packages/runtime-vapor/__tests__/componentAttrs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
nextTick,
onUpdated,
ref,
shallowRef,
triggerRef,
withModifiers,
} from '@vue/runtime-dom'
import {
Expand Down Expand Up @@ -1730,4 +1732,223 @@ describe('attribute fallthrough', () => {
const { host } = define(App).render()
expect(host.innerHTML).toBe('<div>class prop = DROPPED</div>')
})

it('should reconcile dynamic local and fallthrough attrs', async () => {
const title = ref('child')
const attrs = ref<Record<string, any>>({})
const Child = compile(
`<template>
<div
id="child"
class="child"
style="color: red"
:title="data"
/>
</template>`,
title,
)
const App = compile(
`<template><components.Child v-bind="data" /></template>`,
attrs,
{ Child },
)

const { host } = define(App).render()
const el = host.firstElementChild as HTMLElement
expect(el.id).toBe('child')
expect(el.title).toBe('child')
expect(el.className).toBe('child')
expect(el.style.color).toBe('red')

attrs.value = {
id: 'parent',
title: 'parent',
class: 'child parent',
style: { color: 'blue' },
}
await nextTick()
expect(el.id).toBe('parent')
expect(el.title).toBe('parent')

title.value = 'child-next'
await nextTick()
expect(el.title).toBe('parent')

attrs.value = {}
await nextTick()
expect(el.hasAttribute('id')).toBe(false)
expect(el.title).toBe('child-next')
expect(el.className).toBe('')
expect(el.style.color).toBe('')
})

it('should discard a local v-bind source removed while shadowed', async () => {
const local = ref<Record<string, string>>({ title: 'child' })
const attrs = ref<Record<string, string>>({ title: 'parent' })
const Child = compile(`<template><div v-bind="data" /></template>`, local)
const App = compile(
`<template><components.Child v-bind="data" /></template>`,
attrs,
{ Child },
)

const { host } = define(App).render()
const el = host.firstElementChild as HTMLElement
expect(el.title).toBe('parent')

local.value = {}
await nextTick()
expect(el.title).toBe('parent')

attrs.value = {}
await nextTick()
expect(el.hasAttribute('title')).toBe(false)
})

it('should merge and update local and fallthrough listeners', async () => {
const calls: string[] = []
const local = ref<{ onClick: (event: Event) => void }>({
onClick: () => calls.push('local'),
})
const parent = ref<((event: Event) => void) | undefined>(() =>
calls.push('parent'),
)
const Child = compile(
`<template><button v-bind="data" /></template>`,
local,
)
const App = compile(
`<template><components.Child :onClick="data" /></template>`,
parent,
{ Child },
)

const { host } = define(App).render()
const button = host.firstElementChild as HTMLButtonElement
button.click()
expect(calls).toEqual(['local', 'parent'])

calls.length = 0
local.value = {
onClick(event: Event) {
calls.push('local')
event.stopImmediatePropagation()
},
}
await nextTick()
button.click()
expect(calls).toEqual(['local'])

calls.length = 0
parent.value = undefined
await nextTick()
button.click()
expect(calls).toEqual(['local'])
})

it('should preserve fallthrough once listeners across updates', async () => {
const handler = vi.fn()
const attrs = ref<Record<string, any>>({
onClickOnce: handler,
title: 'before',
})
const Child = compile(`<template><button /></template>`, ref(null))
const App = compile(
`<template><components.Child v-bind="data" /></template>`,
attrs,
{ Child },
)

const { host } = define(App).render()
const button = host.firstElementChild as HTMLButtonElement
button.click()
expect(handler).toHaveBeenCalledTimes(1)

attrs.value = { onClickOnce: handler, title: 'after' }
await nextTick()
expect(button.title).toBe('after')
button.click()
expect(handler).toHaveBeenCalledTimes(1)
})

it('should filter and remove functional fallthrough on a component root', async () => {
const attrs = ref<Record<string, string>>({
id: 'blocked',
class: 'allowed',
})
const Leaf = compile(`<template><div /></template>`, ref(null))
const Functional = () => createComponent(Leaf, null, null, true)
const App = compile(
`<template><components.Functional v-bind="data" /></template>`,
attrs,
{ Functional },
)

const { host } = define(App).render()
expect(host.innerHTML).toBe('<div class="allowed"></div>')

attrs.value = { id: 'blocked' }
await nextTick()
const el = host.firstElementChild as HTMLElement
expect(el.className).toBe('')
expect(el.hasAttribute('id')).toBe(false)
})

it('should preserve a class token still owned by fallthrough attrs', async () => {
const childClass = ref('shared')
const parentClass = ref('shared parent')
const Child = compile(
`<template><div :class="data" /></template>`,
childClass,
)
const App = compile(
`<template><components.Child :class="data" /></template>`,
parentClass,
{ Child },
)

const { host } = define(App).render()
const el = host.firstElementChild as HTMLElement
expect(el.className).toBe('shared parent')

childClass.value = ''
await nextTick()
expect(el.className).toBe('shared parent')

parentClass.value = ''
await nextTick()
expect(el.className).toBe('')
})

it('should preserve fallthrough style precedence across local updates', async () => {
const childStyle = shallowRef({ color: 'red' })
const parentStyle = ref<Record<string, string>>({
color: 'blue',
background: 'blue',
})
const Child = compile(
`<template><div :style="data" /></template>`,
childStyle,
)
const App = compile(
`<template><components.Child :style="data" /></template>`,
parentStyle,
{ Child },
)

const { host } = define(App).render()
const el = host.firstElementChild as HTMLElement
expect(el.style.color).toBe('blue')
expect(el.style.background).toBe('blue')

childStyle.value.color = 'green'
triggerRef(childStyle)
await nextTick()
expect(el.style.color).toBe('blue')

parentStyle.value = {}
await nextTick()
expect(el.style.color).toBe('green')
expect(el.style.background).toBe('')
})
})
55 changes: 27 additions & 28 deletions packages/runtime-vapor/src/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,18 +312,18 @@ export function createComponent(
? currentInstance && isVaporTransition(currentInstance!.type)
: false)) &&
isVaporComponent(currentInstance) &&
currentInstance.type.inheritAttrs !== false &&
currentInstance.hasFallthrough
forwardsFallthroughAttrs(currentInstance)
) {
// check if we are the single root of the parent
// if yes, inject parent attrs as dynamic props source
const attrs = currentInstance.attrs
const owner = currentInstance
const getAttrs = () => resolveFallthroughAttrs(owner)
if (rawProps && rawProps !== EMPTY_OBJ) {
;((rawProps as RawProps).$ || ((rawProps as RawProps).$ = [])).push(
() => attrs,
getAttrs,
)
} else {
rawProps = { $: [() => attrs] } as RawProps
rawProps = { $: [getAttrs] } as RawProps
}
}

Expand Down Expand Up @@ -640,6 +640,20 @@ export function shouldUseFunctionalFallthrough(
)
}

export function forwardsFallthroughAttrs(
instance: VaporComponentInstance,
): boolean {
return instance.hasFallthrough && instance.type.inheritAttrs !== false
}

function resolveFallthroughAttrs(
instance: VaporComponentInstance,
): Record<string, any> {
return shouldUseFunctionalFallthrough(instance.type)
? getFunctionalFallthrough(instance.attrs) || EMPTY_OBJ
: instance.attrs
}

export function applyFallthroughProps(
el: Element,
attrs: Record<string, any>,
Expand Down Expand Up @@ -1550,17 +1564,10 @@ function handleSetupResult(
}

// single root, inherit attrs
if (
instance.hasFallthrough &&
component.inheritAttrs !== false &&
Object.keys(instance.attrs).length
) {
const getFallthroughAttrs = shouldUseFunctionalFallthrough(component)
? () => getFunctionalFallthrough(instance.attrs)
: () => instance.attrs
if (forwardsFallthroughAttrs(instance)) {
// attach attrs to the root element, or to root dynamic fragments so they
// can be (re-)applied during each branch update
applyFallthroughAttrs(instance.block, instance, getFallthroughAttrs)
applyFallthroughAttrs(instance.block, instance)
}

if (__DEV__) {
Expand All @@ -1575,7 +1582,6 @@ function handleSetupResult(
function applyFallthroughAttrs(
block: Block,
instance: VaporComponentInstance,
getFallthroughAttrs: () => Record<string, any> | undefined,
scope?: EffectScope,
): void {
let hasSlotFragment = false
Expand All @@ -1601,28 +1607,22 @@ function applyFallthroughAttrs(
// slot fragments warn instead of inheriting attrs, skip them
if (!(frag.__vf & SLOT)) {
// Nested dynamic fragments need their own fallthrough hook.
registerDynamicFragmentFallthroughAttrs(
frag,
instance,
getFallthroughAttrs,
)
registerDynamicFragmentFallthroughAttrs(frag, instance)
}
}
}

if (root && !hasSlotFragment) {
const applyEffect = () =>
renderEffect(() => {
const attrs = getFallthroughAttrs()
if (attrs) applyFallthroughProps(root, attrs)
})
renderEffect(() =>
applyFallthroughProps(root, resolveFallthroughAttrs(instance)),
)
// ensure the render effect is cleaned up when the branch scope is stopped
scope ? scope.run(applyEffect) : applyEffect()
} else if (__DEV__) {
const accessedAttrs = instance.accessedAttrs
const fallthroughAttrs = getFallthroughAttrs()
const fallthroughAttrs = resolveFallthroughAttrs(instance)
if (
fallthroughAttrs &&
Object.keys(fallthroughAttrs).length &&
(hasSlotFragment ||
(dynamicRoot && dynamicRoot.hasNonSingleRoot) ||
Expand Down Expand Up @@ -1693,14 +1693,13 @@ function containsTeleportFragment(block: Block): boolean {
function registerDynamicFragmentFallthroughAttrs(
frag: DynamicFragment,
instance: VaporComponentInstance,
getFallthroughAttrs: () => Record<string, any> | undefined,
): void {
// avoid registering duplicate hooks
if (frag.hasFallthroughAttrs) return

frag.hasFallthroughAttrs = true
;(frag.onBeforeInsert ||= []).push(nodes =>
applyFallthroughAttrs(nodes, instance, getFallthroughAttrs, frag.scope!),
applyFallthroughAttrs(nodes, instance, frag.scope!),
)
}

Expand Down
22 changes: 11 additions & 11 deletions packages/runtime-vapor/src/componentProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -628,18 +628,18 @@ export function hasFallthroughAttrs(
comp: VaporComponent,
rawProps: RawProps | null | undefined,
): boolean {
if (rawProps) {
// determine fallthrough
if (rawProps.$ || !comp.props) {
if (!rawProps) return false

// determine fallthrough
// A dynamic source can be empty initially and add attrs later, so its
// presence alone requires installing the fallthrough effect.
if (rawProps.$) return true

// check if rawProps contains any keys not declared
const propsOptions = comp.props && normalizePropsOptions(comp)[0]
for (const key in rawProps) {
if (!propsOptions || !hasOwn(propsOptions, camelize(key))) {
return true
} else {
// check if rawProps contains any keys not declared
const propsOptions = normalizePropsOptions(comp)[0]!
for (const key in rawProps) {
if (!hasOwn(propsOptions, camelize(key))) {
return true
}
}
}
}
return false
Expand Down
Loading
Loading