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('

20

20

'); + expectRenderCounts({ A: 1, C: 1 }); + + state.x = 4; + await nextTick(); + expect(fixture.innerHTML).toEqual('

30

30

'); + 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('

330

330

'); + expectRenderCounts({ A: 1, C: 1 }); + + // Setting without change of value should not re-render. + state.x = 10; + await nextTick(); + expect(fixture.innerHTML).toEqual('

330

330

'); + 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("

2b

"); }); }); + +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 }); + }); +});