From ff9b256044191569d77cb5e7ab5d991d8f8efb52 Mon Sep 17 00:00:00 2001 From: daiwei Date: Tue, 29 Sep 2026 14:52:04 +0800 Subject: [PATCH 01/11] fix(runtime-vapor): deliver evaluated props to components --- packages/runtime-core/src/apiCreateApp.ts | 2 + .../__tests__/apiCreateVaporApp.spec.ts | 18 + .../__tests__/apiDefineAsyncComponent.spec.ts | 109 +++ .../__tests__/apiSetupHelpers.spec.ts | 6 +- .../runtime-vapor/__tests__/component.spec.ts | 2 + .../__tests__/componentAttrs.spec.ts | 89 ++ .../__tests__/componentEmits.spec.ts | 68 ++ .../__tests__/componentProps.spec.ts | 837 +++++++++++++++++- .../__tests__/components/KeepAlive.spec.ts | 206 +++++ .../__tests__/customElement.spec.ts | 28 + packages/runtime-vapor/__tests__/gc.spec.ts | 51 ++ .../__tests__/vdomInterop.spec.ts | 164 ++++ .../src/apiDefineAsyncComponent.ts | 15 +- .../src/apiDefineCustomElement.ts | 3 +- packages/runtime-vapor/src/component.ts | 100 +-- packages/runtime-vapor/src/componentEmits.ts | 5 +- packages/runtime-vapor/src/componentProps.ts | 536 +++++------ .../runtime-vapor/src/components/KeepAlive.ts | 3 +- .../src/components/TransitionGroup.ts | 13 +- packages/runtime-vapor/src/fragment.ts | 3 +- packages/runtime-vapor/src/keepAlive.ts | 2 - packages/runtime-vapor/src/vdomInterop.ts | 88 +- vite.config.ts | 2 + 23 files changed, 1929 insertions(+), 421 deletions(-) create mode 100644 packages/runtime-vapor/__tests__/gc.spec.ts 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() + }) }) diff --git a/packages/runtime-vapor/__tests__/componentProps.spec.ts b/packages/runtime-vapor/__tests__/componentProps.spec.ts index be98c7359ac..82df3c53745 100644 --- a/packages/runtime-vapor/__tests__/componentProps.spec.ts +++ b/packages/runtime-vapor/__tests__/componentProps.spec.ts @@ -1,20 +1,24 @@ // NOTE: This test is implemented based on the case of `runtime-core/__test__/componentProps.spec.ts`. import { - // currentInstance, + createApp, inject, isShallow, 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 +549,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 +562,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 +1361,825 @@ 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, + ) + }, + ) }) 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..2e0d23a9ac4 --- /dev/null +++ b/packages/runtime-vapor/__tests__/gc.spec.ts @@ -0,0 +1,51 @@ +// @vitest-environment jsdom + +import { nextTick, shallowRef } from '@vue/runtime-dom' +import { createVaporApp } 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() + } + }) +}) 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..a9d4f5f3f1b 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 } 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, $: [() => parent.rawValues.value] }, // Avoid wrapper slot fallthrough {}, ) @@ -290,7 +289,7 @@ function createErrorComp( function createInnerComp( comp: VaporComponent, parent: VaporComponentInstance & TransitionOptions, - rawProps = parent.rawProps, + rawProps: RawProps = { $: [() => parent.rawValues.value] }, 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..a64dd39696d 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -64,6 +64,7 @@ import { onScopeDispose, proxyRefs, setActiveSub, + shallowRef, toRaw, unref, } from '@vue/reactivity' @@ -86,17 +87,16 @@ import { import { type DynamicPropsSource, type RawProps, - getKeysFromRawProps, getPropsProxyHandlers, getStaticBindingKeys, hasFallthroughAttrs, + initProps, 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 { @@ -393,6 +393,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 + initProps(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 +464,17 @@ 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) + // Dynamic slot descriptors share the input lifetime; local effects do not. + if (rawSlots && (rawSlots as RawSlots).$) { + inputScope.run(() => { + rawSlots = keepAliveCtx!.isolateSlotSources(rawSlots as RawSlots) + }) } } - // 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 +482,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 @@ -535,6 +516,8 @@ export function createComponent( 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 +564,8 @@ export function createComponent( instance.emitsOptions = normalizeEmitsOptions(component) } + initProps(instance, once) + // hydrating async component if ( isHydrating && @@ -698,10 +683,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 +881,9 @@ export class VaporComponentInstance< TypeRefs extends Record = Record, > implements GenericComponentInstance { vapor: true + propsValues: Record + rawValues: ShallowRef> + propsEffect?: RenderEffect uid: number type: VaporComponent root: GenericComponentInstance | null @@ -1074,7 +1058,10 @@ export class VaporComponentInstance< this.isDeactivated = false - // init props + // Track through the public proxies to avoid pulling generic reactive + // handlers into pure Vapor bundles. + this.propsValues = Object.create(null) + this.rawValues = shallowRef(EMPTY_OBJ) // Snapshot raw parent inputs before creating proxies so delayed reads from // v-once children cannot observe later parent updates. this.rawProps = @@ -1082,19 +1069,15 @@ export class VaporComponentInstance< // 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) @@ -1133,15 +1116,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.value) } } diff --git a/packages/runtime-vapor/src/componentEmits.ts b/packages/runtime-vapor/src/componentEmits.ts index d2e33af5190..2c9a40f7a92 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.value, + defaultPropGetter, event, ...rawArgs, ) diff --git a/packages/runtime-vapor/src/componentProps.ts b/packages/runtime-vapor/src/componentProps.ts index 2b0528fef90..d46a097c912 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, @@ -16,9 +18,7 @@ import { } from '@vue/shared' import type { VaporComponent, VaporComponentInstance } from './component' import { - type GenericComponentInstance, type NormalizedPropsOptions, - type VNode, baseNormalizePropsOptions, currentInstance, isEmitListener, @@ -32,15 +32,21 @@ import { } from '@vue/runtime-dom' import { type ComputedRef, + EffectFlags, + ITERATE_KEY, ReactiveFlags, + TrackOpTypes, + TriggerOpTypes, computed, getCurrentScope, onScopeDispose, - shallowReactive, + setActiveSub, + track, + trigger, } 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" @@ -51,145 +57,15 @@ 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 { +type FunctionSource = (() => T) & { + _cache?: ComputedRef +} + +export function resolveFunctionSource(source: FunctionSource): T { // use existing cache if available if (source._cache) { return source._cache.value @@ -220,7 +96,6 @@ export function resolveFunctionSource( return source._cache.value } - // no parent, no cache - just call directly return source() } @@ -336,127 +211,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, - ) - } - } - } - } + if (isProp(key)) { + track(instance.propsValues, TrackOpTypes.GET, key) + return instance.propsValues[key] } - 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, - ) - } - } - } - 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 +245,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 + track(instance.propsValues, TrackOpTypes.GET, 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 + track(instance.propsValues, TrackOpTypes.HAS, 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) { + track(target.propsValues, TrackOpTypes.ITERATE, ITERATE_KEY) + 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 +392,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( @@ -664,26 +421,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 +497,194 @@ export const rawPropsProxyHandlers: ProxyHandler = { } }, } + +// Inputs belong to the child scope, but run in the parent's update order. +export function initProps( + instance: VaporComponentInstance, + once = false, +): void { + const scope = instance.inputScope || instance.scope + const previousEffect = instance.propsEffect + if (previousEffect) previousEffect.stop() + const parent = instance.parent + const previous = setCurrentInstance(parent || instance, scope) + try { + const sources = instance.rawProps.$ + // VDOM already evaluated these inputs and delivers subsequent patches. + if (once || (sources && sources[interopKey])) { + const subscriber = setActiveSub() + try { + updateProps(instance, collectProps(instance.rawProps)) + } finally { + setActiveSub(subscriber) + } + return + } + const input = new RenderEffect(() => { + const context = setCurrentInstance(parent || instance, scope) + let raw: Record + try { + raw = collectProps(instance.rawProps) + } finally { + restoreCurrentInstance(context) + } + if (input.active) updateProps(instance, raw) + }, true) + instance.propsEffect = input + if (!previousEffect) { + input.run() + } else { + if (scope.flags & EffectFlags.PAUSED) input.pause() + input.notify() + } + } catch (error) { + // Initialization can fail before createComponent registers its teardown. + if (instance.propsEffect) instance.propsEffect.stop() + instance.scope.stop() + if (instance.inputScope) instance.inputScope.stop() + throw error + } finally { + restoreCurrentInstance(previous) + } +} + +export function collectProps(sources: RawProps): Record { + // Raw attrs may contain prototype-named keys such as __proto__. + const raw: Record = Object.create(null) + const dynamicSources = sources.$ || EMPTY_ARR + for (let i = -1; i < dynamicSources.length; i++) { + const source = i < 0 ? sources : dynamicSources[i] + const dynamic = isFunction(source) + const resolved = dynamic ? source() : source + for (const key in resolved) { + if (i < 0 && key === '$') continue + const value = dynamic ? resolved[key] : readSource(resolved[key]) + if (!dynamicSources.length) { + raw[key] = value + } else if (key === 'class') { + if (raw.class !== value) raw.class = normalizeClass([raw.class, value]) + } else if (key === 'style') { + raw.style = normalizeStyle([raw.style, value]) + } else if (isOn(key)) { + const existing = raw[key] + if ( + value && + existing !== value && + !(isArray(existing) && existing.includes(value)) + ) { + raw[key] = existing ? ([] as any[]).concat(existing, value) : value + } else if (value == null && existing == null && !isModelListener(key)) { + raw[key] = value + } + } else if (key !== '') { + raw[key] = value + } + } + } + if (raw.class && !isString(raw.class)) raw.class = normalizeClass(raw.class) + if (raw.style && isObject(raw.style)) raw.style = normalizeStyle([raw.style]) + return raw +} + +export function updateProps( + instance: VaporComponentInstance, + raw: Record, +): void { + const values = instance.propsValues + const [options, castKeys] = normalizePropsOptions(instance.type) + const emits = normalizeEmitsOptions(instance.type) + const subscriber = setActiveSub() + try { + // Raw listeners become current before normalized keys notify watchers. + instance.rawValues.value = stabilizeDynamicSourceValue( + instance.rawValues.value, + raw, + ) + raw = instance.rawValues.value + const vnode = isInteropEnabled && instance.interopVNode + if (vnode && vnode.vi) vnode.props = raw + const present: Record = Object.create(null) + const casts: Record = Object.create(null) + for (const key in raw) { + if (isReservedProp(key)) continue + const normalized = camelize(key) + if (options && hasOwn(options, normalized)) { + present[normalized] = true + if (castKeys && castKeys.includes(normalized)) { + casts[normalized] = raw[key] + } else { + setPropValue(values, normalized, raw[key]) + } + } else if (!isEmitListener(emits, key)) { + present[key] = true + setPropValue(values, key, raw[key]) + } + } + if (castKeys) { + for (const key of castKeys) { + setPropValue( + values, + key, + resolvePropValue( + options!, + key, + casts[key], + instance, + resolveDefault, + !hasOwn(casts, key), + ), + ) + present[key] = true + } + } + for (const key in options) { + if (!present[key]) { + setPropValue( + values, + key, + resolvePropValue( + options, + key, + undefined, + instance, + resolveDefault, + true, + ), + ) + } + present[key] = true + } + for (const key in values) { + if (!present[key]) { + const oldValue = values[key] + delete values[key] + trigger(values, TriggerOpTypes.DELETE, key, undefined, oldValue) + } + } + if (__DEV__ && options) { + pushWarningContext(instance) + try { + validateProps(raw, instance.props, options) + } finally { + popWarningContext() + } + } + } finally { + setActiveSub(subscriber) + } +} + +function setPropValue( + values: Record, + key: string, + value: unknown, +) { + const hadKey = hasOwn(values, key) + const oldValue = values[key] + values[key] = value + if (!hadKey) { + trigger(values, TriggerOpTypes.ADD, key, value) + } else if (hasChanged(value, oldValue)) { + trigger(values, TriggerOpTypes.SET, key, value, oldValue) + } +} diff --git a/packages/runtime-vapor/src/components/KeepAlive.ts b/packages/runtime-vapor/src/components/KeepAlive.ts index d5b1d4012e1..fefb11ee67f 100644 --- a/packages/runtime-vapor/src/components/KeepAlive.ts +++ b/packages/runtime-vapor/src/components/KeepAlive.ts @@ -28,7 +28,7 @@ import { isVaporComponent, } from '../component' import { isAsyncComponentEnabled } from '../asyncComponentState' -import { isolatePropSources, resolveFunctionSource } from '../componentProps' +import { resolveFunctionSource } from '../componentProps' import type { DynamicSlotFn, RawSlots } from '../componentSlots' import { type DefineVaporComponent, @@ -357,7 +357,6 @@ const VaporKeepAliveImpl = defineVaporComponent({ }) const keepAliveCtx: KeepAliveInstance['ctx'] = { - isolatePropSources, isolateSlotSources, getStorageContainer: () => storageContainer, getCachedComponent: (comp, key) => { diff --git a/packages/runtime-vapor/src/components/TransitionGroup.ts b/packages/runtime-vapor/src/components/TransitionGroup.ts index 737cfe2a74d..fa1addc3ed7 100644 --- a/packages/runtime-vapor/src/components/TransitionGroup.ts +++ b/packages/runtime-vapor/src/components/TransitionGroup.ts @@ -52,7 +52,7 @@ import { type VaporComponentOptions, isVaporComponent, } from '../component' -import { type RawProps, resolveDynamicProps } from '../componentProps' +import type { RawProps } from '../componentProps' import { createElement } from '../dom/node' import { DynamicFragment, @@ -553,7 +553,7 @@ function trackTransitionGroupUpdate( 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,14 +562,7 @@ 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) - } + owner.rawValues.value }) } finally { restoreCurrentInstance(prevGroup) 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..09c91ea3162 100644 --- a/packages/runtime-vapor/src/keepAlive.ts +++ b/packages/runtime-vapor/src/keepAlive.ts @@ -6,11 +6,9 @@ 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 diff --git a/packages/runtime-vapor/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts index 946c3a0376e..5eab57eece5 100644 --- a/packages/runtime-vapor/src/vdomInterop.ts +++ b/packages/runtime-vapor/src/vdomInterop.ts @@ -71,7 +71,7 @@ import { warn, withCtx, } from '@vue/runtime-dom' -import { effectScope, setActiveSub } from '@vue/reactivity' +import { type EffectScope, effectScope, setActiveSub } from '@vue/reactivity' import { type LooseRawProps, type VaporComponent, @@ -122,14 +122,15 @@ import { } from '@vue/shared' import { type RawProps, + collectProps, rawPropsProxyHandlers, - setupPropsValidation, snapshotRawProps, + updateProps, } from './componentProps' import type { RawSlots, VaporSlot } from './componentSlots' import { dynamicSlotsProxyHandlers, getSlot } 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 { @@ -1444,25 +1445,54 @@ 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 currentRaw: Record = EMPTY_OBJ + let isMounted = false + if (!once) inputScope ||= effectScope(true) + const previous = setCurrentInstance(parentComponent, inputScope) let vnode: VNode try { - vnode = createVNode( - comp, - rawProps && extend({}, new Proxy(rawProps, rawPropsProxyHandlers)), - ) + if (once) { + const subscriber = setActiveSub() + try { + currentRaw = collectProps((rawProps || EMPTY_OBJ) as RawProps) + } finally { + setActiveSub(subscriber) + } + } else { + const input = new RenderEffect(() => { + const context = setCurrentInstance(parentComponent, inputScope) + try { + currentRaw = collectProps((rawProps || EMPTY_OBJ) as RawProps) + } finally { + restoreCurrentInstance(context) + } + if (propsInstance && input.active) { + updateProps(propsInstance, currentRaw) + } + }, true) + input.run() + } + vnode = createVNode(comp, currentRaw) + } catch (error) { + if (inputScope) inputScope.stop() + throw error } finally { - setActiveSub(prevSub) + restoreCurrentInstance(previous) } 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 +1535,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 + // The detached input scope follows the rendered component's lifetime. + instance.scope.run(() => { + onScopeDispose(() => { + if (inputScope) inputScope.stop() + wrapper.scope.stop() + }) + }) + try { + updateProps(wrapper, currentRaw) + } catch (error) { + if (inputScope) inputScope.stop() + wrapper.scope.stop() + throw error + } const attrs = createInternalObject() const isFilteredAttr = (key: string | symbol): boolean => @@ -1564,19 +1611,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 +1648,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) @@ -3668,6 +3705,7 @@ function updateInteropVNode( ): void { state.pendingVNodeUpdate = vnode instance.rawPropsRef!.value = filterReservedProps(vnode.props) + updateProps(instance, vnode.props || EMPTY_OBJ) instance.rawSlotsRef!.value = normalizeInteropSlots(vnode.children) // align with VDOM: vnode beforeUpdate runs before directive beforeUpdate. invokeInteropVNodeBeforeUpdate(instance, 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', ], }, }, From 54fada21feee8fdf319c80d1ed491e017080d437 Mon Sep 17 00:00:00 2001 From: edison Date: Tue, 29 Sep 2026 19:24:59 -0700 Subject: [PATCH 02/11] fix(runtime-vapor): deliver props and dynamic slots in one atomic input effect --- packages/reactivity/src/index.ts | 2 +- .../runtime-vapor/__tests__/apiWatch.spec.ts | 3 +- .../runtime-vapor/__tests__/component.spec.ts | 4 +- .../__tests__/componentEmits.spec.ts | 71 ++- .../__tests__/componentProps.spec.ts | 354 +++++++++++++++ .../__tests__/componentSlots.spec.ts | 348 ++++++++++++++ packages/runtime-vapor/__tests__/hmr.spec.ts | 26 ++ .../src/apiDefineAsyncComponent.ts | 6 +- packages/runtime-vapor/src/component.ts | 31 +- packages/runtime-vapor/src/componentEmits.ts | 2 +- packages/runtime-vapor/src/componentProps.ts | 427 +++++++++++++----- packages/runtime-vapor/src/componentSlots.ts | 62 +++ .../runtime-vapor/src/components/KeepAlive.ts | 41 +- .../src/components/TransitionGroup.ts | 18 +- packages/runtime-vapor/src/keepAlive.ts | 2 - packages/runtime-vapor/src/renderEffect.ts | 10 + packages/runtime-vapor/src/vdomInterop.ts | 51 ++- 17 files changed, 1251 insertions(+), 207 deletions(-) diff --git a/packages/reactivity/src/index.ts b/packages/reactivity/src/index.ts index ef643940b00..e2b4fd5dfd4 100644 --- a/packages/reactivity/src/index.ts +++ b/packages/reactivity/src/index.ts @@ -105,4 +105,4 @@ export { /** * @internal */ -export { setActiveSub } from './system' +export { endBatch, setActiveSub, startBatch } from './system' diff --git a/packages/runtime-vapor/__tests__/apiWatch.spec.ts b/packages/runtime-vapor/__tests__/apiWatch.spec.ts index 290c509552c..cf935dc4b3e 100644 --- a/packages/runtime-vapor/__tests__/apiWatch.spec.ts +++ b/packages/runtime-vapor/__tests__/apiWatch.spec.ts @@ -299,8 +299,7 @@ describe('apiWatch', () => { } define(Comp).render() // should not record watcher in detached scope - // the 1 is the props validation effect - expect(getEffectsCount(instance!.scope)).toBe(1) + expect(getEffectsCount(instance!.scope)).toBe(0) }) test('watchEffect should keep running if created in a detached scope', async () => { diff --git a/packages/runtime-vapor/__tests__/component.spec.ts b/packages/runtime-vapor/__tests__/component.spec.ts index 55c29e178a0..35714000a06 100644 --- a/packages/runtime-vapor/__tests__/component.spec.ts +++ b/packages/runtime-vapor/__tests__/component.spec.ts @@ -417,8 +417,8 @@ describe('component', () => { }).render() const i = instance as VaporComponentInstance - // watchEffect + renderEffect + props validation effect - expect(getEffectsCount(i.scope)).toBe(3) + // watchEffect + renderEffect + expect(getEffectsCount(i.scope)).toBe(2) expect(host.innerHTML).toBe('
0
') app.unmount() diff --git a/packages/runtime-vapor/__tests__/componentEmits.spec.ts b/packages/runtime-vapor/__tests__/componentEmits.spec.ts index 640529ef7f7..3068975e7fe 100644 --- a/packages/runtime-vapor/__tests__/componentEmits.spec.ts +++ b/packages/runtime-vapor/__tests__/componentEmits.spec.ts @@ -5,6 +5,7 @@ import { isEmitListener, + markRaw, nextTick, onBeforeUnmount, ref, @@ -17,7 +18,7 @@ import { defineVaporComponent, template, } from '../src' -import { compile, makeRender } from './_utils' +import { compile, makeRender, renderParity } from './_utils' const define = makeRender() @@ -638,7 +639,7 @@ describe('component: emit', () => { expect(onObject).toHaveBeenCalledTimes(1) }) - test('emit sees the new raw listener before its declared prop is published', async () => { + test('a sync watcher sees the new listener as a prop and through emit', async () => { const oldListener = vi.fn() const newListener = vi.fn() const data = ref({ count: 0, listener: oldListener }) @@ -674,7 +675,7 @@ describe('component: emit', () => { data.value.listener = newListener await nextTick() - expect(propListeners).toEqual([oldListener]) + expect(propListeners).toEqual([newListener]) expect(props.onChange).toBe(newListener) expect(oldListener).not.toHaveBeenCalled() expect(newListener).toHaveBeenCalledExactlyOnceWith(1) @@ -704,4 +705,68 @@ describe('component: emit', () => { 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 82df3c53745..949d5a8df28 100644 --- a/packages/runtime-vapor/__tests__/componentProps.spec.ts +++ b/packages/runtime-vapor/__tests__/componentProps.spec.ts @@ -2,8 +2,10 @@ import { createApp, + currentInstance, inject, isShallow, + markRaw, nextTick, provide, ref, @@ -2182,4 +2184,356 @@ describe('component: props', () => { ) }, ) + + 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() + }) + + // 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..e99da76108f 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,351 @@ 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('a delivery is atomic across props and descriptors', async () => { + const data = ref({ x: { 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.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() + }) + + // 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__/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/src/apiDefineAsyncComponent.ts b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts index a9d4f5f3f1b..a0eb2b1ec8e 100644 --- a/packages/runtime-vapor/src/apiDefineAsyncComponent.ts +++ b/packages/runtime-vapor/src/apiDefineAsyncComponent.ts @@ -17,7 +17,7 @@ import { type VaporComponentInstance, createComponent, } from './component' -import type { RawProps } from './componentProps' +import { type RawProps, trackRawValues } from './componentProps' import { enableAsyncComponent } from './asyncComponentState' import { renderEffect } from './renderEffect' import { DynamicFragment, isDynamicFragment } from './fragment' @@ -280,7 +280,7 @@ function createErrorComp( comp, parent, // Forward the wrapper's delivered props and attrs to the error component. - { error: getError, $: [() => parent.rawValues.value] }, + { error: getError, $: [() => trackRawValues(parent)] }, // Avoid wrapper slot fallthrough {}, ) @@ -289,7 +289,7 @@ function createErrorComp( function createInnerComp( comp: VaporComponent, parent: VaporComponentInstance & TransitionOptions, - rawProps: RawProps = { $: [() => parent.rawValues.value] }, + rawProps: RawProps = { $: [() => trackRawValues(parent)] }, rawSlots = parent.rawSlots, ): VaporComponentInstance { const prevInstance = setCurrentInstance(parent) diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index a64dd39696d..85cfd66d468 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -64,7 +64,6 @@ import { onScopeDispose, proxyRefs, setActiveSub, - shallowRef, toRaw, unref, } from '@vue/reactivity' @@ -86,6 +85,7 @@ import { } from '@vue/shared' import { type DynamicPropsSource, + INITIAL_RAW_VALUES, type RawProps, getPropsProxyHandlers, getStaticBindingKeys, @@ -102,9 +102,11 @@ import { patchDynamicProps } from './dom/prop' import { type LooseRawSlots, type RawSlots, + type SlotSourceCell, type StaticSlots, dynamicSlotsProxyHandlers, getSlot, + initSlots, normalizeRawSlots, snapshotRawSlots, } from './componentSlots' @@ -466,12 +468,6 @@ export function createComponent( let inputScope: EffectScope | undefined if (keepAliveCtx && !once && !managedMount) { inputScope = new EffectScope(true) - // Dynamic slot descriptors share the input lifetime; local effects do not. - if (rawSlots && (rawSlots as RawSlots).$) { - inputScope.run(() => { - rawSlots = keepAliveCtx!.isolateSlotSources(rawSlots as RawSlots) - }) - } } // The VDOM bridge delivers inputs through the same cache-owned scope. @@ -564,7 +560,16 @@ export function createComponent( instance.emitsOptions = normalizeEmitsOptions(component) } - initProps(instance, once) + try { + initSlots(instance) + initProps(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 ( @@ -882,8 +887,11 @@ export class VaporComponentInstance< > implements GenericComponentInstance { vapor: true propsValues: Record - rawValues: ShallowRef> + rawValues: Record propsEffect?: RenderEffect + slotSources?: SlotSourceCell[] + // false when the inputs can never change: reading them subscribes to nothing + hasDynamicProps: boolean uid: number type: VaporComponent root: GenericComponentInstance | null @@ -1061,7 +1069,8 @@ export class VaporComponentInstance< // Track through the public proxies to avoid pulling generic reactive // handlers into pure Vapor bundles. this.propsValues = Object.create(null) - this.rawValues = shallowRef(EMPTY_OBJ) + this.rawValues = INITIAL_RAW_VALUES + this.hasDynamicProps = true // Snapshot raw parent inputs before creating proxies so delayed reads from // v-once children cannot observe later parent updates. this.rawProps = @@ -1119,7 +1128,7 @@ export class VaporComponentInstance< // Parent-provided keys are needed by APIs such as useModel. rawKeys(): string[] { const vnode = isInteropEnabled && this.interopVNode - return Object.keys(vnode ? vnode.props || EMPTY_OBJ : this.rawValues.value) + 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 2c9a40f7a92..12abab2fe42 100644 --- a/packages/runtime-vapor/src/componentEmits.ts +++ b/packages/runtime-vapor/src/componentEmits.ts @@ -38,7 +38,7 @@ export function emit( const vnode = isInteropEnabled && instance.interopVNode baseEmit( instance, - vnode ? vnode.props || EMPTY_OBJ : instance.rawValues.value, + 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 d46a097c912..b71cccde272 100644 --- a/packages/runtime-vapor/src/componentProps.ts +++ b/packages/runtime-vapor/src/componentProps.ts @@ -17,6 +17,7 @@ import { normalizeStyle, } from '@vue/shared' import type { VaporComponent, VaporComponentInstance } from './component' +import type { SlotSourceCell } from './componentSlots' import { type NormalizedPropsOptions, baseNormalizePropsOptions, @@ -31,16 +32,17 @@ import { warn, } from '@vue/runtime-dom' import { - type ComputedRef, EffectFlags, ITERATE_KEY, ReactiveFlags, TrackOpTypes, TriggerOpTypes, computed, + endBatch, getCurrentScope, onScopeDispose, setActiveSub, + startBatch, track, trigger, } from '@vue/reactivity' @@ -61,8 +63,8 @@ export function resolveSource(source: T | (() => T)): T { return isFunction(source) ? resolveFunctionSource(source as () => T) : source } -type FunctionSource = (() => T) & { - _cache?: ComputedRef +export type FunctionSource = (() => T) & { + _cache?: { readonly value: T } } export function resolveFunctionSource(source: FunctionSource): T { @@ -150,13 +152,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 } @@ -216,7 +235,7 @@ export function getPropsProxyHandlers( if (key === ReactiveFlags.IS_REACTIVE || key === ReactiveFlags.IS_SHALLOW) return true if (isProp(key)) { - track(instance.propsValues, TrackOpTypes.GET, key) + trackPropsValue(instance, TrackOpTypes.GET, key) return instance.propsValues[key] } } @@ -247,20 +266,20 @@ export function getPropsProxyHandlers( const getAttr = (instance: VaporComponentInstance, key: string | symbol) => { if (isAttr(key)) { - track(instance.propsValues, TrackOpTypes.GET, key) + trackPropsValue(instance, TrackOpTypes.GET, key) return instance.propsValues[key] } } const hasAttr = (instance: VaporComponentInstance, key: string | symbol) => { if (!isAttr(key)) return false - track(instance.propsValues, TrackOpTypes.HAS, key) + trackPropsValue(instance, TrackOpTypes.HAS, key) return hasOwn(instance.propsValues, key) } const attrsHandlers = { get: getAttr, has: hasAttr, ownKeys(target) { - track(target.propsValues, TrackOpTypes.ITERATE, ITERATE_KEY) + trackPropsValue(target, TrackOpTypes.ITERATE, ITERATE_KEY) return Object.keys(target.propsValues).filter(isAttr) }, getOwnPropertyDescriptor(target, key: string | symbol) { @@ -498,193 +517,375 @@ export const rawPropsProxyHandlers: ProxyHandler = { }, } +const RAW_VALUES_KEY: unique symbol = Symbol(__DEV__ ? 'Raw values' : '') + +// `rawValues` before the first delivery +export const INITIAL_RAW_VALUES: Record = {} + +function trackPropsValue( + instance: VaporComponentInstance, + type: TrackOpTypes, + key: unknown, +): void { + if (instance.hasDynamicProps) track(instance.propsValues, type, key) +} + +// 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, TrackOpTypes.GET, RAW_VALUES_KEY) + return instance.rawValues +} + // Inputs belong to the child scope, but run in the parent's update order. export function initProps( instance: VaporComponentInstance, once = false, ): void { const scope = instance.inputScope || instance.scope - const previousEffect = instance.propsEffect - if (previousEffect) previousEffect.stop() - const parent = instance.parent - const previous = setCurrentInstance(parent || instance, scope) + const prevEffect = instance.propsEffect + if (prevEffect) prevEffect.stop() + const parent = instance.parent || instance + const rawProps = instance.rawProps + const cells = instance.slotSources + const prev = setCurrentInstance(parent, scope) try { - const sources = instance.rawProps.$ + const dynamicSources = rawProps.$ // VDOM already evaluated these inputs and delivers subsequent patches. - if (once || (sources && sources[interopKey])) { - const subscriber = setActiveSub() + const isInterop = + isInteropEnabled && !!(dynamicSources && dynamicSources[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 + const prevSub = setActiveSub() try { - updateProps(instance, collectProps(instance.rawProps)) + updateProps(instance, collectProps(rawProps)) } finally { - setActiveSub(subscriber) + setActiveSub(prevSub) } return } - const input = new RenderEffect(() => { - const context = setCurrentInstance(parent || instance, scope) - let raw: Record + 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 { - raw = collectProps(instance.rawProps) + 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(context) + restoreCurrentInstance(prevInner) + } + if (effect.active) { + // a delivery that throws half-way is redone in full + hasDelivered = false + // sync watchers run once the props and the descriptors are all + // written, and outside this effect: their callbacks must not + // subscribe it + const prevSub = setActiveSub() + startBatch() + try { + if (rawValues || propsToUpdate) { + updateProps(instance, rawValues || prevRawValues, propsToUpdate) + } + if (cells) commitSlotSources(cells) + } finally { + endBatch() + setActiveSub(prevSub) + } + hasDelivered = true } - if (input.active) updateProps(instance, raw) }, true) - instance.propsEffect = input - if (!previousEffect) { - input.run() + if (canRebind) instance.propsEffect = 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) input.pause() - input.notify() + if (scope.flags & EffectFlags.PAUSED) effect.pause() + effect.notify() + } + } finally { + restoreCurrentInstance(prev) + } +} + +export function collectSlotSources(cells: SlotSourceCell[]): void { + for (let i = 0; i < cells.length; i++) { + const cell = cells[i] + if (cell) cell.next = cell.source() + } +} + +// an unchanged descriptor keeps the identity its readers already saw +export function commitSlotSources(cells: SlotSourceCell[]): void { + const prevSub = setActiveSub() + try { + for (let i = 0; i < cells.length; i++) { + const cell = cells[i] + if (cell) { + cell.committed.value = stabilizeDynamicSourceValue( + cell.committed.value, + cell.next!, + ) + } } - } catch (error) { - // Initialization can fail before createComponent registers its teardown. - if (instance.propsEffect) instance.propsEffect.stop() - instance.scope.stop() - if (instance.inputScope) instance.inputScope.stop() - throw error } finally { - restoreCurrentInstance(previous) + setActiveSub(prevSub) } } -export function collectProps(sources: RawProps): Record { +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 raw: Record = Object.create(null) - const dynamicSources = sources.$ || EMPTY_ARR + const rawValues: Record = Object.create(null) + const dynamicSources = rawProps.$ || EMPTY_ARR for (let i = -1; i < dynamicSources.length; i++) { - const source = i < 0 ? sources : dynamicSources[i] - const dynamic = isFunction(source) - const resolved = dynamic ? source() : source + 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 = dynamic ? resolved[key] : readSource(resolved[key]) + const value = isDynamic ? resolved[key] : readSource(resolved[key]) if (!dynamicSources.length) { - raw[key] = value + rawValues[key] = value } else if (key === 'class') { - if (raw.class !== value) raw.class = normalizeClass([raw.class, value]) + if (rawValues.class !== value) { + rawValues.class = normalizeClass([rawValues.class, value]) + } } else if (key === 'style') { - raw.style = normalizeStyle([raw.style, value]) + rawValues.style = normalizeStyle([rawValues.style, value]) } else if (isOn(key)) { - const existing = raw[key] + const existing = rawValues[key] if ( value && existing !== value && !(isArray(existing) && existing.includes(value)) ) { - raw[key] = existing ? ([] as any[]).concat(existing, value) : value + rawValues[key] = existing + ? ([] as any[]).concat(existing, value) + : value } else if (value == null && existing == null && !isModelListener(key)) { - raw[key] = value + rawValues[key] = value } } else if (key !== '') { - raw[key] = value + rawValues[key] = value } } } - if (raw.class && !isString(raw.class)) raw.class = normalizeClass(raw.class) - if (raw.style && isObject(raw.style)) raw.style = normalizeStyle([raw.style]) - return raw + // 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 } export function updateProps( instance: VaporComponentInstance, - raw: Record, + rawValues: Record, + // the entries to set on the delivered `rawValues`, as [key, value, …] + propsToUpdate?: unknown[], ): void { - const values = instance.propsValues - const [options, castKeys] = normalizePropsOptions(instance.type) - const emits = normalizeEmitsOptions(instance.type) - const subscriber = setActiveSub() + const propsValues = instance.propsValues + const [options, needCastKeys] = normalizePropsOptions(instance.type) + const emitsOptions = normalizeEmitsOptions(instance.type) + const prevSub = setActiveSub() + // sync watchers run once every key is written + startBatch() try { + 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.value = stabilizeDynamicSourceValue( - instance.rawValues.value, - raw, - ) - raw = instance.rawValues.value + instance.rawValues = rawValues + if (propsToUpdate || (!isInitial && rawValues !== prevRawValues)) { + trigger(propsValues, TriggerOpTypes.SET, RAW_VALUES_KEY) + } const vnode = isInteropEnabled && instance.interopVNode - if (vnode && vnode.vi) vnode.props = raw - const present: Record = Object.create(null) - const casts: Record = Object.create(null) - for (const key in raw) { + 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 normalized = camelize(key) - if (options && hasOwn(options, normalized)) { - present[normalized] = true - if (castKeys && castKeys.includes(normalized)) { - casts[normalized] = raw[key] - } else { - setPropValue(values, normalized, raw[key]) + 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(emits, key)) { - present[key] = true - setPropValue(values, key, raw[key]) + } else if (isEmitListener(emitsOptions, key)) { + continue } + if (present) present[key] = true + setPropValue(propsValues, key, value, isInitial) } - if (castKeys) { - for (const key of castKeys) { + // 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( - values, + propsValues, key, resolvePropValue( options!, key, - casts[key], + isAbsent ? undefined : rawCastValues![key], instance, resolveDefault, - !hasOwn(casts, key), + isAbsent, ), + isInitial, ) - present[key] = true + if (present) present[key] = true } } - for (const key in options) { - if (!present[key]) { - setPropValue( - values, - key, - resolvePropValue( - options, + if (!propsToUpdate) { + for (const key in options) { + if (present ? !present[key] : !hasOwn(propsValues, key)) { + setPropValue( + propsValues, key, - undefined, - instance, - resolveDefault, - true, - ), - ) + resolvePropValue( + options, + key, + undefined, + instance, + resolveDefault, + true, + ), + isInitial, + ) + if (present) present[key] = true + } } - present[key] = true - } - for (const key in values) { - if (!present[key]) { - const oldValue = values[key] - delete values[key] - trigger(values, TriggerOpTypes.DELETE, key, undefined, oldValue) + if (present) { + for (const key in propsValues) { + if (!present[key]) { + const oldValue = propsValues[key] + delete propsValues[key] + trigger( + propsValues, + TriggerOpTypes.DELETE, + key, + undefined, + oldValue, + ) + } + } } } if (__DEV__ && options) { pushWarningContext(instance) try { - validateProps(raw, instance.props, options) + validateProps(rawValues, instance.props, options) } finally { popWarningContext() } } } finally { - setActiveSub(subscriber) + endBatch() + setActiveSub(prevSub) } } function setPropValue( - values: Record, + propsValues: Record, key: string, value: unknown, + isInitial: boolean, ) { - const hadKey = hasOwn(values, key) - const oldValue = values[key] - values[key] = value + if (isInitial) { + propsValues[key] = value + return + } + const hadKey = hasOwn(propsValues, key) + const oldValue = propsValues[key] + propsValues[key] = value if (!hadKey) { - trigger(values, TriggerOpTypes.ADD, key, value) + trigger(propsValues, TriggerOpTypes.ADD, key, value) } else if (hasChanged(value, oldValue)) { - trigger(values, TriggerOpTypes.SET, key, value, oldValue) + trigger(propsValues, TriggerOpTypes.SET, key, value, oldValue) } } diff --git a/packages/runtime-vapor/src/componentSlots.ts b/packages/runtime-vapor/src/componentSlots.ts index f108a784647..cbc70faf0af 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 { type ShallowRef, shallowRef } from '@vue/reactivity' import type { LooseRawProps, VaporComponentInstance } from './component' import { renderEffect } from './renderEffect' import { @@ -106,6 +109,65 @@ 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. +export interface SlotSourceCell { + source: DynamicSlotFn + committed: ShallowRef> + next?: ReturnType +} + +/** + * 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. `cells` is parallel to the sources; a static source leaves + * a hole. Slot functions keep their closure semantics. + */ +export function isolateSlotSources( + rawSlots: RawSlots, + cells: SlotSourceCell[], +): RawSlots { + const dynamicSources = rawSlots.$! + let isolatedSources: DynamicSlotSource[] | undefined + for (let i = 0; i < dynamicSources.length; i++) { + const source = dynamicSources[i] + if (isFunction(source)) { + if (!isolatedSources) isolatedSources = dynamicSources.slice() + const committed = shallowRef() as ShallowRef> + cells[i] = { source, committed } + isolatedSources[i] = isolateSlotSource(committed) + } + } + if (!isolatedSources) return rawSlots + + const isolated = extend({}, rawSlots, { $: isolatedSources }) as RawSlots + rawSlotsOwnerMap.set(isolated, rawSlotsOwnerMap.get(rawSlots) || null) + return isolated +} + +function isolateSlotSource( + committed: ShallowRef>, +): DynamicSlotFn { + const isolated: FunctionSource> = () => + committed.value + // readers resolve through the cell instead of a computed of their own + isolated._cache = committed + return isolated +} + +export function initSlots(instance: VaporComponentInstance): void { + const rawSlots = instance.rawSlots + const dynamicSources = rawSlots.$ + if (!dynamicSources) return + const cells: SlotSourceCell[] = new Array(dynamicSources.length) + const isolated = isolateSlotSources(rawSlots, cells) + if (isolated !== rawSlots) { + instance.slotSources = cells + instance.rawSlots = isolated + instance.slots = new Proxy(isolated, dynamicSlotsProxyHandlers) + } +} + /** * Freeze the slot set of a v-once component: dynamic sources resolve once, * in `resolveSlot` precedence, into plain entries. The slot functions stay diff --git a/packages/runtime-vapor/src/components/KeepAlive.ts b/packages/runtime-vapor/src/components/KeepAlive.ts index fefb11ee67f..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 { 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,7 +354,6 @@ const VaporKeepAliveImpl = defineVaporComponent({ }) const keepAliveCtx: KeepAliveInstance['ctx'] = { - isolateSlotSources, getStorageContainer: () => storageContainer, getCachedComponent: (comp, key) => { if (isInteropEnabled && isVNode(comp)) { @@ -670,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 fa1addc3ed7..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 } from '../componentProps' +import { trackRawValues } from '../componentProps' import { createElement } from '../dom/node' import { DynamicFragment, @@ -548,8 +548,8 @@ 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 @@ -562,21 +562,13 @@ function trackTransitionGroupUpdate( const prevGroup = setCurrentInstance(instance, owner.scope) try { renderEffect(() => { - owner.rawValues.value + 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/keepAlive.ts b/packages/runtime-vapor/src/keepAlive.ts index 09c91ea3162..1746dfa151d 100644 --- a/packages/runtime-vapor/src/keepAlive.ts +++ b/packages/runtime-vapor/src/keepAlive.ts @@ -6,10 +6,8 @@ import { import type { EffectScope } from '@vue/reactivity' import type { Block } from './block' import type { DynamicFragment } from './fragment' -import type { RawSlots } from './componentSlots' export interface VaporKeepAliveContext { - 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 5eab57eece5..986882180b7 100644 --- a/packages/runtime-vapor/src/vdomInterop.ts +++ b/packages/runtime-vapor/src/vdomInterop.ts @@ -123,12 +123,18 @@ import { import { type RawProps, collectProps, + collectSlotSources, + commitSlotSources, rawPropsProxyHandlers, snapshotRawProps, updateProps, } 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, renderEffect } from './renderEffect' import { createTextNode, parentNode } from './dom/node' @@ -1453,39 +1459,50 @@ function createVDOMComponent( const useBridge = shouldUseRendererBridge(component) const comp = useBridge ? ensureRendererBridge(component) : component let propsInstance: VaporComponentInstance | undefined - let currentRaw: Record = EMPTY_OBJ + let rawValues: Record = EMPTY_OBJ let isMounted = false if (!once) inputScope ||= effectScope(true) - const previous = setCurrentInstance(parentComponent, inputScope) + const prevInstance = setCurrentInstance(parentComponent, inputScope) let vnode: VNode try { if (once) { - const subscriber = setActiveSub() + const prevSub = setActiveSub() try { - currentRaw = collectProps((rawProps || EMPTY_OBJ) as RawProps) + rawValues = collectProps((rawProps || EMPTY_OBJ) as RawProps) } finally { - setActiveSub(subscriber) + setActiveSub(prevSub) } } else { - const input = new RenderEffect(() => { - const context = setCurrentInstance(parentComponent, inputScope) + const dynamicSlotSources = rawSlots && (rawSlots as RawSlots).$ + let cells: SlotSourceCell[] | undefined + if (dynamicSlotSources) { + cells = new Array(dynamicSlotSources.length) + rawSlots = isolateSlotSources(rawSlots as RawSlots, cells) + } + const effect = new RenderEffect(() => { + const prevInner = setCurrentInstance(parentComponent, inputScope) try { - currentRaw = collectProps((rawProps || EMPTY_OBJ) as RawProps) + rawValues = collectProps( + (rawProps || EMPTY_OBJ) as RawProps, + rawValues.style, + ) + if (cells) collectSlotSources(cells) } finally { - restoreCurrentInstance(context) + restoreCurrentInstance(prevInner) } - if (propsInstance && input.active) { - updateProps(propsInstance, currentRaw) + if (effect.active) { + if (propsInstance) updateProps(propsInstance, rawValues) + if (cells) commitSlotSources(cells) } }, true) - input.run() + effect.run() } - vnode = createVNode(comp, currentRaw) + vnode = createVNode(comp, rawValues) } catch (error) { if (inputScope) inputScope.stop() throw error } finally { - restoreCurrentInstance(previous) + restoreCurrentInstance(prevInstance) } const { frag, syncNodes } = createVNodeFragment(vnode) frag.inputScope = inputScope @@ -1559,7 +1576,7 @@ function createVDOMComponent( }) }) try { - updateProps(wrapper, currentRaw) + updateProps(wrapper, rawValues) } catch (error) { if (inputScope) inputScope.stop() wrapper.scope.stop() From 7a6ed0d396762aa4f3601df6f808dd6aee3a9ffb Mon Sep 17 00:00:00 2001 From: edison Date: Tue, 29 Sep 2026 20:01:06 -0700 Subject: [PATCH 03/11] fix(runtime-vapor): commit props and dynamic slots to a vdom child in one batch --- .../__tests__/componentSlots.spec.ts | 81 ++++++++++--------- packages/runtime-vapor/src/componentProps.ts | 40 +++++---- packages/runtime-vapor/src/vdomInterop.ts | 5 +- 3 files changed, 73 insertions(+), 53 deletions(-) diff --git a/packages/runtime-vapor/__tests__/componentSlots.spec.ts b/packages/runtime-vapor/__tests__/componentSlots.spec.ts index e99da76108f..1135fae96ba 100644 --- a/packages/runtime-vapor/__tests__/componentSlots.spec.ts +++ b/packages/runtime-vapor/__tests__/componentSlots.spec.ts @@ -8106,42 +8106,51 @@ describe('component: slots', () => { // props and descriptors reach a sync watcher together. vdom delivers the // props first, so the watcher throws there - test('a delivery is atomic across props and descriptors', async () => { - const data = ref({ x: { 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.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.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() + }, + ) // props are evaluated before the descriptors, as in vdom: one read, and // none after the creation was aborted diff --git a/packages/runtime-vapor/src/componentProps.ts b/packages/runtime-vapor/src/componentProps.ts index b71cccde272..767b5f59f41 100644 --- a/packages/runtime-vapor/src/componentProps.ts +++ b/packages/runtime-vapor/src/componentProps.ts @@ -601,20 +601,12 @@ export function initProps( if (effect.active) { // a delivery that throws half-way is redone in full hasDelivered = false - // sync watchers run once the props and the descriptors are all - // written, and outside this effect: their callbacks must not - // subscribe it - const prevSub = setActiveSub() - startBatch() - try { - if (rawValues || propsToUpdate) { - updateProps(instance, rawValues || prevRawValues, propsToUpdate) - } - if (cells) commitSlotSources(cells) - } finally { - endBatch() - setActiveSub(prevSub) - } + deliverInputs( + instance, + rawValues || (propsToUpdate && prevRawValues), + propsToUpdate, + cells, + ) hasDelivered = true } }, true) @@ -635,6 +627,26 @@ export function initProps( } } +// 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, + propsToUpdate: unknown[] | undefined, + cells: SlotSourceCell[] | undefined, +): void { + const prevSub = setActiveSub() + startBatch() + try { + if (instance && rawValues) updateProps(instance, rawValues, propsToUpdate) + if (cells) commitSlotSources(cells) + } finally { + endBatch() + setActiveSub(prevSub) + } +} + export function collectSlotSources(cells: SlotSourceCell[]): void { for (let i = 0; i < cells.length; i++) { const cell = cells[i] diff --git a/packages/runtime-vapor/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts index 986882180b7..b4f6899f8c1 100644 --- a/packages/runtime-vapor/src/vdomInterop.ts +++ b/packages/runtime-vapor/src/vdomInterop.ts @@ -124,7 +124,7 @@ import { type RawProps, collectProps, collectSlotSources, - commitSlotSources, + deliverInputs, rawPropsProxyHandlers, snapshotRawProps, updateProps, @@ -1491,8 +1491,7 @@ function createVDOMComponent( restoreCurrentInstance(prevInner) } if (effect.active) { - if (propsInstance) updateProps(propsInstance, rawValues) - if (cells) commitSlotSources(cells) + deliverInputs(propsInstance, rawValues, undefined, cells) } }, true) effect.run() From 9012ef878a9116251c882ed18cbf2f37300d2884 Mon Sep 17 00:00:00 2001 From: edison Date: Tue, 29 Sep 2026 20:29:57 -0700 Subject: [PATCH 04/11] perf(runtime-vapor): key prop and slot deps on the instance instead of targetMap --- packages/reactivity/src/dep.ts | 32 +++++++- packages/reactivity/src/index.ts | 4 + packages/runtime-vapor/src/component.ts | 9 +++ packages/runtime-vapor/src/componentProps.ts | 83 +++++++++++--------- packages/runtime-vapor/src/componentSlots.ts | 37 +++++---- 5 files changed, 106 insertions(+), 59 deletions(-) diff --git a/packages/reactivity/src/dep.ts b/packages/reactivity/src/dep.ts index a24000329c1..83ce69c0164 100644 --- a/packages/reactivity/src/dep.ts +++ b/packages/reactivity/src/dep.ts @@ -13,14 +13,15 @@ import { 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 +30,35 @@ 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: Dep): void { + if (activeSub !== undefined) { + link(dep, activeSub) + } +} + +/** + * @internal + */ +export function triggerDep(dep: Dep): void { + const subs = dep.subs + if (subs !== undefined) { + startBatch() + propagate(subs) + shallowPropagate(subs) + endBatch() + } +} + // 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 e2b4fd5dfd4..78893753a9d 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, diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 85cfd66d468..a70466fba53 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, @@ -889,6 +890,13 @@ export class VaporComponentInstance< propsValues: Record rawValues: Record propsEffect?: RenderEffect + /** + * @internal + */ + propsDeps: Record | undefined + /** + * @internal + */ slotSources?: SlotSourceCell[] // false when the inputs can never change: reading them subscribes to nothing hasDynamicProps: boolean @@ -1070,6 +1078,7 @@ export class VaporComponentInstance< // handlers into pure Vapor bundles. this.propsValues = Object.create(null) this.rawValues = INITIAL_RAW_VALUES + this.propsDeps = undefined this.hasDynamicProps = true // Snapshot raw parent inputs before creating proxies so delayed reads from // v-once children cannot observe later parent updates. diff --git a/packages/runtime-vapor/src/componentProps.ts b/packages/runtime-vapor/src/componentProps.ts index 767b5f59f41..ba584f39c1b 100644 --- a/packages/runtime-vapor/src/componentProps.ts +++ b/packages/runtime-vapor/src/componentProps.ts @@ -32,19 +32,17 @@ import { warn, } from '@vue/runtime-dom' import { + Dep, EffectFlags, - ITERATE_KEY, ReactiveFlags, - TrackOpTypes, - TriggerOpTypes, computed, endBatch, getCurrentScope, onScopeDispose, setActiveSub, startBatch, - track, - trigger, + trackDep, + triggerDep, } from '@vue/reactivity' import { normalizeEmitsOptions } from './componentEmits' import { RenderEffect } from './renderEffect' @@ -235,7 +233,7 @@ export function getPropsProxyHandlers( if (key === ReactiveFlags.IS_REACTIVE || key === ReactiveFlags.IS_SHALLOW) return true if (isProp(key)) { - trackPropsValue(instance, TrackOpTypes.GET, key) + trackPropsValue(instance, key) return instance.propsValues[key] } } @@ -266,20 +264,20 @@ export function getPropsProxyHandlers( const getAttr = (instance: VaporComponentInstance, key: string | symbol) => { if (isAttr(key)) { - trackPropsValue(instance, TrackOpTypes.GET, key) + trackPropsValue(instance, key) return instance.propsValues[key] } } const hasAttr = (instance: VaporComponentInstance, key: string | symbol) => { if (!isAttr(key)) return false - trackPropsValue(instance, TrackOpTypes.HAS, key) + trackPropsValue(instance, key) return hasOwn(instance.propsValues, key) } const attrsHandlers = { get: getAttr, has: hasAttr, ownKeys(target) { - trackPropsValue(target, TrackOpTypes.ITERATE, ITERATE_KEY) + trackPropsValue(target, ATTR_KEYS) return Object.keys(target.propsValues).filter(isAttr) }, getOwnPropertyDescriptor(target, key: string | symbol) { @@ -518,16 +516,31 @@ 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`. A plain +// object stays in fast mode; only `__proto__` could not be a key of it. function trackPropsValue( instance: VaporComponentInstance, - type: TrackOpTypes, - key: unknown, + key: string | symbol, ): void { - if (instance.hasDynamicProps) track(instance.propsValues, type, key) + if (!instance.hasDynamicProps || key === '__proto__') return + const deps = instance.propsDeps || (instance.propsDeps = {}) + trackDep(deps[key] || (deps[key] = new Dep())) +} + +function triggerPropsValue( + instance: VaporComponentInstance, + key: string | symbol, +): void { + const deps = instance.propsDeps + if (deps) { + const dep = deps[key] + if (dep) triggerDep(dep) + } } // Subscribes to every delivered input. `rawValues` itself is read untracked: @@ -535,7 +548,7 @@ function trackPropsValue( export function trackRawValues( instance: VaporComponentInstance, ): Record { - trackPropsValue(instance, TrackOpTypes.GET, RAW_VALUES_KEY) + trackPropsValue(instance, RAW_VALUES_KEY) return instance.rawValues } @@ -656,19 +669,15 @@ export function collectSlotSources(cells: SlotSourceCell[]): void { // an unchanged descriptor keeps the identity its readers already saw export function commitSlotSources(cells: SlotSourceCell[]): void { - const prevSub = setActiveSub() - try { - for (let i = 0; i < cells.length; i++) { - const cell = cells[i] - if (cell) { - cell.committed.value = stabilizeDynamicSourceValue( - cell.committed.value, - cell.next!, - ) + for (let i = 0; i < cells.length; i++) { + const cell = cells[i] + if (cell) { + const next = stabilizeDynamicSourceValue(cell.committed, cell.next!) + if (hasChanged(next, cell.committed)) { + cell.committed = next + triggerDep(cell) } } - } finally { - setActiveSub(prevSub) } } @@ -782,7 +791,7 @@ export function updateProps( // Raw listeners become current before normalized keys notify watchers. instance.rawValues = rawValues if (propsToUpdate || (!isInitial && rawValues !== prevRawValues)) { - trigger(propsValues, TriggerOpTypes.SET, RAW_VALUES_KEY) + triggerPropsValue(instance, RAW_VALUES_KEY) } const vnode = isInteropEnabled && instance.interopVNode if (vnode && vnode.vi) vnode.props = rawValues @@ -809,7 +818,7 @@ export function updateProps( continue } if (present) present[key] = true - setPropValue(propsValues, key, value, isInitial) + setPropValue(instance, key, value, isInitial) } // after the ordinary props: a default factory may read them if (needCastKeys && (rawCastValues || !propsToUpdate)) { @@ -818,7 +827,7 @@ export function updateProps( const isAbsent = !rawCastValues || !hasOwn(rawCastValues, key) if (isAbsent && propsToUpdate) continue setPropValue( - propsValues, + instance, key, resolvePropValue( options!, @@ -837,7 +846,7 @@ export function updateProps( for (const key in options) { if (present ? !present[key] : !hasOwn(propsValues, key)) { setPropValue( - propsValues, + instance, key, resolvePropValue( options, @@ -855,15 +864,9 @@ export function updateProps( if (present) { for (const key in propsValues) { if (!present[key]) { - const oldValue = propsValues[key] delete propsValues[key] - trigger( - propsValues, - TriggerOpTypes.DELETE, - key, - undefined, - oldValue, - ) + triggerPropsValue(instance, key) + triggerPropsValue(instance, ATTR_KEYS) } } } @@ -883,11 +886,12 @@ export function updateProps( } function setPropValue( - propsValues: Record, + instance: VaporComponentInstance, key: string, value: unknown, isInitial: boolean, ) { + const propsValues = instance.propsValues if (isInitial) { propsValues[key] = value return @@ -896,8 +900,9 @@ function setPropValue( const oldValue = propsValues[key] propsValues[key] = value if (!hadKey) { - trigger(propsValues, TriggerOpTypes.ADD, key, value) + triggerPropsValue(instance, key) + triggerPropsValue(instance, ATTR_KEYS) } else if (hasChanged(value, oldValue)) { - trigger(propsValues, TriggerOpTypes.SET, key, value, oldValue) + triggerPropsValue(instance, key) } } diff --git a/packages/runtime-vapor/src/componentSlots.ts b/packages/runtime-vapor/src/componentSlots.ts index cbc70faf0af..ca30cb06880 100644 --- a/packages/runtime-vapor/src/componentSlots.ts +++ b/packages/runtime-vapor/src/componentSlots.ts @@ -27,7 +27,7 @@ import { currentInstance, isAsyncWrapper, } from '@vue/runtime-dom' -import { type ShallowRef, shallowRef } from '@vue/reactivity' +import { Dep, trackDep } from '@vue/reactivity' import type { LooseRawProps, VaporComponentInstance } from './component' import { renderEffect } from './renderEffect' import { @@ -110,11 +110,19 @@ export function normalizeRawSlots( } // A dynamic slot descriptor's committed value: the input effect writes it in -// the parent's update order, the isolated source reads it. -export interface SlotSourceCell { - source: DynamicSlotFn - committed: ShallowRef> - next?: ReturnType +// 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 + } } /** @@ -133,9 +141,8 @@ export function isolateSlotSources( const source = dynamicSources[i] if (isFunction(source)) { if (!isolatedSources) isolatedSources = dynamicSources.slice() - const committed = shallowRef() as ShallowRef> - cells[i] = { source, committed } - isolatedSources[i] = isolateSlotSource(committed) + const cell = (cells[i] = new SlotSourceCell(source)) + isolatedSources[i] = isolateSlotSource(cell) } } if (!isolatedSources) return rawSlots @@ -145,14 +152,12 @@ export function isolateSlotSources( return isolated } -function isolateSlotSource( - committed: ShallowRef>, -): DynamicSlotFn { - const isolated: FunctionSource> = () => - committed.value +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 = committed - return isolated + isolated._cache = cell + return isolated as DynamicSlotFn } export function initSlots(instance: VaporComponentInstance): void { From cbe799adc3e184982893258dbcce34ec564f5412 Mon Sep 17 00:00:00 2001 From: edison Date: Tue, 29 Sep 2026 20:56:09 -0700 Subject: [PATCH 05/11] refactor(runtime-vapor): publish inputs through one shell and key deps on a null-prototype table --- packages/reactivity/src/index.ts | 2 +- .../__tests__/componentProps.spec.ts | 28 +++ packages/runtime-vapor/src/componentProps.ts | 208 +++++++++--------- packages/runtime-vapor/src/componentSlots.ts | 36 +-- packages/runtime-vapor/src/vdomInterop.ts | 24 +- 5 files changed, 167 insertions(+), 131 deletions(-) diff --git a/packages/reactivity/src/index.ts b/packages/reactivity/src/index.ts index 78893753a9d..9a7267f62af 100644 --- a/packages/reactivity/src/index.ts +++ b/packages/reactivity/src/index.ts @@ -109,4 +109,4 @@ export { /** * @internal */ -export { endBatch, setActiveSub, startBatch } from './system' +export { activeSub, endBatch, setActiveSub, startBatch } from './system' diff --git a/packages/runtime-vapor/__tests__/componentProps.spec.ts b/packages/runtime-vapor/__tests__/componentProps.spec.ts index 949d5a8df28..bcaa375b203 100644 --- a/packages/runtime-vapor/__tests__/componentProps.spec.ts +++ b/packages/runtime-vapor/__tests__/componentProps.spec.ts @@ -2453,6 +2453,34 @@ describe('component: props', () => { 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' } }) diff --git a/packages/runtime-vapor/src/componentProps.ts b/packages/runtime-vapor/src/componentProps.ts index ba584f39c1b..2b623328854 100644 --- a/packages/runtime-vapor/src/componentProps.ts +++ b/packages/runtime-vapor/src/componentProps.ts @@ -35,6 +35,7 @@ import { Dep, EffectFlags, ReactiveFlags, + activeSub, computed, endBatch, getCurrentScope, @@ -521,14 +522,14 @@ 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`. A plain -// object stays in fast mode; only `__proto__` could not be a key of it. +// 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 || key === '__proto__') return - const deps = instance.propsDeps || (instance.propsDeps = {}) + if (!instance.hasDynamicProps || activeSub === undefined) return + const deps = instance.propsDeps || (instance.propsDeps = Object.create(null)) trackDep(deps[key] || (deps[key] = new Dep())) } @@ -575,11 +576,13 @@ export function initProps( if (once || (!cells && (isInterop || (!canRebind && !hasGetters)))) { instance.hasDynamicProps = !once && isInterop const prevSub = setActiveSub() + let rawValues: Record try { - updateProps(instance, collectProps(rawProps)) + rawValues = collectProps(rawProps) } finally { setActiveSub(prevSub) } + deliverInputs(instance, rawValues) return } instance.hasDynamicProps = canRebind || hasGetters @@ -646,8 +649,8 @@ export function initProps( export function deliverInputs( instance: VaporComponentInstance | undefined, rawValues: Record | undefined, - propsToUpdate: unknown[] | undefined, - cells: SlotSourceCell[] | undefined, + propsToUpdate?: unknown[], + cells?: SlotSourceCell[], ): void { const prevSub = setActiveSub() startBatch() @@ -655,28 +658,29 @@ export function deliverInputs( if (instance && rawValues) updateProps(instance, rawValues, propsToUpdate) if (cells) commitSlotSources(cells) } finally { - endBatch() - setActiveSub(prevSub) + try { + endBatch() + } finally { + setActiveSub(prevSub) + } } } export function collectSlotSources(cells: SlotSourceCell[]): void { for (let i = 0; i < cells.length; i++) { const cell = cells[i] - if (cell) cell.next = cell.source() + cell.next = cell.source() } } // an unchanged descriptor keeps the identity its readers already saw -export function commitSlotSources(cells: SlotSourceCell[]): void { +function commitSlotSources(cells: SlotSourceCell[]): void { for (let i = 0; i < cells.length; i++) { const cell = cells[i] - if (cell) { - const next = stabilizeDynamicSourceValue(cell.committed, cell.next!) - if (hasChanged(next, cell.committed)) { - cell.committed = next - triggerDep(cell) - } + const next = stabilizeDynamicSourceValue(cell.committed, cell.next!) + if (hasChanged(next, cell.committed)) { + cell.committed = next + triggerDep(cell) } } } @@ -765,7 +769,9 @@ function collectPropsToUpdate( return propsToUpdate } -export function updateProps( +// 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 the delivered `rawValues`, as [key, value, …] @@ -774,114 +780,106 @@ export function updateProps( const propsValues = instance.propsValues const [options, needCastKeys] = normalizePropsOptions(instance.type) const emitsOptions = normalizeEmitsOptions(instance.type) - const prevSub = setActiveSub() - // sync watchers run once every key is written - startBatch() - try { - 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 + 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 } - } else if (isEmitListener(emitsOptions, key)) { + 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 - 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 + } + } + if (!propsToUpdate) { + for (const key in options) { + if (present ? !present[key] : !hasOwn(propsValues, key)) { setPropValue( instance, key, resolvePropValue( - options!, + options, key, - isAbsent ? undefined : rawCastValues![key], + undefined, instance, resolveDefault, - isAbsent, + true, ), 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]) { - delete propsValues[key] - triggerPropsValue(instance, key) - triggerPropsValue(instance, ATTR_KEYS) - } + if (present) { + for (const key in propsValues) { + if (!present[key]) { + delete propsValues[key] + triggerPropsValue(instance, key) + triggerPropsValue(instance, ATTR_KEYS) } } } - if (__DEV__ && options) { - pushWarningContext(instance) - try { - validateProps(rawValues, instance.props, options) - } finally { - popWarningContext() - } + } + if (__DEV__ && options) { + pushWarningContext(instance) + try { + validateProps(rawValues, instance.props, options) + } finally { + popWarningContext() } - } finally { - endBatch() - setActiveSub(prevSub) } } diff --git a/packages/runtime-vapor/src/componentSlots.ts b/packages/runtime-vapor/src/componentSlots.ts index ca30cb06880..157cb22020d 100644 --- a/packages/runtime-vapor/src/componentSlots.ts +++ b/packages/runtime-vapor/src/componentSlots.ts @@ -128,28 +128,30 @@ export class SlotSourceCell extends Dep { /** * 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. `cells` is parallel to the sources; a static source leaves - * a hole. Slot functions keep their closure semantics. + * 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 isolateSlotSources( rawSlots: RawSlots, - cells: SlotSourceCell[], -): RawSlots { +): [RawSlots, SlotSourceCell[]] | undefined { const dynamicSources = rawSlots.$! - let isolatedSources: DynamicSlotSource[] | undefined + let count = 0 for (let i = 0; i < dynamicSources.length; i++) { + if (isFunction(dynamicSources[i])) count++ + } + 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)) { - if (!isolatedSources) isolatedSources = dynamicSources.slice() - const cell = (cells[i] = new SlotSourceCell(source)) + const cell = (cells[n++] = new SlotSourceCell(source)) isolatedSources[i] = isolateSlotSource(cell) } } - if (!isolatedSources) return rawSlots - const isolated = extend({}, rawSlots, { $: isolatedSources }) as RawSlots rawSlotsOwnerMap.set(isolated, rawSlotsOwnerMap.get(rawSlots) || null) - return isolated + return [isolated, cells] } function isolateSlotSource(cell: SlotSourceCell): DynamicSlotFn { @@ -162,14 +164,12 @@ function isolateSlotSource(cell: SlotSourceCell): DynamicSlotFn { export function initSlots(instance: VaporComponentInstance): void { const rawSlots = instance.rawSlots - const dynamicSources = rawSlots.$ - if (!dynamicSources) return - const cells: SlotSourceCell[] = new Array(dynamicSources.length) - const isolated = isolateSlotSources(rawSlots, cells) - if (isolated !== rawSlots) { - instance.slotSources = cells - instance.rawSlots = isolated - instance.slots = new Proxy(isolated, dynamicSlotsProxyHandlers) + if (!rawSlots.$) return + const isolated = isolateSlotSources(rawSlots) + if (isolated) { + instance.rawSlots = isolated[0] + instance.slotSources = isolated[1] + instance.slots = new Proxy(isolated[0], dynamicSlotsProxyHandlers) } } diff --git a/packages/runtime-vapor/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts index b4f6899f8c1..191d2064060 100644 --- a/packages/runtime-vapor/src/vdomInterop.ts +++ b/packages/runtime-vapor/src/vdomInterop.ts @@ -127,7 +127,6 @@ import { deliverInputs, rawPropsProxyHandlers, snapshotRawProps, - updateProps, } from './componentProps' import type { RawSlots, SlotSourceCell, VaporSlot } from './componentSlots' import { @@ -1461,6 +1460,9 @@ function createVDOMComponent( let propsInstance: VaporComponentInstance | undefined let rawValues: Record = EMPTY_OBJ let isMounted = false + // a cache hit can rebind a kept-alive component to other inputs + const canRebind = !!inputScope + let isStatic = once if (!once) inputScope ||= effectScope(true) const prevInstance = setCurrentInstance(parentComponent, inputScope) let vnode: VNode @@ -1473,11 +1475,13 @@ function createVDOMComponent( setActiveSub(prevSub) } } else { - const dynamicSlotSources = rawSlots && (rawSlots as RawSlots).$ let cells: SlotSourceCell[] | undefined - if (dynamicSlotSources) { - cells = new Array(dynamicSlotSources.length) - rawSlots = isolateSlotSources(rawSlots as RawSlots, cells) + if (rawSlots && (rawSlots as RawSlots).$) { + const isolated = isolateSlotSources(rawSlots as RawSlots) + if (isolated) { + rawSlots = isolated[0] + cells = isolated[1] + } } const effect = new RenderEffect(() => { const prevInner = setCurrentInstance(parentComponent, inputScope) @@ -1495,6 +1499,11 @@ function createVDOMComponent( } }, true) effect.run() + // every getter and descriptor turned out to be constant + if (!effect.deps && !canRebind) { + effect.stop() + isStatic = true + } } vnode = createVNode(comp, rawValues) } catch (error) { @@ -1567,6 +1576,7 @@ function createVDOMComponent( )) simpleSetCurrentInstance(prev) wrapper.interopVNode = vnode + wrapper.hasDynamicProps = !isStatic // The detached input scope follows the rendered component's lifetime. instance.scope.run(() => { onScopeDispose(() => { @@ -1575,7 +1585,7 @@ function createVDOMComponent( }) }) try { - updateProps(wrapper, rawValues) + deliverInputs(wrapper, rawValues) } catch (error) { if (inputScope) inputScope.stop() wrapper.scope.stop() @@ -3721,7 +3731,7 @@ function updateInteropVNode( ): void { state.pendingVNodeUpdate = vnode instance.rawPropsRef!.value = filterReservedProps(vnode.props) - updateProps(instance, vnode.props || EMPTY_OBJ) + deliverInputs(instance, vnode.props || EMPTY_OBJ) instance.rawSlotsRef!.value = normalizeInteropSlots(vnode.children) // align with VDOM: vnode beforeUpdate runs before directive beforeUpdate. invokeInteropVNodeBeforeUpdate(instance, vnode, prevVNode) From 9668e81d31a320bdc84871eb45c7e79caa273778 Mon Sep 17 00:00:00 2001 From: edison Date: Tue, 29 Sep 2026 21:23:02 -0700 Subject: [PATCH 06/11] refactor(runtime-vapor): deliver v-once inputs once instead of snapshotting them --- packages/runtime-vapor/src/component.ts | 14 ++------- packages/runtime-vapor/src/componentProps.ts | 3 +- packages/runtime-vapor/src/componentSlots.ts | 31 -------------------- packages/runtime-vapor/src/vdomInterop.ts | 19 ++++++------ 4 files changed, 14 insertions(+), 53 deletions(-) diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index a70466fba53..4b9d037a1c5 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -95,7 +95,6 @@ import { normalizePropsOptions, resolveDynamicProps, resolveSource, - snapshotRawProps, } from './componentProps' import { type RenderEffect, renderEffect } from './renderEffect' import { emit, normalizeEmitsOptions } from './componentEmits' @@ -109,7 +108,6 @@ import { getSlot, initSlots, normalizeRawSlots, - snapshotRawSlots, } from './componentSlots' import { currentRenderContext, @@ -507,7 +505,6 @@ export function createComponent( rawProps as RawProps, rawSlots, appContext, - once, ce, ) if (key !== undefined) instance.$key = key @@ -1030,7 +1027,6 @@ export class VaporComponentInstance< rawProps?: RawProps | null, rawSlots?: LooseRawSlots | null, appContext?: GenericAppContext, - once?: boolean, ce?: (instance: VaporComponentInstance) => void, ) { this.vapor = true @@ -1080,10 +1076,7 @@ export class VaporComponentInstance< this.rawValues = INITIAL_RAW_VALUES this.propsDeps = undefined this.hasDynamicProps = true - // 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 + 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) @@ -1098,10 +1091,7 @@ export class VaporComponentInstance< ) 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 diff --git a/packages/runtime-vapor/src/componentProps.ts b/packages/runtime-vapor/src/componentProps.ts index 2b623328854..403800c2b98 100644 --- a/packages/runtime-vapor/src/componentProps.ts +++ b/packages/runtime-vapor/src/componentProps.ts @@ -579,10 +579,11 @@ export function initProps( let rawValues: Record try { rawValues = collectProps(rawProps) + if (cells) collectSlotSources(cells) } finally { setActiveSub(prevSub) } - deliverInputs(instance, rawValues) + deliverInputs(instance, rawValues, undefined, cells) return } instance.hasDynamicProps = canRebind || hasGetters diff --git a/packages/runtime-vapor/src/componentSlots.ts b/packages/runtime-vapor/src/componentSlots.ts index 157cb22020d..0c4be821b9e 100644 --- a/packages/runtime-vapor/src/componentSlots.ts +++ b/packages/runtime-vapor/src/componentSlots.ts @@ -173,37 +173,6 @@ export function initSlots(instance: VaporComponentInstance): void { } } -/** - * 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. - */ -export function snapshotRawSlots(rawSlots: RawSlots): RawSlots { - const dynamicSources = rawSlots.$ - if (!dynamicSources) return rawSlots - const snapshot: RawSlots = {} - for (const key in rawSlots) { - if (key !== '$') snapshot[key] = rawSlots[key] - } - for (const source of dynamicSources) { - 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] - } - } - for (const symbol of Object.getOwnPropertySymbols(rawSlots)) { - ;(snapshot as any)[symbol] = (rawSlots as any)[symbol] - } - rawSlotsOwnerMap.set(snapshot, rawSlotsOwnerMap.get(rawSlots) || null) - return snapshot -} - function withSlotOwner(slots: RawSlots, fn: () => T): T { const owner = rawSlotsOwnerMap.get(slots) if (owner === undefined) { diff --git a/packages/runtime-vapor/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts index 191d2064060..3ba71e0fd7e 100644 --- a/packages/runtime-vapor/src/vdomInterop.ts +++ b/packages/runtime-vapor/src/vdomInterop.ts @@ -1467,22 +1467,24 @@ function createVDOMComponent( const prevInstance = setCurrentInstance(parentComponent, inputScope) let vnode: VNode try { + let cells: SlotSourceCell[] | undefined + if (rawSlots && (rawSlots as RawSlots).$) { + const isolated = isolateSlotSources(rawSlots as RawSlots) + if (isolated) { + rawSlots = isolated[0] + cells = isolated[1] + } + } if (once) { const prevSub = setActiveSub() try { rawValues = collectProps((rawProps || EMPTY_OBJ) as RawProps) + if (cells) collectSlotSources(cells) } finally { setActiveSub(prevSub) } + if (cells) deliverInputs(undefined, undefined, undefined, cells) } else { - let cells: SlotSourceCell[] | undefined - if (rawSlots && (rawSlots as RawSlots).$) { - const isolated = isolateSlotSources(rawSlots as RawSlots) - if (isolated) { - rawSlots = isolated[0] - cells = isolated[1] - } - } const effect = new RenderEffect(() => { const prevInner = setCurrentInstance(parentComponent, inputScope) try { @@ -1572,7 +1574,6 @@ function createVDOMComponent( undefined, rawSlots as RawSlots, parentComponent ? parentComponent.appContext : undefined, - once, )) simpleSetCurrentInstance(prev) wrapper.interopVNode = vnode From 4e9036efbce522166de4c1e8fdabbd1e380be317 Mon Sep 17 00:00:00 2001 From: edison Date: Tue, 29 Sep 2026 21:48:33 -0700 Subject: [PATCH 07/11] refactor(runtime-vapor): share the input tracking primitives with refs and key deps in a map --- packages/reactivity/src/dep.ts | 38 +++++++++-- packages/reactivity/src/ref.ts | 29 ++------- .../__tests__/componentProps.spec.ts | 4 +- packages/runtime-vapor/src/component.ts | 2 +- packages/runtime-vapor/src/componentProps.ts | 63 +++++++++++-------- packages/runtime-vapor/src/componentSlots.ts | 7 +-- packages/runtime-vapor/src/vdomInterop.ts | 48 +++++++------- 7 files changed, 103 insertions(+), 88 deletions(-) diff --git a/packages/reactivity/src/dep.ts b/packages/reactivity/src/dep.ts index 83ce69c0164..40d895a3a63 100644 --- a/packages/reactivity/src/dep.ts +++ b/packages/reactivity/src/dep.ts @@ -1,12 +1,14 @@ 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, @@ -40,8 +42,15 @@ export class Dep implements ReactiveNode { * Tracks a dep its owner keys on its own, outside `targetMap`. * @internal */ -export function trackDep(dep: Dep): void { +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) } } @@ -49,13 +58,32 @@ export function trackDep(dep: Dep): void { /** * @internal */ -export function triggerDep(dep: Dep): void { +export function triggerDep( + dep: ReactiveNode, + target: object = dep, + key: unknown = 'value', + newValue?: unknown, + oldValue?: unknown, +): void { const subs = dep.subs if (subs !== undefined) { - startBatch() + if (__DEV__) { + triggerEventInfos.push({ + target, + type: TriggerOpTypes.SET, + key, + newValue, + oldValue, + }) + } propagate(subs) shallowPropagate(subs) - endBatch() + if (!batchDepth) { + flush() + } + if (__DEV__) { + triggerEventInfos.pop() + } } } diff --git a/packages/reactivity/src/ref.ts b/packages/reactivity/src/ref.ts index 598482c88bf..e894402a50c 100644 --- a/packages/reactivity/src/ref.ts +++ b/packages/reactivity/src/ref.ts @@ -8,9 +8,9 @@ import { isSymbol, } from '@vue/shared' import type { ComputedRef, WritableComputedRef } from './computed' -import { ReactiveFlags, TrackOpTypes, TriggerOpTypes } from './constants' -import { onTrack, triggerEventInfos } from './debug' -import { getDepFromReactive } from './dep' +import { ReactiveFlags, TriggerOpTypes } from './constants' +import { triggerEventInfos } from './debug' +import { getDepFromReactive, trackDep, triggerDep } from './dep' import { type Builtin, type ShallowReactiveBrand, @@ -26,10 +26,8 @@ import { type Link, type ReactiveNode, ReactiveFlags as _ReactiveFlags, - activeSub, batchDepth, flush, - link, propagate, shallowPropagate, } from './system' @@ -233,27 +231,10 @@ class RefImpl implements ReactiveNode { export function triggerRef(ref: Ref): void { // ref may be an instance of ObjectRefImpl const dep = (ref as unknown as RefImpl).dep - if (dep !== undefined && dep.subs !== undefined) { - propagate(dep.subs) - shallowPropagate(dep.subs) - if (!batchDepth) { - flush() - } - } + if (dep !== undefined) triggerDep(dep) } -function trackRef(dep: ReactiveNode) { - if (activeSub !== undefined) { - if (__DEV__) { - onTrack(activeSub!, { - target: dep, - type: TrackOpTypes.GET, - key: 'value', - }) - } - link(dep, activeSub!) - } -} +const trackRef = trackDep export type MaybeRef = | T diff --git a/packages/runtime-vapor/__tests__/componentProps.spec.ts b/packages/runtime-vapor/__tests__/componentProps.spec.ts index bcaa375b203..7265320acce 100644 --- a/packages/runtime-vapor/__tests__/componentProps.spec.ts +++ b/packages/runtime-vapor/__tests__/componentProps.spec.ts @@ -2460,11 +2460,11 @@ describe('component: props', () => { const Child = compile( ``, data, diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 4b9d037a1c5..eef6892ecc8 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -890,7 +890,7 @@ export class VaporComponentInstance< /** * @internal */ - propsDeps: Record | undefined + propsDeps: Map | undefined /** * @internal */ diff --git a/packages/runtime-vapor/src/componentProps.ts b/packages/runtime-vapor/src/componentProps.ts index 403800c2b98..4c94d6b692b 100644 --- a/packages/runtime-vapor/src/componentProps.ts +++ b/packages/runtime-vapor/src/componentProps.ts @@ -529,18 +529,22 @@ function trackPropsValue( key: string | symbol, ): void { if (!instance.hasDynamicProps || activeSub === undefined) return - const deps = instance.propsDeps || (instance.propsDeps = Object.create(null)) - trackDep(deps[key] || (deps[key] = new Dep())) + 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[key] - if (dep) triggerDep(dep) + const dep = deps.get(key) + if (dep) triggerDep(dep, instance.propsValues, key, newValue, oldValue) } } @@ -575,15 +579,7 @@ export function initProps( const hasGetters = hasDynamicPropsSource(rawProps) if (once || (!cells && (isInterop || (!canRebind && !hasGetters)))) { instance.hasDynamicProps = !once && isInterop - const prevSub = setActiveSub() - let rawValues: Record - try { - rawValues = collectProps(rawProps) - if (cells) collectSlotSources(cells) - } finally { - setActiveSub(prevSub) - } - deliverInputs(instance, rawValues, undefined, cells) + deliverInputs(instance, collectInputs(rawProps, cells), cells) return } instance.hasDynamicProps = canRebind || hasGetters @@ -618,12 +614,7 @@ export function initProps( if (effect.active) { // a delivery that throws half-way is redone in full hasDelivered = false - deliverInputs( - instance, - rawValues || (propsToUpdate && prevRawValues), - propsToUpdate, - cells, - ) + deliverInputs(instance, rawValues, cells, propsToUpdate) hasDelivered = true } }, true) @@ -644,19 +635,37 @@ export function initProps( } } +// 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, - propsToUpdate?: unknown[], cells?: SlotSourceCell[], + propsToUpdate?: unknown[], ): void { const prevSub = setActiveSub() startBatch() try { - if (instance && rawValues) updateProps(instance, rawValues, propsToUpdate) + 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 { @@ -679,6 +688,7 @@ 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) @@ -686,7 +696,7 @@ function commitSlotSources(cells: SlotSourceCell[]): void { } } -function hasDynamicPropsSource(rawProps: RawProps): boolean { +export function hasDynamicPropsSource(rawProps: RawProps): boolean { if (rawProps.$) return true for (const key in rawProps) { if (isFunction(rawProps[key])) return true @@ -775,7 +785,7 @@ function collectPropsToUpdate( function updateProps( instance: VaporComponentInstance, rawValues: Record, - // the entries to set on the delivered `rawValues`, as [key, value, …] + // the entries to set on `rawValues`, the delivered frame, as [key, value, …] propsToUpdate?: unknown[], ): void { const propsValues = instance.propsValues @@ -867,8 +877,9 @@ function updateProps( if (present) { for (const key in propsValues) { if (!present[key]) { + const oldValue = propsValues[key] delete propsValues[key] - triggerPropsValue(instance, key) + triggerPropsValue(instance, key, undefined, oldValue) triggerPropsValue(instance, ATTR_KEYS) } } @@ -899,9 +910,9 @@ function setPropValue( const oldValue = propsValues[key] propsValues[key] = value if (!hadKey) { - triggerPropsValue(instance, key) + triggerPropsValue(instance, key, value) triggerPropsValue(instance, ATTR_KEYS) } else if (hasChanged(value, oldValue)) { - triggerPropsValue(instance, key) + triggerPropsValue(instance, key, value, oldValue) } } diff --git a/packages/runtime-vapor/src/componentSlots.ts b/packages/runtime-vapor/src/componentSlots.ts index 0c4be821b9e..1c9ac56d441 100644 --- a/packages/runtime-vapor/src/componentSlots.ts +++ b/packages/runtime-vapor/src/componentSlots.ts @@ -134,7 +134,8 @@ export class SlotSourceCell extends Dep { export function isolateSlotSources( rawSlots: RawSlots, ): [RawSlots, SlotSourceCell[]] | undefined { - const dynamicSources = rawSlots.$! + const dynamicSources = rawSlots.$ + if (!dynamicSources) return let count = 0 for (let i = 0; i < dynamicSources.length; i++) { if (isFunction(dynamicSources[i])) count++ @@ -163,9 +164,7 @@ function isolateSlotSource(cell: SlotSourceCell): DynamicSlotFn { } export function initSlots(instance: VaporComponentInstance): void { - const rawSlots = instance.rawSlots - if (!rawSlots.$) return - const isolated = isolateSlotSources(rawSlots) + const isolated = isolateSlotSources(instance.rawSlots) if (isolated) { instance.rawSlots = isolated[0] instance.slotSources = isolated[1] diff --git a/packages/runtime-vapor/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts index 3ba71e0fd7e..0c48b045f05 100644 --- a/packages/runtime-vapor/src/vdomInterop.ts +++ b/packages/runtime-vapor/src/vdomInterop.ts @@ -122,9 +122,11 @@ import { } from '@vue/shared' import { type RawProps, + collectInputs, collectProps, collectSlotSources, deliverInputs, + hasDynamicPropsSource, rawPropsProxyHandlers, snapshotRawProps, } from './componentProps' @@ -1460,30 +1462,24 @@ function createVDOMComponent( let propsInstance: VaporComponentInstance | undefined let rawValues: Record = EMPTY_OBJ let isMounted = false - // a cache hit can rebind a kept-alive component to other inputs - const canRebind = !!inputScope - let isStatic = once - if (!once) inputScope ||= effectScope(true) + 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 initProps; 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 { - let cells: SlotSourceCell[] | undefined - if (rawSlots && (rawSlots as RawSlots).$) { - const isolated = isolateSlotSources(rawSlots as RawSlots) - if (isolated) { - rawSlots = isolated[0] - cells = isolated[1] - } - } - if (once) { - const prevSub = setActiveSub() - try { - rawValues = collectProps((rawProps || EMPTY_OBJ) as RawProps) - if (cells) collectSlotSources(cells) - } finally { - setActiveSub(prevSub) - } - if (cells) deliverInputs(undefined, undefined, undefined, cells) + 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) @@ -1496,15 +1492,15 @@ function createVDOMComponent( } finally { restoreCurrentInstance(prevInner) } - if (effect.active) { - deliverInputs(propsInstance, rawValues, undefined, cells) + if (effect.active && (propsInstance || cells)) { + deliverInputs(propsInstance, rawValues, cells) } }, true) effect.run() // every getter and descriptor turned out to be constant - if (!effect.deps && !canRebind) { + if (!effect.deps) { effect.stop() - isStatic = true + hasDynamicProps = false } } vnode = createVNode(comp, rawValues) @@ -1577,7 +1573,7 @@ function createVDOMComponent( )) simpleSetCurrentInstance(prev) wrapper.interopVNode = vnode - wrapper.hasDynamicProps = !isStatic + wrapper.hasDynamicProps = hasDynamicProps // The detached input scope follows the rendered component's lifetime. instance.scope.run(() => { onScopeDispose(() => { From 1617aa51b01a449806cc8d384bdb6d1c51f00430 Mon Sep 17 00:00:00 2001 From: edison Date: Tue, 29 Sep 2026 22:36:25 -0700 Subject: [PATCH 08/11] refactor(runtime-vapor): hand vdom-evaluated props straight to the input store --- packages/runtime-vapor/src/component.ts | 9 +++---- packages/runtime-vapor/src/componentProps.ts | 28 +++++++++++--------- packages/runtime-vapor/src/vdomInterop.ts | 16 +++-------- 3 files changed, 24 insertions(+), 29 deletions(-) diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index eef6892ecc8..809f790990f 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -91,7 +91,7 @@ import { getPropsProxyHandlers, getStaticBindingKeys, hasFallthroughAttrs, - initProps, + initInputs, normalizePropsOptions, resolveDynamicProps, resolveSource, @@ -397,7 +397,7 @@ export function createComponent( // Rebind cache-owned inputs to the current call site's getters. if (isVaporComponent(cached) && cached.inputScope) { cached.rawProps = (rawProps || EMPTY_OBJ) as RawProps - initProps(cached) + initInputs(cached) } // a nested branch teardown stops the branch scope that unmounts the // cached component, so the scope re-entering it takes over @@ -560,7 +560,7 @@ export function createComponent( try { initSlots(instance) - initProps(instance, once) + initInputs(instance, once) } catch (error) { // Inputs can fail before the teardown below is registered. if (__DEV__) unregisterHMR(instance) @@ -886,7 +886,7 @@ export class VaporComponentInstance< vapor: true propsValues: Record rawValues: Record - propsEffect?: RenderEffect + inputEffect?: RenderEffect /** * @internal */ @@ -931,7 +931,6 @@ export class VaporComponentInstance< interopVNode?: VNode // to hold vnode props / slots in vdom interop mode - rawPropsRef?: ShallowRef rawSlotsRef?: ShallowRef emit: EmitFn diff --git a/packages/runtime-vapor/src/componentProps.ts b/packages/runtime-vapor/src/componentProps.ts index 4c94d6b692b..c4ceada475d 100644 --- a/packages/runtime-vapor/src/componentProps.ts +++ b/packages/runtime-vapor/src/componentProps.ts @@ -51,7 +51,9 @@ 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 = @@ -116,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) @@ -422,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 — @@ -558,12 +558,12 @@ export function trackRawValues( } // Inputs belong to the child scope, but run in the parent's update order. -export function initProps( +export function initInputs( instance: VaporComponentInstance, once = false, ): void { const scope = instance.inputScope || instance.scope - const prevEffect = instance.propsEffect + const prevEffect = instance.inputEffect if (prevEffect) prevEffect.stop() const parent = instance.parent || instance const rawProps = instance.rawProps @@ -572,14 +572,18 @@ export function initProps( try { const dynamicSources = rawProps.$ // VDOM already evaluated these inputs and delivers subsequent patches. - const isInterop = - isInteropEnabled && !!(dynamicSources && dynamicSources[interopKey]) + 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 - deliverInputs(instance, collectInputs(rawProps, cells), cells) + // vdom hands over values, not getters: nothing to evaluate + deliverInputs( + instance, + isInterop ? rawProps : collectInputs(rawProps, cells), + cells, + ) return } instance.hasDynamicProps = canRebind || hasGetters @@ -618,7 +622,7 @@ export function initProps( hasDelivered = true } }, true) - if (canRebind) instance.propsEffect = effect + if (canRebind) instance.inputEffect = effect if (!prevEffect) { effect.run() // every getter and descriptor turned out to be constant diff --git a/packages/runtime-vapor/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts index 0c48b045f05..0cf4f2459ff 100644 --- a/packages/runtime-vapor/src/vdomInterop.ts +++ b/packages/runtime-vapor/src/vdomInterop.ts @@ -377,7 +377,8 @@ const vaporInteropImpl = { const prev = currentInstance simpleSetCurrentInstance(parentComponent) - const propsRef = shallowRef(filterReservedProps(vnode.props)) + const rawProps = filterReservedProps(vnode.props) as RawProps + rawProps[interopKey] = true const slotsRef = shallowRef(normalizeInteropSlots(vnode.children)) const rawSlots = createInteropRawSlots(slotsRef) @@ -386,24 +387,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) @@ -1468,7 +1461,7 @@ function createVDOMComponent( rawSlots = isolated[0] cells = isolated[1] } - // inputs that can never change are delivered once, as in initProps; a + // 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 && @@ -3727,7 +3720,6 @@ function updateInteropVNode( prevVNode: VNode, ): void { state.pendingVNodeUpdate = vnode - instance.rawPropsRef!.value = filterReservedProps(vnode.props) deliverInputs(instance, vnode.props || EMPTY_OBJ) instance.rawSlotsRef!.value = normalizeInteropSlots(vnode.children) // align with VDOM: vnode beforeUpdate runs before directive beforeUpdate. From b1b69c88af2e609026066ba08c10fe239a99d074 Mon Sep 17 00:00:00 2001 From: edison Date: Tue, 29 Sep 2026 23:07:48 -0700 Subject: [PATCH 09/11] refactor(reactivity): keep ref tracking on its own path --- packages/reactivity/src/ref.ts | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/reactivity/src/ref.ts b/packages/reactivity/src/ref.ts index e894402a50c..598482c88bf 100644 --- a/packages/reactivity/src/ref.ts +++ b/packages/reactivity/src/ref.ts @@ -8,9 +8,9 @@ import { isSymbol, } from '@vue/shared' import type { ComputedRef, WritableComputedRef } from './computed' -import { ReactiveFlags, TriggerOpTypes } from './constants' -import { triggerEventInfos } from './debug' -import { getDepFromReactive, trackDep, triggerDep } from './dep' +import { ReactiveFlags, TrackOpTypes, TriggerOpTypes } from './constants' +import { onTrack, triggerEventInfos } from './debug' +import { getDepFromReactive } from './dep' import { type Builtin, type ShallowReactiveBrand, @@ -26,8 +26,10 @@ import { type Link, type ReactiveNode, ReactiveFlags as _ReactiveFlags, + activeSub, batchDepth, flush, + link, propagate, shallowPropagate, } from './system' @@ -231,10 +233,27 @@ class RefImpl implements ReactiveNode { export function triggerRef(ref: Ref): void { // ref may be an instance of ObjectRefImpl const dep = (ref as unknown as RefImpl).dep - if (dep !== undefined) triggerDep(dep) + if (dep !== undefined && dep.subs !== undefined) { + propagate(dep.subs) + shallowPropagate(dep.subs) + if (!batchDepth) { + flush() + } + } } -const trackRef = trackDep +function trackRef(dep: ReactiveNode) { + if (activeSub !== undefined) { + if (__DEV__) { + onTrack(activeSub!, { + target: dep, + type: TrackOpTypes.GET, + key: 'value', + }) + } + link(dep, activeSub!) + } +} export type MaybeRef = | T From 485eca563907a0386324b2027e691b3c8947e5bc Mon Sep 17 00:00:00 2001 From: edison Date: Wed, 30 Sep 2026 01:20:38 -0700 Subject: [PATCH 10/11] fix(runtime-vapor): refresh a vdom child's raw props on patch and deliver its slots in the same batch --- .../__tests__/componentSlots.spec.ts | 40 +++++++++++++++++++ packages/runtime-vapor/__tests__/gc.spec.ts | 38 +++++++++++++++++- packages/runtime-vapor/src/vdomInterop.ts | 39 +++++++++++++----- 3 files changed, 104 insertions(+), 13 deletions(-) diff --git a/packages/runtime-vapor/__tests__/componentSlots.spec.ts b/packages/runtime-vapor/__tests__/componentSlots.spec.ts index 1135fae96ba..cdc11bbd271 100644 --- a/packages/runtime-vapor/__tests__/componentSlots.spec.ts +++ b/packages/runtime-vapor/__tests__/componentSlots.spec.ts @@ -8152,6 +8152,46 @@ describe('component: slots', () => { }, ) + 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() + }) + // props are evaluated before the descriptors, as in vdom: one read, and // none after the creation was aborted test.each([ diff --git a/packages/runtime-vapor/__tests__/gc.spec.ts b/packages/runtime-vapor/__tests__/gc.spec.ts index 2e0d23a9ac4..1642f38582c 100644 --- a/packages/runtime-vapor/__tests__/gc.spec.ts +++ b/packages/runtime-vapor/__tests__/gc.spec.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom -import { nextTick, shallowRef } from '@vue/runtime-dom' -import { createVaporApp } from '../src' +import { createApp, nextTick, shallowRef } from '@vue/runtime-dom' +import { createVaporApp, vaporInteropPlugin } from '../src' import { compile } from './_utils' describe.skipIf(!global.gc)('component props gc', () => { @@ -48,4 +48,38 @@ describe.skipIf(!global.gc)('component props gc', () => { 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/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts index 0cf4f2459ff..4d28e02f287 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 { type EffectScope, effectScope, setActiveSub } from '@vue/reactivity' +import { + type EffectScope, + effectScope, + endBatch, + setActiveSub, + startBatch, +} from '@vue/reactivity' import { type LooseRawProps, type VaporComponent, @@ -348,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 @@ -377,8 +385,7 @@ const vaporInteropImpl = { const prev = currentInstance simpleSetCurrentInstance(parentComponent) - const rawProps = filterReservedProps(vnode.props) as RawProps - rawProps[interopKey] = true + const rawProps = interopRawProps(vnode) const slotsRef = shallowRef(normalizeInteropSlots(vnode.children)) const rawSlots = createInteropRawSlots(slotsRef) @@ -3720,8 +3727,18 @@ function updateInteropVNode( prevVNode: VNode, ): void { state.pendingVNodeUpdate = vnode - deliverInputs(instance, vnode.props || EMPTY_OBJ) - 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 { + endBatch() + setActiveSub(prevSub) + } // align with VDOM: vnode beforeUpdate runs before directive beforeUpdate. invokeInteropVNodeBeforeUpdate(instance, vnode, prevVNode) updateInteropDirs(instance, state, vnode, prevVNode) From f74e04a78fce1839c29ac79ff57c32d1269b9bc8 Mon Sep 17 00:00:00 2001 From: edison Date: Wed, 30 Sep 2026 01:33:23 -0700 Subject: [PATCH 11/11] fix(runtime-vapor): restore the active subscriber when an interop delivery throws --- .../__tests__/componentSlots.spec.ts | 41 +++++++++++++++++++ packages/runtime-vapor/src/vdomInterop.ts | 7 +++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/runtime-vapor/__tests__/componentSlots.spec.ts b/packages/runtime-vapor/__tests__/componentSlots.spec.ts index cdc11bbd271..7e953ffd7a6 100644 --- a/packages/runtime-vapor/__tests__/componentSlots.spec.ts +++ b/packages/runtime-vapor/__tests__/componentSlots.spec.ts @@ -8192,6 +8192,47 @@ describe('component: slots', () => { 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([ diff --git a/packages/runtime-vapor/src/vdomInterop.ts b/packages/runtime-vapor/src/vdomInterop.ts index 4d28e02f287..b27a1f96ed4 100644 --- a/packages/runtime-vapor/src/vdomInterop.ts +++ b/packages/runtime-vapor/src/vdomInterop.ts @@ -3736,8 +3736,11 @@ function updateInteropVNode( deliverInputs(instance, rawProps) instance.rawSlotsRef!.value = normalizeInteropSlots(vnode.children) } finally { - endBatch() - setActiveSub(prevSub) + try { + endBatch() + } finally { + setActiveSub(prevSub) + } } // align with VDOM: vnode beforeUpdate runs before directive beforeUpdate. invokeInteropVNodeBeforeUpdate(instance, vnode, prevVNode)