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) {