pmndrs / valtio

💊 Valtio makes proxy-state simple for React and Vanilla
http://valtio.pmnd.rs
MIT License
8.66k stars 241 forks source link

refactor(types): make `Snapshot<T>` use read-only collections #850

Closed cAttte closed 5 months ago

cAttte commented 5 months ago

hey! this PR changes the Snapshot<T> type so that any collection types (Map, Set, WeakMap, & WeakSet) will be made read-only as well (i.e. omit mutating methods). this helps catch mistakes like the following:

const snap = useSnapshot<{ users: Set<User> }>(store)
snap.users.delete(user.id) // now, "Property 'delete' does not exist"

this is technically a build-breaking change, just like adding readonly was, but any users affected by typed immutability have probably fixed it already. i decided to go with two internal types (Collection and Readonly<T>, which uses Omit<T>), resulting in slightly uglier type names (e.g. Readonly<Set<T>> vs. the native ReadonlySet<T>), but nicer typedefs.

vercel[bot] commented 5 months ago

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
valtio ✅ Ready (Inspect) Visit Preview 💬 Add feedback Feb 9, 2024 1:22am
codesandbox-ci[bot] commented 5 months ago

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

Latest deployment of this branch, based on commit fa5d99f229090aa26c9a0b8c332f36cb008afb27:

Sandbox Source
React Configuration
React TypeScript Configuration
React Browserify Configuration
React Snowpack Configuration
React Parcel Configuration
dai-shi commented 5 months ago

Map and Set are not read-only, so it's not correct. In general, we don't recommend using Maps, Sets and so on, and the reason for typing is just to be precise. It feels like there's room for improvement in docs.

cAttte commented 5 months ago

@dai-shi, sorry for not making myself clear enough. i'm not actually using Map and Set directly (those are indeed unfreezable and untrackable which, i'm guessing, is why you say they're not recommended); i'm using valtio's proxyMap() and proxySet(), which do get frozen because their underlying data properties get frozen (but you probably already knew that):

const store = proxy({ set: proxySet() })
const snap = snapshot(store)
snap.set.add("!") // TypeError: ... object is not extensible

this is the type of case that should be reflected by the typings. i think the main issue lies in the fact that proxy{Set,Map}() return objects with an interface identical to their native counterparts, which due to duck-typing makes the pairs indistinguishable.

i think that there are only two solutions:

dai-shi commented 5 months ago

Thanks for the clarification. I don't think there's an ultimate solution. https://tsplay.dev/mZBp9m

Branding might work, but we don't want to complicate our implementation.

Our suggestion is module augmentation. https://github.com/pmndrs/valtio/blob/26caa885050b5c9ac8c80c90e216599936e925f8/readme.md?plain=1#L56-L64

Define your MySnapshot type to support proxySet:

 declare module 'valtio' { 
   function useSnapshot<T extends object>(p: T): MySnapshot<T>
 } 

Do you know if it's possible to augment types when users import valtio/utils?

cAttte commented 5 months ago

yeah, it seems that just putting the augmentation in utils.ts works (and stops working when you remove the imports)

dai-shi commented 5 months ago

Nice. As we don't always want to augment if valtio/utils is imported, it would be better if we separate valtio/utils/proxySet. (and it's a breaking change.)

dai-shi commented 5 months ago

On second thought, I'm not sure if it's narrowing, but I'm probably okay with this:

diff --git a/src/vanilla.ts b/src/vanilla.ts
index aceafef..26c4fcd 100644
--- a/src/vanilla.ts
+++ b/src/vanilla.ts
@@ -5,8 +5,6 @@ const isObject = (x: unknown): x is object =>

 type AnyFunction = (...args: any[]) => any

-type AsRef = { $$valtioRef: true }
-
 type ProxyObject = object

 type Path = (string | symbol)[]
@@ -25,19 +23,20 @@ type SnapshotIgnore =
   | Set<any>
   | WeakMap<any, any>
   | WeakSet<any>
-  | AsRef
   | Error
   | RegExp
   | AnyFunction
   | Primitive

-type Snapshot<T> = T extends SnapshotIgnore
-  ? T
-  : T extends Promise<unknown>
-    ? Awaited<T>
-    : T extends object
-      ? { readonly [K in keyof T]: Snapshot<T[K]> }
-      : T
+type Snapshot<T> = T extends { $$valtioSnapshot: infer S }
+  ? S
+  : T extends SnapshotIgnore
+    ? T
+    : T extends Promise<unknown>
+      ? Awaited<T>
+      : T extends object
+        ? { readonly [K in keyof T]: Snapshot<T[K]> }
+        : T

 /**
  * This is not a public API.
@@ -403,9 +402,9 @@ export function snapshot<T extends object>(
   return createSnapshot(target, ensureVersion(), handlePromise) as Snapshot<T>
 }

-export function ref<T extends object>(obj: T): T & AsRef {
+export function ref<T extends object>(obj: T) {
   refSet.add(obj)
-  return obj as T & AsRef
+  return obj as T & { $$valtioSnapshot: T }
 }

 export const unstable_buildProxyFunction = buildProxyFunction

What do you think?

dai-shi commented 5 months ago

In case you missed it.