From 460856510d1a6356194e10f887df9b057297c07e Mon Sep 17 00:00:00 2001 From: Evan You Date: Mon, 30 May 2022 16:37:53 +0800 Subject: [PATCH] wip: setup() template refs support --- src/core/instance/render.ts | 4 +- src/core/vdom/create-functional-component.ts | 9 +- .../vdom/helpers/normalize-scoped-slots.ts | 14 +- src/core/vdom/modules/template-ref.ts | 86 ++- .../{ref.spec.ts => template-ref.spec.ts} | 4 +- test/unit/features/v3/apiSetup.spec.ts | 2 +- .../unit/features/v3/setupTemplateRef.spec.ts | 501 ++++++++++++++++++ typescript/vnode.d.ts | 3 +- 8 files changed, 592 insertions(+), 31 deletions(-) rename test/unit/features/{ref.spec.ts => template-ref.spec.ts} (98%) create mode 100644 test/unit/features/v3/setupTemplateRef.spec.ts diff --git a/src/core/instance/render.ts b/src/core/instance/render.ts index fc0407b8..900cc130 100644 --- a/src/core/instance/render.ts +++ b/src/core/instance/render.ts @@ -100,9 +100,9 @@ export function renderMixin(Vue: typeof Component) { if (_parentVnode) { vm.$scopedSlots = normalizeScopedSlots( + vm.$parent!, _parentVnode.data!.scopedSlots, - vm.$slots, - vm.$scopedSlots + vm.$slots ) if (vm._slotsProxy) { syncSetupSlots(vm._slotsProxy, vm.$scopedSlots) diff --git a/src/core/vdom/create-functional-component.ts b/src/core/vdom/create-functional-component.ts index eee5811f..019453f5 100644 --- a/src/core/vdom/create-functional-component.ts +++ b/src/core/vdom/create-functional-component.ts @@ -52,6 +52,7 @@ export function FunctionalRenderContext( this.slots = () => { if (!this.$slots) { normalizeScopedSlots( + parent, data.scopedSlots, (this.$slots = resolveSlots(children, parent)) ) @@ -62,7 +63,7 @@ export function FunctionalRenderContext( Object.defineProperty(this, 'scopedSlots', { enumerable: true, get() { - return normalizeScopedSlots(data.scopedSlots, this.slots()) + return normalizeScopedSlots(parent, data.scopedSlots, this.slots()) } } as any) @@ -72,7 +73,11 @@ export function FunctionalRenderContext( this.$options = options // pre-resolve slots for renderSlot() this.$slots = this.slots() - this.$scopedSlots = normalizeScopedSlots(data.scopedSlots, this.$slots) + this.$scopedSlots = normalizeScopedSlots( + parent, + data.scopedSlots, + this.$slots + ) } if (options._scopeId) { diff --git a/src/core/vdom/helpers/normalize-scoped-slots.ts b/src/core/vdom/helpers/normalize-scoped-slots.ts index f65a3ae8..ab22597a 100644 --- a/src/core/vdom/helpers/normalize-scoped-slots.ts +++ b/src/core/vdom/helpers/normalize-scoped-slots.ts @@ -3,13 +3,16 @@ import { normalizeChildren } from 'core/vdom/helpers/normalize-children' import { emptyObject, isArray } from 'shared/util' import { isAsyncPlaceholder } from './is-async-placeholder' import type VNode from '../vnode' +import { Component } from 'typescript/component' +import { currentInstance, setCurrentInstance } from 'v3/currentInstance' export function normalizeScopedSlots( + ownerVm: Component, slots: { [key: string]: Function } | void, - normalSlots: { [key: string]: Array }, - prevSlots?: { [key: string]: Function } | void + normalSlots: { [key: string]: VNode[] } ): any { let res + const prevSlots = ownerVm.$scopedSlots const hasNormalSlots = Object.keys(normalSlots).length > 0 const isStable = slots ? !!slots.$stable : !hasNormalSlots const key = slots && slots.$key @@ -33,7 +36,7 @@ export function normalizeScopedSlots( res = {} for (const key in slots) { if (slots[key] && key[0] !== '$') { - res[key] = normalizeScopedSlot(normalSlots, key, slots[key]) + res[key] = normalizeScopedSlot(ownerVm, normalSlots, key, slots[key]) } } } @@ -54,14 +57,17 @@ export function normalizeScopedSlots( return res } -function normalizeScopedSlot(normalSlots, key, fn) { +function normalizeScopedSlot(vm, normalSlots, key, fn) { const normalized = function () { + const cur = currentInstance + setCurrentInstance(vm) let res = arguments.length ? fn.apply(null, arguments) : fn({}) res = res && typeof res === 'object' && !isArray(res) ? [res] // single vnode : normalizeChildren(res) const vnode: VNode | null = res && res[0] + setCurrentInstance(cur) return res && (!vnode || (res.length === 1 && vnode.isComment && !isAsyncPlaceholder(vnode))) // #9658, #10391 diff --git a/src/core/vdom/modules/template-ref.ts b/src/core/vdom/modules/template-ref.ts index ed7e4426..8688697e 100644 --- a/src/core/vdom/modules/template-ref.ts +++ b/src/core/vdom/modules/template-ref.ts @@ -1,5 +1,15 @@ -import { remove, isDef, isArray } from 'shared/util' +import { + remove, + isDef, + hasOwn, + isArray, + isFunction, + invokeWithErrorHandling, + warn +} from 'core/util' import type { VNodeWithData } from 'typescript/vnode' +import { Component } from 'typescript/component' +import { isRef } from 'v3' export default { create(_: any, vnode: VNodeWithData) { @@ -17,28 +27,66 @@ export default { } export function registerRef(vnode: VNodeWithData, isRemoval?: boolean) { - const key = vnode.data.ref - if (!isDef(key)) return + const ref = vnode.data.ref + if (!isDef(ref)) return const vm = vnode.context - const ref = vnode.componentInstance || vnode.elm + const refValue = vnode.componentInstance || vnode.elm + const value = isRemoval ? null : refValue + + if (isFunction(ref)) { + invokeWithErrorHandling(ref, vm, [value], vm, `template ref function`) + return + } + + const setupRefKey = vnode.data.ref_key + const isFor = vnode.data.refInFor + const _isString = typeof ref === 'string' || typeof ref === 'number' + const _isRef = isRef(ref) const refs = vm.$refs - const obj = refs[key] - if (isRemoval) { - if (isArray(obj)) { - remove(obj, ref) - } else if (obj === ref) { - refs[key] = undefined - } - } else { - if (vnode.data.refInFor) { - if (!isArray(obj)) { - refs[key] = [ref] - } else if (obj.indexOf(ref) < 0) { - obj.push(ref) + + if (_isString || _isRef) { + if (isFor) { + const existing = _isString ? refs[ref] : ref.value + if (isRemoval) { + isArray(existing) && remove(existing, refValue) + } else { + if (!isArray(existing)) { + if (_isString) { + refs[ref] = [refValue] + setSetupRef(vm, ref, refs[ref]) + } else { + ref.value = [refValue] + if (setupRefKey) refs[setupRefKey] = ref.value as any + } + } else if (!existing.includes(refValue)) { + existing.push(refValue) + } } - } else { - refs[key] = ref + } else if (_isString) { + if (isRemoval && refs[ref] !== refValue) { + return + } + refs[ref] = value + setSetupRef(vm, ref, value) + } else if (_isRef) { + if (isRemoval && ref.value !== refValue) { + return + } + ref.value = value + if (setupRefKey) refs[setupRefKey] = value + } else if (__DEV__) { + warn(`Invalid template ref type: ${typeof ref}`) + } + } +} + +function setSetupRef({ _setupState }: Component, key: string, val: any) { + if (_setupState && hasOwn(_setupState, key)) { + if (isRef(_setupState[key])) { + _setupState[key].value = val + } else { + _setupState[key] = val } } } diff --git a/test/unit/features/ref.spec.ts b/test/unit/features/template-ref.spec.ts similarity index 98% rename from test/unit/features/ref.spec.ts rename to test/unit/features/template-ref.spec.ts index 0629e14a..00aeeb2f 100644 --- a/test/unit/features/ref.spec.ts +++ b/test/unit/features/template-ref.spec.ts @@ -58,7 +58,7 @@ describe('ref', () => { expect(vm.$refs.foo).toBe(vm.$el) vm.value = 'bar' waitForUpdate(() => { - expect(vm.$refs.foo).toBeUndefined() + expect(vm.$refs.foo).toBe(null) expect(vm.$refs.bar).toBe(vm.$el) }).then(done) }) @@ -101,7 +101,7 @@ describe('ref', () => { vm.test = '' }) .then(() => { - expect(vm.$refs.test).toBeUndefined() + expect(vm.$refs.test).toBe(null) }) .then(done) }) diff --git a/test/unit/features/v3/apiSetup.spec.ts b/test/unit/features/v3/apiSetup.spec.ts index 806ab55f..f3c152b4 100644 --- a/test/unit/features/v3/apiSetup.spec.ts +++ b/test/unit/features/v3/apiSetup.spec.ts @@ -164,7 +164,7 @@ describe('api: setup context', () => { const id = ref('foo') const Child = { - setup(props: any, { slots }: any) { + setup(_props: any, { slots }: any) { return () => h('div', [...slots.foo(), ...slots.bar()]) } } diff --git a/test/unit/features/v3/setupTemplateRef.spec.ts b/test/unit/features/v3/setupTemplateRef.spec.ts new file mode 100644 index 00000000..189b3a1e --- /dev/null +++ b/test/unit/features/v3/setupTemplateRef.spec.ts @@ -0,0 +1,501 @@ +import Vue from 'vue' +import { ref, h, nextTick, reactive } from 'v3/index' + +// reference: https://vue-composition-api-rfc.netlify.com/api.html#template-refs + +describe('api: setup() template refs', () => { + it('string ref mount', () => { + const el = ref(null) + + const Comp = { + setup() { + return { + refKey: el + } + }, + render() { + return h('div', { ref: 'refKey' }) + } + } + const vm = new Vue(Comp).$mount() + expect(el.value).toBe(vm.$el) + }) + + it('string ref update', async () => { + const fooEl = ref(null) + const barEl = ref(null) + const refKey = ref('foo') + + const Comp = { + setup() { + return { + foo: fooEl, + bar: barEl + } + }, + render() { + return h('div', { ref: refKey.value }) + } + } + const vm = new Vue(Comp).$mount() + expect(barEl.value).toBe(null) + + refKey.value = 'bar' + await nextTick() + expect(fooEl.value).toBe(null) + expect(barEl.value).toBe(vm.$el) + }) + + it('string ref unmount', async () => { + const el = ref(null) + const toggle = ref(true) + + const Comp = { + setup() { + return { + refKey: el + } + }, + render() { + return toggle.value ? h('div', { ref: 'refKey' }) : null + } + } + + const vm = new Vue(Comp).$mount() + expect(el.value).toBe(vm.$el) + + toggle.value = false + await nextTick() + expect(el.value).toBe(null) + }) + + it('function ref mount', () => { + const fn = vi.fn() + + const Comp = { + render: () => h('div', { ref: fn }) + } + const vm = new Vue(Comp).$mount() + expect(fn.mock.calls[0][0]).toBe(vm.$el) + }) + + it('function ref update', async () => { + const fn1 = vi.fn() + const fn2 = vi.fn() + const fn = ref(fn1) + + const Comp = { render: () => h('div', { ref: fn.value }) } + + const vm = new Vue(Comp).$mount() + expect(fn1.mock.calls).toHaveLength(1) + expect(fn1.mock.calls[0][0]).toBe(vm.$el) + expect(fn2.mock.calls).toHaveLength(0) + + fn.value = fn2 + await nextTick() + expect(fn1.mock.calls).toHaveLength(2) + expect(fn1.mock.calls[1][0]).toBe(null) + expect(fn2.mock.calls).toHaveLength(1) + expect(fn2.mock.calls[0][0]).toBe(vm.$el) + }) + + it('function ref unmount', async () => { + const fn = vi.fn() + const toggle = ref(true) + + const Comp = { + render: () => (toggle.value ? h('div', { ref: fn }) : null) + } + const vm = new Vue(Comp).$mount() + expect(fn.mock.calls[0][0]).toBe(vm.$el) + toggle.value = false + await nextTick() + expect(fn.mock.calls[1][0]).toBe(null) + }) + + it('render function ref mount', () => { + const el = ref(null) + + const Comp = { + setup() { + return () => h('div', { ref: el }) + } + } + const vm = new Vue(Comp).$mount() + expect(el.value).toBe(vm.$el) + }) + + it('render function ref update', async () => { + const refs = { + foo: ref(null), + bar: ref(null) + } + const refKey = ref('foo') + + const Comp = { + setup() { + return () => h('div', { ref: refs[refKey.value] }) + } + } + const vm = new Vue(Comp).$mount() + expect(refs.foo.value).toBe(vm.$el) + expect(refs.bar.value).toBe(null) + + refKey.value = 'bar' + await nextTick() + expect(refs.foo.value).toBe(null) + expect(refs.bar.value).toBe(vm.$el) + }) + + it('render function ref unmount', async () => { + const el = ref(null) + const toggle = ref(true) + + const Comp = { + setup() { + return () => (toggle.value ? h('div', { ref: el }) : null) + } + } + const vm = new Vue(Comp).$mount() + expect(el.value).toBe(vm.$el) + + toggle.value = false + await nextTick() + expect(el.value).toBe(null) + }) + + it('string ref inside slots', async () => { + const spy = vi.fn() + const Child = { + render(this: any) { + return this.$slots.default + } + } + + const Comp = { + render() { + return h(Child, [h('div', { ref: 'foo' })]) + }, + mounted(this: any) { + spy(this.$refs.foo.tagName) + } + } + new Vue(Comp).$mount() + expect(spy).toHaveBeenCalledWith('DIV') + }) + + it('string ref inside scoped slots', async () => { + const spy = vi.fn() + const Child = { + render(this: any) { + return this.$scopedSlots.default() + } + } + + const Comp = { + render() { + return h(Child, { + scopedSlots: { + default: () => [h('div', { ref: 'foo' })] + } + }) + }, + mounted(this: any) { + spy(this.$refs.foo.tagName) + } + } + new Vue(Comp).$mount() + expect(spy).toHaveBeenCalledWith('DIV') + }) + + it('should work with direct reactive property', () => { + const state = reactive({ + refKey: null + }) + + const Comp = { + setup() { + return state + }, + render() { + return h('div', { ref: 'refKey' }) + } + } + const vm = new Vue(Comp).$mount() + expect(state.refKey).toBe(vm.$el) + }) + + test('multiple refs', () => { + const refKey1 = ref(null) + const refKey2 = ref(null) + const refKey3 = ref(null) + + const Comp = { + setup() { + return { + refKey1, + refKey2, + refKey3 + } + }, + render() { + return h('div', [ + h('div', { ref: 'refKey1' }), + h('div', { ref: 'refKey2' }), + h('div', { ref: 'refKey3' }) + ]) + } + } + const vm = new Vue(Comp).$mount() + expect(refKey1.value).toBe(vm.$el.children[0]) + expect(refKey2.value).toBe(vm.$el.children[1]) + expect(refKey3.value).toBe(vm.$el.children[2]) + }) + + // vuejs/core#1505 + test('reactive template ref in the same template', async () => { + const Comp = { + setup() { + const el = ref() + return { el } + }, + render(this: any) { + return h( + 'div', + { attrs: { id: 'foo' }, ref: 'el' }, + this.el && this.el.id + ) + } + } + + const vm = new Vue(Comp).$mount() + // ref not ready on first render, but should queue an update immediately + expect(vm.$el.outerHTML).toBe(`
`) + await nextTick() + // ref should be updated + expect(vm.$el.outerHTML).toBe(`
foo
`) + }) + + // vuejs/core#1834 + test('exchange refs', async () => { + const refToggle = ref(false) + const spy = vi.fn() + + const Comp = { + render(this: any) { + return h('div', [ + h('p', { ref: refToggle.value ? 'foo' : 'bar' }), + h('i', { ref: refToggle.value ? 'bar' : 'foo' }) + ]) + }, + mounted(this: any) { + spy(this.$refs.foo.tagName, this.$refs.bar.tagName) + }, + updated(this: any) { + spy(this.$refs.foo.tagName, this.$refs.bar.tagName) + } + } + + new Vue(Comp).$mount() + + expect(spy.mock.calls[0][0]).toBe('I') + expect(spy.mock.calls[0][1]).toBe('P') + refToggle.value = true + await nextTick() + expect(spy.mock.calls[1][0]).toBe('P') + expect(spy.mock.calls[1][1]).toBe('I') + }) + + // vuejs/core#1789 + test('toggle the same ref to different elements', async () => { + const refToggle = ref(false) + const spy = vi.fn() + + const Comp = { + render(this: any) { + return refToggle.value ? h('p', { ref: 'foo' }) : h('i', { ref: 'foo' }) + }, + mounted(this: any) { + spy(this.$refs.foo.tagName) + }, + updated(this: any) { + spy(this.$refs.foo.tagName) + } + } + + new Vue(Comp).$mount() + + expect(spy.mock.calls[0][0]).toBe('I') + refToggle.value = true + await nextTick() + expect(spy.mock.calls[1][0]).toBe('P') + }) + + // vuejs/core#2078 + // @discrepancy Vue 2 doesn't handle merge refs + // test('handling multiple merged refs', async () => { + // const Foo = { + // render: () => h('div', 'foo') + // } + // const Bar = { + // render: () => h('div', 'bar') + // } + + // const viewRef = shallowRef(Foo) + // const elRef1 = ref() + // const elRef2 = ref() + + // const App = { + // render() { + // if (!viewRef.value) { + // return null + // } + // const view = h(viewRef.value, { ref: elRef1 }) + // return h(view, { ref: elRef2 }) + // } + // } + + // new Vue(App).$mount() + + // expect(elRef1.value.$el.innerHTML).toBe('foo') + // expect(elRef1.value).toBe(elRef2.value) + + // viewRef.value = Bar + // await nextTick() + // expect(elRef1.value.$el.innerHTML).toBe('bar') + // expect(elRef1.value).toBe(elRef2.value) + + // viewRef.value = null + // await nextTick() + // expect(elRef1.value).toBeNull() + // expect(elRef1.value).toBe(elRef2.value) + // }) + + // compiled output of