DrSensor / nusa

incremental runtime that bring both simplicity and power into webdev (buildless, cross-language, data-driven)
MIT License
4 stars 0 forks source link

Reactive decorator #16

Open DrSensor opened 2 years ago

DrSensor commented 2 years ago
import { reactive, autorun, unreact } from "https://esm.run/wiles/decorator"
import { clear, halt, listen } from "https://esm.run/wiles/std"
export default class Global {
  static total = 0
  @reactive accessor count: number

  /** imply @derive with count as dependency */
  get half() { return this.count / 2 }

  increment() { this.count++ }

  /** automatically run when instantiated (implicit) */
  @autorun.now #halfSum() { Global.total += this.half }

  /** need to be called to register the effect (explicit) */
  @autorun enableLog() { console.log(this.count) }

  constructor({
    log = true,
  } = {}) {
    if (log) this.enableLog()
  }

  disableLog() { clear(this.enableLog) }

  toggleLog() {
    if (this.enableLog == clear) {
      console.warn("log permanently disabled")
      return
    }
    if (this.enableLog == halt) listen(this.enableLog)
    else if (this.enableLog == listen) halt(this.enableLog)
  }
}

Note no need for @derive decorator (maybe 🤔)

Behaviour:

  1. When method for event handler is called, the autorun happen after event handler is finished. This give a chance to conditionally disable autorun when assigning the reactive accessor. (i.e if (this.count++ < 5) unreact(this, "count"))
  2. Else the autorun will just run immediately when assigning the reactive accessor.

📚 References

DrSensor commented 1 year ago

💡 Wild Idea

NO NEED FOR DECORATOR❗

Just effect(...) and done

export default class {
  count = 0
  tick = 0
  constructor() {
    effect(passive => console.log(
      this.count,   // tracked
      passive.tick, // not tracked
    ))
  }

  // <span :: text:=computed />
  get computed() {
    return effect(passive => passive.tick + this.count)
  }
  /* or can be simplified as
  readonly computed = effect(passive => passive.tick + this.count)
  */

  // <button :: on:click="increment">++</button>
  increment() { this.count++ } // print into console

  // <button :: on:click="decrement">++</button>
  decrement() { this.tick++ } // update <span> text
}

Note that due to automatic binding (loose mode), effect only run after constructor even though it's called inside it. Using effect as decorator might make more sense 🤔


import { effect, passive } from "nusa/reactive" // yup, this module `export let passive`

export default class { count = 0 tick = 0

constructor() {}

@effect #logger() { console.log( this.count, // tracked passive.tick, // not tracked )) }

// @effect get computed() { return passive.tick + this.count }

// increment() { this.count++ } // print into console

// decrement() { this.tick++ } // update text }

> Another approach is by signaling that class scope cause reactive effect
```js
export default class {
  count = 0
  tick = 0

  constructor() {
    this.#enableLogger() // WARNING: need to be explicitly called
  }

  #enableLogger() {
    const passive = effect() // signal that this method may cause reactive effect
    console.log(
      this.count,   // tracked
      passive.tick, // not tracked
    ))
  }

  // <span :: text:=computed />
  get computed() {
    const passive = effect()
    return passive.tick + this.count
  }

  // <button :: on:click="increment">++</button>
  increment() { this.count++ } // print into console

  // <button :: on:click="decrement">++</button>
  decrement() { this.tick++ } // update <span> text
}

🧑‍⚖️ Verdict: decorator WIN!

DrSensor commented 1 year ago

Using it on specific lifecycle 🤔

import { use } as at from "nusa/std"
import { effect, idle } from "nusa/reactive"
import * as at from "nusa/lifecycle"

import Counter from "./counter.js"
import Program from "./program.js" // let say it extends Counter
// and both modules <link> inside the same <render-scope>

at.render(() => {
  const counter = use(Counter), $counter = idle(counter)
  const program = use(Program), $program = idle(program)
  // or maybe `$program = effect.passive(program)` 🤔
  effect(() => {
    console.log("Counter:"
    , counter.count   // tracked
    , $counter.tick)  // not tracked
    console.log("Program:"
    , $program.count  // not tracked
    , program.tick)   // tracked
  })
})
DrSensor commented 1 year ago

Options

reset

default: false (for performance)

Reset dependency graph after effect finish executed. When false, the dependency graph will incrementally build up instead of being renewed each time effect is executed.

export default class {
  location = '37°46′39″N 122°24′59″W'
  zipCode = "94103"
  preference = "location"

  // @effect({ reset: true })
  @effect.reset get weather() {
    switch (this.preference) {
      case "location":
        return lookUpWetherByGeo(this.location)
      case "zip":
        return lookUpWetherByZip(this.zipCode)
      default: 
        return null
    }
  }
}

props/attrs bound to weather will get updated either when:

DrSensor commented 1 year ago

Making every properties and accessors reactive when initialize effect decorator is problematic because there is no way to get accessor of private field via Class.prototype. So marking them as @active accessor is necessary.

export default class {
  @active accessor tick = 0
  @active static accessor #sum = 0

  @effect get blow() { // not run until it's accessed
    return (this.#sum -= this.tick)
  }
  @effect grow(value = 0) { // not run until it's called
    if (effect.invoke) { // first time invoke
      this.#sum += value
      this.tick // listen to tick changes
    } else { // continuous effect
      this.#sum += this.tick + value
    }
    // TODO: How to persist last `value` 🤔
  }
  @effect.invoke #log() { // run immediately after instantiation
    console.log("tick", this.tick)
    console.log("sum", this.#sum)
  }
}

So yeah, @effect should be companied with @active for the sake of consistency, no magic. Though there is a pattern where you don't need @active accessor be in the same class as @effect method.

TODO: