Skip to content

All internal references within Signals should be weak #265

@elliotgoodrich

Description

@elliotgoodrich

This issue suggests that all the internal references in the Signal library should be weak references in order to avoid the potential for leaking objects, either temporarily or permanently.

Goal 1: Avoid leaking if we don't call unwatch

Given the code,

const a = Signal.State(0);
const b = Signal.Computed(() => a.get());
b.get();
const w = Signal.Watcher(() => {});
w.watch(b);

we end up with the following dependency graph,

graph TD;
    root --> a & b & w
    a --> b
    b --> a
    b --> l1(["() => a.get()"])
    l1 --> a
    w --> l2(["() => {}"])
    w --> b
    b --> w
Loading

if we no longer hold rooted references to w and b, we will leak these objects as long as there is a rooted reference to a and we have yet to call w.unwatch(b).

const a = Signal.State(0);
{
  const b = Signal.Computed(() => a.get());
  b.get();
  const w = Signal.Watcher(() => {});
  w.watch(b);
}

since there is a strong reference internally held from a to b once a watcher is attached,

graph TD;
    root --> a
    a --> b
    b --> a
    b --> l1(["() => a.get()"])
    l1 --> a
    w --> l2(["() => {}"])
    w --> b
    b --> w
Loading

If however, the back references from source to consumer that are created once a watcher is attached are made to be weak, we no longer have a strong, rooted reference to b and w,

graph TD;
    root --> a
    a -.-> b
    b --> a
    b --> l1(["() => a.get()"])
    l1 --> a
    w --> l2(["() => {}"])
    w --> b
    b -.-> w
Loading

allowing these to be GCed,

graph TD;
    root --> a
Loading

Recommendation: The references from consumer to source (Watcher to Signal and Computed to Signal) should be made weak.

Goal 2: Avoid leaking if we don't call unwatch (part 2)

Given the code,

const w = Signal.Watcher(() => {
  const p = w.getPending();
  doAsync(() => doTheWork(p));
});
const b = Signal.Computed(() => getSignalA().get());
const d = Signal.Computed(() => getSignalC().get());
w.watch(b, d);

we have the following dependency graph (omitting the lambdas for simplicity),

graph TD;
    w --> b --> a
    w --> d --> c
    root --> d & b & w
    b -.-> w
    d -.-> w
    a -.-> b
    c -.-> d
Loading

If we no longer hold rooted references to b or d, it is not possible for it to be GCed since w holds strong references to both,

const w = Signal.Watcher(() => {
  const p = w.getPending();
  doAsync(() => doTheWork(p));
});
const b = Signal.Computed(() => getSignalA().get());
{
  const d = Signal.Computed(() => getSignalC().get());
  w.watch(b, d);
}

d and c are still rooted despite not being reachable from the user's code,

graph TD;
    w --> b --> a
    w --> d --> c
    root --> b & w
    b -.-> w
    d -.-> w
    a -.-> b
    c -.-> d
Loading

If the Watcher holds weak references to the Signals that it watches, then Computeds not reachable through user code can be GCed.

graph TD;
    w -.-> b --> a
    w -.-> d --> c
    root --> b & w
    b -.-> w
    d -.-> w
    a -.-> b
    c -.-> d
Loading

Here both d and c can be collected,

graph TD;
    w -.-> b --> a
    root --> b & w
    b -.-> w
    a -.-> b
Loading

Recommendation: The references from Watcher to Signal should be made weak.

Goal 3: Avoid Computed-capture reference mismatch

Given the code below,

const i = Signal.State(1);
const arr = [
  Signal.State("a"),
  Signal.State("b"),
];
const c = Signal.Computed(() => arr[i.get()].get());
assert(c.get() === "b");

we get the following dependency graph,

graph TD;
    root --> c & i & arr
    c --> b
    b -.-> c
    c --> l(["() => arr[i.get()].get()"])
    l --> arr
    l --> i
    arr --> b
    arr --> a
Loading

If we then change the index and remove the last element of arr,

i.set(0);
arr.pop();

we continue to depend on b even though it is not reachable from user code

graph TD;
    root --> c & i & arr
    c <-.-> b
    b -.-> c
    c --> l(["() => arr[i.get()].get()"])
    l --> arr
    l --> i
    arr --> a
    b ~~~ a
Loading

and this is only fixed when c.get() is invoked and the internal dependencies within Signal are updated.

If all of the internal Signal references are made weak, we no longer have a strong, rooted reference to b and it can be collected.

graph TD;
    root --> c & i & arr
    b <-.-> c
    c --> l(["() => arr[i.get()].get()"])
    l --> arr
    l --> i
    arr --> a
Loading

Recommendation: The references from Computed to Signal should be made weak.

This is the last remaining internal Signal reference that was not weak.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Status

    Stage 2.7 Blockers

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions