Skip to content

Commit

Permalink
feat: Support promise in watch util (#825)
Browse files Browse the repository at this point in the history
* feat: Support promise in watch util
Implements #507

* chore: reverted return type of callback in subscribe, removed mem optimization

* chore: useFakeTimers

* fix: return type + sleep

* chore: code order

---------

Co-authored-by: Daishi Kato <dai-shi@users.noreply.github.com>
  • Loading branch information
LiamMartens and dai-shi committed Nov 28, 2023
1 parent 01672d9 commit 5950195
Show file tree
Hide file tree
Showing 2 changed files with 90 additions and 16 deletions.
39 changes: 24 additions & 15 deletions src/vanilla/utils/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { subscribe } from '../../vanilla.ts'

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<Cleanup | void> | undefined
type WatchOptions = {
sync?: boolean
}
Expand Down Expand Up @@ -53,10 +55,12 @@ export function watch(
}
}

const revalidate = () => {
const revalidate = async () => {
if (!alive) {
return
}

// run own cleanups before re-subscribing
cleanups.forEach((clean) => clean())
cleanups.clear()

Expand All @@ -69,35 +73,40 @@ export function watch(

// Ensures that the parent is reset if the callback throws an error.
try {
const cleanupReturn = callback((proxyObject) => {
const promiseOrPossibleCleanup = callback((proxyObject) => {
proxiesToSubscribe.add(proxyObject)
// in case the callback is a promise and the watch has ended
if (alive && !subscriptions.has(proxyObject)) {
// subscribe to new proxy immediately -> this fixes problems when Promises are used due to the callstack
const unsubscribe = subscribe(proxyObject, revalidate, options?.sync)
subscriptions.set(proxyObject, unsubscribe)
}
return proxyObject
})
const couldBeCleanup =
promiseOrPossibleCleanup && promiseOrPossibleCleanup instanceof Promise
? await promiseOrPossibleCleanup
: promiseOrPossibleCleanup

// If there's a cleanup, we add this to the cleanups set
if (cleanupReturn) {
cleanups.add(cleanupReturn)
if (couldBeCleanup) {
if (alive) {
cleanups.add(couldBeCleanup)
} else {
cleanup()
}
}
} finally {
currentCleanups = parent
}

// Unsubscribe old subscriptions
subscriptions.forEach((unsubscribe, proxyObject) => {
if (proxiesToSubscribe.has(proxyObject)) {
// Already subscribed
proxiesToSubscribe.delete(proxyObject)
} else {
if (!proxiesToSubscribe.has(proxyObject)) {
subscriptions.delete(proxyObject)
unsubscribe()
}
})

// Subscribe to new proxies
proxiesToSubscribe.forEach((proxyObject) => {
const unsubscribe = subscribe(proxyObject, revalidate, options?.sync)
subscriptions.set(proxyObject, unsubscribe)
})
}

// If there's a parent watch call, we attach this watch's
Expand Down
67 changes: 66 additions & 1 deletion tests/watch.test.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
import { describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { proxy } from 'valtio'
import { watch } from 'valtio/utils'

const sleep = (ms: number) =>
new Promise((resolve) => {
setTimeout(resolve, ms)
})

describe('watch', () => {
beforeEach(() => {
vi.useFakeTimers()
})

afterEach(() => {
vi.useRealTimers()
})

it('should re-run for individual proxy updates', async () => {
const reference = proxy({ value: 'Example' })

Expand Down Expand Up @@ -96,4 +109,56 @@ describe('watch', () => {

reference.value = 'Update'
})
it('should support promise watchers', async () => {
const reference = proxy({ value: 'Example' })

const callback = vi.fn()

const waitPromise = sleep(10000)
watch(async (get) => {
await waitPromise
get(reference)
callback()
})

vi.runAllTimers()
await waitPromise

expect(callback).toBeCalledTimes(1)
// listener will only be attached after one promise callback due to the await stack
await Promise.resolve()
reference.value = 'Update'
// wait for internal promise
await Promise.resolve()
// wait for next promise resolve call due to promise usage inside of callback
await Promise.resolve()
expect(callback).toBeCalledTimes(2)
})

it('should not subscribe if the watch is stopped before the promise completes', async () => {
const reference = proxy({ value: 'Example' })

const callback = vi.fn()

const waitPromise = sleep(10000)
const stop = watch(async (get) => {
await waitPromise
get(reference)
callback()
})
stop()

vi.runAllTimers()
await waitPromise

expect(callback).toBeCalledTimes(1)
// listener will only be attached after one promise callback due to the await stack
await Promise.resolve()
reference.value = 'Update'
// wait for internal promise
await Promise.resolve()
// wait for next promise resolve call due to promise usage inside of callback
await Promise.resolve()
expect(callback).toBeCalledTimes(1)
})
})

1 comment on commit 5950195

@vercel
Copy link

@vercel vercel bot commented on 5950195 Nov 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

valtio – ./

valtio-pmndrs.vercel.app
valtio-git-main-pmndrs.vercel.app
valtio.pmnd.rs
valtio.vercel.app

Please sign in to comment.