diff --git a/packages/runtime-core/__tests__/rendererComponent.spec.ts b/packages/runtime-core/__tests__/rendererComponent.spec.ts index fefc4137034..fa3c192e885 100644 --- a/packages/runtime-core/__tests__/rendererComponent.spec.ts +++ b/packages/runtime-core/__tests__/rendererComponent.spec.ts @@ -46,7 +46,7 @@ describe('renderer: component', () => { expect(parentVnode!.el).toBe(childVnode2!.el) }) - it('should create an Component with props', () => { + it('should create a component with props', () => { const Comp = { render: () => { return h('div') @@ -57,7 +57,7 @@ describe('renderer: component', () => { expect(serializeInner(root)).toBe(`
`) }) - it('should create an Component with direct text children', () => { + it('should create a component with direct text children', () => { const Comp = { render: () => { return h('div', 'test') diff --git a/packages/runtime-vapor/__tests__/component.spec.ts b/packages/runtime-vapor/__tests__/component.spec.ts index a84125b5232..a4af5be7ab1 100644 --- a/packages/runtime-vapor/__tests__/component.spec.ts +++ b/packages/runtime-vapor/__tests__/component.spec.ts @@ -1,14 +1,267 @@ -import { ref, watchEffect } from '@vue/runtime-dom' -import { renderEffect, setText, template } from '../src' +import { + type Ref, + inject, + nextTick, + onUpdated, + provide, + ref, + watch, + watchEffect, +} from '@vue/runtime-dom' +import { + createComponent, + createIf, + createTextNode, + renderEffect, + setText, + template, +} from '../src' import { makeRender } from './_utils' import type { VaporComponentInstance } from '../src/component' const define = makeRender() -// TODO port tests from rendererComponent.spec.ts - describe('component', () => { - test('unmountComponent', async () => { + it('should update parent(hoc) component host el when child component self update', async () => { + const value = ref(true) + let childNode1: Node | null = null + let childNode2: Node | null = null + + const { component: Child } = define({ + setup() { + return createIf( + () => value.value, + () => (childNode1 = template('
')()), + () => (childNode2 = template('')()), + ) + }, + }) + + const { host } = define({ + setup() { + return createComponent(Child) + }, + }).render() + + expect(host.innerHTML).toBe('
') + expect(host.children[0]).toBe(childNode1) + + value.value = false + await nextTick() + expect(host.innerHTML).toBe('') + expect(host.children[0]).toBe(childNode2) + }) + + it('should create a component with props', () => { + const { component: Comp } = define({ + setup() { + return template('
', true)() + }, + }) + + const { host } = define({ + setup() { + return createComponent(Comp, { id: () => 'foo', class: () => 'bar' }) + }, + }).render() + + expect(host.innerHTML).toBe('
') + }) + + it('should not update Component if only changed props are declared emit listeners', async () => { + const updatedSyp = vi.fn() + const { component: Comp } = define({ + emits: ['foo'], + setup() { + onUpdated(updatedSyp) + return template('
', true)() + }, + }) + + const toggle = ref(true) + const fn1 = () => {} + const fn2 = () => {} + define({ + setup() { + const _on_foo = () => (toggle.value ? fn1() : fn2()) + return createComponent(Comp, { onFoo: () => _on_foo }) + }, + }).render() + expect(updatedSyp).toHaveBeenCalledTimes(0) + + toggle.value = false + await nextTick() + expect(updatedSyp).toHaveBeenCalledTimes(0) + }) + + it('component child synchronously updating parent state should trigger parent re-render', async () => { + const { component: Child } = define({ + setup() { + const n = inject>('foo')! + n.value++ + const n0 = template('
')() + renderEffect(() => setText(n0, n.value)) + return n0 + }, + }) + + const { host } = define({ + setup() { + const n = ref(0) + provide('foo', n) + const n0 = template('
')() + renderEffect(() => setText(n0, n.value)) + return [n0, createComponent(Child)] + }, + }).render() + + expect(host.innerHTML).toBe('
0
1
') + await nextTick() + expect(host.innerHTML).toBe('
1
1
') + }) + + it('component child updating parent state in pre-flush should trigger parent re-render', async () => { + const { component: Child } = define({ + props: ['value'], + setup(props: any, { emit }) { + watch( + () => props.value, + val => emit('update', val), + ) + const n0 = template('
')() + renderEffect(() => setText(n0, props.value)) + return n0 + }, + }) + + const outer = ref(0) + const { host } = define({ + setup() { + const inner = ref(0) + const n0 = template('
')() + renderEffect(() => setText(n0, inner.value)) + const n1 = createComponent(Child, { + value: () => outer.value, + onUpdate: () => (val: number) => (inner.value = val), + }) + return [n0, n1] + }, + }).render() + + expect(host.innerHTML).toBe('
0
0
') + outer.value++ + await nextTick() + expect(host.innerHTML).toBe('
1
1
') + }) + + it('child only updates once when triggered in multiple ways', async () => { + const a = ref(0) + const calls: string[] = [] + + const { component: Child } = define({ + props: ['count'], + setup(props: any) { + onUpdated(() => calls.push('update child')) + return createTextNode(() => [`${props.count} - ${a.value}`]) + }, + }) + + const { host } = define({ + setup() { + return createComponent(Child, { count: () => a.value }) + }, + }).render() + + expect(host.innerHTML).toBe('0 - 0') + expect(calls).toEqual([]) + + // This will trigger child rendering directly, as well as via a prop change + a.value++ + await nextTick() + expect(host.innerHTML).toBe('1 - 1') + expect(calls).toEqual(['update child']) + }) + + it(`an earlier update doesn't lead to excessive subsequent updates`, async () => { + const globalCount = ref(0) + const parentCount = ref(0) + const calls: string[] = [] + + const { component: Child } = define({ + props: ['count'], + setup(props: any) { + watch( + () => props.count, + () => { + calls.push('child watcher') + globalCount.value = props.count + }, + ) + onUpdated(() => calls.push('update child')) + return [] + }, + }) + + const { component: Parent } = define({ + props: ['count'], + setup(props: any) { + onUpdated(() => calls.push('update parent')) + const n1 = createTextNode(() => [ + `${globalCount.value} - ${props.count}`, + ]) + const n2 = createComponent(Child, { count: () => parentCount.value }) + return [n1, n2] + }, + }) + + const { host } = define({ + setup() { + onUpdated(() => calls.push('update root')) + return createComponent(Parent, { count: () => globalCount.value }) + }, + }).render() + + expect(host.innerHTML).toBe(`0 - 0`) + expect(calls).toEqual([]) + + parentCount.value++ + await nextTick() + expect(host.innerHTML).toBe(`1 - 1`) + expect(calls).toEqual(['child watcher', 'update parent']) + }) + + it('child component props update should not lead to double update', async () => { + const text = ref(0) + const spy = vi.fn() + + const { component: Comp } = define({ + props: ['text'], + setup(props: any) { + const n1 = template('

')() + renderEffect(() => { + spy() + setText(n1, props.text) + }) + return n1 + }, + }) + + const { host } = define({ + setup() { + return createComponent(Comp, { text: () => text.value }) + }, + }).render() + + expect(host.innerHTML).toBe('

0

') + expect(spy).toHaveBeenCalledTimes(1) + + text.value++ + await nextTick() + expect(host.innerHTML).toBe('

1

') + expect(spy).toHaveBeenCalledTimes(2) + }) + + it('unmount component', async () => { const { host, app, instance } = define(() => { const count = ref(0) const t0 = template('
') @@ -28,4 +281,26 @@ describe('component', () => { expect(host.innerHTML).toBe('') expect(i.scope.effects.length).toBe(0) }) + + it('warn if functional vapor component not return a block', () => { + define(() => { + return () => {} + }).render() + + expect( + 'Functional vapor component must return a block directly', + ).toHaveBeenWarned() + }) + + it('warn if setup return a function and no render function', () => { + define({ + setup() { + return () => [] + }, + }).render() + + expect( + 'Vapor component setup() returned non-block value, and has no render function', + ).toHaveBeenWarned() + }) })