diff --git a/packages/runtime-vapor/__tests__/apiLifecycle.spec.ts b/packages/runtime-vapor/__tests__/apiLifecycle.spec.ts index dc31059f2..72d62c593 100644 --- a/packages/runtime-vapor/__tests__/apiLifecycle.spec.ts +++ b/packages/runtime-vapor/__tests__/apiLifecycle.spec.ts @@ -136,7 +136,7 @@ describe('api: lifecycle hooks', () => { it('onBeforeUnmount', async () => { const toggle = ref(true) const fn = vi.fn(() => { - expect(host.innerHTML).toBe('
') + expect(host.innerHTML).toBe('
') }) const { render, host } = define({ setup() { @@ -165,14 +165,14 @@ describe('api: lifecycle hooks', () => { toggle.value = false await nextTick() - // expect(fn).toHaveBeenCalledTimes(1) // FIXME: not called + expect(fn).toHaveBeenCalledTimes(1) expect(host.innerHTML).toBe('') }) it('onUnmounted', async () => { const toggle = ref(true) const fn = vi.fn(() => { - expect(host.innerHTML).toBe('
') + expect(host.innerHTML).toBe('') }) const { render, host } = define({ setup() { @@ -201,14 +201,14 @@ describe('api: lifecycle hooks', () => { toggle.value = false await nextTick() - // expect(fn).toHaveBeenCalledTimes(1) // FIXME: not called + expect(fn).toHaveBeenCalledTimes(1) expect(host.innerHTML).toBe('') }) it('onBeforeUnmount in onMounted', async () => { const toggle = ref(true) const fn = vi.fn(() => { - expect(host.innerHTML).toBe('
') + expect(host.innerHTML).toBe('
') }) const { render, host } = define({ setup() { @@ -239,29 +239,32 @@ describe('api: lifecycle hooks', () => { toggle.value = false await nextTick() - // expect(fn).toHaveBeenCalledTimes(1) // FIXME: not called + expect(fn).toHaveBeenCalledTimes(1) expect(host.innerHTML).toBe('') }) it('lifecycle call order', async () => { - const count = ref(0) + const rootCounter = ref(0) + const propsCounter = ref(0) const toggle = ref(true) const calls: string[] = [] const { render } = define({ setup() { - onBeforeMount(() => calls.push('onBeforeMount')) - onMounted(() => calls.push('onMounted')) - onBeforeUpdate(() => calls.push('onBeforeUpdate')) - onUpdated(() => calls.push('onUpdated')) - onBeforeUnmount(() => calls.push('onBeforeUnmount')) - onUnmounted(() => calls.push('onUnmounted')) + onBeforeMount(() => calls.push('root onBeforeMount')) + onMounted(() => calls.push('root onMounted')) + onBeforeUpdate(() => calls.push('root onBeforeUpdate')) + onUpdated(() => calls.push('root onUpdated')) + onBeforeUnmount(() => calls.push('root onBeforeUnmount')) + onUnmounted(() => calls.push('root onUnmounted')) return (() => { - const n0 = createIf( + const n0 = template('

')() + renderEffect(() => setText(n0, rootCounter.value)) + const n1 = createIf( () => toggle.value, - () => createComponent(Mid, { count: () => count.value }), + () => createComponent(Mid, { count: () => propsCounter.value }), ) - return n0 + return [n0, n1] })() }, }) @@ -303,42 +306,65 @@ describe('api: lifecycle hooks', () => { // mount render() expect(calls).toEqual([ - 'onBeforeMount', + 'root onBeforeMount', 'mid onBeforeMount', 'child onBeforeMount', 'child onMounted', 'mid onMounted', - 'onMounted', + 'root onMounted', ]) calls.length = 0 - // update - count.value++ + // props update + propsCounter.value++ + await nextTick() + // There are no calls in the root and mid, + // but maybe such performance would be better. + expect(calls).toEqual([ + // 'root onBeforeUpdate', + // 'mid onBeforeUpdate', + 'child onBeforeUpdate', + 'child onUpdated', + // 'mid onUpdated', + // 'root onUpdated', + ]) + + calls.length = 0 + + // root update + rootCounter.value++ await nextTick() - // FIXME: not called - // expect(calls).toEqual([ - // 'root onBeforeUpdate', - // 'mid onBeforeUpdate', - // 'child onBeforeUpdate', - // 'child onUpdated', - // 'mid onUpdated', - // 'root onUpdated', - // ]) + // Root update events should not be passed to children. + expect(calls).toEqual(['root onBeforeUpdate', 'root onUpdated']) calls.length = 0 // unmount toggle.value = false - // FIXME: not called - // expect(calls).toEqual([ - // 'root onBeforeUnmount', - // 'mid onBeforeUnmount', - // 'child onBeforeUnmount', - // 'child onUnmounted', - // 'mid onUnmounted', - // 'root onUnmounted', - // ]) + await nextTick() + expect(calls).toEqual([ + 'root onBeforeUpdate', + 'mid onBeforeUnmount', + 'child onBeforeUnmount', + 'child onUnmounted', + 'mid onUnmounted', + 'root onUpdated', + ]) + + calls.length = 0 + + // mount + toggle.value = true + await nextTick() + expect(calls).toEqual([ + 'root onBeforeUpdate', + 'mid onBeforeMount', + 'child onBeforeMount', + 'child onMounted', + 'mid onMounted', + 'root onUpdated', + ]) }) it('onRenderTracked', async () => { @@ -458,7 +484,7 @@ describe('api: lifecycle hooks', () => { expect(fn).toHaveBeenCalledTimes(2) toggle.value = false await nextTick() - // expect(fn).toHaveBeenCalledTimes(4) // FIXME: not called unmounted hook + expect(fn).toHaveBeenCalledTimes(4) }) // #136 diff --git a/packages/runtime-vapor/__tests__/component.spec.ts b/packages/runtime-vapor/__tests__/component.spec.ts index c166a8c73..64cbebe8e 100644 --- a/packages/runtime-vapor/__tests__/component.spec.ts +++ b/packages/runtime-vapor/__tests__/component.spec.ts @@ -1,4 +1,15 @@ -import { ref, setText, template, watchEffect } from '../src' +import { + type Directive, + createComponent, + createIf, + nextTick, + ref, + renderEffect, + setText, + template, + watchEffect, + withDirectives, +} from '../src' import { describe, expect } from 'vitest' import { makeRender } from './_utils' @@ -19,4 +30,129 @@ describe('component', () => { app.unmount() expect(host.innerHTML).toBe('') }) + + it('directive lifecycle hooks call order', async () => { + const rootCounter = ref(0) + const propsCounter = ref(0) + const toggle = ref(true) + const calls: string[] = [] + + const vDirective = (name: string): Directive => ({ + created: () => calls.push(`${name} created`), + beforeMount: () => calls.push(`${name} beforeMount`), + mounted: () => calls.push(`${name} mounted`), + beforeUpdate: () => calls.push(`${name} beforeUpdate`), + updated: () => calls.push(`${name} updated`), + beforeUnmount: () => calls.push(`${name} beforeUnmount`), + unmounted: () => calls.push(`${name} unmounted`), + }) + + const { render } = define({ + setup() { + return (() => { + const n0 = withDirectives(template('

')(), [ + [vDirective('root')], + ]) + renderEffect(() => setText(n0, rootCounter.value)) + const n1 = createIf( + () => toggle.value, + () => createComponent(Mid, { count: () => propsCounter.value }), + ) + return [n0, n1] + })() + }, + }) + + const Mid = { + props: ['count'], + setup(props: any) { + return (() => { + withDirectives(template('

')(), [[vDirective('mid')]]) + const n0 = createComponent(Child, { count: () => props.count }) + return n0 + })() + }, + } + + const Child = { + props: ['count'], + setup(props: any) { + return (() => { + const t0 = template('
') + const n0 = t0() + withDirectives(n0, [[vDirective('child')]]) + renderEffect(() => setText(n0, props.count)) + return n0 + })() + }, + } + + // mount + render() + expect(calls).toEqual([ + 'root created', + 'mid created', + 'child created', + 'root beforeMount', + 'mid beforeMount', + 'child beforeMount', + 'root mounted', + 'mid mounted', + 'child mounted', + ]) + + calls.length = 0 + + // props update + propsCounter.value++ + await nextTick() + // There are no calls in the root and mid, + // but maybe such performance would be better. + expect(calls).toEqual([ + // 'root beforeUpdate', + // 'mid beforeUpdate', + 'child beforeUpdate', + 'child updated', + // 'mid updated', + // 'root updated', + ]) + + calls.length = 0 + + // root update + rootCounter.value++ + await nextTick() + // Root update events should not be passed to children. + expect(calls).toEqual(['root beforeUpdate', 'root updated']) + + calls.length = 0 + + // unmount + toggle.value = false + await nextTick() + expect(calls).toEqual([ + 'root beforeUpdate', + 'mid beforeUnmount', + 'child beforeUnmount', + 'mid unmounted', + 'child unmounted', + 'root updated', + ]) + + calls.length = 0 + + // mount + toggle.value = true + await nextTick() + expect(calls).toEqual([ + 'root beforeUpdate', + 'mid created', + 'child created', + 'mid beforeMount', + 'child beforeMount', + 'mid mounted', + 'child mounted', + 'root updated', + ]) + }) }) diff --git a/packages/runtime-vapor/src/apiCreateComponent.ts b/packages/runtime-vapor/src/apiCreateComponent.ts index 15a37133f..622024f85 100644 --- a/packages/runtime-vapor/src/apiCreateComponent.ts +++ b/packages/runtime-vapor/src/apiCreateComponent.ts @@ -1,12 +1,13 @@ -import { - type Component, - createComponentInstance, - currentInstance, -} from './component' +import { type Component, createComponentInstance } from './component' import { setupComponent } from './apiRender' import type { RawProps } from './componentProps' import type { RawSlots } from './componentSlots' import { withAttrs } from './componentAttrs' +import { getCurrentScope } from '@vue/reactivity' +import type { BlockEffectScope } from './blockEffectScope' +import { setDirectiveBinding } from './directives' +import { VaporLifecycleHooks } from './enums' +import { scheduleLifecycleHooks } from './componentLifecycle' export function createComponent( comp: Component, @@ -15,7 +16,7 @@ export function createComponent( singleRoot: boolean = false, once: boolean = false, ) { - const current = currentInstance! + const parentScope = getCurrentScope() as BlockEffectScope const instance = createComponentInstance( comp, singleRoot ? withAttrs(rawProps) : rawProps, @@ -24,8 +25,39 @@ export function createComponent( ) setupComponent(instance, singleRoot) - // register sub-component with current component for lifecycle management - current.comps.add(instance) + setDirectiveBinding( + instance, + instance, + { + beforeMount: scheduleLifecycleHooks( + instance, + VaporLifecycleHooks.BEFORE_MOUNT, + 'beforeMount', + ), + mounted: scheduleLifecycleHooks( + instance, + VaporLifecycleHooks.MOUNTED, + 'mounted', + () => (instance.isMounted = true), + true, + ), + beforeUnmount: scheduleLifecycleHooks( + instance, + VaporLifecycleHooks.BEFORE_UNMOUNT, + 'beforeUnmount', + ), + unmounted: scheduleLifecycleHooks( + instance, + VaporLifecycleHooks.UNMOUNTED, + 'unmounted', + () => (instance.isUnmounted = true), + true, + ), + }, + null, + undefined, + parentScope, + ) return instance } diff --git a/packages/runtime-vapor/src/apiCreateFor.ts b/packages/runtime-vapor/src/apiCreateFor.ts index 07bbd151f..f405c7593 100644 --- a/packages/runtime-vapor/src/apiCreateFor.ts +++ b/packages/runtime-vapor/src/apiCreateFor.ts @@ -18,7 +18,7 @@ import { type Block, type Fragment, fragmentKey } from './apiRender' import { warn } from './warning' import { currentInstance } from './component' import { componentKey } from './component' -import { BlockEffectScope, isRenderEffectScope } from './blockEffectScope' +import { BlockEffectScope, isBlockEffectScope } from './blockEffectScope' import { createChildFragmentDirectives, invokeWithMount, @@ -62,7 +62,7 @@ export const createFor = ( } const instance = currentInstance! - if (__DEV__ && (!instance || !isRenderEffectScope(parentScope))) { + if (__DEV__ && (!instance || !isBlockEffectScope(parentScope))) { warn('createFor() can only be used inside setup()') } diff --git a/packages/runtime-vapor/src/apiCreateIf.ts b/packages/runtime-vapor/src/apiCreateIf.ts index dce38bae9..1034f46a1 100644 --- a/packages/runtime-vapor/src/apiCreateIf.ts +++ b/packages/runtime-vapor/src/apiCreateIf.ts @@ -3,7 +3,7 @@ import { getCurrentScope } from '@vue/reactivity' import { createComment, createTextNode, insert, remove } from './dom/element' import { currentInstance } from './component' import { warn } from './warning' -import { BlockEffectScope, isRenderEffectScope } from './blockEffectScope' +import { BlockEffectScope, isBlockEffectScope } from './blockEffectScope' import { createChildFragmentDirectives, invokeWithMount, @@ -36,7 +36,7 @@ export const createIf = ( } const instance = currentInstance! - if (__DEV__ && (!instance || !isRenderEffectScope(parentScope))) { + if (__DEV__ && (!instance || !isBlockEffectScope(parentScope))) { warn('createIf() can only be used inside setup()') } diff --git a/packages/runtime-vapor/src/apiLifecycle.ts b/packages/runtime-vapor/src/apiLifecycle.ts index d3c69b58f..1ea749f6d 100644 --- a/packages/runtime-vapor/src/apiLifecycle.ts +++ b/packages/runtime-vapor/src/apiLifecycle.ts @@ -24,9 +24,6 @@ const injectHook = ( const wrappedHook = hook.__weh || (hook.__weh = (...args: unknown[]) => { - if (target.isUnmounted) { - return - } pauseTracking() const reset = setCurrentInstance(target) const res = target.scope.run(() => diff --git a/packages/runtime-vapor/src/apiRender.ts b/packages/runtime-vapor/src/apiRender.ts index 086f18d63..252d16fca 100644 --- a/packages/runtime-vapor/src/apiRender.ts +++ b/packages/runtime-vapor/src/apiRender.ts @@ -6,7 +6,7 @@ import { validateComponentName, } from './component' import { insert, querySelector, remove } from './dom/element' -import { flushPostFlushCbs, queuePostFlushCb } from './scheduler' +import { flushPostFlushCbs } from './scheduler' import { invokeLifecycle } from './componentLifecycle' import { VaporLifecycleHooks } from './enums' import { @@ -137,7 +137,7 @@ function mountComponent( instance, VaporLifecycleHooks.MOUNTED, 'mounted', - instance => (instance.isMounted = true), + () => (instance.isMounted = true), true, ) @@ -166,7 +166,7 @@ export function unmountComponent(instance: ComponentInternalInstance) { instance, VaporLifecycleHooks.UNMOUNTED, 'unmounted', - instance => queuePostFlushCb(() => (instance.isUnmounted = true)), + () => (instance.isUnmounted = true), true, ) flushPostFlushCbs() diff --git a/packages/runtime-vapor/src/blockEffectScope.ts b/packages/runtime-vapor/src/blockEffectScope.ts index 92fc01983..c7a8961d5 100644 --- a/packages/runtime-vapor/src/blockEffectScope.ts +++ b/packages/runtime-vapor/src/blockEffectScope.ts @@ -29,7 +29,7 @@ export class BlockEffectScope extends EffectScope { } } -export function isRenderEffectScope( +export function isBlockEffectScope( scope: EffectScope | undefined, ): scope is BlockEffectScope { return scope instanceof BlockEffectScope diff --git a/packages/runtime-vapor/src/component.ts b/packages/runtime-vapor/src/component.ts index 4c4b02a52..256c98eae 100644 --- a/packages/runtime-vapor/src/component.ts +++ b/packages/runtime-vapor/src/component.ts @@ -162,7 +162,6 @@ export interface ComponentInternalInstance { provides: Data scope: BlockEffectScope - comps: Set rawProps: NormalizedRawProps propsOptions: NormalizedPropsOptions @@ -286,7 +285,6 @@ export function createComponentInstance( scope: null!, provides: parent ? parent.provides : Object.create(_appContext.provides), type: component, - comps: new Set(), // resolved props and emits options rawProps: null!, // set later diff --git a/packages/runtime-vapor/src/componentLifecycle.ts b/packages/runtime-vapor/src/componentLifecycle.ts index 2bff5a0f6..715a4a997 100644 --- a/packages/runtime-vapor/src/componentLifecycle.ts +++ b/packages/runtime-vapor/src/componentLifecycle.ts @@ -1,36 +1,43 @@ -import { invokeArrayFns } from '@vue/shared' +import { NOOP, invokeArrayFns } from '@vue/shared' import type { VaporLifecycleHooks } from './enums' import { type ComponentInternalInstance, setCurrentInstance } from './component' -import { queuePostFlushCb } from './scheduler' import { type DirectiveHookName, invokeDirectiveHook } from './directives' +import { queuePostFlushCb } from './scheduler' export function invokeLifecycle( instance: ComponentInternalInstance, lifecycle: VaporLifecycleHooks, directive: DirectiveHookName, - cb?: (instance: ComponentInternalInstance) => void, + cb?: () => void, post?: boolean, ) { - invokeArrayFns(post ? [invokeSub, invokeCurrent] : [invokeCurrent, invokeSub]) + const fn = scheduleLifecycleHooks(instance, lifecycle, directive, cb, post) + return post ? queuePostFlushCb(fn) : fn() +} - function invokeCurrent() { - cb && cb(instance) - const hooks = instance[lifecycle] - if (hooks) { - const fn = () => { - const reset = setCurrentInstance(instance) - instance.scope.run(() => invokeArrayFns(hooks)) - reset() - } - post ? queuePostFlushCb(fn) : fn() - } +export function scheduleLifecycleHooks( + instance: ComponentInternalInstance, + lifecycle: VaporLifecycleHooks, + directive: DirectiveHookName, + cb = NOOP, + reverse?: boolean, +) { + const hooks = reverse + ? [cb, callDirHooks, callLifecycleHooks] + : [callLifecycleHooks, callDirHooks, cb] + + return () => invokeArrayFns(hooks) + function callDirHooks() { invokeDirectiveHook(instance, directive, instance.scope) } - - function invokeSub() { - instance.comps.forEach(comp => - invokeLifecycle(comp, lifecycle, directive, cb, post), - ) + function callLifecycleHooks() { + // lifecycle hooks may be mounted halfway. + const lifecycleHooks = instance[lifecycle] + if (lifecycleHooks && lifecycleHooks.length) { + const reset = setCurrentInstance(instance) + instance.scope.run(() => invokeArrayFns(lifecycleHooks)) + reset() + } } } diff --git a/packages/runtime-vapor/src/directives.ts b/packages/runtime-vapor/src/directives.ts index 30ea1012f..ff6fa27ef 100644 --- a/packages/runtime-vapor/src/directives.ts +++ b/packages/runtime-vapor/src/directives.ts @@ -21,7 +21,7 @@ import { } from './errorHandling' import { queueJob, queuePostFlushCb } from './scheduler' import { warn } from './warning' -import { type BlockEffectScope, isRenderEffectScope } from './blockEffectScope' +import { type BlockEffectScope, isBlockEffectScope } from './blockEffectScope' import { normalizeBlock } from './dom/element' export type DirectiveModifiers = Record @@ -112,8 +112,8 @@ export function withDirectives( const instance = currentInstance! const parentScope = getCurrentScope() as BlockEffectScope - if (__DEV__ && !isRenderEffectScope(parentScope)) { - warn(`Directives should be used inside of RenderEffectScope.`) + if (__DEV__ && !isBlockEffectScope(parentScope)) { + warn(`Directives should be used inside of BlockEffectScope.`) } const directivesMap = (parentScope.dirs ||= new Map()) @@ -264,3 +264,20 @@ export function createRenderingUpdateTrigger( } } } + +/** For internal use, set directive binding */ +export function setDirectiveBinding( + instance: ComponentInternalInstance | null, + anchor: Node | ComponentInternalInstance, + dir: Directive, + value: any = null, + oldValue: any = undefined, + parentScope = getCurrentScope() as BlockEffectScope, +) { + if (__DEV__ && !isBlockEffectScope(parentScope)) { + warn('directive binding can only be added to BlockEffectScope') + } + + const directiveBindingsMap = (parentScope.dirs ||= new Map()) + directiveBindingsMap.set(anchor, [{ dir, instance, value, oldValue }]) +} diff --git a/packages/runtime-vapor/src/directivesChildFragment.ts b/packages/runtime-vapor/src/directivesChildFragment.ts index ff2f6e72f..75a29b807 100644 --- a/packages/runtime-vapor/src/directivesChildFragment.ts +++ b/packages/runtime-vapor/src/directivesChildFragment.ts @@ -1,12 +1,12 @@ -import { ReactiveEffect, getCurrentScope } from '@vue/reactivity' +import { ReactiveEffect } from '@vue/reactivity' import { - type Directive, type DirectiveHookName, createRenderingUpdateTrigger, invokeDirectiveHook, + setDirectiveBinding, } from './directives' import { warn } from './warning' -import { type BlockEffectScope, isRenderEffectScope } from './blockEffectScope' +import { type BlockEffectScope, isBlockEffectScope } from './blockEffectScope' import { currentInstance } from './component' import { VaporErrorCodes, callWithErrorHandling } from './errorHandling' import { queueJob, queuePostFlushCb } from './scheduler' @@ -25,14 +25,8 @@ export function createChildFragmentDirectives( ) { let isTriggered = false const instance = currentInstance! - const parentScope = getCurrentScope() as BlockEffectScope - if (__DEV__) { - if (!isRenderEffectScope(parentScope)) { - warn('child directives can only be added to a render effect scope') - } - if (!instance) { - warn('child directives can only be added in a component') - } + if (__DEV__ && !instance) { + warn('child directives can only be added in a component') } const callSourceWithErrorHandling = () => @@ -43,22 +37,13 @@ export function createChildFragmentDirectives( return } - const directiveBindingsMap = (parentScope.dirs ||= new Map()) - const dir: Directive = { + setDirectiveBinding(instance, anchor, { beforeUpdate: onDirectiveBeforeUpdate, beforeMount: () => invokeChildrenDirectives('beforeMount'), mounted: () => invokeChildrenDirectives('mounted'), beforeUnmount: () => invokeChildrenDirectives('beforeUnmount'), unmounted: () => invokeChildrenDirectives('unmounted'), - } - directiveBindingsMap.set(anchor, [ - { - dir, - instance, - value: null, - oldValue: undefined, - }, - ]) + }) const effect = new ReactiveEffect(callSourceWithErrorHandling) const triggerRenderingUpdate = createRenderingUpdateTrigger(instance, effect) @@ -93,7 +78,7 @@ export function createChildFragmentDirectives( } export function invokeWithMount(scope: BlockEffectScope, handler?: () => any) { - if (isRenderEffectScope(scope.parent) && !scope.parent.im) { + if (isBlockEffectScope(scope.parent) && !scope.parent.im) { return handler && handler() } return invokeWithDirsHooks(scope, 'mount', handler)