proposal-signals
proposal-signals copied to clipboard
Add `Signal.subtle.requestSettledCallback`
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
orqueueMicrotask
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.