tc39 / proposal-signals

A proposal to add signals to JavaScript.
MIT License
3.29k stars 57 forks source link

Clarification request: (Semantic) equivalence of push-only and push/pull models #233

Open dehmer opened 1 month ago

dehmer commented 1 month ago

I tried to re-implement our own Signal library on top of the current polyfill (v0.1.1). Our library is based on a two-phase push-only model with explicit dependency tracking. This is pretty much OK for static dependency graphs (our main applications/use cases). Effects are more or less the same as computed/derived signals, except that effects can be optionally disposed.

From my understanding

implementing push-only semantics (including effects) is not possible with the current proposal, let alone differences in runtime characteristics. Synchronous eager effects cannot be realized with Computed + Watcher.

Reasoning would be that since pulling signal values in watcher must be deferred to not read stale state, effects are inherently called asynchronous.

Thanks for your attention and please help me understand whether or not this presumption is correct.

Here is some complementary code to (maybe) better show what I'm trying to achieve.

import assert from 'assert'
import { Signal as Polyfill } from 'signal-polyfill'
import Signal from '@syncpoint/signal'

const { State, Computed } = Polyfill
const { Watcher } = Polyfill.subtle

// 'Simple' effect as proposed in different locations.
// Don't care about clean-up for brevity.
const effect = callback => {
  let busy = false
  const watcher = new Watcher(() => {
    const pull = () => {
      // Pulling immediately may result in stale state.
      watcher.getPending().forEach(s => s.get())
      watcher.watch() // re-watch
      busy = false
    }

    !busy && (busy = true, queueMicrotask(pull))
  })

  const computed = new Computed(callback)
  watcher.watch(computed)
  computed.get() // pull immediately
}

describe('Polyfill', function () {
  it('async/effect', async function () {
    const a = new State(4)
    const acc = []
    effect(() => acc.push(a.get()))
    const countdown = ((n) => setInterval(() => n && a.set(n--), 0))
    countdown(3)
    await new Promise(resolve => setTimeout(resolve, 10))
    assert.deepStrictEqual(acc, [4, 3, 2, 1]) //=> [PASS]
  })

  it('sync/effect [presumably impossible]', function () {
    const a = new State(4)
    const acc = []
    effect(() => acc.push(a.get()))
    ;[3, 2, 1].forEach(a.set.bind(a)) // no time for watcher to kick in
    assert.deepStrictEqual(acc, [4, 3, 2, 1]) //=> [FAIL] actual: [4]
  })
})

describe('Signal', function () {
  it('on :: Signal s => (a -> *) -> s a -> (() -> Unit)', function () {
    const a = Signal.of(4)
    const acc = []
    a.on(v => acc.push(v)) // eager, synchronous effect
    ;[3, 2, 1].map(a)
    assert.deepStrictEqual(acc, [4, 3, 2, 1]) //=> [PASS]
  })

  it('scan :: Signal s => (b -> a -> b) -> b -> s a -> s b', function () {
    const a = Signal.of(4)
    const b = Signal.scan((acc, v) => acc.concat(v), [], a)
    ;[3, 2, 1].map(a)
    assert.deepStrictEqual(b(), [4, 3, 2, 1]) //=> [PASS]
  })
})
NullVoxPopuli commented 1 month ago

Code here: https://github.com/syncpoint/signal/tree/trunk