Просмотр исходного кода

feat: directive lifecycle hooks in `v-for`, `v-if` and component (#123)

Co-authored-by: 三咲智子 Kevin Deng <sxzz@sxzz.moe>
Rizumu Ayaka 2 лет назад
Родитель
Сommit
b5ecb72864

+ 7 - 7
packages/reactivity/src/effectScope.ts

@@ -34,13 +34,13 @@ export class EffectScope {
    */
   private index: number | undefined
 
-  constructor(public detached = false) {
-    this.parent = activeEffectScope
-    if (!detached && activeEffectScope) {
-      this.index =
-        (activeEffectScope.scopes || (activeEffectScope.scopes = [])).push(
-          this,
-        ) - 1
+  constructor(
+    public detached = false,
+    parent = activeEffectScope,
+  ) {
+    this.parent = parent
+    if (!detached && parent) {
+      this.index = (parent.scopes || (parent.scopes = [])).push(this) - 1
     }
   }
 

+ 1 - 1
packages/runtime-vapor/__tests__/directives/vShow.spec.ts

@@ -77,7 +77,7 @@ describe('directive: v-show', () => {
     }).render()
 
     expect(host.innerHTML).toBe('<button>toggle</button><div>child</div>')
-    expect(instance.dirs.get(n0)![0].dir).toBe(vShow)
+    expect(instance.scope.dirs!.get(n0)![0].dir).toBe(vShow)
 
     const btn = host.querySelector('button')
     btn?.click()

+ 100 - 1
packages/runtime-vapor/__tests__/for.spec.ts

@@ -1,5 +1,16 @@
-import { createFor, nextTick, ref, renderEffect } from '../src'
+import { NOOP } from '@vue/shared'
+import {
+  type Directive,
+  children,
+  createFor,
+  nextTick,
+  ref,
+  renderEffect,
+  template,
+  withDirectives,
+} from '../src'
 import { makeRender } from './_utils'
+import { unmountComponent } from '../src/apiRender'
 
 const define = makeRender()
 
@@ -184,4 +195,92 @@ describe('createFor', () => {
     await nextTick()
     expect(host.innerHTML).toBe('<!--for-->')
   })
+
+  test('should work with directive hooks', async () => {
+    const calls: string[] = []
+    const list = ref([0])
+    const update = ref(0)
+    const add = () => list.value.push(list.value.length)
+    const spySrcFn = vi.fn(() => list.value)
+
+    const vDirective: Directive = {
+      created: (el, { value }) => calls.push(`${value} created`),
+      beforeMount: (el, { value }) => calls.push(`${value} beforeMount`),
+      mounted: (el, { value }) => calls.push(`${value} mounted`),
+      beforeUpdate: (el, { value }) => calls.push(`${value} beforeUpdate`),
+      updated: (el, { value }) => calls.push(`${value} updated`),
+      beforeUnmount: (el, { value }) => calls.push(`${value} beforeUnmount`),
+      unmounted: (el, { value }) => calls.push(`${value} unmounted`),
+    }
+
+    const t0 = template('<p></p>')
+    const { instance } = define(() => {
+      const n1 = createFor(spySrcFn, block => {
+        const n2 = t0()
+        const n3 = children(n2, 0)
+        withDirectives(n3, [[vDirective, () => block.s[0]]])
+        return [n2, NOOP]
+      })
+      renderEffect(() => update.value)
+      return [n1]
+    }).render()
+
+    await nextTick()
+    // `${item index} ${hook name}`
+    expect(calls).toEqual(['0 created', '0 beforeMount', '0 mounted'])
+    calls.length = 0
+    expect(spySrcFn).toHaveBeenCalledTimes(1)
+
+    add()
+    await nextTick()
+    expect(calls).toEqual([
+      '0 beforeUpdate',
+      '1 created',
+      '1 beforeMount',
+      '0 updated',
+      '1 mounted',
+    ])
+    calls.length = 0
+    expect(spySrcFn).toHaveBeenCalledTimes(2)
+
+    list.value.reverse()
+    await nextTick()
+    expect(calls).toEqual([
+      '1 beforeUpdate',
+      '0 beforeUpdate',
+      '1 updated',
+      '0 updated',
+    ])
+    expect(spySrcFn).toHaveBeenCalledTimes(3)
+    list.value.reverse()
+    await nextTick()
+    calls.length = 0
+    expect(spySrcFn).toHaveBeenCalledTimes(4)
+
+    update.value++
+    await nextTick()
+    expect(calls).toEqual([
+      '0 beforeUpdate',
+      '1 beforeUpdate',
+      '0 updated',
+      '1 updated',
+    ])
+    calls.length = 0
+    expect(spySrcFn).toHaveBeenCalledTimes(4)
+
+    list.value.pop()
+    await nextTick()
+    expect(calls).toEqual([
+      '0 beforeUpdate',
+      '1 beforeUnmount',
+      '0 updated',
+      '1 unmounted',
+    ])
+    calls.length = 0
+    expect(spySrcFn).toHaveBeenCalledTimes(5)
+
+    unmountComponent(instance)
+    expect(calls).toEqual(['0 beforeUnmount', '0 unmounted'])
+    expect(spySrcFn).toHaveBeenCalledTimes(5)
+  })
 })

+ 119 - 1
packages/runtime-vapor/__tests__/if.spec.ts

@@ -1,4 +1,5 @@
 import {
+  children,
   createIf,
   insert,
   nextTick,
@@ -6,9 +7,11 @@ import {
   renderEffect,
   setText,
   template,
+  withDirectives,
 } from '../src'
 import type { Mock } from 'vitest'
 import { makeRender } from './_utils'
+import { unmountComponent } from '../src/apiRender'
 
 const define = makeRender()
 
@@ -24,6 +27,8 @@ describe('createIf', () => {
     let spyElseFn: Mock<any, any>
     const count = ref(0)
 
+    const spyConditionFn = vi.fn(() => count.value)
+
     // templates can be reused through caching.
     const t0 = template('<div></div>')
     const t1 = template('<p></p>')
@@ -34,7 +39,7 @@ describe('createIf', () => {
 
       insert(
         createIf(
-          () => count.value,
+          spyConditionFn,
           // v-if
           (spyIfFn ||= vi.fn(() => {
             const n2 = t1()
@@ -55,24 +60,28 @@ describe('createIf', () => {
     }).render()
 
     expect(host.innerHTML).toBe('<div><p>zero</p><!--if--></div>')
+    expect(spyConditionFn).toHaveBeenCalledTimes(1)
     expect(spyIfFn!).toHaveBeenCalledTimes(0)
     expect(spyElseFn!).toHaveBeenCalledTimes(1)
 
     count.value++
     await nextTick()
     expect(host.innerHTML).toBe('<div><p>1</p><!--if--></div>')
+    expect(spyConditionFn).toHaveBeenCalledTimes(2)
     expect(spyIfFn!).toHaveBeenCalledTimes(1)
     expect(spyElseFn!).toHaveBeenCalledTimes(1)
 
     count.value++
     await nextTick()
     expect(host.innerHTML).toBe('<div><p>2</p><!--if--></div>')
+    expect(spyConditionFn).toHaveBeenCalledTimes(3)
     expect(spyIfFn!).toHaveBeenCalledTimes(1)
     expect(spyElseFn!).toHaveBeenCalledTimes(1)
 
     count.value = 0
     await nextTick()
     expect(host.innerHTML).toBe('<div><p>zero</p><!--if--></div>')
+    expect(spyConditionFn).toHaveBeenCalledTimes(4)
     expect(spyIfFn!).toHaveBeenCalledTimes(1)
     expect(spyElseFn!).toHaveBeenCalledTimes(2)
   })
@@ -124,4 +133,113 @@ describe('createIf', () => {
     await nextTick()
     expect(host.innerHTML).toBe('<!--if-->')
   })
+
+  test('should work with directive hooks', async () => {
+    const calls: string[] = []
+    const show1 = ref(true)
+    const show2 = ref(true)
+    const update = ref(0)
+
+    const spyConditionFn1 = vi.fn(() => show1.value)
+    const spyConditionFn2 = vi.fn(() => show2.value)
+
+    const vDirective: any = {
+      created: (el: any, { value }: any) => calls.push(`${value} created`),
+      beforeMount: (el: any, { value }: any) =>
+        calls.push(`${value} beforeMount`),
+      mounted: (el: any, { value }: any) => calls.push(`${value} mounted`),
+      beforeUpdate: (el: any, { value }: any) =>
+        calls.push(`${value} beforeUpdate`),
+      updated: (el: any, { value }: any) => calls.push(`${value} updated`),
+      beforeUnmount: (el: any, { value }: any) =>
+        calls.push(`${value} beforeUnmount`),
+      unmounted: (el: any, { value }: any) => calls.push(`${value} unmounted`),
+    }
+
+    const t0 = template('<p></p>')
+    const { instance } = define(() => {
+      const n1 = createIf(
+        spyConditionFn1,
+        () => {
+          const n2 = t0()
+          withDirectives(children(n2, 0), [
+            [vDirective, () => (update.value, '1')],
+          ])
+          return n2
+        },
+        () =>
+          createIf(
+            spyConditionFn2,
+            () => {
+              const n2 = t0()
+              withDirectives(children(n2, 0), [[vDirective, () => '2']])
+              return n2
+            },
+            () => {
+              const n2 = t0()
+              withDirectives(children(n2, 0), [[vDirective, () => '3']])
+              return n2
+            },
+          ),
+      )
+      return [n1]
+    }).render()
+
+    await nextTick()
+    expect(calls).toEqual(['1 created', '1 beforeMount', '1 mounted'])
+    calls.length = 0
+    expect(spyConditionFn1).toHaveBeenCalledTimes(1)
+    expect(spyConditionFn2).toHaveBeenCalledTimes(0)
+
+    show1.value = false
+    await nextTick()
+    expect(calls).toEqual([
+      '1 beforeUnmount',
+      '2 created',
+      '2 beforeMount',
+      '1 unmounted',
+      '2 mounted',
+    ])
+    calls.length = 0
+    expect(spyConditionFn1).toHaveBeenCalledTimes(2)
+    expect(spyConditionFn2).toHaveBeenCalledTimes(1)
+
+    show2.value = false
+    await nextTick()
+    expect(calls).toEqual([
+      '2 beforeUnmount',
+      '3 created',
+      '3 beforeMount',
+      '2 unmounted',
+      '3 mounted',
+    ])
+    calls.length = 0
+    expect(spyConditionFn1).toHaveBeenCalledTimes(2)
+    expect(spyConditionFn2).toHaveBeenCalledTimes(2)
+
+    show1.value = true
+    await nextTick()
+    expect(calls).toEqual([
+      '3 beforeUnmount',
+      '1 created',
+      '1 beforeMount',
+      '3 unmounted',
+      '1 mounted',
+    ])
+    calls.length = 0
+    expect(spyConditionFn1).toHaveBeenCalledTimes(3)
+    expect(spyConditionFn2).toHaveBeenCalledTimes(2)
+
+    update.value++
+    await nextTick()
+    expect(calls).toEqual(['1 beforeUpdate', '1 updated'])
+    calls.length = 0
+    expect(spyConditionFn1).toHaveBeenCalledTimes(3)
+    expect(spyConditionFn2).toHaveBeenCalledTimes(2)
+
+    unmountComponent(instance)
+    expect(calls).toEqual(['1 beforeUnmount', '1 unmounted'])
+    expect(spyConditionFn1).toHaveBeenCalledTimes(3)
+    expect(spyConditionFn2).toHaveBeenCalledTimes(2)
+  })
 })

+ 65 - 23
packages/runtime-vapor/src/apiCreateFor.ts

@@ -1,14 +1,26 @@
-import { type EffectScope, effectScope, isReactive } from '@vue/reactivity'
+import { getCurrentScope, isReactive, traverse } from '@vue/reactivity'
 import { isArray, isObject, isString } from '@vue/shared'
-import { createComment, createTextNode, insert, remove } from './dom/element'
-import { renderEffect } from './renderEffect'
+import {
+  createComment,
+  createTextNode,
+  insert,
+  remove as removeBlock,
+} from './dom/element'
 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 {
+  createChildFragmentDirectives,
+  invokeWithMount,
+  invokeWithUnmount,
+  invokeWithUpdate,
+} from './directivesChildFragment'
 import type { DynamicSlot } from './componentSlots'
 
 interface ForBlock extends Fragment {
-  scope: EffectScope
+  scope: BlockEffectScope
   /** state, use short key since it's used a lot in generated code */
   s: [item: any, key: any, index?: number]
   update: () => void
@@ -16,9 +28,11 @@ interface ForBlock extends Fragment {
   memo: any[] | undefined
 }
 
+type Source = any[] | Record<any, any> | number | Set<any> | Map<any, any>
+
 /*! #__NO_SIDE_EFFECTS__ */
 export const createFor = (
-  src: () => any[] | Record<any, any> | number | Set<any> | Map<any, any>,
+  src: () => Source,
   renderItem: (block: ForBlock) => [Block, () => void],
   getKey?: (item: any, key: any, index?: number) => any,
   getMemo?: (item: any, key: any, index?: number) => any[],
@@ -29,18 +43,34 @@ export const createFor = (
   let oldBlocks: ForBlock[] = []
   let newBlocks: ForBlock[]
   let parent: ParentNode | undefined | null
+  const update = getMemo ? updateWithMemo : updateWithoutMemo
+  const parentScope = getCurrentScope()!
   const parentAnchor = __DEV__ ? createComment('for') : createTextNode()
   const ref: Fragment = {
     nodes: oldBlocks,
     [fragmentKey]: true,
   }
-  const update = getMemo ? updateWithMemo : updateWithoutMemo
-  once ? renderList() : renderEffect(renderList)
+
+  const instance = currentInstance!
+  if (__DEV__ && (!instance || !isRenderEffectScope(parentScope))) {
+    warn('createFor() can only be used inside setup()')
+  }
+
+  createChildFragmentDirectives(
+    parentAnchor,
+    () => oldBlocks.map(b => b.scope),
+    // source getter
+    () => traverse(src(), 1),
+    // init cb
+    getValue => doFor(getValue()),
+    // effect cb
+    getValue => doFor(getValue()),
+    once,
+  )
 
   return ref
 
-  function renderList() {
-    const source = src()
+  function doFor(source: any) {
     const newLength = getLength(source)
     const oldLength = oldBlocks.length
     newBlocks = new Array(newLength)
@@ -225,7 +255,8 @@ export const createFor = (
     idx: number,
     anchor: Node = parentAnchor,
   ): ForBlock {
-    const scope = effectScope()
+    const scope = new BlockEffectScope(instance, parentScope)
+
     const [item, key, index] = getItem(source, idx)
     const block: ForBlock = (newBlocks[idx] = {
       nodes: null!, // set later
@@ -239,8 +270,12 @@ export const createFor = (
     const res = scope.run(() => renderItem(block))!
     block.nodes = res[0]
     block.update = res[1]
-    if (getMemo) block.update()
-    if (parent) insert(block.nodes, parent, anchor)
+
+    invokeWithMount(scope, () => {
+      if (getMemo) block.update()
+      if (parent) insert(block.nodes, parent, anchor)
+    })
+
     return block
   }
 
@@ -275,10 +310,13 @@ export const createFor = (
         }
       }
     }
-    if (needsUpdate) {
-      block.s = [newItem, newKey, newIndex]
-      block.update()
-    }
+
+    block.s = [newItem, newKey, newIndex]
+    invokeWithUpdate(block.scope, () => {
+      if (needsUpdate) {
+        block.update()
+      }
+    })
   }
 
   function updateWithoutMemo(
@@ -287,20 +325,24 @@ export const createFor = (
     newKey = block.s[1],
     newIndex = block.s[2],
   ) {
-    if (
+    let needsUpdate =
       newItem !== block.s[0] ||
       newKey !== block.s[1] ||
       newIndex !== block.s[2] ||
       !isReactive(newItem)
-    ) {
-      block.s = [newItem, newKey, newIndex]
-      block.update()
-    }
+
+    block.s = [newItem, newKey, newIndex]
+    invokeWithUpdate(block.scope, () => {
+      if (needsUpdate) {
+        block.update()
+      }
+    })
   }
 
   function unmount({ nodes, scope }: ForBlock) {
-    remove(nodes, parent!)
-    scope.stop()
+    invokeWithUnmount(scope, () => {
+      removeBlock(nodes, parent!)
+    })
   }
 }
 

+ 51 - 25
packages/runtime-vapor/src/apiCreateIf.ts

@@ -1,7 +1,15 @@
-import { renderEffect } from './renderEffect'
 import { type Block, type Fragment, fragmentKey } from './apiRender'
-import { type EffectScope, effectScope } from '@vue/reactivity'
+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 {
+  createChildFragmentDirectives,
+  invokeWithMount,
+  invokeWithUnmount,
+  invokeWithUpdate,
+} from './directivesChildFragment'
 
 type BlockFn = () => Block
 
@@ -18,7 +26,8 @@ export const createIf = (
   let branch: BlockFn | undefined
   let parent: ParentNode | undefined | null
   let block: Block | undefined
-  let scope: EffectScope | undefined
+  let scope: BlockEffectScope | undefined
+  const parentScope = getCurrentScope()!
   const anchor = __DEV__ ? createComment('if') : createTextNode()
   const fragment: Fragment = {
     nodes: [],
@@ -26,35 +35,37 @@ export const createIf = (
     [fragmentKey]: true,
   }
 
+  const instance = currentInstance!
+  if (__DEV__ && (!instance || !isRenderEffectScope(parentScope))) {
+    warn('createIf() can only be used inside setup()')
+  }
+
   // TODO: SSR
   // if (isHydrating) {
   //   parent = hydrationNode!.parentNode
   //   setCurrentHydrationNode(hydrationNode!)
   // }
 
-  if (once) {
-    doIf()
-  } else {
-    renderEffect(() => doIf())
-  }
-
-  function doIf() {
-    if ((newValue = !!condition()) !== oldValue) {
-      parent ||= anchor.parentNode
-      if (block) {
-        scope!.stop()
-        remove(block, parent!)
-      }
-      if ((branch = (oldValue = newValue) ? b1 : b2)) {
-        scope = effectScope()
-        fragment.nodes = block = scope.run(branch)!
-        parent && insert(block, parent, anchor)
-      } else {
-        scope = block = undefined
-        fragment.nodes = []
+  createChildFragmentDirectives(
+    anchor,
+    () => (scope ? [scope] : []),
+    // source getter
+    condition,
+    // init cb
+    getValue => {
+      newValue = !!getValue()
+      doIf()
+    },
+    // effect cb
+    getValue => {
+      if ((newValue = !!getValue()) !== oldValue) {
+        doIf()
+      } else if (scope) {
+        invokeWithUpdate(scope)
       }
-    }
-  }
+    },
+    once,
+  )
 
   // TODO: SSR
   // if (isHydrating) {
@@ -62,4 +73,19 @@ export const createIf = (
   // }
 
   return fragment
+
+  function doIf() {
+    parent ||= anchor.parentNode
+    if (block) {
+      invokeWithUnmount(scope!, () => remove(block!, parent!))
+    }
+    if ((branch = (oldValue = newValue) ? b1 : b2)) {
+      scope = new BlockEffectScope(instance, parentScope)
+      fragment.nodes = block = scope.run(branch)!
+      invokeWithMount(scope, () => parent && insert(block!, parent, anchor))
+    } else {
+      scope = block = undefined
+      fragment.nodes = []
+    }
+  }
 }

+ 36 - 0
packages/runtime-vapor/src/blockEffectScope.ts

@@ -0,0 +1,36 @@
+import { EffectScope } from '@vue/reactivity'
+import type { ComponentInternalInstance } from './component'
+import type { DirectiveBindingsMap } from './directives'
+
+export class BlockEffectScope extends EffectScope {
+  /**
+   * instance
+   * @internal
+   */
+  it: ComponentInternalInstance
+  /**
+   * isMounted
+   * @internal
+   */
+  im: boolean
+  /**
+   * directives
+   * @internal
+   */
+  dirs?: DirectiveBindingsMap
+
+  constructor(
+    instance: ComponentInternalInstance,
+    parentScope: EffectScope | null,
+  ) {
+    super(false, parentScope || undefined)
+    this.im = false
+    this.it = instance
+  }
+}
+
+export function isRenderEffectScope(
+  scope: EffectScope | undefined,
+): scope is BlockEffectScope {
+  return scope instanceof BlockEffectScope
+}

+ 5 - 6
packages/runtime-vapor/src/component.ts

@@ -1,7 +1,6 @@
-import { EffectScope, isRef } from '@vue/reactivity'
+import { isRef } from '@vue/reactivity'
 import { EMPTY_OBJ, hasOwn, isArray, isFunction } from '@vue/shared'
 import type { Block } from './apiRender'
-import type { DirectiveBinding } from './directives'
 import {
   type ComponentPropsOptions,
   type NormalizedPropsOptions,
@@ -27,6 +26,7 @@ import { VaporLifecycleHooks } from './apiLifecycle'
 import { warn } from './warning'
 import { type AppContext, createAppContext } from './apiCreateVaporApp'
 import type { Data } from '@vue/runtime-shared'
+import { BlockEffectScope } from './blockEffectScope'
 
 export type Component = FunctionalComponent | ObjectComponent
 
@@ -154,10 +154,9 @@ export interface ComponentInternalInstance {
   parent: ComponentInternalInstance | null
 
   provides: Data
-  scope: EffectScope
+  scope: BlockEffectScope
   component: Component
   comps: Set<ComponentInternalInstance>
-  dirs: Map<Node, DirectiveBinding[]>
 
   rawProps: NormalizedRawProps
   propsOptions: NormalizedPropsOptions
@@ -280,11 +279,10 @@ export function createComponentInstance(
 
     parent,
 
-    scope: new EffectScope(true /* detached */)!,
+    scope: null!,
     provides: parent ? parent.provides : Object.create(_appContext.provides),
     component,
     comps: new Set(),
-    dirs: new Map(),
 
     // resolved props and emits options
     rawProps: null!, // set later
@@ -355,6 +353,7 @@ export function createComponentInstance(
      */
     // [VaporLifecycleHooks.SERVER_PREFETCH]: null,
   }
+  instance.scope = new BlockEffectScope(instance, parent && parent.scope)
   initProps(instance, rawProps, !isFunction(component), once)
   initSlots(instance, slots, dynamicSlots)
   instance.emit = emit.bind(null, instance)

+ 1 - 1
packages/runtime-vapor/src/componentLifecycle.ts

@@ -25,7 +25,7 @@ export function invokeLifecycle(
       post ? queuePostFlushCb(fn) : fn()
     }
 
-    invokeDirectiveHook(instance, directive)
+    invokeDirectiveHook(instance, directive, instance.scope)
   }
 
   function invokeSub() {

+ 102 - 23
packages/runtime-vapor/src/directives.ts

@@ -1,32 +1,48 @@
-import { isFunction } from '@vue/shared'
+import { invokeArrayFns, isFunction } from '@vue/shared'
 import {
   type ComponentInternalInstance,
   currentInstance,
   isVaporComponent,
+  setCurrentInstance,
 } from './component'
-import { pauseTracking, resetTracking, traverse } from '@vue/reactivity'
-import { VaporErrorCodes, callWithAsyncErrorHandling } from './errorHandling'
-import { renderEffect } from './renderEffect'
+import {
+  EffectFlags,
+  ReactiveEffect,
+  type SchedulerJob,
+  getCurrentScope,
+  pauseTracking,
+  resetTracking,
+  traverse,
+} from '@vue/reactivity'
+import {
+  VaporErrorCodes,
+  callWithAsyncErrorHandling,
+  callWithErrorHandling,
+} from './errorHandling'
+import { queueJob, queuePostFlushCb } from './scheduler'
 import { warn } from './warning'
+import { type BlockEffectScope, isRenderEffectScope } from './blockEffectScope'
 import { normalizeBlock } from './dom/element'
 
 export type DirectiveModifiers<M extends string = string> = Record<M, boolean>
 
-export interface DirectiveBinding<V = any, M extends string = string> {
+export interface DirectiveBinding<T = any, V = any, M extends string = string> {
   instance: ComponentInternalInstance
   source?: () => V
   value: V
   oldValue: V | null
   arg?: string
   modifiers?: DirectiveModifiers<M>
-  dir: ObjectDirective<any, V>
+  dir: ObjectDirective<T, V, M>
 }
 
+export type DirectiveBindingsMap = Map<Node, DirectiveBinding[]>
+
 export type DirectiveHook<
   T = any | null,
   V = any,
   M extends string = string,
-> = (node: T, binding: DirectiveBinding<V, M>) => void
+> = (node: T, binding: DirectiveBinding<T, V, M>) => void
 
 // create node -> `created` -> node operation -> `beforeMount` -> node mounted -> `mounted`
 // effect update -> `beforeUpdate` -> node updated -> `updated`
@@ -43,7 +59,7 @@ export type ObjectDirective<T = any, V = any, M extends string = string> = {
   [K in DirectiveHookName]?: DirectiveHook<T, V, M> | undefined
 } & {
   /** Watch value deeply */
-  deep?: boolean
+  deep?: boolean | number
 }
 
 export type FunctionDirective<
@@ -86,9 +102,18 @@ export function withDirectives<T extends ComponentInternalInstance | Node>(
     node = nodeOrComponent
   }
 
-  const instance = currentInstance
-  if (!instance.dirs.has(node)) instance.dirs.set(node, [])
-  const bindings = instance.dirs.get(node)!
+  let bindings: DirectiveBinding[]
+  const instance = currentInstance!
+  const parentScope = getCurrentScope() as BlockEffectScope
+
+  if (__DEV__ && !isRenderEffectScope(parentScope)) {
+    warn(`Directives should be used inside of RenderEffectScope.`)
+  }
+
+  const directivesMap = (parentScope.dirs ||= new Map())
+  if (!(bindings = directivesMap.get(node))) {
+    directivesMap.set(node, (bindings = []))
+  }
 
   for (const directive of directives) {
     let [dir, source, arg, modifiers] = directive
@@ -103,25 +128,38 @@ export function withDirectives<T extends ComponentInternalInstance | Node>(
     const binding: DirectiveBinding = {
       dir,
       instance,
-      source,
       value: null, // set later
       oldValue: undefined,
       arg,
       modifiers,
     }
-    bindings.push(binding)
-
-    callDirectiveHook(node, binding, instance, 'created')
 
-    // register source
     if (source) {
       if (dir.deep) {
         const deep = dir.deep === true ? undefined : dir.deep
         const baseSource = source
         source = () => traverse(baseSource(), deep)
       }
-      renderEffect(source)
+
+      const effect = new ReactiveEffect(() =>
+        callWithErrorHandling(
+          source!,
+          instance,
+          VaporErrorCodes.RENDER_FUNCTION,
+        ),
+      )
+      const triggerRenderingUpdate = createRenderingUpdateTrigger(
+        instance,
+        effect,
+      )
+      effect.scheduler = () => queueJob(triggerRenderingUpdate)
+
+      binding.source = effect.run.bind(effect)
     }
+
+    bindings.push(binding)
+
+    callDirectiveHook(node, binding, instance, 'created')
   }
 
   return nodeOrComponent
@@ -145,13 +183,14 @@ function getComponentNode(component: ComponentInternalInstance) {
 export function invokeDirectiveHook(
   instance: ComponentInternalInstance | null,
   name: DirectiveHookName,
-  nodes?: IterableIterator<Node>,
+  scope: BlockEffectScope,
 ) {
-  if (!instance) return
-  nodes = nodes || instance.dirs.keys()
-  for (const node of nodes) {
-    const directives = instance.dirs.get(node) || []
-    for (const binding of directives) {
+  const { dirs } = scope
+  if (name === 'mounted') scope.im = true
+  if (!dirs) return
+  const iterator = dirs.entries()
+  for (const [node, bindings] of iterator) {
+    for (const binding of bindings) {
       callDirectiveHook(node, binding, instance, name)
     }
   }
@@ -179,3 +218,43 @@ function callDirectiveHook(
   ])
   resetTracking()
 }
+
+export function createRenderingUpdateTrigger(
+  instance: ComponentInternalInstance,
+  effect: ReactiveEffect,
+): SchedulerJob {
+  job.id = instance.uid
+  return job
+  function job() {
+    if (!(effect.flags & EffectFlags.ACTIVE) || !effect.dirty) {
+      return
+    }
+
+    if (instance.isMounted && !instance.isUpdating) {
+      instance.isUpdating = true
+      const reset = setCurrentInstance(instance)
+
+      const { bu, u, scope } = instance
+      const { dirs } = scope
+      // beforeUpdate hook
+      if (bu) {
+        invokeArrayFns(bu)
+      }
+      invokeDirectiveHook(instance, 'beforeUpdate', scope)
+
+      queuePostFlushCb(() => {
+        instance.isUpdating = false
+        const reset = setCurrentInstance(instance)
+        if (dirs) {
+          invokeDirectiveHook(instance, 'updated', scope)
+        }
+        // updated hook
+        if (u) {
+          queuePostFlushCb(u)
+        }
+        reset()
+      })
+      reset()
+    }
+  }
+}

+ 152 - 0
packages/runtime-vapor/src/directivesChildFragment.ts

@@ -0,0 +1,152 @@
+import { ReactiveEffect, getCurrentScope } from '@vue/reactivity'
+import {
+  type Directive,
+  type DirectiveHookName,
+  createRenderingUpdateTrigger,
+  invokeDirectiveHook,
+} from './directives'
+import { warn } from './warning'
+import { type BlockEffectScope, isRenderEffectScope } from './blockEffectScope'
+import { currentInstance } from './component'
+import { VaporErrorCodes, callWithErrorHandling } from './errorHandling'
+import { queueJob, queuePostFlushCb } from './scheduler'
+
+/**
+ * used in createIf and createFor
+ * manage directives of child fragments in components.
+ */
+export function createChildFragmentDirectives(
+  anchor: Node,
+  getScopes: () => BlockEffectScope[],
+  source: () => any,
+  initCallback: (getValue: () => any) => void,
+  effectCallback: (getValue: () => any) => void,
+  once?: boolean,
+) {
+  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')
+    }
+  }
+
+  const callSourceWithErrorHandling = () =>
+    callWithErrorHandling(source, instance, VaporErrorCodes.RENDER_FUNCTION)
+
+  if (once) {
+    initCallback(callSourceWithErrorHandling)
+    return
+  }
+
+  const directiveBindingsMap = (parentScope.dirs ||= new Map())
+  const dir: Directive = {
+    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)
+  effect.scheduler = () => {
+    isTriggered = true
+    queueJob(triggerRenderingUpdate)
+  }
+
+  const getValue = () => effect.run()
+
+  initCallback(getValue)
+
+  function onDirectiveBeforeUpdate() {
+    if (isTriggered) {
+      isTriggered = false
+      effectCallback(getValue)
+    } else {
+      const scopes = getScopes()
+      for (const scope of scopes) {
+        invokeWithUpdate(scope)
+      }
+      return
+    }
+  }
+
+  function invokeChildrenDirectives(name: DirectiveHookName) {
+    const scopes = getScopes()
+    for (const scope of scopes) {
+      invokeDirectiveHook(instance, name, scope)
+    }
+  }
+}
+
+export function invokeWithMount(scope: BlockEffectScope, handler?: () => any) {
+  if (isRenderEffectScope(scope.parent) && !scope.parent.im) {
+    return handler && handler()
+  }
+  return invokeWithDirsHooks(scope, 'mount', handler)
+}
+
+export function invokeWithUnmount(
+  scope: BlockEffectScope,
+  handler?: () => void,
+) {
+  try {
+    return invokeWithDirsHooks(scope, 'unmount', handler)
+  } finally {
+    scope.stop()
+  }
+}
+
+export function invokeWithUpdate(
+  scope: BlockEffectScope,
+  handler?: () => void,
+) {
+  return invokeWithDirsHooks(scope, 'update', handler)
+}
+
+const lifecycleMap = {
+  mount: ['beforeMount', 'mounted'],
+  update: ['beforeUpdate', 'updated'],
+  unmount: ['beforeUnmount', 'unmounted'],
+} as const
+
+function invokeWithDirsHooks(
+  scope: BlockEffectScope,
+  name: keyof typeof lifecycleMap,
+  handler?: () => any,
+) {
+  const { dirs, it: instance } = scope
+  const [before, after] = lifecycleMap[name]
+
+  if (!dirs) {
+    const res = handler && handler()
+    if (name === 'mount') {
+      queuePostFlushCb(() => (scope.im = true))
+    }
+    return res
+  }
+
+  invokeDirectiveHook(instance, before, scope)
+  try {
+    if (handler) {
+      return handler()
+    }
+  } finally {
+    queuePostFlushCb(() => {
+      invokeDirectiveHook(instance, after, scope)
+    })
+  }
+}

+ 40 - 36
packages/runtime-vapor/src/renderEffect.ts

@@ -18,73 +18,77 @@ import { invokeDirectiveHook } from './directives'
 export function renderEffect(cb: () => void) {
   const instance = getCurrentInstance()
   const scope = getCurrentScope()
-  let effect: ReactiveEffect
 
-  const job: SchedulerJob = () => {
+  if (scope) {
+    const baseCb = cb
+    cb = () => scope.run(baseCb)
+  }
+
+  if (instance) {
+    const baseCb = cb
+    cb = () => {
+      const reset = setCurrentInstance(instance)
+      baseCb()
+      reset()
+    }
+    job.id = instance.uid
+  }
+
+  const effect = new ReactiveEffect(() =>
+    callWithAsyncErrorHandling(cb, instance, VaporErrorCodes.RENDER_FUNCTION),
+  )
+
+  effect.scheduler = () => queueJob(job)
+  if (__DEV__ && instance) {
+    effect.onTrack = instance.rtc
+      ? e => invokeArrayFns(instance.rtc!, e)
+      : void 0
+    effect.onTrigger = instance.rtg
+      ? e => invokeArrayFns(instance.rtg!, e)
+      : void 0
+  }
+  effect.run()
+
+  function job() {
     if (!(effect.flags & EffectFlags.ACTIVE) || !effect.dirty) {
       return
     }
 
+    const reset = instance && setCurrentInstance(instance)
+
     if (instance && instance.isMounted && !instance.isUpdating) {
       instance.isUpdating = true
 
-      const { bu, u, dirs } = instance
+      const { bu, u, scope } = instance
+      const { dirs } = scope
       // beforeUpdate hook
       if (bu) {
         invokeArrayFns(bu)
       }
       if (dirs) {
-        invokeDirectiveHook(instance, 'beforeUpdate')
+        invokeDirectiveHook(instance, 'beforeUpdate', scope)
       }
 
       effect.run()
 
       queuePostFlushCb(() => {
         instance.isUpdating = false
+        const reset = setCurrentInstance(instance)
         if (dirs) {
-          invokeDirectiveHook(instance, 'updated')
+          invokeDirectiveHook(instance, 'updated', scope)
         }
         // updated hook
         if (u) {
           queuePostFlushCb(u)
         }
+        reset()
       })
     } else {
       effect.run()
     }
-  }
-
-  if (scope) {
-    const baseCb = cb
-    cb = () => scope.run(baseCb)
-  }
-
-  if (instance) {
-    const baseCb = cb
-    cb = () => {
-      const reset = setCurrentInstance(instance)
-      baseCb()
-      reset()
-    }
-  }
 
-  effect = new ReactiveEffect(() =>
-    callWithAsyncErrorHandling(cb, instance, VaporErrorCodes.RENDER_FUNCTION),
-  )
-
-  effect.scheduler = () => {
-    if (instance) job.id = instance.uid
-    queueJob(job)
-  }
-  if (__DEV__ && instance) {
-    effect.onTrack = instance.rtc
-      ? e => invokeArrayFns(instance.rtc!, e)
-      : void 0
-    effect.onTrigger = instance.rtg
-      ? e => invokeArrayFns(instance.rtg!, e)
-      : void 0
+    reset && reset()
   }
-  effect.run()
 }
 
 export function firstEffect(