diff --git a/packages/reactivity/src/dep.ts b/packages/reactivity/src/dep.ts index a24000329c1..40d895a3a63 100644 --- a/packages/reactivity/src/dep.ts +++ b/packages/reactivity/src/dep.ts @@ -1,26 +1,29 @@ import { isArray, isIntegerKey, isMap, isSymbol } from '@vue/shared' -import { type TrackOpTypes, TriggerOpTypes } from './constants' +import { TrackOpTypes, TriggerOpTypes } from './constants' import { onTrack, triggerEventInfos } from './debug' import { type Link, ReactiveFlags, type ReactiveNode, activeSub, + batchDepth, endBatch, + flush, link, propagate, shallowPropagate, startBatch, } from './system' -class Dep implements ReactiveNode { +export class Dep implements ReactiveNode { _subs: Link | undefined = undefined subsTail: Link | undefined = undefined flags: ReactiveFlags = ReactiveFlags.None + // a standalone dep has no map to leave once nothing subscribes it constructor( - private map: KeyToDepMap, - private key: unknown, + private map?: KeyToDepMap | undefined, + private key?: unknown, ) {} get subs(): Link | undefined { @@ -29,12 +32,61 @@ class Dep implements ReactiveNode { set subs(value: Link | undefined) { this._subs = value - if (value === undefined) { + if (value === undefined && this.map) { this.map.delete(this.key) } } } +/** + * Tracks a dep its owner keys on its own, outside `targetMap`. + * @internal + */ +export function trackDep( + dep: ReactiveNode, + target: object = dep, + key: unknown = 'value', +): void { + if (activeSub !== undefined) { + if (__DEV__) { + onTrack(activeSub, { target, type: TrackOpTypes.GET, key }) + } + link(dep, activeSub) + } +} + +/** + * @internal + */ +export function triggerDep( + dep: ReactiveNode, + target: object = dep, + key: unknown = 'value', + newValue?: unknown, + oldValue?: unknown, +): void { + const subs = dep.subs + if (subs !== undefined) { + if (__DEV__) { + triggerEventInfos.push({ + target, + type: TriggerOpTypes.SET, + key, + newValue, + oldValue, + }) + } + propagate(subs) + shallowPropagate(subs) + if (!batchDepth) { + flush() + } + if (__DEV__) { + triggerEventInfos.pop() + } + } +} + // The main WeakMap that stores {target -> key -> dep} connections. // Conceptually, it's easier to think of a dependency as a Dep class // which maintains a Set of subscribers, but we simply store them as diff --git a/packages/reactivity/src/index.ts b/packages/reactivity/src/index.ts index ef643940b00..9a7267f62af 100644 --- a/packages/reactivity/src/index.ts +++ b/packages/reactivity/src/index.ts @@ -72,6 +72,10 @@ export { ARRAY_ITERATE_KEY, MAP_KEY_ITERATE_KEY, } from './dep' +/** + * @internal + */ +export { Dep, trackDep, triggerDep } from './dep' export { effectScope, EffectScope, @@ -105,4 +109,4 @@ export { /** * @internal */ -export { setActiveSub } from './system' +export { activeSub, endBatch, setActiveSub, startBatch } from './system' diff --git a/packages/runtime-core/src/apiCreateApp.ts b/packages/runtime-core/src/apiCreateApp.ts index 134be560303..7863cea2db8 100644 --- a/packages/runtime-core/src/apiCreateApp.ts +++ b/packages/runtime-core/src/apiCreateApp.ts @@ -1,3 +1,4 @@ +import type { EffectScope } from '@vue/reactivity' import { type Component, type ComponentInternalInstance, @@ -288,6 +289,7 @@ export interface VdomInVaporInterface { props?: any, slots?: any, once?: boolean, + inputScope?: EffectScope, ) => any slot: ( slots: any, diff --git a/packages/runtime-vapor/__tests__/apiCreateVaporApp.spec.ts b/packages/runtime-vapor/__tests__/apiCreateVaporApp.spec.ts index a29a849d773..96cf69b5759 100644 --- a/packages/runtime-vapor/__tests__/apiCreateVaporApp.spec.ts +++ b/packages/runtime-vapor/__tests__/apiCreateVaporApp.spec.ts @@ -58,6 +58,24 @@ describe('api: createVaporApp', () => { expect(`already been mounted`).toHaveBeenWarned() }) + test('preserves root context while evaluating root props', () => { + const Comp = defineVaporComponent({ + props: ['value'], + setup(props) { + return createTextNode(String(props.value)) + }, + }) + const app = createVaporApp(Comp, { value: () => inject('token') }) + app.provide('token', 'provided value') + const root = document.createElement('div') + try { + app.mount(root) + expect(root.textContent).toBe('provided value') + } finally { + app.unmount() + } + }) + test('mount should no-op when selector returns null', () => { const Comp = defineVaporComponent({ setup() { diff --git a/packages/runtime-vapor/__tests__/apiDefineAsyncComponent.spec.ts b/packages/runtime-vapor/__tests__/apiDefineAsyncComponent.spec.ts index f97752fdffe..90f53b1cf67 100644 --- a/packages/runtime-vapor/__tests__/apiDefineAsyncComponent.spec.ts +++ b/packages/runtime-vapor/__tests__/apiDefineAsyncComponent.spec.ts @@ -1515,6 +1515,115 @@ describe('api: defineAsyncComponent', () => { app.unmount() }, ) + + test.each([false, true])( + 'forwards delivered wrapper inputs without resampling them (v-once: %s)', + async once => { + const source = ref('a') + const sample = vi.fn(() => source.value) + const data = ref({ sample }) + let resolve!: (component: VaporComponent) => void + let read!: () => unknown + const Child = defineVaporComponent({ + props: ['value'], + setup(props) { + read = () => props.value + const node = template('')() + renderEffect(() => setElementText(node, String(props.value))) + return node + }, + }) + const Async = defineVaporAsyncComponent( + () => new Promise(r => (resolve = r)), + ) + const App = compile( + ``, + data, + { Async }, + ) + const { app, host } = define(App).render() + expect(sample).toHaveBeenCalledTimes(1) + + source.value = 'b' + await nextTick() + expect(sample).toHaveBeenCalledTimes(once ? 1 : 2) + resolve(Child) + await timeout() + + const delivered = once ? 'a' : 'b' + expect(host.textContent).toBe(delivered) + expect(read()).toBe(delivered) + expect(sample).toHaveBeenCalledTimes(once ? 1 : 2) + + source.value = 'c' + expect(read()).toBe(delivered) + await nextTick() + expect(read()).toBe(once ? 'a' : 'c') + expect(host.textContent).toBe(once ? 'a' : 'c') + expect(sample).toHaveBeenCalledTimes(once ? 1 : 3) + app.unmount() + }, + ) + + test('forwards delivered props and listeners to the error component without resampling sources', async () => { + const first = vi.fn() + const second = vi.fn() + const source = ref({ name: 'a', listener: first }) + const sample = vi.fn(() => ({ + item: { name: source.value.name }, + title: source.value.name, + onClick: source.value.listener, + })) + const data = ref({ sample }) + const failure = new Error('failed') + let reject!: (error: Error) => void + const ErrorComp = compile( + ` + `, + data, + ) + const Async = defineVaporAsyncComponent({ + loader: () => new Promise((_, r) => (reject = r)), + errorComponent: ErrorComp, + }) + const App = compile( + '', + data, + { Async }, + ) + const { app, host, mount } = define(App).create() + const errors: unknown[] = [] + app.config.errorHandler = error => errors.push(error) + mount() + expect(sample).toHaveBeenCalledTimes(1) + + reject(failure) + await timeout() + const button = host.querySelector('button')! + expect(button.textContent).toBe('a:failed') + expect(button.title).toBe('a') + expect(sample).toHaveBeenCalledTimes(1) + button.click() + expect(first).toHaveBeenCalledTimes(1) + expect(second).not.toHaveBeenCalled() + + source.value = { name: 'b', listener: second } + button.click() + expect(first).toHaveBeenCalledTimes(2) + expect(second).not.toHaveBeenCalled() + await nextTick() + + expect(host.querySelector('button')).toBe(button) + expect(button.textContent).toBe('b:failed') + expect(button.title).toBe('b') + expect(sample).toHaveBeenCalledTimes(2) + button.click() + expect(first).toHaveBeenCalledTimes(2) + expect(second).toHaveBeenCalledTimes(1) + expect(errors).toEqual([failure]) + app.unmount() + }) }) function mountAsyncError( diff --git a/packages/runtime-vapor/__tests__/apiSetupHelpers.spec.ts b/packages/runtime-vapor/__tests__/apiSetupHelpers.spec.ts index 6a5cd4c6ad2..b92fdd005c7 100644 --- a/packages/runtime-vapor/__tests__/apiSetupHelpers.spec.ts +++ b/packages/runtime-vapor/__tests__/apiSetupHelpers.spec.ts @@ -1,12 +1,12 @@ import { createComponent, defineVaporComponent, template } from '../src' -import { ref, useAttrs, useSlots } from '@vue/runtime-dom' +import { nextTick, ref, useAttrs, useSlots } from '@vue/runtime-dom' import { makeRender } from './_utils' import type { VaporComponentInstance } from '../src/component' const define = makeRender() describe('SFC `, + data, + ) + const { app, host } = define( + compile( + '', + data, + { Child }, + ), + ).render() + expect(data.value.count).toBe(1) + expect(host.innerHTML).toBe('
0
') + await nextTick() + expect(host.innerHTML).toBe('
1
') + app.unmount() + }) + + // vdom reads listeners off the vnode, which nothing can subscribe to + test('emitting inside an effect does not subscribe it to the inputs', async () => { + const pings: Record = {} + await renderParity( + { + Child: ``, + App: ``, + }, + () => ref({ other: 'a', local: 1, pings: [] }), + async (data, root, mode) => { + data.value.other = 'b' + await nextTick() + pings[mode] = data.value.pings.length + }, + ) + expect(pings.vdom).toBe(1) + expect(pings.vapor).toBe(pings.vdom) + }) + + test('writing a model inside an effect does not subscribe it to the inputs', async () => { + const runs: Record = {} + await renderParity( + { + Child: ``, + App: ``, + }, + () => + ref({ + m: 'a', + local: 'b', + other: 0, + counter: markRaw({ runs: 0 }), + }), + async (data, root, mode) => { + await nextTick() + const before = data.value.counter.runs + data.value.other++ + await nextTick() + runs[mode] = data.value.counter.runs - before + }, + ) + expect(runs.vdom).toBe(0) + expect(runs.vapor).toBe(runs.vdom) + }) }) diff --git a/packages/runtime-vapor/__tests__/componentProps.spec.ts b/packages/runtime-vapor/__tests__/componentProps.spec.ts index be98c7359ac..7265320acce 100644 --- a/packages/runtime-vapor/__tests__/componentProps.spec.ts +++ b/packages/runtime-vapor/__tests__/componentProps.spec.ts @@ -1,20 +1,26 @@ // NOTE: This test is implemented based on the case of `runtime-core/__test__/componentProps.spec.ts`. import { - // currentInstance, + createApp, + currentInstance, inject, isShallow, + markRaw, nextTick, provide, ref, + shallowRef, toRefs, watch, + watchSyncEffect, } from '@vue/runtime-dom' import { createComponent, + createVaporApp, defineVaporComponent, renderEffect, template, + vaporInteropPlugin, } from '../src' import { resolveDynamicProps } from '../src/componentProps' import { compile, makeRender, renderParity } from './_utils' @@ -545,8 +551,12 @@ describe('component: props', () => { const bar = ref(1) render({ foo: () => foo.value, bar: () => bar.value }) + const captured = props.foo + expect(captured).toBe(foo.value) // nested mutation should not trigger, same as shallowReactive props in vdom foo.value.nested.count++ + expect(props.foo).toBe(captured) + expect(props.foo.nested.count).toBe(1) await nextTick() expect(changeSpy).toHaveBeenCalledTimes(0) @@ -554,6 +564,12 @@ describe('component: props', () => { await nextTick() expect(changeSpy).toHaveBeenCalledTimes(1) expect(isShallow(props)).toBe(true) + + foo.value = { nested: { count: 2 } } + expect(props.foo).toBe(captured) + await nextTick() + expect(props.foo).toBe(foo.value) + expect(changeSpy).toHaveBeenCalledTimes(2) }) test('support null in required + multiple-type declarations', () => { @@ -1347,4 +1363,1205 @@ describe('component: props', () => { }, ) }) + + // #15673: a compiled prop getter belongs to the parent's template, so once + // the guard around it turns false the branch is going away; an inline + // consumer of the child (a `flush: 'sync'` watcher) must not surface the + // read that the guard made unreachable. + describe('prop source re-read after its guard flipped', () => { + const Child = `` + + // `x` goes away while Child (or a component around it) shows its `y`: + // neither mode may see the watcher fire or the read throw + async function expectGuardedRead(srcs: Record) { + const seen: Record = {} + const { vdom, vapor } = await renderParity( + { Child, ...srcs }, + () => ref({ x: { y: 'a' }, seen: [] }), + (data, root, mode) => { + expect(root.textContent).toBe('child: a') + data.value.x = undefined + seen[mode] = data.value.seen + }, + ) + expect(seen.vdom).toEqual([]) + expect(seen.vapor).toEqual(seen.vdom) + expect(vapor.text).toBe('x is gone') + expect(vdom.text).toBe(vapor.text) + } + + test('v-if child with a sync watcher', () => + expectGuardedRead({ + App: ``, + })) + + test('v-for row created after the branch rendered', async () => { + const seen: Record = {} + const { vdom, vapor } = await renderParity( + { + Child, + App: ``, + }, + () => ref({ x: { y: 'a' }, list: [1], seen: [] }), + async (data, root, mode) => { + data.value.list.push(2) + await nextTick() + expect(root.textContent!.replace(/\s/g, '')).toBe('child:achild:a') + data.value.x = undefined + seen[mode] = data.value.seen + }, + ) + expect(seen.vdom).toEqual([]) + expect(seen.vapor).toEqual(seen.vdom) + expect(vapor.text).toBe('x is gone') + expect(vdom.text).toBe(vapor.text) + }) + + test('grandchild watching a forwarded prop', () => + expectGuardedRead({ + Mid: ``, + App: ``, + })) + + test('source fixed again within the same tick', async () => { + const seen: Record = {} + const { vdom, vapor } = await renderParity( + { + Child, + App: ``, + }, + () => ref({ x: { y: 'a' }, seen: [] }), + async (data, root, mode) => { + data.value.x = undefined + data.value.x = { y: 'b' } + await nextTick() + seen[mode] = data.value.seen + }, + ) + expect(seen.vdom).toEqual(['b']) + expect(seen.vapor).toEqual(seen.vdom) + expect(vapor.text).toBe('child: b') + expect(vdom.text).toBe(vapor.text) + }) + + test('guard flips without the source throwing', async () => { + const seen: Record = {} + const { vdom, vapor } = await renderParity( + { + Child, + App: ``, + }, + () => ref({ x: { ok: true, y: 'a' }, seen: [] }), + (data, root, mode) => { + expect(root.textContent).toBe('child: a') + data.value.x = { ok: false, y: 'other' } + seen[mode] = data.value.seen + }, + ) + expect(seen.vdom).toEqual([]) + expect(seen.vapor).toEqual(seen.vdom) + expect(vapor.text).toBe('x is gone') + expect(vdom.text).toBe(vapor.text) + }) + + test('computed over the prop read before the sync watcher exists', async () => { + const seen: Record = {} + const { vdom, vapor } = await renderParity( + { + Child: ``, + App: ``, + }, + () => ref({ x: { y: 'a' }, local: 0, seen: [] }), + (data, root, mode) => { + expect(data.value.first).toBe('a') + data.value.x = undefined + data.value.local++ + seen[mode] = data.value.seen + }, + ) + expect(seen.vdom).toEqual(['a']) + expect(seen.vapor).toEqual(seen.vdom) + expect(vapor.text).toBe('x is gone') + expect(vdom.text).toBe(vapor.text) + }) + + // Only the prop reads move to the parent's commit; the watcher itself + // stays synchronous for everything else it depends on. + test('sync watcher over local state and a prop', async () => { + const seen: Record = {} + await renderParity( + { + Child: ``, + App: ``, + }, + () => ref({ x: { y: 'a' }, local: 0, seen: [] }), + async (data, root, mode) => { + data.value.local = 1 + seen[`${mode}:local`] = [...data.value.seen] + data.value.x.y = 'b' + seen[`${mode}:prop`] = [...data.value.seen] + await nextTick() + seen[`${mode}:flushed`] = [...data.value.seen] + }, + ) + expect(seen['vdom:local']).toEqual(['1:a']) + expect(seen['vapor:local']).toEqual(seen['vdom:local']) + expect(seen['vdom:prop']).toEqual(['1:a']) + expect(seen['vapor:prop']).toEqual(seen['vdom:prop']) + expect(seen['vdom:flushed']).toEqual(['1:a', '1:b']) + expect(seen['vapor:flushed']).toEqual(seen['vdom:flushed']) + }) + + test('v-bind object source', () => + expectGuardedRead({ + App: ``, + })) + + test('slot content guarded by the slot owner', () => + expectGuardedRead({ + Wrapper: ``, + App: ``, + })) + + test('slot content guarded inside the slot host', () => + expectGuardedRead({ + Wrapper: ``, + App: ``, + })) + + // useModel watches the prop synchronously on the component's behalf + test('v-model child under the guard', async () => { + const { vdom, vapor } = await renderParity( + { + Child: ``, + App: ``, + }, + () => ref({ x: { y: 'a' } }), + (data, root) => { + expect(root.textContent).toBe('child: a') + data.value.x = undefined + }, + ) + expect(vapor.text).toBe('x is gone') + expect(vdom.text).toBe(vapor.text) + }) + + // the same raw props serve every component a dynamic component switches + // to; a commit must not outlive the component that asked for it + test('dynamic component switched away from the watching one', async () => { + const { vdom, vapor } = await renderParity( + { + A: Child, + B: ``, + App: ``, + }, + () => ref({ a: true, y: 'a', seen: [] }), + async (data, root) => { + expect(root.textContent).toBe('child: a') + data.value.a = false + await nextTick() + expect(root.textContent).toBe('b: a') + data.value.y = 'b' + await nextTick() + }, + ) + expect(vapor.text).toBe('b: b') + expect(vdom.text).toBe(vapor.text) + }) + + // coverage guard: interop props are committed by the vdom parent's patch + // already, so a vapor child under a vdom v-if needs no commit of its own + test('vapor child with a sync watcher under a vdom v-if', async () => { + const data = ref({ x: { y: 'a' }, seen: [] }) + const components: Record = {} + components.Child = compile(Child, data, components, { vapor: true }) + const App = compile( + ` + `, + data, + components, + { vapor: false }, + ) + const root = document.createElement('div') + const app = createApp(App) + app.use(vaporInteropPlugin).mount(root) + expect(root.textContent).toBe('child: a') + data.value.x = undefined + await nextTick() + expect(data.value.seen).toEqual([]) + expect(root.textContent).toBe('x is gone') + app.unmount() + }) + + test('vdom child with a sync watcher under a vapor v-if', async () => { + const data = ref({ x: { y: 'a' }, seen: [] }) + const components: Record = {} + components.Child = compile(Child, data, components, { vapor: false }) + const App = compile( + ``, + data, + components, + { vapor: true }, + ) + const root = document.createElement('div') + const app = createVaporApp(App) + app.use(vaporInteropPlugin).mount(root) + expect(root.textContent).toBe('child: a') + data.value.x = undefined + await nextTick() + expect(data.value.seen).toEqual([]) + expect(root.textContent).toBe('x is gone') + app.unmount() + }) + + // #15228: deactivation freezes parent inputs while local effects stay live. + test('kept-alive child with a sync watcher', async () => { + const seen: Record = {} + const { vdom, vapor } = await renderParity( + { + Child, + App: ``, + }, + () => ref({ x: { y: 'a' }, seen: [] }), + async (data, root, mode) => { + expect(root.textContent).toBe('child: a') + data.value.x = undefined + await nextTick() + expect(root.textContent).toBe('x is gone') + data.value.x = { y: 'b' } + await nextTick() + seen[mode] = data.value.seen + }, + ) + expect(seen.vdom).toEqual(['b']) + expect(seen.vapor).toEqual(seen.vdom) + expect(vapor.text).toBe('child: b') + expect(vdom.text).toBe(vapor.text) + }) + + // a v-bind source hands over the reactive object itself; the commit must + // snapshot its top level so the child stops depending on the container + test('v-bind of a reactive object', async () => { + const seen: Record = {} + const { vdom, vapor } = await renderParity( + { + Child, + App: ``, + }, + () => ref({ bag: { y: 'a' }, seen: [] }), + async (data, root, mode) => { + expect(root.textContent).toBe('child: a') + data.value.bag.y = 'b' + await nextTick() + expect(root.textContent).toBe('child: b') + delete data.value.bag.y + seen[mode] = data.value.seen + }, + ) + expect(seen.vdom).toEqual(['b']) + expect(seen.vapor).toEqual(seen.vdom) + expect(vapor.text).toBe('x is gone') + expect(vdom.text).toBe(vapor.text) + }) + + test('computed over a v-bind prop read before the sync watcher exists', async () => { + const seen: Record = {} + const { vdom, vapor } = await renderParity( + { + Child: ``, + App: ``, + }, + () => ref({ bag: { y: 'a' }, seen: [] }), + (data, root, mode) => { + expect(data.value.first).toBe('a') + delete data.value.bag.y + seen[mode] = data.value.seen + }, + ) + expect(seen.vdom).toEqual([]) + expect(seen.vapor).toEqual(seen.vdom) + expect(vapor.text).toBe('x is gone') + expect(vdom.text).toBe(vapor.text) + }) + + // A first read triggered outside the component must see its delivered value. + test('input first read by a sync watcher outside the component', async () => { + const seen: Record = {} + await renderParity( + { + Child: ``, + App: ``, + }, + () => + ref({ + x: { y: 'a' }, + enabled: false, + seen: [], + }), + (data, root, mode) => { + data.value.x.y = 'b' + data.value.enabled = true + seen[mode] = [...data.value.seen] + }, + ) + expect(seen.vdom).toEqual(['a']) + expect(seen.vapor).toEqual(seen.vdom) + }) + + test('object prop keeps its identity when a sync watcher is registered', async () => { + const same: Record = {} + await renderParity( + { + Child: ``, + App: ``, + }, + () => ref({ x: 'a' }), + (data, root, mode) => { + same[mode] = data.value.same + }, + ) + expect(same.vdom).toEqual([true, true]) + expect(same.vapor).toEqual(same.vdom) + }) + + test('sync watcher callbacks do not become prop source dependencies', async () => { + await renderParity( + { + Child: ``, + App: ``, + }, + () => ref({ x: 'a' }), + async (data, root) => { + expect(root.textContent).toBe('a:0') + data.value.x = 'b' + await nextTick() + expect(root.textContent).toBe('b:1') + data.value.x = 'c' + await nextTick() + expect(root.textContent).toBe('c:2') + }, + ) + }) + + // Without a guard the read is the parent's own bug. vdom reports it from + // the parent render; vapor must report it the same way instead of + // throwing it at whoever assigned the ref. + test('unguarded source reports through the app error handler', async () => { + for (const vapor of [false, true]) { + const data = ref({ x: { y: 'a' }, seen: [] }) + const components: Record = {} + components.Child = compile(Child, data, components, { vapor }) + const App = compile( + ` + `, + data, + components, + { vapor }, + ) + const root = document.createElement('div') + const app = vapor ? createVaporApp(App) : createApp(App) + const handler = (app.config.errorHandler = vi.fn()) + app.use(vaporInteropPlugin).mount(root) + expect(root.textContent).toBe('child: a') + + expect(() => (data.value.x = undefined)).not.toThrow() + await nextTick() + expect(handler).toHaveBeenCalledTimes(1) + expect(handler.mock.calls[0][0]).toBeInstanceOf(TypeError) + expect(data.value.seen).toEqual([]) + app.unmount() + } + }) + }) + + test('keeps the public props identity and updates only affected key consumers', async () => { + const data = ref({ count: 0, stable: 42 }) + let props: any + let readProps!: () => unknown + const countReads: unknown[] = [] + const stableReads: unknown[] = [] + let setups = 0 + const Child = defineVaporComponent({ + props: ['count', 'stable'], + setup(received) { + setups++ + props = received + readProps = () => received + watchSyncEffect(() => countReads.push(received.count)) + watchSyncEffect(() => stableReads.push(received.stable)) + return [] + }, + }) + const { app } = define( + compile( + ``, + data, + { Child }, + ), + ).render() + const original = props + + data.value.count = 1 + expect(props.count).toBe(0) + await nextTick() + + app.unmount() + expect(setups).toBe(1) + expect(readProps()).toBe(original) + expect(props.count).toBe(1) + expect(countReads).toEqual([0, 1]) + expect(stableReads).toEqual([42]) + }) + + test('retains an unread intermediate delivery after its source becomes invalid', async () => { + let exposed: any + const data = ref({ + x: { y: 'a' }, + capture: (instance: any) => { + if (instance) exposed = instance + }, + }) + const Child = defineVaporComponent({ + props: ['value'], + setup(props, { expose }) { + expose({ read: () => props.value }) + return [] + }, + }) + const { app } = define( + compile( + ``, + data, + { Child }, + ), + ).render() + const read = exposed.read + // No application consumer has read the value, including its initial value. + // This test is also run with DEV disabled to exclude validation reads. + data.value.x = { y: 'b' } + await nextTick() + data.value.x = undefined + + expect(read()).toBe('b') + await nextTick() + expect(read()).toBe('b') + app.unmount() + }) + + test('updates dynamic v-bind keys and attrs without invoking function values', async () => { + const first = vi.fn() + const second = vi.fn() + const data = ref({ bag: { known: 'a', legacy: 'old', fn: first } }) + let props: any + let attrs: any + const Child = defineVaporComponent({ + inheritAttrs: false, + props: ['known', 'fn'], + setup(received, context) { + props = received + attrs = context.attrs + return [] + }, + }) + const { app } = define( + compile( + ``, + data, + { Child }, + ), + ).render() + expect(props.fn).toBe(first) + expect({ ...attrs }).toEqual({ legacy: 'old' }) + + delete data.value.bag.legacy + data.value.bag.next = 'new' + data.value.bag.known = 'b' + data.value.bag.fn = second + await nextTick() + + expect(props.known).toBe('b') + expect(props.fn).toBe(second) + expect({ ...attrs }).toEqual({ next: 'new' }) + expect(first).not.toHaveBeenCalled() + expect(second).not.toHaveBeenCalled() + app.unmount() + }) + + test('uses Object.is equality for delivered NaN and signed zero values', async () => { + const data = shallowRef({ value: NaN }) + const seen: unknown[] = [] + const Child = defineVaporComponent({ + props: ['value'], + setup(props) { + watchSyncEffect(() => seen.push(props.value)) + return [] + }, + }) + const { app } = define( + compile( + ``, + data, + { Child }, + ), + ).render() + + // Replacing the container reruns input collection even when the prop is equal. + data.value = { value: NaN } + await nextTick() + expect(seen).toEqual([NaN]) + + data.value = { value: 0 } + await nextTick() + data.value = { value: -0 } + await nextTick() + data.value = { value: -0 } + await nextTick() + data.value = { value: 0 } + await nextTick() + + expect(seen).toEqual([NaN, 0, -0, 0]) + app.unmount() + }) + + test('preserves ref prop identity without unwrapping or tracking its inner value', async () => { + const first = ref('first') + const second = ref('second') + const data = shallowRef({ value: first }) + const seen: unknown[] = [] + let props: any + const Child = defineVaporComponent({ + props: ['value'], + setup(received) { + props = received + watchSyncEffect(() => seen.push(received.value)) + return [] + }, + }) + const { app } = define( + compile( + ``, + data, + { Child }, + ), + ).render() + expect(props.value).toBe(first) + + first.value = 'changed' + await nextTick() + expect(props.value).toBe(first) + expect(props.value.value).toBe('changed') + expect(seen).toEqual([first]) + + data.value = { value: second } + expect(props.value).toBe(first) + await nextTick() + expect(props.value).toBe(second) + expect(seen).toEqual([first, second]) + app.unmount() + }) + + test('resolves Boolean and default props when dynamic inputs change', async () => { + await renderParity( + { + Child: ``, + App: ``, + }, + () => ref({ bag: {} }), + async (data, root) => { + expect(root.textContent).toBe('false:fallback') + data.value.bag = { enabled: '', label: undefined } + await nextTick() + expect(root.textContent).toBe('true:fallback') + data.value.bag = { enabled: false, label: 'named' } + await nextTick() + expect(root.textContent).toBe('false:named') + data.value.bag = {} + await nextTick() + expect(root.textContent).toBe('false:fallback') + }, + ) + }) + + test('updates ordinary props before running default factories', async () => { + await renderParity( + { + Child: ``, + App: ``, + }, + () => + ref({ + bag: { first: 'first-old', normal: 'old', second: 'second-old' }, + reads: [], + }), + async (data, root) => { + expect(data.value.reads).toEqual([]) + data.value.bag = { normal: 'new', first: undefined, second: undefined } + await nextTick() + expect(data.value.reads).toEqual([ + ['first', 'new', 'second-old'], + ['second', 'new', 'first-default'], + ]) + expect(root.textContent).toBe('first-default:new:second-default') + }, + ) + }) + + test.each(['getter', 'default factory'])( + 'stops collecting inputs after an initial %s failure', + async failureStage => { + const trigger = ref(0) + const failure = new Error(`initial ${failureStage} failure`) + const errors: unknown[] = [] + const setup = vi.fn(() => []) + const read = vi.fn(() => { + void trigger.value + if (failureStage === 'getter') throw failure + return undefined + }) + const defaultValue = vi.fn(() => { + throw failure + }) + const Child = defineVaporComponent({ + props: { + value: + failureStage === 'default factory' ? { default: defaultValue } : {}, + }, + setup, + }) + const { create, mount } = define({ + render: () => [], + setup: () => createComponent(Child, { value: read }), + }) + const { app } = create() + app.config.errorHandler = error => errors.push(error) + mount() + expect(errors).toEqual([failure]) + expect(setup).not.toHaveBeenCalled() + expect(read).toHaveBeenCalledTimes(1) + app.unmount() + trigger.value++ + await nextTick() + expect(read).toHaveBeenCalledTimes(1) + expect(errors).toEqual([failure]) + expect(defaultValue).toHaveBeenCalledTimes( + failureStage === 'default factory' ? 1 : 0, + ) + }, + ) + + describe('input evaluation', () => { + // the inputs of a child are evaluated together, so a literal is a new + // value whenever one of them changes + test('a literal input is a new value when another one changes', async () => { + const changed: Record = {} + await renderParity( + { + Child: ``, + App: ``, + }, + () => + ref({ count: 0, stable: 's', counter: markRaw({ changed: 0 }) }), + async (data, root, mode) => { + data.value.count++ + await nextTick() + expect(root.textContent).toBe('1') + changed[mode] = data.value.counter.changed + }, + ) + expect(changed.vdom).toBe(1) + expect(changed.vapor).toBe(changed.vdom) + }) + + test('the inputs of a child are evaluated together', async () => { + const calls: Record = {} + await renderParity( + { + Child: ``, + App: ``, + }, + () => ref({ flag: 0, own: 0, counter: markRaw({ calls: 0 }) }), + async (data, root, mode) => { + const seen = [data.value.counter.calls] + data.value.flag++ + await nextTick() + data.value.flag++ + await nextTick() + seen.push(data.value.counter.calls) + data.value.own++ + await nextTick() + seen.push(data.value.counter.calls) + calls[mode] = seen + }, + ) + expect(calls.vdom).toEqual([1, 3, 4]) + expect(calls.vapor).toEqual(calls.vdom) + }) + + // normalizing a style allocates; an untouched source must not look new. + // vdom notifies attrs as a whole, so its watcher runs again + test.each([ + ['alone', ''], + ['next to a v-bind source', 'v-bind="data.bag"'], + ])( + 'a style input %s keeps its value while another one changes', + async (_, bind) => { + const runs: Record = {} + await renderParity( + { + Child: ``, + App: ``, + }, + () => + ref({ + c: 'red', + n: 0, + bag: {}, + counter: markRaw({ runs: 0 }), + }), + async (data, root, mode) => { + data.value.n++ + await nextTick() + expect(root.textContent).toBe('1') + runs[mode] = data.value.counter.runs + }, + ) + expect(runs.vdom).toBe(2) + expect(runs.vapor).toBe(1) + }, + ) + + test('each kind of input follows its own source', async () => { + const steps: Record = {} + await renderParity( + { + Child: ``, + App: ``, + }, + () => + ref({ + flag: false, + size: 's', + n: 0, + title: 't', + on: false, + pings: [], + handler: markRaw(function first(this: any) {}), + }), + async (data, root, mode) => { + const seen = [root.textContent!.trim()] + const step = async (change: () => void) => { + change() + await nextTick() + seen.push(root.textContent!.trim()) + } + await step(() => (data.value.flag = '')) + await step(() => (data.value.size = undefined)) + await step(() => (data.value.size = 'l')) + await step(() => data.value.n++) + await step(() => (data.value.title = 'x')) + await step(() => (data.value.on = true)) + await step(() => { + data.value.handler = markRaw(() => data.value.pings.push('second')) + }) + data.value.ping() + seen.push(data.value.pings.join()) + steps[mode] = seen + }, + ) + expect(steps.vdom).toEqual([ + 'false|s|0|t|', + 'true|s|0|t|', + 'true|m|0|t|', + 'true|l|0|t|', + 'true|l|1|t|', + 'true|l|1|x|', + 'true|l|1|x|on', + 'true|l|1|x|on', + 'second', + ]) + expect(steps.vapor).toEqual(steps.vdom) + }) + + // a delivery that throws half-way is redone in full on the next run. + // vdom resolves a default before the sibling props it reads, so the + // factory cannot fail there + test('a failed delivery is retried in full', async () => { + const data = ref({ + model: 'x', + options: [{ value: 'a' }], + disabled: false, + }) + const Child = compile( + ``, + data, + ) + const App = compile( + ``, + data, + { Child }, + ) + const errors: unknown[] = [] + const root = document.createElement('div') + const app = createVaporApp(App) + app.config.errorHandler = e => errors.push(e) + app.mount(root) + expect(root.textContent).toBe('x|false') + data.value.model = undefined + data.value.options = [] + data.value.disabled = true + await nextTick() + expect(errors.length).toBe(1) + data.value.options = [{ value: 'b' }] + await nextTick() + expect(root.textContent).toBe('b|true') + app.unmount() + }) + + // a sync watcher sees a delivery as a whole, whichever prop is written + // first. vdom writes key by key, so the guarded-first order throws there + test.each([ + ['the guard first', `:show="data.show" :item="data.item"`], + ['the guarded one first', `:item="data.item" :show="data.show"`], + ])('a delivery is atomic for a sync watcher, %s', async (_, attrs) => { + const data = ref({ show: true, item: { name: 'a' }, seen: [] }) + const Child = compile( + ``, + data, + ) + const App = compile( + ``, + data, + { Child }, + ) + const root = document.createElement('div') + const app = createVaporApp(App) + app.mount(root) + expect(data.value.seen).toEqual(['a']) + data.value.show = false + data.value.item = null + await nextTick() + expect(data.value.seen).toEqual(['a']) + data.value.item = { name: 'b' } + data.value.show = true + await nextTick() + expect(data.value.seen).toEqual(['a', 'b']) + app.unmount() + }) + + // reading `attrs.toString` goes through the proxy: such a key must not + // resolve to an inherited member of the dep table + test('an inherited key is not a dep', () => { + const data = ref({ n: 0 }) + const Child = compile( + ``, + data, + ) + const { app } = define( + compile( + ``, + data, + { Child }, + ), + ).render() + expect(Object.prototype.toString).not.toHaveProperty('subs') + expect(Object).not.toHaveProperty('subs') + app.unmount() + }) + + // the host's update job unmounts the child before its inputs can run + test('inputs run after the vdom host that guards them', async () => { + const data = ref({ user: { name: 'n' } }) + const components: Record = {} + components.Modal = compile( + ``, + data, + components, + { vapor: false }, + ) + components.Child = compile( + ``, + data, + components, + ) + const App = compile( + ``, + data, + components, + ) + const root = document.createElement('div') + const app = createVaporApp(App) + app.use(vaporInteropPlugin).mount(root) + expect(root.textContent).toBe('n') + data.value.user = null + await nextTick() + expect(root.textContent).toBe('') + app.unmount() + }) + + // observed on the instance: inputs that can never change are not tracked + test('inputs that cannot change are static', () => { + const instances: Record = {} + const data = ref({ c: 1 }) + const Child = (name: string) => + compile( + ``, + data, + ) + data.value.capture = (name: string) => (instances[name] = currentInstance) + const { app } = define( + compile( + ``, + data, + { + None: Child('none'), + Static: Child('static'), + Constant: Child('constant'), + Live: Child('live'), + }, + ), + ).render() + expect(instances.none.hasDynamicProps).toBe(false) + expect(instances.static.hasDynamicProps).toBe(false) + expect(instances.constant.hasDynamicProps).toBe(false) + expect(instances.live.hasDynamicProps).toBe(true) + expect(instances.constant.props.opts).toEqual({ x: 1 }) + app.unmount() + }) + }) }) diff --git a/packages/runtime-vapor/__tests__/componentSlots.spec.ts b/packages/runtime-vapor/__tests__/componentSlots.spec.ts index 607d45680a5..7e953ffd7a6 100644 --- a/packages/runtime-vapor/__tests__/componentSlots.spec.ts +++ b/packages/runtime-vapor/__tests__/componentSlots.spec.ts @@ -26,6 +26,7 @@ import { createSlots, currentInstance, h, + markRaw, nextTick, onScopeDispose, reactive, @@ -7840,4 +7841,441 @@ describe('component: slots', () => { } }) }) + + // A dynamic slot descriptor is the parent's expression as much as a prop + // getter is: the child reads what the parent delivered, in its update order. + describe('dynamic slot descriptors are delivered', () => { + const Child = (read: string) => `` + + async function expectGuarded( + srcs: Record, + x: any, + first: unknown, + ) { + const seen: Record = {} + const { vdom, vapor } = await renderParity( + srcs, + () => ref({ x, seen: [] }), + (data, root, mode) => { + data.value.x = undefined + seen[mode] = [...data.value.seen] + }, + ) + expect(seen.vdom).toEqual([first]) + expect(seen.vapor).toEqual(seen.vdom) + expect(vapor.text).toBe('gone') + expect(vdom.text).toBe(vapor.text) + } + + test('conditional slot read by a sync watcher', () => + expectGuarded( + { + Child: Child('!!slots.foo'), + App: ``, + }, + { show: true }, + true, + )) + + test('dynamic slot name read by a sync watcher', () => + expectGuarded( + { + Child: Child('Object.keys(slots).join()'), + App: ``, + }, + { name: 'foo' }, + 'foo', + )) + + test('v-for slots read by a sync watcher', () => + expectGuarded( + { + Child: Child('Object.keys(slots).join()'), + App: ``, + }, + { names: ['foo', 'bar'] }, + 'foo,bar', + )) + + test('vdom child under a vapor parent', async () => { + const data = ref({ x: { show: true }, seen: [] }) + const components: Record = {} + components.Child = compile(Child('!!slots.foo'), data, components, { + vapor: false, + }) + const App = compile( + ``, + data, + components, + { vapor: true }, + ) + const root = document.createElement('div') + const app = createVaporApp(App) + app.use(vaporInteropPlugin).mount(root) + expect(root.textContent).toBe('foo') + data.value.x = undefined + expect(data.value.seen).toEqual([true]) + await nextTick() + expect(root.textContent).toBe('gone') + app.unmount() + }) + + // vdom slots are not reactive, so only the timing is comparable: nothing + // reaches the child before the parent's update + test('a slot toggle reaches the child in the flush', async () => { + const seen: Record = {} + const { vdom, vapor } = await renderParity( + { + Child: Child('!!slots.foo'), + App: ``, + }, + () => ref({ x: { show: true }, seen: [] }), + async (data, root, mode) => { + data.value.x.show = false + seen[mode] = [...data.value.seen] + await nextTick() + }, + ) + expect(seen.vdom).toEqual([true]) + expect(seen.vapor).toEqual(seen.vdom) + expect(vapor.text).toBe('') + expect(vdom.text).toBe(vapor.text) + }) + + test('descriptors run after the vdom host that guards them', async () => { + const data = ref({ x: { flag: true } }) + const components: Record = {} + components.Host = compile( + ``, + data, + components, + { vapor: false }, + ) + components.Child = compile( + ``, + data, + components, + ) + const App = compile( + ``, + data, + components, + ) + const root = document.createElement('div') + const app = createVaporApp(App) + app.use(vaporInteropPlugin).mount(root) + expect(root.textContent).toBe('F') + data.value.x = undefined + await nextTick() + expect(root.textContent).toBe('') + app.unmount() + }) + + // the inner component's props arrive one hop later than the wrapper's + test('an async inner component reads its props before the descriptors', async () => { + let resolve!: (comp: any) => void + const data = ref({ item: undefined, seen: [] }) + const components: Record = {} + components.Inner = compile( + ``, + data, + components, + ) + components.Async = defineVaporAsyncComponent( + () => new Promise(r => (resolve = r)), + ) + const App = compile( + ``, + data, + components, + ) + const root = document.createElement('div') + const app = createVaporApp(App) + app.mount(root) + resolve(components.Inner) + await new Promise(r => setTimeout(r)) + expect(root.innerHTML).toBe( + '
', + ) + data.value.item = { name: 'n' } + await nextTick() + expect(data.value.seen).toEqual(['n']) + expect(root.textContent).toBe('foo') + app.unmount() + }) + + // a cache hit rebinds the props to the call site; the descriptors keep + // following them + test('descriptors follow the props again after a KeepAlive cache hit', async () => { + const data = ref({ cur: 'A', item: undefined, seen: [] }) + const components: Record = {} + const KeptChild = (name: string) => + compile( + ``, + data, + components, + ) + components.A = KeptChild('A') + components.B = KeptChild('B') + const App = compile( + ``, + data, + components, + ) + const root = document.createElement('div') + const app = createVaporApp(App) + app.mount(root) + data.value.cur = 'B' + await nextTick() + data.value.cur = 'A' + await nextTick() + data.value.item = { name: 'n' } + await nextTick() + expect(data.value.seen).toEqual(['A:n']) + expect(root.textContent).toBe('foo') + app.unmount() + }) + + // props and descriptors reach a sync watcher together. vdom delivers the + // props first, so the watcher throws there + test.each([ + ['vapor', true], + ['vdom', false], + ])( + 'a delivery is atomic across props and descriptors for a %s child', + async (_, vapor) => { + const data = ref({ x: { item: { name: 'a' } }, seen: [] }) + const components: Record = {} + components.Child = compile( + ``, + data, + components, + { vapor }, + ) + const App = compile( + ``, + data, + components, + ) + const root = document.createElement('div') + const app = createVaporApp(App) + app.use(vaporInteropPlugin).mount(root) + expect(data.value.seen).toEqual(['a']) + data.value.x = null + await nextTick() + expect(data.value.seen).toEqual(['a']) + expect(root.textContent).toBe('') + data.value.x = { item: { name: 'b' } } + await nextTick() + expect(data.value.seen).toEqual(['a', 'b']) + app.unmount() + }, + ) + + test('a vdom parent delivers props and slots to a vapor child together', async () => { + const data = ref({ x: { item: { name: 'a' } }, seen: [] }) + const components: Record = {} + components.Child = compile( + ``, + data, + components, + ) + const App = compile( + ` + `, + data, + components, + { vapor: false }, + ) + const root = document.createElement('div') + const app = createApp(App) + app.use(vaporInteropPlugin).mount(root) + expect(data.value.seen).toEqual(['a']) + data.value.x = null + await nextTick() + expect(data.value.seen).toEqual(['a']) + data.value.x = { item: { name: 'b' } } + await nextTick() + expect(data.value.seen).toEqual(['a', 'b']) + app.unmount() + }) + + // the delivery runs inside the vdom parent's update: a throwing sync + // watcher must not leave the tracking context unrestored + test('a throwing sync watcher in a vapor child of a vdom parent', async () => { + const data = ref({ n: 0 }) + const components: Record = {} + components.Child = compile( + ``, + data, + components, + ) + const App = compile( + ` + `, + data, + components, + { vapor: false }, + ) + const root = document.createElement('div') + const app = createApp(App) + app.use(vaporInteropPlugin).mount(root) + data.value.n = 1 + await expect(nextTick()).rejects.toThrow('boom') + expect( + 'Unhandled error during execution of watcher callback', + ).toHaveBeenWarned() + expect( + 'Unhandled error during execution of component update', + ).toHaveBeenWarned() + app.unmount() + }) + + // props are evaluated before the descriptors, as in vdom: one read, and + // none after the creation was aborted + test.each([ + ['vapor', true], + ['vdom', false], + ])( + 'a throwing descriptor leaves no input of a %s child behind', + async (_, vapor) => { + const data = ref({ n: 0, counter: markRaw({ reads: 0 }) }) + const components: Record = {} + components.Child = compile( + ``, + data, + components, + { vapor }, + ) + const App = compile( + ``, + data, + components, + { vapor: true }, + ) + const app = createVaporApp(App) + app.use(vaporInteropPlugin) + expect(() => app.mount(document.createElement('div'))).toThrow() + expect( + 'Unhandled error during execution of setup function', + ).toHaveBeenWarned() + expect(data.value.counter.reads).toBe(1) + data.value.n++ + await nextTick() + expect(data.value.counter.reads).toBe(1) + }, + ) + }) }) diff --git a/packages/runtime-vapor/__tests__/components/KeepAlive.spec.ts b/packages/runtime-vapor/__tests__/components/KeepAlive.spec.ts index 9c6bdf47fa1..75cc9dcea98 100644 --- a/packages/runtime-vapor/__tests__/components/KeepAlive.spec.ts +++ b/packages/runtime-vapor/__tests__/components/KeepAlive.spec.ts @@ -5213,4 +5213,210 @@ describe('VaporKeepAlive', () => { expect(out.vapor).toEqual(out.vdom) }) }) + + describe('input delivery across cache hits', () => { + test('keeps inactive inputs while local effects run and stops sampling on unmount', async () => { + const selected = ref<{ name: string } | undefined>({ name: 'a' }) + const local = ref(0) + const sample = vi.fn(() => selected.value!.name) + const activated: unknown[] = [] + const disposed = vi.fn() + let read!: () => unknown + const setup = vi.fn((props: { value: string }) => { + read = () => props.value + }) + const Child = compile( + ``, + { local, setup, activated, disposed } as any, + ) + const Parent = compile( + ``, + { selected, sample } as any, + { Child }, + ) + const { host, app } = define(Parent).render() + await nextTick() + const node = host.querySelector('div')! + expect(node.textContent).toBe('a:0') + + for (const [previous, value] of [ + ['a', 'b'], + ['b', 'c'], + ]) { + const samples = sample.mock.calls.length + selected.value = undefined + await nextTick() + expect(host.textContent).toBe('') + expect(read()).toBe(previous) + expect(sample).toHaveBeenCalledTimes(samples) + + local.value++ + await nextTick() + expect(node.textContent).toBe(`${previous}:${local.value}`) + expect(host.textContent).toBe('') + expect(sample).toHaveBeenCalledTimes(samples) + + selected.value = { name: value } + await nextTick() + expect(host.querySelector('div')).toBe(node) + expect(node.textContent).toBe(`${value}:${local.value}`) + expect(activated[activated.length - 1]).toBe(value) + } + expect(activated).toEqual(['a', 'b', 'c']) + expect(setup).toHaveBeenCalledTimes(1) + expect(disposed).not.toHaveBeenCalled() + + selected.value = undefined + await nextTick() + const samples = sample.mock.calls.length + app.unmount() + selected.value = { name: 'after-unmount' } + await nextTick() + expect(sample).toHaveBeenCalledTimes(samples) + expect(disposed).toHaveBeenCalledTimes(1) + }) + + test('rebinds a queued invalid input to another call site without retaining its old dependency', async () => { + const view = ref<'a' | 'b' | 'none'>('a') + const sourceA = ref<{ value: string } | undefined>({ value: 'a0' }) + const sourceB = ref({ value: 'b0' }) + const readA = vi.fn(() => sourceA.value!.value) + const readB = vi.fn(() => sourceB.value.value) + const activated: unknown[] = [] + const setup = vi.fn() + let read!: () => unknown + const Child = defineVaporComponent({ + props: ['value'], + setup(props) { + setup() + read = () => props.value + onActivated(() => activated.push(props.value)) + const node = template('
')() as HTMLDivElement + const text = child(node) as Text + renderEffect(() => setText(text, props.value)) + return node + }, + }) + // Explicit call sites let the same cache entry receive different getters. + const Parent = defineVaporComponent(() => + createComponent(VaporKeepAlive, null, { + default: () => + createIf( + () => view.value === 'a', + () => createComponent(Child, { key: 'shared', value: readA }), + () => + createIf( + () => view.value === 'b', + () => createComponent(Child, { key: 'shared', value: readB }), + ), + singleRootIfElse, + ), + }), + ) + const { host, app } = define(Parent).render() + await nextTick() + const node = host.querySelector('div')! + const aCalls = readA.mock.calls.length + expect(host.textContent).toBe('a0') + + // Queue the old getter before the structural update makes it unreachable. + sourceA.value = undefined + view.value = 'none' + await nextTick() + expect(readA).toHaveBeenCalledTimes(aCalls) + expect(host.textContent).toBe('') + expect(read()).toBe('a0') + + view.value = 'b' + await nextTick() + expect(host.querySelector('div')).toBe(node) + expect(host.textContent).toBe('b0') + expect(setup).toHaveBeenCalledTimes(1) + expect(activated).toEqual(['a0', 'b0']) + expect(readA).toHaveBeenCalledTimes(aCalls) + const bCalls = readB.mock.calls.length + + sourceA.value = { value: 'a-after-rebind' } + await nextTick() + expect(readA).toHaveBeenCalledTimes(aCalls) + expect(readB).toHaveBeenCalledTimes(bCalls) + expect(read()).toBe('b0') + + sourceB.value.value = 'b1' + await nextTick() + expect(readB.mock.calls.length).toBeGreaterThan(bCalls) + expect(host.textContent).toBe('b1') + expect(read()).toBe('b1') + app.unmount() + }) + + test.each([undefined, {}])( + 'adds attrs and fallthrough when an entry initially given %j is reused by another call site', + async initialRawProps => { + const page = ref(0) + const title = ref('from-new-call-site') + const setup = vi.fn() + let attrs!: Record + const Child = defineVaporComponent({ + setup(_props, context) { + // No initial key or inherited `$` may hide the empty-input case. + expect( + Object.keys((currentInstance as VaporComponentInstance).rawProps), + ).toEqual([]) + setup() + attrs = context.attrs + return template('
child
')() + }, + }) + expect(Child.props).toBeUndefined() + const Other = defineVaporComponent(() => template('

other

')()) + // Compiled branch keys or fallthrough sources could mask no rawProps. + const Parent = defineVaporComponent(() => + createComponent(VaporKeepAlive, null, { + default: () => + createIf( + () => page.value === 0, + () => createComponent(Child, initialRawProps), + () => + createIf( + () => page.value === 1, + () => createComponent(Other), + () => createComponent(Child, { title: () => title.value }), + ), + ), + }), + ) + const { host, app } = define(Parent).render() + const node = host.querySelector('div')! + expect(Object.keys(attrs)).toEqual([]) + expect(node.hasAttribute('title')).toBe(false) + + page.value = 1 + await nextTick() + expect(host.textContent).toBe('other') + page.value = 2 + await nextTick() + expect(setup).toHaveBeenCalledTimes(1) + expect(host.querySelector('div')).toBe(node) + expect(attrs.title).toBe('from-new-call-site') + expect(Object.keys(attrs)).toEqual(['title']) + expect(node.getAttribute('title')).toBe('from-new-call-site') + + title.value = 'updated' + await nextTick() + expect(attrs.title).toBe('updated') + expect(node.getAttribute('title')).toBe('updated') + app.unmount() + }, + ) + }) }) diff --git a/packages/runtime-vapor/__tests__/customElement.spec.ts b/packages/runtime-vapor/__tests__/customElement.spec.ts index 20271409244..952f5ffbb77 100644 --- a/packages/runtime-vapor/__tests__/customElement.spec.ts +++ b/packages/runtime-vapor/__tests__/customElement.spec.ts @@ -305,6 +305,34 @@ describe('defineVaporCustomElement', () => { expect(e.getAttribute('baz-qux')).toBe('four') }) + test('preserves function-valued properties without calling them during delivery', async () => { + const first = vi.fn(() => 'first') + const second = vi.fn(() => 'second') + let readCallback!: () => Function | undefined + const FunctionElement = defineVaporCustomElement({ + props: { callback: Function }, + setup(props) { + readCallback = () => props.callback + return template('
child
')() + }, + }) + customElements.define('my-el-function-property', FunctionElement) + const element = new FunctionElement({ callback: first }) + container.appendChild(element) + + expect(readCallback()).toBe(first) + expect(first).not.toHaveBeenCalled() + + element.callback = second + expect(readCallback()).toBe(first) + await nextTick() + expect(readCallback()).toBe(second) + expect(first).not.toHaveBeenCalled() + expect(second).not.toHaveBeenCalled() + expect(readCallback()!()).toBe('second') + expect(second).toHaveBeenCalledTimes(1) + }) + test('props via attributes and properties changed together', async () => { const e = new E() e.foo = 'foo1' diff --git a/packages/runtime-vapor/__tests__/gc.spec.ts b/packages/runtime-vapor/__tests__/gc.spec.ts new file mode 100644 index 00000000000..1642f38582c --- /dev/null +++ b/packages/runtime-vapor/__tests__/gc.spec.ts @@ -0,0 +1,85 @@ +// @vitest-environment jsdom + +import { createApp, nextTick, shallowRef } from '@vue/runtime-dom' +import { createVaporApp, vaporInteropPlugin } from '../src' +import { compile } from './_utils' + +describe.skipIf(!global.gc)('component props gc', () => { + const gc = () => { + return new Promise(resolve => { + setTimeout(() => { + global.gc!() + resolve() + }) + }) + } + + test('releases the initial cached prop value after an update', async () => { + const data = shallowRef({ y: { value: 0 } }) + // @ts-expect-error ES2021 API + const initialValue = new WeakRef(data.value.y) + const components = { + Child: compile( + ``, + data, + ), + } + const App = compile( + ``, + data, + components, + ) + const root = document.createElement('div') + const app = createVaporApp(App) + app.mount(root) + try { + expect(root.textContent).toBe('0') + data.value = { y: { value: 1 } } + await nextTick() + expect(root.textContent).toBe('1') + await gc() + expect(initialValue.deref()).toBeUndefined() + } finally { + app.unmount() + } + }) + + test('releases the initial prop value delivered by a vdom parent', async () => { + const data = shallowRef({ y: { value: 0 } }) + // @ts-expect-error ES2021 API + const initialValue = new WeakRef(data.value.y) + const components = { + Child: compile( + ``, + data, + ), + } + const App = compile( + ` + `, + data, + components, + { vapor: false }, + ) + const root = document.createElement('div') + const app = createApp(App).use(vaporInteropPlugin) + app.mount(root) + try { + expect(root.textContent).toBe('0') + data.value = { y: { value: 1 } } + await nextTick() + expect(root.textContent).toBe('1') + await gc() + expect(initialValue.deref()).toBeUndefined() + } finally { + app.unmount() + } + }) +}) diff --git a/packages/runtime-vapor/__tests__/hmr.spec.ts b/packages/runtime-vapor/__tests__/hmr.spec.ts index f9976390d50..ccdf817dc15 100644 --- a/packages/runtime-vapor/__tests__/hmr.spec.ts +++ b/packages/runtime-vapor/__tests__/hmr.spec.ts @@ -2179,4 +2179,30 @@ describe('hot module replacement', () => { expect(setupSpy).toHaveBeenCalledTimes(2) expect(unmountSpy).toHaveBeenCalledTimes(0) }) + + test('a creation that fails on its inputs leaves no instance behind', () => { + const root = document.createElement('div') + const childId = 'test-failed-inputs-child' + const Child = defineVaporComponent({ + __hmrId: childId, + render: compileToFunction('
'), + }) + createRecord(childId, Child as any) + const Parent = defineVaporComponent({ + components: { Child }, + setup() { + return { obj: undefined as any } + }, + render: compileToFunction( + ``, + ), + }) + const app = createVaporApp(Parent) + const errors: unknown[] = [] + app.config.errorHandler = e => errors.push(e) + app.mount(root) + expect(errors.length).toBe(1) + rerender(childId, compileToFunction('
!
')) + app.unmount() + }) }) diff --git a/packages/runtime-vapor/__tests__/vdomInterop.spec.ts b/packages/runtime-vapor/__tests__/vdomInterop.spec.ts index d5a6622956a..6f17227fb6a 100644 --- a/packages/runtime-vapor/__tests__/vdomInterop.spec.ts +++ b/packages/runtime-vapor/__tests__/vdomInterop.spec.ts @@ -15,6 +15,7 @@ import { currentInstance, defineAsyncComponent, defineComponent, + effectScope, getCurrentInstance, getCurrentScope, h, @@ -12312,4 +12313,167 @@ describe('vdomInterop', () => { expect(vdom.after).toBe('
img
') expect(vapor.after).toBe(vdom.after) }) + + describe('VDOM child input delivery', () => { + test('delivers listener changes before notifying child watchers', async () => { + const first = vi.fn() + const second = vi.fn() + const data = ref({ count: 0, onPing: first }) + const Child = defineComponent({ + props: ['count'], + emits: ['ping'], + setup(props, { emit }) { + watch( + () => props.count, + value => emit('ping', value), + { flush: 'sync' }, + ) + return () => + h( + 'button', + { onClick: () => emit('ping', props.count) }, + props.count, + ) + }, + }) + const App = compile( + '', + data, + { Child }, + ) + const { app, host } = define(App).render() + try { + const button = host.querySelector('button')! + button.click() + expect(first).toHaveBeenCalledExactlyOnceWith(0) + + data.value.onPing = second + button.click() + expect(first).toHaveBeenCalledTimes(2) + expect(second).not.toHaveBeenCalled() + await nextTick() + button.click() + expect(second).toHaveBeenCalledExactlyOnceWith(0) + + data.value.count = 1 + data.value.onPing = first + await nextTick() + expect(host.textContent).toBe('1') + expect(first.mock.calls).toEqual([[0], [0], [1]]) + expect(second).toHaveBeenCalledTimes(1) + } finally { + app.unmount() + } + }) + + test.each(['direct', 'spread'])( + 'evaluates %s inputs once per delivery', + async binding => { + const value = ref('first') + const read = vi.fn(() => + binding === 'spread' ? { value: value.value } : value.value, + ) + const data = ref({ read }) + const Child = defineComponent({ + props: ['value'], + setup: props => () => h('span', props.value), + }) + const Parent = compile( + ``, + data, + { Child }, + ) + const App = compile( + '', + data, + { Parent }, + ) + const { app, host } = define(App).render() + try { + expect(host.textContent).toBe('first') + expect(read).toHaveBeenCalledTimes(1) + value.value = 'second' + await nextTick() + expect(host.textContent).toBe('second') + expect(read).toHaveBeenCalledTimes(2) + } finally { + app.unmount() + } + value.value = 'after unmount' + await nextTick() + expect(read).toHaveBeenCalledTimes(2) + }, + ) + + test('evaluates v-once spread inputs only once', async () => { + const value = ref('first') + const read = vi.fn(() => ({ value: value.value })) + const Child = defineComponent({ + props: ['value'], + setup: props => () => h('span', props.value), + }) + const App = compile( + '', + ref({ read }), + { Child }, + ) + const { app, host } = define(App).render() + try { + expect(host.textContent).toBe('first') + expect(read).toHaveBeenCalledTimes(1) + value.value = 'second' + await nextTick() + expect(host.textContent).toBe('first') + expect(read).toHaveBeenCalledTimes(1) + } finally { + app.unmount() + } + }) + + test.each(['collect', 'normalize'])( + 'stops input collection when initial %s fails', + async phase => { + const value = ref(0) + const error = new Error('input initialization failed') + const read = vi.fn(() => { + const current = value.value + if (phase === 'collect') throw error + return current + }) + const Child = { + props: { + count: Number, + other: { + default: () => { + throw error + }, + }, + }, + render: () => null, + } + const app = createApp({ render: () => null }).use(vaporInteropPlugin) + const owner = effectScope() + try { + owner.run(() => { + expect(() => { + const frag = app._context.vdom!.mount(Child, null, { + count: read, + }) + frag.insert(document.createElement('div'), null) + }).toThrow(error) + expect(getCurrentScope()).toBe(owner) + }) + value.value++ + await nextTick() + expect(read).toHaveBeenCalledTimes(1) + } finally { + owner.stop() + } + }, + ) + }) }) diff --git a/packages/runtime-vapor/src/apiDefineAsyncComponent.ts b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts index 1e12eb3f5d0..a0eb2b1ec8e 100644 --- a/packages/runtime-vapor/src/apiDefineAsyncComponent.ts +++ b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts @@ -11,13 +11,13 @@ import { setCurrentInstance, useAsyncComponentState, } from '@vue/runtime-dom' -import { extend } from '@vue/shared' import { defineVaporComponent } from './apiDefineComponent' import { type VaporComponent, type VaporComponentInstance, createComponent, } from './component' +import { type RawProps, trackRawValues } from './componentProps' import { enableAsyncComponent } from './asyncComponentState' import { renderEffect } from './renderEffect' import { DynamicFragment, isDynamicFragment } from './fragment' @@ -279,9 +279,8 @@ function createErrorComp( return createInnerComp( comp, parent, - // the wrapper's props and attrs reach the error component the way the - // vdom wrapper's attrs fall through to its error vnode root - extend({ error: getError }, parent.rawProps), + // Forward the wrapper's delivered props and attrs to the error component. + { error: getError, $: [() => trackRawValues(parent)] }, // Avoid wrapper slot fallthrough {}, ) @@ -290,7 +289,7 @@ function createErrorComp( function createInnerComp( comp: VaporComponent, parent: VaporComponentInstance & TransitionOptions, - rawProps = parent.rawProps, + rawProps: RawProps = { $: [() => trackRawValues(parent)] }, rawSlots = parent.rawSlots, ): VaporComponentInstance { const prevInstance = setCurrentInstance(parent) @@ -299,10 +298,10 @@ function createInnerComp( comp, rawProps, rawSlots, - // rawProps is shared and already contains fallthrough attrs. - // so isSingleRoot should be undefined + // The wrapper already delivered parent inputs and fallthrough attrs. + // Forward that frame without re-evaluating the original sources. undefined, - // The wrapper already snapshotted rawProps when it is v-once. + // A v-once wrapper never publishes another input frame. undefined, parent.appContext, ) diff --git a/packages/runtime-vapor/src/apiDefineCustomElement.ts b/packages/runtime-vapor/src/apiDefineCustomElement.ts index ab82408a326..fbfb9a7d0fe 100644 --- a/packages/runtime-vapor/src/apiDefineCustomElement.ts +++ b/packages/runtime-vapor/src/apiDefineCustomElement.ts @@ -264,7 +264,8 @@ export class VaporElement extends VueElementBase< createComponent( this._def, - this._props, + // Host properties are values; a function property is not a prop getter. + { $: [() => this._props] }, this.shadowRoot ? undefined : this._createSlots(), undefined, undefined, diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index c62d8955e21..809f790990f 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -57,6 +57,7 @@ import { remove, } from './block' import { + type Dep, type ShallowRef, getCurrentScope, isRef, @@ -85,28 +86,28 @@ import { } from '@vue/shared' import { type DynamicPropsSource, + INITIAL_RAW_VALUES, type RawProps, - getKeysFromRawProps, getPropsProxyHandlers, getStaticBindingKeys, hasFallthroughAttrs, + initInputs, normalizePropsOptions, resolveDynamicProps, resolveSource, - setupPropsValidation, - snapshotRawProps, } from './componentProps' -import { renderEffect } from './renderEffect' +import { type RenderEffect, renderEffect } from './renderEffect' import { emit, normalizeEmitsOptions } from './componentEmits' import { patchDynamicProps } from './dom/prop' import { type LooseRawSlots, type RawSlots, + type SlotSourceCell, type StaticSlots, dynamicSlotsProxyHandlers, getSlot, + initSlots, normalizeRawSlots, - snapshotRawSlots, } from './componentSlots' import { currentRenderContext, @@ -393,6 +394,11 @@ export function createComponent( keepAliveCtx = ctx const cached = !managedMount && ctx.getCachedComponent(component, key) if (cached) { + // Rebind cache-owned inputs to the current call site's getters. + if (isVaporComponent(cached) && cached.inputScope) { + cached.rawProps = (rawProps || EMPTY_OBJ) as RawProps + initInputs(cached) + } // a nested branch teardown stops the branch scope that unmounts the // cached component, so the scope re-entering it takes over const scope = getCurrentScope() @@ -459,41 +465,11 @@ export function createComponent( } let inputScope: EffectScope | undefined - if ( - keepAliveCtx && - !once && - (rawProps || (rawSlots && (rawSlots as RawSlots).$)) - ) { - // The cached component keeps its detached scope active, so commit only - // its direct inputs through a cache-owned scope. Descendants read from - // the same committed inputs and need no additional isolation. - // v-once snapshots raw props and the slot set in the instance - // constructor, so it does not need a live commit effect after creation. - const scope = new EffectScope(true) - let isolated = false - scope.run(() => { - if (rawProps) { - const next = keepAliveCtx!.isolatePropSources(rawProps as RawProps) - isolated = next !== rawProps - rawProps = next - } - - // Static slots are fixed function entries. Only `$` contains live slot - // descriptor sources that useSlots() can re-resolve while cached; slot - // function execution retains its existing closure semantics. - if (rawSlots && (rawSlots as RawSlots).$) { - const next = keepAliveCtx!.isolateSlotSources(rawSlots as RawSlots) - isolated = isolated || next !== rawSlots - rawSlots = next - } - }) - if (isolated) { - inputScope = scope - } + if (keepAliveCtx && !once && !managedMount) { + inputScope = new EffectScope(true) } - // A VDOM child reads the same raw sources through its interop fragment, so - // it is mounted with the isolated inputs and owns the commit scope too. + // The VDOM bridge delivers inputs through the same cache-owned scope. if (isInteropEnabled && useVdomInterop(component, appContext)) { const frag = appContext.vdom!.mount( component as any, @@ -501,8 +477,8 @@ export function createComponent( rawProps, normalizeRawSlots(rawSlots), once, + inputScope, ) - if (inputScope) frag.inputScope = inputScope // the explicit key wins over one merged in from a spread object if (key !== undefined) { frag.$key = key @@ -529,12 +505,13 @@ export function createComponent( rawProps as RawProps, rawSlots, appContext, - once, ce, ) if (key !== undefined) instance.$key = key if (inputScope) { instance.inputScope = inputScope + // A later cache hit can add attrs absent from the first call site. + instance.hasFallthrough = true } if (asyncBoundary) markAsyncBoundary(instance) @@ -581,6 +558,17 @@ export function createComponent( instance.emitsOptions = normalizeEmitsOptions(component) } + try { + initSlots(instance) + initInputs(instance, once) + } catch (error) { + // Inputs can fail before the teardown below is registered. + if (__DEV__) unregisterHMR(instance) + instance.scope.stop() + if (inputScope) inputScope.stop() + throw error + } + // hydrating async component if ( isHydrating && @@ -698,10 +686,6 @@ export function setupComponent( const prevInstance = setCurrentInstance(instance) const prevSub = setActiveSub() - if (__DEV__) { - setupPropsValidation(instance) - } - const setupFn = isFunction(component) ? component : component.setup const setupResult = setupFn ? callWithErrorHandling(setupFn, instance, ErrorCodes.SETUP_FUNCTION, [ @@ -900,6 +884,19 @@ export class VaporComponentInstance< TypeRefs extends Record = Record, > implements GenericComponentInstance { vapor: true + propsValues: Record + rawValues: Record + inputEffect?: RenderEffect + /** + * @internal + */ + propsDeps: Map | undefined + /** + * @internal + */ + slotSources?: SlotSourceCell[] + // false when the inputs can never change: reading them subscribes to nothing + hasDynamicProps: boolean uid: number type: VaporComponent root: GenericComponentInstance | null @@ -934,7 +931,6 @@ export class VaporComponentInstance< interopVNode?: VNode // to hold vnode props / slots in vdom interop mode - rawPropsRef?: ShallowRef rawSlotsRef?: ShallowRef emit: EmitFn @@ -1030,7 +1026,6 @@ export class VaporComponentInstance< rawProps?: RawProps | null, rawSlots?: LooseRawSlots | null, appContext?: GenericAppContext, - once?: boolean, ce?: (instance: VaporComponentInstance) => void, ) { this.vapor = true @@ -1074,33 +1069,28 @@ export class VaporComponentInstance< this.isDeactivated = false - // init props - // Snapshot raw parent inputs before creating proxies so delayed reads from - // v-once children cannot observe later parent updates. - this.rawProps = - once && rawProps ? snapshotRawProps(rawProps) : rawProps || EMPTY_OBJ + // Track through the public proxies to avoid pulling generic reactive + // handlers into pure Vapor bundles. + this.propsValues = Object.create(null) + this.rawValues = INITIAL_RAW_VALUES + this.propsDeps = undefined + this.hasDynamicProps = true + this.rawProps = rawProps || EMPTY_OBJ // a custom element host mutates its props object after creation, so its // attrs key set is never static this.hasFallthrough = !!ce || hasFallthroughAttrs(comp, this.rawProps) - if (rawProps || comp.props) { - const [propsHandlers, attrsHandlers] = getPropsProxyHandlers(comp) - this.attrs = new Proxy(this, attrsHandlers) - this.props = ( - comp.props - ? new Proxy(this, propsHandlers!) - : isFunction(comp) - ? this.attrs - : EMPTY_OBJ - ) as Props - } else { - this.props = this.attrs = EMPTY_OBJ as Props - } + const [propsHandlers, attrsHandlers] = getPropsProxyHandlers(comp) + this.attrs = new Proxy(this, attrsHandlers) + this.props = ( + comp.props + ? new Proxy(this, propsHandlers!) + : isFunction(comp) + ? this.attrs + : EMPTY_OBJ + ) as Props // init slots - let normalizedRawSlots = normalizeRawSlots(rawSlots) - if (once && normalizedRawSlots) { - normalizedRawSlots = snapshotRawSlots(normalizedRawSlots) - } + const normalizedRawSlots = normalizeRawSlots(rawSlots) this.rawSlots = normalizedRawSlots || EMPTY_OBJ this.slots = ( normalizedRawSlots @@ -1133,15 +1123,10 @@ export class VaporComponentInstance< } } - /** - * Expose `getKeysFromRawProps` on the instance so it can be used in code - * paths where it's needed, e.g. `useModel` - */ + // Parent-provided keys are needed by APIs such as useModel. rawKeys(): string[] { const vnode = isInteropEnabled && this.interopVNode - return vnode - ? Object.keys(vnode.props || EMPTY_OBJ) - : getKeysFromRawProps(this.rawProps) + return Object.keys(vnode ? vnode.props || EMPTY_OBJ : this.rawValues) } } diff --git a/packages/runtime-vapor/src/componentEmits.ts b/packages/runtime-vapor/src/componentEmits.ts index d2e33af5190..12abab2fe42 100644 --- a/packages/runtime-vapor/src/componentEmits.ts +++ b/packages/runtime-vapor/src/componentEmits.ts @@ -5,7 +5,6 @@ import { } from '@vue/runtime-dom' import type { VaporComponent, VaporComponentInstance } from './component' import { EMPTY_OBJ, isArray } from '@vue/shared' -import { getAttrFromRawProps } from './componentProps' import { isInteropEnabled } from './vdomInteropState' /** @@ -39,8 +38,8 @@ export function emit( const vnode = isInteropEnabled && instance.interopVNode baseEmit( instance, - vnode ? vnode.props || EMPTY_OBJ : instance.rawProps || EMPTY_OBJ, - vnode ? defaultPropGetter : getAttrFromRawProps, + vnode ? vnode.props || EMPTY_OBJ : instance.rawValues, + defaultPropGetter, event, ...rawArgs, ) diff --git a/packages/runtime-vapor/src/componentProps.ts b/packages/runtime-vapor/src/componentProps.ts index 2b0528fef90..c4ceada475d 100644 --- a/packages/runtime-vapor/src/componentProps.ts +++ b/packages/runtime-vapor/src/componentProps.ts @@ -3,9 +3,11 @@ import { EMPTY_OBJ, NO, camelize, + hasChanged, hasOwn, isArray, isFunction, + isModelListener, isObject, isOn, isPlainObject, @@ -15,10 +17,9 @@ import { normalizeStyle, } from '@vue/shared' import type { VaporComponent, VaporComponentInstance } from './component' +import type { SlotSourceCell } from './componentSlots' import { - type GenericComponentInstance, type NormalizedPropsOptions, - type VNode, baseNormalizePropsOptions, currentInstance, isEmitListener, @@ -31,165 +32,43 @@ import { warn, } from '@vue/runtime-dom' import { - type ComputedRef, + Dep, + EffectFlags, ReactiveFlags, + activeSub, computed, + endBatch, getCurrentScope, onScopeDispose, - shallowReactive, + setActiveSub, + startBatch, + trackDep, + triggerDep, } from '@vue/reactivity' import { normalizeEmitsOptions } from './componentEmits' -import { renderEffect } from './renderEffect' -import type { interopKey } from './vdomInteropState' +import { RenderEffect } from './renderEffect' +import { interopKey, isInteropEnabled } from './vdomInteropState' export type RawProps = Record & { // generated by compiler for :[key]="x" or v-bind="x" - $?: DynamicPropsSource[] & { [interopKey]?: boolean } + $?: DynamicPropsSource[] + // evaluated by vdom, which patches the values in later + [interopKey]?: boolean } export type DynamicPropsSource = | (() => Record) | Record -export function isolatePropSources(rawProps: RawProps): RawProps { - // Static values cannot change while cached and need no commit boundary. - let hasFunctionSource = false - for (const key in rawProps) { - if (key !== '$' && isFunction(rawProps[key])) hasFunctionSource = true - } - const dynamicSources = rawProps.$ - if (dynamicSources && !hasFunctionSource) { - for (let i = 0; i < dynamicSources.length; i++) { - const source = dynamicSources[i] - if (isFunction(source)) { - hasFunctionSource = true - break - } else { - for (const key in source) { - if (isFunction(source[key])) { - hasFunctionSource = true - break - } - } - if (hasFunctionSource) break - } - } - } - if (!hasFunctionSource) return rawProps - - const isolated: RawProps = Object.create(null) - let committed: Record | undefined - for (const key in rawProps) { - if (key === '$') continue - const source = rawProps[key] - if (isFunction(source)) { - const target = - committed || (committed = shallowReactive>({})) - isolated[key] = () => target[key] - } else { - isolated[key] = source - } - } - - let committedDynamicSources: - | (Record | undefined)[] - | undefined - let previousDynamicSources: Record[] | undefined - if (dynamicSources) { - const isolatedDynamicSources: DynamicPropsSource[] & { - [interopKey]?: boolean - } = [] - committedDynamicSources = [] - previousDynamicSources = [] - for (let i = 0; i < dynamicSources.length; i++) { - const source = dynamicSources[i] - if (isFunction(source)) { - const target = (committedDynamicSources[i] = shallowReactive< - Record - >({})) - previousDynamicSources[i] = {} - isolatedDynamicSources[i] = () => target - } else { - const isolatedSource: Record = Object.create(null) - let target: Record | undefined - for (const key in source) { - const value = source[key] - if (isFunction(value)) { - if (!target) { - target = committedDynamicSources[i] = shallowReactive< - Record - >({}) - } - const committedSource = target - isolatedSource[key] = () => committedSource[key] - } else { - isolatedSource[key] = value - } - } - isolatedDynamicSources[i] = target ? isolatedSource : source - } - } - const symbols = Object.getOwnPropertySymbols(dynamicSources) - for (let i = 0; i < symbols.length; i++) { - ;(isolatedDynamicSources as any)[symbols[i]] = (dynamicSources as any)[ - symbols[i] - ] - } - isolated.$ = isolatedDynamicSources - } - - // Each source keeps its original position and key spelling so prop/attr - // precedence is unchanged. The writer belongs to the cached component's - // KeepAlive input scope, so deactivation pauses parent updates without - // pausing the component. - // Source invalidations while this effect is paused still leave it dirty. - // Resuming the input scope therefore schedules one commit with the - // latest raw props, while an unchanged cache entry needs no work on activation. - renderEffect(() => { - if (committed) { - for (const key in rawProps) { - if (key !== '$' && isFunction(rawProps[key])) { - committed[key] = resolveSource(rawProps[key]) - } - } - } - if (dynamicSources) { - for (let i = 0; i < dynamicSources.length; i++) { - const source = dynamicSources[i] - const target = committedDynamicSources![i] - if (!target) continue - if (isFunction(source)) { - const next = resolveFunctionSource(source) || EMPTY_OBJ - const previous = previousDynamicSources![i] - for (const key in previous) { - if (!hasOwn(next, key)) { - delete target[key] - delete previous[key] - } - } - for (const key in next) { - target[key] = previous[key] = next[key] - } - } else { - for (const key in source) { - if (isFunction(source[key])) { - target[key] = resolveSource(source[key]) - } - } - } - } - } - }, true) - return isolated -} - export function resolveSource(source: T | (() => T)): T { return isFunction(source) ? resolveFunctionSource(source as () => T) : source } -export function resolveFunctionSource( - source: (() => T) & { _cache?: ComputedRef }, -): T { +export type FunctionSource = (() => T) & { + _cache?: { readonly value: T } +} + +export function resolveFunctionSource(source: FunctionSource): T { // use existing cache if available if (source._cache) { return source._cache.value @@ -220,7 +99,6 @@ export function resolveFunctionSource( return source._cache.value } - // no parent, no cache - just call directly return source() } @@ -240,9 +118,7 @@ export function snapshotRawProps(rawProps: RawProps): RawProps { const dynamicSources = rawProps.$ if (dynamicSources) { - const snapshotSources: DynamicPropsSource[] & { - [interopKey]?: boolean - } = [] + const snapshotSources: DynamicPropsSource[] = [] for (let i = 0; i < dynamicSources.length; i++) { const source = dynamicSources[i] const isDynamic = isFunction(source) @@ -275,13 +151,30 @@ function readSource(source: T | (() => T)): T { } function freezeValue(key: string, value: unknown): unknown { - if (key === 'class' && value && !isString(value)) return normalizeClass(value) - // wrapped so a lone (possibly reactive) style object is copied too - if (key === 'style' && isObject(value)) return normalizeStyle([value]) + value = normalizePropValue(key, value) return isFunction(value) ? () => value : value } -function stabilizeDynamicSourceValue(oldValue: T | undefined, value: T): T { +// class and style are delivered normalized, as mergeProps would +function normalizePropValue( + key: string, + value: unknown, + prevValue?: unknown, +): unknown { + if (key === 'class') { + return value && !isString(value) ? normalizeClass(value) : value + } + // wrapped so a lone (possibly reactive) style object is copied too; the + // copy is a new object, so an equal style is the one already delivered + return key === 'style' && isObject(value) + ? stabilizeDynamicSourceValue(prevValue, normalizeStyle([value])) + : value +} + +export function stabilizeDynamicSourceValue( + oldValue: T | undefined, + value: T, +): T { if (!isPlainObject(oldValue) || !isPlainObject(value)) { return value } @@ -336,127 +229,14 @@ export function getPropsProxyHandlers( : (key: string | symbol) => isString(key) && !isReservedProp(key) ) as (key: string | symbol) => key is string - // vdom normalizes class and style on the vnode, so prop resolution already - // receives normalized values. Match that order here. - const normalizeRawProp = (key: string, value: unknown) => { - if (!value) return value - if (key === 'class' && !isString(value)) return normalizeClass(value) - if (key === 'style' && isObject(value)) return normalizeStyle(value) - return value - } - const getProp = (instance: VaporComponentInstance, key: string | symbol) => { - // this enables direct watching of props and prevents `Invalid watch source` DEV warnings. - // props are shallow reactive in vdom, so watching them should not traverse deeply. + // Keep direct props watchers shallow, as in VDOM. if (key === ReactiveFlags.IS_REACTIVE || key === ReactiveFlags.IS_SHALLOW) return true - - if (!isProp(key)) return - const rawProps = instance.rawProps - const dynamicSources = rawProps.$ - if (dynamicSources && isOn(key)) { - const handlers: Record = {} - let matchedKey: string | undefined - // Match mergeProps: merge exact raw keys in source order before - // resolving camelized aliases in their first occurrence order. - for (let i = -1; i < dynamicSources.length; i++) { - const source = i < 0 ? rawProps : dynamicSources[i] - const isDynamic = isFunction(source) - const resolved = isDynamic ? resolveFunctionSource(source) : source - for (const rawKey in resolved) { - if (camelize(rawKey) === key) { - if (!hasOwn(handlers, rawKey)) matchedKey = rawKey - const value = isDynamic - ? resolved[rawKey] - : resolveSource(resolved[rawKey]) - handlers[rawKey] = mergeEventHandlers(handlers[rawKey], value) - } - } - } - return resolvePropValue( - propsOptions!, - key, - matchedKey === undefined ? undefined : handlers[matchedKey], - instance, - resolveDefault, - matchedKey === undefined, - ) - } - const merged = - dynamicSources && (key === 'class' || key === 'style') - ? ([] as unknown[]) - : undefined - if (dynamicSources) { - let i = dynamicSources.length - let source, isDynamic, rawKey - while (i--) { - source = dynamicSources[i] - isDynamic = isFunction(source) - source = isDynamic - ? (resolveFunctionSource( - source as () => Record, - ) as any) - : source - for (rawKey in source) { - if (camelize(rawKey) === key) { - const value = isDynamic - ? source[rawKey] - : resolveSource(source[rawKey]) - if (merged) { - merged.push(value) - } else { - return resolvePropValue( - propsOptions!, - key, - normalizeRawProp(key, value), - instance, - resolveDefault, - ) - } - } - } - } - } - for (const rawKey in rawProps) { - if (camelize(rawKey) === key) { - const value = resolveSource(rawProps[rawKey]) - if (merged) { - merged.push(value) - } else { - return resolvePropValue( - propsOptions!, - key, - normalizeRawProp(key, value), - instance, - resolveDefault, - ) - } - } + if (isProp(key)) { + trackPropsValue(instance, key) + return instance.propsValues[key] } - const hasMerged = !!(merged && merged.length) - let value - if (hasMerged) { - if (merged.length === 1) { - value = merged[0] - } else if (key === 'class') { - // Match mergeProps, including leaving all-undefined classes undefined. - for (let i = merged.length - 1; i >= 0; i--) { - if (value !== merged[i]) { - value = normalizeClass([value, merged[i]]) - } - } - } else { - value = merged.reverse() - } - } - return resolvePropValue( - propsOptions!, - key, - normalizeRawProp(key, value), - instance, - resolveDefault, - !hasMerged, - ) } const propsHandlers = propsOptions @@ -483,37 +263,30 @@ export function getPropsProxyHandlers( }) } - const getAttr = (target: RawProps, key: string | symbol) => { - if ( - isString(key) && - !isReservedProp(key) && - !isProp(key) && - !isEmitListener(emitsOptions, key) - ) { - return getAttrFromRawProps(target, key) - } - } - - const hasAttr = (target: RawProps, key: string | symbol) => { + const getAttr = (instance: VaporComponentInstance, key: string | symbol) => { if (isAttr(key)) { - return hasAttrFromRawProps(target, key) - } else { - return false + trackPropsValue(instance, key) + return instance.propsValues[key] } } - - const getAttrKeys = (target: VaporComponentInstance) => - getKeysFromRawProps(target.rawProps).filter(isAttr) + const hasAttr = (instance: VaporComponentInstance, key: string | symbol) => { + if (!isAttr(key)) return false + trackPropsValue(instance, key) + return hasOwn(instance.propsValues, key) + } const attrsHandlers = { - get: (target, key: string | symbol) => getAttr(target.rawProps, key), - has: (target, key: string | symbol) => hasAttr(target.rawProps, key), - ownKeys: getAttrKeys, + get: getAttr, + has: hasAttr, + ownKeys(target) { + trackPropsValue(target, ATTR_KEYS) + return Object.keys(target.propsValues).filter(isAttr) + }, getOwnPropertyDescriptor(target, key: string | symbol) { - if (isString(key) && hasAttr(target.rawProps, key)) { + if (hasAttr(target, key)) { return { configurable: true, enumerable: true, - get: () => getAttr(target.rawProps, key), + get: () => getAttr(target, key), } } }, @@ -637,9 +410,11 @@ function resolveDefault( instance: VaporComponentInstance, ) { const prev = setCurrentInstance(instance) - const res = factory.call(null, instance.props) - restoreCurrentInstance(prev) - return res + try { + return factory.call(null, instance.props) + } finally { + restoreCurrentInstance(prev) + } } export function hasFallthroughAttrs( @@ -647,8 +422,8 @@ export function hasFallthroughAttrs( rawProps: RawProps | null | undefined, ): boolean { if (rawProps) { - // dynamic sources can produce attrs keys at any time - if (rawProps.$) { + // dynamic sources and vdom patches can produce attrs keys at any time + if (rawProps.$ || (isInteropEnabled && rawProps[interopKey])) { return true } // otherwise fallthrough potential requires an undeclared static key — @@ -664,26 +439,6 @@ export function hasFallthroughAttrs( return false } -/** - * dev only - */ -export function setupPropsValidation( - instance: VaporComponentInstance, - warningContext: GenericComponentInstance | VNode = instance, -): void { - const rawProps = instance.rawProps - if (!rawProps) return - renderEffect(() => { - pushWarningContext(warningContext) - validateProps( - resolveDynamicProps(rawProps), - instance.props, - normalizePropsOptions(instance.type)[0]!, - ) - popWarningContext() - }, true /* noLifecycle */) -} - export function resolveDynamicProps(props: RawProps): Record { const mergedRawProps: Record = {} for (const key in props) { @@ -760,3 +515,408 @@ export const rawPropsProxyHandlers: ProxyHandler = { } }, } + +const RAW_VALUES_KEY: unique symbol = Symbol(__DEV__ ? 'Raw values' : '') +const ATTR_KEYS: unique symbol = Symbol(__DEV__ ? 'Attr keys' : '') + +// `rawValues` before the first delivery +export const INITIAL_RAW_VALUES: Record = {} + +// One dep per key, on the instance rather than in `targetMap`, created for +// a subscriber only. +function trackPropsValue( + instance: VaporComponentInstance, + key: string | symbol, +): void { + if (!instance.hasDynamicProps || activeSub === undefined) return + const deps = instance.propsDeps || (instance.propsDeps = new Map()) + let dep = deps.get(key) + if (!dep) deps.set(key, (dep = new Dep(deps, key))) + trackDep(dep, instance.propsValues, key) +} + +function triggerPropsValue( + instance: VaporComponentInstance, + key: string | symbol, + newValue?: unknown, + oldValue?: unknown, +): void { + const deps = instance.propsDeps + if (deps) { + const dep = deps.get(key) + if (dep) triggerDep(dep, instance.propsValues, key, newValue, oldValue) + } +} + +// Subscribes to every delivered input. `rawValues` itself is read untracked: +// emit and rawKeys must not subscribe their caller, as a vnode would not. +export function trackRawValues( + instance: VaporComponentInstance, +): Record { + trackPropsValue(instance, RAW_VALUES_KEY) + return instance.rawValues +} + +// Inputs belong to the child scope, but run in the parent's update order. +export function initInputs( + instance: VaporComponentInstance, + once = false, +): void { + const scope = instance.inputScope || instance.scope + const prevEffect = instance.inputEffect + if (prevEffect) prevEffect.stop() + const parent = instance.parent || instance + const rawProps = instance.rawProps + const cells = instance.slotSources + const prev = setCurrentInstance(parent, scope) + try { + const dynamicSources = rawProps.$ + // VDOM already evaluated these inputs and delivers subsequent patches. + const isInterop = isInteropEnabled && !!rawProps[interopKey] + // a cache hit can rebind a kept-alive instance to other inputs + const canRebind = !!instance.inputScope + const hasGetters = hasDynamicPropsSource(rawProps) + if (once || (!cells && (isInterop || (!canRebind && !hasGetters)))) { + instance.hasDynamicProps = !once && isInterop + // vdom hands over values, not getters: nothing to evaluate + deliverInputs( + instance, + isInterop ? rawProps : collectInputs(rawProps, cells), + cells, + ) + return + } + instance.hasDynamicProps = canRebind || hasGetters + // without dynamic sources the keys are static: after the first delivery + // only the getters can change + const hasStaticKeys = !dynamicSources + let hasDelivered = false + const effect = new RenderEffect(() => { + const prevRawValues = instance.rawValues + const prevInner = setCurrentInstance(parent, scope) + let rawValues: Record | undefined + let propsToUpdate: unknown[] | undefined + try { + if (!hasDelivered || instance.hasDynamicProps) { + if (hasDelivered && hasStaticKeys) { + propsToUpdate = collectPropsToUpdate(rawProps, prevRawValues) + } else { + rawValues = collectProps(rawProps, prevRawValues.style) + if ( + hasDelivered && + stabilizeDynamicSourceValue(prevRawValues, rawValues) === + prevRawValues + ) { + rawValues = undefined + } + } + } + if (cells) collectSlotSources(cells) + } finally { + restoreCurrentInstance(prevInner) + } + if (effect.active) { + // a delivery that throws half-way is redone in full + hasDelivered = false + deliverInputs(instance, rawValues, cells, propsToUpdate) + hasDelivered = true + } + }, true) + if (canRebind) instance.inputEffect = effect + if (!prevEffect) { + effect.run() + // every getter and descriptor turned out to be constant + if (!effect.deps && !canRebind) { + effect.stop() + instance.hasDynamicProps = false + } + } else { + if (scope.flags & EffectFlags.PAUSED) effect.pause() + effect.notify() + } + } finally { + restoreCurrentInstance(prev) + } +} + +// Evaluates every input once, untracked. +export function collectInputs( + rawProps: RawProps, + cells: SlotSourceCell[] | undefined, +): Record { + const prevSub = setActiveSub() + try { + const rawValues = collectProps(rawProps) + if (cells) collectSlotSources(cells) + return rawValues + } finally { + setActiveSub(prevSub) + } +} + +// One delivery: sync watchers run once the props and the descriptors are all +// written, and outside the input effect, which their callbacks must not +// subscribe. +export function deliverInputs( + instance: VaporComponentInstance | undefined, + rawValues: Record | undefined, + cells?: SlotSourceCell[], + propsToUpdate?: unknown[], +): void { + const prevSub = setActiveSub() + startBatch() + try { + if (instance && (rawValues || propsToUpdate)) { + // the fast path patches the delivered frame in place + updateProps(instance, rawValues || instance.rawValues, propsToUpdate) + } + if (cells) commitSlotSources(cells) + } finally { + try { + endBatch() + } finally { + setActiveSub(prevSub) + } + } +} + +export function collectSlotSources(cells: SlotSourceCell[]): void { + for (let i = 0; i < cells.length; i++) { + const cell = cells[i] + cell.next = cell.source() + } +} + +// an unchanged descriptor keeps the identity its readers already saw +function commitSlotSources(cells: SlotSourceCell[]): void { + for (let i = 0; i < cells.length; i++) { + const cell = cells[i] + const next = stabilizeDynamicSourceValue(cell.committed, cell.next!) + cell.next = undefined + if (hasChanged(next, cell.committed)) { + cell.committed = next + triggerDep(cell) + } + } +} + +export function hasDynamicPropsSource(rawProps: RawProps): boolean { + if (rawProps.$) return true + for (const key in rawProps) { + if (isFunction(rawProps[key])) return true + } + return false +} + +export function collectProps( + rawProps: RawProps, + prevStyle?: unknown, +): Record { + // Raw attrs may contain prototype-named keys such as __proto__. + const rawValues: Record = Object.create(null) + const dynamicSources = rawProps.$ || EMPTY_ARR + for (let i = -1; i < dynamicSources.length; i++) { + const source = i < 0 ? rawProps : dynamicSources[i] + const isDynamic = isFunction(source) + const resolved = isDynamic ? source() : source + for (const key in resolved) { + if (i < 0 && key === '$') continue + const value = isDynamic ? resolved[key] : readSource(resolved[key]) + if (!dynamicSources.length) { + rawValues[key] = value + } else if (key === 'class') { + if (rawValues.class !== value) { + rawValues.class = normalizeClass([rawValues.class, value]) + } + } else if (key === 'style') { + rawValues.style = normalizeStyle([rawValues.style, value]) + } else if (isOn(key)) { + const existing = rawValues[key] + if ( + value && + existing !== value && + !(isArray(existing) && existing.includes(value)) + ) { + rawValues[key] = existing + ? ([] as any[]).concat(existing, value) + : value + } else if (value == null && existing == null && !isModelListener(key)) { + rawValues[key] = value + } + } else if (key !== '') { + rawValues[key] = value + } + } + } + // merged sources are normalized above + if (dynamicSources.length) { + if (rawValues.style) { + rawValues.style = stabilizeDynamicSourceValue(prevStyle, rawValues.style) + } + } else { + if (rawValues.class) { + rawValues.class = normalizePropValue('class', rawValues.class) + } + if (rawValues.style) { + rawValues.style = normalizePropValue('style', rawValues.style, prevStyle) + } + } + return rawValues +} + +// The getters that changed against delivered raw values whose keys are +// static, as [key, value, …]. +function collectPropsToUpdate( + rawProps: RawProps, + prevRawValues: Record, +): unknown[] | undefined { + let propsToUpdate: unknown[] | undefined + for (const key in rawProps) { + const source = rawProps[key] + if (isFunction(source)) { + const prevValue = prevRawValues[key] + const value = normalizePropValue(key, source(), prevValue) + if (hasChanged(value, prevValue)) { + ;(propsToUpdate || (propsToUpdate = [])).push(key, value) + } + } + } + return propsToUpdate +} + +// Writes one delivery into the instance; `deliverInputs` owns the batch and +// the untracked window around it. +function updateProps( + instance: VaporComponentInstance, + rawValues: Record, + // the entries to set on `rawValues`, the delivered frame, as [key, value, …] + propsToUpdate?: unknown[], +): void { + const propsValues = instance.propsValues + const [options, needCastKeys] = normalizePropsOptions(instance.type) + const emitsOptions = normalizeEmitsOptions(instance.type) + const prevRawValues = instance.rawValues + // nothing is subscribed or left over before the first delivery + const isInitial = prevRawValues === INITIAL_RAW_VALUES + if (propsToUpdate) { + for (let i = 0; i < propsToUpdate.length; i += 2) { + rawValues[propsToUpdate[i] as string] = propsToUpdate[i + 1] + } + } else if (!isInitial) { + rawValues = stabilizeDynamicSourceValue(prevRawValues, rawValues) + } + // Raw listeners become current before normalized keys notify watchers. + instance.rawValues = rawValues + if (propsToUpdate || (!isInitial && rawValues !== prevRawValues)) { + triggerPropsValue(instance, RAW_VALUES_KEY) + } + const vnode = isInteropEnabled && instance.interopVNode + if (vnode && vnode.vi) vnode.props = rawValues + + const present: Record | undefined = + propsToUpdate || isInitial ? undefined : Object.create(null) + let rawCastValues: Record | undefined + const keys = propsToUpdate || Object.keys(rawValues) + for (let i = 0; i < keys.length; i += propsToUpdate ? 2 : 1) { + let key = keys[i] as string + if (isReservedProp(key)) continue + const value = rawValues[key] + const camelKey = camelize(key) + if (options && hasOwn(options, camelKey)) { + key = camelKey + if (needCastKeys && needCastKeys.includes(key)) { + if (!rawCastValues) { + rawCastValues = Object.create(null) as Record + } + rawCastValues[key] = value + continue + } + } else if (isEmitListener(emitsOptions, key)) { + continue + } + if (present) present[key] = true + setPropValue(instance, key, value, isInitial) + } + // after the ordinary props: a default factory may read them + if (needCastKeys && (rawCastValues || !propsToUpdate)) { + for (let i = 0; i < needCastKeys.length; i++) { + const key = needCastKeys[i] + const isAbsent = !rawCastValues || !hasOwn(rawCastValues, key) + if (isAbsent && propsToUpdate) continue + setPropValue( + instance, + key, + resolvePropValue( + options!, + key, + isAbsent ? undefined : rawCastValues![key], + instance, + resolveDefault, + isAbsent, + ), + isInitial, + ) + if (present) present[key] = true + } + } + if (!propsToUpdate) { + for (const key in options) { + if (present ? !present[key] : !hasOwn(propsValues, key)) { + setPropValue( + instance, + key, + resolvePropValue( + options, + key, + undefined, + instance, + resolveDefault, + true, + ), + isInitial, + ) + if (present) present[key] = true + } + } + if (present) { + for (const key in propsValues) { + if (!present[key]) { + const oldValue = propsValues[key] + delete propsValues[key] + triggerPropsValue(instance, key, undefined, oldValue) + triggerPropsValue(instance, ATTR_KEYS) + } + } + } + } + if (__DEV__ && options) { + pushWarningContext(instance) + try { + validateProps(rawValues, instance.props, options) + } finally { + popWarningContext() + } + } +} + +function setPropValue( + instance: VaporComponentInstance, + key: string, + value: unknown, + isInitial: boolean, +) { + const propsValues = instance.propsValues + if (isInitial) { + propsValues[key] = value + return + } + const hadKey = hasOwn(propsValues, key) + const oldValue = propsValues[key] + propsValues[key] = value + if (!hadKey) { + triggerPropsValue(instance, key, value) + triggerPropsValue(instance, ATTR_KEYS) + } else if (hasChanged(value, oldValue)) { + triggerPropsValue(instance, key, value, oldValue) + } +} diff --git a/packages/runtime-vapor/src/componentSlots.ts b/packages/runtime-vapor/src/componentSlots.ts index f108a784647..1c9ac56d441 100644 --- a/packages/runtime-vapor/src/componentSlots.ts +++ b/packages/runtime-vapor/src/componentSlots.ts @@ -3,6 +3,7 @@ import { NO, VaporSlotFlags, VaporSlotStability, + extend, hasOwn, isArray, isForwardedSlot, @@ -15,6 +16,7 @@ import { registerNestedVDOMCleanup, } from './block' import { + type FunctionSource, type RawProps, rawPropsProxyHandlers, resolveFunctionSource, @@ -25,6 +27,7 @@ import { currentInstance, isAsyncWrapper, } from '@vue/runtime-dom' +import { Dep, trackDep } from '@vue/reactivity' import type { LooseRawProps, VaporComponentInstance } from './component' import { renderEffect } from './renderEffect' import { @@ -106,35 +109,67 @@ export function normalizeRawSlots( return normalized } +// A dynamic slot descriptor's committed value: the input effect writes it in +// the parent's update order, the isolated source reads it as its `_cache`. +export class SlotSourceCell extends Dep { + committed: ReturnType | undefined = undefined + next: ReturnType | undefined = undefined + + constructor(public source: DynamicSlotFn) { + super() + } + + get value(): ReturnType | undefined { + trackDep(this) + return this.committed + } +} + /** - * Freeze the slot set of a v-once component: dynamic sources resolve once, - * in `resolveSlot` precedence, into plain entries. The slot functions stay - * live; the child re-runs them on its own updates. + * A dynamic slot descriptor is the parent's expression, like a prop getter: + * replace each function source with a reader of the cell the input effect + * commits it to. Slot functions keep their closure semantics. Returns the + * isolated slots with their cells, or nothing when no source is a function. */ -export function snapshotRawSlots(rawSlots: RawSlots): RawSlots { +export function isolateSlotSources( + rawSlots: RawSlots, +): [RawSlots, SlotSourceCell[]] | undefined { const dynamicSources = rawSlots.$ - if (!dynamicSources) return rawSlots - const snapshot: RawSlots = {} - for (const key in rawSlots) { - if (key !== '$') snapshot[key] = rawSlots[key] + if (!dynamicSources) return + let count = 0 + for (let i = 0; i < dynamicSources.length; i++) { + if (isFunction(dynamicSources[i])) count++ } - for (const source of dynamicSources) { + if (!count) return + const cells: SlotSourceCell[] = new Array(count) + const isolatedSources = dynamicSources.slice() + for (let i = 0, n = 0; i < dynamicSources.length; i++) { + const source = dynamicSources[i] if (isFunction(source)) { - const slot = withSlotOwner(rawSlots, () => source()) - if (isArray(slot)) { - for (const s of slot) snapshot[String(s.name)] = s.fn - } else if (slot) { - snapshot[String(slot.name)] = slot.fn - } - } else { - for (const key in source) snapshot[key] = source[key] + const cell = (cells[n++] = new SlotSourceCell(source)) + isolatedSources[i] = isolateSlotSource(cell) } } - for (const symbol of Object.getOwnPropertySymbols(rawSlots)) { - ;(snapshot as any)[symbol] = (rawSlots as any)[symbol] + const isolated = extend({}, rawSlots, { $: isolatedSources }) as RawSlots + rawSlotsOwnerMap.set(isolated, rawSlotsOwnerMap.get(rawSlots) || null) + return [isolated, cells] +} + +function isolateSlotSource(cell: SlotSourceCell): DynamicSlotFn { + const isolated: FunctionSource | undefined> = () => + cell.value + // readers resolve through the cell instead of a computed of their own + isolated._cache = cell + return isolated as DynamicSlotFn +} + +export function initSlots(instance: VaporComponentInstance): void { + const isolated = isolateSlotSources(instance.rawSlots) + if (isolated) { + instance.rawSlots = isolated[0] + instance.slotSources = isolated[1] + instance.slots = new Proxy(isolated[0], dynamicSlotsProxyHandlers) } - rawSlotsOwnerMap.set(snapshot, rawSlotsOwnerMap.get(rawSlots) || null) - return snapshot } function withSlotOwner(slots: RawSlots, fn: () => T): T { diff --git a/packages/runtime-vapor/src/components/KeepAlive.ts b/packages/runtime-vapor/src/components/KeepAlive.ts index d5b1d4012e1..3114b17eb01 100644 --- a/packages/runtime-vapor/src/components/KeepAlive.ts +++ b/packages/runtime-vapor/src/components/KeepAlive.ts @@ -28,13 +28,11 @@ import { isVaporComponent, } from '../component' import { isAsyncComponentEnabled } from '../asyncComponentState' -import { isolatePropSources, resolveFunctionSource } from '../componentProps' -import type { DynamicSlotFn, RawSlots } from '../componentSlots' import { type DefineVaporComponent, defineVaporComponent, } from '../apiDefineComponent' -import { ShapeFlags, invokeArrayFns, isArray, isFunction } from '@vue/shared' +import { ShapeFlags, invokeArrayFns, isArray } from '@vue/shared' import { createElement } from '../dom/node' import { unsetRef } from '../refCleanup' import { @@ -45,9 +43,8 @@ import { isFragment, isInteropFragment, } from '../fragment' -import { EffectScope, shallowReactive } from '@vue/reactivity' +import { EffectScope } from '@vue/reactivity' import { isInteropEnabled } from '../vdomInteropState' -import { renderEffect } from '../renderEffect' import { type VaporKeepAliveContext, currentCacheKey, @@ -357,8 +354,6 @@ const VaporKeepAliveImpl = defineVaporComponent({ }) const keepAliveCtx: KeepAliveInstance['ctx'] = { - isolatePropSources, - isolateSlotSources, getStorageContainer: () => storageContainer, getCachedComponent: (comp, key) => { if (isInteropEnabled && isVNode(comp)) { @@ -671,36 +666,3 @@ export function deactivate( devtoolsComponentAdded(instance) } } - -function isolateSlotSources(rawSlots: RawSlots): RawSlots { - const dynamicSources = rawSlots.$! - - const isolatedSources = dynamicSources.slice() - const committedSources = shallowReactive< - Array | undefined> - >([]) - let hasFunctionSource = false - for (let i = 0; i < dynamicSources.length; i++) { - const source = dynamicSources[i] - if (isFunction(source)) { - hasFunctionSource = true - isolatedSources[i] = (() => committedSources[i]) as DynamicSlotFn - } - } - if (!hasFunctionSource) return rawSlots - - const isolated = { ...rawSlots, $: isolatedSources } as RawSlots - // VDOM materializes dynamic slots before caching a child. Commit Vapor's - // live slot descriptors through the input scope so useSlots() observes - // the same last-patched slot table while deactivated. Slot functions - // themselves remain unchanged and retain their existing closure semantics. - renderEffect(() => { - for (let i = 0; i < dynamicSources.length; i++) { - const source = dynamicSources[i] - if (isFunction(source)) { - committedSources[i] = resolveFunctionSource(source as DynamicSlotFn) - } - } - }, true) - return isolated -} diff --git a/packages/runtime-vapor/src/components/TransitionGroup.ts b/packages/runtime-vapor/src/components/TransitionGroup.ts index 737cfe2a74d..9b3309a93bd 100644 --- a/packages/runtime-vapor/src/components/TransitionGroup.ts +++ b/packages/runtime-vapor/src/components/TransitionGroup.ts @@ -22,7 +22,7 @@ import { vShowHidden, warn, } from '@vue/runtime-dom' -import { extend, isArray, isFunction } from '@vue/shared' +import { extend, isArray } from '@vue/shared' import { type Block, type BlockFn, @@ -52,7 +52,7 @@ import { type VaporComponentOptions, isVaporComponent, } from '../component' -import { type RawProps, resolveDynamicProps } from '../componentProps' +import { trackRawValues } from '../componentProps' import { createElement } from '../dom/node' import { DynamicFragment, @@ -548,12 +548,12 @@ function trackTransitionGroupUpdate( return } - // Fully static raw props can never notify - skip the tracking effect. - if (!hasDynamicPropsSource(owner.rawProps)) return + // Inputs that cannot change never notify - skip the tracking effect. + if (!owner.hasDynamicProps) return trackedTransitionGroupOwners.add(owner) // A component child can update from parent-driven props without re-running - // the surrounding v-for fragment. Track raw props directly instead of + // the surrounding v-for fragment. Track delivered parent inputs instead of // using component updated hooks, because child-local state updates should // not trigger TransitionGroup move bookkeeping. This matches VDOM behavior. // The effect belongs to the group instance, so its runs report through the @@ -562,28 +562,13 @@ function trackTransitionGroupUpdate( const prevGroup = setCurrentInstance(instance, owner.scope) try { renderEffect(() => { - // dynamic prop sources resolve as child props: run the getters as the - // child instance - const prev = setCurrentInstance(owner, owner.scope) - try { - resolveDynamicProps(owner.rawProps) - } finally { - restoreCurrentInstance(prev) - } + trackRawValues(owner) }) } finally { restoreCurrentInstance(prevGroup) } } -function hasDynamicPropsSource(props: RawProps): boolean { - if (props.$) return true - for (const key in props) { - if (key !== '$' && isFunction(props[key])) return true - } - return false -} - function recordPosition(c: ResolvedTransitionBlock) { const el = getTransitionElement(c) if (el) newPositionMap.set(c, el.getBoundingClientRect()) diff --git a/packages/runtime-vapor/src/fragment.ts b/packages/runtime-vapor/src/fragment.ts index f4a79ea6e2b..62245489e0d 100644 --- a/packages/runtime-vapor/src/fragment.ts +++ b/packages/runtime-vapor/src/fragment.ts @@ -127,8 +127,7 @@ export class VaporFragment< patchVNode?: (next: VNode) => void scope?: EffectScope /** - * @internal the KeepAlive-owned scope that commits this fragment's raw input - * sources, paused while the fragment is cached (see `isolatePropSources`) + * @internal the KeepAlive-owned input scope, paused while cached */ inputScope?: EffectScope setRef?: ( diff --git a/packages/runtime-vapor/src/keepAlive.ts b/packages/runtime-vapor/src/keepAlive.ts index 1664cb101d4..1746dfa151d 100644 --- a/packages/runtime-vapor/src/keepAlive.ts +++ b/packages/runtime-vapor/src/keepAlive.ts @@ -6,12 +6,8 @@ import { import type { EffectScope } from '@vue/reactivity' import type { Block } from './block' import type { DynamicFragment } from './fragment' -import type { RawProps } from './componentProps' -import type { RawSlots } from './componentSlots' export interface VaporKeepAliveContext { - isolatePropSources(rawProps: RawProps): RawProps - isolateSlotSources(rawSlots: RawSlots): RawSlots // caches or stops the outgoing branch scope and returns whether its DOM // removal must wait for the incoming cache decision. `prevKey` is the // outgoing branch key: `frag.current` already names the incoming one. diff --git a/packages/runtime-vapor/src/renderEffect.ts b/packages/runtime-vapor/src/renderEffect.ts index bbb5711524f..5c2b7bcefd3 100644 --- a/packages/runtime-vapor/src/renderEffect.ts +++ b/packages/runtime-vapor/src/renderEffect.ts @@ -20,6 +20,7 @@ import { import { inOnce } from './once' import { invokeArrayFns } from '@vue/shared' import { isSuspenseEnabled } from './suspense' +import { isInteropEnabled } from './vdomInteropState' export class RenderEffect extends ReactiveEffect { i: VaporComponentInstance | null @@ -37,6 +38,15 @@ export class RenderEffect extends ReactiveEffect { super(noLifecycle ? render : undefined) this.render = render const instance = currentInstance as VaporComponentInstance | null + // a vdom instance rendering vapor content owns the update job at order 0 + if ( + isInteropEnabled && + instance && + !instance.vapor && + !instance.effectCount + ) { + instance.effectCount = 1 + } this.order = instance ? instance.effectCount++ : 0 if (__DEV__ && !__TEST__ && !this.subs && !isVaporComponent(instance)) { warn('renderEffect called without active EffectScope or Vapor instance.') diff --git a/packages/runtime-vapor/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts index 946c3a0376e..b27a1f96ed4 100644 --- a/packages/runtime-vapor/src/vdomInterop.ts +++ b/packages/runtime-vapor/src/vdomInterop.ts @@ -71,7 +71,13 @@ import { warn, withCtx, } from '@vue/runtime-dom' -import { effectScope, setActiveSub } from '@vue/reactivity' +import { + type EffectScope, + effectScope, + endBatch, + setActiveSub, + startBatch, +} from '@vue/reactivity' import { type LooseRawProps, type VaporComponent, @@ -122,14 +128,22 @@ import { } from '@vue/shared' import { type RawProps, + collectInputs, + collectProps, + collectSlotSources, + deliverInputs, + hasDynamicPropsSource, rawPropsProxyHandlers, - setupPropsValidation, snapshotRawProps, } from './componentProps' -import type { RawSlots, VaporSlot } from './componentSlots' -import { dynamicSlotsProxyHandlers, getSlot } from './componentSlots' +import type { RawSlots, SlotSourceCell, VaporSlot } from './componentSlots' +import { + dynamicSlotsProxyHandlers, + getSlot, + isolateSlotSources, +} from './componentSlots' import { inOnce, withOnce } from './once' -import { renderEffect } from './renderEffect' +import { RenderEffect, renderEffect } from './renderEffect' import { createTextNode, parentNode } from './dom/node' import { optimizePropertyLookup } from './dom/prop' import { @@ -340,14 +354,16 @@ function getInteropTransitionElement( } } -function filterReservedProps(props: VNode['props']): VNode['props'] { - const filtered: VNode['props'] = {} +// The vnode's evaluated props as the child's raw props: marked for +// initInputs, and replaced on every patch so no first frame is retained. +function interopRawProps(vnode: VNode): RawProps { + const rawProps: RawProps = {} + const props = vnode.props for (const key in props) { - if (!isReservedProp(key)) { - filtered[key] = props[key] - } + if (!isReservedProp(key)) rawProps[key] = props[key] } - return filtered + rawProps[interopKey] = true + return rawProps } // mounting vapor components and slots in vdom @@ -369,7 +385,7 @@ const vaporInteropImpl = { const prev = currentInstance simpleSetCurrentInstance(parentComponent) - const propsRef = shallowRef(filterReservedProps(vnode.props)) + const rawProps = interopRawProps(vnode) const slotsRef = shallowRef(normalizeInteropSlots(vnode.children)) const rawSlots = createInteropRawSlots(slotsRef) @@ -378,24 +394,16 @@ const vaporInteropImpl = { setRenderContext(deriveSuspense(prevCtx, parentSuspense)) } - const dynamicPropSource: (() => any)[] & { [interopKey]?: boolean } = [ - () => propsRef.value, - ] - // mark as interop props - dynamicPropSource[interopKey] = true // @ts-expect-error const instance = (vnode.component = createComponent( vnode.type as any as VaporComponent, - { - $: dynamicPropSource, - } as RawProps, + rawProps, rawSlots, undefined, undefined, (parentComponent ? parentComponent.appContext : vnode.appContext) as any, true, )) - instance.rawPropsRef = propsRef instance.rawSlotsRef = slotsRef // read by vdom's shouldUpdateComponent to skip listener-only prop changes instance.emitsOptions = normalizeEmitsOptions(instance.type) @@ -1444,25 +1452,70 @@ function createVDOMComponent( rawProps?: LooseRawProps | null, rawSlots?: LooseRawSlots | null, once?: boolean, + inputScope?: EffectScope, ): VaporFragment { let suspense = currentRenderContext.suspense || (parentComponent && parentComponent.suspense) const useBridge = shouldUseRendererBridge(component) const comp = useBridge ? ensureRendererBridge(component) : component - // the props update through the wrapper instance, so resolving the initial - // ones must not track in the caller's effect (e.g. a teleport's children) - const prevSub = setActiveSub() + let propsInstance: VaporComponentInstance | undefined + let rawValues: Record = EMPTY_OBJ + let isMounted = false + let cells: SlotSourceCell[] | undefined + const isolated = rawSlots && isolateSlotSources(rawSlots as RawSlots) + if (isolated) { + rawSlots = isolated[0] + cells = isolated[1] + } + // inputs that can never change are delivered once, as in initInputs; a + // kept-alive vdom child is not rebound on a cache hit + let hasDynamicProps = + !once && + (!!cells || hasDynamicPropsSource((rawProps || EMPTY_OBJ) as RawProps)) + if (hasDynamicProps) inputScope ||= effectScope(true) + const prevInstance = setCurrentInstance(parentComponent, inputScope) let vnode: VNode try { - vnode = createVNode( - comp, - rawProps && extend({}, new Proxy(rawProps, rawPropsProxyHandlers)), - ) + if (!hasDynamicProps) { + rawValues = collectInputs((rawProps || EMPTY_OBJ) as RawProps, cells) + if (cells) deliverInputs(undefined, undefined, cells) + } else { + const effect = new RenderEffect(() => { + const prevInner = setCurrentInstance(parentComponent, inputScope) + try { + rawValues = collectProps( + (rawProps || EMPTY_OBJ) as RawProps, + rawValues.style, + ) + if (cells) collectSlotSources(cells) + } finally { + restoreCurrentInstance(prevInner) + } + if (effect.active && (propsInstance || cells)) { + deliverInputs(propsInstance, rawValues, cells) + } + }, true) + effect.run() + // every getter and descriptor turned out to be constant + if (!effect.deps) { + effect.stop() + hasDynamicProps = false + } + } + vnode = createVNode(comp, rawValues) + } catch (error) { + if (inputScope) inputScope.stop() + throw error } finally { - setActiveSub(prevSub) + restoreCurrentInstance(prevInstance) } const { frag, syncNodes } = createVNodeFragment(vnode) + frag.inputScope = inputScope + // Before mounting there is no VDOM instance scope to own these inputs. + onScopeDispose(() => { + if (!isMounted && inputScope) inputScope.stop() + }, true) const keepAliveCtx = isKeepAliveEnabled ? (getKeepAliveContext(parentComponent) as KeepAliveInstance['ctx'] | null) : null @@ -1505,19 +1558,36 @@ function createVDOMComponent( const prev = currentInstance simpleSetCurrentInstance(parentComponent) // Reuse VDOM's normalized options so Options API merging stays in VDOM. - const wrapper = new VaporComponentInstance>( + const wrapper = (propsInstance = new VaporComponentInstance< + Record + >( useBridge ? (comp as any) : { props: instance.propsOptions[0], __propsOptions: instance.propsOptions, }, - rawProps as RawProps, + undefined, rawSlots as RawSlots, parentComponent ? parentComponent.appContext : undefined, - once, - ) + )) simpleSetCurrentInstance(prev) + wrapper.interopVNode = vnode + wrapper.hasDynamicProps = hasDynamicProps + // The detached input scope follows the rendered component's lifetime. + instance.scope.run(() => { + onScopeDispose(() => { + if (inputScope) inputScope.stop() + wrapper.scope.stop() + }) + }) + try { + deliverInputs(wrapper, rawValues) + } catch (error) { + if (inputScope) inputScope.stop() + wrapper.scope.stop() + throw error + } const attrs = createInternalObject() const isFilteredAttr = (key: string | symbol): boolean => @@ -1564,19 +1634,9 @@ function createVDOMComponent( vnode.children = instance.slots vnode.shapeFlag |= ShapeFlags.SLOTS_CHILDREN } - - if (__DEV__) { - const prev = setCurrentInstance(wrapper, instance.scope) - try { - setupPropsValidation(wrapper, vnode) - } finally { - restoreCurrentInstance(prev) - } - } } let rawRef: VNodeNormalizedRef | null | undefined - let isMounted = false let isUnmounted = false let isDomRemoved = false const removeDom = (parentNode?: ParentNode): void => { @@ -1611,7 +1671,7 @@ function createVDOMComponent( } isUnmounted = true isMounted = false - if (isKeepAliveEnabled && frag.inputScope) frag.inputScope.stop() + if (inputScope) inputScope.stop() internals.um(vnode, parentComponent as any, parentSuspense, !!parentNode) // VDOM transitions own their leaving DOM until the leave finishes. if (!transition) removeDom(parentNode) @@ -3667,8 +3727,21 @@ function updateInteropVNode( prevVNode: VNode, ): void { state.pendingVNodeUpdate = vnode - instance.rawPropsRef!.value = filterReservedProps(vnode.props) - instance.rawSlotsRef!.value = normalizeInteropSlots(vnode.children) + const rawProps = (instance.rawProps = interopRawProps(vnode)) + // props and slots reach sync watchers together, outside the renderer's + // update effect + const prevSub = setActiveSub() + startBatch() + try { + deliverInputs(instance, rawProps) + instance.rawSlotsRef!.value = normalizeInteropSlots(vnode.children) + } finally { + try { + endBatch() + } finally { + setActiveSub(prevSub) + } + } // align with VDOM: vnode beforeUpdate runs before directive beforeUpdate. invokeInteropVNodeBeforeUpdate(instance, vnode, prevVNode) updateInteropDirs(instance, state, vnode, prevVNode) diff --git a/vite.config.ts b/vite.config.ts index 1c11e623621..f3bf930c503 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -62,6 +62,7 @@ export default defineConfig({ include: [ 'packages/reactivity/__tests__/gc.spec.ts', 'packages/runtime-vapor/__tests__/components/Transition.gc.spec.ts', + 'packages/runtime-vapor/__tests__/gc.spec.ts', 'packages/server-renderer/__tests__/ssrWatch.spec.ts', 'packages/server-renderer/__tests__/ssrRender.spec.ts', ], @@ -96,6 +97,7 @@ export default defineConfig({ ...configDefaults.exclude, '**/e2e/**', 'packages/runtime-vapor/__tests__/components/Transition.gc.spec.ts', + 'packages/runtime-vapor/__tests__/gc.spec.ts', ], }, },