sveltejs / svelte

Cybernetically enhanced web apps
https://svelte.dev
MIT License
78.04k stars 4.08k forks source link

Support Higher Order Stores with runes #9651

Closed mrh1997 closed 4 months ago

mrh1997 commented 9 months ago

Describe the problem

In Svelte 4 Higher Order stores are supported. This is a great feature that allows to separation of concerns for stores.

But in svelte 5 I see now way to accomplish this feature:

The following modification of the svelte 5 tutorial shows the problem.

<script>
    let count = $state(0);
    function increment() {
        count += 1;
    }

        // this would be a very generic function which could be reused with any store (if higher order stores would word):
    function delayed(baseStore, delayLen) {
          let resultStore = $state(baseStore);
      const buffer = []
      $effect(() => {
          buffer.push(count);
          if (buffer.length > delayLen) resultStore = buffer.shift()
      })
          return {
             get val() { return resultStore; }
          }
    }

    let delayedCount = delayed(count, 5);
</script>

<button on:click={increment}>
    clicks: {delayedCount.val}
</button>

Describe the proposed solution

When passing a rune/signal as parameter I would intuitively expect, that it is handled as if the parameter is also a rune/signal WITHIN the function.

The example given above would then work out of the box.

Alternatives considered

If automatic detection of runes and passing them into function is hard, this could be done with an explicit wrapper like:

let delayedCount = delayed($use(count), 5)

But this is much more unintuitive and probably error prone, as devs will forget to add the $use

Importance

i cannot use svelte without it

brunnerh commented 9 months ago

Importance

i cannot use svelte without it

You can still use stores in v5, you know.

Also, for read-only operations, it's easy to turn the value into an accessor function:

function delayed(accessor, delayLen) {
    let resultStore = $state(accessor());
    // ...
}

let delayedCount = delayed(() => count, 5);

Though better composability in general would be nice. The problem with you suggestion is, that it would leak implementation details (signals) which is not intended. There would have to be either some transfer process via runes, which has been suggested before, or a wrapping convention (like .value) which is not ideal either...

brunnerh commented 9 months ago

Actually, most of the time you don't need write access anyway, so plain functions are probably just the way to go, e.g.

<script lang="ts">
    function debounced<T>(accessor: () => T, delay = 500) {
        let value = $state(accessor());
        let timeout: number | undefined = undefined;

        $effect(() => {
            const newValue = accessor();
            timeout = setTimeout(() => value = newValue, delay);

            return () => clearTimeout(timeout);
        });

        return () => value;
    }

    function uppercased(accessor: () => string) {
        const uppercased = $derived(accessor().toUpperCase());
        return () => uppercased;
    }

    let text = $state('Hello');
    const transformed = uppercased(debounced(() => text));
</script>

<input bind:value={text} /> <br />
Debounced/Uppercased: {transformed()}

REPL

For read/write transforms, using a box, possibly with some unboxing runery, might be preferable 🤔

mrh1997 commented 9 months ago

@brunnerh : Thanks for your hint with the accessor. Its a workaround, but at least there IS already a solution.

Regarding your "debounce/uppercased" sample: It has a flaw in reusablity since it returns a function. Usually a composable returns an object with getters in svelte 5.

This means on creation of the composable I had to decide if it is really composable with others (i.e. return a function) or if it uses the "standard" svelte5 way (i.e. return a object with getters).

It would be really preferable if there is a "standardized" way how to handle this (no matter if the composable is readable or writable). In my opinion this "standard way" should be with enough syntactic sugar, so that as developer nearly no boilerplate code is needed (i.e. for boxing and unboxing). This aligns with sveltes strenghts compared to other frameworks.

Rich-Harris commented 4 months ago

One thing I'd love to explore is whether async iterables would be sufficiently flexible for this, and whether we could design a good API around it. Here's a proof of concept using the delayedCount example.

The core idea is that you could turn a piece of state into an async iterable, transform it, and use a variant of $derived to turn the end result back into state — so an imaginary version of the example above could look like this...

<script>
  let count = $state(0);

  function increment() {
    count += 1;
  }

  async function* delayed(iterable, length) {
    const buffer = [];

    for await (const value of iterable) {
      buffer.push(value);

      if (buffer.length > length) {
        yield buffer.shift();
      }
    }
  }

  let delayedCount = $derived.from(delayed($iterable(count), 5));
</script>

<button on:click={increment}>
  clicks: {delayedCount}
</button>

<p>count: {count}</p>
<p>delayed count: {delayedCount}</p>

...or this, if the logic was something one-off:

<script>
  let count = $state(0);

  function increment() {
    count += 1;
  }

  let delayedCount = $derived.from(async function*() {
    const buffer = [];

    for await (const value of $iterable(count)) {
      buffer.push(value);

      if (buffer.length > 5) {
        yield buffer.shift();
      }
    }
  });
</script>

<button on:click={increment}>
  clicks: {delayedCount}
</button>

<p>count: {count}</p>
<p>delayed count: {delayedCount}</p>

Regarding your "debounce/uppercased" sample: It has a flaw in reusablity since it returns a function

Note that you can always use let x = $derived.by(fn) if you want to reference x instead of fn().

mrh1997 commented 4 months ago

What I really like about this proposal is, that svelte would get a "bridge" between svelte reactive variables and async iterables which allows a completely different programming model. This way the developer would be free to decide which model to use depending on his/her usecase (especially the "delayed" example could be implemented more elegent via async iterables).

By the way: $derived.from() would be the inverse function/rune of $iterable(), right? How about naming them, so that this fact is visible immediately. I.e. $to_iter() and $from_iter()?

Nevertheless in my opinion this still does not solve the original problem. Defining a composable with this extension would be very easy (and elegant). But using it requires still boilerplate code that makes it harder to read. Assuming that functions are written once/used many times this should be the other way round.

How about decorating the composable (i.e. "delayed") somehow and tell svelte that this is a function where specific parameters shall be converted to iterables and the result shall be converted back to a svelte reactive state? Unfortunately I have no understanding about the internal svelte 5 architecture so I can't provide a proposal how to solve that...

Rich-Harris commented 4 months ago

But using it requires still boilerplate code that makes it harder to read. Assuming that functions are written once/used many times this should be the other way round

I'm not following — where's the boilerplate here? The $derived.from?

let stream = $iterable(count);
let delayed_by_1 = $derived.from(delayed(stream, 1));
let delayed_by_2 = $derived.from(delayed(stream, 2));
let delayed_by_3 = $derived.from(delayed(stream, 3));

Regardless, it turns out there are some flaws with the iterable approach:

Luckily I don't actually think it's necessary. As shown in https://github.com/sveltejs/svelte/issues/9651#issuecomment-1826445943, I'm pretty sure we can achieve everything we want with the built-in primitives we already have:

function delay(fn, ms, initial = fn()) {
  let delayed = $state(initial);

  $effect.pre(() => {
    const value = fn();

    setTimeout(() => {
      delayed = value;
    }, ms);
  });

  return () => delayed;
}

(Note that this is pushing values on a timeout rather than buffering them, but you get the idea. If you cleared the timeout in the effect return value, it becomes a debounce function.)

In other words, the basic form is type Transform<T, U> = (input: () => T, ...args: any[]) => () => U — you pass in a function that returns some state (plus some options), and you get a function that returns some state. This is inherently composable (i.e. it's trivial to chain these operations together), and — arguably the piece that was missing earlier in this discussion, since it's a newer feature — it works well with $derived.by:

let delayed = $derived.by(delay(() => count, 500));

We could build helpers around it...

function transform(initial, fn) {
  let result = $state(initial);

  $effect(() => {
    return fn((v) => {
      result = v;
    });
  });

  return () => result;
}

const delay = (fn, ms, initial = fn()) => transform(initial, (push) => {
  const value = fn();

  setTimeout(() => {
    push(value);
  }, ms);
});

...though that's arguably unnecessary (and removes some flexibility).

mrh1997 commented 4 months ago

Ah! I wasn't aware of the new $derive.by(). Actually this allows to create composed reactives that are behaving exactly like a normal derived reactive (i.e. no parentheses or attribute access are needed to resolve them). ;-)

(although as a developer doing a lot of python code I had to say that I loved the expressiveness of the iterator solution; but I understand your critics. JS seems not to embrace that ideom as well as python )