import { type EffectScope, ReactiveEffect, type Ref, inject, nextTick, onMounted, onUpdated, provide, ref, useAttrs, watch, watchEffect, } from '@vue/runtime-dom' import { createComponent, createIf, createTextNode, defineVaporComponent, renderEffect, setInsertionState, template, } from '../src' import { makeRender } from './_utils' import type { VaporComponentInstance } from '../src/component' import { setElementText, setText } from '../src/dom/prop' const define = makeRender() describe('component', () => { 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(() => setElementText(n0, n.value)) return n0 }, }) const { host } = define({ setup() { const n = ref(0) provide('foo', n) const n0 = template('
')() renderEffect(() => setElementText(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(() => setElementText(n0, props.value)) return n0 }, }) const outer = ref(0) const { host } = define({ setup() { const inner = ref(0) const n0 = template('
')() renderEffect(() => setElementText(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')) const n = createTextNode() renderEffect(() => { setText(n, `${props.count} - ${a.value}`) }) return n }, }) 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() const n2 = createComponent(Child, { count: () => parentCount.value }) renderEffect(() => { setText(n1, `${globalCount.value} - ${props.count}`) }) 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() setElementText(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('properly mount child component when using setInsertionState', async () => { const spy = vi.fn() const { component: Comp } = define({ setup() { onMounted(spy) return template('

hi

')() }, }) const { host } = define({ setup() { const n2 = template('
', true)() setInsertionState(n2 as any) createComponent(Comp) return n2 }, }).render() expect(host.innerHTML).toBe('

hi

') expect(spy).toHaveBeenCalledTimes(1) }) it('unmount component', async () => { const { host, app, instance } = define(() => { const count = ref(0) const t0 = template('
') const n0 = t0() watchEffect(() => { setElementText(n0, count.value) }) renderEffect(() => {}) return n0 }).render() const i = instance as VaporComponentInstance // watchEffect + renderEffect + props validation effect expect(getEffectsCount(i.scope)).toBe(3) expect(host.innerHTML).toBe('
0
') app.unmount() expect(host.innerHTML).toBe('') expect(getEffectsCount(i.scope)).toBe(0) }) it('work with v-once + props', () => { const Child = defineVaporComponent({ props: { count: Number, }, setup(props) { const n0 = template(' ')() as any renderEffect(() => setText(n0, String(props.count))) return n0 }, }) const count = ref(0) const { html } = define({ setup() { return createComponent( Child, { count: () => count.value }, null, true, true, // v-once ) }, }).render() expect(html()).toBe('0') count.value++ expect(html()).toBe('0') }) it('work with v-once + attrs', () => { const Child = defineVaporComponent({ setup() { const attrs = useAttrs() const n0 = template(' ')() as any renderEffect(() => setText(n0, attrs.count as string)) return n0 }, }) const count = ref(0) const { html } = define({ setup() { return createComponent( Child, { count: () => count.value }, null, true, true, // v-once ) }, }).render() expect(html()).toBe('0') count.value++ expect(html()).toBe('0') }) it('v-once props should be frozen and not update when parent changes', async () => { const localCount = ref(0) const Child = defineVaporComponent({ props: { count: Number, }, setup(props) { const n0 = template('
')() as any renderEffect(() => setElementText(n0, `${localCount.value} - ${props.count}`), ) return n0 }, }) const parentCount = ref(0) const { html } = define({ setup() { return createComponent( Child, { count: () => parentCount.value }, null, true, true, // v-once ) }, }).render() expect(html()).toBe('
0 - 0
') parentCount.value++ await nextTick() expect(html()).toBe('
0 - 0
') localCount.value++ await nextTick() expect(html()).toBe('
1 - 0
') }) it('v-once attrs should be frozen and not update when parent changes', async () => { const localCount = ref(0) const Child = defineVaporComponent({ inheritAttrs: false, setup() { const attrs = useAttrs() const n0 = template('
')() as any renderEffect(() => setElementText(n0, `${localCount.value} - ${attrs.count}`), ) return n0 }, }) const parentCount = ref(0) const { html } = define({ setup() { return createComponent( Child, { count: () => parentCount.value }, null, true, true, // v-once ) }, }).render() expect(html()).toBe('
0 - 0
') parentCount.value++ await nextTick() expect(html()).toBe('
0 - 0
') localCount.value++ await nextTick() expect(html()).toBe('
1 - 0
') }) test('should mount component only with template in production mode', () => { __DEV__ = false const { component: Child } = define({ render() { return template('
HI
', true)() }, }) const { host } = define({ setup() { return createComponent(Child, null, null, true) }, }).render() expect(host.innerHTML).toBe('
HI
') __DEV__ = true }) it('warn if functional vapor component not return a block', () => { // @ts-expect-error 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() }) it('warn non-existent property access', () => { define({ setup() { return {} }, render(ctx: any) { ctx.foo return [] }, }).render() expect( 'Property "foo" was accessed during render but is not defined on instance.', ).toHaveBeenWarned() }) }) function getEffectsCount(scope: EffectScope): number { let n = 0 for (let dep = scope.deps; dep !== undefined; dep = dep.nextDep) { if (dep.dep instanceof ReactiveEffect) { n++ } } return n }