Skip to content
Merged
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
153 changes: 153 additions & 0 deletions packages/runtime-vapor/__tests__/componentProps.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<div></div>')
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('<div>1-2-3</div>')
// 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('<div></div>')
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('<div>1-2</div>')
expect(sourceCallCount).toBe(1)

// Update source
obj.value = { foo: 10, bar: 20 }
await nextTick()

expect(host.innerHTML).toBe('<div>10-20</div>')
// 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('<div></div>')
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('<div foo="1" bar="2" baz="3">1-2-3</div>')
// 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('<div></div>')
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('<div>static-1-bar</div>')
expect(sourceCallCount).toBe(1)

obj.value = { foo: 2 }
await nextTick()

expect(host.innerHTML).toBe('<div>static-2-bar</div>')
expect(sourceCallCount).toBe(2)
})
})
})
33 changes: 27 additions & 6 deletions packages/runtime-vapor/src/componentProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -35,11 +35,24 @@ export type DynamicPropsSource =
| (() => Record<string, unknown>)
| Record<string, () => unknown>

// TODO optimization: maybe convert functions into computeds
export function resolveSource(
source: Record<string, any> | (() => Record<string, any>),
): Record<string, any> {
return isFunction(source) ? source() : source
return isFunction(source)
? resolveFunctionSource(source as () => Record<string, any>)
: source
}

/**
* Resolve a function source with computed caching.
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation comment is incomplete. It should explain:

  1. What "computed caching" means in this context
  2. That the cache is stored on the function object itself via a _cache property
  3. The implications of this approach (e.g., functions should be stable references)
  4. When the cache is invalidated (when the computed's dependencies change)

Example:

/**
 * Resolve a function source with computed caching.
 * 
 * Wraps the source function in a computed ref to cache its result.
 * The cache is stored directly on the function object via a `_cache` property,
 * so the same function reference will always use the same cache.
 * The cached value will be recomputed when the function's reactive dependencies change.
 * 
 * @param source - The function to resolve, must return a value of type T
 * @returns The cached result of calling the source function
 */
Suggested change
* Resolve a function source with computed caching.
* Resolve a function source with computed caching.
*
* Wraps the source function in a computed ref to cache its result.
* The cache is stored directly on the function object via a `_cache` property,
* so the same function reference will always use the same cache.
* The cached value will be recomputed when the function's reactive dependencies change.
* This approach requires that the function reference remains stable; if a new function
* is passed, a new cache will be created and the previous cache will not be reused.
*
* @param source - The function to resolve, must return a value of type T
* @returns The cached result of calling the source function

Copilot uses AI. Check for mistakes.
*/
export function resolveFunctionSource<T>(
source: (() => T) & { _cache?: ComputedRef<T> },
): T {
if (!source._cache) {
source._cache = computed(source)
}
return source._cache.value
Comment on lines +49 to +55
Copy link

Copilot AI Dec 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The resolveFunctionSource function mutates the input function by attaching a _cache property. This could cause unexpected behavior if:

  1. The same function is used in multiple contexts or components - they would share the same cached computed ref
  2. Functions are dynamically created in loops or conditionals - the cache would persist even after the function is no longer needed, potentially causing memory leaks

Consider:

  • Adding a WeakMap-based cache instead of mutating the function object
  • Documenting this behavior clearly if it's intentional
  • Ensuring function sources are stable references (not recreated on each render)
Suggested change
export function resolveFunctionSource<T>(
source: (() => T) & { _cache?: ComputedRef<T> },
): T {
if (!source._cache) {
source._cache = computed(source)
}
return source._cache.value
const functionSourceCache: WeakMap<Function, ComputedRef<any>> = new WeakMap();
export function resolveFunctionSource<T>(
source: () => T,
): T {
let cache = functionSourceCache.get(source);
if (!cache) {
cache = computed(source);
functionSourceCache.set(source, cache);
}
return cache.value;

Copilot uses AI. Check for mistakes.
}

export function getPropsProxyHandlers(
Expand Down Expand Up @@ -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<string, unknown>,
) as any)
: source
for (rawKey in source) {
if (camelize(rawKey) === key) {
return resolvePropValue(
Expand Down Expand Up @@ -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<string, unknown>,
) as any)
: source
if (source && hasOwn(source, key)) {
const value = isDynamic ? source[key] : source[key]()
if (merged) {
Expand Down Expand Up @@ -337,7 +358,7 @@ export function resolveDynamicProps(props: RawProps): Record<string, unknown> {
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') {
Expand Down
24 changes: 4 additions & 20 deletions packages/runtime-vapor/src/componentSlots.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -52,24 +51,9 @@ export type StaticSlots = Record<string, VaporSlot>

export type VaporSlot = BlockFn
export type DynamicSlot = { name: string; fn: VaporSlot }
export type DynamicSlotFn = (() => DynamicSlot | DynamicSlot[]) & {
_cache?: ComputedRef<DynamicSlot | DynamicSlot[]>
}
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<RawSlots> = {
get: getSlot,
has: (target, key: string) => !!getSlot(target, key),
Expand All @@ -90,7 +74,7 @@ export const dynamicSlotsProxyHandlers: ProxyHandler<RawSlots> = {
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 {
Expand Down Expand Up @@ -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) {
Expand Down
Loading