From 37a6b4a0e8cb8f925bc7b9f5acc17ac5879f75bf Mon Sep 17 00:00:00 2001 From: Michael Beckemeyer Date: Thu, 22 Aug 2024 16:07:00 +0200 Subject: [PATCH] Provide `nextTick()` helper --- packages/reactivity-core/CHANGELOG.md | 2 + packages/reactivity-core/README.md | 8 ++-- packages/reactivity-core/async.test.ts | 16 +++----- packages/reactivity-core/async.ts | 39 +++++++++++++------- packages/reactivity-core/index.ts | 2 +- packages/reactivity-core/sync.ts | 24 ++++++------ packages/reactivity-core/test/sharedTests.ts | 16 ++++---- packages/reactivity-core/types.ts | 4 +- packages/reactivity-core/watch.ts | 5 ++- 9 files changed, 64 insertions(+), 52 deletions(-) diff --git a/packages/reactivity-core/CHANGELOG.md b/packages/reactivity-core/CHANGELOG.md index d8c7a0e..ec83639 100644 --- a/packages/reactivity-core/CHANGELOG.md +++ b/packages/reactivity-core/CHANGELOG.md @@ -13,6 +13,8 @@ Improved `watch` ergonomics: Other changes: +- Add a new helper function `nextTick` to wait for the execution of pending asynchronous callbacks. + This can be useful in tests. - Update dependencies ## v0.4.0 diff --git a/packages/reactivity-core/README.md b/packages/reactivity-core/README.md index 3213f1e..886cff0 100644 --- a/packages/reactivity-core/README.md +++ b/packages/reactivity-core/README.md @@ -319,6 +319,10 @@ async function fetchUserDetails(id: string, signal: AbortSignal): Promise The following table provides a quick overview of the different variants of `effect` and `watch`: +> NOTE: In most circumstances, `watchValue`, `watch` or `effect` are the right choice. +> The `sync*` variants are useful when you need to run the callback immediately. +> For more details, see [Sync vs async effect / watch](#sync-vs-async-effect--watch). + | Function | Kind of values | Callback condition | Callback delay | | ---------------- | -------------------------------------------------- | ------------------------------------------------- | -------------- | | `effect` | N/A | After _any_ used signal changes. | Slight delay. | @@ -332,10 +336,6 @@ Note that `watchValue` and `watch` are almost the same. `watch` supports watching multiple values at once directly (but forces you to return an array) while `watchValue` only supports a single value. In truth, only their default `equal` functions are different: `watchValue` uses `===` while `watch` uses shallow array equality. -More most circumstances, `watchValue`, `watch` or `effect` are the right choice. -The `sync*` variants are useful when you need to run the callback immediately. -For more details, see [Sync vs async effect / watch](#sync-vs-async-effect--watch). - ### Complex values Up to this point, examples have used primitive values such as strings or integers. diff --git a/packages/reactivity-core/async.test.ts b/packages/reactivity-core/async.test.ts index 38a6aea..340184c 100644 --- a/packages/reactivity-core/async.test.ts +++ b/packages/reactivity-core/async.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { reactive } from "./ReactiveImpl"; -import { effect, watch, watchValue } from "./async"; +import { effect, nextTick, watch, watchValue } from "./async"; import { defineSharedEffectTests, defineSharedWatchTests } from "./test/sharedTests"; describe("effect", () => { @@ -20,7 +20,7 @@ describe("effect", () => { r.value = 1; expect(spy).toHaveBeenCalledTimes(1); // _not_ called again - await waitForMacroTask(); + await nextTick(); expect(spy).toHaveBeenCalledTimes(2); // called after delay expect(spy.mock.lastCall![0]).toBe(1); }); @@ -40,7 +40,7 @@ describe("effect", () => { r1.value = 2; r2.value = 21; r2.value = 22; - await waitForMacroTask(); + await nextTick(); expect(spy).toHaveBeenCalledTimes(2); // called after delay expect(spy.mock.lastCall).toEqual([2, 22]); }); @@ -56,7 +56,7 @@ describe("effect", () => { r.value = 2; // triggers execution handle.destroy(); - await waitForMacroTask(); + await nextTick(); expect(spy).toHaveBeenCalledTimes(1); // not called again }); }); @@ -79,7 +79,7 @@ describe("watch", () => { r1.value = 2; // ignored handle.destroy(); - await waitForMacroTask(); + await nextTick(); expect(spy).toBeCalledTimes(0); }); @@ -97,12 +97,8 @@ describe("watch", () => { r1.value = 2; expect(spy).toBeCalledTimes(0); - await waitForMacroTask(); + await nextTick(); expect(spy).toBeCalledTimes(1); }); }); }); - -function waitForMacroTask() { - return new Promise((resolve) => setTimeout(resolve, 10)); -} diff --git a/packages/reactivity-core/async.ts b/packages/reactivity-core/async.ts index e2b50d0..7ae88e0 100644 --- a/packages/reactivity-core/async.ts +++ b/packages/reactivity-core/async.ts @@ -194,7 +194,7 @@ class AsyncEffect { /** * Watches a single reactive value and executes a callback whenever that value changes. - * + * * `watchValue` works like this: * * 1. The `selector` is a tracked function that shall return a value. @@ -205,21 +205,21 @@ class AsyncEffect { * * The values returned by the selector are compared using object identity by default (i.e. `===`). * Note that you can provide a custom `equal` function to change this behavior. - * + * * Example: - * + * * ```ts * import { reactive, watchValue } from "@conterra/reactivity-core"; - * + * * const v1 = reactive(1); * const v2 = reactive(2); - * + * * // Executes whenever the _sum_ of the two values changes. * watchValue(() => v1.value + v2.value, (sum) => { * console.log("new sum", sum); * }); * ``` - * + * * `watchValue` returns a handle that can be used to unsubscribe from changes. * That handle's `destroy()` function should be called to stop watching when you are no longer interested in updates: * @@ -236,32 +236,32 @@ class AsyncEffect { * > NOTE: This function will slightly defer re-executions of the given `callback`. * > In other words, the re-execution does not happen _immediately_ after a reactive dependency changed. * > This is done to avoid redundant executions as a result of many fine-grained changes. - * > + * > * > If you need more control, take a look at {@link syncWatchValue}. - * + * * @param selector a function that returns the value to watch. * @param callback a function that will be executed whenever the watched value changes. * @param options additional options. */ -export function watchValue ( +export function watchValue( selector: () => T, callback: WatchCallback, options?: WatchOptions & { immediate?: false } ): CleanupHandle; /** * This overload is used when `immediate` is not set to `false`. - * + * * @param selector a function that returns the value to watch. * @param callback a function that will be executed whenever the watched value changed. * @param options additional options. * @group Watching */ -export function watchValue ( +export function watchValue( selector: () => T, callback: WatchImmediateCallback, options?: WatchOptions ): CleanupHandle; -export function watchValue ( +export function watchValue( selector: () => T, callback: WatchImmediateCallback, options?: WatchOptions @@ -331,7 +331,7 @@ export function watch( ): CleanupHandle; /** * This overload is used when `immediate` is not set to `false`. - * + * * @param selector a function that returns the values to watch. * @param callback a function that will be executed whenever the watched values changed. * @param options additional options. @@ -353,6 +353,19 @@ export function watch( }); } +/** + * Returns a promise that resolves after all _currently scheduled_ asynchronous callbacks have executed. + * + * This function is useful in tests to wait for the execution of side effects triggered by an asynchronous `watch` or an `effect`. + * + * @group Watching + */ +export function nextTick(): Promise { + return new Promise((resolve) => { + dispatchCallback(resolve); + }); +} + const tasks = new TaskQueue(); function dispatchCallback(callback: () => void): CleanupHandle { diff --git a/packages/reactivity-core/index.ts b/packages/reactivity-core/index.ts index ab5fb2e..61f2f1b 100644 --- a/packages/reactivity-core/index.ts +++ b/packages/reactivity-core/index.ts @@ -45,6 +45,6 @@ export { isReactive } from "./ReactiveImpl"; export { syncEffect, syncEffectOnce, syncWatch, syncWatchValue } from "./sync"; -export { effect, watch, watchValue } from "./async"; +export { effect, watch, watchValue, nextTick } from "./async"; export * from "./collections"; export * from "./struct"; diff --git a/packages/reactivity-core/sync.ts b/packages/reactivity-core/sync.ts index 3527203..e26cfd6 100644 --- a/packages/reactivity-core/sync.ts +++ b/packages/reactivity-core/sync.ts @@ -94,25 +94,25 @@ export function syncEffectOnce(callback: EffectCallback, onInvalidate: () => voi /** * Watches a single reactive value and executes a callback whenever that value changes. - * + * * This function is the synchronous variant of {@link watchValue}. * It will re-execute after every fine grained change, even if those changes occur in immediate succession. * `syncWatchValue` should therefore be considered a low level primitive, for most use cases {@link watchValue} should be the right tool instead. - * + * * Example: - * + * * ```ts * import { reactive, syncWatchValue } from "@conterra/reactivity-core"; - * + * * const v1 = reactive(1); * const v2 = reactive(2); - * + * * // Executes whenever the _sum_ of the two values changes. * syncWatchValue(() => v1.value + v2.value, (sum) => { * console.log("new sum", sum); * }); * ``` - * + * * `syncWatchValue` returns a handle that can be used to unsubscribe from changes. * That handle's `destroy()` function should be called to stop watching when you are no longer interested in updates: * @@ -125,31 +125,31 @@ export function syncEffectOnce(callback: EffectCallback, onInvalidate: () => voi * ``` * * > NOTE: You must *not* modify the parameters that get passed into `callback`. - * + * * @param selector a function that returns the value to watch. * @param callback a function that will be executed whenever the watched value changes. * @param options additional options. * @group Watching */ -export function syncWatchValue ( +export function syncWatchValue( selector: () => T, callback: WatchCallback, options?: WatchOptions & { immediate?: false } ): CleanupHandle; /** * This overload is used when `immediate` is not set to `false`. - * + * * @param selector a function that returns the value to watch. * @param callback a function that will be executed whenever the watched value changes. * @param options additional options. * @group Watching */ -export function syncWatchValue ( +export function syncWatchValue( selector: () => T, callback: WatchImmediateCallback, options?: WatchOptions ): CleanupHandle; -export function syncWatchValue ( +export function syncWatchValue( selector: () => T, callback: WatchImmediateCallback, options?: WatchOptions @@ -205,7 +205,7 @@ export function syncWatch( ): CleanupHandle; /** * This overload is used when `immediate` is not set to `false`. - * + * * @param selector a function that returns the values to watch. * @param callback a function that will be executed whenever the watched values changed. * @param options additional options. diff --git a/packages/reactivity-core/test/sharedTests.ts b/packages/reactivity-core/test/sharedTests.ts index 103c5de..530cb9e 100644 --- a/packages/reactivity-core/test/sharedTests.ts +++ b/packages/reactivity-core/test/sharedTests.ts @@ -1,5 +1,6 @@ import { afterEach } from "node:test"; import { MockInstance, beforeEach, describe, expect, it, vi } from "vitest"; +import { nextTick } from "../async"; import { batch, reactive } from "../ReactiveImpl"; import { type syncEffect, type syncWatch, type syncWatchValue } from "../sync"; import * as report from "../utils/reportTaskError"; @@ -448,9 +449,12 @@ export function defineSharedWatchTests( const spy = vi.fn(); const r1 = reactive(1); const r2 = reactive(2); - watchValueImpl(() => r1.value + r2.value, (sum, oldSum) => { - spy(sum, oldSum); - }); + watchValueImpl( + () => r1.value + r2.value, + (sum, oldSum) => { + spy(sum, oldSum); + } + ); expect(spy).toBeCalledTimes(0); await doMutation(() => { @@ -686,17 +690,13 @@ async function doMutationImpl(fn: () => void, type: "sync" | "async"): Promise (err = e)); fn(); - await waitForMacroTask(); + await nextTick(); if (err) { throw err; } } } -function waitForMacroTask() { - return new Promise((resolve) => setTimeout(resolve, 4)); -} - function mockErrorReport() { const errorSpy = vi.spyOn(report, "reportTaskError").mockImplementation(() => {}); return errorSpy; diff --git a/packages/reactivity-core/types.ts b/packages/reactivity-core/types.ts index fe753c9..6e5ccfd 100644 --- a/packages/reactivity-core/types.ts +++ b/packages/reactivity-core/types.ts @@ -152,7 +152,7 @@ export type WatchCallback = (value: T, oldValue: T) => void | CleanupFunc; /** * Like {@link WatchCallback}, but the `oldValue` parameter may be `undefined` for the first invocation. * This is the case when `immediate: true` has been passed to the watch function, in which case there cannot be a previous value. - * + * * @group Watching */ export type WatchImmediateCallback = (value: T, oldValue: T | undefined) => void | CleanupFunc; @@ -176,7 +176,7 @@ export interface WatchOptions { /** * A function that returns `true` if the two values are considered equal. * If this function is provided, the watch callback will only be triggered if this function returns `false`. - * + * * By default, an implementation based on object identity is used. */ equal?(prev: T, next: T): boolean; diff --git a/packages/reactivity-core/watch.ts b/packages/reactivity-core/watch.ts index 0502e46..3e01228 100644 --- a/packages/reactivity-core/watch.ts +++ b/packages/reactivity-core/watch.ts @@ -40,7 +40,8 @@ export function watchImpl( const next = computedArgs.value; // Tracked untracked(() => { const prev = value; - const shouldExecute = (firstExecution && immediate) || (!firstExecution && !equal(prev, next)); + const shouldExecute = + (firstExecution && immediate) || (!firstExecution && !equal(prev, next)); if (shouldExecute || firstExecution) { value = next; firstExecution = false; @@ -66,4 +67,4 @@ export function watchImpl( function trivialEquals(a: unknown, b: unknown) { return a === b; -} \ No newline at end of file +}