test(vapor): apiWatch

This commit is contained in:
Evan You 2024-12-10 18:43:26 +08:00
parent 12ef12105b
commit 4366a7e213
No known key found for this signature in database
GPG Key ID: 00E9AB7A6704CE0A
7 changed files with 336 additions and 75 deletions

View File

@ -494,7 +494,7 @@ export {
validateProps, validateProps,
} from './componentProps' } from './componentProps'
export { baseEmit, isEmitListener } from './componentEmits' export { baseEmit, isEmitListener } from './componentEmits'
export { type SchedulerJob, queueJob } from './scheduler' export { type SchedulerJob, queueJob, flushOnAppMount } from './scheduler'
export { export {
type ComponentInternalOptions, type ComponentInternalOptions,
type GenericComponentInstance, type GenericComponentInstance,

View File

@ -45,7 +45,7 @@ import {
type SchedulerJob, type SchedulerJob,
SchedulerJobFlags, SchedulerJobFlags,
type SchedulerJobs, type SchedulerJobs,
flushPostFlushCbs, flushOnAppMount,
flushPreFlushCbs, flushPreFlushCbs,
queueJob, queueJob,
queuePostFlushCb, queuePostFlushCb,
@ -2357,7 +2357,6 @@ function baseCreateRenderer(
return teleportEnd ? hostNextSibling(teleportEnd) : el return teleportEnd ? hostNextSibling(teleportEnd) : el
} }
let isFlushing = false
const render: RootRenderFunction = (vnode, container, namespace) => { const render: RootRenderFunction = (vnode, container, namespace) => {
if (vnode == null) { if (vnode == null) {
if (container._vnode) { if (container._vnode) {
@ -2375,12 +2374,7 @@ function baseCreateRenderer(
) )
} }
container._vnode = vnode container._vnode = vnode
if (!isFlushing) { flushOnAppMount()
isFlushing = true
flushPreFlushCbs()
flushPostFlushCbs()
isFlushing = false
}
} }
const internals: RendererInternals = { const internals: RendererInternals = {
@ -2449,7 +2443,7 @@ function baseCreateRenderer(
createApp: createAppAPI( createApp: createAppAPI(
mountApp, mountApp,
unmountApp, unmountApp,
getComponentPublicInstance, getComponentPublicInstance as any,
render, render,
), ),
} }

View File

@ -217,6 +217,19 @@ export function flushPostFlushCbs(seen?: CountMap): void {
} }
} }
let isFlushing = false
/**
* @internal
*/
export function flushOnAppMount(): void {
if (!isFlushing) {
isFlushing = true
flushPreFlushCbs()
flushPostFlushCbs()
isFlushing = false
}
}
const getId = (job: SchedulerJob): number => const getId = (job: SchedulerJob): number =>
job.id == null ? (job.flags! & SchedulerJobFlags.PRE ? -1 : Infinity) : job.id job.id == null ? (job.flags! & SchedulerJobFlags.PRE ? -1 : Infinity) : job.id

View File

@ -1,80 +1,330 @@
import type { Ref } from '@vue/reactivity'
import { import {
EffectScope, currentInstance,
effectScope,
nextTick, nextTick,
onWatcherCleanup, onMounted,
onUpdated,
ref, ref,
watch,
watchEffect, watchEffect,
watchSyncEffect, } from '@vue/runtime-dom'
} from '../src' import { createComponent, defineVaporComponent, renderEffect } from '../src'
import { makeRender } from './_utils'
import type { VaporComponentInstance } from '../src/component'
describe.todo('watchEffect and onWatcherCleanup', () => { const define = makeRender()
test('basic', async () => {
let dummy = 0
let source: Ref<number>
const scope = new EffectScope()
scope.run(() => { // only need to port test cases related to in-component usage
source = ref(0) describe('apiWatch', () => {
watchEffect(onCleanup => { // #7030
source.value it.todo(
// need if support
onCleanup(() => (dummy += 2)) 'should not fire on child component unmount w/ flush: pre',
onWatcherCleanup(() => (dummy += 3)) async () => {
onWatcherCleanup(() => (dummy += 5)) const visible = ref(true)
const cb = vi.fn()
const Parent = defineVaporComponent({
props: ['visible'],
setup() {
// @ts-expect-error
return visible.value ? h(Comp) : null
},
}) })
const Comp = {
setup() {
watch(visible, cb, { flush: 'pre' })
return []
},
}
define(Parent).render({
visible: () => visible.value,
}) })
expect(cb).not.toHaveBeenCalled()
visible.value = false
await nextTick() await nextTick()
expect(dummy).toBe(0) expect(cb).not.toHaveBeenCalled()
},
)
scope.run(() => { // #7030
source.value++ it('flush: pre watcher in child component should not fire before parent update', async () => {
const b = ref(0)
const calls: string[] = []
const Comp = {
setup() {
watch(
() => b.value,
val => {
calls.push('watcher child')
},
{ flush: 'pre' },
)
renderEffect(() => {
b.value
calls.push('render child')
}) })
await nextTick() return []
expect(dummy).toBe(10) },
}
scope.run(() => { const Parent = {
source.value++ props: ['a'],
}) setup() {
await nextTick() watch(
expect(dummy).toBe(20) () => b.value,
val => {
scope.stop() calls.push('watcher parent')
await nextTick() },
expect(dummy).toBe(30) { flush: 'pre' },
)
renderEffect(() => {
b.value
calls.push('render parent')
}) })
test('nested call to watchEffect', async () => { return createComponent(Comp)
let dummy = 0 },
let source: Ref<number> }
let double: Ref<number>
const scope = new EffectScope()
scope.run(() => { define(Parent).render({
source = ref(0) a: () => b.value,
double = ref(0) })
expect(calls).toEqual(['render parent', 'render child'])
b.value++
await nextTick()
expect(calls).toEqual([
'render parent',
'render child',
'watcher parent',
'render parent',
'watcher child',
'render child',
])
})
// #1763
it('flush: pre watcher watching props should fire before child update', async () => {
const a = ref(0)
const b = ref(0)
const c = ref(0)
const calls: string[] = []
const Comp = {
props: ['a', 'b'],
setup(props: any) {
watch(
() => props.a + props.b,
() => {
calls.push('watcher 1')
c.value++
},
{ flush: 'pre' },
)
// #1777 chained pre-watcher
watch(
c,
() => {
calls.push('watcher 2')
},
{ flush: 'pre' },
)
renderEffect(() => {
c.value
calls.push('render')
})
return []
},
}
define(Comp).render({
a: () => a.value,
b: () => b.value,
})
expect(calls).toEqual(['render'])
// both props are updated
// should trigger pre-flush watcher first and only once
// then trigger child render
a.value++
b.value++
await nextTick()
expect(calls).toEqual(['render', 'watcher 1', 'watcher 2', 'render'])
})
// #5721
it('flush: pre triggered in component setup should be buffered and called before mounted', () => {
const count = ref(0)
const calls: string[] = []
const App = {
setup() {
watch(
count,
() => {
calls.push('watch ' + count.value)
},
{ flush: 'pre' },
)
onMounted(() => {
calls.push('mounted')
})
// mutate multiple times
count.value++
count.value++
count.value++
return []
},
}
define(App).render()
expect(calls).toMatchObject(['watch 3', 'mounted'])
})
// #1852
it.todo(
// need if + templateRef
'flush: post watcher should fire after template refs updated',
async () => {
const toggle = ref(false)
let dom: Element | null = null
const App = {
setup() {
const domRef = ref<Element | null>(null)
watch(
toggle,
() => {
dom = domRef.value
},
{ flush: 'post' },
)
return () => {
// @ts-expect-error
return toggle.value ? h('p', { ref: domRef }) : null
}
},
}
// @ts-expect-error
render(h(App), nodeOps.createElement('div'))
expect(dom).toBe(null)
toggle.value = true
await nextTick()
expect(dom!.tagName).toBe('P')
},
)
test('should not leak `this.proxy` to setup()', () => {
const source = vi.fn()
const Comp = defineVaporComponent({
setup() {
watch(source, () => {})
return []
},
})
define(Comp).render()
// should not have any arguments
expect(source.mock.calls[0]).toMatchObject([])
})
// #2728
test('pre watcher callbacks should not track dependencies', async () => {
const a = ref(0)
const b = ref(0)
const updated = vi.fn()
const Comp = defineVaporComponent({
props: ['a'],
setup(props) {
onUpdated(updated)
watch(
() => props.a,
() => {
b.value
},
)
renderEffect(() => {
props.a
})
return []
},
})
define(Comp).render({
a: () => a.value,
})
a.value++
await nextTick()
expect(updated).toHaveBeenCalledTimes(1)
b.value++
await nextTick()
// should not track b as dependency of Child
expect(updated).toHaveBeenCalledTimes(1)
})
// #4158
test('watch should not register in owner component if created inside detached scope', () => {
let instance: VaporComponentInstance
const Comp = {
setup() {
instance = currentInstance as VaporComponentInstance
effectScope(true).run(() => {
watch(
() => 1,
() => {},
)
})
return []
},
}
define(Comp).render()
// should not record watcher in detached scope
expect(instance!.scope.effects.length).toBe(0)
})
test('watchEffect should keep running if created in a detached scope', async () => {
const trigger = ref(0)
let countWE = 0
let countW = 0
const Comp = {
setup() {
effectScope(true).run(() => {
watchEffect(() => { watchEffect(() => {
double.value = source.value * 2 trigger.value
onWatcherCleanup(() => (dummy += 2)) countWE++
})
watchSyncEffect(() => {
double.value
onWatcherCleanup(() => (dummy += 3))
}) })
watch(trigger, () => countW++)
}) })
return []
},
}
const { app } = define(Comp).render()
// only watchEffect as ran so far
expect(countWE).toBe(1)
expect(countW).toBe(0)
trigger.value++
await nextTick() await nextTick()
expect(dummy).toBe(0) // both watchers run while component is mounted
expect(countWE).toBe(2)
expect(countW).toBe(1)
scope.run(() => source.value++) app.unmount()
await nextTick() await nextTick()
expect(dummy).toBe(5) trigger.value++
scope.run(() => source.value++)
await nextTick() await nextTick()
expect(dummy).toBe(10) // both watchers run again event though component has been unmounted
expect(countWE).toBe(3)
scope.stop() expect(countW).toBe(2)
await nextTick()
expect(dummy).toBe(15)
}) })
}) })

View File

@ -5,7 +5,7 @@ const node2 = document.createTextNode('node2')
const node3 = document.createTextNode('node3') const node3 = document.createTextNode('node3')
const anchor = document.createTextNode('anchor') const anchor = document.createTextNode('anchor')
describe('node ops', () => { describe('block + node ops', () => {
test('normalizeBlock', () => { test('normalizeBlock', () => {
expect(normalizeBlock([node1, node2, node3])).toEqual([node1, node2, node3]) expect(normalizeBlock([node1, node2, node3])).toEqual([node1, node2, node3])
expect(normalizeBlock([node1, [node2, [node3]]])).toEqual([ expect(normalizeBlock([node1, [node2, [node3]]])).toEqual([

View File

@ -11,6 +11,7 @@ import {
type AppUnmountFn, type AppUnmountFn,
type CreateAppFunction, type CreateAppFunction,
createAppAPI, createAppAPI,
flushOnAppMount,
normalizeContainer, normalizeContainer,
warn, warn,
} from '@vue/runtime-dom' } from '@vue/runtime-dom'
@ -23,6 +24,7 @@ const mountApp: AppMountFn<ParentNode> = (app, container) => {
if (container.nodeType === 1 /* Node.ELEMENT_NODE */) { if (container.nodeType === 1 /* Node.ELEMENT_NODE */) {
container.textContent = '' container.textContent = ''
} }
const instance = createComponent( const instance = createComponent(
app._component, app._component,
app._props as RawProps, app._props as RawProps,
@ -30,7 +32,10 @@ const mountApp: AppMountFn<ParentNode> = (app, container) => {
false, false,
app._context, app._context,
) )
mountComponent(instance, container) mountComponent(instance, container)
flushOnAppMount()
return instance return instance
} }

View File

@ -18,6 +18,7 @@ import {
nextUid, nextUid,
popWarningContext, popWarningContext,
pushWarningContext, pushWarningContext,
queuePostFlushCb,
registerHMR, registerHMR,
simpleSetCurrentInstance, simpleSetCurrentInstance,
startMeasure, startMeasure,
@ -453,10 +454,8 @@ export function mountComponent(
if (!instance.isMounted) { if (!instance.isMounted) {
if (instance.bm) invokeArrayFns(instance.bm) if (instance.bm) invokeArrayFns(instance.bm)
insert(instance.block, parent, anchor) insert(instance.block, parent, anchor)
// TODO queuePostFlushCb(() => { if (instance.m) queuePostFlushCb(() => invokeArrayFns(instance.m!))
if (instance.m) invokeArrayFns(instance.m)
instance.isMounted = true instance.isMounted = true
// })
} else { } else {
insert(instance.block, parent, anchor) insert(instance.block, parent, anchor)
} }
@ -479,10 +478,10 @@ export function unmountComponent(
unmountComponent(c) unmountComponent(c)
} }
if (parent) remove(instance.block, parent) if (parent) remove(instance.block, parent)
// TODO queuePostFlushCb(() => { if (instance.um) {
if (instance.um) invokeArrayFns(instance.um) queuePostFlushCb(() => invokeArrayFns(instance.um!))
}
instance.isUnmounted = true instance.isUnmounted = true
// })
} else if (parent) { } else if (parent) {
remove(instance.block, parent) remove(instance.block, parent)
} }