Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions packages/reactivity/__tests__/readonly.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,3 +559,66 @@ test('should be able to trigger with triggerRef', () => {
triggerRef(ror)
expect(dummy).toBe(2)
})

describe('readonly(reactive(array)) element access', () => {
// concat/toReversed/toSorted/toSpliced go through reactiveReadArray, which
// wrapped elements with toReactive and so dropped the readonly layer. The
// giveaway is the dev warning: writing through the paths below used to land on
// the raw object without one, while the same write through an index warns.
const paths: [string, (state: any) => any][] = [
['index', state => state[0]],
['filter', state => state.filter(() => true)[0]],
['slice', state => state.slice()[0]],
['concat', state => state.concat()[0]],
['toReversed', state => state.toReversed()[1]],
['toSorted', state => state.toSorted()[0]],
['toSpliced', state => state.toSpliced(0, 0)[0]],
]

test.each(paths)('%s preserves readonly and reactivity', (_name, get) => {
const raw = [{ a: 1 }, { a: 2 }]
const source = reactive(raw)
const state = readonly(source)
const item = get(state)

expect(isReadonly(item)).toBe(true)
expect(isReactive(item)).toBe(true)
expect(item).toBe(state[0])

let observed = 0
effect(() => {
observed = item.a
})
expect(observed).toBe(1)

item.a = 999
expect(raw[0].a).toBe(1)
expect(observed).toBe(1)
expect(`target is readonly`).toHaveBeenWarned()

source[0].a = 2
expect(observed).toBe(2)
})

test('concat wraps elements coming from a readonly argument', () => {
const raw = [{ a: 1 }]
const other = [{ b: 1 }]
const input = readonly(reactive(raw))
const merged = reactive<unknown[]>([]).concat(input, reactive(other))

expect(isReadonly(merged[0])).toBe(true)
expect(isReactive(merged[0])).toBe(true)
expect(merged[0]).toBe(input[0])
expect(isReadonly(merged[1])).toBe(false)
expect(isReactive(merged[1])).toBe(true)
})

test('concat preserves elements from a non-reactive readonly argument', () => {
const input = readonly([{ a: 1 }])
const merged = reactive<unknown[]>([]).concat(input)

expect(isReadonly(merged[0])).toBe(true)
expect(isReactive(merged[0])).toBe(false)
expect(merged[0]).toBe(input[0])
})
})
14 changes: 12 additions & 2 deletions packages/reactivity/src/arrayInstrumentations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,23 @@ import { isArray } from '@vue/shared'
/**
* Track array iteration and return:
* - if input is reactive: a cloned raw array with reactive values
* - if input is non-reactive or shallowReactive: the original raw array
* - if input is readonly: a cloned raw array with readonly values, preserving
* reactivity
* - if input is raw or shallow: the original raw array
*/
export function reactiveReadArray<T>(array: T[]): T[] {
const raw = toRaw(array)
if (raw === array) return raw
track(raw, TrackOpTypes.ITERATE, ARRAY_ITERATE_KEY)
return isShallow(array) ? raw : raw.map(toReactive)
if (isShallow(array)) return raw
if (!isReadonly(array)) return raw.map(toReactive)

// Preserve proxy layers without checking the array's flags for every element.
return (
isReactive(array)
? raw.map(item => toReadonly(toReactive(item)))
: raw.map(toReadonly)
) as T[]
}

/**
Expand Down
Loading