diff --git a/src/runtime/computed.ts b/src/runtime/computed.ts
new file mode 100644
index 000000000..4815ccfdd
--- /dev/null
+++ b/src/runtime/computed.ts
@@ -0,0 +1,79 @@
+import { Reactive, Target, multiReactive, toRaw } from "./reactivity";
+
+/**
+ * Creates a lazy reactive computed value.
+ *
+ * Calling the resulting function on the target not only returns the computed value,
+ * it also caches the result in the target. As a result, succeeding function calls
+ * will not trigger recalculation. And because of the reactivity system, the cached
+ * value will be invalidated when any of the dependencies of the compute function
+ * changes.
+ *
+ * Aside from caching, the computation is part of the reactivity system. This means
+ * that it plays well with rerendering. For example, having the following tree,
+ * ``, where `A` reads from a computed value, when the computed
+ * value changes (or the dependencies of the computed value changes), only the
+ * components that read from the computed value will rerender. In this case, only
+ * `A` will rerender.
+ *
+ * Note that this is only valid for one target and one compute function.
+ * Use `computed` for shared compute functions.
+ */
+export function defineComputed(compute: (target: any) => any, name?: string) {
+ // This is the key that will be used to store the compute value in the target.
+ const cacheKey = name ? Symbol(name) : Symbol();
+ let isValid = false;
+ const invalidate = () => (isValid = false);
+ return (target: any) => {
+ if (isValid) {
+ // Return the cached value if it is still valid.
+ // This will subscribe the target's reactive directly to the cached value.
+ return target[cacheKey];
+ } else {
+ // Create a target with multiple reactives.
+ // - First is the original target's reactive.
+ // - Second is the invalidate function.
+ // This means that when any of the dependencies of the compute function changes,
+ // the invalidate function and the original target's reactive will be notified.
+ const mTarget = multiReactive(target, invalidate);
+ // Call the compute function on the multi-reactive target.
+ // This will subscribe the reactives to the dependencies of the compute function.
+ const value = compute(mTarget);
+ isValid = true;
+ try {
+ return value;
+ } finally {
+ // Right after return, the value is cached in the target.
+ // This will notify the subscribers of this computed value.
+ target[cacheKey] = value;
+ }
+ }
+ };
+}
+
+// map: target -> compute -> cached compute
+const t2c2cc = new WeakMap();
+
+/**
+ * This allows sharing of a declared computed such that for each target-compute
+ * combination, there is a corresponding cached computed function.
+ */
+export function computed(
+ compute: (target: T | Reactive) => R,
+ name?: string
+) {
+ return (target: T | Reactive): R => {
+ const raw = toRaw(target);
+ let c2cc = t2c2cc.get(raw);
+ if (!c2cc) {
+ c2cc = new Map();
+ t2c2cc.set(raw, c2cc);
+ }
+ let cachedCompute = c2cc.get(compute);
+ if (!cachedCompute) {
+ cachedCompute = defineComputed(compute, name);
+ c2cc.set(compute, cachedCompute);
+ }
+ return cachedCompute(target);
+ };
+}
diff --git a/src/runtime/index.ts b/src/runtime/index.ts
index 651263a9c..f0eb23637 100644
--- a/src/runtime/index.ts
+++ b/src/runtime/index.ts
@@ -40,6 +40,7 @@ export type { ComponentConstructor } from "./component";
export { useComponent, useState } from "./component_node";
export { status } from "./status";
export { reactive, markRaw, toRaw } from "./reactivity";
+export { computed } from "./computed";
export { useEffect, useEnv, useExternalListener, useRef, useChildSubEnv, useSubEnv } from "./hooks";
export { EventBus, whenReady, loadFile, markup } from "./utils";
export {
diff --git a/src/runtime/reactivity.ts b/src/runtime/reactivity.ts
index 9588d6cc8..15ecb1121 100644
--- a/src/runtime/reactivity.ts
+++ b/src/runtime/reactivity.ts
@@ -11,8 +11,8 @@ const NO_CALLBACK = () => {
// The following types only exist to signify places where objects are expected
// to be reactive or not, they provide no type checking benefit over "object"
-type Target = object;
-type Reactive = T;
+export type Target = object;
+export type Reactive = T;
type Collection = Set | Map | WeakMap;
type CollectionRawType = "Set" | "Map" | "WeakMap";
@@ -107,6 +107,19 @@ function observeTargetKey(target: Target, key: PropertyKey, callback: Callback):
}
callbacksToTargets.get(callback)!.add(target);
}
+
+function clearAndCall(callback: Callback) {
+ clearReactivesForCallback(callback);
+ if (callback instanceof Array) {
+ // Recursively clear and call all callback pairs.
+ for (const cb of callback) {
+ clearAndCall(cb);
+ }
+ } else {
+ callback();
+ }
+}
+
/**
* Notify Reactives that are observing a given target that a key has changed on
* the target.
@@ -127,8 +140,7 @@ function notifyReactives(target: Target, key: PropertyKey): void {
}
// Loop on copy because clearReactivesForCallback will modify the set in place
for (const callback of [...callbacks]) {
- clearReactivesForCallback(callback);
- callback();
+ clearAndCall(callback);
}
}
@@ -176,6 +188,7 @@ export function getSubscriptions(callback: Callback) {
// Maps reactive objects to the underlying target
export const targets = new WeakMap, Target>();
const reactiveCache = new WeakMap>>();
+const proxyToCallback = new WeakMap, Callback>();
/**
* Creates a reactive proxy for an object. Reading data on the reactive object
* subscribes to changes to the data. Writing data on the object will cause the
@@ -225,10 +238,27 @@ export function reactive(target: T, callback: Callback = NO_CA
: basicProxyHandler(callback);
const proxy = new Proxy(target, handler as ProxyHandler) as Reactive;
reactivesForTarget.set(callback, proxy);
+ proxyToCallback.set(proxy, callback);
targets.set(proxy, target);
}
return reactivesForTarget.get(callback) as Reactive;
}
+
+/**
+ * Creates a target that will notify multiple reactives when dependencies change.
+ */
+export function multiReactive(
+ reactiveTarget: T | Reactive,
+ callback: Callback
+): T {
+ const existingCB = proxyToCallback.get(reactiveTarget);
+ if (existingCB && existingCB !== NO_CALLBACK) {
+ return reactive(reactiveTarget, [callback, existingCB]);
+ } else {
+ return reactive(reactiveTarget, callback);
+ }
+}
+
/**
* Creates a basic proxy handler for regular objects and arrays.
*
diff --git a/src/runtime/utils.ts b/src/runtime/utils.ts
index 7aa9b112f..a2d6e0039 100644
--- a/src/runtime/utils.ts
+++ b/src/runtime/utils.ts
@@ -1,5 +1,5 @@
import { OwlError } from "../common/owl_error";
-export type Callback = () => void;
+export type Callback = (() => void) | [first: Callback, second: Callback];
/**
* Creates a batched version of a callback so that all calls to it in the same
@@ -8,9 +8,9 @@ export type Callback = () => void;
* @param callback the callback to batch
* @returns a batched version of the original callback
*/
-export function batched(callback: Callback): Callback {
+export function batched(callback: (...args: Args) => any) {
let scheduled = false;
- return async (...args) => {
+ return async (...args: Args) => {
if (!scheduled) {
scheduled = true;
await Promise.resolve();
diff --git a/tests/computed.test.ts b/tests/computed.test.ts
new file mode 100644
index 000000000..87945855b
--- /dev/null
+++ b/tests/computed.test.ts
@@ -0,0 +1,579 @@
+import { Component, mount, onRendered, reactive, useState, xml } from "../src";
+import { batched } from "../src/runtime/utils";
+import { makeTestFixture, nextMicroTick, nextTick } from "./helpers";
+
+function effect(cb: any, deps: any) {
+ const reactiveDeps = reactive(deps, function recompute() {
+ cb(...reactiveDeps);
+ });
+ cb(...reactiveDeps);
+}
+
+function lazyComputed(obj: any, propName: string, compute: any) {
+ const key = Symbol(propName);
+ Object.defineProperty(obj, propName, {
+ get() {
+ return this[key]();
+ },
+ configurable: true,
+ });
+
+ effect(
+ function recompute(obj: any) {
+ const value: any[] = [];
+ obj[key] = () => {
+ if (!value.length) {
+ value.push(compute(obj));
+ }
+ return value[0];
+ };
+ },
+ [obj]
+ );
+}
+
+describe("computed - with effect", () => {
+ let orderComputeCounts = { itemTotal: 0, orderTotal: 0 };
+ const resetOrderComputeCounts = () => {
+ orderComputeCounts.itemTotal = 0;
+ orderComputeCounts.orderTotal = 0;
+ };
+
+ const expectOrderComputeCounts = (expected: { itemTotal: number; orderTotal: number }) => {
+ expect(orderComputeCounts).toEqual(expected);
+ resetOrderComputeCounts();
+ };
+
+ beforeEach(() => {
+ resetOrderComputeCounts();
+ });
+
+ type Product = { unitPrice: number };
+ type OrderItem = { product: Product; quantity: number; itemTotal: number };
+ type Order = { items: any[]; discount: number; orderTotal: number };
+
+ const createProduct = (unitPrice: number): Product => {
+ return reactive({ unitPrice });
+ };
+
+ const createOrderItem = (product: Product, quantity: number) => {
+ const item = reactive({ product, quantity });
+ lazyComputed(item, "itemTotal", (item: OrderItem) => {
+ orderComputeCounts.itemTotal++;
+
+ return item.product.unitPrice * item.quantity;
+ });
+ return item;
+ };
+
+ const createOrder = () => {
+ const order = reactive({ items: [], discount: 0 });
+ lazyComputed(order, "orderTotal", (order: Order) => {
+ orderComputeCounts.orderTotal++;
+
+ let result = 0;
+ for (let item of order.items) {
+ result += item.itemTotal;
+ }
+ return result * (1 - order.discount / 100);
+ });
+ return order as unknown as Order;
+ };
+
+ test("effect depends on getter", () => {
+ let distanceComputeCount = 0;
+ const expectDistanceComputeCount = (expected: number) => {
+ expect(distanceComputeCount).toBe(expected);
+ distanceComputeCount = 0;
+ };
+
+ // point <- computed distance <- computed deepComputedVal
+ const point = reactive({ x: 0, y: 0 }) as any;
+ lazyComputed(point, "distance", (p: typeof point) => {
+ distanceComputeCount++;
+ return Math.sqrt(Math.pow(p.x, 2) + Math.pow(p.y, 2));
+ });
+ lazyComputed(point, "deepComputedVal", (p: typeof point) => {
+ // absurd computation to test that the getter is not recomputed
+ let result = 0;
+ for (let i = 0; i < 5; i++) {
+ // @ts-ignore
+ result += p.distance;
+ }
+ return result;
+ });
+
+ let val = 0;
+ effect(
+ (p: any) => {
+ // Notice that in this effect, only the `deepComputedVal` is directly used.
+ // It is indirectly dependent on the `x` and `y` of `p`.
+ // Nevertheless, mutating `x` or `y` should trigger this effect.
+ val = p.deepComputedVal;
+ },
+ [point]
+ );
+
+ expect(val).toEqual(0);
+ expectDistanceComputeCount(1);
+ expect(point.distance).toEqual(0);
+ // No recomputation even after the previous `distance` call.
+ expectDistanceComputeCount(0);
+
+ point.x = 3;
+ expect(val).toEqual(15);
+ expectDistanceComputeCount(1);
+ expect(point.distance).toEqual(3);
+ // No recomputation even after the previous `distance` call.
+ expectDistanceComputeCount(0);
+
+ point.y = 4;
+ expect(val).toEqual(25);
+ expectDistanceComputeCount(1);
+ expect(point.distance).toEqual(5);
+ // No recomputation even after the previous `distance` call.
+ expectDistanceComputeCount(0);
+ });
+
+ test("can depend on network of objects", () => {
+ const p1 = createProduct(10);
+ const p2 = createProduct(20);
+ const p3 = createProduct(30);
+ const o = createOrder();
+ o.items.push(createOrderItem(p1, 1));
+ o.items.push(createOrderItem(p2, 2));
+ o.items.push(createOrderItem(p3, 3));
+
+ let orderTotal = 0;
+ effect(
+ (o: any) => {
+ orderTotal = o.orderTotal;
+ },
+ [o]
+ );
+
+ expect(orderTotal).toEqual(140);
+
+ p1.unitPrice = 11;
+ expect(orderTotal).toEqual(141);
+
+ o.items[1].quantity = 4;
+ expect(orderTotal).toEqual(181);
+
+ o.items.push(createOrderItem(createProduct(40), 1));
+ expect(orderTotal).toEqual(221);
+ });
+
+ test("batched effect", async () => {
+ const p1 = createProduct(10);
+ const p2 = createProduct(20);
+ const p3 = createProduct(30);
+ const o = createOrder();
+ o.items.push(createOrderItem(p1, 1));
+ o.items.push(createOrderItem(p2, 2));
+ o.items.push(createOrderItem(p3, 3));
+
+ let orderTotal = 0;
+ effect(
+ batched((o) => {
+ orderTotal = o.orderTotal;
+ }),
+ [o]
+ );
+
+ await nextMicroTick();
+
+ expect(orderTotal).toEqual(140);
+ expectOrderComputeCounts({ itemTotal: 3, orderTotal: 1 });
+
+ p1.unitPrice = 11;
+ await nextMicroTick();
+
+ expect(orderTotal).toEqual(141);
+ expectOrderComputeCounts({ itemTotal: 1, orderTotal: 1 });
+
+ o.items[1].quantity = 4;
+ p3.unitPrice = 31;
+ await nextMicroTick();
+
+ expect(orderTotal).toEqual(184);
+ expectOrderComputeCounts({ itemTotal: 2, orderTotal: 1 });
+
+ o.items.push(createOrderItem(createProduct(40), 1));
+ o.items[0].quantity = 5;
+ p2.unitPrice = 21;
+ await nextMicroTick();
+
+ expect(orderTotal).toEqual(272);
+ expectOrderComputeCounts({ itemTotal: 3, orderTotal: 1 });
+ expect(o.orderTotal).toEqual(272);
+ // No recomputation even after the previous `getOrderTotal` call.
+ expectOrderComputeCounts({ itemTotal: 0, orderTotal: 0 });
+
+ o.discount = 10;
+ await nextMicroTick();
+ expect(orderTotal).toEqual(244.8);
+ expectOrderComputeCounts({ itemTotal: 0, orderTotal: 1 });
+ });
+
+ test("computed sorted array", async () => {
+ let sortingCount = 0;
+ const expectSortingCount = (expected: number) => {
+ expect(sortingCount).toBe(expected);
+ sortingCount = 0;
+ };
+
+ const array = reactive([
+ 52, 26, 71, 63, 72, 57, 71, 11, 17, 30, 52, 90, 14, 33, 86, 13, 62, 34, 99, 61, 21, 92, 95,
+ 99, 0, 92, 6, 35, 95, 39, 87, 30, 50, 74, 21, 67, 34, 98, 99, 46, 85, 63, 41, 56, 18, 43, 23,
+ 59, 52, 12,
+ ]);
+
+ lazyComputed(array, "sortedArray", (a: typeof array) => {
+ sortingCount++;
+ const copy = [...a];
+ return copy.sort((a, b) => a - b);
+ });
+
+ lazyComputed(array, "range", (a: typeof array) => {
+ // @ts-ignore
+ a = a.sortedArray;
+ return a[a.length - 1] - a[0];
+ });
+
+ lazyComputed(array, "average", (a: typeof array) => {
+ let sum = 0;
+ for (let i = 0; i < a.length; i++) {
+ sum += a[i];
+ }
+ return Math.trunc(sum / a.length);
+ });
+
+ lazyComputed(array, "min", (a: typeof array) => {
+ // @ts-ignore
+ a = a.sortedArray;
+ return a[0];
+ });
+
+ lazyComputed(array, "max", (a: typeof array) => {
+ // @ts-ignore
+ a = a.sortedArray;
+ return a[a.length - 1];
+ });
+
+ lazyComputed(array, "statTotal", (a: typeof array) => {
+ // @ts-ignore
+ return a.range + a.average + a.min + a.max;
+ });
+
+ let val = 0;
+ effect(
+ batched((a) => {
+ val = a.statTotal + a.statTotal + a.statTotal;
+ }),
+ [array]
+ );
+
+ await nextMicroTick();
+
+ expectSortingCount(1);
+ expect(val).toEqual(250 * 3);
+
+ array.push(99, 100);
+ await nextMicroTick();
+
+ expectSortingCount(1);
+ expect(val).toEqual(254 * 3);
+
+ array.push(-50);
+ await nextMicroTick();
+
+ expectSortingCount(1);
+ expect(val).toEqual(252 * 3);
+
+ // After some mutations, the original order is kept because the `sortedArray` getter makes a copy of the array before sorting.
+ const arrayCopy = [...array];
+ for (let i = 0; i < array.length; i++) {
+ expect(array[i]).toEqual(arrayCopy[i]);
+ }
+
+ // Sort will perform so many mutations in the original array.
+ // This will register several of recomputations.
+ // But since the effect is batched, it will only be recomputed once.
+ array.sort((a, b) => b - a);
+ await nextMicroTick();
+
+ expectSortingCount(1);
+ expect(val).toEqual(252 * 3);
+ });
+});
+
+describe("computed - with components", () => {
+ type State = { a: number; b: number };
+
+ let fixture: HTMLElement;
+ let computeCounts = { c: 0, d: 0, e: 0, f: 0 };
+
+ const expectComputeCounts = (expected: { c: number; d: number; e: number; f: number }) => {
+ expect(computeCounts).toEqual(expected);
+ computeCounts = { c: 0, d: 0, e: 0, f: 0 };
+ };
+
+ beforeEach(() => {
+ fixture = makeTestFixture();
+ computeCounts = { c: 0, d: 0, e: 0, f: 0 };
+ });
+
+ const updateA = (self: State, by: number) => {
+ self.a += by;
+ };
+ const updateB = (self: State, by: number) => {
+ self.b += by;
+ };
+
+ const createComponents = (state: State) => {
+ lazyComputed(state, "c", (self: any) => {
+ computeCounts.c++;
+ return self.a + self.b;
+ });
+ lazyComputed(state, "d", (self: any) => {
+ computeCounts.d++;
+ return 2 * self.a;
+ });
+ lazyComputed(state, "e", (self: any) => {
+ computeCounts.e++;
+ let result = 0;
+ for (let i = 0; i < 5; i++) {
+ result += self.c + self.d;
+ }
+ return result;
+ });
+ lazyComputed(state, "f", (self: any) => {
+ computeCounts.f++;
+ let result = 0;
+ for (let i = 0; i < 10; i++) {
+ result += self.d;
+ }
+ return result;
+ });
+ let renderCounts = { App: 0, C: 0, D: 0, E: 0, F: 0 };
+ const expectRenderCounts = (expected: {
+ App: number;
+ C: number;
+ D: number;
+ E: number;
+ F: number;
+ }) => {
+ expect(renderCounts).toEqual(expected);
+ renderCounts = { App: 0, C: 0, D: 0, E: 0, F: 0 };
+ };
+
+ class BaseComp extends Component {
+ state = useState(state);
+ setup() {
+ onRendered(() => {
+ const name = (this.constructor as any).name as keyof typeof renderCounts;
+ renderCounts[name]++;
+ });
+ }
+ }
+ class C extends BaseComp {
+ static components = {};
+ }
+ class D extends BaseComp {
+ static components = {};
+ }
+ class E extends BaseComp {
+ static components = {};
+ }
+ class F extends BaseComp {
+ static components = {};
+ }
+ class App extends BaseComp {
+ static components = {};
+ }
+ return { App, C, D, E, F, expectRenderCounts };
+ };
+
+ test("number of rerendering - sibling components", async () => {
+ const state = reactive({ a: 1, b: 1 });
+ const { App, C, D, E, F, expectRenderCounts } = createComponents(state);
+
+ C.template = xml``;
+
+ D.template = xml``;
+
+ E.template = xml``;
+
+ F.template = xml``;
+
+ App.template = xml`
`;
+ App.components = { C, D, E, F };
+
+ await mount(App, fixture);
+ expectRenderCounts({ App: 1, C: 1, D: 1, E: 1, F: 1 });
+
+ updateA(state, 1);
+ await nextTick();
+ // App doesn't depend on the state, therefore it doesn't re-render
+ expectRenderCounts({ App: 0, C: 1, D: 1, E: 1, F: 1 });
+
+ updateB(state, 1);
+ await nextTick();
+ // changing b doesn't affect d
+ expectRenderCounts({ App: 0, C: 1, D: 0, E: 1, F: 0 });
+
+ // Multiple changes should only render once.
+ for (let i = 0; i < 10; i++) {
+ updateA(state, 1);
+ }
+ await nextTick();
+ expectRenderCounts({ App: 0, C: 1, D: 1, E: 1, F: 1 });
+ });
+
+ test("number of rerenderings - nested components", async () => {
+ const state = reactive({ a: 1, b: 1 });
+ const { App, C, D, E, F, expectRenderCounts } = createComponents(state);
+
+ F.template = xml``;
+
+ E.template = xml``;
+ E.components = { F };
+
+ D.template = xml``;
+ D.components = { E };
+
+ C.template = xml``;
+ C.components = { D };
+
+ App.template = xml`
`;
+ App.components = { C };
+
+ await mount(App, fixture);
+ expectRenderCounts({ App: 1, C: 1, D: 1, E: 1, F: 1 });
+
+ updateA(state, 1);
+ await nextTick();
+ // App doesn't depend on the state, therefore it doesn't re-render
+ expectRenderCounts({ App: 0, C: 1, D: 1, E: 1, F: 1 });
+
+ updateB(state, 1);
+ await nextTick();
+ // changing b doesn't affect d
+ expectRenderCounts({ App: 0, C: 1, D: 0, E: 1, F: 0 });
+
+ // Multiple changes should only render once.
+ for (let i = 0; i < 10; i++) {
+ updateA(state, 1);
+ }
+ await nextTick();
+ expectRenderCounts({ App: 0, C: 1, D: 1, E: 1, F: 1 });
+ });
+
+ test("number of compute calls in components", async () => {
+ const state = reactive({ a: 1, b: 1 });
+ const { App, C, D, E, F } = createComponents(state);
+
+ C.template = xml``;
+
+ D.template = xml``;
+
+ E.template = xml``;
+
+ F.template = xml``;
+
+ App.template = xml`
`;
+ App.components = { C, D, E, F };
+
+ await mount(App, fixture);
+ expectComputeCounts({ c: 1, d: 1, e: 1, f: 1 });
+
+ updateA(state, 1);
+ await nextTick();
+ // everything will be re-computed
+ expectComputeCounts({ c: 1, d: 1, e: 1, f: 1 });
+
+ updateB(state, 1);
+ await nextTick();
+ // only c and e will be re-computed
+ expectComputeCounts({ c: 1, d: 0, e: 1, f: 0 });
+
+ // update both
+ updateA(state, 1);
+ updateB(state, 1);
+ await nextTick();
+ // all will be re-computed and only once
+ expectComputeCounts({ c: 1, d: 1, e: 1, f: 1 });
+ });
+
+ test("more complicated compute tree", async () => {
+ const state = reactive({ x: 3, y: 2 });
+
+ lazyComputed(state, "b", (self: any) => {
+ return self.x + self.y;
+ });
+ lazyComputed(state, "e", (self: any) => {
+ return self.b * self.x;
+ });
+ lazyComputed(state, "f", (self: any) => {
+ return self.e + self.b;
+ });
+
+ const renderCounts = { A: 0, C: 0 };
+ const expectRenderCounts = (expected: { A: number; C: number }) => {
+ expect(renderCounts).toEqual(expected);
+ renderCounts.A = 0;
+ renderCounts.C = 0;
+ };
+
+ class BaseComp extends Component {
+ state = useState(state);
+ }
+ class A extends BaseComp {
+ static components = {};
+ static template = xml``;
+ setup() {
+ super.setup();
+ onRendered(() => {
+ renderCounts.A++;
+ });
+ }
+ }
+ class C extends BaseComp {
+ static components = {};
+ static template = xml``;
+ setup() {
+ super.setup();
+ onRendered(() => {
+ renderCounts.C++;
+ });
+ }
+ }
+ class App extends Component {
+ static components = { A, C };
+ static template = xml``;
+ }
+
+ await mount(App, fixture);
+ expect(fixture.innerHTML).toEqual('');
+ expectRenderCounts({ A: 1, C: 1 });
+
+ state.x = 4;
+ await nextTick();
+ expect(fixture.innerHTML).toEqual('');
+ expectRenderCounts({ A: 1, C: 1 });
+
+ // Mutating both should also result to just one re-rendering.
+ state.x = 10;
+ state.y = 20;
+ await nextTick();
+ expect(fixture.innerHTML).toEqual('');
+ expectRenderCounts({ A: 1, C: 1 });
+
+ // Setting without change of value should not re-render.
+ state.x = 10;
+ await nextTick();
+ expect(fixture.innerHTML).toEqual('');
+ expectRenderCounts({ A: 0, C: 0 });
+ });
+});
diff --git a/tests/reactivity.test.ts b/tests/reactivity.test.ts
index bad028cd8..22d019cc6 100644
--- a/tests/reactivity.test.ts
+++ b/tests/reactivity.test.ts
@@ -9,7 +9,7 @@ import {
markRaw,
toRaw,
} from "../src";
-import { reactive, getSubscriptions } from "../src/runtime/reactivity";
+import { reactive, getSubscriptions, multiReactive } from "../src/runtime/reactivity";
import { batched } from "../src/runtime/utils";
import {
makeDeferred,
@@ -2424,3 +2424,104 @@ describe("Reactivity: useState", () => {
expect(fixture.innerHTML).toBe("");
});
});
+
+describe("multiReactive", () => {
+ /**
+ * A version of effect that combines with the reactive
+ * object so that both have the same dependencies.
+ */
+ function effectMulti(dep: T, fn: (o: T) => void) {
+ const r: any = multiReactive(dep, () => fn(r));
+ fn(r);
+ }
+
+ test("basic", async () => {
+ let count = 0;
+ const expectCount = (expected: number) => {
+ expect(count).toBe(expected);
+ count = 0;
+ };
+
+ const t = reactive({ a: 3, b: 2 }, () => count++);
+
+ let val = 0;
+ effectMulti(t, (t) => {
+ count++;
+ val = t.a + t.b;
+ });
+
+ expect(val).toBe(5);
+ // count is one initially because only the effect is called.
+ // This is the time that the callback of t starts subscribing to changes of t.
+ expectCount(1);
+
+ t.a = 4;
+ expect(val).toBe(6);
+ // count will be 2 because t's callback is called and the effect is called.
+ expectCount(2);
+ });
+
+ test("doesn't call the NO_CALLBACK function", async () => {
+ let count = 0;
+ const expectCount = (expected: number) => {
+ expect(count).toBe(expected);
+ count = 0;
+ };
+
+ // This target has the NO_CALLBACK function as reactive.
+ const t = reactive({ a: 3, b: 2 });
+
+ let val = 0;
+ effectMulti(t, (t) => {
+ count++;
+ val = t.a + t.b;
+ });
+
+ expect(val).toBe(5);
+ expectCount(1);
+
+ t.a = 4;
+ expect(val).toBe(6);
+ expectCount(1);
+
+ t.a = 4;
+ expect(val).toBe(6);
+ expectCount(0);
+ });
+
+ test("properly clear reactives from other observed keys when already called", async () => {
+ // This only works for batched effects because there is time to clear
+ // the already called callbacks before the effect is called.
+
+ let counts = { t: 0, mt: 0 };
+ function expectCounts(expected: { t: number; mt: number }) {
+ expect(counts).toEqual(expected);
+ counts = { t: 0, mt: 0 };
+ }
+
+ const t = reactive({ a: 3, b: 2 }, () => counts.t++);
+ const mt = multiReactive(t, () => counts.mt++);
+
+ let val = 0;
+ effectMulti(
+ mt,
+ batched((t) => {
+ val = t.a * t.b;
+ })
+ );
+
+ await nextMicroTick();
+ expect(val).toBe(6);
+ expectCounts({ t: 0, mt: 0 });
+
+ t.a = 4;
+ t.b = 3;
+ await nextMicroTick();
+ expect(val).toBe(12);
+ // Even if both a and b changed, the reactives are only called once.
+ // When `a` is set, the mt and t reactives are notified, and during this
+ // notification, these reactives' subscription to `b` is removed. So when
+ // `b` is set, there are no more callbacks to call.
+ expectCounts({ t: 1, mt: 1 });
+ });
+});