From c4aa8ea66d2ef218d2b792f3fcdeba21d402e7b3 Mon Sep 17 00:00:00 2001 From: Evan You Date: Thu, 26 May 2022 00:30:31 +0800 Subject: [PATCH] wip: reactive tests passing --- src/composition-api/reactivity/computed.ts | 4 + src/composition-api/reactivity/effect.ts | 3 + src/composition-api/reactivity/reactive.ts | 20 +- src/composition-api/reactivity/ref.ts | 2 +- src/core/instance/init.ts | 5 +- src/core/observer/index.ts | 2 +- test/helpers/to-have-warned.ts | 2 +- .../reactivity/reactive.spec.ts | 284 ++++++++++++++++++ .../composition-api/reactivity/ref.spec.ts | 6 +- 9 files changed, 318 insertions(+), 10 deletions(-) create mode 100644 test/unit/features/composition-api/reactivity/reactive.spec.ts diff --git a/src/composition-api/reactivity/computed.ts b/src/composition-api/reactivity/computed.ts index 91924b24d..b9daf8abf 100644 --- a/src/composition-api/reactivity/computed.ts +++ b/src/composition-api/reactivity/computed.ts @@ -18,3 +18,7 @@ export interface WritableComputedOptions { get: ComputedGetter set: ComputedSetter } + +export function computed() { + // TODO +} diff --git a/src/composition-api/reactivity/effect.ts b/src/composition-api/reactivity/effect.ts index 895e20301..e6bcaaeb1 100644 --- a/src/composition-api/reactivity/effect.ts +++ b/src/composition-api/reactivity/effect.ts @@ -52,3 +52,6 @@ export class ReactiveEffect { this.active = false } } + +// since we are not exposing this in Vue 2, it's used only for internal testing. +export const effect = (fn: () => any) => new ReactiveEffect(fn) diff --git a/src/composition-api/reactivity/reactive.ts b/src/composition-api/reactivity/reactive.ts index 1cd310a85..db0ce06af 100644 --- a/src/composition-api/reactivity/reactive.ts +++ b/src/composition-api/reactivity/reactive.ts @@ -1,5 +1,6 @@ import { observe, Observer } from 'core/observer' -import { Ref, UnwrapRefSimple } from './ref' +import { def, isPrimitive, warn } from 'core/util' +import type { Ref, UnwrapRefSimple, RawSymbol } from './ref' export const enum ReactiveFlags { SKIP = '__v_skip', @@ -25,7 +26,10 @@ export function reactive(target: T): UnwrapNestedRefs export function reactive(target: object) { // if trying to observe a readonly proxy, return the readonly version. if (!isReadonly(target)) { - observe(target) + const ob = observe(target) + if (__DEV__ && !ob && (target == null || isPrimitive(target))) { + warn(`value cannot be made reactive: ${String(target)}`) + } } return target } @@ -42,3 +46,15 @@ export function isReadonly(value: unknown): boolean { // TODO return !!(value && (value as Target).__v_isReadonly) } + +export function toRaw(observed: T): T { + // TODO for readonly + return observed +} + +export function markRaw( + value: T +): T & { [RawSymbol]?: true } { + def(value, ReactiveFlags.SKIP, true) + return value +} diff --git a/src/composition-api/reactivity/ref.ts b/src/composition-api/reactivity/ref.ts index 960b198ba..b0c9c4a0e 100644 --- a/src/composition-api/reactivity/ref.ts +++ b/src/composition-api/reactivity/ref.ts @@ -1,5 +1,5 @@ import { defineReactive } from 'core/observer/index' -import { isReactive, ShallowReactiveMarker } from './reactive' +import { isReactive, type ShallowReactiveMarker } from './reactive' import type { IfAny } from 'typescript/utils' import Dep from 'core/observer/dep' import { warn, isArray } from 'core/util' diff --git a/src/core/instance/init.ts b/src/core/instance/init.ts index 6a58c432d..aaf55a35d 100644 --- a/src/core/instance/init.ts +++ b/src/core/instance/init.ts @@ -26,8 +26,11 @@ export function initMixin(Vue: Component) { mark(startTag) } - // a flag to avoid this being observed + // a flag to mark this as a Vue instance without having to do instanceof + // check vm._isVue = true + // avoid instances from being observed + vm.__v_skip = true // merge options if (options && options._isComponent) { // optimize internal component instantiation diff --git a/src/core/observer/index.ts b/src/core/observer/index.ts index 8a2da69e1..7f688d423 100644 --- a/src/core/observer/index.ts +++ b/src/core/observer/index.ts @@ -120,7 +120,7 @@ export function observe(value: any, asRootData?: boolean): Observer | void { !isServerRendering() && (isArray(value) || isPlainObject(value)) && Object.isExtensible(value) && - !value._isVue + !value.__v_skip ) { ob = new Observer(value) } diff --git a/test/helpers/to-have-warned.ts b/test/helpers/to-have-warned.ts index 2aca2bdcf..cba4bc0ca 100644 --- a/test/helpers/to-have-warned.ts +++ b/test/helpers/to-have-warned.ts @@ -27,7 +27,7 @@ expect.extend({ toHaveBeenWarnedLast(received: string) { asserted.add(received) const passed = - warn.mock.calls[warn.mock.calls.length - 1][0].includes(received) + warn.mock.calls[warn.mock.calls.length - 1]?.[0].includes(received) if (passed) { return { pass: true, diff --git a/test/unit/features/composition-api/reactivity/reactive.spec.ts b/test/unit/features/composition-api/reactivity/reactive.spec.ts new file mode 100644 index 000000000..5d03554ef --- /dev/null +++ b/test/unit/features/composition-api/reactivity/reactive.spec.ts @@ -0,0 +1,284 @@ +import { ref, isRef } from 'vca/reactivity/ref' +import { reactive, isReactive, toRaw, markRaw } from 'vca/reactivity/reactive' +import { effect } from 'vca/reactivity/effect' +import { set } from 'core/observer' +// TODO import { computed } from 'vca/reactivity/computed' + +describe('reactivity/reactive', () => { + test('Object', () => { + const original = { foo: 1 } + const observed = reactive(original) + // @discrepancy Vue 2 does not create proxy objects + // expect(observed).not.toBe(original) + expect(isReactive(observed)).toBe(true) + // @discrepancy Vue 2 does not create proxy objects + // expect(isReactive(original)).toBe(false) + // get + expect(observed.foo).toBe(1) + // has + expect('foo' in observed).toBe(true) + // ownKeys + expect(Object.keys(observed)).toEqual(['foo']) + }) + + test('proto', () => { + const obj = {} + const reactiveObj = reactive(obj) + expect(isReactive(reactiveObj)).toBe(true) + // read prop of reactiveObject will cause reactiveObj[prop] to be reactive + // @ts-ignore + const prototype = reactiveObj['__proto__'] + const otherObj = { data: ['a'] } + expect(isReactive(otherObj)).toBe(false) + const reactiveOther = reactive(otherObj) + expect(isReactive(reactiveOther)).toBe(true) + expect(reactiveOther.data[0]).toBe('a') + }) + + test('nested reactives', () => { + const original = { + nested: { + foo: 1 + }, + array: [{ bar: 2 }] + } + const observed = reactive(original) + expect(isReactive(observed.nested)).toBe(true) + expect(isReactive(observed.array)).toBe(true) + expect(isReactive(observed.array[0])).toBe(true) + }) + + // @discrepancy Vue 2 does not support collections + // test('observing subtypes of IterableCollections(Map, Set)', () => { + // // subtypes of Map + // class CustomMap extends Map {} + // const cmap = reactive(new CustomMap()) + + // expect(cmap instanceof Map).toBe(true) + // expect(isReactive(cmap)).toBe(true) + + // cmap.set('key', {}) + // expect(isReactive(cmap.get('key'))).toBe(true) + + // // subtypes of Set + // class CustomSet extends Set {} + // const cset = reactive(new CustomSet()) + + // expect(cset instanceof Set).toBe(true) + // expect(isReactive(cset)).toBe(true) + + // let dummy + // effect(() => (dummy = cset.has('value'))) + // expect(dummy).toBe(false) + // cset.add('value') + // expect(dummy).toBe(true) + // cset.delete('value') + // expect(dummy).toBe(false) + // }) + + // test('observing subtypes of WeakCollections(WeakMap, WeakSet)', () => { + // // subtypes of WeakMap + // class CustomMap extends WeakMap {} + // const cmap = reactive(new CustomMap()) + + // expect(cmap instanceof WeakMap).toBe(true) + // expect(isReactive(cmap)).toBe(true) + + // const key = {} + // cmap.set(key, {}) + // expect(isReactive(cmap.get(key))).toBe(true) + + // // subtypes of WeakSet + // class CustomSet extends WeakSet {} + // const cset = reactive(new CustomSet()) + + // expect(cset instanceof WeakSet).toBe(true) + // expect(isReactive(cset)).toBe(true) + + // let dummy + // effect(() => (dummy = cset.has(key))) + // expect(dummy).toBe(false) + // cset.add(key) + // expect(dummy).toBe(true) + // cset.delete(key) + // expect(dummy).toBe(false) + // }) + + test('observed value should proxy mutations to original (Object)', () => { + const original: any = { foo: 1 } + const observed = reactive(original) + // set + observed.bar = 1 + expect(observed.bar).toBe(1) + expect(original.bar).toBe(1) + // delete + delete observed.foo + expect('foo' in observed).toBe(false) + expect('foo' in original).toBe(false) + }) + + test('original value change should reflect in observed value (Object)', () => { + const original: any = { foo: 1 } + const observed = reactive(original) + // set + original.bar = 1 + expect(original.bar).toBe(1) + expect(observed.bar).toBe(1) + // delete + delete original.foo + expect('foo' in original).toBe(false) + expect('foo' in observed).toBe(false) + }) + + test('setting a property with an unobserved value should wrap with reactive', () => { + const observed = reactive<{ foo?: object }>({}) + const raw = {} + set(observed, 'foo', raw) + // @discrepancy not a proxy + // expect(observed.foo).not.toBe(raw) + expect(isReactive(observed.foo)).toBe(true) + }) + + test('observing already observed value should return same Proxy', () => { + const original = { foo: 1 } + const observed = reactive(original) + const observed2 = reactive(observed) + expect(observed2).toBe(observed) + }) + + test('observing the same value multiple times should return same Proxy', () => { + const original = { foo: 1 } + const observed = reactive(original) + const observed2 = reactive(original) + expect(observed2).toBe(observed) + }) + + test('should not pollute original object with Proxies', () => { + const original: any = { foo: 1 } + const original2 = { bar: 2 } + const observed = reactive(original) + const observed2 = reactive(original2) + observed.bar = observed2 + expect(observed.bar).toBe(observed2) + expect(original.bar).toBe(original2) + }) + + test('toRaw', () => { + const original = { foo: 1 } + const observed = reactive(original) + expect(toRaw(observed)).toBe(original) + expect(toRaw(original)).toBe(original) + }) + + test('toRaw on object using reactive as prototype', () => { + const original = reactive({}) + const obj = Object.create(original) + const raw = toRaw(obj) + expect(raw).toBe(obj) + expect(raw).not.toBe(toRaw(original)) + }) + + test('should not unwrap Ref', () => { + const observedNumberRef = reactive(ref(1)) + const observedObjectRef = reactive(ref({ foo: 1 })) + + expect(isRef(observedNumberRef)).toBe(true) + expect(isRef(observedObjectRef)).toBe(true) + }) + + // TODO + // test('should unwrap computed refs', () => { + // // readonly + // const a = computed(() => 1) + // // writable + // const b = computed({ + // get: () => 1, + // set: () => {} + // }) + // const obj = reactive({ a, b }) + // // check type + // obj.a + 1 + // obj.b + 1 + // expect(typeof obj.a).toBe(`number`) + // expect(typeof obj.b).toBe(`number`) + // }) + + test('should allow setting property from a ref to another ref', () => { + const foo = ref(0) + const bar = ref(1) + const observed = reactive({ a: foo }) + let dummy + effect(() => { + dummy = observed.a + }) + expect(dummy).toBe(0) + + // @ts-ignore + observed.a = bar + expect(dummy).toBe(1) + + bar.value++ + expect(dummy).toBe(2) + }) + + test('non-observable values', () => { + const assertValue = (value: any) => { + reactive(value) + expect( + `value cannot be made reactive: ${String(value)}` + ).toHaveBeenWarnedLast() + } + + // number + assertValue(1) + // string + assertValue('foo') + // boolean + assertValue(false) + // null + assertValue(null) + // undefined + assertValue(undefined) + // symbol + const s = Symbol() + assertValue(s) + + // built-ins should work and return same value + const p = Promise.resolve() + expect(reactive(p)).toBe(p) + const r = new RegExp('') + expect(reactive(r)).toBe(r) + const d = new Date() + expect(reactive(d)).toBe(d) + }) + + test('markRaw', () => { + const obj = reactive({ + foo: { a: 1 }, + bar: markRaw({ b: 2 }) + }) + expect(isReactive(obj.foo)).toBe(true) + expect(isReactive(obj.bar)).toBe(false) + }) + + test('should not observe non-extensible objects', () => { + const obj = reactive({ + foo: Object.preventExtensions({ a: 1 }), + // sealed or frozen objects are considered non-extensible as well + bar: Object.freeze({ a: 1 }), + baz: Object.seal({ a: 1 }) + }) + expect(isReactive(obj.foo)).toBe(false) + expect(isReactive(obj.bar)).toBe(false) + expect(isReactive(obj.baz)).toBe(false) + }) + + test('should not observe objects with __v_skip', () => { + const original = { + foo: 1, + __v_skip: true + } + const observed = reactive(original) + expect(isReactive(observed)).toBe(false) + }) +}) diff --git a/test/unit/features/composition-api/reactivity/ref.spec.ts b/test/unit/features/composition-api/reactivity/ref.spec.ts index d696348e2..fd20dae85 100644 --- a/test/unit/features/composition-api/reactivity/ref.spec.ts +++ b/test/unit/features/composition-api/reactivity/ref.spec.ts @@ -9,11 +9,9 @@ import { customRef, Ref } from 'vca/reactivity/ref' -import { ReactiveEffect } from 'vca/reactivity/effect' +import { effect } from 'vca/reactivity/effect' import { isReactive, isShallow, reactive } from 'vca/reactivity/reactive' -const effect = (fn: () => any) => new ReactiveEffect(fn) - describe('reactivity/ref', () => { it('should hold a value', () => { const a = ref(1) @@ -117,7 +115,7 @@ describe('reactivity/ref', () => { expect((arr[1] as Ref).value).toBe(3) }) - // Vue 2 does not observe array properties + // @discrepancy Vue 2 does not observe array properties // it('should unwrap ref types as props of arrays', () => { // const arr = [ref(0)] // const symbolKey = Symbol('')