Open onurkerimov opened 1 month ago
I just updated my issue description, and made some points more clear.
Can you not build Derived on top of Signal.subtle.untrack()
?
import {describe as suite, test} from 'node:test';
import assert from 'node:assert';
import {Signal} from 'signal-polyfill';
type Signal<T> = Signal.State<T> | Signal.Computed<T>;
suite('Derived', () => {
test('can do explicit tracking', () => {
class Derived<T> extends Signal.Computed<T> {
constructor(fn: (get: <T>(signal: Signal<T>) => T) => T) {
const watchedSignals = new Set<Signal<any>>();
const get = <T>(signal: Signal<T>) => {
watchedSignals.add(signal);
return signal.get();
};
super(() => {
// First, run the computation untracked
const result = Signal.subtle.untrack(() => fn(get));
// Then trigger the automatic tracking outside of untracked()
for (const signal of watchedSignals) {
signal.get();
}
return result;
});
}
}
const a = new Signal.State(0);
const b = new Signal.State(0);
const computedSum = new Signal.Computed(() => a.get() + b.get());
const desrivedSum = new Derived((get) => get(a) + b.get());
assert.equal(computedSum.get(), 0);
assert.equal(desrivedSum.get(), 0);
// a is explicitly tracked, so both sums update
a.set(1);
assert.equal(computedSum.get(), 1);
assert.equal(desrivedSum.get(), 1);
// b is only implicitly tracked, so only the Computed updates
b.set(1);
assert.equal(computedSum.get(), 2);
assert.equal(desrivedSum.get(), 1);
});
});
First of all, thanks to all contributors.
In my opinion, a critical API primitive is missing from this proposal. In addition to
Signal.Computed
, there should beSignal.Derived
. (I'm going to call thisDerived
in this document, the naming can be changed if they look like they mean the same thing) I'm the author of a state management library called xoid, and I had chance to think about these topics in the recent years, and I feel like I have something to add after examining the proposal.Rationale
This proposal involves two concepts which don't necessarily come together: "push-then-pull" model, and implicit dependency tracking, and it looks like it doesn't provide a way to use the built-in "push-then-pull" model without using implicit-dependency-tracking related native code.
Is this going to make the proposal better for library authors? Yes, I strongly believe so.
Is this going to confuse some people? Yes. I wouldn't bring this up if it didn't have crucial benefits which I'll explain further in this document. If you also agree with the benefits I'll explain, but if you think that adding an extra thing would harm the learning curve of this API, it also might be an option to place the related functionality under the
.subtle
key. If it's already possible using the current.subtle
interface, consider this issue as a question/request-for-docs and please demonstrate so. It'll be appreciated!What is
Signal.Derived
?Signal.Derived
would be the "explicit dependency collection" version ofSignal.Computed
. Let me demonstrate the usages of the two:Computed
andDerived
are similar contructors.Computed
collects dependencies automatically when.get()
is used, andDerived
, which I propose, collects them explicitly via its own collector/"getter" function. (Perhaps using the word getter for this one should be avoided) If you've used Recoil before, then you may find this familiar. In Recoilselector
function is used to create selector atoms, which is similar to the concept of computed signals. Inselector
, there's no implicit/automatic dependency collection in the callback function. Everyting needs to be wrapped insideget(...)
.My criticism with the current state of the proposal is that building an explicit-collection-based library like Recoil, or Jotai, or xoid, doesn't seem trivial as building implicit/magical-collection-based libraries such as MobX, Valtio,
@vue/reactivity
. Both families of libraries make use of the "push-then-pull" model built-in to this proposal, and in the current proposed API, implementing the former family of libraries seem more convoluted than the latter.Why this would be a problem? Is the explicit-collection as the "default" behavior, and the implicit ones are an "enhancement"? I think, yes, and I'll try to explain it. You can prove me wrong here, and I'd love to learn if I'm wrong about this. It seems very obvious to me that
Derived
precedesComputed
. Because, implicit tracking is the opt-in one. It's less intuitive to think the other way around.Computed
requires more work thanDerived
, because it holds a "keeping the current context/listener" while intercepting "get()" calls. I'll explain more concretely why it involves more work (computation) in the next section. One of my most important standing points is that it requires more computation, and therefore the its better to expose the API primitive that does less work (and is also useful as is).Why
Derived
is a more fundamental API primitive thanComputed
Imagine we have
Signal.Derived
. Using it, an oversimplified version of implementingSignal.Computed
looks like this:Derived
doesn't do implicit tracking by itself, so we add a logic to set acurrentCollector
temporarily. I have also tried this in the past for my libraries. You can check https://github.com/xoidlabs/xoid/blob/shave-off-2/packages/xoid/src/tracking/index.tsx to see how I implementedcomputed
as a special case ofatom
in xoid.I think this is the intuitive way to derive one from another. It cannot be the other way around. Well, maybe someone can come up with the opposite alternative, but I strongly suspect that it would be too convoluted compared to this one, and of course, I'd love to see if I'm wrong here. Currently I'm thinking strongly in terms of "implicit dependency collection" is more work compared to the explicit one, and conceptually that it's opt-in rather than opt-out.
Thought Experiment: What happens when we try to derive
Signal.Derived
fromSignal.Computed
Let me try to disprove my claims. Imagine we don't have
Signal.Derived
(current state of the proposal). To implement my Jotai-likederived
function, I'd have to do:The latter may not seem absurd at the first glance, but don't forget that there's still an UNUSED
.get()
tracking mechanism under the hood! This is the whole point. There's an elegant way of derivingDerive
fromComputed
, but there's isn't for the opposite.Summary
Derived
andComputed
would be in this order.*Computed
does extra work, which is keeping track of the "current getter" (demonstrated above)Derived
is is ~90% the work. After that ~10% of the work is implementingComputed
. That's how most of the complexity lies inDerived
.Computed
, and not exposeDerived
. Which is already 90% of the work, and it would be useful primitive for one family of state management libraries.*: Conceptualization and implementation are hand-to-hand. It's not very surprising that they agree with each other, but I think it's worth double-checking.
Last words
Signals
object, how can I opt out of the implicit dependency collection? Would it be more performant if I implement it from scratch?".Thanks again to all contributors!