Closed ryansolid closed 4 years ago
Overall, I think this will be great to have more formalized understanding of when each reactive context will execute. Simplifying the interaction with refs will be a plus in many situations, removing the need for setTimeout
in many places and opening the door for future improvements.
In this proposal, is the only difference between a renderEffect and an effect that the former runs immediately the first time? After that first run would they be equivalent? I ask because I wonder if they could use the same creation function with an extra parameter control if it is an immediate effect or otherwise. I like to simplify the API whenever possible, plus this might give an upgrade path by making the parameter optional and having the default behavior like it is today. How does createDeferred
fit into this?
This change will require users to use the primitives in their intended ways, for example keeping memos free from side effects. Will you be able to enforce that or notify authors that they are doing something improper like writing to a signal in a memo? I think this will help a lot for such a fundamental breaking change.
I say do it.
Yes. To be fair that is an implementation detail. I originally was thinking 3 independent queues but I realized that I got the same effect (pardon the pun) simply by running them immediately as expected. There might be a reason to handle them differently but currently I don't have one. I think the trickiest part is reconciling different things. createDependentEffect
comes to mind which has its own options. That has always bugged me. But it has a bunch of extra code I'd rather not include in the package so separate function is good. As for createEffect and createRenderEffect I think using an argument is reasonable but I think it's important to change the default behaviour.
createDeferred
is really createDeferredMemo
. It is interesting in that it basically is used to schedule downstream work when CPU is available. But it does beg the question of how or if it should play into some sort of consistency model. I think this is mostly to break out of it. But it does ask the question if it's quite right with a different async model.
Preventing signal writes in a memo I can enforce but it would be hard to prevent other side effects. Maybe worth doing simply to show the library doesn't support it.
Thanks for the comments and thoughts. This is a big change. And while I have the basics worked out which is the majority of the breaking part for end users. This is not the end of this as it fundamentally changes internals a good amount. It opens up the concept of priority queues inside the reactive runtime. So while it's backward compatible (with API tweak), it means modelling some async stuff differently.
I've updated the RFC with the latest updates from doing more investigation. Still very interested on feedback. The biggest thing is this change introduces some new APIs that will be commonly used especially createComputed
. Very interested in finding a name that best captures this computation that looks like an effect
but is for updating other signals/resources and runs immediately along with Memos.
Also something to keep in mind is this adds more API surface area for the end user. They need to learn when to use which.
It seems with some other libs (f.e. Vue) there's not much new to learn API-wise when trying to do SSR with it. We only need to learn when/how to make network requests (namely put them in certain lifecycle hooks, and only two of them are called during SSR).
It should be as easy as possible for a dev not to shoot their foot. F.e. Solid JSX lends to writing very nice and simple code like follows within a Solid component:
const c = <canvas/>
const gl = c.getContext('webgl')
// ...
return c
How can SSR be made to work while not limiting code style?
For sure, so maybe it's best to view this is a bit like introducing new lifecycles. So I wouldn't say a library like Vue doesn't necessarily have less API to learn. You just have been using these lifecycle hooks all along whereas Solid did not because it did not have the equivalent. So introducing new lifecycles even if opt-in does change the way you write code.
For example we can make createEffect
never execute on the server. That seems reasonable and is a glaring omission from this proposal. Conceptually there for users we now have a lifecycle:
createMemo
, createComputed
) - derive reactive values, synchronize state update other signalscreateEffect
) - (client only?)The 1 & 2 ping pong back and forth until they settle and then we render and apply side effects. This is definitely more directed. It suggests that for synchronous execution (ie.. not event handlers, or returned promises) you should only write to other signals in createComputed
. createEffect
should not write to the reactive system and createMemo
is pure. There is a definite place to do things, but maybe the naming isn't clear enough.
For most users we are introducing one new concept. I understand the weight of that but we are also making createEffect
work much more like its React and Vue counterparts. It's just difficult to explain why createComputed
needs to exist. It's really just an autorun
version of createMemo
. You could achieve the same as using createMemo
without using its return value but that doesn't make sense from a naming perspective. Another take is there should only be a createComputed
and replace both createMemo
and createComputed
with it and ignore any concerns about whether it returns a value or not. Just use it both ways very much like a Knockout computed
but I think it is better to be directed.
How can SSR be made to work while not limiting code style?
The example above would never work in string rendered SSR in any library. Accessing DOM APIs on the server would have never worked yet writing code this way is a convenience that can be done on the client when dealing with DOM nodes directly. Is it unfair to expect that people understand that isn't SSR safe? There are always things you can do in libraries on the client that wouldn't work on the server. These new computations can streamline stuff for us, but are more restrictive by nature. It is this restriction that makes Vue able to handle these different cases consistently rather than its flexibility. I know freedom is part of what people love about Solid so much so I feel awkward being too heavy-handed here. Some components will just not be SSR friendly and I think that is fine? I'm not sure SSR can ever be anything other than a pale imitation.
Updated the proposal focus on createComputed
and createEffect
as the 2 primitives from an end user perspective and handle the render case as configuration to createEffect
The development on this RFC is going well it's now in beta. If you are interested in trying it out use the latest on the beta
tag on npm. As of writing this that is v0.20.0-beta-6.
I thought I'll share the approach I use in chronograph for this problem, perhaps you'll find it useful and draw some inspiration from it. It somewhat generalizes what you are describing.
I'm assigning every atom with the "level" attribute, which is a simple number. "level" is supposed to be within some narrow range, starting from the 0, like 0..10, or 0..15 (for implementation efficiency).
Then I use a "leveled" LIFO queue to determine the order of calculations for atoms: https://github.com/bryntum/chronograph/blob/chrono2/src/util/LeveledQueue2.ts
Atoms can go "in" the queue in any order. Atoms can go "out" of the queue - the atoms with lower level goes "out" first.
Then we add additional "invariant" - that lower levels atoms can not observe higher level atoms.
As a result we have the following property - when an atom of certain level is being calculated, all lower-level atoms have been already calculated (and their effects observed).
This property allows us to implement things like lazy "distant" effects - for example, you send a message to the atom and that message does not propagate immediately for some reason (needs other messages). Then, when calculating stack, that message can propagate (and update other atoms), but we want to avoid re-calculating atoms that are affected by this propagate - thus we first calculate the lower level, then higher level.
The levels I have in chrono are based on the distance from the user input - ie, level 0 is user input (signal). Level 1 is a computed value, that only observes atoms of level 0 (and 1). Level 2 observers levels 0, 1, 2, etc
@canonic-epicure Thanks for the explanation. I might want to talk to you about async consistency at some point.
In any case close as released with v0.20.0
@ryansolid Sure. Ping me in discord if anything.
Summary
This proposal looks to make Solid's execution cycle more intuitive by deferring the execution of end-user effects until after rendering has connected to the DOM. In this model, refs will be available at the time effects run.
Here is an example of how this could work: https://codesandbox.io/s/effect-timing-ts8uh?file=/index.js
Please leave any feedback below. I'd love to have feedback on any of the pieces I am proposing here from syntax to overall objective.
Motivation
Consider how the following works today. We are adding a 3rd party chart that renders in a canvas element that we want to update with prop updates:
We create a signal for the ref so can make sure the effect doesn't run with no canvas element and doesn't run again. Even in a ref we set a timeout so that we know the element has had time to attach to the DOM. However, if we order our effect executions we remove the complexity.
Proposal
Different types of computations get executed in different groups. Today we record a change (multiple if batched) and then resolve all computations in a single queue. Pretty much all reactive systems work like this. The intention here is to have more control over when we propagate changes to the outside world by splitting computations between pure and unpure queues. Conceptually this works in 3 phases.
To accomplish this we need to split apart our current definition of what an
effect
is in Solid. From now on aneffect
refers to a computation that affects something outside of the reactive system, like the DOM, or the console. In so we introduce another primitive to handle the reactive update case,createComputed
.createEffect
- unpure way of creating side effects in Solid. It is queued and always runs after the reactive system has settled. This means refs will have resolved, elements will be attached to the DOM (unless you are creating them on an isolated branch but that's on you).createComputed
** - pure in that this is a computation that is designed only to provide synchronization to the reactive graph by updating other signals. It runs at the same time ascreateMemo
and has the execution behavior ofcreateEffect
today.** I'm open to suggestions on what to call these more immediate computations.
Removals
The intention is to remove
afterEffects
as it will no longer be needed ascreateEffect
not only fills that role but also is tracked.createDependentEffect
is harder to justify as there are so many effect-like things, plus it's easy to recreate, by just accessing everything at the top of the computation and then untracking the rest.Drawbacks
This is a breaking change. Things will execute at different times. The fix to get original behaviour will be to change to
createComputed
but I believe once in place you will want to usecreateEffect
when dealing with refs. This is unavoidable and their is no easing in. This change will be immediate and universal.Other Considerations
This opens up the path for future work in Solid, around separating pure computations from effectful ones for different async Suspense models that are truly consistent. This enables the equivalent of React's Concurrent Mode in a granular reactive library. For this proposal to work we need to ensure effects are not fired while the Suspense components are unmounted as well.
Adoption
It is a breaking change across all libraries. And will be released as a new minor version (since there is no major version). This code change probably won't break builds but it will change how current code works.