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

Add `Signal.subtle.requestSettledCallback`

Open robbiespeed opened this issue 4 months ago • 14 comments

Proposal

Add requestSettledCallback to the subtle namespace. This function would schedule a callback to run the next time graph propagation completes (everything has marked dirty), after a signal.set() call before it yields back to the program. It functions similar to requestIdleCallback in that it calling requestSettledCallback inside the executing settled queue will add the callback to the next queue, not the current one. This is unlike queueMicrotask which appends to the current running queue if there is one.

namespace Signal {
  namespace subtle {
    function requestSettledCallback(callback: () => void): void;
  }
}

Open to other suggestions for naming too, though I think the requestXCallback is probably a good framework to follow.

Motivation / Use Case

There are a few open issues I believe this would solve:

  • #121
  • #177
  • #184

Synchronous Effects

Userland synchronous effects could be implemented with userland batch and the use of requestSettledCallback.

let pending = false;
let isInBatch = false;
const batchQueue: (() => void)[] = [];

let w = new Signal.subtle.Watcher(() => {
  if (!pending) {
    pending = true;
    const cb = () => {
      pending = false;
      for (let s of w.getPending()) s.get();
      w.watch();
    };
    if (isInBatch) {
      // At the end of batch `cb` will run
      batchQueue.push(cb);
    } else {
      // At the end of state.set() `cb` will run
      Signal.subtle.queueSettled(cb);
    }
  }
});

export function batch (cb: () => void) {
  isInBatch = true;
  cb();
  isInBatch = false;
  let next = batchQueue.pop();
  while (next) {
    next();
    next = batchQueue.pop();
  }
}

export function synchronousEffect(cb: () => void) {
  let destructor;
  let c = new Signal.Computed(() => { destructor?.(); destructor = cb(); });
  w.watch(c);
  c.get();
  return () => { destructor?.(); w.unwatch(c) };
}

Batching isn't strictly necessary here, but for environments where effects (or subscriptions like Preact) happen synchronously it's a helpful mechanism to avoid unnecessary runs.

Selector

Selectors which avoid double re-renders could be implemented.

Double rerender happens if the synchronization to child signals happens asynchronously, because the view may render the input or something derived from it, as well as the child signals. Having a synchronous mechanism to do the setting of the child signals prevents this.

export function createSelector<TIn, TOut>(
  input: Signal<TIn>,
  mapper: (isSelected: boolean) => TOut
): (value: TIn) => Signal<TOut> {
  const childSignals = new Map<TIn, WeakRef<Signal<TOut>>>();
  const setters = new Map<TIn, (out: TOut) => undefined>();
  const registry = new FinalizationRegistry((value: TIn) => {
    childSignals.delete(value);
  });

  let store = input.unwrap();

  const handleChange = () => {
    const nextValue = input.unwrap();
    if (store === nextValue) {
      return false;
    }

    childSignals.get(store)?.deref()?.set(mapper(false));
    childSignals.get(nextValue)?.deref()?.set(mapper(true));
    store = nextValue;

    return false;
  }

  const w = new Signal.subtle.Watcher(() =>
    Signal.subtle.queueSettled(handleChange)
  );
  w.watch(input);

  return function selector(value: TIn): Signal<TOut> {
    let selectedSignal = childSignals.get(value)?.deref();
    if (selectedSignal !== undefined) {
      return selectedSignal;
    }

    selectedSignal = new Signal.State(mapper(store === value));
    childSignals.set(value, new WeakRef(selectedSignal));
    registry.register(selectedSignal, value);

    return selectedSignal;
  };
}

Alternatives

A. Allow untracked get inside notify

If we allow this using the existing algorithm (notify runs as it's producers are dirtied, during the dirtying phase), what we get is instability in the graph. The notify callback could mark something clean which was just marked dirty, then it can get marked dirty again, triggering the same notify callback, and so on. As an example of that you can check-out this jsbin which allows get in notify. What you see is not only that the notify is called multiple times, but the underlying computed being watched also recomputes multiple times with incorrect state, then ends computing with correct state (after all it's producers have finally been dirtied).

B. Schedule notify to run after graph has finished dirtying

Instead of executing notify callbacks as producers are dirtied, push them to a queue and run that queue when dirtying is done (the same place callbacks queued via requestSettledCallback would have run).

This is safe, and won't have the same downsides as the alternative A.

There are minor drawbacks however:

  • Callbacks that were going to be queued via requestAnimationFrame or queueMicrotask end up doubly queued. Once via a wrapper callback that gets queued to run after dirtying phase, and again after the callback goes into the final queue. Ex:
let pending = false;
const w = new Watcher(
  // this is queued to run at a safe time after all nodes are dirtied (1st Queue)
  () => {
    if (pending) return;
    pending = true;
    // This is the thing we actually want to run, but we need to append it to a queue (2nd Queue)
    const cb = () => {
      pending = false;
      for (let s of w.getPending()) s.get();
      w.watch();
    };
    queueMictotask(cb);
  }
);
  • Only notify callbacks can be scheduled directly into the Watcher queue. This makes it difficult (but still possible) to schedule polling like mechanisms for after any state change in the graph.
  • Lack of symmetry. With requestSettledCallback to change a reaction from asynchronous (queueMicrotask) to synchronous or vice versa is as simple as replacing the scheduling function being called.

robbiespeed avatar Apr 18 '24 05:04 robbiespeed