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',
+ ])
+ })
})
deep?: boolean
once?: boolean
scheduler?: Scheduler
+ middleware?: BaseWatchMiddleware
onError?: HandleError
onWarn?: HandleWarn
}
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
scheduler = DEFAULT_SCHEDULER,
onWarn = __DEV__ ? warn : NOOP,
onError = DEFAULT_HANDLE_ERROR,
+ middleware,
onTrack,
onTrigger,
}: BaseWatchOptions = EMPTY_OBJ,
activeEffect = currentEffect
}
}
+ if (middleware) {
+ const baseGetter = getter
+ getter = () => middleware(baseGetter)
+ }
}
} else {
getter = NOOP
? (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 {
traverse,
BaseWatchErrorCodes,
type BaseWatchOptions,
+ type BaseWatchMiddleware,
type Scheduler,
} from './baseWatch'
import { defineComponent } from 'vue'
import {
nextTick,
+ onBeforeUpdate,
onEffectCleanup,
+ onUpdated,
ref,
render,
renderEffect,
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 () => {
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}`)
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'])
expect(calls).toEqual(['post 0'])
calls.length = 0
+ // Update
changeRender()
change()
+
expect(calls).toEqual(['sync cleanup 0', 'sync 1'])
calls.length = 0
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()
+ })
})
-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'
// lifecycle
get isMounted(): boolean
get isUnmounted(): boolean
+ isUpdating: boolean
isUnmountedRef: Ref<boolean>
isMountedRef: Ref<boolean>
// TODO: registory of provides, lifecycles, ...
// 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, ...
-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>
| 'created'
| 'beforeMount'
| 'mounted'
- // | 'beforeUpdate'
+ | 'beforeUpdate'
| 'updated'
| 'beforeUnmount'
| 'unmounted'
}
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
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)
}
}
}
function callDirectiveHook(
node: Node,
binding: DirectiveBinding,
+ instance: ComponentInternalInstance | null,
name: DirectiveHookName,
) {
const { dir } = binding
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
}
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
if (__DEV__) extendOptions.onWarn = warn
- // TODO: Life Cycle Hooks
-
// TODO: SSR
// if (__SSR__) {}
handleErrorWithInstance(err, instance, type)
extendOptions.scheduler = createVaporRenderingScheduler(instance)
+ extendOptions.middleware = createMiddleware(instance)
+
let effect = baseWatch(source, cb, extendOptions)
const unwatch = !effect
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
+ }
import type { Scheduler } from '@vue/reactivity'
import type { ComponentInternalInstance } from './component'
+import { isArray } from '@vue/shared'
export interface SchedulerJob extends Function {
id?: number
}
}
-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()
}
onMounted,
onBeforeMount,
getCurrentInstance,
+ onBeforeUpdate,
+ onUpdated,
} from 'vue/vapor'
const instance = getCurrentInstance()!
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>
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) {
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'
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>