Skip to content

Commit 8a057d5

Browse files
overthemikedai-shi
andauthored
changed deepClone back to original and added deepProxy (#1169)
* changed deepClone back to original and added deepProxy * added a memo cache in deep proxy to protect aqgainst cyclic references * format * added tests and fixed inner Map/Set bug * fixed import in test file * added documentation for deepProxy * modified name of deepProxy to unstable_deepProxy * forgot one * format * Update docs/how-tos/how-to-reset-state.mdx --------- Co-authored-by: Daishi Kato <dai-shi@users.noreply.github.com>
1 parent a1dbe07 commit 8a057d5

10 files changed

Lines changed: 518 additions & 158 deletions

File tree

docs/api/utils/proxyMap.mdx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,23 @@ state.set(key, 'value')
6161
state.get(key) //undefined
6262
```
6363

64+
## `isProxyMap`
65+
66+
If you want to check if an object is a proxyMap, you can use the `isProxyMap` function.
67+
68+
```js
69+
import { proxy, ref } from 'valtio'
70+
import { proxyMap, isProxyMap } from 'valtio/utils'
71+
72+
const state = proxy({
73+
nativeMap: ref(new Map(/*...*/)),
74+
proxyMap: proxyMap(),
75+
})
76+
77+
isProxyMap(state.nativeMap) // false
78+
isProxyMap(state.proxyMap) // true
79+
```
80+
6481
## Codesandbox demo
6582

6683
https://codesandbox.io/s/github/pmndrs/valtio/tree/main/examples/todo-with-proxyMap

docs/api/utils/proxySet.mdx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,20 @@ const state = proxy({
5353
set: proxySet(),
5454
})
5555
```
56+
57+
## `isProxySet`
58+
59+
If you want to check if an object is a proxyMap, you can use the `isProxySet` function.
60+
61+
```js
62+
import { proxy, ref } from 'valtio'
63+
import { proxySet, isProxySet } from 'valtio/utils'
64+
65+
const state = proxy({
66+
nativeSet: ref(new Set(/*...*/)),
67+
proxySet: proxySet(),
68+
})
69+
70+
isProxySet(state.nativeSet) // false
71+
isProxySet(state.proxySet) // true
72+
```
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
title: 'unstable_deepProxy'
3+
section: 'API'
4+
subSection: 'Utils'
5+
description: ''
6+
---
7+
8+
# `unstable_deepProxy`
9+
10+
You can use this utility to recursively go through an object and proxy all proxiable objects with the exception of anything marked with `ref()`. `Map`s will become `proxyMap`s and `Set`s will become `proxySet`s.
11+
12+
```js
13+
import {
14+
unstable_deepProxy as deepProxy,
15+
isProxyMap,
16+
isProxySet,
17+
} from 'valtio/utils'
18+
19+
const obj = {
20+
mySet: new Set(),
21+
myMap: new Map(),
22+
sub: {
23+
foo: 'bar',
24+
},
25+
}
26+
27+
const clonedProxy = deepProxy(obj)
28+
29+
console.log(isProxyMap(clonedProxy.myMap)) // true
30+
console.log(isProxySet(clonedProxy.mySet)) // true
31+
```

docs/how-tos/how-to-reset-state.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,5 @@ Using `structuredClone()`
5050
In 2022, there was a new global function added called `structuredClone` that is widely available in most modern browsers. You can use `structuredClone` in the same way as `deepClone` above, however `deepClone` is preferred as it will be aware of any `ref`s in your state.
5151

5252
</blockquote>
53+
54+
> Note: deepClone will convert proxyMap and proxySet back to plain objects. If you have an object that has these within its tree, consider using `unstable_deepProxy` instead.

src/vanilla/utils.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@ export { subscribeKey } from './utils/subscribeKey.ts'
22
export { watch } from './utils/watch.ts'
33
export { devtools } from './utils/devtools.ts'
44
export { deepClone } from './utils/deepClone.ts'
5-
export { proxySet } from './utils/proxySet.ts'
6-
export { proxyMap } from './utils/proxyMap.ts'
5+
export { unstable_deepProxy } from './utils/deepProxy.ts'
6+
export { proxySet, isProxySet } from './utils/proxySet.ts'
7+
export { proxyMap, isProxyMap } from './utils/proxyMap.ts'

src/vanilla/utils/deepClone.ts

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import { unstable_getInternalStates } from '../../vanilla.ts'
2-
import { isProxyMap, proxyMap } from './proxyMap.ts'
3-
import { isProxySet, proxySet } from './proxySet.ts'
42

53
const isObject = (x: unknown): x is object =>
64
typeof x === 'object' && x !== null
@@ -29,16 +27,6 @@ export function deepClone<T>(
2927
return obj
3028
}
3129

32-
if (isProxySet(obj)) {
33-
return proxySet([...(obj as unknown as Iterable<unknown>)]) as unknown as T
34-
}
35-
36-
if (isProxyMap(obj)) {
37-
return proxyMap([
38-
...(obj as unknown as Map<unknown, unknown>).entries(),
39-
]) as unknown as T
40-
}
41-
4230
const baseObject: T = Array.isArray(obj)
4331
? []
4432
: Object.create(Object.getPrototypeOf(obj))

src/vanilla/utils/deepProxy.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { proxy, unstable_getInternalStates } from '../../vanilla.ts'
2+
import { isProxyMap, proxyMap } from './proxyMap.ts'
3+
import { isProxySet, proxySet } from './proxySet.ts'
4+
5+
const isObject = (x: unknown): x is object =>
6+
typeof x === 'object' && x !== null
7+
8+
let defaultRefSet: WeakSet<object> | undefined
9+
const getDefaultRefSet = (): WeakSet<object> => {
10+
if (!defaultRefSet) {
11+
defaultRefSet = unstable_getInternalStates().refSet
12+
}
13+
return defaultRefSet
14+
}
15+
16+
const cloneContainer = <T extends object>(src: T): T => {
17+
return (
18+
Array.isArray(src) ? [] : Object.create(Object.getPrototypeOf(src))
19+
) as T
20+
}
21+
22+
/**
23+
* Deeply proxies an input while normalizing Maps/Sets into proxyMap/proxySet.
24+
* - Values in refSet or primitives are returned as-is.
25+
* - Map/proxyMap and Set/proxySet are re-instantiated by passing their existing iterables directly.
26+
* - Arrays/objects are rebuilt recursively and wrapped with proxy().
27+
*/
28+
export function unstable_deepProxy<T>(
29+
obj: T,
30+
getRefSet: () => WeakSet<object> = getDefaultRefSet,
31+
): T {
32+
const memo = new WeakMap<object, unknown>()
33+
34+
const visit = (value: unknown) => {
35+
if (!isObject(value) || getRefSet().has(value)) return value
36+
37+
// Normalize Sets: deep-visit each element before wrapping
38+
if (value instanceof Set || isProxySet(value as object)) {
39+
const input = value as Iterable<unknown>
40+
const items: unknown[] = []
41+
for (const el of input) {
42+
items.push(visit(el))
43+
}
44+
return proxySet(items)
45+
}
46+
47+
// Normalize Maps: deep-visit keys and values before wrapping
48+
if (value instanceof Map || isProxyMap(value as object)) {
49+
const input = value as Iterable<[unknown, unknown]>
50+
const entries: [unknown, unknown][] = []
51+
for (const [k, v] of input) {
52+
entries.push([visit(k), visit(v)])
53+
}
54+
return proxyMap(entries)
55+
}
56+
57+
const hit = memo.get(value)
58+
59+
if (hit) return hit
60+
61+
const target = cloneContainer(value)
62+
memo.set(value, target)
63+
64+
for (const key of Reflect.ownKeys(value)) {
65+
const desc = Reflect.getOwnPropertyDescriptor(value, key)
66+
if (!desc) continue // type guard to make sure we can access metadata
67+
if ('value' in desc) {
68+
const next = visit((value as Record<PropertyKey, unknown>)[key])
69+
Object.defineProperty(target, key, { ...desc, value: next })
70+
} else {
71+
Object.defineProperty(target, key, desc)
72+
}
73+
}
74+
75+
return proxy(target)
76+
}
77+
78+
return visit(obj) as T
79+
}

tests/deepClone.test.tsx

Lines changed: 1 addition & 144 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from 'vitest'
22
import { proxy } from 'valtio'
3-
import { deepClone, proxyMap, proxySet } from 'valtio/utils'
3+
import { deepClone } from 'valtio/utils'
44

55
describe('deepClone', () => {
66
// Basic data types
@@ -54,147 +54,4 @@ describe('deepClone', () => {
5454
expect(cloned).toEqual(original)
5555
expect(cloned).not.toBe(original)
5656
})
57-
58-
// ProxySet tests
59-
it('should properly clone a proxySet', () => {
60-
const original = proxySet<number>([1, 2, 3])
61-
const cloned = deepClone(original)
62-
63-
// Check if values are the same
64-
expect([...cloned]).toEqual([...original])
65-
66-
// Check if it's a different instance
67-
expect(cloned).not.toBe(original)
68-
69-
// Check if it's still a proxySet (by checking methods)
70-
expect(typeof cloned.add).toBe('function')
71-
expect(typeof cloned.delete).toBe('function')
72-
expect(typeof cloned.clear).toBe('function')
73-
expect(typeof cloned[Symbol.iterator]).toBe('function')
74-
expect(Object.prototype.toString.call(cloned)).toBe('[object Set]')
75-
})
76-
77-
it('should maintain proxySet reactivity', () => {
78-
const state = proxy({
79-
count: 0,
80-
set: proxySet<number>([1, 2, 3]),
81-
})
82-
83-
const cloned = deepClone(state)
84-
85-
// Add a new item to the cloned set
86-
cloned.set.add(4)
87-
88-
// Verify the item was added
89-
expect([...cloned.set]).toContain(4)
90-
91-
// Verify it's still a reactive proxySet (we can check by seeing if add method throws an error)
92-
expect(() => cloned.set.add(5)).not.toThrow()
93-
})
94-
95-
// ProxyMap tests
96-
it('should properly clone a proxyMap', () => {
97-
const original = proxyMap<string, number>([
98-
['a', 1],
99-
['b', 2],
100-
['c', 3],
101-
])
102-
const cloned = deepClone(original)
103-
104-
// Check if values are the same
105-
expect([...cloned.entries()]).toEqual([...original.entries()])
106-
107-
// Check if it's a different instance
108-
expect(cloned).not.toBe(original)
109-
110-
// Check if it's still a proxyMap (by checking methods)
111-
expect(typeof cloned.set).toBe('function')
112-
expect(typeof cloned.get).toBe('function')
113-
expect(typeof cloned.delete).toBe('function')
114-
expect(typeof cloned.clear).toBe('function')
115-
expect(typeof cloned.entries).toBe('function')
116-
expect(Object.prototype.toString.call(cloned)).toBe('[object Map]')
117-
})
118-
119-
it('should maintain proxyMap reactivity', () => {
120-
const state = proxy({
121-
count: 0,
122-
map: proxyMap<string, number>([
123-
['a', 1],
124-
['b', 2],
125-
]),
126-
})
127-
128-
const cloned = deepClone(state)
129-
130-
// Set a new entry in the cloned map
131-
cloned.map.set('c', 3)
132-
133-
// Verify the entry was added
134-
expect(cloned.map.get('c')).toBe(3)
135-
136-
// Verify it's still a reactive proxyMap (we can check by seeing if set method throws an error)
137-
expect(() => cloned.map.set('d', 4)).not.toThrow()
138-
})
139-
140-
// Complex object with both proxySet and proxyMap
141-
it('should handle complex objects with both proxySet and proxyMap', () => {
142-
const original = proxy({
143-
name: 'test',
144-
count: 42,
145-
set: proxySet<number>([1, 2, 3]),
146-
map: proxyMap<string, any>([
147-
['a', 1],
148-
['b', { nested: true }],
149-
['c', proxySet<string>(['x', 'y', 'z'])],
150-
]),
151-
nested: {
152-
anotherSet: proxySet<string>(['a', 'b', 'c']),
153-
},
154-
})
155-
156-
const cloned = deepClone(original)
157-
158-
// Check basic properties
159-
expect(cloned.name).toBe('test')
160-
expect(cloned.count).toBe(42)
161-
162-
// Check proxySet
163-
expect([...cloned.set]).toEqual([1, 2, 3])
164-
165-
// Check proxyMap
166-
expect(cloned.map.get('a')).toBe(1)
167-
expect(cloned.map.get('b')).toEqual({ nested: true })
168-
169-
// Check nested proxySet inside proxyMap
170-
const nestedSet = cloned.map.get('c')
171-
expect([...nestedSet]).toEqual(['x', 'y', 'z'])
172-
expect(typeof nestedSet.add).toBe('function')
173-
174-
// Check nested object with proxySet
175-
expect([...cloned.nested.anotherSet]).toEqual(['a', 'b', 'c'])
176-
177-
// Verify reactivity is maintained
178-
expect(() => cloned.set.add(4)).not.toThrow()
179-
expect(() => cloned.map.set('d', 4)).not.toThrow()
180-
expect(() => cloned.map.get('c').add('w')).not.toThrow()
181-
expect(() => cloned.nested.anotherSet.add('d')).not.toThrow()
182-
})
183-
184-
// Edge cases
185-
it('should handle empty proxySet and proxyMap', () => {
186-
const original = proxy({
187-
emptySet: proxySet<number>(),
188-
emptyMap: proxyMap<string, number>(),
189-
})
190-
191-
const cloned = deepClone(original)
192-
193-
expect(cloned.emptySet.size).toBe(0)
194-
expect(cloned.emptyMap.size).toBe(0)
195-
196-
// Verify they're still proxy collections
197-
expect(() => cloned.emptySet.add(1)).not.toThrow()
198-
expect(() => cloned.emptyMap.set('a', 1)).not.toThrow()
199-
})
20057
})

0 commit comments

Comments
 (0)