Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[IMP] reactivity: add support for derived properties #1596

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src/runtime/reactivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,30 @@ export function reactive<T extends Target>(target: T, callback: Callback = NO_CA
const proxy = new Proxy(target, handler as ProxyHandler<T>) as Reactive<T>;
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<T>;
}
Expand Down Expand Up @@ -463,3 +487,18 @@ function collectionsProxyHandler<T extends Collection>(
},
}) as ProxyHandler<T>;
}

const IS_DERIVED_DESCRIPTOR = Symbol("is derived descriptor");
export function derived<T extends Reactive<any>[], 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<T extends object[]>(cb: (...args: [...T]) => void, deps: [...T]) {
const reactiveDeps = reactive(deps, () => {
cb(...reactiveDeps);
});
cb(...reactiveDeps);
}
155 changes: 154 additions & 1 deletion tests/reactivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -2424,3 +2424,156 @@ describe("Reactivity: useState", () => {
expect(fixture.innerHTML).toBe("<div><p><span>2b</span></p></div>");
});
});

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);
});
});
Loading