Coordinating Signals in a Layout System
Hi all, I'm trying to implement a layout system in SolidJS that works similarly to mobile frameworks like Jetpack Compose and SwiftUI. In my system, you write things like this:
And each of those components internally calls a special
<Layout>
component, e.g.:
Layout
roughly looks like this:
Note that props.layout
should be idempotent so I can safely call it more than once, even though it sets signals inside updateBBox
. I need to cache/memoize props.layout
since it is expensive to compute. According to the docs, it sounds like I shouldn't use createMemo
since layout does have side effects.
The problem I'm having is that in Parent
's layout
function, it needs to ensure that its children's layout
functions have been run so their bboxes are up to date. E.g.:
The problem is that the parent effect runs before the child effect. What's the best way to accomplish ensure that the child layout is up to date when getBBox
is called?
Thanks!16 Replies
One more thing I forgot to mention is that it would be great if I could move the
layout
function into the scenegraph store somehow. It would make my system more portable if everything is store-based.According to the docs, it sounds like I shouldn't use createMemo since layout does have side effects.Does it set signals? If so, you're right; but if not, you can use
createMemo
. Only Solid-specific side effects matter here.
The problem is that the parent effect runs before the child effect. What's the best way to accomplish ensure that the child layout is up to date when getBBox is called?Some options: * Wrap the parent's actual work in
queueMicrotask
, which pushes it till all synchronous computation (including other effects). But dependencies (signal calls) need to come before the queueMicrotask
wrapper.
* I think if you construct the JSX and then create the effect, the order will flip, like so:
Have you considered a context? Seems like it might be what you're looking for.
@jmp3741Hi Erik, thanks for the help!
The scenegraph is a Solid store and updateBBox calls
setScenegraph
, so the layout effect has a Solid-specific side effect.
But swapping the order of the jsx and effect did the trick!
In terms of moving the layout functions into the scenegraph store I was mostly wondering if there was a way to keep the reactive computation in the store so it can be called by other components. Right now I'm dependent on the Solid effect queue for layout updates.
That being said, all the layout scheduling problems I've run into so far can be solved by the jsx-effect swap trick. So I'll just stick with that for now.
Thanks again!Glad you have something working! It's a little tricky to think about alternate designs from this level of detail. You could consider getters/setters in the store, which should let you put signals in there.
Basically I have a store
It sets up a DAG of scenegraph nodes. The bbox and transform (and ownership of the properties within those fields) is updated by
setBBox
. For example, I may have an AlignLeft
parent that sets the left
bbox property of each of its children and obtains ownership of each of its children's left
bbox property.
The ref
nodes allow indirection so multiple nodes in the scenegraph can modify the same bbox and transforms. (This is why I can't derive bbox and transform information directly from the layout functions and just memo that.)
The ownership information ensures that even when a node is written to by multiple parents, the properties are uniquely writable by a single parent so there are no edit conflicts.
Suppose I have a component like this:
Then the first Rect
will run its layout, setting its width, height, and y values. The AlignLeft
's layout will run later, thus filling in the x
value.
This works alright for a lot of things, but one tricky addition is making it so that you can adapt to screen size. For example, you might want to shrink a Rect
so that it fits inside the screen. To accomplish this in Jetpack Compose, they pass width and height information from the parent to the child, which is used during the child's layout. Here's a simplified example of that:
But notice that this changes how layout
s are run. Now the parent controls when the child layout happens. This is useful in some cases like for implementing flexbox. In that case you have a flow sort of like this:
So parent and child layout computations are interleaved in a way that I don't think is possible using createEffect
?
Another route to take is to store maxHeight
and maxWidth
in a local context for each child. This can be useful so that you can change the structure of the component based on those constraints. E.g.
This seems pretty expressive and a lot nicer than writing layout functions directly, but I haven't figured out how the API would work if you could also write stuff like child0.layout({ maxHeight: 5000, maxWidth: 3000})
. I think the problem is that within a layout function you want the child's layout computation to have settled after the child0.layout
call is complete, and I'm finding it pretty hard to reason about when that happens.
Would be curious to hear your thoughts! I don't have the API for this nailed down very well.Also in case you are wondering, my goal with this library is actually to make diagrams (like Euclidean geometry, algorithms, and chemical molecules) not really UIs.
Nice! I'm a geometer myself. I was looking at Haskell Diagrams recently which has some neat ideas. I'm curious where this JSX-based approach can go. (Less related, but I wrote my own figure drawing tool that uses JSX: https://github.com/edemaine/svgtiler)
I'm a little worried about how Solid effects get ordered in updates. (Do you have reactive updates?) I'm confident about the order on initial render, but I don't know whether the order is preserved during updates... Which might be an issue even for your current approach.
Ideally you'd work more directly with Solid's reactive graph, which is also a DAG. But I don't see how to do it with createMemo... It may not be possible with Solid's current design.
To do flexbox layout, I wonder if the parent could pass in some kind of callback function (via props say) to the child, and child calls it after it lays itself out. It's pretty clear what this would do in initial render but I'm less sure about updates. More generally, you could imagine passing in "before" anf "after" layout callbacks, which might be an interesting design... This is more like manual wiring but could still be reactive.
Thanks for the thoughts! The SVG tiler looks cool.
I do share your worries about the layout update order. My previous system prototype only supported static diagrams so there was no problem with ordering.
My current approaches are either (i) have parent layouts call child layouts to enforce a layout order or (ii) ensure that layouts can happen in any order. (i) is how mobile frameworks and CSS (I think) accomplish this so whenever a parent is stale they re-run that layout and also check if the child layouts are stale. Maybe I can implement a different caching system just for the layout functions. (ii) might be possible thanks to the ownership properties on each bbox. Those are established on first render. (I haven't thought about what happens on subsequent renders when the graph might change, though.) After they're established, the layouts can run in any order it might just be faster or slower since layouts may have to run multiple times before converging. But I haven't implemented flexbox in this new system, so I'm not 100% sure if it works with the ownership model.
But yeah I was hoping there would be a more Solid-native way of ensuring this. I'll keep thinking about it.
Out of curiosity, do you know what breaks when you write signals from
createMemo
?
The docs say:
The memo function should not change other signals by calling setters (it should be "pure"). This enables Solid to optimize the execution order of memo updates according to their dependency graph, so that all memos can update at most once in response to a dependency change.Does that mean if I write signals but can ensure the memo won't run a second time in response to those changes, then it's safe? Alternatively, maybe there's a way to use
createComputed
for this?I think so... I think nothing actually breaks, it can just cause multiple updates to the same memo/whatever when one update would have sufficed. So might be a reasonable approach to consider.
Yeah, this is the current way to make instantly updating things that aren't memos (e.g. one change updates multiple signals), though memos are generally better because their ordering can be optimized more. I think the are other primitives coming some day... But these don't work (easily) if you want to wait until after a render.
Got it, thanks I'll look into those approaches then.
Maybe interesting for u @jmp3741 : https://github.com/bigmistqke/solid-canvas it uses https://primitives.solidjs.community/package/jsx-tokenizer under the hood. jsx-tokenizer allows you to return non-jsx elements inside jsx, which allows you to walk up and down the jsxtoken-tree, and have your children's order consistent w the jsx (not possible with context only). I use it in solid-canvas to do the render-loop. It gives you a lot of control over when and what you want to calculate, instead of relying on effect-order.
That looks very cool! I'm having some trouble understanding what the expressiveness of jsx-tokenizer is. What is an example of something I can't do with effects but that I can do with jsx-tokenizer? e.g. are you trying to walk the canvas tree in the same order every frame?
I think I'm running into race conditions with effects now. (The bug appears when I run my code normally but when I insert a debug statement it disappears.) Is that something that the tokenizer solves?
race condition was fake news...
e.g. are you trying to walk the canvas tree in the same order every frame?Exactly. So how it works: in solid your components are actually secretly able to return anything; it does not have to be dom-related at all. Typescript will complain at you, since it's not expected behavior, but there is nothing in solid's compilation that holds you from returning objects. is actually perfectly valid solid-code. With the above code you can run up the tree exactly as you want, and you don't have to count on effects running in the right order. The tree will always be up-to-date: p.ex if you add some jsx (like that Show with the count), the layout and render-functions will automatically run bc of the effect in Layout, and props.children will always give you the children in the correct order. jsx-tokenizer is a way to standardize this pattern, and offer some type-safety while using it. Currently with jsx-tokenizer we return an empty function-component to which we attach the return-value of the component-function, which can honestly be a bit finnicky to use. That might change soon to just a regular object, similar to code above, see https://github.com/solidjs-community/solid-primitives/issues/399.
GitHub
Issues · solidjs-community/solid-primitives
A library of high-quality primitives that extend SolidJS reactivity. - Issues · solidjs-community/solid-primitives
A lot of the behaviors of running up and down a component-tree can also be accomplished with context:
But then you lose control over the order of the children, since the order of the children-array is set by the order in which they mount/unmount and not the order of the JSX. In some situations this is fine, p.ex in #solid-three we are now going for that route since children-order does not matter in 3D, but in 2D it will be necessary, for z-order and relative layouts.
thanks that's super helpful! This route does seem very promising and I'll explore it once I start to hit some more roadblocks. thanks so much for clarifying
@bigmistqke I'm finally getting around to using tokens! So far everything has been mostly smooth, but I'm wondering how to use higher-order components with tokenized children? I wrap all my components in some contexts to implicitly pass & generate ids
I've tried something like this (as well as the commented out version)
WrappedComponent
is a token.
I get the "Tokens can only be rendered with resolveTokens" error.
I need the context here b/c I'm inspecting the name
prop before it is passed to the wrapped component.
other than this snag the tokens api has been really nice to work with!
I think I've figured it out... getting closer at leastpersonally i don't use jsx-tokenizer anymore these days. instead I just yolo and do
const Component = () => whatever as unknown as JSX.Element
and then read the data from props.children
. was too much abstraction and too finnicky.