Skip to content

Commit

Permalink
Provide nextTick() helper
Browse files Browse the repository at this point in the history
  • Loading branch information
mbeckem committed Aug 22, 2024
1 parent 5f997c8 commit 37a6b4a
Show file tree
Hide file tree
Showing 9 changed files with 64 additions and 52 deletions.
2 changes: 2 additions & 0 deletions packages/reactivity-core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions packages/reactivity-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,10 @@ async function fetchUserDetails(id: string, signal: AbortSignal): Promise<void>

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. |
Expand All @@ -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.
Expand Down
16 changes: 6 additions & 10 deletions packages/reactivity-core/async.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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);
});
Expand All @@ -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]);
});
Expand All @@ -56,7 +56,7 @@ describe("effect", () => {
r.value = 2; // triggers execution

handle.destroy();
await waitForMacroTask();
await nextTick();
expect(spy).toHaveBeenCalledTimes(1); // not called again
});
});
Expand All @@ -79,7 +79,7 @@ describe("watch", () => {
r1.value = 2; // ignored
handle.destroy();

await waitForMacroTask();
await nextTick();
expect(spy).toBeCalledTimes(0);
});

Expand All @@ -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));
}
39 changes: 26 additions & 13 deletions packages/reactivity-core/async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
*
Expand All @@ -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<T> (
export function watchValue<T>(
selector: () => T,
callback: WatchCallback<T>,
options?: WatchOptions<T> & { 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<T> (
export function watchValue<T>(
selector: () => T,
callback: WatchImmediateCallback<T>,
options?: WatchOptions<T>
): CleanupHandle;
export function watchValue<T> (
export function watchValue<T>(
selector: () => T,
callback: WatchImmediateCallback<T>,
options?: WatchOptions<T>
Expand Down Expand Up @@ -331,7 +331,7 @@ export function watch<const Values extends readonly unknown[]>(
): 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.
Expand All @@ -353,6 +353,19 @@ export function watch<const Values extends readonly unknown[]>(
});
}

/**
* 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<void> {
return new Promise((resolve) => {
dispatchCallback(resolve);
});
}

const tasks = new TaskQueue();

function dispatchCallback(callback: () => void): CleanupHandle {
Expand Down
2 changes: 1 addition & 1 deletion packages/reactivity-core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
24 changes: 12 additions & 12 deletions packages/reactivity-core/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
*
Expand All @@ -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<T> (
export function syncWatchValue<T>(
selector: () => T,
callback: WatchCallback<T>,
options?: WatchOptions<T> & { 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<T> (
export function syncWatchValue<T>(
selector: () => T,
callback: WatchImmediateCallback<T>,
options?: WatchOptions<T>
): CleanupHandle;
export function syncWatchValue<T> (
export function syncWatchValue<T>(
selector: () => T,
callback: WatchImmediateCallback<T>,
options?: WatchOptions<T>
Expand Down Expand Up @@ -205,7 +205,7 @@ export function syncWatch<const Values extends readonly unknown[]>(
): 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.
Expand Down
16 changes: 8 additions & 8 deletions packages/reactivity-core/test/sharedTests.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -686,17 +690,13 @@ async function doMutationImpl(fn: () => void, type: "sync" | "async"): Promise<v
let err: Error | undefined;
errorSpy!.mockImplementationOnce((e) => (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;
Expand Down
4 changes: 2 additions & 2 deletions packages/reactivity-core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ export type WatchCallback<T> = (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<T> = (value: T, oldValue: T | undefined) => void | CleanupFunc;
Expand All @@ -176,7 +176,7 @@ export interface WatchOptions<T> {
/**
* 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;
Expand Down
5 changes: 3 additions & 2 deletions packages/reactivity-core/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ export function watchImpl<T>(
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;
Expand All @@ -66,4 +67,4 @@ export function watchImpl<T>(

function trivialEquals(a: unknown, b: unknown) {
return a === b;
}
}

0 comments on commit 37a6b4a

Please sign in to comment.