Skip to content
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
79 changes: 62 additions & 17 deletions doc/v3/owl/reference/reactivity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>();
const obj = signal.Object<{ count: number }>();
const set = signal.Set<string>();
const map = signal.Map<string, number>();
```

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
Expand Down
43 changes: 21 additions & 22 deletions packages/owl-core/src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ function basicProxyHandler<T extends Target>(atom: Atom | null): ProxyHandler<T>
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);
};
}
Expand All @@ -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);
}
};
Expand All @@ -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),
Expand All @@ -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);
Expand All @@ -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;
};
Expand All @@ -341,13 +340,13 @@ function delegateAndNotify(
*
* @param target @see proxy
*/
function makeClearNotifier(target: Map<any, any> | Set<any>, atom: Atom | null) {
function makeClearNotifier(target: Map<any, any> | Set<any>) {
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);
}
};
}
Expand All @@ -361,40 +360,40 @@ function makeClearNotifier(target: Map<any, any> | Set<any>, 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),
}),
};
/**
Expand Down
12 changes: 8 additions & 4 deletions packages/owl-core/src/signal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,18 +48,20 @@ function triggerSignal(signal: Signal<any>): void {
onWriteAtom((signal as any)[atomSymbol]);
}

function signalArray<T>(): Signal<T[]>;
function signalArray<T>(initialValue: T[]): Signal<T[]>;
function signalArray<T>(initialValue: NoInfer<T>[], options: SignalOptions<T>): Signal<T[]>;
function signalArray<T>(initialValue: T[]): Signal<T[]> {
function signalArray<T>(initialValue: T[] = []): Signal<T[]> {
return buildSignal<T[]>(initialValue, (atom) => proxifyTarget(atom.value, atom));
}

function signalObject<T extends Record<PropertyKey, any>>(): Signal<T>;
function signalObject<T extends Record<PropertyKey, any>>(initialValue: T): Signal<T>;
function signalObject<T extends Record<PropertyKey, any>>(
initialValue: NoInfer<T>,
options: SignalOptions<T>
): Signal<T>;
function signalObject<T extends Record<PropertyKey, any>>(initialValue: T): Signal<T> {
function signalObject<T extends Record<PropertyKey, any>>(initialValue: T = {} as T): Signal<T> {
return buildSignal<T>(initialValue, (atom) => proxifyTarget(atom.value, atom));
}

Expand All @@ -69,18 +71,20 @@ interface MapSignalOptions<K, V> {
valueType?: V;
}

function signalMap<K, V>(): Signal<Map<K, V>>;
function signalMap<K, V>(initialValue: Map<K, V>): Signal<Map<K, V>>;
function signalMap<K, V>(
initialValue: NoInfer<Map<K, V>>,
options: MapSignalOptions<K, V>
): Signal<Map<K, V>>;
function signalMap<K, V>(initialValue: Map<K, V>): Signal<Map<K, V>> {
function signalMap<K, V>(initialValue: Map<K, V> = new Map()): Signal<Map<K, V>> {
return buildSignal<Map<K, V>>(initialValue, (atom) => proxifyTarget(atom.value, atom));
}

function signalSet<T>(): Signal<Set<T>>;
function signalSet<T>(initialValue: Set<T>): Signal<Set<T>>;
function signalSet<T>(initialValue: Set<NoInfer<T>>, options: SignalOptions<T>): Signal<Set<T>>;
function signalSet<T>(initialValue: Set<T>): Signal<Set<T>> {
function signalSet<T>(initialValue: Set<T> = new Set()): Signal<Set<T>> {
return buildSignal<Set<T>>(initialValue, (atom) => proxifyTarget(atom.value, atom));
}

Expand Down
Loading
Loading