diff --git a/src/runtime/reactivity.ts b/src/runtime/reactivity.ts index 12d61a7d5..96f98d731 100644 --- a/src/runtime/reactivity.ts +++ b/src/runtime/reactivity.ts @@ -227,6 +227,30 @@ export function reactive(target: T, callback: Callback = NO_CA const proxy = new Proxy(target, handler as ProxyHandler) as Reactive; reactivesForTarget.set(callback, proxy); targets.set(proxy, target); + // FIXME: this probably slows down reactive creation significantly, we probably don't want to do + // it all the time. Maybe should be a separate function. + const derivedDescriptors = Object.entries(Object.getOwnPropertyDescriptors(target)).filter( + ([k, descriptor]) => { + if (toRaw(descriptor.value)?.[IS_DERIVED_DESCRIPTOR]) { + delete target[k as keyof typeof target]; // prevent circular call in effect below + return true; + } + return false; + } + ); + for (const [ + key, + { + value: [deps, compute], + }, + ] of derivedDescriptors) { + effect( + (proxy, deps) => { + proxy[key as keyof typeof proxy] = Reflect.apply(compute, proxy, deps); + }, + [proxy, deps] + ); + } } return reactivesForTarget.get(callback) as Reactive; } @@ -463,3 +487,18 @@ function collectionsProxyHandler( }, }) as ProxyHandler; } + +const IS_DERIVED_DESCRIPTOR = Symbol("is derived descriptor"); +export function derived[], U>(deps: T, compute: (...args: T) => U) { + return Object.assign([deps, compute], { [IS_DERIVED_DESCRIPTOR]: true }) as unknown as U; +} + +/** + * Creates a side-effect that runs based on the content of reactive objects. + */ +export function effect(cb: (...args: [...T]) => void, deps: [...T]) { + const reactiveDeps = reactive(deps, () => { + cb(...reactiveDeps); + }); + cb(...reactiveDeps); +} diff --git a/tests/reactivity.test.ts b/tests/reactivity.test.ts index bad028cd8..234c502bd 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, derived } from "../src/runtime/reactivity"; import { batched } from "../src/runtime/utils"; import { makeDeferred, @@ -2424,3 +2424,156 @@ describe("Reactivity: useState", () => { expect(fixture.innerHTML).toBe("

2b

"); }); }); + +describe("derived", () => { + test("can read", async () => { + const state = reactive({ a: derived([], () => 1) }); + expect(state.a).toBe(1); + }); + + test("can create new keys", () => { + const state: any = reactive({ b: derived([], () => 2) }); + state.a = 1; + expect(state.a).toBe(1); + }); + + test("can update", () => { + const o = reactive({ a: 1 }); + let computeCall = 0; + const state = reactive({ + a: derived([o], (o) => { + computeCall++; + return o.a; + }), + }); + expect(computeCall).toBe(1); + expect(state.a).toBe(1); + o.a = 2; + expect(computeCall).toBe(2); + expect(state.a).toBe(2); + }); + + test("callback is called when changing an observed property", async () => { + let notifyCount = 0; + const o = reactive({ a: 1 }); + let computeCall = 0; + const state = reactive( + { + a: derived([o], (o) => { + computeCall++; + return o.a; + }), + }, + () => notifyCount++ + ); + expect(computeCall).toBe(1); + expect(notifyCount).toBe(0); + expect(state.a).toBe(1); + o.a = 2; + expect(computeCall).toBe(2); + expect(notifyCount).toBe(1); + expect(state.a).toBe(2); + o.a = 5; + expect(computeCall).toBe(3); + expect(notifyCount).toBe(2); + expect(state.a).toBe(5); + }); + + test("multiple dependencies", async () => { + let notifyCount = 0; + const a = reactive({ val: 1 }); + const b = reactive({ val: 2 }); + let computeCall = 0; + const state = reactive( + { + c: derived([a, b], (a, b) => { + computeCall++; + return a.val + b.val; + }), + }, + () => notifyCount++ + ); + expect(computeCall).toBe(1); + a.val = 2; + expect(computeCall).toBe(2); + expect(notifyCount).toBe(0); + expect(state.c).toBe(4); + a.val = 4; + expect(computeCall).toBe(3); + expect(notifyCount).toBe(1); + expect(state.c).toBe(6); + b.val = 3; + expect(computeCall).toBe(4); + expect(notifyCount).toBe(2); + expect(state.c).toBe(7); + }); + + test("dependency on own fields", async () => { + let notifyCount = 0; + const a = reactive({ val: 1 }); + let computeCall = 0; + const state = reactive( + { + b: 2, + c: derived([a], function (this: any, a) { + computeCall++; + return a.val + this.b; + }), + }, + () => notifyCount++ + ); + expect(computeCall).toBe(1); + a.val = 2; + expect(computeCall).toBe(2); + expect(notifyCount).toBe(0); + expect(state.c).toBe(4); + a.val = 4; + expect(computeCall).toBe(3); + expect(notifyCount).toBe(1); + expect(state.c).toBe(6); + state.b = 3; + expect(computeCall).toBe(4); + expect(notifyCount).toBe(2); + expect(state.c).toBe(7); + }); + + test("dependency on derived property", () => { + let computeB = 0; + let computeC = 0; + const state = reactive({ + a: 1, + b: derived([], function (this: any) { + computeB++; + return this.a + 1; + }), + c: derived([], function (this: any) { + computeC++; + return this.b + 1; + }), + }); + expect(computeB).toBe(1); + expect(computeC).toBe(1); + expect(state.c).toBe(3); + }); + + test("dependency on derived property appearing later in object", () => { + let computeB = 0; + let computeC = 0; + const state = reactive({ + a: 1, + c: derived([], function (this: any) { + computeC++; + return this.b + 1; + }), + b: derived([], function (this: any) { + computeB++; + return this.a + 1; + }), + }); + expect(computeB).toBe(1); + // because computation is eager and naive, C is first computed to be undefined, then B is computed + // to be 2, and the computation of B causes C to recompute and become 3. This causes C to compute twice. + expect(computeC).toBe(2); + expect(state.c).toBe(3); + }); +});