diff --git a/doc/v3/owl/reference/reactivity.md b/doc/v3/owl/reference/reactivity.md index bc6fa94e8..95819e8c4 100644 --- a/doc/v3/owl/reference/reactivity.md +++ b/doc/v3/owl/reference/reactivity.md @@ -57,38 +57,83 @@ class Counter extends Component { ### Collection Signals -Manipulating collections (arrays, objects, maps, sets) is a very common need. -Plain signals hold a reference, so mutating the contents (e.g. `push`) does not -change the reference and won't trigger updates. To solve this, Owl provides -four collection signal variants that wrap the value in a shallow proxy, so -mutations are automatically detected: +A plain signal holds a reference, so mutating the contents in place (e.g. +`push`, `add`, a property assignment) does not change the reference and won't +trigger updates. To solve this, Owl provides four collection signal variants — +`signal.Array`, `signal.Object`, `signal.Set`, `signal.Map` — that wrap the +underlying value in a reactive proxy and expose the usual signal API on top: ```js const list = signal.Array([1, 2, 3]); -list().push(4); // detected — subscribers are notified - const obj = signal.Object({ a: 1 }); -obj().a = 2; // detected +const set = signal.Set(new Set([1, 2])); +const map = signal.Map(new Map([["a", 1]])); +``` -const set = signal.Set(new Set()); -set().add("hello"); // detected +The initial value is optional — omit it to start with an empty collection. +A type parameter is usually helpful in that case: + +```js +const list = signal.Array(); +const obj = signal.Object<{ count: number }>(); +const set = signal.Set(); +const map = signal.Map(); +``` + +Reading the signal (`list()`) returns the proxy. Mutations on the proxy are +detected automatically; replacing the entire value with `.set(...)` also +notifies every subscriber: -const map = signal.Map(new Map()); +```js +list().push(4); // in-place mutation, detected +obj().a = 2; // property write, detected +set().add("hello"); // detected map().set("key", "value"); // detected + +list.set([10, 20]); // whole-value replacement, detected ``` -**Caveat:** the proxy is shallow. Deeply nested mutations are **not** detected: +#### Tracking granularity + +`signal.Array` and `signal.Object` invalidate the **whole signal** on any +mutation. Reading the proxy — whether `obj()` itself or a single property like +`obj().a` — subscribes the caller to the entire collection, and _any_ write +re-runs every observer. This is the right model when the collection is small +or consumers usually look at all of it. + +`signal.Set` and `signal.Map` track **per-key**, just like `proxy`. +Subscribing to `set().has(1)` only re-runs when key `1` is added or removed; +`set().add(2)` leaves observers of `has(1)` (or `map.get(otherKey)`) alone. +Iteration (`[...set()]`, `forEach`, `keys`, `values`, `entries`, `size`) +subscribes to every key, so an effect that iterates still re-runs on any +add/delete. ```js -const list = signal.Array([{ nested: { value: 1 } }]); +const set = signal.Set < number > new Set(); + +effect(() => console.log("has(1) =", set().has(1))); +set().add(2); // logged once at setup, NOT re-run (key 2 is unrelated) +set().add(1); // re-run: has(1) flipped to true +set.set(new Set()); // re-run: whole signal was replaced +``` -// This is detected (direct mutation on the proxied array element): -list().push({ nested: { value: 2 } }); +#### Shallow wrapping -// This is NOT detected (deep nested mutation): -list()[0].nested.value = 42; +The proxy is shallow: only mutations on the collection itself are tracked. +Nested objects stored inside are returned raw, so deep mutations are **not** +detected: + +```js +const list = signal.Array([{ nested: { value: 1 } }]); + +list().push({ nested: { value: 2 } }); // detected (mutation on the array) +list()[0].nested.value = 42; // NOT detected (deep mutation) ``` +If you need deep reactivity, use [`proxy`](#proxy) instead — it wraps nested +objects recursively. Reach for a collection signal when shallow wrapping is +enough and you want the explicit `.set(newValue)` replacement API. + ## Computed Values A computed value is a lazily-evaluated derived value. It tracks its dependencies diff --git a/packages/owl-core/src/proxy.ts b/packages/owl-core/src/proxy.ts index e8b94cd14..eeefb8e6d 100644 --- a/packages/owl-core/src/proxy.ts +++ b/packages/owl-core/src/proxy.ts @@ -255,7 +255,7 @@ function basicProxyHandler(atom: Atom | null): ProxyHandler function makeKeyObserver(methodName: "has" | "get", target: any, atom: Atom | null) { return (key: any) => { key = toRaw(key); - onReadTargetKey(target, key, atom); + onReadTargetKey(target, key, null); return possiblyReactive(target[methodName](key), atom); }; } @@ -273,11 +273,11 @@ function makeIteratorObserver( atom: Atom | null ) { return function* () { - onReadTargetKey(target, KEYCHANGES, atom); + onReadTargetKey(target, KEYCHANGES, null); const keys = target.keys(); for (const item of target[methodName]()) { const key = keys.next().value; - onReadTargetKey(target, key, atom); + onReadTargetKey(target, key, null); yield possiblyReactive(item, atom); } }; @@ -292,9 +292,9 @@ function makeIteratorObserver( */ function makeForEachObserver(target: any, atom: Atom | null) { return function forEach(forEachCb: (val: any, key: any, target: any) => void, thisArg: any) { - onReadTargetKey(target, KEYCHANGES, atom); + onReadTargetKey(target, KEYCHANGES, null); target.forEach(function (val: any, key: any, targetObj: any) { - onReadTargetKey(target, key, atom); + onReadTargetKey(target, key, null); forEachCb.call( thisArg, possiblyReactive(val, atom), @@ -317,8 +317,7 @@ function makeForEachObserver(target: any, atom: Atom | null) { function delegateAndNotify( setterName: "set" | "add" | "delete", getterName: "has" | "get", - target: any, - atom: Atom | null + target: any ) { return (key: any, value: any) => { key = toRaw(key); @@ -327,10 +326,10 @@ function delegateAndNotify( const ret = target[setterName](key, value); const hasKey = target.has(key); if (hadKey !== hasKey) { - onWriteTargetKey(target, KEYCHANGES, atom); + onWriteTargetKey(target, KEYCHANGES, null); } if (originalValue !== target[getterName](key)) { - onWriteTargetKey(target, key, atom); + onWriteTargetKey(target, key, null); } return ret; }; @@ -341,13 +340,13 @@ function delegateAndNotify( * * @param target @see proxy */ -function makeClearNotifier(target: Map | Set, atom: Atom | null) { +function makeClearNotifier(target: Map | Set) { return () => { const allKeys = [...target.keys()]; target.clear(); - onWriteTargetKey(target, KEYCHANGES, atom); + onWriteTargetKey(target, KEYCHANGES, null); for (const key of allKeys) { - onWriteTargetKey(target, key, atom); + onWriteTargetKey(target, key, null); } }; } @@ -361,40 +360,40 @@ function makeClearNotifier(target: Map | Set, atom: Atom | null) const rawTypeToFuncHandlers = { Set: (target: any, atom: Atom | null) => ({ has: makeKeyObserver("has", target, atom), - add: delegateAndNotify("add", "has", target, atom), - delete: delegateAndNotify("delete", "has", target, atom), + add: delegateAndNotify("add", "has", target), + delete: delegateAndNotify("delete", "has", target), keys: makeIteratorObserver("keys", target, atom), values: makeIteratorObserver("values", target, atom), entries: makeIteratorObserver("entries", target, atom), [Symbol.iterator]: makeIteratorObserver(Symbol.iterator, target, atom), forEach: makeForEachObserver(target, atom), - clear: makeClearNotifier(target, atom), + clear: makeClearNotifier(target), get size() { - onReadTargetKey(target, KEYCHANGES, atom); + onReadTargetKey(target, KEYCHANGES, null); return target.size; }, }), Map: (target: any, atom: Atom | null) => ({ has: makeKeyObserver("has", target, atom), get: makeKeyObserver("get", target, atom), - set: delegateAndNotify("set", "get", target, atom), - delete: delegateAndNotify("delete", "has", target, atom), + set: delegateAndNotify("set", "get", target), + delete: delegateAndNotify("delete", "has", target), keys: makeIteratorObserver("keys", target, atom), values: makeIteratorObserver("values", target, atom), entries: makeIteratorObserver("entries", target, atom), [Symbol.iterator]: makeIteratorObserver(Symbol.iterator, target, atom), forEach: makeForEachObserver(target, atom), - clear: makeClearNotifier(target, atom), + clear: makeClearNotifier(target), get size() { - onReadTargetKey(target, KEYCHANGES, atom); + onReadTargetKey(target, KEYCHANGES, null); return target.size; }, }), WeakMap: (target: any, atom: Atom | null) => ({ has: makeKeyObserver("has", target, atom), get: makeKeyObserver("get", target, atom), - set: delegateAndNotify("set", "get", target, atom), - delete: delegateAndNotify("delete", "has", target, atom), + set: delegateAndNotify("set", "get", target), + delete: delegateAndNotify("delete", "has", target), }), }; /** diff --git a/packages/owl-core/src/signal.ts b/packages/owl-core/src/signal.ts index 595861404..efc3c9644 100644 --- a/packages/owl-core/src/signal.ts +++ b/packages/owl-core/src/signal.ts @@ -48,18 +48,20 @@ function triggerSignal(signal: Signal): void { onWriteAtom((signal as any)[atomSymbol]); } +function signalArray(): Signal; function signalArray(initialValue: T[]): Signal; function signalArray(initialValue: NoInfer[], options: SignalOptions): Signal; -function signalArray(initialValue: T[]): Signal { +function signalArray(initialValue: T[] = []): Signal { return buildSignal(initialValue, (atom) => proxifyTarget(atom.value, atom)); } +function signalObject>(): Signal; function signalObject>(initialValue: T): Signal; function signalObject>( initialValue: NoInfer, options: SignalOptions ): Signal; -function signalObject>(initialValue: T): Signal { +function signalObject>(initialValue: T = {} as T): Signal { return buildSignal(initialValue, (atom) => proxifyTarget(atom.value, atom)); } @@ -69,18 +71,20 @@ interface MapSignalOptions { valueType?: V; } +function signalMap(): Signal>; function signalMap(initialValue: Map): Signal>; function signalMap( initialValue: NoInfer>, options: MapSignalOptions ): Signal>; -function signalMap(initialValue: Map): Signal> { +function signalMap(initialValue: Map = new Map()): Signal> { return buildSignal>(initialValue, (atom) => proxifyTarget(atom.value, atom)); } +function signalSet(): Signal>; function signalSet(initialValue: Set): Signal>; function signalSet(initialValue: Set>, options: SignalOptions): Signal>; -function signalSet(initialValue: Set): Signal> { +function signalSet(initialValue: Set = new Set()): Signal> { return buildSignal>(initialValue, (atom) => proxifyTarget(atom.value, atom)); } diff --git a/packages/owl-core/tests/signals.test.ts b/packages/owl-core/tests/signals.test.ts index 96b58c860..a2ee27796 100644 --- a/packages/owl-core/tests/signals.test.ts +++ b/packages/owl-core/tests/signals.test.ts @@ -44,6 +44,17 @@ test("trigger a signal", async () => { }); describe("signal.Array", () => { + test("can be created without an initial value", async () => { + const reactiveArray = signal.Array(); + expect(reactiveArray()).toEqual([]); + + const e = spyEffect(() => [...reactiveArray()]); + e(); + reactiveArray().push(1); + await waitScheduler(); + expectSpy(e.spy, 2, { result: [1] }); + }); + test("simple use", async () => { const reactiveArray = signal.Array([]); @@ -120,6 +131,17 @@ describe("signal.Array", () => { }); describe("signal.Object", () => { + test("can be created without an initial value", async () => { + const reactiveObject = signal.Object>(); + expect(reactiveObject()).toEqual({}); + + const e = spyEffect(() => reactiveObject()); + e(); + reactiveObject().a = 1; + await waitScheduler(); + expectSpy(e.spy, 2, { result: { a: 1 } }); + }); + test("simple use", async () => { const reactiveObject = signal.Object>({}); @@ -202,6 +224,17 @@ describe("signal.Object", () => { }); describe("signal.Map", () => { + test("can be created without an initial value", async () => { + const reactiveMap = signal.Map(); + expect(reactiveMap()).toEqual(new Map()); + + const e = spyEffect(() => reactiveMap().get("a")); + e(); + reactiveMap().set("a", 1); + await waitScheduler(); + expectSpy(e.spy, 2, { result: 1 }); + }); + test("simple use", async () => { const reactiveMap = signal.Map(new Map()); @@ -234,6 +267,47 @@ describe("signal.Map", () => { expect(reactiveMap().get("a")).toBe(obj); }); + test("get(key) only subscribes to that key", async () => { + const reactiveMap = signal.Map(new Map()); + + const e = spyEffect(() => reactiveMap().get("a")); + e(); + expectSpy(e.spy, 1, { result: undefined }); + + // mutating an unobserved key must not trigger the effect + reactiveMap().set("b", 42); + await waitScheduler(); + expectSpy(e.spy, 1, { result: undefined }); + + reactiveMap().set("a", 1); + await waitScheduler(); + expectSpy(e.spy, 2, { result: 1 }); + + reactiveMap().delete("b"); + await waitScheduler(); + expectSpy(e.spy, 2, { result: 1 }); + + reactiveMap().delete("a"); + await waitScheduler(); + expectSpy(e.spy, 3, { result: undefined }); + }); + + test("has(key) only subscribes to that key", async () => { + const reactiveMap = signal.Map(new Map()); + + const e = spyEffect(() => reactiveMap().has("a")); + e(); + expectSpy(e.spy, 1, { result: false }); + + reactiveMap().set("b", 42); + await waitScheduler(); + expectSpy(e.spy, 1, { result: false }); + + reactiveMap().set("a", 1); + await waitScheduler(); + expectSpy(e.spy, 2, { result: true }); + }); + test("set or delete element on map", async () => { const reactiveMap = signal.Map(new Map()); @@ -272,6 +346,17 @@ describe("signal.Map", () => { }); describe("signal.Set", () => { + test("can be created without an initial value", async () => { + const reactiveSet = signal.Set(); + expect(reactiveSet()).toEqual(new Set()); + + const e = spyEffect(() => reactiveSet().has(1)); + e(); + reactiveSet().add(1); + await waitScheduler(); + expectSpy(e.spy, 2, { result: true }); + }); + test("simple use", async () => { const reactiveSet = signal.Set(new Set()); @@ -304,6 +389,43 @@ describe("signal.Set", () => { expect(reactiveSet().values().next().value).toBe(obj); }); + test("has(key) only subscribes to that key", async () => { + const reactiveSet = signal.Set(new Set()); + + const e = spyEffect(() => reactiveSet().has(1)); + e(); + expectSpy(e.spy, 1, { result: false }); + + // adding a different key must not trigger the effect + reactiveSet().add(2); + await waitScheduler(); + expectSpy(e.spy, 1, { result: false }); + + reactiveSet().add(1); + await waitScheduler(); + expectSpy(e.spy, 2, { result: true }); + + reactiveSet().delete(2); + await waitScheduler(); + expectSpy(e.spy, 2, { result: true }); + + reactiveSet().delete(1); + await waitScheduler(); + expectSpy(e.spy, 3, { result: false }); + }); + + test("replacing the whole signal still invalidates per-key observers", async () => { + const reactiveSet = signal.Set(new Set([1])); + + const e = spyEffect(() => reactiveSet().has(1)); + e(); + expectSpy(e.spy, 1, { result: true }); + + reactiveSet.set(new Set([2])); + await waitScheduler(); + expectSpy(e.spy, 2, { result: false }); + }); + test("add or delete item on Set", async () => { const reactiveSet = signal.Set(new Set());