skip to Main Content

I’m building a custom dropdown, where each option has an SVG icon and a label.
While the icon of the selected element is rendered initially, the SVG icon is removed upon expanding the select. Whenever a new option is selected, it’s rendered again until the select is expanded again. There’s no change to the value().icon state upon expansion.

Within the JSX there’s no condition applied to the svg in value().icon either:

      <button
        onClick={() => setExpanded((prev) => !prev)}
      >
          <span>{value().icon}</span>
          <span>{value().label}</span>
          <svg />
      </button>

Reproducible Example Playground

enter image description here

Based on browser debugging (breakpoint on node removal on the svg), I believe something odd happens around dom-expressions L78. The value in parent.appendChild(value) seems to correctly contain the svg on first selection (when it prevails), but also on expansion (when it’s removed), which I can’t make sense of.

2

Answers


  1. Your list is wrapped into a Show component, whenever state is updated whole list will re-created because hiding will destroy the previously rendered element.

    If you check the output code, your component logic is complicated and svg part ends up tied to the Show element even though svg part is pure:

    import { template as _$template } from "solid-js/web";
    import { delegateEvents as _$delegateEvents } from "solid-js/web";
    import { setAttribute as _$setAttribute } from "solid-js/web";
    import { effect as _$effect } from "solid-js/web";
    import { memo as _$memo } from "solid-js/web";
    import { createComponent as _$createComponent } from "solid-js/web";
    import { insert as _$insert } from "solid-js/web";
    const _tmpl$ = /*#__PURE__*/_$template(`<ul>`),
      _tmpl$2 = /*#__PURE__*/_$template(`<div><button><span></span><span></span><svg fill="#000000" height="1rem" width="1rem" viewBox="0 0 330 330"><path id="XMLID_225_" d="M325.607,79.393c-5.857-5.857-15.355-5.858-21.213,0.001l-139.39,139.393L25.607,79.393
        c-5.857-5.857-15.355-5.858-21.213,0.001c-5.858,5.858-5.858,15.355,0,21.213l150.004,150c2.813,2.813,6.628,4.393,10.606,4.393
        s7.794-1.581,10.606-4.394l149.996-150C331.465,94.749,331.465,85.251,325.607,79.393z">`),
      _tmpl$3 = /*#__PURE__*/_$template(`<li><button><span>`),
      _tmpl$4 = /*#__PURE__*/_$template(`<span>`),
      _tmpl$5 = /*#__PURE__*/_$template(`<svg height="1rem" width="1rem" viewBox="0 0 512 512"><g><polygon points="256,0 72.115,256 256,512 439.885,256     ">`);
    import { createSignal, For, JSXElement, Show } from "solid-js";
    import { render } from "solid-js/web";
    function Select(p) {
      const props = {
        selectedIndex: 0,
        ...p
      };
      const [value, setValue] = createSignal(props.options[props.selectedIndex]);
      const [expanded, setExpanded] = createSignal(false);
      return (() => {
        const _el$ = _tmpl$2(),
          _el$2 = _el$.firstChild,
          _el$3 = _el$2.firstChild,
          _el$4 = _el$3.nextSibling;
        _el$.$$focusout = e =>
        // don't process onFocusOut if the focus is in a child element (e.g., option button click)
        !e.currentTarget.contains(e.relatedTarget) && setExpanded(false);
        _el$2.$$click = () => setExpanded(prev => !prev);
        _$insert(_el$3, () => value().icon);
        _$insert(_el$4, () => value().label);
        _$insert(_el$, _$createComponent(Show, {
          get when() {
            return expanded();
          },
          get children() {
            const _el$5 = _tmpl$();
            _$insert(_el$5, _$createComponent(For, {
              get each() {
                return props.options;
              },
              children: option => (() => {
                const _el$6 = _tmpl$3(),
                  _el$7 = _el$6.firstChild,
                  _el$8 = _el$7.firstChild;
                _el$7.$$click = () => {
                  setValue(option);
                  setExpanded(false);
                };
                _$insert(_el$7, (() => {
                  const _c$ = _$memo(() => !!option.icon);
                  return () => _c$() && (() => {
                    const _el$9 = _tmpl$4();
                    _$insert(_el$9, () => option.icon);
                    return _el$9;
                  })();
                })(), _el$8);
                _$insert(_el$8, () => option.label);
                return _el$6;
              })()
            }));
            return _el$5;
          }
        }), null);
        return _el$;
      })();
    }
    function Icon(props) {
      return (() => {
        const _el$10 = _tmpl$5();
        _$effect(() => _$setAttribute(_el$10, "fill", props.color));
        return _el$10;
      })();
    }
    function App() {
      return _$createComponent(Select, {
        get options() {
          return [{
            icon: _$createComponent(Icon, {
              color: "#fc5614"
            }),
            label: "Red",
            value: "red"
          }, {
            icon: _$createComponent(Icon, {
              color: "#25b9e6"
            }),
            label: "Blue",
            value: "blue"
          }, {
            icon: _$createComponent(Icon, {
              color: "#79e625"
            }),
            label: "Green",
            value: "green"
          }];
        }
      });
    }
    render(() => _$createComponent(App, {}), document.getElementById("app"));
    _$delegateEvents(["focusout", "click"]);
    

    You need to refactor your code in a way that state update does not trigger re-render for the svg. You can move your Select logic into smaller, well contained sub-components which provides proper isolation and can take advantage of memos and untracks in case state update spill onto them.

    Solid does not use VDOM but compiles JSX into native DOM elements. The way it works is, Solid converts component’s html structure into a template. Whenever state gets updated, it clones this template, fills with with dynamic values by evaluating them, and re-insert it to its parent component.

    You can write any expression inside JSX. Chlid components are functions and they are compiled into function calls.

    If you take a look at the output code, you will see svg is compiled into _tmpl$5 and it is inserted into its parent under Show:

    $insert(_el$, _$createComponent(Show, {
     // Snipped for brevity 
    }))
    

    This means, whenever expanded value changes the children of Show component will be re-created and re-inserted.

    Normally you don’t expect svg ends up being a child to Show because it comes before the Show in the DOM hierarchy and appears outside of it. Your component logic unnecessary complex and convoluted causing some unexpected outcome, a it is pointed in the accepted answer.

    Don’t rush for returning an element, take you time, build your logic, tame your state, only then return the element.

    Here is an Select demo I wrote for another answer which might be helpful. It has basic functionality but can be improved easily:

    https://playground.solidjs.com/anonymous/e58974e7-287f-4f56-8ab3-33787d93c629

    Login or Signup to reply.
  2. Firstly, I would copy the code from the playground to that it’s reproducible in the future, but I added some comments in the code.

    import { createSignal, For, JSXElement, Show } from "solid-js";
    import { render } from "solid-js/web";
    
    function Select(p: {
      options: { label: string; value: string; icon?: JSXElement }[];
      selectedIndex?: number;
    }) {
      const props = {
        selectedIndex: 0,
        ...p,
      };
    
      const [value, setValue] = createSignal(props.options[props.selectedIndex]);
    
      const [expanded, setExpanded] = createSignal(false);
    
      return (
        <div
          onFocusOut={(e) => 
            // don't process onFocusOut if the focus is in a child element (e.g., option button click)
            !e.currentTarget.contains(e.relatedTarget as Node) && setExpanded(false)
          }
        >
          <button
            onClick={() => setExpanded((prev) => !prev)}
          >
              <span>{value().icon}</span> {/* try to append the icon */}
              <span>{value().label}</span>
              <svg
                fill="#000000"
                height="1rem"
                width="1rem"
                viewBox="0 0 330 330"
              >
                <path
                  id="XMLID_225_"
                  d="M325.607,79.393c-5.857-5.857-15.355-5.858-21.213,0.001l-139.39,139.393L25.607,79.393
        c-5.857-5.857-15.355-5.858-21.213,0.001c-5.858,5.858-5.858,15.355,0,21.213l150.004,150c2.813,2.813,6.628,4.393,10.606,4.393
        s7.794-1.581,10.606-4.394l149.996-150C331.465,94.749,331.465,85.251,325.607,79.393z"
                />
              </svg>
          </button>
          <Show when={expanded()}>
              <ul>
                <For each={props.options}>
                  {(option) => (
                    <li>
                      <button
                        onClick={() => {
                          setValue(option);
                          setExpanded(false);
                        }}
                      >
                        {option.icon && <span>{option.icon}</span>} {/* try to append the icon again */}
                        <span>{option.label}</span>
                      </button>
                    </li>
                  )}
                </For>
              </ul>
          </Show>
        </div>
      );
    }
    
    function Icon(props: {color: string}) {
      return (
        <svg
          fill={props.color}
          height="1rem"
          width="1rem"
          viewBox="0 0 512 512"
        >
          <g>
            <polygon points="256,0 72.115,256 256,512 439.885,256   " />
          </g>
        </svg>
      )
    }
    
    function App() {
      return (
        <Select
          options={[
            {
              icon: <Icon color="#fc5614" />,
              label: "Red",
              value: "red",
            },
            {
              icon: <Icon color="#25b9e6" />,
              label: "Blue",
              value: "blue",
            },
            {
              icon: <Icon color="#79e625" />,
              label: "Green",
              value: "green",
            },
          ]}
        />
      );
    }
    
    render(() => <App />, document.getElementById("app")!);
    

    In SolidJS, when you do for example const par = <p>hello</p> for example, par will refer to an actual DOM element (unlike in React which uses a virtual DOM node).

    So, the restrictions of a real DOM node applies. For example, if you try appending a node to multiple parents, parent1.appendChild(node); parent2.appendChild(node), the child node is not cloned, but simply moved to parent2. So, parent1 will not have the child because the child goes with parent2.

    In each call to App(), for each color there is only one <Icon/> instance.
    So effectively, when you show the options, what happens is that you have one DOM node that it tries to append to two different positions. But then the node can only appear in at most one place (because the node has at most one parent).

    A workaround is to not use a single element like icon?: JSXElement, but rather to use icon?: () => JSXElement which will generate separate elements as many times as it is called, along with appropriate changes in other places (e.g. icon: () => <Icon color="#fc5614" /> in App and <span>{value().icon?.()}</span> in Select).

    This restriction doesn’t apply to strings like value().value, probably because it is only converted to an actual DOM node much later (unlike JSX tags which are converted to actual DOM elements very soon in SolidJS).
    The restriction also doesn’t seem to apply to React, probably because it converts the virtual DOM nodes to real DOM pretty late (so something like 2{child}3{child}4{child} will not give you a weird behavior in React even when the child is a JSXElement, but it can be quite weird in SolidJS).

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search