S
SolidJS12mo ago
jmp

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:
<Parent>
<Child1 />
<Child2 />
</Parent>
<Parent>
<Child1 />
<Child2 />
</Parent>
And each of those components internally calls a special <Layout> component, e.g.:
const Parent = (props) => {
const layout = ...
const paint = ...

return <Layout layout={layout} paint={paint}>
{props.children}
</Layout>
}
const Parent = (props) => {
const layout = ...
const paint = ...

return <Layout layout={layout} paint={paint}>
{props.children}
</Layout>
}
Layout roughly looks like this:
const Layout = (props) => {
const [scenegraph, { getBBox, updateBBox, createNode }] = useScenegraph();

createNode(props.id);

createEffect(() => {
const bbox = props.layout(scenegraph[props.id].children);
updateBBox(bbox);
});

return <Dynamic component={props.paint} {...scenegraphArgs} />
}
const Layout = (props) => {
const [scenegraph, { getBBox, updateBBox, createNode }] = useScenegraph();

createNode(props.id);

createEffect(() => {
const bbox = props.layout(scenegraph[props.id].children);
updateBBox(bbox);
});

return <Dynamic component={props.paint} {...scenegraphArgs} />
}
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.:
(children) => {
const child0BBox = getBBox(children[0]); // getBBox needs to ensure that children[0]'s layout function has been run at this point
...
}
(children) => {
const child0BBox = getBBox(children[0]); // getBBox needs to ensure that children[0]'s layout function has been run at this point
...
}
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
jmp
jmp12mo ago
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.
Erik Demaine
Erik Demaine12mo ago
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:
const jsx = <>{props.children</>
createEffect(() => { /* after children effects */ })
const jsx = <>{props.children</>
createEffect(() => { /* after children effects */ })
Have you considered a context? Seems like it might be what you're looking for. @jmp3741
jmp
jmp12mo ago
Hi 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!
Erik Demaine
Erik Demaine12mo ago
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.
jmp
jmp12mo ago
Basically I have a store
type Scenegraph = { [key: Id]: ScenegraphNode; } // this is the store type
type Id = string;
type ScenegraphNode =
| {
type: "node";
bbox: BBox;
bboxOwners: BBoxOwners;
transform: Transform;
transformOwners: TransformOwners;
children: Set<Id>;
parent: Id | null;
}
| {
type: "ref";
refId: Id;
parent: Id | null;
};
type Scenegraph = { [key: Id]: ScenegraphNode; } // this is the store type
type Id = string;
type ScenegraphNode =
| {
type: "node";
bbox: BBox;
bboxOwners: BBoxOwners;
transform: Transform;
transformOwners: TransformOwners;
children: Set<Id>;
parent: Id | null;
}
| {
type: "ref";
refId: Id;
parent: Id | null;
};
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:
<AlignLeft ...>
<Rect width={50} height={50} y={0} ... />
<Rect ... />
</AlignLeft>
<AlignLeft ...>
<Rect width={50} height={50} y={0} ... />
<Rect ... />
</AlignLeft>
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:
const parentLayout = (children, { maxHeight, maxWidth }) => {
const [child0, ...] = children;
child0.layout({ maxHeight: 5000, maxWidth: 3000});
...
}
const parentLayout = (children, { maxHeight, maxWidth }) => {
const [child0, ...] = children;
child0.layout({ maxHeight: 5000, maxWidth: 3000});
...
}
But notice that this changes how layouts 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:
const parentLayout = (children, { maxHeight, maxWidth }) => {
for (const child of children) {
child.layout({...});
// do some stuff
}
const parentLayout = (children, { maxHeight, maxWidth }) => {
for (const child of children) {
child.layout({...});
// do some stuff
}
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.
const Child = () => {
const { maxWidth, maxHeight } = useDims(); // sort of like a media query

<Switch fallback={<></>}>
<Match when={maxWidth() > 1000}>
...
</Match>
<Match when={maxWidth() > 500}>
...
</Match>
</Switch>
}
const Child = () => {
const { maxWidth, maxHeight } = useDims(); // sort of like a media query

<Switch fallback={<></>}>
<Match when={maxWidth() > 1000}>
...
</Match>
<Match when={maxWidth() > 500}>
...
</Match>
</Switch>
}
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.
jmp
jmp12mo ago
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.
Erik Demaine
Erik Demaine12mo ago
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.
jmp
jmp12mo ago
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?
Erik Demaine
Erik Demaine12mo ago
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.
jmp
jmp12mo ago
Got it, thanks I'll look into those approaches then.
bigmistqke
bigmistqke12mo ago
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.
jmp
jmp12mo ago
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...
bigmistqke
bigmistqke12mo ago
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.
const LayoutNode = () => {
return {
render: () => {
props.children.forEach(children => children.render());
/*... do some render related stuff ... */
},
layout: () => {
props.children.forEach(children => children.layout());
/*... do some layout related stuff ... */
}
} // as unknown as JSX.Element if you want to satisfy the ts-gods
}

const Layout = (props: {children: JSX.Element[]}) => {
useEffect(() => props.children.forEach(child => child.render()));
useEffect(() => props.children.forEach(child => child.layout()));
return <></>
}

const App = () => {
return <Layout>
<Show when={count() > 5}>
<LayoutNode/>
</Show>
<LayoutNode>
<LayoutNode/>
</LayoutNode>
</Layout>
}
const LayoutNode = () => {
return {
render: () => {
props.children.forEach(children => children.render());
/*... do some render related stuff ... */
},
layout: () => {
props.children.forEach(children => children.layout());
/*... do some layout related stuff ... */
}
} // as unknown as JSX.Element if you want to satisfy the ts-gods
}

const Layout = (props: {children: JSX.Element[]}) => {
useEffect(() => props.children.forEach(child => child.render()));
useEffect(() => props.children.forEach(child => child.layout()));
return <></>
}

const App = () => {
return <Layout>
<Show when={count() > 5}>
<LayoutNode/>
</Show>
<LayoutNode>
<LayoutNode/>
</LayoutNode>
</Layout>
}
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
bigmistqke
bigmistqke12mo ago
A lot of the behaviors of running up and down a component-tree can also be accomplished with context:
const Context = createContext();
const LayoutNode = () => {
const [children, setChildren] = createSignal([]);
const {add, remove} = useContext(Context);
const token = {
render: () => {
children().forEach(child => child.render()));
/*... do some render related stuff ... */
}
}
onMount(() => add(token));
onCleanup(() => remove(token));

return <Context.Provider value={{
add: (ref) => setChildren(c => [...c, ref]),
remove: (ref) => setChildren(c => c.filter(v => v !== ref))
}}>
{props.children}
</Context.Provider>
}
const Layout = (props) => {
const [children, setChildren] = createSignal([]);
useEffect(() => props.children.forEach(child => child.render()));

return <Context.Provider value={{
add: (ref) => setChildren(c => [...c, ref]),
remove: (ref) => setChildren(c => c.filter(v => v !== ref))
}}>
{props.children}
</Context.Provider>
}
const Context = createContext();
const LayoutNode = () => {
const [children, setChildren] = createSignal([]);
const {add, remove} = useContext(Context);
const token = {
render: () => {
children().forEach(child => child.render()));
/*... do some render related stuff ... */
}
}
onMount(() => add(token));
onCleanup(() => remove(token));

return <Context.Provider value={{
add: (ref) => setChildren(c => [...c, ref]),
remove: (ref) => setChildren(c => c.filter(v => v !== ref))
}}>
{props.children}
</Context.Provider>
}
const Layout = (props) => {
const [children, setChildren] = createSignal([]);
useEffect(() => props.children.forEach(child => child.render()));

return <Context.Provider value={{
add: (ref) => setChildren(c => [...c, ref]),
remove: (ref) => setChildren(c => c.filter(v => v !== ref))
}}>
{props.children}
</Context.Provider>
}
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.
jmp
jmp2mo ago
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)
return (
<ParentScopeIdContext.Provider value={scopeId}>
<IdContext.Provider value={id}>
{/* {untrack(() => {
const tokens = resolveTokens(ScenegraphTokenizer, () => (
// <Dynamic
// component={WrappedComponent}
// {...(props as WithBluefishProps<ComponentProps>)}
// name={id()}
// />

));

return tokens();
})} */}
<WrappedComponent
{...(props as WithBluefishProps<ComponentProps>)}
name={id()}
/>
</IdContext.Provider>
</ParentScopeIdContext.Provider>
);
return (
<ParentScopeIdContext.Provider value={scopeId}>
<IdContext.Provider value={id}>
{/* {untrack(() => {
const tokens = resolveTokens(ScenegraphTokenizer, () => (
// <Dynamic
// component={WrappedComponent}
// {...(props as WithBluefishProps<ComponentProps>)}
// name={id()}
// />

));

return tokens();
})} */}
<WrappedComponent
{...(props as WithBluefishProps<ComponentProps>)}
name={id()}
/>
</IdContext.Provider>
</ParentScopeIdContext.Provider>
);
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 least
bigmistqke
bigmistqke2mo ago
personally 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.