From 360272bde32e93b1a8d611e4b97af1300c38e7a3 Mon Sep 17 00:00:00 2001 From: Evan You Date: Wed, 6 Jul 2022 12:49:30 +0800 Subject: [PATCH] fix(ssr/reactivity): fix composition api behavior in SSR fix #12615 --- .../test/ssr-reactivity.spec.ts | 192 ++++++++++++++++++ src/core/observer/index.ts | 109 +++++----- src/v3/reactivity/reactive.ts | 15 +- src/v3/reactivity/ref.ts | 8 +- 4 files changed, 265 insertions(+), 59 deletions(-) create mode 100644 packages/server-renderer/test/ssr-reactivity.spec.ts diff --git a/packages/server-renderer/test/ssr-reactivity.spec.ts b/packages/server-renderer/test/ssr-reactivity.spec.ts new file mode 100644 index 00000000000..f9b145c7740 --- /dev/null +++ b/packages/server-renderer/test/ssr-reactivity.spec.ts @@ -0,0 +1,192 @@ +// @vitest-environment node + +import Vue from 'vue' +import { + reactive, + ref, + isReactive, + shallowRef, + isRef, + set, + nextTick, + getCurrentInstance +} from 'v3' +import { createRenderer } from '../src' + +describe('SSR Reactive', () => { + beforeEach(() => { + // force SSR env + global.process.env.VUE_ENV = 'server' + }) + + it('should not affect non reactive APIs', () => { + expect(typeof window).toBe('undefined') + expect((Vue.observable({}) as any).__ob__).toBeUndefined() + }) + + it('reactive behavior should be consistent in SSR', () => { + const obj = reactive({ + foo: ref(1), + bar: { + baz: ref(2) + }, + arr: [{ foo: ref(3) }] + }) + expect(isReactive(obj)).toBe(true) + expect(obj.foo).toBe(1) + + expect(isReactive(obj.bar)).toBe(true) + expect(obj.bar.baz).toBe(2) + + expect(isReactive(obj.arr)).toBe(true) + expect(isReactive(obj.arr[0])).toBe(true) + expect(obj.arr[0].foo).toBe(3) + }) + + it('ref value', () => { + const r = ref({}) + expect(isReactive(r.value)).toBe(true) + }) + + it('should render', async () => { + const app = new Vue({ + setup() { + return { + count: ref(42) + } + }, + render(this: any, h) { + return h('div', this.count) + } + }) + + const serverRenderer = createRenderer() + const html = await serverRenderer.renderToString(app) + expect(html).toBe('
42
') + }) + + it('reactive + isReactive', () => { + const state = reactive({}) + expect(isReactive(state)).toBe(true) + }) + + it('shallowRef + isRef', () => { + const state = shallowRef({}) + expect(isRef(state)).toBe(true) + }) + + it('should work on objects sets with set()', () => { + const state = ref({}) + + set(state.value, 'a', {}) + expect(isReactive(state.value.a)).toBe(true) + + set(state.value, 'a', {}) + expect(isReactive(state.value.a)).toBe(true) + }) + + it('should work on arrays sets with set()', () => { + const state = ref([]) + + set(state.value, 1, {}) + expect(isReactive(state.value[1])).toBe(true) + + set(state.value, 1, {}) + expect(isReactive(state.value[1])).toBe(true) + }) + + // #550 + it('props should work with set', async done => { + let props: any + + const app = new Vue({ + render(this: any, h) { + return h('child', { attrs: { msg: this.msg } }) + }, + setup() { + return { msg: ref('hello') } + }, + components: { + child: { + render(this: any, h: any) { + return h('span', this.data.msg) + }, + props: ['msg'], + setup(_props) { + props = _props + + return { data: _props } + } + } + } + }) + + const serverRenderer = createRenderer() + const html = await serverRenderer.renderToString(app) + + expect(html).toBe('hello') + + expect(props.bar).toBeUndefined() + set(props, 'bar', 'bar') + expect(props.bar).toBe('bar') + + done() + }) + + // #721 + it('should behave correctly', () => { + const state = ref({ old: ref(false) }) + set(state.value, 'new', ref(true)) + // console.log(process.server, 'state.value', JSON.stringify(state.value)) + + expect(state.value).toMatchObject({ + old: false, + new: true + }) + }) + + // #721 + it('should behave correctly for the nested ref in the object', () => { + const state = { old: ref(false) } + set(state, 'new', ref(true)) + expect(JSON.stringify(state)).toBe( + '{"old":{"value":false},"new":{"value":true}}' + ) + }) + + // #721 + it('should behave correctly for ref of object', () => { + const state = ref({ old: ref(false) }) + set(state.value, 'new', ref(true)) + expect(JSON.stringify(state.value)).toBe('{"old":false,"new":true}') + }) + + it('ssr should not RangeError: Maximum call stack size exceeded', async () => { + new Vue({ + setup() { + // @ts-expect-error + const app = getCurrentInstance().proxy + let mockNt: any = [] + mockNt.__ob__ = {} + const test = reactive({ + app, + mockNt + }) + return { + test + } + } + }) + await nextTick() + expect( + `"RangeError: Maximum call stack size exceeded"` + ).not.toHaveBeenWarned() + }) + + it('should work on objects sets with set()', () => { + const state = ref({}) + set(state.value, 'a', {}) + + expect(isReactive(state.value.a)).toBe(true) + }) +}) diff --git a/src/core/observer/index.ts b/src/core/observer/index.ts index 6180b8e904f..004a65fe6b5 100644 --- a/src/core/observer/index.ts +++ b/src/core/observer/index.ts @@ -13,7 +13,8 @@ import { isUndef, isValidArrayIndex, isServerRendering, - hasChanged + hasChanged, + noop } from '../util/index' import { isReadonly, isRef, TrackOpTypes, TriggerOpTypes } from '../../v3' @@ -31,6 +32,14 @@ export function toggleObserving(value: boolean) { shouldObserve = value } +// ssr mock dep +const mockDep = { + notify: noop, + depend: noop, + addSub: noop, + removeSub: noop +} as Dep + /** * Observer class that is attached to each observed * object. Once attached, the observer converts the target @@ -41,78 +50,63 @@ export class Observer { dep: Dep vmCount: number // number of vms that have this object as root $data - constructor(public value: any, public shallow = false) { + constructor(public value: any, public shallow = false, public mock = false) { // this.value = value - this.dep = new Dep() + this.dep = mock ? mockDep : new Dep() this.vmCount = 0 def(value, '__ob__', this) if (isArray(value)) { - if (hasProto) { - protoAugment(value, arrayMethods) - } else { - copyAugment(value, arrayMethods, arrayKeys) + if (!mock) { + if (hasProto) { + /* eslint-disable no-proto */ + ;(value as any).__proto__ = arrayMethods + /* eslint-enable no-proto */ + } else { + for (let i = 0, l = arrayKeys.length; i < l; i++) { + const key = arrayKeys[i] + def(value, key, arrayMethods[key]) + } + } } if (!shallow) { this.observeArray(value) } } else { - this.walk(value, shallow) - } - } - - /** - * Walk through all properties and convert them into - * getter/setters. This method should only be called when - * value type is Object. - */ - walk(obj: object, shallow: boolean) { - const keys = Object.keys(obj) - for (let i = 0; i < keys.length; i++) { - const key = keys[i] - defineReactive(obj, key, NO_INIITIAL_VALUE, undefined, shallow) + /** + * Walk through all properties and convert them into + * getter/setters. This method should only be called when + * value type is Object. + */ + const keys = Object.keys(value) + for (let i = 0; i < keys.length; i++) { + const key = keys[i] + defineReactive(value, key, NO_INIITIAL_VALUE, undefined, shallow, mock) + } } } /** * Observe a list of Array items. */ - observeArray(items: Array) { - for (let i = 0, l = items.length; i < l; i++) { - observe(items[i]) + observeArray(value: any[]) { + for (let i = 0, l = value.length; i < l; i++) { + observe(value[i], false, this.mock) } } } // helpers -/** - * Augment a target Object or Array by intercepting - * the prototype chain using __proto__ - */ -function protoAugment(target, src: Object) { - /* eslint-disable no-proto */ - target.__proto__ = src - /* eslint-enable no-proto */ -} - -/** - * Augment a target Object or Array by defining - * hidden properties. - */ -/* istanbul ignore next */ -function copyAugment(target: Object, src: Object, keys: Array) { - for (let i = 0, l = keys.length; i < l; i++) { - const key = keys[i] - def(target, key, src[key]) - } -} - /** * Attempt to create an observer instance for a value, * returns the new observer if successfully observed, * or the existing observer if the value already has one. */ -export function observe(value: any, shallow?: boolean): Observer | void { +export function observe( + value: any, + shallow?: boolean, + ssrMockReactivity?: boolean +): Observer | void { if (!isObject(value) || isRef(value) || value instanceof VNode) { return } @@ -121,12 +115,12 @@ export function observe(value: any, shallow?: boolean): Observer | void { ob = value.__ob__ } else if ( shouldObserve && - !isServerRendering() && + (ssrMockReactivity || !isServerRendering()) && (isArray(value) || isPlainObject(value)) && Object.isExtensible(value) && - !value.__v_skip + !value.__v_skip /* ReactiveFlags.SKIP */ ) { - ob = new Observer(value, shallow) + ob = new Observer(value, shallow, ssrMockReactivity) } return ob } @@ -139,7 +133,8 @@ export function defineReactive( key: string, val?: any, customSetter?: Function | null, - shallow?: boolean + shallow?: boolean, + mock?: boolean ) { const dep = new Dep() @@ -158,7 +153,7 @@ export function defineReactive( val = obj[key] } - let childOb = !shallow && observe(val) + let childOb = !shallow && observe(val, false, mock) Object.defineProperty(obj, key, { enumerable: true, configurable: true, @@ -202,7 +197,7 @@ export function defineReactive( } else { val = newVal } - childOb = !shallow && observe(newVal) + childOb = !shallow && observe(newVal, false, mock) if (__DEV__) { dep.notify({ type: TriggerOpTypes.SET, @@ -241,16 +236,20 @@ export function set( __DEV__ && warn(`Set operation on key "${key}" failed: target is readonly.`) return } + const ob = (target as any).__ob__ if (isArray(target) && isValidArrayIndex(key)) { target.length = Math.max(target.length, key) target.splice(key, 1, val) + // when mocking for SSR, array methods are not hijacked + if (!ob.shallow && ob.mock) { + observe(val, false, true) + } return val } if (key in target && !(key in Object.prototype)) { target[key] = val return val } - const ob = (target as any).__ob__ if ((target as any)._isVue || (ob && ob.vmCount)) { __DEV__ && warn( @@ -263,7 +262,7 @@ export function set( target[key] = val return val } - defineReactive(ob.value, key, val) + defineReactive(ob.value, key, val, undefined, ob.shallow, ob.mock) if (__DEV__) { ob.dep.notify({ type: TriggerOpTypes.ADD, diff --git a/src/v3/reactivity/reactive.ts b/src/v3/reactivity/reactive.ts index d750dd2c713..0ff682243a2 100644 --- a/src/v3/reactivity/reactive.ts +++ b/src/v3/reactivity/reactive.ts @@ -1,5 +1,12 @@ import { observe, Observer } from 'core/observer' -import { def, isArray, isPrimitive, warn, toRawType } from 'core/util' +import { + def, + isArray, + isPrimitive, + warn, + toRawType, + isServerRendering +} from 'core/util' import type { Ref, UnwrapRefSimple, RawSymbol } from './ref' export const enum ReactiveFlags { @@ -67,7 +74,11 @@ function makeReactive(target: any, shallow: boolean) { ) } } - const ob = observe(target, shallow) + const ob = observe( + target, + shallow, + isServerRendering() /* ssr mock reactivity */ + ) if (__DEV__ && !ob) { if (target == null || isPrimitive(target)) { warn(`value cannot be made reactive: ${String(target)}`) diff --git a/src/v3/reactivity/ref.ts b/src/v3/reactivity/ref.ts index 3b8d2fc7d1e..f7026df05a1 100644 --- a/src/v3/reactivity/ref.ts +++ b/src/v3/reactivity/ref.ts @@ -6,7 +6,7 @@ import { } from './reactive' import type { IfAny } from 'types/utils' import Dep from 'core/observer/dep' -import { warn, isArray, def } from 'core/util' +import { warn, isArray, def, isServerRendering } from 'core/util' import { TrackOpTypes, TriggerOpTypes } from './operations' declare const RefSymbol: unique symbol @@ -69,7 +69,11 @@ function createRef(rawValue: unknown, shallow: boolean) { const ref: any = {} def(ref, RefFlag, true) def(ref, ReactiveFlags.IS_SHALLOW, true) - ref.dep = defineReactive(ref, 'value', rawValue, null, shallow) + def( + ref, + 'dep', + defineReactive(ref, 'value', rawValue, null, shallow, isServerRendering()) + ) return ref }