Uniview

Solid

The Solid binding — and three traps that will bite you

@uniview/tui-solid is feature-for-feature equivalent to @uniview/tui-react: same components, same props, same output. What differs is the reactivity model — and three things that are genuinely easy to get wrong.

Mount once

The React demo keeps mutable state and calls root.render(...) again after every key. The Solid one mounts a single time; a signal write is enough, and only the panels that actually changed are repainted.

const [focused, setFocused] = createSignal(2);

root.render(() => <App focused={focused()} />);   // once

// later, from a key handler — no rerender call anywhere:
setFocused(3);

Resize needs no re-render either: renderer.resize() invalidates layout and schedules its own repaint, and the component tree is untouched.

Trap 1 — never write literal <text> JSX

solid-js ships an SVG text intrinsic, and an explicit member beats the renderer's catch-all index signature. So <text> is type-checked against SVG attributes, not ours.

<text color="red">nope</text>          {/* ❌ resolves to solid-js's SVG <text> */}
<Text color="red">yes</Text>           {/* ✅ the exported component */}

<box> and <richtext> have no such collision, but the capitalized components are the supported surface.

Trap 2 — never destructure props

Destructuring reads the value once and freezes it — the component stops being reactive. This is not a style rule; a mutation test in the suite fails if you break it.

// ❌ focused is captured at creation and never updates
function Panel({ title, focused }: PanelProps) { … }

// ✅
function Panel(props: PanelProps) {
  const [local, rest] = splitProps(props, ["title", "focused"]);
  return <box {...rest} borderColor={local.focused ? "green" : undefined} />;
}

Trap 3 — tsx/esbuild cannot compile this

Solid JSX has to go through babel-preset-solid targeting Uniview's universal renderer. esbuild — which tsx uses — only knows the React-style transform and would emit React.createElement-shaped calls. Use the public Vite helper, which configures the transform and the development/browser conditions for you:

vite.config.ts
import { univiewSolid } from "@uniview/tui-solid/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [univiewSolid()],
});

Keep JSX preserved in TypeScript and load the public JSX augmentation. The compiled output is then resolved through @uniview/tui-solid/renderer, not an internal package path.

tsconfig.json
{
  "compilerOptions": {
    "jsx": "preserve",
    "jsxImportSource": "solid-js",
    "module": "preserve",
    "moduleResolution": "bundler",
    "strict": true
  },
  "include": ["src", "vite.config.ts"]
}
package.json
{ "scripts": { "dev": "vite-node src/main.tsx" } }

The compiled JSX imports the public @uniview/tui-solid/renderer entry. It does not require an internal Uniview renderer package. Add the public JSX augmentation once so TypeScript sees the terminal intrinsic elements:

src/uniview-jsx.d.ts
import type {} from "@uniview/tui-solid/jsx-runtime";

The helper deduplicates solid-js, keeps the renderer in Vite's SSR graph, and sets the required conditions. Add vite, vite-node, TypeScript, and @types/node as normal development tooling; they are not terminal-app runtime dependencies. The supported range is Vite 5.4.21 through 8.1.5: the mandatory pre-packaging gate runs both Vite 5.4.21 Solid examples, including a signal-driven second frame, and the packed-consumer gate runs Vite 8.1.5 / vite-node 6.0.0 on its supported Node versions.

Reactivity notes

  • useStatecreateSignal, useMemocreateMemo (no dependency array — Solid tracks reads itself), React.memo → drop it.
  • Lists use <For> / <Index>; there is no key prop.
  • Hoverable's render-prop child receives an Accessor<boolean>, so call it: hovered().

A controlled list still needs the "last requested" trick

List is controlled: selectedIndex only becomes the new prop once the parent commits. If the parent commits asynchronously — a selection owned by a host and echoed back over RPC, say — two arrow keys in one tick would both read the stale index and collapse into a single step. List therefore tracks the last requested index internally.

This is easy to dismiss as a React artifact. It is not: Solid compiles selectedIndex={sel()} to a live getter, so a synchronous parent does update in time — which means the naive test passes and the bug hides until the parent is async. List handles it for you; if you write your own controlled widget, do the same.

On this page