diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index d6a56d587..f9285005c 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -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, diff --git a/packages/runtime-core/src/renderer.ts b/packages/runtime-core/src/renderer.ts index a21476de2..0bedefd5d 100644 --- a/packages/runtime-core/src/renderer.ts +++ b/packages/runtime-core/src/renderer.ts @@ -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, ), } diff --git a/packages/runtime-core/src/scheduler.ts b/packages/runtime-core/src/scheduler.ts index d657f56e5..a407df467 100644 --- a/packages/runtime-core/src/scheduler.ts +++ b/packages/runtime-core/src/scheduler.ts @@ -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 diff --git a/packages/runtime-vapor/__tests__/apiWatch.spec.ts b/packages/runtime-vapor/__tests__/apiWatch.spec.ts index 0ed45e46a..c76ce2f2c 100644 --- a/packages/runtime-vapor/__tests__/apiWatch.spec.ts +++ b/packages/runtime-vapor/__tests__/apiWatch.spec.ts @@ -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 - 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 - let double: Ref - 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(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) }) }) diff --git a/packages/runtime-vapor/__tests__/block.spec.ts b/packages/runtime-vapor/__tests__/block.spec.ts index ddd4035f6..306a12810 100644 --- a/packages/runtime-vapor/__tests__/block.spec.ts +++ b/packages/runtime-vapor/__tests__/block.spec.ts @@ -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([ diff --git a/packages/runtime-vapor/src/apiCreateApp.ts b/packages/runtime-vapor/src/apiCreateApp.ts index 74910af36..f88db3d32 100644 --- a/packages/runtime-vapor/src/apiCreateApp.ts +++ b/packages/runtime-vapor/src/apiCreateApp.ts @@ -11,6 +11,7 @@ import { type AppUnmountFn, type CreateAppFunction, createAppAPI, + flushOnAppMount, normalizeContainer, warn, } from '@vue/runtime-dom' @@ -23,6 +24,7 @@ const mountApp: AppMountFn = (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 = (app, container) => { false, app._context, ) + mountComponent(instance, container) + flushOnAppMount() + return instance } diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index a51d0d692..2e0a98171 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -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) }