Repository: pmndrs/valtio
Pull request #325 (2022-01-13 14:23:43)

Feat/add proxy set
I created a new PR as I rebased from fix(core): expose canProxy in handler #321. Below is the output of the tests that are generated. I can of course add more if needed.
![Screenshot 2022-01-13 at 15 20 48](https://user-images.githubusercontent.com/5574267/149347175-58e83596-2b56-4c06-bfa2-f19b81da7186.png)
 
I was thinking to add an example as well inside the example folder `todo-with-proxySet` or whatever the name. Let me know if you think it's worth it


vercel[bot]: This pull request is being automatically deployed with Vercel ([learn more](https://vercel.link/github-learn-more)).  
To see the status of your deployment, click below or on the icon next to each commit.

🔍 Inspect: [https://vercel.com/pmndrs/valtio/7Z8aHfa3FT7L7h5YQJJ83sBh6wPU](https://vercel.com/pmndrs/valtio/7Z8aHfa3FT7L7h5YQJJ83sBh6wPU)  
✅ Preview: *In Progress*


dai-shi: > I was thinking to add an example as well inside the example folder `todo-with-proxySet` or whatever the name. Let me know if you think it's worth it

That's a very nice idea!
fkhadra: Amazing, do you want me to add an example before we merge? I'm trying to figure out a good use case(a simple one) for using Set. I thought first about the todo but most of the operations relies on the todo id which makes sense
dai-shi: You can work on examples in a separate PR. Can you add a short section in readme?

fkhadra: > You can work on examples in a separate PR. Can you add a short section in readme?

Sure, no problem I'll do this later today when I'm back. I've also pushed another commit to remove the `writable` from data as we don't reassign it.
fkhadra: Added a tiny readme section let me know if it's enough

Patch:
diff --git a/readme.md b/readme.md
index 4d95cdc..1a9ed43 100644
--- a/readme.md
+++ b/readme.md
@@ -348,6 +348,26 @@ state.redo()
 console.log(state.value) // ---> { count: 1 }
 ```
 
+#### `proxySet` util
+
+This is to create a proxy which mimic the native Set behavior. The API is the same as Set API
+
+```js
+
+import { proxySet } from 'valtio/utils'
+ 
+const state = proxySet([1,2,3])
+//can be used inside a proxy as well
+//const state = proxy({
+//    count: 1,
+//    set: proxySet()
+//})
+
+state.add(4)
+state.delete(1)
+state.forEach(v => console.log(v)) // 2,3,4
+```
+
 #### Plugins
 
 - [eslint-plugin-valtio](https://github.com/pmndrs/eslint-plugin-valtio)
diff --git a/src/utils.ts b/src/utils.ts
index 3a14bac..f7c1f3a 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -9,3 +9,4 @@ export {
 export { addComputed_DEPRECATED as addComputed } from './utils/addComputed'
 export { proxyWithComputed } from './utils/proxyWithComputed'
 export { proxyWithHistory } from './utils/proxyWithHistory'
+export { proxySet } from './utils/proxySet'
diff --git a/src/utils/proxySet.ts b/src/utils/proxySet.ts
new file mode 100644
index 0000000..c45eae6
--- /dev/null
+++ b/src/utils/proxySet.ts
@@ -0,0 +1,96 @@
+import { proxy } from 'valtio'
+
+// properies that we don't want to expose to the end-user
+type InternalProxySet<T> = Set<T> & {
+  data: T[]
+  toJSON: object
+}
+
+/**
+ * proxySet
+ *
+ * This is to create a proxy which mimic the native Set behavior.
+ * The API is the same as Set API
+ *
+ * @example
+ * import { proxySet } from 'valtio/utils'
+ * const state = proxySet([1,2,3])
+ * //can be used inside a proxy as well
+ * const state = proxy({
+ *   count: 1,
+ *   set: proxySet()
+ * })
+ */
+export const proxySet = <T>(initialValues: Iterable<T> | null = []): Set<T> => {
+  const set: InternalProxySet<T> = proxy({
+    data: Array.from(new Set(initialValues)),
+    has(value) {
+      return this.data.indexOf(value) !== -1
+    },
+    add(value) {
+      let hasProxy = false
+      if (typeof value === 'object' && value !== null) {
+        hasProxy = this.data.indexOf(proxy(value as T & object)) !== -1
+      }
+      if (this.data.indexOf(value) === -1 && !hasProxy) {
+        this.data.push(value)
+      }
+      return this
+    },
+    delete(value) {
+      const index = this.data.indexOf(value)
+      if (index === -1) {
+        return false
+      }
+      this.data.splice(index, 1)
+      return true
+    },
+    clear() {
+      this.data.splice(0)
+    },
+    get size() {
+      return this.data.length
+    },
+    forEach(cb) {
+      this.data.forEach((value) => {
+        cb(value, value, this)
+      })
+    },
+    get [Symbol.toStringTag]() {
+      return 'Set'
+    },
+    toJSON() {
+      return {}
+    },
+    [Symbol.iterator]() {
+      return this.data[Symbol.iterator]()
+    },
+    values() {
+      return this.data.values()
+    },
+    keys() {
+      // for Set.keys is an alias for Set.values()
+      return this.data.values()
+    },
+    entries() {
+      // array.entries returns [index, value] while Set [value, value]
+      return new Set(this.data).entries()
+    },
+  })
+
+  Object.defineProperties(set, {
+    data: {
+      enumerable: false,
+    },
+    size: {
+      enumerable: false,
+    },
+    toJSON: {
+      enumerable: false,
+    },
+  })
+
+  Object.seal(set)
+
+  return set as Set<T>
+}


Test patch:
diff --git a/tests/proxySet.test.tsx b/tests/proxySet.test.tsx
new file mode 100644
index 0000000..5099ea6
--- /dev/null
+++ b/tests/proxySet.test.tsx
@@ -0,0 +1,347 @@
+import { StrictMode } from 'react'
+import { fireEvent, render, waitFor } from '@testing-library/react'
+import { proxy, useSnapshot } from 'valtio'
+import { proxySet } from 'valtio/utils'
+
+const consoleError = console.error
+beforeEach(() => {
+  console.error = jest.fn((message) => {
+    if (
+      process.env.NODE_ENV === 'production' &&
+      message.startsWith('act(...) is not supported in production')
+    ) {
+      return
+    }
+
+    consoleError(message)
+  })
+})
+afterEach(() => {
+  console.error = consoleError
+})
+
+// used to initialize proxySet during tests
+const initialValues = [
+  {
+    name: 'array',
+    value: ['lorem', false, 1],
+  },
+  {
+    name: 'nested array',
+    value: [
+      [1, 2, 3],
+      [1, 2],
+      [51, 2, 3],
+    ],
+  },
+  {
+    name: 'Map',
+    value: new Map([
+      ['key1', 'value1'],
+      ['key2', 'value2'],
+    ]),
+  },
+  {
+    name: 'Set',
+    value: new Set([
+      ['key1', 'value1'],
+      ['key2', 'value2'],
+    ]),
+  },
+  {
+    name: 'string value',
+    value: 'hello',
+  },
+  {
+    name: 'nested Set',
+    value: new Set([
+      new Set([1, 2, 3]),
+      new Set([1, 2]),
+      new Set(['x', 'y', 'z']),
+    ]),
+  },
+  {
+    name: 'proxySet',
+    value: proxySet([1, {}, true]),
+  },
+  {
+    name: 'array of proxySet',
+    value: [proxySet([1, 2, 3]), proxySet([1, 2]), proxySet(['x', 'y', 'z'])],
+  },
+  {
+    name: 'list of objects',
+    value: [
+      { id: Symbol(), field: 'field', bool: true, null: null },
+      { id: Symbol(), field: 'field1', bool: false, null: null },
+      { id: Symbol(), field: 'field3', bool: true, null: null },
+    ],
+  },
+]
+
+// used to test various input types
+const inputValues = [
+  {
+    name: 'array',
+    value: [1, 'hello'],
+  },
+  {
+    name: 'nested array',
+    value: [[1, 'hello']],
+  },
+  {
+    name: 'Map',
+    value: new Map<any, any>([
+      ['key1', 'value1'],
+      [{}, 'value2'],
+    ]),
+  },
+  {
+    name: 'boolean',
+    value: false,
+  },
+  {
+    name: 'number',
+    value: 123,
+  },
+  {
+    name: 'string',
+    value: 'hello',
+  },
+  {
+    name: 'Set',
+    value: new Set([1, 2, 3]),
+  },
+  {
+    name: 'proxySet',
+    value: proxySet([1, {}, null, 'xyz', Symbol()]),
+  },
+  {
+    name: 'object',
+    value: { id: Symbol(), field: 'field', bool: true, null: null },
+  },
+]
+
+describe('features parity with native Set', () => {
+  initialValues.forEach(({ name, value }) => {
+    it(`support Set operations on ${name}`, () => {
+      const set = proxySet<unknown>(value)
+      const nativeSet = new Set<unknown>(value)
+
+      // check for Symbol.toStringTag / toString
+      expect(`${set}`).toBe(`${nativeSet}`)
+
+      const expectOutputToMatch = () => {
+        expect(set.size).toStrictEqual(nativeSet.size)
+        expect(Array.from(set.values())).toStrictEqual(
+          Array.from(nativeSet.values())
+        )
+        expect(Array.from(set.keys())).toStrictEqual(
+          Array.from(nativeSet.keys())
+        )
+        expect(Array.from(set.entries())).toStrictEqual(
+          Array.from(nativeSet.entries())
+        )
+        expect(JSON.stringify(set)).toStrictEqual(JSON.stringify(nativeSet))
+
+        // cover loops
+        const handleForEach = jest.fn()
+        const handleForOf = jest.fn()
+
+        set.forEach(handleForEach)
+        expect(handleForEach).toHaveBeenCalledTimes(set.size)
+
+        for (const _ of set) {
+          handleForOf()
+        }
+
+        expect(handleForOf).toHaveBeenCalledTimes(set.size)
+      }
+
+      expectOutputToMatch()
+
+      const [valueToDeleteFromSet] = set
+      const [valueToDeleteFromNativeSet] = nativeSet
+
+      expect(set.delete(valueToDeleteFromSet)).toBe(
+        nativeSet.delete(valueToDeleteFromNativeSet)
+      )
+
+      expectOutputToMatch()
+
+      set.add('newValue')
+      nativeSet.add('newValue')
+      expectOutputToMatch()
+
+      set.clear()
+      nativeSet.clear()
+      expectOutputToMatch()
+    })
+  })
+
+  inputValues.forEach(({ value, name }) => {
+    it(`prevent adding duplicate for type ${name}`, () => {
+      const set = proxySet<unknown>([value])
+
+      expect(set.size).toBe(1)
+
+      set.add(value)
+      expect(set.size).toBe(1)
+    })
+  })
+})
+
+describe('unsupported initial values', () => {
+  const unsupportedInputTestCases = [
+    {
+      name: 'boolean',
+      value: true,
+    },
+    {
+      name: 'number',
+      value: 123,
+    },
+    {
+      name: 'symbol',
+      value: Symbol(),
+    },
+  ]
+
+  unsupportedInputTestCases.forEach(({ name, value }) => {
+    it(`throw type error when using ${name} as initial value`, () => {
+      expect(() => proxySet(value as any)).toThrow(TypeError)
+    })
+  })
+})
+
+describe('clear set', () => {
+  initialValues.forEach(({ name, value }) => {
+    it(`clear proxySet of ${name}`, async () => {
+      const state = proxy({
+        set: proxySet<unknown>(value),
+      })
+
+      const TestComponent = () => {
+        const snap = useSnapshot(state)
+
+        return (
+          <>
+            <div>size: {snap.set.size}</div>
+            <button onClick={() => state.set.clear()}>button</button>
+          </>
+        )
+      }
+
+      const { getByText } = render(
+        <StrictMode>
+          <TestComponent />
+        </StrictMode>
+      )
+
+      getByText(`size: ${state.set.size}`)
+
+      fireEvent.click(getByText('button'))
+      await waitFor(() => {
+        getByText('size: 0')
+      })
+    })
+  })
+})
+
+describe('add value', () => {
+  inputValues.forEach(({ name, value }) => {
+    it(`update size when adding ${name}`, async () => {
+      const state = proxy({
+        set: proxySet(),
+      })
+
+      const TestComponent = () => {
+        const snap = useSnapshot(state)
+
+        return (
+          <>
+            <div>size: {snap.set.size}</div>
+            <button onClick={() => state.set.add(value)}>button</button>
+          </>
+        )
+      }
+
+      const { getByText } = render(
+        <StrictMode>
+          <TestComponent />
+        </StrictMode>
+      )
+
+      getByText('size: 0')
+      fireEvent.click(getByText('button'))
+
+      await waitFor(() => {
+        getByText('size: 1')
+      })
+    })
+  })
+})
+
+describe('delete', () => {
+  initialValues.forEach(({ name, value }) => {
+    it(`support delete on ${name}`, async () => {
+      const state = proxy({
+        set: proxySet<unknown>(value),
+      })
+
+      // pick a random value from the set
+      const valueToDelete = Array.from(state.set)[
+        Math.floor(Math.random() * state.set.size)
+      ]
+
+      const TestComponent = () => {
+        const snap = useSnapshot(state)
+
+        return (
+          <>
+            <div>size: {snap.set.size}</div>
+            <button onClick={() => state.set.delete(valueToDelete)}>
+              button
+            </button>
+          </>
+        )
+      }
+
+      const { getByText } = render(
+        <StrictMode>
+          <TestComponent />
+        </StrictMode>
+      )
+
+      getByText(`size: ${state.set.size}`)
+
+      const expectedSizeAfterDelete =
+        state.set.size > 1 ? state.set.size - 1 : 0
+
+      fireEvent.click(getByText('button'))
+      await waitFor(() => {
+        getByText(`size: ${expectedSizeAfterDelete}`)
+      })
+    })
+  })
+
+  inputValues.forEach(({ name, value }) => {
+    it(`return false when trying to delete non-existing value of type ${name}`, () => {
+      const set = proxySet()
+
+      expect(set.delete(value)).toBe(false)
+    })
+  })
+})
+
+describe('proxySet internal', () => {
+  it('should be sealed', () => {
+    expect(Object.isSealed(proxySet())).toBe(true)
+  })
+
+  it('should list only enumerable properties', () => {
+    const notEnumerableProps = ['data', 'size', 'toJSON']
+    expect(
+      Object.keys(proxySet()).some((k) => notEnumerableProps.includes(k))
+    ).toBe(false)
+  })
+})