-
-
Notifications
You must be signed in to change notification settings - Fork 95
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implement core without Sets, Maps, Arrays etc. #136
Conversation
|
❌ Deploy Preview for preact-signals-demo failed.
|
Really like where this is going 🎉 |
I like even more where this is going 🚀 |
…er 100 iterations
Avoid a closure on every effect() call. This improves the memory consumption of trivial effects by about 1/3 while being 4x faster (Node.js 18).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a fantastic piece of engineering!! Really impressed with the results and the speed improvements 👍
This pull request rewrites the core to avoid allocating Sets, Maps and Arrays on the fly. The implementation uses linked lists of nodes that contain both the source (signal) and target (computed/effect) of dependency-subscription pairs. The aim was to facilitate constant-time subscribe/unsubscribe operations without sets.
Currently this breaks the Preact/React integrations, as the core internals have changed quite a bit.
Very lazily computed signals
Computed signals evaluate their values lazily whenever their
.value
is accessed or.peek()
is called.Computed signals also lazily subscribe to be notified when their dependencies change only when the computed itself already has subscribers. This is done so that computed signals that aren't currently (direct or indirect) dependencies of some effects or other computed signals can be garbage collected when they go out of scope.
When value of a subscribeless computed signal is accessed the computed signal always checks whether its dependencies have changed. To speed this process up, signals also keep track of their own "version numbers" that change whenever their value changes. Computed signals then store (in the linked list nodes) last version numbers they've seen from their dependencies, and use that info to estimate whether their computation function has to be re-evaluated.
When a computed has subscribers it gets notifications about changed dependencies as per usual, and the recomputation process can be short-circuited when the computed signal's
._valid
flag istrue
.Every signal value change increments a global version number. Computed signals remember what the global version number was when their computed value was last validated. This info is then used to fast-track
.value
/.peek()
calls when nothing has changed globally.Error handling
If a computed signal value function throws, the error is stored and subsequent
.value
/.peek()
calls throw the same error without re-evaluating the compute function until one of the computed signal's dependency values changes.If a computed value depends on itself (directly or indirectly) a "cycle detected" error is raised inside the compute function. To prevent runaway recursive effects, the top level batch handler tracks how many operation batches it has had to process consecutively. After 100 iterations all 'signal.value = ...` calls throw a "cycle detected" error.
When an effect in a batch handler throws an error it's percolated to the calling context that started the topmost batch handler. If multiple effects fail inside one batch handling loop then only the first error is percolated to the top.
Notes about avoiding sets
The main use case for using Sets instead the original implementation was to avoid subscribing to the same dependency twice inside an effect or a computed signal's value function. Arrays can be used instead of Sets, but for then avoiding duplicate subscriptions requires checking the array for duplicates, which is an O(n) operation.
This PR tried to solve this with the linked lists, by making signals keep track of the last evaluation context in the stack that has accessed their value. Signals can then can quickly check whether they've already been inserted to the evaluation context's dependencies. This way the check is constant-time.
Signal's
._node
property keeps track of the last linked list node in the evaluation stack where the signal is the dependency. This way_node.target
value can be used for the evaluation context check described above, but also for recycling nodes.Those same linked list nodes are also used to remember
node.signal
's._node
values after entering a new evaluation context, and then for restoring the._node
values when exiting from the evaluation context.Batching is done with a linked list of effects that should be run in the next batch iteration. To avoid memory churn Effect instances themselves act as nodes in that linked list.
Benchmark
This (extremely simplistic, should be run with d8 etc.) benchmark:
I get on my machine: