feat(runtime-vapor): lifecycle beforeUpdate and updated hooks (#89)

This commit is contained in:
Rizumu Ayaka 2024-01-13 03:25:57 +08:00 committed by GitHub
parent bb8cc447eb
commit 2cce436aaf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 379 additions and 84 deletions

View 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',
])
})
})

View 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 {

View File

@ -76,5 +76,6 @@ export {
traverse,
BaseWatchErrorCodes,
type BaseWatchOptions,
type BaseWatchMiddleware,
type Scheduler,
} from './baseWatch'

View 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 __returned__
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}`))
})
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()
})
})

View 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, ...

View 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
}

View 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
}

View 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()
}

View 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>

View 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>