diff --git a/packages/reactivity/__tests__/reactive.spec.ts b/packages/reactivity/__tests__/reactive.spec.ts index ed7097aeb67..eb6ad1ac862 100644 --- a/packages/reactivity/__tests__/reactive.spec.ts +++ b/packages/reactivity/__tests__/reactive.spec.ts @@ -307,6 +307,26 @@ describe('reactivity/reactive', () => { expect(() => markRaw(obj)).not.toThrowError() }) + test('markRaw preserves opt-out on non-extensible targets', () => { + const sealed = Object.seal({ a: 1 }) + const marked = markRaw(sealed) + expect(marked).toBe(sealed) + // SKIP flag cannot be added onto a sealed target, so reactive() learns + // about the opt-out via the side rawSet instead. + expect(reactive(marked)).toBe(sealed) + expect(isReactive(reactive({ inner: markRaw(sealed) }).inner)).toBe(false) + }) + + test('deep traverse should skip markRaw non-extensible targets', async () => { + const { traverse } = await import('../src/watch') + const sealed = Object.seal({ inner: { a: 1 } }) + const marked = markRaw(sealed) + const seen = new Map() + traverse(marked, Infinity, seen) + // SKIP-flag markRaw stops here; the rawSet fallback must do the same. + expect(seen.has(marked.inner)).toBe(false) + }) + test('markRaw should not redefine on an marked object', () => { const obj = markRaw({ foo: 1 }) const raw = markRaw(obj) @@ -321,16 +341,34 @@ describe('reactivity/reactive', () => { expect(b.a === a).toBe(false) }) - test('should not observe non-extensible objects', () => { + test('should not observe frozen 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 }), + foo: Object.freeze({ a: 1 }), }) expect(isReactive(obj.foo)).toBe(false) - expect(isReactive(obj.bar)).toBe(false) - expect(isReactive(obj.baz)).toBe(false) + }) + + test('should observe sealed and non-extensible objects', () => { + // sealed and Object.preventExtensions targets still allow mutating their + // existing keys, so reactive() should proxy them. See issue #14893. + const sealed = reactive(Object.seal({ a: 1 })) + const nonExtensible = reactive(Object.preventExtensions({ a: 1 })) + expect(isReactive(sealed)).toBe(true) + expect(isReactive(nonExtensible)).toBe(true) + + let dummy + effect(() => { + dummy = sealed.a + nonExtensible.a + }) + expect(dummy).toBe(2) + sealed.a = 10 + nonExtensible.a = 20 + expect(dummy).toBe(30) + + // siblings share the same createReactiveObject guard. + expect(isReactive(shallowReactive(Object.seal({ a: 1 })))).toBe(true) + expect(isReadonly(readonly(Object.seal({ a: 1 })))).toBe(true) + expect(isReadonly(shallowReadonly(Object.seal({ a: 1 })))).toBe(true) }) test('should not observe objects with __v_skip', () => { diff --git a/packages/reactivity/src/reactive.ts b/packages/reactivity/src/reactive.ts index 3075f47a5ff..6c375cff9a6 100644 --- a/packages/reactivity/src/reactive.ts +++ b/packages/reactivity/src/reactive.ts @@ -23,6 +23,20 @@ export interface Target { [ReactiveFlags.RAW]?: any } +// non-extensible targets opted out via markRaw (SKIP flag can't be defined on them) +const rawSet: WeakSet = new WeakSet() + +/** + * Internal: true if the value has opted out of reactivity via markRaw, + * either through the SKIP flag or through the rawSet fallback. + */ +export function isRawMarked(value: unknown): boolean { + return ( + !!value && + (!!(value as Target)[ReactiveFlags.SKIP] || rawSet.has(value as object)) + ) +} + export const reactiveMap: WeakMap = new WeakMap() export const shallowReactiveMap: WeakMap = new WeakMap< Target, @@ -284,8 +298,8 @@ function createReactiveObject( ) { return target } - // only specific value types can be observed. - if (target[ReactiveFlags.SKIP] || !Object.isExtensible(target)) { + // skip frozen targets and anything opted out via markRaw. + if (isRawMarked(target) || Object.isFrozen(target)) { return target } // target already has corresponding Proxy @@ -418,8 +432,13 @@ export type Raw = T & { [RawSymbol]?: true } * @see {@link https://vuejs.org/api/reactivity-advanced.html#markraw} */ export function markRaw(value: T): Raw { - if (!hasOwn(value, ReactiveFlags.SKIP) && Object.isExtensible(value)) { - def(value, ReactiveFlags.SKIP, true) + if (!hasOwn(value, ReactiveFlags.SKIP)) { + if (Object.isExtensible(value)) { + def(value, ReactiveFlags.SKIP, true) + } else { + // SKIP can't be defined on a non-extensible target; track it via rawSet. + rawSet.add(value) + } } return value } diff --git a/packages/reactivity/src/watch.ts b/packages/reactivity/src/watch.ts index 6bc009985e0..4717af5b3e2 100644 --- a/packages/reactivity/src/watch.ts +++ b/packages/reactivity/src/watch.ts @@ -12,7 +12,7 @@ import { } from '@vue/shared' import { warn } from './warning' import type { ComputedRef } from './computed' -import { ReactiveFlags } from './constants' +import { isRawMarked } from './reactive' import { type DebuggerOptions, EffectFlags, @@ -333,7 +333,7 @@ export function traverse( depth: number = Infinity, seen?: Map, ): unknown { - if (depth <= 0 || !isObject(value) || (value as any)[ReactiveFlags.SKIP]) { + if (depth <= 0 || !isObject(value) || isRawMarked(value)) { return value }