sveltejs / svelte

web development for the rest of us
https://svelte.dev
MIT License
80.26k stars 4.27k forks source link

Nullish coalescing assignment (??=) does not work with state #14268

Open kkolinko opened 2 weeks ago

kkolinko commented 2 weeks ago

Describe the problem

Nullish coalescing assignment (??=) operator may be used to update fields that are either null or a non-empty array.

Such code pattern can be seen in Svelte internals, e.g. (internal/client/runtime.js): (dependency.reactions ??= []).push(reaction);

If I try to use the same pattern, the results differ on whether I deal with a class field vs with a variable in a component.

Essentially, the code is the following:

  let items = $state(null);
  let counter = 0;
  function addItem() {
    (items ??= []).push( "" + (++counter));
  }
  function addItem2() {
    (items ??= []);
    items.push( "" + (++counter));
  }
  function addItem3() {
    items = items ?? [];
    items.push( "" + (++counter));
  }
  function addItem4() {
    if ( ! items) {
      items = [];
    }
    items.push( "" + (++counter));
  }
  function reset() {
    items = null;
    counter = 0;
  }
</script>
<p>Items: {"" + items}</p>
<button onclick={() => addItem()}>Add</button>

The result is displayed with explicit conversion to string ("" + ...) so that I can differ a "null" value from an empty array.

For a class the code is

<script>
    class Data {
      items = $state(null);
      counter = 0;
      addItem() {
         (this.items ??= []).push( "" + (++this.counter));
      }
      ...
    }
    const data = new Data();
</script>
<p>Items: {"" + data.items}</p>

(Explicitly converting to a string ).

Expected behaviour: I expect to see the following, after each click on an "Add" button:

Actual behaviour

Describe the proposed solution

I think that it can be left as is and documented as a limitation of the $state rune.

Though if I look at the code, generated by Svelte 5.1.15, for the case of a Component it is:

  function addItem() {
    $.set(items, $.get(items) ?? []).push("" + (counter += 1));
  }
  function addItem2() {
    $.set(items, $.get(items) ?? []);
    $.get(items).push("" + (counter += 1));
  }
  function addItem3() {
    $.set(items, $.proxy($.get(items) ?? []));
    $.get(items).push("" + (counter += 1));
  }

I see that in addItem() and addItem2() the value could be wrapped with $.proxy(...) like it was done for addItem3():

  function addItem() {
    $.set(items, $.get(items) ?? $.proxy( [] )).push("" + (counter += 1));
  }
  function addItem2() {
    $.set(items, $.get(items) ?? $.proxy( [] ));
    ...

In the case of a class the code is:

  class Data {
    #items = $.state(null);
    get items() {
      return $.get(this.#items);
    }
    set items(value) {
      $.set(this.#items, $.proxy(value));
    }
    counter = 0;

    addItem() {
      (this.items ??= []).push("" + ++this.counter);
    }
...

The addItem() could be:

      (this.items ??= $.proxy( [] )).push("" + ++this.counter);

based on the fact that second invocation of proxy() inside the setter method will be effectively a noop. I have not come up with a good solution for this case yet.

Importance

would make my life easier

dummdidumm commented 2 weeks ago

I debugged this a bit, and it seems that the $.proxy(value) call inside set items(value) comes too late. It seems the getter returns a value that is not proxified yet? It's very strange, and I'm wondering if this is a browser bug or spec gotcha or something else.