diff --git a/CHANGELOG.md b/CHANGELOG.md index 792f1400e..faeae81c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ * **compiler:** compiler options have been adjusted. - new option `decodeEntities` is added. - `namedCharacterReferences` option has been removed. - - `maxCRNameLength` option has been rmeoved. + - `maxCRNameLength` option has been removed. * **asyncComponent:** `retryWhen` and `maxRetries` options for `defineAsyncComponent` has been replaced by the more flexible `onError` option, per https://github.com/vuejs/rfcs/pull/148 diff --git a/packages/reactivity/__tests__/readonly.spec.ts b/packages/reactivity/__tests__/readonly.spec.ts index e009022d0..0f5b23bec 100644 --- a/packages/reactivity/__tests__/readonly.spec.ts +++ b/packages/reactivity/__tests__/readonly.spec.ts @@ -5,9 +5,6 @@ import { isReactive, isReadonly, markNonReactive, - markReadonly, - lock, - unlock, effect, ref, shallowReadonly @@ -91,22 +88,7 @@ describe('reactivity/readonly', () => { ).toHaveBeenWarnedLast() }) - it('should allow mutation when unlocked', () => { - const observed: any = readonly({ foo: 1, bar: { baz: 2 } }) - unlock() - observed.prop = 2 - observed.bar.qux = 3 - delete observed.bar.baz - delete observed.foo - lock() - expect(observed.prop).toBe(2) - expect(observed.foo).toBeUndefined() - expect(observed.bar.qux).toBe(3) - expect('baz' in observed.bar).toBe(false) - expect(`target is readonly`).not.toHaveBeenWarned() - }) - - it('should not trigger effects when locked', () => { + it('should not trigger effects', () => { const observed: any = readonly({ a: 1 }) let dummy effect(() => { @@ -118,20 +100,6 @@ describe('reactivity/readonly', () => { expect(dummy).toBe(1) expect(`target is readonly`).toHaveBeenWarned() }) - - it('should trigger effects when unlocked', () => { - const observed: any = readonly({ a: 1 }) - let dummy - effect(() => { - dummy = observed.a - }) - expect(dummy).toBe(1) - unlock() - observed.a = 2 - lock() - expect(observed.a).toBe(2) - expect(dummy).toBe(2) - }) }) describe('Array', () => { @@ -183,23 +151,7 @@ describe('reactivity/readonly', () => { expect(`target is readonly.`).toHaveBeenWarnedTimes(5) }) - it('should allow mutation when unlocked', () => { - const observed: any = readonly([{ foo: 1, bar: { baz: 2 } }]) - unlock() - observed[1] = 2 - observed.push(3) - observed[0].foo = 2 - observed[0].bar.baz = 3 - lock() - expect(observed.length).toBe(3) - expect(observed[1]).toBe(2) - expect(observed[2]).toBe(3) - expect(observed[0].foo).toBe(2) - expect(observed[0].bar.baz).toBe(3) - expect(`target is readonly`).not.toHaveBeenWarned() - }) - - it('should not trigger effects when locked', () => { + it('should not trigger effects', () => { const observed: any = readonly([{ a: 1 }]) let dummy effect(() => { @@ -215,30 +167,6 @@ describe('reactivity/readonly', () => { expect(dummy).toBe(1) expect(`target is readonly`).toHaveBeenWarnedTimes(2) }) - - it('should trigger effects when unlocked', () => { - const observed: any = readonly([{ a: 1 }]) - let dummy - effect(() => { - dummy = observed[0].a - }) - expect(dummy).toBe(1) - - unlock() - - observed[0].a = 2 - expect(observed[0].a).toBe(2) - expect(dummy).toBe(2) - - observed[0] = { a: 3 } - expect(observed[0].a).toBe(3) - expect(dummy).toBe(3) - - observed.unshift({ a: 4 }) - expect(observed[0].a).toBe(4) - expect(dummy).toBe(4) - lock() - }) }) const maps = [Map, WeakMap] @@ -276,23 +204,6 @@ describe('reactivity/readonly', () => { ).toHaveBeenWarned() }) - test('should allow mutation & trigger effect when unlocked', () => { - const map = readonly(new Collection()) - const isWeak = Collection === WeakMap - const key = {} - let dummy - effect(() => { - dummy = map.get(key) + (isWeak ? 0 : map.size) - }) - expect(dummy).toBeNaN() - unlock() - map.set(key, 1) - lock() - expect(dummy).toBe(isWeak ? 1 : 2) - expect(map.get(key)).toBe(1) - expect(`target is readonly`).not.toHaveBeenWarned() - }) - if (Collection === Map) { test('should retrieve readonly values on iteration', () => { const key1 = {} @@ -347,22 +258,6 @@ describe('reactivity/readonly', () => { ).toHaveBeenWarned() }) - test('should allow mutation & trigger effect when unlocked', () => { - const set = readonly(new Collection()) - const key = {} - let dummy - effect(() => { - dummy = set.has(key) - }) - expect(dummy).toBe(false) - unlock() - set.add(key) - lock() - expect(dummy).toBe(true) - expect(set.has(key)).toBe(true) - expect(`target is readonly`).not.toHaveBeenWarned() - }) - if (Collection === Set) { test('should retrieve readonly values on iteration', () => { const original = new Collection([{}, {}]) @@ -401,6 +296,19 @@ describe('reactivity/readonly', () => { expect(toRaw(a)).toBe(toRaw(b)) }) + test('readonly should track and trigger if wrapping reactive original', () => { + const a = reactive({ n: 1 }) + const b = readonly(a) + let dummy + effect(() => { + dummy = b.n + }) + expect(dummy).toBe(1) + a.n++ + expect(b.n).toBe(2) + expect(dummy).toBe(2) + }) + test('observing already observed value should return same Proxy', () => { const original = { foo: 1 } const observed = readonly(original) @@ -424,17 +332,6 @@ describe('reactivity/readonly', () => { expect(isReactive(obj.bar)).toBe(false) }) - test('markReadonly', () => { - const obj = reactive({ - foo: { a: 1 }, - bar: markReadonly({ b: 2 }) - }) - expect(isReactive(obj.foo)).toBe(true) - expect(isReactive(obj.bar)).toBe(true) - expect(isReadonly(obj.foo)).toBe(false) - expect(isReadonly(obj.bar)).toBe(true) - }) - test('should make ref readonly', () => { const n: any = readonly(ref(1)) n.value = 2 @@ -470,13 +367,5 @@ describe('reactivity/readonly', () => { `Set operation on key "foo" failed: target is readonly.` ).not.toHaveBeenWarned() }) - - test('should keep reactive properties reactive', () => { - const props: any = shallowReadonly({ n: reactive({ foo: 1 }) }) - unlock() - props.n = reactive({ foo: 2 }) - lock() - expect(isReactive(props.n)).toBe(true) - }) }) }) diff --git a/packages/reactivity/__tests__/ref.spec.ts b/packages/reactivity/__tests__/ref.spec.ts index 6620f7fd9..27c7a0b26 100644 --- a/packages/reactivity/__tests__/ref.spec.ts +++ b/packages/reactivity/__tests__/ref.spec.ts @@ -3,12 +3,13 @@ import { effect, reactive, isRef, + toRef, toRefs, Ref, isReactive } from '../src/index' import { computed } from '@vue/runtime-dom' -import { shallowRef, unref } from '../src/ref' +import { shallowRef, unref, customRef } from '../src/ref' describe('reactivity/ref', () => { it('should hold a value', () => { @@ -168,6 +169,34 @@ describe('reactivity/ref', () => { expect(isRef({ value: 0 })).toBe(false) }) + test('toRef', () => { + const a = reactive({ + x: 1 + }) + const x = toRef(a, 'x') + expect(isRef(x)).toBe(true) + expect(x.value).toBe(1) + + // source -> proxy + a.x = 2 + expect(x.value).toBe(2) + + // proxy -> source + x.value = 3 + expect(a.x).toBe(3) + + // reactivity + let dummyX + effect(() => { + dummyX = x.value + }) + expect(dummyX).toBe(x.value) + + // mutating source should trigger effect using the proxy refs + a.x = 4 + expect(dummyX).toBe(4) + }) + test('toRefs', () => { const a = reactive({ x: 1, @@ -208,4 +237,35 @@ describe('reactivity/ref', () => { expect(dummyX).toBe(4) expect(dummyY).toBe(5) }) + + test('customRef', () => { + let value = 1 + let _trigger: () => void + + const custom = customRef((track, trigger) => ({ + get() { + track() + return value + }, + set(newValue: number) { + value = newValue + _trigger = trigger + } + })) + + expect(isRef(custom)).toBe(true) + + let dummy + effect(() => { + dummy = custom.value + }) + expect(dummy).toBe(1) + + custom.value = 2 + // should not trigger yet + expect(dummy).toBe(1) + + _trigger!() + expect(dummy).toBe(2) + }) }) diff --git a/packages/reactivity/src/baseHandlers.ts b/packages/reactivity/src/baseHandlers.ts index 4d5ec50f7..5fc10b027 100644 --- a/packages/reactivity/src/baseHandlers.ts +++ b/packages/reactivity/src/baseHandlers.ts @@ -1,7 +1,6 @@ import { reactive, readonly, toRaw } from './reactive' import { TrackOpTypes, TriggerOpTypes } from './operations' import { track, trigger, ITERATE_KEY } from './effect' -import { LOCKED } from './lock' import { isObject, hasOwn, isSymbol, hasChanged, isArray } from '@vue/shared' import { isRef } from './ref' @@ -12,7 +11,7 @@ const builtInSymbols = new Set( ) const get = /*#__PURE__*/ createGetter() -const shallowReactiveGet = /*#__PURE__*/ createGetter(false, true) +const shallowGet = /*#__PURE__*/ createGetter(false, true) const readonlyGet = /*#__PURE__*/ createGetter(true) const shallowReadonlyGet = /*#__PURE__*/ createGetter(true, true) @@ -36,23 +35,32 @@ const arrayInstrumentations: Record = {} function createGetter(isReadonly = false, shallow = false) { return function get(target: object, key: string | symbol, receiver: object) { - if (isArray(target) && hasOwn(arrayInstrumentations, key)) { + const targetIsArray = isArray(target) + if (targetIsArray && hasOwn(arrayInstrumentations, key)) { return Reflect.get(arrayInstrumentations, key, receiver) } const res = Reflect.get(target, key, receiver) + if (isSymbol(key) && builtInSymbols.has(key)) { return res } + if (shallow) { - track(target, TrackOpTypes.GET, key) - // TODO strict mode that returns a shallow-readonly version of the value + !isReadonly && track(target, TrackOpTypes.GET, key) return res } - // ref unwrapping, only for Objects, not for Arrays. - if (isRef(res) && !isArray(target)) { - return res.value + + if (isRef(res)) { + if (targetIsArray) { + !isReadonly && track(target, TrackOpTypes.GET, key) + return res + } else { + // ref unwrapping, only for Objects, not for Arrays. + return res.value + } } - track(target, TrackOpTypes.GET, key) + + !isReadonly && track(target, TrackOpTypes.GET, key) return isObject(res) ? isReadonly ? // need to lazy access readonly and reactive here to avoid @@ -64,27 +72,15 @@ function createGetter(isReadonly = false, shallow = false) { } const set = /*#__PURE__*/ createSetter() -const shallowReactiveSet = /*#__PURE__*/ createSetter(false, true) -const readonlySet = /*#__PURE__*/ createSetter(true) -const shallowReadonlySet = /*#__PURE__*/ createSetter(true, true) +const shallowSet = /*#__PURE__*/ createSetter(true) -function createSetter(isReadonly = false, shallow = false) { +function createSetter(shallow = false) { return function set( target: object, key: string | symbol, value: unknown, receiver: object ): boolean { - if (isReadonly && LOCKED) { - if (__DEV__) { - console.warn( - `Set operation on key "${String(key)}" failed: target is readonly.`, - target - ) - } - return true - } - const oldValue = (target as any)[key] if (!shallow) { value = toRaw(value) @@ -141,30 +137,32 @@ export const mutableHandlers: ProxyHandler = { export const readonlyHandlers: ProxyHandler = { get: readonlyGet, - set: readonlySet, has, ownKeys, - deleteProperty(target: object, key: string | symbol): boolean { - if (LOCKED) { - if (__DEV__) { - console.warn( - `Delete operation on key "${String( - key - )}" failed: target is readonly.`, - target - ) - } - return true - } else { - return deleteProperty(target, key) + set(target, key) { + if (__DEV__) { + console.warn( + `Set operation on key "${String(key)}" failed: target is readonly.`, + target + ) } + return true + }, + deleteProperty(target, key) { + if (__DEV__) { + console.warn( + `Delete operation on key "${String(key)}" failed: target is readonly.`, + target + ) + } + return true } } export const shallowReactiveHandlers: ProxyHandler = { ...mutableHandlers, - get: shallowReactiveGet, - set: shallowReactiveSet + get: shallowGet, + set: shallowSet } // Props handlers are special in the sense that it should not unwrap top-level @@ -172,6 +170,5 @@ export const shallowReactiveHandlers: ProxyHandler = { // retain the reactivity of the normal readonly object. export const shallowReadonlyHandlers: ProxyHandler = { ...readonlyHandlers, - get: shallowReadonlyGet, - set: shallowReadonlySet + get: shallowReadonlyGet } diff --git a/packages/reactivity/src/collectionHandlers.ts b/packages/reactivity/src/collectionHandlers.ts index 40e4b476a..6ab7e6fdd 100644 --- a/packages/reactivity/src/collectionHandlers.ts +++ b/packages/reactivity/src/collectionHandlers.ts @@ -1,7 +1,6 @@ import { toRaw, reactive, readonly } from './reactive' import { track, trigger, ITERATE_KEY, MAP_KEY_ITERATE_KEY } from './effect' import { TrackOpTypes, TriggerOpTypes } from './operations' -import { LOCKED } from './lock' import { isObject, capitalize, @@ -142,7 +141,7 @@ function createForEach(isReadonly: boolean) { const observed = this const target = toRaw(observed) const wrap = isReadonly ? toReadonly : toReactive - track(target, TrackOpTypes.ITERATE, ITERATE_KEY) + !isReadonly && track(target, TrackOpTypes.ITERATE, ITERATE_KEY) // important: create sure the callback is // 1. invoked with the reactive map as `this` and 3rd arg // 2. the value received should be a corresponding reactive/readonly. @@ -161,11 +160,12 @@ function createIterableMethod(method: string | symbol, isReadonly: boolean) { const isKeyOnly = method === 'keys' && isMap const innerIterator = getProto(target)[method].apply(target, args) const wrap = isReadonly ? toReadonly : toReactive - track( - target, - TrackOpTypes.ITERATE, - isKeyOnly ? MAP_KEY_ITERATE_KEY : ITERATE_KEY - ) + !isReadonly && + track( + target, + TrackOpTypes.ITERATE, + isKeyOnly ? MAP_KEY_ITERATE_KEY : ITERATE_KEY + ) // return a wrapped iterator which returns observed versions of the // values emitted from the real iterator return { @@ -187,23 +187,16 @@ function createIterableMethod(method: string | symbol, isReadonly: boolean) { } } -function createReadonlyMethod( - method: Function, - type: TriggerOpTypes -): Function { +function createReadonlyMethod(type: TriggerOpTypes): Function { return function(this: CollectionTypes, ...args: unknown[]) { - if (LOCKED) { - if (__DEV__) { - const key = args[0] ? `on key "${args[0]}" ` : `` - console.warn( - `${capitalize(type)} operation ${key}failed: target is readonly.`, - toRaw(this) - ) - } - return type === TriggerOpTypes.DELETE ? false : this - } else { - return method.apply(this, args) + if (__DEV__) { + const key = args[0] ? `on key "${args[0]}" ` : `` + console.warn( + `${capitalize(type)} operation ${key}failed: target is readonly.`, + toRaw(this) + ) } + return type === TriggerOpTypes.DELETE ? false : this } } @@ -230,10 +223,10 @@ const readonlyInstrumentations: Record = { return size((this as unknown) as IterableCollections) }, has, - add: createReadonlyMethod(add, TriggerOpTypes.ADD), - set: createReadonlyMethod(set, TriggerOpTypes.SET), - delete: createReadonlyMethod(deleteEntry, TriggerOpTypes.DELETE), - clear: createReadonlyMethod(clear, TriggerOpTypes.CLEAR), + add: createReadonlyMethod(TriggerOpTypes.ADD), + set: createReadonlyMethod(TriggerOpTypes.SET), + delete: createReadonlyMethod(TriggerOpTypes.DELETE), + clear: createReadonlyMethod(TriggerOpTypes.CLEAR), forEach: createForEach(true) } diff --git a/packages/reactivity/src/effect.ts b/packages/reactivity/src/effect.ts index 6a09640e8..aab4ae6db 100644 --- a/packages/reactivity/src/effect.ts +++ b/packages/reactivity/src/effect.ts @@ -12,6 +12,7 @@ const targetMap = new WeakMap() export interface ReactiveEffect { (...args: any[]): T _isEffect: true + id: number active: boolean raw: () => T deps: Array @@ -21,7 +22,7 @@ export interface ReactiveEffect { export interface ReactiveEffectOptions { lazy?: boolean computed?: boolean - scheduler?: (job: () => void) => void + scheduler?: (job: ReactiveEffect) => void onTrack?: (event: DebuggerEvent) => void onTrigger?: (event: DebuggerEvent) => void onStop?: () => void @@ -74,6 +75,8 @@ export function stop(effect: ReactiveEffect) { } } +let uid = 0 + function createReactiveEffect( fn: (...args: any[]) => T, options: ReactiveEffectOptions @@ -96,6 +99,7 @@ function createReactiveEffect( } } } as ReactiveEffect + effect.id = uid++ effect._isEffect = true effect.active = true effect.raw = fn diff --git a/packages/reactivity/src/index.ts b/packages/reactivity/src/index.ts index 21a9eae8f..280b09c51 100644 --- a/packages/reactivity/src/index.ts +++ b/packages/reactivity/src/index.ts @@ -1,4 +1,14 @@ -export { ref, unref, shallowRef, isRef, toRefs, Ref, UnwrapRef } from './ref' +export { + ref, + unref, + shallowRef, + isRef, + toRef, + toRefs, + customRef, + Ref, + UnwrapRef +} from './ref' export { reactive, isReactive, @@ -7,7 +17,6 @@ export { isReadonly, shallowReadonly, toRaw, - markReadonly, markNonReactive } from './reactive' export { @@ -31,5 +40,4 @@ export { ReactiveEffectOptions, DebuggerEvent } from './effect' -export { lock, unlock } from './lock' export { TrackOpTypes, TriggerOpTypes } from './operations' diff --git a/packages/reactivity/src/lock.ts b/packages/reactivity/src/lock.ts deleted file mode 100644 index 417526be3..000000000 --- a/packages/reactivity/src/lock.ts +++ /dev/null @@ -1,10 +0,0 @@ -// global immutability lock -export let LOCKED = true - -export function lock() { - LOCKED = true -} - -export function unlock() { - LOCKED = false -} diff --git a/packages/reactivity/src/reactive.ts b/packages/reactivity/src/reactive.ts index bc1b72524..be4f8e9b5 100644 --- a/packages/reactivity/src/reactive.ts +++ b/packages/reactivity/src/reactive.ts @@ -2,14 +2,14 @@ import { isObject, toRawType } from '@vue/shared' import { mutableHandlers, readonlyHandlers, - shallowReadonlyHandlers, - shallowReactiveHandlers + shallowReactiveHandlers, + shallowReadonlyHandlers } from './baseHandlers' import { mutableCollectionHandlers, readonlyCollectionHandlers } from './collectionHandlers' -import { UnwrapRef, Ref, isRef } from './ref' +import { UnwrapRef, Ref } from './ref' import { makeMap } from '@vue/shared' // WeakMaps that store {raw <-> observed} pairs. @@ -20,7 +20,6 @@ const readonlyToRaw = new WeakMap() // WeakSets for values that are marked readonly or non-reactive during // observable creation. -const readonlyValues = new WeakSet() const nonReactiveValues = new WeakSet() const collectionTypes = new Set([Set, Map, WeakMap, WeakSet]) @@ -47,13 +46,6 @@ export function reactive(target: object) { if (readonlyToRaw.has(target)) { return target } - // target is explicitly marked as readonly by user - if (readonlyValues.has(target)) { - return readonly(target) - } - if (isRef(target)) { - return target - } return createReactiveObject( target, rawToReactive, @@ -63,14 +55,22 @@ export function reactive(target: object) { ) } +// Return a reactive-copy of the original object, where only the root level +// properties are reactive, and does NOT unwrap refs nor recursively convert +// returned properties. +export function shallowReactive(target: T): T { + return createReactiveObject( + target, + rawToReactive, + reactiveToRaw, + shallowReactiveHandlers, + mutableCollectionHandlers + ) +} + export function readonly( target: T ): Readonly> { - // value is a mutable observable, retrieve its original and return - // a readonly version. - if (reactiveToRaw.has(target)) { - target = reactiveToRaw.get(target) - } return createReactiveObject( target, rawToReadonly, @@ -96,19 +96,6 @@ export function shallowReadonly( ) } -// Return a reactive-copy of the original object, where only the root level -// properties are reactive, and does NOT unwrap refs nor recursively convert -// returned properties. -export function shallowReactive(target: T): T { - return createReactiveObject( - target, - rawToReactive, - reactiveToRaw, - shallowReactiveHandlers, - mutableCollectionHandlers - ) -} - function createReactiveObject( target: unknown, toProxy: WeakMap, @@ -153,12 +140,8 @@ export function isReadonly(value: unknown): boolean { } export function toRaw(observed: T): T { - return reactiveToRaw.get(observed) || readonlyToRaw.get(observed) || observed -} - -export function markReadonly(value: T): T { - readonlyValues.add(value) - return value + observed = readonlyToRaw.get(observed) || observed + return reactiveToRaw.get(observed) || observed } export function markNonReactive(value: T): T { diff --git a/packages/reactivity/src/ref.ts b/packages/reactivity/src/ref.ts index 479b5847b..2eef093e7 100644 --- a/packages/reactivity/src/ref.ts +++ b/packages/reactivity/src/ref.ts @@ -70,6 +70,31 @@ export function unref(ref: T): T extends Ref ? V : T { return isRef(ref) ? (ref.value as any) : ref } +export type CustomRefFactory = ( + track: () => void, + trigger: () => void +) => { + get: () => T + set: (value: T) => void +} + +export function customRef(factory: CustomRefFactory): Ref { + const { get, set } = factory( + () => track(r, TrackOpTypes.GET, 'value'), + () => trigger(r, TriggerOpTypes.SET, 'value') + ) + const r = { + _isRef: true, + get value() { + return get() + }, + set value(v) { + set(v) + } + } + return r as any +} + export function toRefs( object: T ): { [K in keyof T]: Ref } { @@ -78,12 +103,12 @@ export function toRefs( } const ret: any = {} for (const key in object) { - ret[key] = toProxyRef(object, key) + ret[key] = toRef(object, key) } return ret } -function toProxyRef( +export function toRef( object: T, key: K ): Ref { diff --git a/packages/runtime-core/__tests__/componentProps.spec.ts b/packages/runtime-core/__tests__/componentProps.spec.ts index 402fc0d62..9e8299ffc 100644 --- a/packages/runtime-core/__tests__/componentProps.spec.ts +++ b/packages/runtime-core/__tests__/componentProps.spec.ts @@ -175,9 +175,17 @@ describe('component props', () => { expect(proxy.foo).toBe(2) expect(proxy.bar).toEqual({ a: 1 }) - render(h(Comp, { foo: undefined, bar: { b: 2 } }), root) + render(h(Comp, { bar: { b: 2 } }), root) expect(proxy.foo).toBe(1) expect(proxy.bar).toEqual({ b: 2 }) + + render(h(Comp, { foo: 3, bar: { b: 3 } }), root) + expect(proxy.foo).toBe(3) + expect(proxy.bar).toEqual({ b: 3 }) + + render(h(Comp, { bar: { b: 4 } }), root) + expect(proxy.foo).toBe(1) + expect(proxy.bar).toEqual({ b: 4 }) }) test('optimized props updates', async () => { diff --git a/packages/runtime-core/__tests__/componentProxy.spec.ts b/packages/runtime-core/__tests__/componentProxy.spec.ts index cbe18c2ca..753dbac4b 100644 --- a/packages/runtime-core/__tests__/componentProxy.spec.ts +++ b/packages/runtime-core/__tests__/componentProxy.spec.ts @@ -3,7 +3,8 @@ import { render, getCurrentInstance, nodeOps, - createApp + createApp, + shallowReadonly } from '@vue/runtime-test' import { mockWarn } from '@vue/shared' import { ComponentInternalInstance } from '../src/component' @@ -85,10 +86,10 @@ describe('component: proxy', () => { } render(h(Comp), nodeOps.createElement('div')) expect(instanceProxy.$data).toBe(instance!.data) - expect(instanceProxy.$props).toBe(instance!.props) - expect(instanceProxy.$attrs).toBe(instance!.attrs) - expect(instanceProxy.$slots).toBe(instance!.slots) - expect(instanceProxy.$refs).toBe(instance!.refs) + expect(instanceProxy.$props).toBe(shallowReadonly(instance!.props)) + expect(instanceProxy.$attrs).toBe(shallowReadonly(instance!.attrs)) + expect(instanceProxy.$slots).toBe(shallowReadonly(instance!.slots)) + expect(instanceProxy.$refs).toBe(shallowReadonly(instance!.refs)) expect(instanceProxy.$parent).toBe( instance!.parent && instance!.parent.proxy ) diff --git a/packages/runtime-core/__tests__/scheduler.spec.ts b/packages/runtime-core/__tests__/scheduler.spec.ts index cf035c97a..a4554325a 100644 --- a/packages/runtime-core/__tests__/scheduler.spec.ts +++ b/packages/runtime-core/__tests__/scheduler.spec.ts @@ -262,4 +262,20 @@ describe('scheduler', () => { // job2 should be called only once expect(calls).toEqual(['job1', 'job2', 'job3', 'job4']) }) + + test('sort job based on id', async () => { + const calls: string[] = [] + const job1 = () => calls.push('job1') + // job1 has no id + const job2 = () => calls.push('job2') + job2.id = 2 + const job3 = () => calls.push('job3') + job3.id = 1 + + queueJob(job1) + queueJob(job2) + queueJob(job3) + await nextTick() + expect(calls).toEqual(['job3', 'job2', 'job1']) + }) }) diff --git a/packages/runtime-core/src/component.ts b/packages/runtime-core/src/component.ts index 50c449661..9bbcc8567 100644 --- a/packages/runtime-core/src/component.ts +++ b/packages/runtime-core/src/component.ts @@ -3,7 +3,8 @@ import { reactive, ReactiveEffect, pauseTracking, - resetTracking + resetTracking, + shallowReadonly } from '@vue/reactivity' import { ComponentPublicInstance, @@ -347,7 +348,7 @@ function setupStatefulComponent( setup, instance, ErrorCodes.SETUP_FUNCTION, - [instance.props, setupContext] + [__DEV__ ? shallowReadonly(instance.props) : instance.props, setupContext] ) resetTracking() currentInstance = null @@ -479,17 +480,6 @@ function finishComponentSetup( } } -const slotsHandlers: ProxyHandler = { - set: () => { - warn(`setupContext.slots is readonly.`) - return false - }, - deleteProperty: () => { - warn(`setupContext.slots is readonly.`) - return false - } -} - const attrHandlers: ProxyHandler = { get: (target, key: string) => { markAttrsAccessed() @@ -514,7 +504,7 @@ function createSetupContext(instance: ComponentInternalInstance): SetupContext { return new Proxy(instance.attrs, attrHandlers) }, get slots() { - return new Proxy(instance.slots, slotsHandlers) + return shallowReadonly(instance.slots) }, get emit() { return (event: string, ...args: any[]) => instance.emit(event, ...args) diff --git a/packages/runtime-core/src/componentOptions.ts b/packages/runtime-core/src/componentOptions.ts index ffd086983..31bb01ad0 100644 --- a/packages/runtime-core/src/componentOptions.ts +++ b/packages/runtime-core/src/componentOptions.ts @@ -15,7 +15,8 @@ import { isArray, EMPTY_OBJ, NOOP, - hasOwn + hasOwn, + isPromise } from '@vue/shared' import { computed } from './apiComputed' import { watch, WatchOptions, WatchCallback } from './apiWatch' @@ -316,6 +317,13 @@ export function applyOptions( ) } const data = dataOptions.call(ctx, ctx) + if (__DEV__ && isPromise(data)) { + warn( + `data() returned a Promise - note data() cannot be async; If you ` + + `intend to perform data fetching before component renders, use ` + + `async setup() + .` + ) + } if (!isObject(data)) { __DEV__ && warn(`data() should return an object.`) } else if (instance.data === EMPTY_OBJ) { diff --git a/packages/runtime-core/src/componentProps.ts b/packages/runtime-core/src/componentProps.ts index 0ca0dbaa0..ee773d93f 100644 --- a/packages/runtime-core/src/componentProps.ts +++ b/packages/runtime-core/src/componentProps.ts @@ -1,4 +1,4 @@ -import { toRaw, lock, unlock, shallowReadonly } from '@vue/reactivity' +import { toRaw, shallowReactive } from '@vue/reactivity' import { EMPTY_OBJ, camelize, @@ -114,7 +114,7 @@ export function initProps( if (isStateful) { // stateful - instance.props = isSSR ? props : shallowReadonly(props) + instance.props = isSSR ? props : shallowReactive(props) } else { if (!options) { // functional w/ optional props, props === attrs @@ -132,9 +132,6 @@ export function updateProps( rawProps: Data | null, optimized: boolean ) { - // allow mutation of propsProxy (which is readonly by default) - unlock() - const { props, attrs, @@ -186,7 +183,16 @@ export function updateProps( // and converted to camelCase (#955) ((kebabKey = hyphenate(key)) === key || !hasOwn(rawProps, kebabKey))) ) { - delete props[key] + if (options) { + props[key] = resolvePropValue( + options, + rawProps || EMPTY_OBJ, + key, + undefined + ) + } else { + delete props[key] + } } } for (const key in attrs) { @@ -196,9 +202,6 @@ export function updateProps( } } - // lock readonly - lock() - if (__DEV__ && rawOptions && rawProps) { validateProps(props, rawOptions) } @@ -250,25 +253,24 @@ function resolvePropValue( key: string, value: unknown ) { - let opt = options[key] - if (opt == null) { - return value - } - const hasDefault = hasOwn(opt, 'default') - // default values - if (hasDefault && value === undefined) { - const defaultValue = opt.default - value = isFunction(defaultValue) ? defaultValue() : defaultValue - } - // boolean casting - if (opt[BooleanFlags.shouldCast]) { - if (!hasOwn(props, key) && !hasDefault) { - value = false - } else if ( - opt[BooleanFlags.shouldCastTrue] && - (value === '' || value === hyphenate(key)) - ) { - value = true + const opt = options[key] + if (opt != null) { + const hasDefault = hasOwn(opt, 'default') + // default values + if (hasDefault && value === undefined) { + const defaultValue = opt.default + value = isFunction(defaultValue) ? defaultValue() : defaultValue + } + // boolean casting + if (opt[BooleanFlags.shouldCast]) { + if (!hasOwn(props, key) && !hasDefault) { + value = false + } else if ( + opt[BooleanFlags.shouldCastTrue] && + (value === '' || value === hyphenate(key)) + ) { + value = true + } } } return value diff --git a/packages/runtime-core/src/componentProxy.ts b/packages/runtime-core/src/componentProxy.ts index f30c300a3..74c5c5912 100644 --- a/packages/runtime-core/src/componentProxy.ts +++ b/packages/runtime-core/src/componentProxy.ts @@ -2,7 +2,12 @@ import { ComponentInternalInstance, Data } from './component' import { nextTick, queueJob } from './scheduler' import { instanceWatch } from './apiWatch' import { EMPTY_OBJ, hasOwn, isGloballyWhitelisted, NOOP } from '@vue/shared' -import { ReactiveEffect, UnwrapRef, toRaw } from '@vue/reactivity' +import { + ReactiveEffect, + UnwrapRef, + toRaw, + shallowReadonly +} from '@vue/reactivity' import { ExtractComputedReturns, ComponentOptionsBase, @@ -36,8 +41,8 @@ export type ComponentPublicInstance< $attrs: Data $refs: Data $slots: Slots - $root: ComponentInternalInstance | null - $parent: ComponentInternalInstance | null + $root: ComponentPublicInstance | null + $parent: ComponentPublicInstance | null $emit: EmitFn $el: any $options: ComponentOptionsBase @@ -57,10 +62,10 @@ const publicPropertiesMap: Record< $: i => i, $el: i => i.vnode.el, $data: i => i.data, - $props: i => i.props, - $attrs: i => i.attrs, - $slots: i => i.slots, - $refs: i => i.refs, + $props: i => (__DEV__ ? shallowReadonly(i.props) : i.props), + $attrs: i => (__DEV__ ? shallowReadonly(i.attrs) : i.attrs), + $slots: i => (__DEV__ ? shallowReadonly(i.slots) : i.slots), + $refs: i => (__DEV__ ? shallowReadonly(i.refs) : i.refs), $parent: i => i.parent && i.parent.proxy, $root: i => i.root && i.root.proxy, $emit: i => i.emit, diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index 4cd04f4db..be87de0f0 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -2,20 +2,21 @@ export const version = __VERSION__ export { - effect, ref, unref, shallowRef, isRef, + toRef, toRefs, + customRef, reactive, isReactive, readonly, isReadonly, shallowReactive, - toRaw, - markReadonly, - markNonReactive + shallowReadonly, + markNonReactive, + toRaw } from '@vue/reactivity' export { computed } from './apiComputed' export { watch, watchEffect } from './apiWatch' diff --git a/packages/runtime-core/src/scheduler.ts b/packages/runtime-core/src/scheduler.ts index c730730f4..e518427d2 100644 --- a/packages/runtime-core/src/scheduler.ts +++ b/packages/runtime-core/src/scheduler.ts @@ -1,7 +1,12 @@ import { ErrorCodes, callWithErrorHandling } from './errorHandling' import { isArray } from '@vue/shared' -const queue: (Function | null)[] = [] +export interface Job { + (): void + id?: number +} + +const queue: (Job | null)[] = [] const postFlushCbs: Function[] = [] const p = Promise.resolve() @@ -9,20 +14,20 @@ let isFlushing = false let isFlushPending = false const RECURSION_LIMIT = 100 -type CountMap = Map +type CountMap = Map export function nextTick(fn?: () => void): Promise { return fn ? p.then(fn) : p } -export function queueJob(job: () => void) { +export function queueJob(job: Job) { if (!queue.includes(job)) { queue.push(job) queueFlush() } } -export function invalidateJob(job: () => void) { +export function invalidateJob(job: Job) { const i = queue.indexOf(job) if (i > -1) { queue[i] = null @@ -45,11 +50,9 @@ function queueFlush() { } } -const dedupe = (cbs: Function[]): Function[] => [...new Set(cbs)] - export function flushPostFlushCbs(seen?: CountMap) { if (postFlushCbs.length) { - const cbs = dedupe(postFlushCbs) + const cbs = [...new Set(postFlushCbs)] postFlushCbs.length = 0 if (__DEV__) { seen = seen || new Map() @@ -63,6 +66,8 @@ export function flushPostFlushCbs(seen?: CountMap) { } } +const getId = (job: Job) => (job.id == null ? Infinity : job.id) + function flushJobs(seen?: CountMap) { isFlushPending = false isFlushing = true @@ -70,6 +75,18 @@ function flushJobs(seen?: CountMap) { if (__DEV__) { seen = seen || new Map() } + + // Sort queue before flush. + // This ensures that: + // 1. Components are updated from parent to child. (because parent is always + // created before the child so its render effect will have smaller + // priority number) + // 2. If a component is unmounted during a parent component's update, + // its update can be skipped. + // Jobs can never be null before flush starts, since they are only invalidated + // during execution of another flushed job. + queue.sort((a, b) => getId(a!) - getId(b!)) + while ((job = queue.shift()) !== undefined) { if (job === null) { continue @@ -88,7 +105,7 @@ function flushJobs(seen?: CountMap) { } } -function checkRecursiveUpdates(seen: CountMap, fn: Function) { +function checkRecursiveUpdates(seen: CountMap, fn: Job | Function) { if (!seen.has(fn)) { seen.set(fn, 1) } else { diff --git a/packages/runtime-core/src/vnode.ts b/packages/runtime-core/src/vnode.ts index ef6f3ab85..aeede390b 100644 --- a/packages/runtime-core/src/vnode.ts +++ b/packages/runtime-core/src/vnode.ts @@ -17,7 +17,7 @@ import { ClassComponent } from './component' import { RawSlots } from './componentSlots' -import { isReactive, Ref } from '@vue/reactivity' +import { isReactive, Ref, toRaw } from '@vue/reactivity' import { AppContext } from './apiCreateApp' import { SuspenseImpl, @@ -292,6 +292,22 @@ function _createVNode( ? ShapeFlags.FUNCTIONAL_COMPONENT : 0 + if ( + __DEV__ && + shapeFlag & ShapeFlags.STATEFUL_COMPONENT && + isReactive(type) + ) { + type = toRaw(type) + warn( + `Vue received a Component which was made a reactive object. This can ` + + `lead to unnecessary performance overhead, and should be avoided by ` + + `marking the component with \`markNonReactive\` or using \`shallowRef\` ` + + `instead of \`ref\`.`, + `\nComponent that was made reactive: `, + type + ) + } + const vnode: VNode = { _isVNode: true, type, diff --git a/packages/runtime-dom/__tests__/patchEvents.spec.ts b/packages/runtime-dom/__tests__/patchEvents.spec.ts index b870a8be3..2bb8e2ac0 100644 --- a/packages/runtime-dom/__tests__/patchEvents.spec.ts +++ b/packages/runtime-dom/__tests__/patchEvents.spec.ts @@ -134,4 +134,18 @@ describe(`runtime-dom: events patching`, () => { expect(fn).toHaveBeenCalledTimes(1) expect(fn2).toHaveBeenCalledWith(event) }) + + it('should support stopImmediatePropagation on multiple listeners', async () => { + const el = document.createElement('div') + const event = new Event('click') + const fn1 = jest.fn((e: Event) => { + e.stopImmediatePropagation() + }) + const fn2 = jest.fn() + patchProp(el, 'onClick', null, [fn1, fn2]) + el.dispatchEvent(event) + await timeout() + expect(fn1).toHaveBeenCalledTimes(1) + expect(fn2).toHaveBeenCalledTimes(0) + }) }) diff --git a/packages/runtime-dom/src/components/TransitionGroup.ts b/packages/runtime-dom/src/components/TransitionGroup.ts index 24d23b9e7..018364411 100644 --- a/packages/runtime-dom/src/components/TransitionGroup.ts +++ b/packages/runtime-dom/src/components/TransitionGroup.ts @@ -13,7 +13,6 @@ import { VNode, warn, resolveTransitionHooks, - toRaw, useTransitionState, getCurrentInstance, setTransitionHooks, @@ -21,6 +20,7 @@ import { onUpdated, SetupContext } from '@vue/runtime-core' +import { toRaw } from '@vue/reactivity' interface Position { top: number diff --git a/packages/runtime-dom/src/modules/events.ts b/packages/runtime-dom/src/modules/events.ts index 6789934c7..bb3ae9be0 100644 --- a/packages/runtime-dom/src/modules/events.ts +++ b/packages/runtime-dom/src/modules/events.ts @@ -1,4 +1,4 @@ -import { EMPTY_OBJ } from '@vue/shared' +import { EMPTY_OBJ, isArray } from '@vue/shared' import { ComponentInternalInstance, callWithAsyncErrorHandling @@ -130,7 +130,7 @@ function createInvoker( // AFTER it was attached. if (e.timeStamp >= invoker.lastUpdated - 1) { callWithAsyncErrorHandling( - invoker.value, + patchStopImmediatePropagation(e, invoker.value), instance, ErrorCodes.NATIVE_EVENT_HANDLER, [e] @@ -142,3 +142,19 @@ function createInvoker( invoker.lastUpdated = getNow() return invoker } + +function patchStopImmediatePropagation( + e: Event, + value: EventValue +): EventValue { + if (isArray(value)) { + const originalStop = e.stopImmediatePropagation + e.stopImmediatePropagation = () => { + originalStop.call(e) + ;(e as any)._stopped = true + } + return value.map(fn => (e: Event) => !(e as any)._stopped && fn(e)) + } else { + return value + } +}