signals
signals copied to clipboard
Proposal: Multi-value Signal using bitmaps (bit arrays)
Hi 👋, this is a PoC of #213 😄 (all tests still passing 😅)
Description
The basic idea of this PR is demonstrate how Signals could be improved to keep track of multiple values (up to 32) using 1 single node to reduce memory footprint and possibly improve performance when multiple (related) values are accessed within a computed or effect.
In this PoC, assume a Signal can have - instead of value - properties signal[0] (alias for signal.value) to signal[31]. Just because numeric properties seem to be accessed faster than string properties (it could have been signal.value0 .. signal.value31 but it's not relevant).
For every get, we keep track of the index - in a bit array - of the accessed property:
get(this: Signal) {
// ...
node._version = this._version;
node._fields |= 1 << i; // i -> 0..31 -> signal[0]..signal[31]
// ...
return this._value[i];
}
And every set operation checks whether the node is watching the property whose value is changed:
set(this: Signal, value) {
// ...
this._value[i] = value; // i -> 0..31
// ...
if (node._fields & (1 << i)) node._target._notify(); // < Notify ONLY if property is watched
}
With this as base, we can create signals that have multiple values (up to 32) and create a lot less nodes if all the accessed properties within a computed belong to the same Signal, for example:
computed(() => signal[0] + signal[1] + signal[2]);
computed(() => signal[1] + signal[5] + signal[31]);
In this particular example, all properties in both computed belong to the same signal, so a single node should be created with bitmap set to fields = (1 << 0) | (1 << 1) | (1 << 2) (for the first). This means that, for example, if value[0] changes, then the second computed won't run even though both use the same signal.
Now, the DX is awkward so this PoC doesn't attempt to be a final version but rather to suggest creating an even lower primitive than Signal. A Signal could be an abstraction for this new primitive where it only uses one field value[0].
An example for such abstraction is the reader function which I included in the code. Which allows creating signals in this manner:
const group = reader({
foo: "",
bar: "",
});
// group.foo;
// group.bar;
// group._signals = [Signal]
// group._size = 2;
( Please check the test cases for reader 😄 )
A Signal could look like:
function signal(value) {
const s = reader({ value });
s.peek = function() {
return s._signals[0][0];
}
}
Of course, this example is just an illustration since better optimizations can be made but I think it conveys the point 😅
But Why?
I want to create a factory which creates classes, sort of like this:
class User extends Factory({
username: t.String(),
// ... many other fields
}) {}
Where t.String is a config object which is converted to a property descriptor with getter/setter which would use a signal. However, this consumes a lot of memory and it's (relatively) slow if multiple related properties are accessed within a computed.
I think this could be improved by using a single Signal to watch multiple related properties (well, up to 32). As in the reader PoC example, in one of the tests, you can see that 93 properties can be used with just 3 signals instead of defining 93 signals.
My implementation is just a PoC, at its current state I just hope to spawn some discussion around this if it's interesting enough to move forward within preact/signals (Please check the new test cases).
I think this could also be interesting for #4 . I don't think all this logic should belong to Signal 😅 to be honest, it was just easier to implement it there for a PoC PR.
⚠️ No Changeset found
Latest commit: 7dbc62f77e7b67cfb78c2361500c50a422e9ba3a
Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.
This PR includes no changesets
When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types
Click here to learn what changesets are, and how to add one.
Click here if you're a maintainer who wants to add a changeset to this PR
Deploy Preview for preact-signals-demo ready!
| Name | Link |
|---|---|
| Latest commit | 7dbc62f77e7b67cfb78c2361500c50a422e9ba3a |
| Latest deploy log | https://app.netlify.com/sites/preact-signals-demo/deploys/6331e2338ff2970008ff1e67 |
| Deploy Preview | https://deploy-preview-217--preact-signals-demo.netlify.app |
| Preview on mobile | Toggle QR Code...Use your smartphone camera to open QR code link. |
To edit notification comments on pull requests, go to your Netlify site settings.
Hi Eddy! I have a hunch that this may be a bit out-of-scope for signals, since we're focused on providing a single "box" primitive that folks can build interesting things like this on top of. Perhaps it would make sense to explore this in one of the object/wrapper libraries folks have been working on that use Signals under the hood?
I think this is a fantastic prototype and well worth exploring further! Agree with @developit that it's currently out of scope for signals as we're trying to keep the API as minimal as possible. The PR here would be perfect as a separate package that users can install and try out 👍
In the current tracking scheme (based on versions numbers) each of the 32 different values probably need their own version numbers. That's because a signal can have multiple listeners (effects, computeds) that run their computations in different times. For example in the following example the last computing c2, a.y is 1 message shouldn't get printed to the console:
const a = reader({ x: 0, y: 0 });
const c1 = computed(() => {
console.log("computing c1, a.x is", a.x);
});
const c2 = computed(() => {
console.log("computing c2, a.y is", a.y);
});
a.x = 1; // Set _fields bit 0.
a.y = 1; // Set _fields bit 1.
c1.value; // Console: computing c1, a.x is 1
c2.value; // Console: computing c2, a.y is 1
a.x = 2; // _fields bits 0 and 1 stay set.
c1.value; // Console: computing c1, a.x is 2
c2.value; // Console: computing c2, a.y is 1
One workaround that comes to mind would be to set a's bits back to zero e.g. when the corresponding signal value is read by an effect/computed. But a solution like that may break cases where computeds depends on a overlapping subsets of properties, like in this example (that works now, this is just to illustrate a hypothetical 🙂):
const a = reader({ x: 0, y: 0, z: 0 });
const c1 = computed(() => {
console.log("computing c1, a.x + a.y is", a.x + a.y);
});
const c2 = computed(() => {
console.log("computing c2, a.y + a.z is", a.y + a.z);
});
c1.value; // should recompute
c2.value; // should recompute
a.y = 2;
c1.value; // should recompute
c2.value; // should recompute