proposal-signals icon indicating copy to clipboard operation
proposal-signals copied to clipboard

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

Open dehmer opened this issue 1 year ago • 1 comments

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]
  })
})

dehmer avatar Jul 17 '24 09:07 dehmer

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

NullVoxPopuli avatar Aug 06 '24 15:08 NullVoxPopuli

@dehmer Thanks for the issue! I believe your statement on "you cannot implement push only semantics with this proposal" is correct. Is there something specific you'd like to discuss or just curious if push only is possible?

jkup avatar Jul 14 '25 14:07 jkup