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() scope.stop()
expect(calls).toEqual(['sync 2', 'post 2']) 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 deep?: boolean
once?: boolean once?: boolean
scheduler?: Scheduler scheduler?: Scheduler
middleware?: BaseWatchMiddleware
onError?: HandleError onError?: HandleError
onWarn?: HandleWarn onWarn?: HandleWarn
} }
@ -83,6 +84,7 @@ export type Scheduler = (
effect: ReactiveEffect, effect: ReactiveEffect,
isInit: boolean, isInit: boolean,
) => void ) => void
export type BaseWatchMiddleware = (next: () => unknown) => any
export type HandleError = (err: unknown, type: BaseWatchErrorCodes) => void export type HandleError = (err: unknown, type: BaseWatchErrorCodes) => void
export type HandleWarn = (msg: string, ...args: any[]) => void export type HandleWarn = (msg: string, ...args: any[]) => void
@ -132,6 +134,7 @@ export function baseWatch(
scheduler = DEFAULT_SCHEDULER, scheduler = DEFAULT_SCHEDULER,
onWarn = __DEV__ ? warn : NOOP, onWarn = __DEV__ ? warn : NOOP,
onError = DEFAULT_HANDLE_ERROR, onError = DEFAULT_HANDLE_ERROR,
middleware,
onTrack, onTrack,
onTrigger, onTrigger,
}: BaseWatchOptions = EMPTY_OBJ, }: BaseWatchOptions = EMPTY_OBJ,
@ -211,6 +214,10 @@ export function baseWatch(
activeEffect = currentEffect activeEffect = currentEffect
} }
} }
if (middleware) {
const baseGetter = getter
getter = () => middleware(baseGetter)
}
} }
} else { } else {
getter = NOOP getter = NOOP
@ -264,6 +271,7 @@ export function baseWatch(
? (newValue as any[]).some((v, i) => hasChanged(v, oldValue[i])) ? (newValue as any[]).some((v, i) => hasChanged(v, oldValue[i]))
: hasChanged(newValue, oldValue)) : hasChanged(newValue, oldValue))
) { ) {
const next = () => {
// cleanup before running cb again // cleanup before running cb again
if (cleanup) { if (cleanup) {
cleanup() cleanup()
@ -272,7 +280,7 @@ export function baseWatch(
activeEffect = effect activeEffect = effect
try { try {
callWithAsyncErrorHandling( callWithAsyncErrorHandling(
cb, cb!,
onError, onError,
BaseWatchErrorCodes.WATCH_CALLBACK, BaseWatchErrorCodes.WATCH_CALLBACK,
[ [
@ -291,6 +299,12 @@ export function baseWatch(
activeEffect = currentEffect activeEffect = currentEffect
} }
} }
if (middleware) {
middleware(next)
} else {
next()
}
}
} else { } else {
// watchEffect // watchEffect
effect.run() effect.run()

View File

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

View File

@ -1,7 +1,9 @@
import { defineComponent } from 'vue' import { defineComponent } from 'vue'
import { import {
nextTick, nextTick,
onBeforeUpdate,
onEffectCleanup, onEffectCleanup,
onUpdated,
ref, ref,
render, render,
renderEffect, renderEffect,
@ -25,6 +27,27 @@ beforeEach(() => {
afterEach(() => { afterEach(() => {
host.remove() 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', () => { describe('renderWatch', () => {
test('effect', async () => { test('effect', async () => {
@ -53,16 +76,26 @@ describe('renderWatch', () => {
expect(dummy).toBe(1) expect(dummy).toBe(1)
}) })
test('scheduling order', async () => { test('should run with the scheduling order', async () => {
const calls: string[] = [] const calls: string[] = []
const demo = defineComponent({ const mount = createDemo(
setup() { () => {
// setup
const source = ref(0) const source = ref(0)
const renderSource = ref(0) const renderSource = ref(0)
const change = () => source.value++ const change = () => source.value++
const changeRender = () => renderSource.value++ const changeRender = () => renderSource.value++
// Life Cycle Hooks
onUpdated(() => {
calls.push(`updated ${source.value}`)
})
onBeforeUpdate(() => {
calls.push(`beforeUpdate ${source.value}`)
})
// Watch API
watchPostEffect(() => { watchPostEffect(() => {
const current = source.value const current = source.value
calls.push(`post ${current}`) calls.push(`post ${current}`)
@ -78,17 +111,11 @@ describe('renderWatch', () => {
calls.push(`sync ${current}`) calls.push(`sync ${current}`)
onEffectCleanup(() => calls.push(`sync cleanup ${current}`)) onEffectCleanup(() => calls.push(`sync cleanup ${current}`))
}) })
const __returned__ = { source, change, renderSource, changeRender } return { source, change, renderSource, changeRender }
Object.defineProperty(__returned__, '__isScriptSetup', {
enumerable: false,
value: true,
})
return __returned__
}, },
}) // render
(_ctx) => {
demo.render = (_ctx: any) => { // Render Watch API
const t0 = template('<div></div>')
renderEffect(() => { renderEffect(() => {
const current = _ctx.renderSource const current = _ctx.renderSource
calls.push(`renderEffect ${current}`) calls.push(`renderEffect ${current}`)
@ -101,10 +128,11 @@ describe('renderWatch', () => {
onEffectCleanup(() => calls.push(`renderWatch cleanup ${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 const { change, changeRender } = instance.setupState as any
expect(calls).toEqual(['pre 0', 'sync 0', 'renderEffect 0']) expect(calls).toEqual(['pre 0', 'sync 0', 'renderEffect 0'])
@ -114,8 +142,10 @@ describe('renderWatch', () => {
expect(calls).toEqual(['post 0']) expect(calls).toEqual(['post 0'])
calls.length = 0 calls.length = 0
// Update
changeRender() changeRender()
change() change()
expect(calls).toEqual(['sync cleanup 0', 'sync 1']) expect(calls).toEqual(['sync cleanup 0', 'sync 1'])
calls.length = 0 calls.length = 0
@ -123,11 +153,75 @@ describe('renderWatch', () => {
expect(calls).toEqual([ expect(calls).toEqual([
'pre cleanup 0', 'pre cleanup 0',
'pre 1', 'pre 1',
'beforeUpdate 1',
'renderEffect cleanup 0', 'renderEffect cleanup 0',
'renderEffect 1', 'renderEffect 1',
'renderWatch 1', 'renderWatch 1',
'post cleanup 0', 'post cleanup 0',
'post 1', '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 { EMPTY_OBJ } from '@vue/shared'
import type { Block } from './render' import type { Block } from './render'
@ -47,6 +53,7 @@ export interface ComponentInternalInstance {
// lifecycle // lifecycle
get isMounted(): boolean get isMounted(): boolean
get isUnmounted(): boolean get isUnmounted(): boolean
isUpdating: boolean
isUnmountedRef: Ref<boolean> isUnmountedRef: Ref<boolean>
isMountedRef: Ref<boolean> isMountedRef: Ref<boolean>
// TODO: registory of provides, lifecycles, ... // TODO: registory of provides, lifecycles, ...
@ -150,11 +157,18 @@ export const createComponentInstance = (
// lifecycle // lifecycle
get isMounted() { get isMounted() {
return isMountedRef.value pauseTracking()
const value = isMountedRef.value
resetTracking()
return value
}, },
get isUnmounted() { get isUnmounted() {
return isUnmountedRef.value pauseTracking()
const value = isUnmountedRef.value
resetTracking()
return value
}, },
isUpdating: false,
isMountedRef, isMountedRef,
isUnmountedRef, isUnmountedRef,
// TODO: registory of provides, appContext, lifecycles, ... // 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 { 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> export type DirectiveModifiers<M extends string = string> = Record<M, boolean>
@ -27,7 +29,7 @@ export type DirectiveHookName =
| 'created' | 'created'
| 'beforeMount' | 'beforeMount'
| 'mounted' | 'mounted'
// | 'beforeUpdate' | 'beforeUpdate'
| 'updated' | 'updated'
| 'beforeUnmount' | 'beforeUnmount'
| 'unmounted' | 'unmounted'
@ -93,12 +95,12 @@ export function withDirectives<T extends Node>(
} }
bindings.push(binding) bindings.push(binding)
callDirectiveHook(node, binding, 'created') callDirectiveHook(node, binding, instance, 'created')
watchEffect(() => { // register source
if (!instance.isMountedRef.value) return if (source) {
callDirectiveHook(node, binding, 'updated') renderWatch(source, NOOP)
}) }
} }
return node return node
@ -114,7 +116,7 @@ export function invokeDirectiveHook(
for (const node of nodes) { for (const node of nodes) {
const directives = instance.dirs.get(node) || [] const directives = instance.dirs.get(node) || []
for (const binding of directives) { for (const binding of directives) {
callDirectiveHook(node, binding, name) callDirectiveHook(node, binding, instance, name)
} }
} }
} }
@ -122,6 +124,7 @@ export function invokeDirectiveHook(
function callDirectiveHook( function callDirectiveHook(
node: Node, node: Node,
binding: DirectiveBinding, binding: DirectiveBinding,
instance: ComponentInternalInstance | null,
name: DirectiveHookName, name: DirectiveHookName,
) { ) {
const { dir } = binding const { dir } = binding
@ -129,9 +132,14 @@ function callDirectiveHook(
if (!hook) return if (!hook) return
const newValue = binding.source ? binding.source() : undefined const newValue = binding.source ? binding.source() : undefined
if (name === 'updated' && binding.value === newValue) return
binding.oldValue = binding.value
binding.value = newValue 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 { import {
type BaseWatchErrorCodes, type BaseWatchErrorCodes,
type BaseWatchMiddleware,
type BaseWatchOptions, type BaseWatchOptions,
baseWatch, baseWatch,
getCurrentScope, getCurrentScope,
} from '@vue/reactivity' } from '@vue/reactivity'
import { NOOP, remove } from '@vue/shared' import { NOOP, invokeArrayFns, remove } from '@vue/shared'
import { currentInstance } from './component' import { type ComponentInternalInstance, currentInstance } from './component'
import { createVaporRenderingScheduler } from './scheduler' import {
createVaporRenderingScheduler,
queuePostRenderEffect,
} from './scheduler'
import { handleError as handleErrorWithInstance } from './errorHandling' import { handleError as handleErrorWithInstance } from './errorHandling'
import { warn } from './warning' import { warn } from './warning'
import { invokeDirectiveHook } from './directive'
type WatchStopHandle = () => void type WatchStopHandle = () => void
@ -28,8 +33,6 @@ function doWatch(source: any, cb?: any): WatchStopHandle {
if (__DEV__) extendOptions.onWarn = warn if (__DEV__) extendOptions.onWarn = warn
// TODO: Life Cycle Hooks
// TODO: SSR // TODO: SSR
// if (__SSR__) {} // if (__SSR__) {}
@ -40,6 +43,8 @@ function doWatch(source: any, cb?: any): WatchStopHandle {
handleErrorWithInstance(err, instance, type) handleErrorWithInstance(err, instance, type)
extendOptions.scheduler = createVaporRenderingScheduler(instance) extendOptions.scheduler = createVaporRenderingScheduler(instance)
extendOptions.middleware = createMiddleware(instance)
let effect = baseWatch(source, cb, extendOptions) let effect = baseWatch(source, cb, extendOptions)
const unwatch = !effect const unwatch = !effect
@ -53,3 +58,44 @@ function doWatch(source: any, cb?: any): WatchStopHandle {
return unwatch 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 { Scheduler } from '@vue/reactivity'
import type { ComponentInternalInstance } from './component' import type { ComponentInternalInstance } from './component'
import { isArray } from '@vue/shared'
export interface SchedulerJob extends Function { export interface SchedulerJob extends Function {
id?: number id?: number
@ -73,7 +74,8 @@ function queueJob(job: SchedulerJob) {
} }
} }
export function queuePostRenderEffect(cb: SchedulerJob) { export function queuePostRenderEffect(cb: SchedulerJobs) {
if (!isArray(cb)) {
if ( if (
!activePostFlushCbs || !activePostFlushCbs ||
!activePostFlushCbs.includes( !activePostFlushCbs.includes(
@ -83,6 +85,12 @@ export function queuePostRenderEffect(cb: SchedulerJob) {
) { ) {
pendingPostFlushCbs.push(cb) 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() queueFlush()
} }

View File

@ -5,6 +5,8 @@ import {
onMounted, onMounted,
onBeforeMount, onBeforeMount,
getCurrentInstance, getCurrentInstance,
onBeforeUpdate,
onUpdated,
} from 'vue/vapor' } from 'vue/vapor'
const instance = getCurrentInstance()! const instance = getCurrentInstance()!
@ -26,12 +28,24 @@ onMounted(() => {
count.value++ count.value++
}, 1000) }, 1000)
}) })
onBeforeUpdate(() => {
console.log('before updated')
})
onUpdated(() => {
console.log('updated')
})
const log = (arg: any) => {
console.log('callback in render effect')
return arg
}
</script> </script>
<template> <template>
<div> <div>
<h1 class="red">Counter</h1> <h1 class="red">Counter</h1>
<div>The number is {{ count }}.</div> <div>The number is {{ log(count) }}.</div>
<div>{{ count }} * 2 = {{ double }}</div> <div>{{ count }} * 2 = {{ double }}</div>
<div style="display: flex; gap: 8px"> <div style="display: flex; gap: 8px">
<button @click="inc">inc</button> <button @click="inc">inc</button>

View File

@ -2,6 +2,7 @@
import { ObjectDirective, FunctionDirective, ref } from '@vue/vapor' import { ObjectDirective, FunctionDirective, ref } from '@vue/vapor'
const text = ref('created (overwrite by v-text), ') const text = ref('created (overwrite by v-text), ')
const counter = ref(0)
const vDirective: ObjectDirective<HTMLDivElement, undefined> = { const vDirective: ObjectDirective<HTMLDivElement, undefined> = {
created(node) { created(node) {
if (!node.parentElement) { if (!node.parentElement) {
@ -17,9 +18,15 @@ const vDirective: ObjectDirective<HTMLDivElement, undefined> = {
mounted(node) { mounted(node) {
if (node.parentElement) node.textContent += 'mounted, ' 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) => { const vDirectiveSimple: FunctionDirective<HTMLDivElement> = (node, binding) => {
console.log(node, binding) console.log('v-directive-simple:', node, binding)
} }
const handleClick = () => { const handleClick = () => {
text.value = 'change' text.value = 'change'
@ -33,4 +40,15 @@ const handleClick = () => {
v-directive-simple="text" v-directive-simple="text"
@click="handleClick" @click="handleClick"
/> />
<button @click="counter++">
{{ counter }} (Click to Update Other Element)
</button>
</template> </template>
<style>
html {
color-scheme: dark;
background-color: #000;
padding: 10px;
}
</style>