valtio icon indicating copy to clipboard operation
valtio copied to clipboard

Promise support in watch

Open Akkuma opened this issue 2 years ago • 8 comments

https://codesandbox.io/s/breaking-watch-ct246n

If you attempt to use async/await in watch it'll break the cleanup cycle. I added a quick test locally and fooled around with it and can avoid the issue with something along these lines:

      // If there's a cleanup, we add this to the cleanups set
      if (cleanupReturn && !(cleanupReturn instanceof Promise)) {
        cleanups.add(cleanupReturn)
      }

However, I'm not sure that's the desired behavior either as that would also mean you can't run a cleanup function using async/await without additional logic, but maybe that's ok.

Derive handles this scenario already of working with promises, so it feels a little odd that watch is the odd function out when it comes to promises. I suppose one workaround is doing a manual subscribe dance around the promises and their subscriptions.

Akkuma avatar Jul 26 '22 02:07 Akkuma

Thanks for reporting. I'm not sure if it's correct to allow async function. watch is a wrapper around subscribe and subscribe doesn't support it, right? useEffect doesn't allow async function either.

Derive handles this scenario already of working with promises

Does derive handles promises? Hm, we don't have cleanup functions but underive instead, so its design is different (because we want derive to return a proxy).

dai-shi avatar Jul 26 '22 02:07 dai-shi

Here's derive using async/await https://codesandbox.io/s/derive-with-promises-yx2fkq?file=/src/App.js and here's subscribe using async/await https://codesandbox.io/s/subscribe-with-promises-7pokmq

I think the key difference is that watch is the only function that allows for an explicit cleanup function returned from it. The code for subscribe doesn't expect or check for any return value from the callback itself. I've figured out an ok alternative to use async/await with watch, by deferring async/await to another function something like:

watch((get) => {
  const asyncThing = get(state).asyncThing
  const asyncStuff = get(state2).asyncStuff
  
  doAsyncAwait(asyncThing, asyncStuff)
}

Whether watch should support async/await inside of it or if this approach should be apart of the wiki I'll leave for you to decide.

Akkuma avatar Jul 26 '22 16:07 Akkuma

https://codesandbox.io/s/derive-with-promises-yx2fkq?file=/src/App.js This is interesting. Thanks for sharing. It's valid because valtio supports promise values.

https://codesandbox.io/s/subscribe-with-promises-7pokmq Okay, right. subscribe callback returns void. So, it's fine.

watch callback should return cleanup or void. I guess it's because watch does subscribe/unsubscribe automatically. #149 @LXSMNSYC ? And, cleanup function must not be a promise.

So, option 1 is to leave it as is. Option 2 could be removing cleanup entirely to match with subscribe, but it's a breaking change, which should be avoided if possible.

I'd be appreciated if you work on docs/wikis.

dai-shi avatar Jul 26 '22 22:07 dai-shi

I think option 1 is superior to option 2. I've found a very nice use for watch's cleanup. You can implement really simple abort controller support on top of the cleanup. If you were to use derive, which is closer to watch than subscribe, you'd have to do arguably a not as clean approach.

watch((get) => {
  const val = get(state).val
  const ac = new AbortController()
  const run = async () => {
     const res = await fetch(val, { signal: ac.signal })
     const json = await json()
   }
   
   run()
   return () => ac.abort()
}

let ac = new AbortController()
derive({
  prop: async (get) => {
    const val = get(state).val
    ac.abort()
    ac = new AbortController
    try {
      const res = await fetch(val, { signal: ac.signal })
      const json = await json()
    } catch (e) {
      return undefined
    }
    return json.val
  }
})

The one alternative option is having specific logic to allow async/await for watch and treat an async function as if it were returning void.

Akkuma avatar Jul 28 '22 01:07 Akkuma

The one alternative option is having specific logic to allow async/await for watch and treat an async function as if it were returning void.

I thought this would be confusing and people might misuse. But, implementation-wise, it's fairly harmless. So, I think we can accept your suggestion.

Here's the diff that could be acceptable:

diff --git a/src/utils/watch.ts b/src/utils/watch.ts
index 14fbd63..bc41d30 100644
--- a/src/utils/watch.ts
+++ b/src/utils/watch.ts
@@ -2,7 +2,7 @@ import { subscribe } from '../vanilla'
 
 type Cleanup = () => void
 type WatchGet = <T extends object>(proxyObject: T) => T
-type WatchCallback = (get: WatchGet) => Cleanup | void | undefined
+type WatchCallback = (get: WatchGet) => Cleanup | void | Promise<void>
 type WatchOptions = {
   sync?: boolean
 }
@@ -75,7 +75,7 @@ export function watch(
       })
 
       // If there's a cleanup, we add this to the cleanups set
-      if (cleanupReturn) {
+      if (typeof cleanupReturn === 'function') {
         cleanups.add(cleanupReturn)
       }
     } finally {

Note: I dropped undefined type as it seems unnecessary. https://tsplay.dev/m3Pj1W

Would you like to open a PR with this fix and a test to cover it?

dai-shi avatar Jul 28 '22 02:07 dai-shi

Just a note here, Vue allows async watch functions. Though it also doesn't utilize any kind of watch "cleanup". There are definitely good use cases.

vincerubinetti avatar Jun 28 '23 00:06 vincerubinetti

Hey I would like to help. Should I try to fix this and open a pull request for a review?

this is my first open source contribution :)

NeriRos avatar Aug 20 '23 13:08 NeriRos

@NeriRos Yes, please go ahead. Note that I'm not yet confident if the outcome is mergeable.

dai-shi avatar Aug 21 '23 01:08 dai-shi