From 637360b2e51834591a422c6dee4e2b4f01ee55ab Mon Sep 17 00:00:00 2001 From: itsybitsybootsy Date: Thu, 4 Jun 2026 09:35:16 +0200 Subject: [PATCH 1/2] fix(reactivity): proxy sealed and non-extensible targets (fix #14893) --- .../reactivity/__tests__/reactive.spec.ts | 42 +++++++++++++++---- packages/reactivity/src/reactive.ts | 20 +++++++-- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/packages/reactivity/__tests__/reactive.spec.ts b/packages/reactivity/__tests__/reactive.spec.ts index ed7097aeb67..c3ba0ed5032 100644 --- a/packages/reactivity/__tests__/reactive.spec.ts +++ b/packages/reactivity/__tests__/reactive.spec.ts @@ -307,6 +307,16 @@ 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('markRaw should not redefine on an marked object', () => { const obj = markRaw({ foo: 1 }) const raw = markRaw(obj) @@ -321,16 +331,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..0023691a963 100644 --- a/packages/reactivity/src/reactive.ts +++ b/packages/reactivity/src/reactive.ts @@ -23,6 +23,9 @@ 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() + export const reactiveMap: WeakMap = new WeakMap() export const shallowReactiveMap: WeakMap = new WeakMap< Target, @@ -284,8 +287,12 @@ 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 ( + target[ReactiveFlags.SKIP] || + rawSet.has(target) || + Object.isFrozen(target) + ) { return target } // target already has corresponding Proxy @@ -418,8 +425,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 } From 5aa896727d74de6ce94911c464c33893ee0d63b9 Mon Sep 17 00:00:00 2001 From: itsybitsybootsy Date: Thu, 4 Jun 2026 14:16:35 +0200 Subject: [PATCH 2/2] fix(reactivity): skip markRaw non-extensible targets in traverse --- packages/reactivity/__tests__/reactive.spec.ts | 10 ++++++++++ packages/reactivity/src/reactive.ts | 17 ++++++++++++----- packages/reactivity/src/watch.ts | 4 ++-- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/reactivity/__tests__/reactive.spec.ts b/packages/reactivity/__tests__/reactive.spec.ts index c3ba0ed5032..eb6ad1ac862 100644 --- a/packages/reactivity/__tests__/reactive.spec.ts +++ b/packages/reactivity/__tests__/reactive.spec.ts @@ -317,6 +317,16 @@ describe('reactivity/reactive', () => { 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) diff --git a/packages/reactivity/src/reactive.ts b/packages/reactivity/src/reactive.ts index 0023691a963..6c375cff9a6 100644 --- a/packages/reactivity/src/reactive.ts +++ b/packages/reactivity/src/reactive.ts @@ -26,6 +26,17 @@ export interface Target { // 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, @@ -288,11 +299,7 @@ function createReactiveObject( return target } // skip frozen targets and anything opted out via markRaw. - if ( - target[ReactiveFlags.SKIP] || - rawSet.has(target) || - Object.isFrozen(target) - ) { + if (isRawMarked(target) || Object.isFrozen(target)) { return target } // target already has corresponding Proxy 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 }