]> git.ipfire.org Git - thirdparty/vuejs/core.git/commitdiff
feat(runtime-vapor): lifecycle beforeUpdate and updated hooks (#89)
authorRizumu Ayaka <rizumu@ayaka.moe>
Fri, 12 Jan 2024 19:25:57 +0000 (03:25 +0800)
committerGitHub <noreply@github.com>
Fri, 12 Jan 2024 19:25:57 +0000 (03:25 +0800)
packages/reactivity/__tests__/baseWatch.spec.ts
packages/reactivity/src/baseWatch.ts
packages/reactivity/src/index.ts
packages/runtime-vapor/__tests__/renderWatch.spec.ts
packages/runtime-vapor/src/component.ts
packages/runtime-vapor/src/directive.ts
packages/runtime-vapor/src/renderWatch.ts
packages/runtime-vapor/src/scheduler.ts
playground/src/App.vue
playground/src/directive.vue

index 02d9e64e0fafeba7d5a79af10ce9921993d85458..0aab0aee6e87e5550e355e0f2fecd89ee612648d 100644 (file)
@@ -175,4 +175,82 @@ describe('baseWatch', () => {
     scope.stop()
     expect(calls).toEqual(['sync 2', 'post 2'])
   })
+  test('baseWatch with middleware', async () => {
+    let effectCalls: string[] = []
+    let watchCalls: string[] = []
+    const source = ref(0)
+
+    // effect
+    baseWatch(
+      () => {
+        source.value
+        effectCalls.push('effect')
+        onEffectCleanup(() => effectCalls.push('effect cleanup'))
+      },
+      null,
+      {
+        scheduler,
+        middleware: next => {
+          effectCalls.push('before effect running')
+          next()
+          effectCalls.push('effect ran')
+        },
+      },
+    )
+    // watch
+    baseWatch(
+      () => source.value,
+      () => {
+        watchCalls.push('watch')
+        onEffectCleanup(() => watchCalls.push('watch cleanup'))
+      },
+      {
+        scheduler,
+        middleware: next => {
+          watchCalls.push('before watch running')
+          next()
+          watchCalls.push('watch ran')
+        },
+      },
+    )
+
+    expect(effectCalls).toEqual([])
+    expect(watchCalls).toEqual([])
+    await nextTick()
+    expect(effectCalls).toEqual([
+      'before effect running',
+      'effect',
+      'effect ran',
+    ])
+    expect(watchCalls).toEqual([])
+    effectCalls.length = 0
+    watchCalls.length = 0
+
+    source.value++
+    await nextTick()
+    expect(effectCalls).toEqual([
+      'before effect running',
+      'effect cleanup',
+      'effect',
+      'effect ran',
+    ])
+    expect(watchCalls).toEqual(['before watch running', 'watch', 'watch ran'])
+    effectCalls.length = 0
+    watchCalls.length = 0
+
+    source.value++
+    await nextTick()
+    expect(effectCalls).toEqual([
+      'before effect running',
+      'effect cleanup',
+      'effect',
+      'effect ran',
+    ])
+    expect(watchCalls).toEqual([
+      'before watch running',
+      'watch cleanup',
+      'watch',
+      'watch ran',
+    ])
+  })
 })
index a97f43366b90859c04c6d8bbc82fe4c0f1cc621a..f6e20cfa52cea09a264e7994d4afb40c9ceaa99c 100644 (file)
@@ -71,6 +71,7 @@ export interface BaseWatchOptions<Immediate = boolean> extends DebuggerOptions {
   deep?: boolean
   once?: boolean
   scheduler?: Scheduler
+  middleware?: BaseWatchMiddleware
   onError?: HandleError
   onWarn?: HandleWarn
 }
@@ -83,6 +84,7 @@ export type Scheduler = (
   effect: ReactiveEffect,
   isInit: boolean,
 ) => void
+export type BaseWatchMiddleware = (next: () => unknown) => any
 export type HandleError = (err: unknown, type: BaseWatchErrorCodes) => void
 export type HandleWarn = (msg: string, ...args: any[]) => void
 
@@ -132,6 +134,7 @@ export function baseWatch(
     scheduler = DEFAULT_SCHEDULER,
     onWarn = __DEV__ ? warn : NOOP,
     onError = DEFAULT_HANDLE_ERROR,
+    middleware,
     onTrack,
     onTrigger,
   }: BaseWatchOptions = EMPTY_OBJ,
@@ -211,6 +214,10 @@ export function baseWatch(
           activeEffect = currentEffect
         }
       }
+      if (middleware) {
+        const baseGetter = getter
+        getter = () => middleware(baseGetter)
+      }
     }
   } else {
     getter = NOOP
@@ -264,31 +271,38 @@ export function baseWatch(
           ? (newValue as any[]).some((v, i) => hasChanged(v, oldValue[i]))
           : hasChanged(newValue, oldValue))
       ) {
-        // cleanup before running cb again
-        if (cleanup) {
-          cleanup()
+        const next = () => {
+          // cleanup before running cb again
+          if (cleanup) {
+            cleanup()
+          }
+          const currentEffect = activeEffect
+          activeEffect = effect
+          try {
+            callWithAsyncErrorHandling(
+              cb!,
+              onError,
+              BaseWatchErrorCodes.WATCH_CALLBACK,
+              [
+                newValue,
+                // pass undefined as the old value when it's changed for the first time
+                oldValue === INITIAL_WATCHER_VALUE
+                  ? undefined
+                  : isMultiSource && oldValue[0] === INITIAL_WATCHER_VALUE
+                    ? []
+                    : oldValue,
+                onEffectCleanup,
+              ],
+            )
+            oldValue = newValue
+          } finally {
+            activeEffect = currentEffect
+          }
         }
-        const currentEffect = activeEffect
-        activeEffect = effect
-        try {
-          callWithAsyncErrorHandling(
-            cb,
-            onError,
-            BaseWatchErrorCodes.WATCH_CALLBACK,
-            [
-              newValue,
-              // pass undefined as the old value when it's changed for the first time
-              oldValue === INITIAL_WATCHER_VALUE
-                ? undefined
-                : isMultiSource && oldValue[0] === INITIAL_WATCHER_VALUE
-                  ? []
-                  : oldValue,
-              onEffectCleanup,
-            ],
-          )
-          oldValue = newValue
-        } finally {
-          activeEffect = currentEffect
+        if (middleware) {
+          middleware(next)
+        } else {
+          next()
         }
       }
     } else {
index 8fe9c18c63aa8ec3a25b05b7846b0626e673fc3b..88ef249e441154750c0e2cb6f2dabb080baeda82 100644 (file)
@@ -76,5 +76,6 @@ export {
   traverse,
   BaseWatchErrorCodes,
   type BaseWatchOptions,
+  type BaseWatchMiddleware,
   type Scheduler,
 } from './baseWatch'
index 0d43ad90f11f6f22e14c4c923ad4b61e34c91f8f..9a48df5c5c57f13d5c58e223beac95dbafcd9d9e 100644 (file)
@@ -1,7 +1,9 @@
 import { defineComponent } from 'vue'
 import {
   nextTick,
+  onBeforeUpdate,
   onEffectCleanup,
+  onUpdated,
   ref,
   render,
   renderEffect,
@@ -25,6 +27,27 @@ beforeEach(() => {
 afterEach(() => {
   host.remove()
 })
+const createDemo = (
+  setupFn: (porps: any, ctx: any) => any,
+  renderFn: (ctx: any) => any,
+) => {
+  const demo = defineComponent({
+    setup(...args) {
+      const returned = setupFn(...args)
+      Object.defineProperty(returned, '__isScriptSetup', {
+        enumerable: false,
+        value: true,
+      })
+      return returned
+    },
+  })
+  demo.render = (ctx: any) => {
+    const t0 = template('<div></div>')
+    renderFn(ctx)
+    return t0()
+  }
+  return () => render(demo as any, {}, '#host')
+}
 
 describe('renderWatch', () => {
   test('effect', async () => {
@@ -53,16 +76,26 @@ describe('renderWatch', () => {
     expect(dummy).toBe(1)
   })
 
-  test('scheduling order', async () => {
+  test('should run with the scheduling order', async () => {
     const calls: string[] = []
 
-    const demo = defineComponent({
-      setup() {
+    const mount = createDemo(
+      () => {
+        // setup
         const source = ref(0)
         const renderSource = ref(0)
         const change = () => source.value++
         const changeRender = () => renderSource.value++
 
+        // Life Cycle Hooks
+        onUpdated(() => {
+          calls.push(`updated ${source.value}`)
+        })
+        onBeforeUpdate(() => {
+          calls.push(`beforeUpdate ${source.value}`)
+        })
+
+        // Watch API
         watchPostEffect(() => {
           const current = source.value
           calls.push(`post ${current}`)
@@ -78,33 +111,28 @@ describe('renderWatch', () => {
           calls.push(`sync ${current}`)
           onEffectCleanup(() => calls.push(`sync cleanup ${current}`))
         })
-        const __returned__ = { source, change, renderSource, changeRender }
-        Object.defineProperty(__returned__, '__isScriptSetup', {
-          enumerable: false,
-          value: true,
+        return { source, change, renderSource, changeRender }
+      },
+      // render
+      (_ctx) => {
+        // Render Watch API
+        renderEffect(() => {
+          const current = _ctx.renderSource
+          calls.push(`renderEffect ${current}`)
+          onEffectCleanup(() => calls.push(`renderEffect cleanup ${current}`))
         })
-        return __returned__
+        renderWatch(
+          () => _ctx.renderSource,
+          (value) => {
+            calls.push(`renderWatch ${value}`)
+            onEffectCleanup(() => calls.push(`renderWatch cleanup ${value}`))
+          },
+        )
       },
-    })
+    )
 
-    demo.render = (_ctx: any) => {
-      const t0 = template('<div></div>')
-      renderEffect(() => {
-        const current = _ctx.renderSource
-        calls.push(`renderEffect ${current}`)
-        onEffectCleanup(() => calls.push(`renderEffect cleanup ${current}`))
-      })
-      renderWatch(
-        () => _ctx.renderSource,
-        (value) => {
-          calls.push(`renderWatch ${value}`)
-          onEffectCleanup(() => calls.push(`renderWatch cleanup ${value}`))
-        },
-      )
-      return t0()
-    }
-
-    const instance = render(demo as any, {}, '#host')
+    // Mount
+    const instance = mount()
     const { change, changeRender } = instance.setupState as any
 
     expect(calls).toEqual(['pre 0', 'sync 0', 'renderEffect 0'])
@@ -114,8 +142,10 @@ describe('renderWatch', () => {
     expect(calls).toEqual(['post 0'])
     calls.length = 0
 
+    // Update
     changeRender()
     change()
+
     expect(calls).toEqual(['sync cleanup 0', 'sync 1'])
     calls.length = 0
 
@@ -123,11 +153,75 @@ describe('renderWatch', () => {
     expect(calls).toEqual([
       'pre cleanup 0',
       'pre 1',
+      'beforeUpdate 1',
       'renderEffect cleanup 0',
       'renderEffect 1',
       'renderWatch 1',
       'post cleanup 0',
       'post 1',
+      'updated 1',
     ])
   })
+
+  test('errors should include the execution location with beforeUpdate hook', async () => {
+    const mount = createDemo(
+      // setup
+      () => {
+        const source = ref()
+        const update = () => source.value++
+        onBeforeUpdate(() => {
+          throw 'error in beforeUpdate'
+        })
+        return { source, update }
+      },
+      // render
+      (ctx) => {
+        renderEffect(() => {
+          ctx.source
+        })
+      },
+    )
+
+    const instance = mount()
+    const { update } = instance.setupState as any
+    await expect(async () => {
+      update()
+      await nextTick()
+    }).rejects.toThrow('error in beforeUpdate')
+
+    expect(
+      '[Vue warn] Unhandled error during execution of beforeUpdate hook',
+    ).toHaveBeenWarned()
+  })
+
+  test('errors should include the execution location with updated hook', async () => {
+    const mount = createDemo(
+      // setup
+      () => {
+        const source = ref(0)
+        const update = () => source.value++
+        onUpdated(() => {
+          throw 'error in updated'
+        })
+        return { source, update }
+      },
+      // render
+      (ctx) => {
+        renderEffect(() => {
+          ctx.source
+        })
+      },
+    )
+
+    const instance = mount()
+    const { update } = instance.setupState as any
+    await expect(async () => {
+      update()
+      await nextTick()
+    }).rejects.toThrow('error in updated')
+
+    expect(
+      '[Vue warn] Unhandled error during execution of updated',
+    ).toHaveBeenWarned()
+  })
 })
index d1e5723f2d6c0e0314ab344b55b9c133675925c4..ab2e49c3af1731bbdc39600c09562c8b75f10121 100644 (file)
@@ -1,4 +1,10 @@
-import { EffectScope, type Ref, ref } from '@vue/reactivity'
+import {
+  EffectScope,
+  type Ref,
+  pauseTracking,
+  ref,
+  resetTracking,
+} from '@vue/reactivity'
 
 import { EMPTY_OBJ } from '@vue/shared'
 import type { Block } from './render'
@@ -47,6 +53,7 @@ export interface ComponentInternalInstance {
   // lifecycle
   get isMounted(): boolean
   get isUnmounted(): boolean
+  isUpdating: boolean
   isUnmountedRef: Ref<boolean>
   isMountedRef: Ref<boolean>
   // TODO: registory of provides, lifecycles, ...
@@ -150,11 +157,18 @@ export const createComponentInstance = (
 
     // lifecycle
     get isMounted() {
-      return isMountedRef.value
+      pauseTracking()
+      const value = isMountedRef.value
+      resetTracking()
+      return value
     },
     get isUnmounted() {
-      return isUnmountedRef.value
+      pauseTracking()
+      const value = isUnmountedRef.value
+      resetTracking()
+      return value
     },
+    isUpdating: false,
     isMountedRef,
     isUnmountedRef,
     // TODO: registory of provides, appContext, lifecycles, ...
index f59ce6a142de433d20bd503299fa167e88341c3f..ad6072b4f56e84120f16e1d0d0ac0559e72215dd 100644 (file)
@@ -1,6 +1,8 @@
-import { isFunction } from '@vue/shared'
+import { NOOP, isFunction } from '@vue/shared'
 import { type ComponentInternalInstance, currentInstance } from './component'
-import { watchEffect } from './apiWatch'
+import { pauseTracking, resetTracking } from '@vue/reactivity'
+import { VaporErrorCodes, callWithAsyncErrorHandling } from './errorHandling'
+import { renderWatch } from './renderWatch'
 
 export type DirectiveModifiers<M extends string = string> = Record<M, boolean>
 
@@ -27,7 +29,7 @@ export type DirectiveHookName =
   | 'created'
   | 'beforeMount'
   | 'mounted'
-  // | 'beforeUpdate'
+  | 'beforeUpdate'
   | 'updated'
   | 'beforeUnmount'
   | 'unmounted'
@@ -93,12 +95,12 @@ export function withDirectives<T extends Node>(
     }
     bindings.push(binding)
 
-    callDirectiveHook(node, binding, 'created')
+    callDirectiveHook(node, binding, instance, 'created')
 
-    watchEffect(() => {
-      if (!instance.isMountedRef.value) return
-      callDirectiveHook(node, binding, 'updated')
-    })
+    // register source
+    if (source) {
+      renderWatch(source, NOOP)
+    }
   }
 
   return node
@@ -114,7 +116,7 @@ export function invokeDirectiveHook(
   for (const node of nodes) {
     const directives = instance.dirs.get(node) || []
     for (const binding of directives) {
-      callDirectiveHook(node, binding, name)
+      callDirectiveHook(node, binding, instance, name)
     }
   }
 }
@@ -122,6 +124,7 @@ export function invokeDirectiveHook(
 function callDirectiveHook(
   node: Node,
   binding: DirectiveBinding,
+  instance: ComponentInternalInstance | null,
   name: DirectiveHookName,
 ) {
   const { dir } = binding
@@ -129,9 +132,14 @@ function callDirectiveHook(
   if (!hook) return
 
   const newValue = binding.source ? binding.source() : undefined
-  if (name === 'updated' && binding.value === newValue) return
-
-  binding.oldValue = binding.value
   binding.value = newValue
-  hook(node, binding)
+  // disable tracking inside all lifecycle hooks
+  // since they can potentially be called inside effects.
+  pauseTracking()
+  callWithAsyncErrorHandling(hook, instance, VaporErrorCodes.DIRECTIVE_HOOK, [
+    node,
+    binding,
+  ])
+  resetTracking()
+  if (name !== 'beforeUpdate') binding.oldValue = binding.value
 }
index fd9385fc5396956dec7fb0521fb1e6d8b520b4bb..e5103d716fe7ab59856a5307183e1adc308ae873 100644 (file)
@@ -1,14 +1,19 @@
 import {
   type BaseWatchErrorCodes,
+  type BaseWatchMiddleware,
   type BaseWatchOptions,
   baseWatch,
   getCurrentScope,
 } from '@vue/reactivity'
-import { NOOP, remove } from '@vue/shared'
-import { currentInstance } from './component'
-import { createVaporRenderingScheduler } from './scheduler'
+import { NOOP, invokeArrayFns, remove } from '@vue/shared'
+import { type ComponentInternalInstance, currentInstance } from './component'
+import {
+  createVaporRenderingScheduler,
+  queuePostRenderEffect,
+} from './scheduler'
 import { handleError as handleErrorWithInstance } from './errorHandling'
 import { warn } from './warning'
+import { invokeDirectiveHook } from './directive'
 
 type WatchStopHandle = () => void
 
@@ -28,8 +33,6 @@ function doWatch(source: any, cb?: any): WatchStopHandle {
 
   if (__DEV__) extendOptions.onWarn = warn
 
-  // TODO: Life Cycle Hooks
-
   // TODO: SSR
   // if (__SSR__) {}
 
@@ -40,6 +43,8 @@ function doWatch(source: any, cb?: any): WatchStopHandle {
     handleErrorWithInstance(err, instance, type)
   extendOptions.scheduler = createVaporRenderingScheduler(instance)
 
+  extendOptions.middleware = createMiddleware(instance)
+
   let effect = baseWatch(source, cb, extendOptions)
 
   const unwatch = !effect
@@ -53,3 +58,44 @@ function doWatch(source: any, cb?: any): WatchStopHandle {
 
   return unwatch
 }
+
+const createMiddleware =
+  (instance: ComponentInternalInstance | null): BaseWatchMiddleware =>
+  (next) => {
+    let value: unknown
+    // with lifecycle
+    if (instance && instance.isMounted) {
+      const { bu, u, dirs } = instance
+      // beforeUpdate hook
+      const isFirstEffect = !instance.isUpdating
+      if (isFirstEffect) {
+        if (bu) {
+          invokeArrayFns(bu)
+        }
+        if (dirs) {
+          invokeDirectiveHook(instance, 'beforeUpdate')
+        }
+        instance.isUpdating = true
+      }
+
+      // run callback
+      value = next()
+
+      if (isFirstEffect) {
+        queuePostRenderEffect(() => {
+          instance.isUpdating = false
+          if (dirs) {
+            invokeDirectiveHook(instance, 'updated')
+          }
+          // updated hook
+          if (u) {
+            queuePostRenderEffect(u)
+          }
+        })
+      }
+    } else {
+      // is not mounted
+      value = next()
+    }
+    return value
+  }
index 2be470254941f0878115bae8602f891af5f79993..7a5afb011d7954430b5a1689dccae62a153ac30e 100644 (file)
@@ -1,5 +1,6 @@
 import type { Scheduler } from '@vue/reactivity'
 import type { ComponentInternalInstance } from './component'
+import { isArray } from '@vue/shared'
 
 export interface SchedulerJob extends Function {
   id?: number
@@ -73,15 +74,22 @@ function queueJob(job: SchedulerJob) {
   }
 }
 
-export function queuePostRenderEffect(cb: SchedulerJob) {
-  if (
-    !activePostFlushCbs ||
-    !activePostFlushCbs.includes(
-      cb,
-      cb.allowRecurse ? postFlushIndex + 1 : postFlushIndex,
-    )
-  ) {
-    pendingPostFlushCbs.push(cb)
+export function queuePostRenderEffect(cb: SchedulerJobs) {
+  if (!isArray(cb)) {
+    if (
+      !activePostFlushCbs ||
+      !activePostFlushCbs.includes(
+        cb,
+        cb.allowRecurse ? postFlushIndex + 1 : postFlushIndex,
+      )
+    ) {
+      pendingPostFlushCbs.push(cb)
+    }
+  } else {
+    // if cb is an array, it is a component lifecycle hook which can only be
+    // triggered by a job, which is already deduped in the main queue, so
+    // we can skip duplicate check here to improve perf
+    pendingPostFlushCbs.push(...cb)
   }
   queueFlush()
 }
index f7c7a681de81f628872ae7eceb2e4b43fe7c3bee..070e727a64afc5df62d583240ddd42e6b82bb41d 100644 (file)
@@ -5,6 +5,8 @@ import {
   onMounted,
   onBeforeMount,
   getCurrentInstance,
+  onBeforeUpdate,
+  onUpdated,
 } from 'vue/vapor'
 
 const instance = getCurrentInstance()!
@@ -26,12 +28,24 @@ onMounted(() => {
     count.value++
   }, 1000)
 })
+
+onBeforeUpdate(() => {
+  console.log('before updated')
+})
+onUpdated(() => {
+  console.log('updated')
+})
+
+const log = (arg: any) => {
+  console.log('callback in render effect')
+  return arg
+}
 </script>
 
 <template>
   <div>
     <h1 class="red">Counter</h1>
-    <div>The number is {{ count }}.</div>
+    <div>The number is {{ log(count) }}.</div>
     <div>{{ count }} * 2 = {{ double }}</div>
     <div style="display: flex; gap: 8px">
       <button @click="inc">inc</button>
index 722801d59d1739a365fe3505b85e81d14ddd8b06..8022b9db1a8cab2f6fc93cfffa36b1170fa4e70c 100644 (file)
@@ -2,6 +2,7 @@
 import { ObjectDirective, FunctionDirective, ref } from '@vue/vapor'
 
 const text = ref('created (overwrite by v-text), ')
+const counter = ref(0)
 const vDirective: ObjectDirective<HTMLDivElement, undefined> = {
   created(node) {
     if (!node.parentElement) {
@@ -17,9 +18,15 @@ const vDirective: ObjectDirective<HTMLDivElement, undefined> = {
   mounted(node) {
     if (node.parentElement) node.textContent += 'mounted, '
   },
+  beforeUpdate(node, binding) {
+    console.log('beforeUpdate', binding, node)
+  },
+  updated(node, binding) {
+    console.log('updated', binding, node)
+  },
 }
 const vDirectiveSimple: FunctionDirective<HTMLDivElement> = (node, binding) => {
-  console.log(node, binding)
+  console.log('v-directive-simple:', node, binding)
 }
 const handleClick = () => {
   text.value = 'change'
@@ -33,4 +40,15 @@ const handleClick = () => {
     v-directive-simple="text"
     @click="handleClick"
   />
+  <button @click="counter++">
+    {{ counter }} (Click to Update Other Element)
+  </button>
 </template>
+
+<style>
+html {
+  color-scheme: dark;
+  background-color: #000;
+  padding: 10px;
+}
+</style>