diff --git a/packages/runtime-vapor/__tests__/componentProps.spec.ts b/packages/runtime-vapor/__tests__/componentProps.spec.ts index 55b492408c0..fdab5586b86 100644 --- a/packages/runtime-vapor/__tests__/componentProps.spec.ts +++ b/packages/runtime-vapor/__tests__/componentProps.spec.ts @@ -591,4 +591,157 @@ describe('component: props', () => { render({ msg: () => 'test' }) expect(`Invalid prop name: "$foo"`).toHaveBeenWarned() }) + + describe('dynamic props source caching', () => { + test('v-bind object should be cached when child accesses multiple props', () => { + let sourceCallCount = 0 + const obj = ref({ foo: 1, bar: 2, baz: 3 }) + + const t0 = template('
') + const Child = defineVaporComponent({ + props: ['foo', 'bar', 'baz'], + setup(props: any) { + const n0 = t0() + // Child component accesses multiple props + renderEffect(() => { + setElementText(n0, `${props.foo}-${props.bar}-${props.baz}`) + }) + return n0 + }, + }) + + const { host } = define({ + setup() { + return createComponent(Child, { + $: [ + () => { + sourceCallCount++ + return obj.value + }, + ], + }) + }, + }).render() + + expect(host.innerHTML).toBe('
1-2-3
') + // Source should only be called once even though 3 props are accessed + expect(sourceCallCount).toBe(1) + }) + + test('v-bind object should update when source changes', async () => { + let sourceCallCount = 0 + const obj = ref({ foo: 1, bar: 2 }) + + const t0 = template('
') + const Child = defineVaporComponent({ + props: ['foo', 'bar'], + setup(props: any) { + const n0 = t0() + renderEffect(() => { + setElementText(n0, `${props.foo}-${props.bar}`) + }) + return n0 + }, + }) + + const { host } = define({ + setup() { + return createComponent(Child, { + $: [ + () => { + sourceCallCount++ + return obj.value + }, + ], + }) + }, + }).render() + + expect(host.innerHTML).toBe('
1-2
') + expect(sourceCallCount).toBe(1) + + // Update source + obj.value = { foo: 10, bar: 20 } + await nextTick() + + expect(host.innerHTML).toBe('
10-20
') + // Should be called again after source changes + expect(sourceCallCount).toBe(2) + }) + + test('v-bind object should be cached when child accesses multiple attrs', () => { + let sourceCallCount = 0 + const obj = ref({ foo: 1, bar: 2, baz: 3 }) + + const t0 = template('
') + const Child = defineVaporComponent({ + // No props declaration - all become attrs + setup(_: any, { attrs }: any) { + const n0 = t0() + renderEffect(() => { + setElementText(n0, `${attrs.foo}-${attrs.bar}-${attrs.baz}`) + }) + return n0 + }, + }) + + const { host } = define({ + setup() { + return createComponent(Child, { + $: [ + () => { + sourceCallCount++ + return obj.value + }, + ], + }) + }, + }).render() + + expect(host.innerHTML).toBe('
1-2-3
') + // Source should only be called once + expect(sourceCallCount).toBe(1) + }) + + test('mixed static and dynamic props', async () => { + let sourceCallCount = 0 + const obj = ref({ foo: 1 }) + + const t0 = template('
') + const Child = defineVaporComponent({ + props: ['id', 'foo', 'class'], + setup(props: any) { + const n0 = t0() + renderEffect(() => { + setElementText(n0, `${props.id}-${props.foo}-${props.class}`) + }) + return n0 + }, + }) + + const { host } = define({ + setup() { + return createComponent(Child, { + id: () => 'static', + $: [ + () => { + sourceCallCount++ + return obj.value + }, + { class: () => 'bar' }, + ], + }) + }, + }).render() + + expect(host.innerHTML).toBe('
static-1-bar
') + expect(sourceCallCount).toBe(1) + + obj.value = { foo: 2 } + await nextTick() + + expect(host.innerHTML).toBe('
static-2-bar
') + expect(sourceCallCount).toBe(2) + }) + }) }) diff --git a/packages/runtime-vapor/src/componentProps.ts b/packages/runtime-vapor/src/componentProps.ts index c10008b3af2..191e78b0b89 100644 --- a/packages/runtime-vapor/src/componentProps.ts +++ b/packages/runtime-vapor/src/componentProps.ts @@ -20,7 +20,7 @@ import { validateProps, warn, } from '@vue/runtime-dom' -import { ReactiveFlags } from '@vue/reactivity' +import { type ComputedRef, ReactiveFlags, computed } from '@vue/reactivity' import { normalizeEmitsOptions } from './componentEmits' import { renderEffect } from './renderEffect' import { pauseTracking, resetTracking } from '@vue/reactivity' @@ -35,11 +35,24 @@ export type DynamicPropsSource = | (() => Record) | Record unknown> -// TODO optimization: maybe convert functions into computeds export function resolveSource( source: Record | (() => Record), ): Record { - return isFunction(source) ? source() : source + return isFunction(source) + ? resolveFunctionSource(source as () => Record) + : source +} + +/** + * Resolve a function source with computed caching. + */ +export function resolveFunctionSource( + source: (() => T) & { _cache?: ComputedRef }, +): T { + if (!source._cache) { + source._cache = computed(source) + } + return source._cache.value } export function getPropsProxyHandlers( @@ -78,7 +91,11 @@ export function getPropsProxyHandlers( while (i--) { source = dynamicSources[i] isDynamic = isFunction(source) - source = isDynamic ? (source as Function)() : source + source = isDynamic + ? (resolveFunctionSource( + source as () => Record, + ) as any) + : source for (rawKey in source) { if (camelize(rawKey) === key) { return resolvePropValue( @@ -205,7 +222,11 @@ export function getAttrFromRawProps(rawProps: RawProps, key: string): unknown { while (i--) { source = dynamicSources[i] isDynamic = isFunction(source) - source = isDynamic ? (source as Function)() : source + source = isDynamic + ? (resolveFunctionSource( + source as () => Record, + ) as any) + : source if (source && hasOwn(source, key)) { const value = isDynamic ? source[key] : source[key]() if (merged) { @@ -337,7 +358,7 @@ export function resolveDynamicProps(props: RawProps): Record { if (props.$) { for (const source of props.$) { const isDynamic = isFunction(source) - const resolved = isDynamic ? source() : source + const resolved = isDynamic ? resolveFunctionSource(source) : source for (const key in resolved) { const value = isDynamic ? resolved[key] : (resolved[key] as Function)() if (key === 'class' || key === 'style') { diff --git a/packages/runtime-vapor/src/componentSlots.ts b/packages/runtime-vapor/src/componentSlots.ts index dfd22c23b28..22886ac1900 100644 --- a/packages/runtime-vapor/src/componentSlots.ts +++ b/packages/runtime-vapor/src/componentSlots.ts @@ -1,7 +1,6 @@ import { EMPTY_OBJ, NO, hasOwn, isArray, isFunction } from '@vue/shared' -import { type ComputedRef, computed } from '@vue/reactivity' import { type Block, type BlockFn, insert, setScopeId } from './block' -import { rawPropsProxyHandlers } from './componentProps' +import { rawPropsProxyHandlers, resolveFunctionSource } from './componentProps' import { type GenericComponentInstance, currentInstance, @@ -52,24 +51,9 @@ export type StaticSlots = Record export type VaporSlot = BlockFn export type DynamicSlot = { name: string; fn: VaporSlot } -export type DynamicSlotFn = (() => DynamicSlot | DynamicSlot[]) & { - _cache?: ComputedRef -} +export type DynamicSlotFn = () => DynamicSlot | DynamicSlot[] export type DynamicSlotSource = StaticSlots | DynamicSlotFn -/** - * Get cached result of a DynamicSlotFn. - * Uses computed to cache the result and avoid redundant calls. - */ -function resolveDynamicSlot( - source: DynamicSlotFn, -): DynamicSlot | DynamicSlot[] { - if (!source._cache) { - source._cache = computed(source) - } - return source._cache.value -} - export const dynamicSlotsProxyHandlers: ProxyHandler = { get: getSlot, has: (target, key: string) => !!getSlot(target, key), @@ -90,7 +74,7 @@ export const dynamicSlotsProxyHandlers: ProxyHandler = { keys = keys.filter(k => k !== '$') for (const source of dynamicSources) { if (isFunction(source)) { - const slot = resolveDynamicSlot(source) + const slot = resolveFunctionSource(source) if (isArray(slot)) { for (const s of slot) keys.push(String(s.name)) } else { @@ -119,7 +103,7 @@ export function getSlot( while (i--) { source = dynamicSources[i] if (isFunction(source)) { - const slot = resolveDynamicSlot(source) + const slot = resolveFunctionSource(source) if (slot) { if (isArray(slot)) { for (const s of slot) {