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,
} from './componentProps'
export { baseEmit, isEmitListener } from './componentEmits'
export { type SchedulerJob, queueJob } from './scheduler'
export { type SchedulerJob, queueJob, flushOnAppMount } from './scheduler'
export {
type ComponentInternalOptions,
type GenericComponentInstance,

View File

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

View File

@ -1,80 +1,330 @@
import type { Ref } from '@vue/reactivity'
import {
EffectScope,
currentInstance,
effectScope,
nextTick,
onWatcherCleanup,
onMounted,
onUpdated,
ref,
watch,
watchEffect,
watchSyncEffect,
} from '../src'
} from '@vue/runtime-dom'
import { createComponent, defineVaporComponent, renderEffect } from '../src'
import { makeRender } from './_utils'
import type { VaporComponentInstance } from '../src/component'
describe.todo('watchEffect and onWatcherCleanup', () => {
test('basic', async () => {
let dummy = 0
let source: Ref<number>
const scope = new EffectScope()
const define = makeRender()
scope.run(() => {
source = ref(0)
watchEffect(onCleanup => {
source.value
onCleanup(() => (dummy += 2))
onWatcherCleanup(() => (dummy += 3))
onWatcherCleanup(() => (dummy += 5))
// only need to port test cases related to in-component usage
describe('apiWatch', () => {
// #7030
it.todo(
// need if support
'should not fire on child component unmount w/ flush: pre',
async () => {
const visible = ref(true)
const cb = vi.fn()
const Parent = defineVaporComponent({
props: ['visible'],
setup() {
// @ts-expect-error
return visible.value ? h(Comp) : null
},
})
})
await nextTick()
expect(dummy).toBe(0)
const Comp = {
setup() {
watch(visible, cb, { flush: 'pre' })
return []
},
}
define(Parent).render({
visible: () => visible.value,
})
expect(cb).not.toHaveBeenCalled()
visible.value = false
await nextTick()
expect(cb).not.toHaveBeenCalled()
},
)
scope.run(() => {
source.value++
})
await nextTick()
expect(dummy).toBe(10)
// #7030
it('flush: pre watcher in child component should not fire before parent update', async () => {
const b = ref(0)
const calls: string[] = []
scope.run(() => {
source.value++
})
await nextTick()
expect(dummy).toBe(20)
const Comp = {
setup() {
watch(
() => b.value,
val => {
calls.push('watcher child')
},
{ flush: 'pre' },
)
renderEffect(() => {
b.value
calls.push('render child')
})
return []
},
}
scope.stop()
const Parent = {
props: ['a'],
setup() {
watch(
() => b.value,
val => {
calls.push('watcher parent')
},
{ flush: 'pre' },
)
renderEffect(() => {
b.value
calls.push('render parent')
})
return createComponent(Comp)
},
}
define(Parent).render({
a: () => b.value,
})
expect(calls).toEqual(['render parent', 'render child'])
b.value++
await nextTick()
expect(dummy).toBe(30)
expect(calls).toEqual([
'render parent',
'render child',
'watcher parent',
'render parent',
'watcher child',
'render child',
])
})
test('nested call to watchEffect', async () => {
let dummy = 0
let source: Ref<number>
let double: Ref<number>
const scope = new EffectScope()
// #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[] = []
scope.run(() => {
source = ref(0)
double = ref(0)
watchEffect(() => {
double.value = source.value * 2
onWatcherCleanup(() => (dummy += 2))
})
watchSyncEffect(() => {
double.value
onWatcherCleanup(() => (dummy += 3))
})
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,
})
await nextTick()
expect(dummy).toBe(0)
scope.run(() => source.value++)
await nextTick()
expect(dummy).toBe(5)
expect(calls).toEqual(['render'])
scope.run(() => source.value++)
// both props are updated
// should trigger pre-flush watcher first and only once
// then trigger child render
a.value++
b.value++
await nextTick()
expect(dummy).toBe(10)
expect(calls).toEqual(['render', 'watcher 1', 'watcher 2', 'render'])
})
scope.stop()
// #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(dummy).toBe(15)
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(() => {
trigger.value
countWE++
})
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()
// both watchers run while component is mounted
expect(countWE).toBe(2)
expect(countW).toBe(1)
app.unmount()
await nextTick()
trigger.value++
await nextTick()
// both watchers run again event though component has been unmounted
expect(countWE).toBe(3)
expect(countW).toBe(2)
})
})

View File

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

View File

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

View File

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