Skip to content

Commit b31d1b5

Browse files
committed
~ Prevent mutating external objects on nested object state
1 parent 853c91f commit b31d1b5

File tree

2 files changed

+65
-8
lines changed

2 files changed

+65
-8
lines changed

src/2n8.test.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,42 @@ test('should not mutate external objects', () => {
248248
expect(externalObj).toStrictEqual({ foo: { bar: 'baz' } })
249249
})
250250

251+
test('should not mutate external objects in nested object state', () => {
252+
const externalObj = { bar: 'baz' }
253+
254+
class Store extends TwoAndEight {
255+
obj = { foo: externalObj }
256+
257+
loadExternalObj(ext: { bar: string }) {
258+
this.obj.foo = ext
259+
}
260+
261+
change() {
262+
this.obj.foo.bar = 'moo'
263+
}
264+
265+
reset() {
266+
this.$reset()
267+
}
268+
}
269+
270+
const store = createStore(new Store())
271+
expect(store.getState().obj).toStrictEqual({ foo: { bar: 'baz' } })
272+
expect(externalObj).toStrictEqual({ bar: 'baz' })
273+
store.getState().change()
274+
expect(store.getState().obj).toStrictEqual({ foo: { bar: 'moo' } })
275+
expect(externalObj).toStrictEqual({ bar: 'baz' })
276+
store.getState().reset()
277+
expect(store.getState().obj).toStrictEqual({ foo: { bar: 'baz' } })
278+
expect(externalObj).toStrictEqual({ bar: 'baz' })
279+
store.getState().loadExternalObj(externalObj)
280+
expect(store.getState().obj).toStrictEqual({ foo: { bar: 'baz' } })
281+
expect(externalObj).toStrictEqual({ bar: 'baz' })
282+
store.getState().change()
283+
expect(store.getState().obj).toStrictEqual({ foo: { bar: 'moo' } })
284+
expect(externalObj).toStrictEqual({ bar: 'baz' })
285+
})
286+
251287
test('should compute derived value', () => {
252288
class Store extends TwoAndEight {
253289
count = 1

src/2n8.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,41 @@
11
import autoBind from 'auto-bind'
2-
import { cloneDeep, isEqual } from 'es-toolkit'
2+
import { cloneDeep, isEqual, isPlainObject } from 'es-toolkit'
33

44
export type State<Store> = {
55
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
66
[K in keyof Store as Store[K] extends Function ? never : K]: Store[K]
77
}
88

9+
function createDeepProxy<T extends object>(rootTarget: T) {
10+
const proxyHandler = {
11+
get(target: T, property: string) {
12+
const value = Reflect.get(target, property)
13+
// Handle nested objects/arrays recursively
14+
if (isPlainObject(value)) {
15+
return createDeepProxy(value)
16+
}
17+
18+
return value
19+
},
20+
21+
set(target: T, property: string, value: unknown) {
22+
Reflect.set(target, property, cloneDeep(value))
23+
return true
24+
},
25+
26+
deleteProperty(target: T, property: string) {
27+
Reflect.deleteProperty(target, property)
28+
return true
29+
},
30+
}
31+
32+
return new Proxy(rootTarget, proxyHandler)
33+
}
34+
935
export abstract class TwoAndEight {
1036
constructor() {
1137
// Remove any references to set data.
12-
const p = new Proxy(this, {
13-
set(target, prop, value) {
14-
Reflect.set(target, prop, cloneDeep(value))
15-
return true
16-
},
17-
})
38+
const p = createDeepProxy(this)
1839

1940
autoBind(p)
2041

@@ -119,7 +140,7 @@ export function createStore<Store extends TwoAndEight>(
119140
}
120141
// Clone all fields to themselves so that external state isn't mutated.
121142
else {
122-
Reflect.set(store, name, structuredClone(value))
143+
Reflect.set(store, name, cloneDeep(value))
123144
}
124145
}
125146
}

0 commit comments

Comments
 (0)