tc39 / proposal-signals

A proposal to add signals to JavaScript.
MIT License
2.87k stars 54 forks source link

The end-goal should be to make framework compilers unnessecary #198

Open cysabi opened 3 weeks ago

cysabi commented 3 weeks ago

why compilers exists

The goal of every framework is to support reactivity while feeling as close to native js as possible.

It's really obvious to anyone who's used svelte that the closer reactive code perceptibly feels to native js, the better the developer experience is.

signals without a compiler

Let's design an api for a non-compiler web framework, the goal is to make users feel like they are writing native js without using a compiler.

For setting signals, the closest we can get to this syntax is a proxy with a value accessor. I'll be using .$ as the accessor.

// ideal (native)
let state = 1
state += 2
ext.onChange(newVal => { state = newVal })

// reactive (v1)
let state = State(1)
state.$ += 1
ext.onChange(newVal => { state.$ = newVal })

But what about reading signals?

// ideal (v1)
const derived = state.$ + 1
elem.innerText = derived.$

Without a compiler, it's impossible to make getting the state reactive in a way that feels native. Since it gets recomputed all the time, we also have to wrap that in a function.

So let's invent Computed, it's basically a variable with a value that's declaratively based on another variable, rather than being imperatively set to whatever the variable is at the time.

// ideal (v1)
const derived = state.$ + 1

// reactive (v2)
const derived = Computed(() => state.$ + 1)

But a computed function can only work for variables we have the luxury of creating ourselves, what happens when we need to set some value that we didn't create? Like the dom?

Without inventing a new function, we could technically make all derived states eagerly compute, and shove a side effect into the computed function.

// ideal (v2)
elem.innerText = Computed(() => state.$ + 1).$

// reactive (v2.eager)
Computed(() => {
  elem.innerText = state.$ + 1
})

And anything that cannot be garbage collected would have some extra boilerplate.

// ideal (v2)
Signal.Computed(() => state.$.event && ext.on(state.$.event, value => console.log(value)))

// reactive (v2.eager)
let listener
Signal.Computed(() => {
  if (state.$.event !== listener?.event) {
    if (unsub) {
      listener.unsub()
      listener = undefined
    }
    if (state.$.event) {
      listener = ext.on(state.$.event, value => console.log(value))
    }
  }
})

But we can't really detect whether a Computed includes side effects or not, which means that every derived has to be eagerly pulled, and we don't want that. We want to only recompute when the values are actually needed by something outside. Looks like it's time for a new primitive!

So we'll invent Effect, It's literally just a computed signal that's eager. And instead of returning value, it's used to imperatively set some values inside the function. We use this when we can't declaratively define them with Computed alone.

Since we don't return anything, we can just use the return value for cleanup functions

// reactive (v3)
Effect(() => {
  const listener = ext.on(state.$.event, value => console.log(value))
  return listener.unsub
})

Ok, we basically invented solid signals wrapped with a proxy. It doesn't seem too bad to write, what's the problem?

the problem

If you're trying to pass a simple variable around, you have to be extra careful to always pass state and not state.$, otherwise you'll unwrap the proxy and lose the reactivity. You can't just pass the value around freely.

With solid, I find it quite the chore to ensure i'm not accidentally calling my getter one step too early, and i find it really frustrating to debug when I do.

The existence of mergeProps and splitProps make this problem even more self-evident. You also get horrendous stuff like const children = children(() => props.children)

This is a core limitation of Javascript itself that makes it hard to design reactive frameworks to feel native without rewriting the syntax of js itself.

At it's core, compilers are trying to solve this limitation, letting users write native-looking js that can do non-native things.

inventing a compiler

So let's write a compiler to handle this stuff for us! Combine the value and reference into one, and unwrap the value automatically whenever necessary. That way we don't need to worry about this anymore.

let $state = 0
const $derived = $state + 1

$effect(() => console.log($derived))

// compiles to

var $state = State(0)
var $derived = Computed(() => $state.$ + 1)

Effect(() => console.log($derived.$))

Oops, we basically just invented svelte runes.

why we should tackle compilers

I think it's clear to me that all modern frameworks are dancing around this same core idea.

We want to express our ui in a declarative & reactive way that feels as native as possible, preferrably as close to the dom as possible.

The problem is javascript isn't declarative or reactive, and there are immovable walls to the developer experience because of that. With solid that looks like everything being wrapped in functions. With react that looks like blunt-force rerunning and component rules. With svelte that looks like a building a compiler so reactivity finally feels native.

But what if instead of needing to build a compiler to let you write native-looking code, we just... made it native?

I think it would be a huge waste of the best opportunity we have not to tackle this, it would solve the core issues plaguing every web framework at it's root.

an example proposal

What if javascript had a top level proxy? That's it. That's the entire proposal.

const MyProxy = Proxy.CreateTopLevelProxy({
  set(value, newValue) {
    console.log("set", value, newValue)
    return newValue
  }
  get(value) {
    console.log("get", value)
    return value
  }
})

state = MyProxy(2)  // "set undefined 2"
state               // "get 2"
value += 1          // "set 2 3"
value               // "get 3"

Then framework authors can implement signals to look as follows.

let state = Signal.State(0)
const derived = Signal.Computed(state + 1) // probably still impossible... for now!

Keep in mind, this is a terribly shallow example of a proposal. I'm not trying to push that this is the practical solution to this problem. This would get rid of the need for compilers and mergeProps, but it would also probably be impossible to optimize.

It's intentionally extreme because the point is really the problem that this is trying to solve, my goal is just to hopefully convince you that this problem is worth solving at all.

At the very very least, I would like to steer the focus of this proposal to be on how it can be extended in the future to solve the need for compilers.

Some extra reading

https://github.com/tc39/proposal-signals/issues/114

https://dev.to/this-is-learning/the-quest-for-reactivescript-3ka3

EisenbergEffect commented 3 weeks ago

One idea that was left open as part of the decorator proposal was to allow for a future addition of variable decorators. Using such a general-purpose feature, one could then choose to employ it specifically for signals if desired.

let @signal counter = 0;
effect(() => console.log(counter)); // Prints 0
counter++; // Prints 1

I favor an approach like this because we would get a general purpose language capability through decorators that could be used for all sorts of things, including signals.

NullVoxPopuli commented 3 weeks ago

aaaaand! If folks are open to classes (which, they're really good as long as folks don't abuse inheritance!)

you can have this today:

class GameBoard {
  @signal turns = 0;

  @signal
  get next() {
    return this.turns + 1;
  }

  nextTurn = () => this.turns++;
}

let board = new GameBoard();

board.turns // 0
board.nextTurn();
board.turns // 1
board.next // 2

using decorators, we don't need special property access, $, or functions (State / Computed), -- with getters, we don't need to do anything special to know that we have reactive values -- it's business as usual, and any effect implementation is going to automatically do the right thing.

With decorators and classes, we achieve the goal of not using compilers while still having native JS "just properties" ergonomics .

cysabi commented 2 weeks ago

aaaaand! If folks are open to classes (which, they're really good as long as folks don't abuse inheritance!)

you can have this today:

class GameBoard {
  @signal turns = 0;

  @signal
  get next() {
    return this.turns + 1;
  }

  nextTurn = () => this.turns++;
}

let board = new GameBoard();

board.turns // 0
board.nextTurn();
board.turns // 1
board.next // 2

using decorators, we don't need special property access, $, or functions (State / Computed), -- with getters, we don't need to do anything special to know that we have reactive values -- it's business as usual, and any effect implementation is going to automatically do the right thing.

With decorators and classes, we achieve the goal of not using compilers while still having native JS "just properties" ergonomics .

I'm curious about this!! if what you're saying is true, I would push for this 100% could you share a slightly more complex example? how would this work with computeds?

NullVoxPopuli commented 2 weeks ago

I'm curious about this!! if what you're saying is true, I would push for this 100%

:tada:

could you share a slightly more complex example? how would this work with computeds?

I can adapt existing code that doesn't yet use signals, but you can play with this @signal decorator here in signal-utils: https://github.com/proposal-signals/signal-utils

Here are the tests for the decorator: https://github.com/proposal-signals/signal-utils/blob/main/tests/%40signal.test.ts

Here is an example adapted from a "Toggle Group" component, where a reactive .value will re-set the set of values that make up the active state for the toggle group (toggles where multiple can be active at once):

class MultiToggleGroup extends Component {
  /**
   * Normalizes @value to a Set
   * and makes sure that even if the input Set is reactive,
   * we don't mistakenly dirty it.
   */
  @signal
  get activePressed() {
    let value = this.args.value;

    if (!value) {
      return new SignalSet();
    }

    if (Array.isArray(value)) {
      return new SignalSet(value);
    }

    if (value instanceof Set) {
      return new SignalSet(value);
    }

    return new SignalSet([value]);
  }
)

here, @signal wraps the getter in a Signal.Computed, and then is mostly for referential integrity of the returned SignalSet.

I hope this helps! and if there are any questions specific to utils that can be made with the proposal, there is a #signal-utils channel in the signals discord https://discord.gg/vCPCVnfMHN, or you could open an issue for discussion on the signal utils repo!