BREAKING CHANGE: `watch` behavior has been adjusted.
- When using the `watch(source, callback, options?)` signature, the
callback now fires lazily by default (consistent with 2.x
behavior).
Note that the `watch(effect, options?)` signature is still eager,
since it must invoke the `effect` immediately to collect
dependencies.
- The `lazy` option has been replaced by the opposite `immediate`
option, which defaults to `false`. (It's ignored when using the
effect signature)
- Due to the above changes, the `watch` option in Options API now
behaves exactly the same as 2.x.
- When using the effect signature or `{ immediate: true }`, the
intital execution is now performed synchronously instead of
deferred until the component is mounted. This is necessary for
certain use cases to work properly with `async setup()` and
Suspense.
The side effect of this is the immediate watcher invocation will
no longer have access to the mounted DOM. However, the watcher can
be initiated inside `onMounted` to retain previous behavior.
function assertCall(spy: jest.Mock, callIndex: number, args: any[]) {
expect(spy.mock.calls[callIndex].slice(0, 2)).toMatchObject(args)
+ expect(spy).toHaveReturnedWith(ctx)
}
- assertCall(spyA, 0, [1, undefined])
- assertCall(spyB, 0, [2, undefined])
- assertCall(spyC, 0, [{ qux: 3 }, undefined])
- expect(spyA).toHaveReturnedWith(ctx)
- expect(spyB).toHaveReturnedWith(ctx)
- expect(spyC).toHaveReturnedWith(ctx)
-
ctx.foo++
await nextTick()
- expect(spyA).toHaveBeenCalledTimes(2)
- assertCall(spyA, 1, [2, 1])
+ expect(spyA).toHaveBeenCalledTimes(1)
+ assertCall(spyA, 0, [2, 1])
ctx.bar++
await nextTick()
- expect(spyB).toHaveBeenCalledTimes(2)
- assertCall(spyB, 1, [3, 2])
+ expect(spyB).toHaveBeenCalledTimes(1)
+ assertCall(spyB, 0, [3, 2])
ctx.baz.qux++
await nextTick()
- expect(spyC).toHaveBeenCalledTimes(2)
+ expect(spyC).toHaveBeenCalledTimes(1)
// new and old objects have same identity
- assertCall(spyC, 1, [{ qux: 4 }, { qux: 4 }])
+ assertCall(spyC, 0, [{ qux: 4 }, { qux: 4 }])
})
test('watch array', async () => {
function assertCall(spy: jest.Mock, callIndex: number, args: any[]) {
expect(spy.mock.calls[callIndex].slice(0, 2)).toMatchObject(args)
+ expect(spy).toHaveReturnedWith(ctx)
}
- assertCall(spyA, 0, [1, undefined])
- assertCall(spyB, 0, [2, undefined])
- assertCall(spyC, 0, [{ qux: 3 }, undefined])
- expect(spyA).toHaveReturnedWith(ctx)
- expect(spyB).toHaveReturnedWith(ctx)
- expect(spyC).toHaveReturnedWith(ctx)
-
ctx.foo++
await nextTick()
- expect(spyA).toHaveBeenCalledTimes(2)
- assertCall(spyA, 1, [2, 1])
+ expect(spyA).toHaveBeenCalledTimes(1)
+ assertCall(spyA, 0, [2, 1])
ctx.bar++
await nextTick()
- expect(spyB).toHaveBeenCalledTimes(2)
- assertCall(spyB, 1, [3, 2])
+ expect(spyB).toHaveBeenCalledTimes(1)
+ assertCall(spyB, 0, [3, 2])
ctx.baz.qux++
await nextTick()
- expect(spyC).toHaveBeenCalledTimes(2)
+ expect(spyC).toHaveBeenCalledTimes(1)
// new and old objects have same identity
- assertCall(spyC, 1, [{ qux: 4 }, { qux: 4 }])
+ assertCall(spyC, 0, [{ qux: 4 }, { qux: 4 }])
})
test('provide/inject', () => {
describe('api: watch', () => {
mockWarn()
- it('basic usage', async () => {
+ it('watch(effect)', async () => {
const state = reactive({ count: 0 })
let dummy
watch(() => {
dummy = state.count
})
- await nextTick()
expect(dummy).toBe(0)
state.count++
expect(dummy).toBe(1)
})
- it('triggers when initial value is null', async () => {
- const state = ref(null)
- const spy = jest.fn()
- watch(() => state.value, spy)
- await nextTick()
- expect(spy).toHaveBeenCalled()
- })
-
- it('triggers when initial value is undefined', async () => {
- const state = ref()
- const spy = jest.fn()
- watch(() => state.value, spy)
- await nextTick()
- expect(spy).toHaveBeenCalled()
- state.value = 3
- await nextTick()
- expect(spy).toHaveBeenCalledTimes(2)
- // testing if undefined can trigger the watcher
- state.value = undefined
- await nextTick()
- expect(spy).toHaveBeenCalledTimes(3)
- // it shouldn't trigger if the same value is set
- state.value = undefined
- await nextTick()
- expect(spy).toHaveBeenCalledTimes(3)
- })
-
it('watching single source: getter', async () => {
const state = reactive({ count: 0 })
let dummy
}
}
)
- await nextTick()
- expect(dummy).toMatchObject([0, undefined])
-
state.count++
await nextTick()
expect(dummy).toMatchObject([1, 0])
prevCount + 1
}
})
- await nextTick()
- expect(dummy).toMatchObject([0, undefined])
-
count.value++
await nextTick()
expect(dummy).toMatchObject([1, 0])
prevCount + 1
}
})
- await nextTick()
- expect(dummy).toMatchObject([1, undefined])
-
count.value++
await nextTick()
expect(dummy).toMatchObject([2, 1])
vals.concat(1)
oldVals.concat(1)
})
- await nextTick()
- expect(dummy).toMatchObject([[1, 1, 2], []])
state.count++
count.value++
count + 1
oldStatus === true
})
- await nextTick()
- expect(dummy).toMatchObject([[1, false], []])
state.count++
status.value = true
const stop = watch(() => {
dummy = state.count
})
- await nextTick()
expect(dummy).toBe(0)
stop()
expect(dummy).toBe(0)
})
- it('cleanup registration (basic)', async () => {
+ it('cleanup registration (effect)', async () => {
const state = reactive({ count: 0 })
const cleanup = jest.fn()
let dummy
onCleanup(cleanup)
dummy = state.count
})
- await nextTick()
expect(dummy).toBe(0)
state.count++
onCleanup(cleanup)
dummy = count
})
+
+ count.value++
await nextTick()
- expect(dummy).toBe(0)
+ expect(cleanup).toHaveBeenCalledTimes(0)
+ expect(dummy).toBe(1)
count.value++
await nextTick()
expect(cleanup).toHaveBeenCalledTimes(1)
- expect(dummy).toBe(1)
+ expect(dummy).toBe(2)
stop()
expect(cleanup).toHaveBeenCalledTimes(2)
})
- it('flush timing: post', async () => {
+ it('flush timing: post (default)', async () => {
const count = ref(0)
+ let callCount = 0
const assertion = jest.fn(count => {
- expect(serializeInner(root)).toBe(`${count}`)
+ callCount++
+ // on mount, the watcher callback should be called before DOM render
+ // on update, should be called after the count is updated
+ const expectedDOM = callCount === 1 ? `` : `${count}`
+ expect(serializeInner(root)).toBe(expectedDOM)
})
const Comp = {
}
const root = nodeOps.createElement('div')
render(h(Comp), root)
- await nextTick()
expect(assertion).toHaveBeenCalledTimes(1)
count.value++
}
const root = nodeOps.createElement('div')
render(h(Comp), root)
- await nextTick()
expect(assertion).toHaveBeenCalledTimes(1)
count.value++
}
const root = nodeOps.createElement('div')
render(h(Comp), root)
- await nextTick()
expect(assertion).toHaveBeenCalledTimes(1)
count.value++
{ deep: true }
)
- await nextTick()
- expect(dummy).toEqual([0, 1, 1, true])
-
state.nested.count++
await nextTick()
expect(dummy).toEqual([1, 1, 1, true])
expect(dummy).toEqual([1, 2, 2, false])
})
- it('lazy', async () => {
+ it('immediate', async () => {
const count = ref(0)
const cb = jest.fn()
- watch(count, cb, { lazy: true })
- await nextTick()
- expect(cb).not.toHaveBeenCalled()
+ watch(count, cb, { immediate: true })
+ expect(cb).toHaveBeenCalledTimes(1)
count.value++
await nextTick()
- expect(cb).toHaveBeenCalled()
+ expect(cb).toHaveBeenCalledTimes(2)
})
- it('ignore lazy option when using simple callback', async () => {
+ it('immediate: triggers when initial value is null', async () => {
+ const state = ref(null)
+ const spy = jest.fn()
+ watch(() => state.value, spy, { immediate: true })
+ expect(spy).toHaveBeenCalled()
+ })
+
+ it('immediate: triggers when initial value is undefined', async () => {
+ const state = ref()
+ const spy = jest.fn()
+ watch(() => state.value, spy, { immediate: true })
+ expect(spy).toHaveBeenCalled()
+ state.value = 3
+ await nextTick()
+ expect(spy).toHaveBeenCalledTimes(2)
+ // testing if undefined can trigger the watcher
+ state.value = undefined
+ await nextTick()
+ expect(spy).toHaveBeenCalledTimes(3)
+ // it shouldn't trigger if the same value is set
+ state.value = undefined
+ await nextTick()
+ expect(spy).toHaveBeenCalledTimes(3)
+ })
+
+ it('warn immediate option when using effect signature', async () => {
const count = ref(0)
let dummy
// @ts-ignore
() => {
dummy = count.value
},
- { lazy: true }
+ { immediate: false }
)
- expect(dummy).toBeUndefined()
- expect(`lazy option is only respected`).toHaveBeenWarned()
-
- await nextTick()
expect(dummy).toBe(0)
+ expect(`"immediate" option is only respected`).toHaveBeenWarned()
count.value++
await nextTick()
deps.push(p.then(() => Promise.resolve()))
watch(() => {
+ calls.push('immediate effect')
+ })
+
+ const count = ref(0)
+ watch(count, v => {
calls.push('watch callback')
})
+ count.value++ // trigger the watcher now
onMounted(() => {
calls.push('mounted')
const root = nodeOps.createElement('div')
render(h(Comp), root)
expect(serializeInner(root)).toBe(`<div>fallback</div>`)
- expect(calls).toEqual([])
+ expect(calls).toEqual([`immediate effect`])
await Promise.all(deps)
await nextTick()
expect(serializeInner(root)).toBe(`<div>async</div>`)
- expect(calls).toEqual([`watch callback`, `mounted`])
+ expect(calls).toEqual([`immediate effect`, `watch callback`, `mounted`])
// effects inside an already resolved suspense should happen at normal timing
toggle.value = false
await nextTick()
+ await nextTick()
expect(serializeInner(root)).toBe(`<!---->`)
- expect(calls).toEqual([`watch callback`, `mounted`, 'unmounted'])
+ expect(calls).toEqual([
+ `immediate effect`,
+ `watch callback`,
+ `mounted`,
+ 'unmounted'
+ ])
})
test('content update before suspense resolve', async () => {
deps.push(p)
watch(() => {
+ calls.push('immediate effect')
+ })
+
+ const count = ref(0)
+ watch(count, () => {
calls.push('watch callback')
})
+ count.value++ // trigger the watcher now
onMounted(() => {
calls.push('mounted')
const root = nodeOps.createElement('div')
render(h(Comp), root)
expect(serializeInner(root)).toBe(`<div>fallback</div>`)
- expect(calls).toEqual([])
+ expect(calls).toEqual(['immediate effect'])
// remove the async dep before it's resolved
toggle.value = false
await Promise.all(deps)
await nextTick()
expect(serializeInner(root)).toBe(`<!---->`)
- // should discard effects
- expect(calls).toEqual([])
+ // should discard effects (except for immediate ones)
+ expect(calls).toEqual(['immediate effect'])
})
test('unmount suspense after resolve', async () => {
expect(fn).toHaveBeenCalledWith(err, 'ref function')
})
- test('in watch (simple usage)', () => {
+ test('in watch (effect)', () => {
const err = new Error('foo')
const fn = jest.fn()
expect(fn).toHaveBeenCalledWith(err, 'watcher callback')
})
- test('in watch getter', () => {
+ test('in watch (getter)', () => {
const err = new Error('foo')
const fn = jest.fn()
expect(fn).toHaveBeenCalledWith(err, 'watcher getter')
})
- test('in watch callback', () => {
+ test('in watch (callback)', async () => {
const err = new Error('foo')
const fn = jest.fn()
}
}
+ const count = ref(0)
const Child = {
setup() {
watch(
- () => 1,
+ () => count.value,
() => {
throw err
}
}
render(h(Comp), nodeOps.createElement('div'))
+
+ count.value++
+ await nextTick()
expect(fn).toHaveBeenCalledWith(err, 'watcher callback')
})
[K in keyof T]: T[K] extends WatchSource<infer V> ? V : never
}
-type MapOldSources<T, Lazy> = {
+type MapOldSources<T, Immediate> = {
[K in keyof T]: T[K] extends WatchSource<infer V>
- ? Lazy extends true ? V : (V | undefined)
+ ? Immediate extends true ? (V | undefined) : V
: never
}
export type CleanupRegistrator = (invalidate: () => void) => void
-export interface WatchOptions<Lazy = boolean> {
- lazy?: Lazy
+export interface BaseWatchOptions {
flush?: 'pre' | 'post' | 'sync'
- deep?: boolean
onTrack?: ReactiveEffectOptions['onTrack']
onTrigger?: ReactiveEffectOptions['onTrigger']
}
+export interface WatchOptions<Immediate = boolean> extends BaseWatchOptions {
+ immediate?: Immediate
+ deep?: boolean
+}
+
export type StopHandle = () => void
const invoke = (fn: Function) => fn()
// overload #1: simple effect
export function watch(
effect: WatchEffect,
- options?: WatchOptions<false>
+ options?: BaseWatchOptions
): StopHandle
// overload #2: single source + cb
-export function watch<T, Lazy extends Readonly<boolean> = false>(
+export function watch<T, Immediate extends Readonly<boolean> = false>(
source: WatchSource<T>,
- cb: WatchCallback<T, Lazy extends true ? T : (T | undefined)>,
- options?: WatchOptions<Lazy>
+ cb: WatchCallback<T, Immediate extends true ? (T | undefined) : T>,
+ options?: WatchOptions<Immediate>
): StopHandle
// overload #3: array of multiple sources + cb
// of all possible value types.
export function watch<
T extends Readonly<WatchSource<unknown>[]>,
- Lazy extends Readonly<boolean> = false
+ Immediate extends Readonly<boolean> = false
>(
sources: T,
- cb: WatchCallback<MapSources<T>, MapOldSources<T, Lazy>>,
- options?: WatchOptions<Lazy>
+ cb: WatchCallback<MapSources<T>, MapOldSources<T, Immediate>>,
+ options?: WatchOptions<Immediate>
): StopHandle
// implementation
cbOrOptions?: WatchCallback<T> | WatchOptions,
options?: WatchOptions
): StopHandle {
- if (isInSSRComponentSetup && !(options && options.flush === 'sync')) {
- // component watchers during SSR are no-op
- return NOOP
- } else if (isFunction(cbOrOptions)) {
- // effect callback as 2nd argument - this is a source watcher
+ if (isFunction(cbOrOptions)) {
+ // watch(source, cb)
return doWatch(effectOrSource, cbOrOptions, options)
} else {
- // 2nd argument is either missing or an options object
- // - this is a simple effect watcher
+ // watch(effect)
return doWatch(effectOrSource, null, cbOrOptions)
}
}
function doWatch(
source: WatchSource | WatchSource[] | WatchEffect,
cb: WatchCallback | null,
- { lazy, deep, flush, onTrack, onTrigger }: WatchOptions = EMPTY_OBJ
+ { immediate, deep, flush, onTrack, onTrigger }: WatchOptions = EMPTY_OBJ
): StopHandle {
+ if (__DEV__ && !cb) {
+ if (immediate !== undefined) {
+ warn(
+ `watch() "immediate" option is only respected when using the ` +
+ `watch(source, callback) signature.`
+ )
+ }
+ if (deep !== undefined) {
+ warn(
+ `watch() "deep" option is only respected when using the ` +
+ `watch(source, callback) signature.`
+ )
+ }
+ }
+
const instance = currentInstance
const suspense = currentSuspense
}
}
+ // in SSR there is no need to setup an actual effect, and it should be noop
+ // unless it's eager
+ if (__NODE_JS__ && isInSSRComponentSetup) {
+ if (!cb) {
+ getter()
+ } else if (immediate) {
+ callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [
+ getter(),
+ undefined,
+ registerCleanup
+ ])
+ }
+ return NOOP
+ }
+
let oldValue = isArray(source) ? [] : INITIAL_WATCHER_VALUE
const applyCb = cb
? () => {
scheduler: applyCb ? () => scheduler(applyCb) : scheduler
})
- if (lazy && cb) {
- oldValue = runner()
- } else {
- if (__DEV__ && lazy && !cb) {
- warn(
- `watch() lazy option is only respected when using the ` +
- `watch(getter, callback) signature.`
- )
- }
- if (applyCb) {
- scheduler(applyCb)
+ recordInstanceBoundEffect(runner)
+
+ // initial run
+ if (applyCb) {
+ if (immediate) {
+ applyCb()
} else {
- scheduler(runner)
+ oldValue = runner()
}
+ } else {
+ runner()
}
- recordInstanceBoundEffect(runner)
return () => {
stop(runner)
if (instance) {
([include, exclude]) => {
include && pruneCache(name => matches(include, name))
exclude && pruneCache(name => matches(exclude, name))
- },
- { lazy: true }
+ }
)
onBeforeUnmount(() => {
})
// lazy watcher will have consistent types for oldValue.
+watch(source, (value, oldValue) => {
+ expectType<string>(value)
+ expectType<string>(oldValue)
+})
+
+watch([source, source2, source3], (values, oldValues) => {
+ expectType<(string | number)[]>(values)
+ expectType<(string | number)[]>(oldValues)
+})
+
+// const array
+watch([source, source2, source3] as const, (values, oldValues) => {
+ expectType<Readonly<[string, string, number]>>(values)
+ expectType<Readonly<[string, string, number]>>(oldValues)
+})
+
+// source + immediate: true
watch(
source,
(value, oldValue) => {
expectType<string>(value)
- expectType<string>(oldValue)
+ expectType<string | undefined>(oldValue)
},
- { lazy: true }
+ { immediate: true }
)
watch(
[source, source2, source3],
(values, oldValues) => {
expectType<(string | number)[]>(values)
- expectType<(string | number)[]>(oldValues)
+ expectType<(string | number | undefined)[]>(oldValues)
},
- { lazy: true }
+ { immediate: true }
)
// const array
[source, source2, source3] as const,
(values, oldValues) => {
expectType<Readonly<[string, string, number]>>(values)
- expectType<Readonly<[string, string, number]>>(oldValues)
+ expectType<
+ Readonly<[string | undefined, string | undefined, number | undefined]>
+ >(oldValues)
},
- { lazy: true }
+ { immediate: true }
)