pyreon

@pyreon/solid-compat provides a SolidJS-compatible API -- createSignal, createEffect, createMemo, control flow components, and more -- all running on Pyreon's reactive engine. Since Pyreon and Solid share the same mental model (fine-grained reactivity, components run once, getter/setter signals), this compatibility layer is particularly thin.

@pyreon/solid-compatstable

Installation

npm install @pyreon/solid-compat
bun add @pyreon/solid-compat
pnpm add @pyreon/solid-compat
yarn add @pyreon/solid-compat

Quick Start

Replace your Solid imports:

// Before
import { createSignal, createEffect, createMemo, Show, For } from 'solid-js'

// After
import { createSignal, createEffect, createMemo, Show, For } from '@pyreon/solid-compat'
import { createSignal, createEffect, Show } from '@pyreon/solid-compat'

const Counter = () => {
  const [count, setCount] = createSignal(0)

  createEffect(() => {
    document.title = `Count: ${count()}`
  })

  return (
    <div>
      <p>Count: {count()}</p>
      <button onClick={() => setCount((prev) => prev + 1)}>+1</button>
      <Show when={() => count() > 5}>
        <p>High count!</p>
      </Show>
    </div>
  )
}

Key Differences from SolidJS

Since Pyreon and Solid share the same reactive paradigm, the API is nearly identical. The main differences are internal:

BehaviorSolidJS@pyreon/solid-compat
Reactive engineSolid's own signal implementationPyreon's @pyreon/reactivity
createComputedDeprecated legacy APIAlias for createEffect
createRenderEffectRuns before DOM paintSame as createEffect -- no paint distinction in Pyreon
on()Explicit dependency helperSupported -- returns a function for use inside createEffect
mergeProps / splitPropsPreserves reactive gettersSame behavior -- preserves property descriptors
Control flow<Show>, <For>, <Switch>, <Match>Re-exported from @pyreon/core -- same API
createStore / produceFrom solid-js/storeUse @pyreon/reactivity's createStore and reconcile
TransitionsuseTransition, startTransitionNot available -- updates are synchronous
createResourceBuilt-in async primitiveUse @pyreon/reactivity's createResource

Why This Layer Is Thin

Solid and Pyreon share the same fundamental design:

  1. Components run once -- the function body is setup, not a render function

  2. Signals are getter/setter pairs -- const [value, setValue] = createSignal(0)

  3. Effects auto-track dependencies -- no deps arrays needed

  4. Control flow is component-based -- <Show>, <For>, <Switch>, <Match>

This means most Solid code works with Pyreon after a simple import swap. The compatibility layer is primarily mapping Solid's API names to Pyreon's underlying primitives.

API Reference

Signals

createSignal

function createSignal<T>(initialValue: T): [SignalGetter<T>, SignalSetter<T>]

type SignalGetter<T> = () => T
type SignalSetter<T> = (v: T | ((prev: T) => T)) => void

Creates a reactive signal. Returns a [getter, setter] tuple -- the same pattern as SolidJS.

const [count, setCount] = createSignal(0)

count() // read (tracked)
setCount(5) // set directly
setCount((prev) => prev + 1) // updater function

Common signal patterns:

// Boolean toggle
const [open, setOpen] = createSignal(false)
const toggle = () => setOpen((prev) => !prev)

// Array state
const [items, setItems] = createSignal<string[]>([])
const addItem = (item: string) => setItems((prev) => [...prev, item])
const removeItem = (index: number) => setItems((prev) => prev.filter((_, i) => i !== index))

// Object state
const [user, setUser] = createSignal<User | null>(null)
const updateName = (name: string) => setUser((prev) => (prev ? { ...prev, name } : null))

// Derived state via createMemo
const [firstName, setFirstName] = createSignal('Alice')
const [lastName, setLastName] = createSignal('Smith')
const fullName = createMemo(() => `${firstName()} ${lastName()}`)

Signal as a reactive data source for components:

// Parent passes a signal getter to child -- child reads it reactively
function Parent() {
  const [count, setCount] = createSignal(0)
  return (
    <div>
      <Display count={count} />
      <button onClick={() => setCount((prev) => prev + 1)}>+1</button>
    </div>
  )
}

function Display(props: { count: () => number }) {
  return <span>Count: {props.count()}</span>
}

Effects

createEffect

function createEffect(fn: () => void): void

Runs fn immediately and re-runs it whenever any signal read inside fn changes. Backed by Pyreon's effect().

createEffect(() => {
  console.log('Count changed to', count())
})

Effect with DOM manipulation:

function AutoScroll(props: { messages: () => Message[] }) {
  let containerRef: HTMLDivElement | undefined

  createEffect(() => {
    // Read messages to track changes
    const msgs = props.messages()
    // Scroll to bottom after new messages
    if (containerRef) {
      containerRef.scrollTop = containerRef.scrollHeight
    }
  })

  return (
    <div ref={containerRef} class="messages">
      {() => props.messages().map((m) => <p>{m.text}</p>)}
    </div>
  )
}

Effect with cleanup pattern:

function WebSocketComponent(props: { url: () => string }) {
  const [messages, setMessages] = createSignal<string[]>([])

  createEffect(() => {
    const wsUrl = props.url()
    const ws = new WebSocket(wsUrl)

    ws.onmessage = (e) => {
      setMessages((prev) => [...prev, e.data])
    }

    // In Solid, onCleanup is used inside createEffect
    // In Pyreon, use onCleanup (which maps to onUnmount)
    onCleanup(() => ws.close())
  })

  return <ul>{() => messages().map((m) => <li>{m}</li>)}</ul>
}

createRenderEffect

function createRenderEffect(fn: () => void): void

Identical to createEffect in Pyreon. Solid distinguishes render effects (run before DOM paint) from regular effects (run after); Pyreon does not make this distinction.

// In Solid, createRenderEffect runs synchronously before paint
// In Pyreon, behaves identically to createEffect
createRenderEffect(() => {
  document.title = `${count()} items`
})

createComputed

Alias for createEffect. This is Solid's deprecated legacy API, kept for compatibility with older Solid codebases.

Derived Values

createMemo

function createMemo<T>(fn: () => T): () => T

Creates a computed derived value. Returns a getter function. The computation is lazy and cached -- it only re-evaluates when its tracked dependencies change.

const [count, setCount] = createSignal(3)
const doubled = createMemo(() => count() * 2)

doubled() // 6
setCount(10)
doubled() // 20

Complex derived state:

const [todos, setTodos] = createSignal<Todo[]>([])
const [filter, setFilter] = createSignal<'all' | 'active' | 'done'>('all')

const filteredTodos = createMemo(() => {
  const list = todos()
  switch (filter()) {
    case 'active':
      return list.filter((t) => !t.done)
    case 'done':
      return list.filter((t) => t.done)
    default:
      return list
  }
})

const stats = createMemo(() => ({
  total: todos().length,
  active: todos().filter((t) => !t.done).length,
  done: todos().filter((t) => t.done).length,
}))

Chained memos:

const [products, setProducts] = createSignal<Product[]>([])
const [search, setSearch] = createSignal('')
const [sort, setSort] = createSignal<'name' | 'price'>('name')
const [page, setPage] = createSignal(0)

// Each memo only recalculates when its direct deps change
const searched = createMemo(() => {
  const q = search().toLowerCase()
  return q ? products().filter((p) => p.name.toLowerCase().includes(q)) : products()
})

const sorted = createMemo(() => {
  const key = sort()
  return [...searched()].sort((a, b) => (a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0))
})

const paginated = createMemo(() => {
  const start = page() * 20
  return sorted().slice(start, start + 20)
})

const totalPages = createMemo(() => Math.ceil(sorted().length / 20))

Ownership and Scoping

createRoot

function createRoot<T>(fn: (dispose: () => void) => T): T

Creates a new reactive scope. The dispose callback stops all effects and computations created within the scope. Essential for top-level reactive code outside of components.

createRoot((dispose) => {
  const [count, setCount] = createSignal(0)
  createEffect(() => console.log(count()))

  // Later: stop all tracking
  setTimeout(dispose, 5000)
})

Top-level reactive store:

// Global reactive state -- must be wrapped in createRoot
const appState = createRoot((dispose) => {
  const [user, setUser] = createSignal<User | null>(null)
  const [theme, setTheme] = createSignal<'light' | 'dark'>('light')

  const isLoggedIn = createMemo(() => user() !== null)

  return {
    user,
    setUser,
    theme,
    setTheme,
    isLoggedIn,
    dispose, // for cleanup
  }
})

// Use in components
function Header() {
  return (
    <header class={() => appState.theme()}>
      <Show when={appState.isLoggedIn} fallback={<LoginButton />}>
        <UserMenu user={appState.user} />
      </Show>
    </header>
  )
}

getOwner / runWithOwner

function getOwner(): EffectScope | null
function runWithOwner<T>(owner: EffectScope | null, fn: () => T): T

Capture the current reactive scope and run code within it later. Useful for effects created asynchronously (e.g., after an await).

const owner = getOwner()

setTimeout(() => {
  // Without runWithOwner, this effect would be unowned (leaked)
  runWithOwner(owner, () => {
    createEffect(() => console.log('Still tracked in the original scope!'))
  })
}, 1000)

Async data loading with owner preservation:

function AsyncLoader(props: { url: () => string }) {
  const [data, setData] = createSignal<any>(null)
  const owner = getOwner()

  createEffect(() => {
    const url = props.url()

    fetch(url)
      .then((r) => r.json())
      .then((result) => {
        // Run in the component's scope so effects are properly tracked
        runWithOwner(owner, () => {
          setData(result)
        })
      })
  })

  return () => (data() ? <DataView data={data} /> : <Loading />)
}

Explicit Dependency Tracking

on

function on<S, V>(
  deps: (() => S) | readonly (() => S)[],
  fn: (input: S, prevInput: S | undefined, prev: V | undefined) => V,
): () => V | undefined

Creates an explicit dependency tracker. Returns a function that, when called inside createEffect, tracks only the specified deps and runs fn with the current input, previous input, and previous return value.

const [a, setA] = createSignal(1)
const [b, setB] = createSignal(10)

// Only tracks `a` -- changes to `b` do not re-run the effect
createEffect(
  on(a, (value, prev) => {
    console.log(`a changed from ${prev} to ${value}`)
    console.log('b is', b()) // reading b does NOT create a dependency
  }),
)

setA(2) // effect runs
setB(20) // effect does NOT run

Multiple dependencies:

const [firstName, setFirstName] = createSignal('Alice')
const [lastName, setLastName] = createSignal('Smith')

createEffect(
  on([firstName, lastName], (values, prevValues) => {
    const [first, last] = values
    console.log(`Name: ${first} ${last}`)
    if (prevValues) {
      console.log(`Was: ${prevValues[0]} ${prevValues[1]}`)
    }
  }),
)

Using on with accumulator pattern:

const [count, setCount] = createSignal(0)

createEffect(
  on(count, (value, _prev, accumulator) => {
    const sum = (accumulator ?? 0) + value
    console.log(`Running sum: ${sum}`)
    return sum // returned value becomes `accumulator` on next run
  }),
)

untrack

function untrack<T>(fn: () => T): T

Runs fn without tracking any signal reads. Maps to Pyreon's runUntracked.

createEffect(() => {
  const tracked = count()
  const untracked = untrack(() => other())
  // Only re-runs when count() changes, not other()
  console.log(tracked, untracked)
})

Common untrack patterns:

// Log current state without tracking it
createEffect(() => {
  const newValue = count()
  untrack(() => {
    console.log('Previous state snapshot:', {
      items: items(),
      filter: filter(),
    })
  })
  console.log('Count is now:', newValue)
})

// Read a config value once
createEffect(() => {
  const data = fetchedData()
  const config = untrack(() => appConfig())
  process(data, config) // only re-runs when fetchedData changes
})

Batching

batch

function batch<T>(fn: () => T): T

Groups multiple signal writes into a single reactive flush. Prevents intermediate states from triggering effects.

batch(() => {
  setName('Alice')
  setAge(30)
  setRole('admin')
})
// Effects see all three changes at once

Batch in event handlers:

function TodoItem(props: { todo: Todo; onToggle: () => void }) {
  return (
    <div
      onClick={() => {
        batch(() => {
          props.onToggle()
          setLastAction('toggle')
          setLastActionTime(Date.now())
        })
      }}
    >
      {props.todo.text}
    </div>
  )
}

Lifecycle

onMount

function onMount(fn: () => void): void

Runs fn once after the component is mounted to the DOM. Direct re-export of Pyreon's onMount.

function AutoFocus() {
  let inputRef: HTMLInputElement | undefined

  onMount(() => {
    inputRef?.focus()
  })

  return <input ref={inputRef} />
}

onMount with cleanup:

function WindowSize() {
  const [size, setSize] = createSignal({ width: 0, height: 0 })

  onMount(() => {
    const update = () =>
      setSize({
        width: window.innerWidth,
        height: window.innerHeight,
      })
    update()
    window.addEventListener('resize', update)

    // In Pyreon, onMount can return a cleanup function
    return () => window.removeEventListener('resize', update)
  })

  return (
    <p>
      Window: {() => size().width}x{() => size().height}
    </p>
  )
}

onCleanup

function onCleanup(fn: () => void): void

Registers a cleanup function that runs when the component is unmounted. Maps to Pyreon's onUnmount.

const Timer = () => {
  const [elapsed, setElapsed] = createSignal(0)

  onMount(() => {
    const id = setInterval(() => setElapsed((prev) => prev + 1), 1000)
    onCleanup(() => clearInterval(id))
  })

  return <p>Elapsed: {elapsed()}s</p>
}

Cleanup for external subscriptions:

function EventSource(props: { channel: string }) {
  const [events, setEvents] = createSignal<Event[]>([])

  const es = new window.EventSource(`/api/events/${props.channel}`)
  es.onmessage = (e) => setEvents((prev) => [...prev, JSON.parse(e.data)])

  onCleanup(() => es.close())

  return (
    <ul>
      {() =>
        events().map((e) => (
          <li>
            {e.type}: {e.data}
          </li>
        ))
      }
    </ul>
  )
}

Selection

createSelector

function createSelector<T>(source: () => T): (key: T) => boolean

Creates an O(1) selector. Returns a function that returns true when key equals the current source value. Only the previously-selected and newly-selected keys are notified on change -- ideal for large lists.

const [selectedId, setSelectedId] = createSignal(1)
const isSelected = createSelector(() => selectedId())

isSelected(1) // true
isSelected(2) // false

setSelectedId(2)
isSelected(1) // false
isSelected(2) // true
// Only the effects for id=1 and id=2 re-ran

Selectable list:

function SelectableList(props: { items: () => Item[] }) {
  const [selectedId, setSelectedId] = createSignal<number | null>(null)
  const isSelected = createSelector(() => selectedId())

  return (
    <ul>
      <For each={props.items} by={(item) => item.id}>
        {(item) => (
          <li
            class={() => (isSelected(item.id) ? 'selected' : '')}
            onClick={() => setSelectedId(item.id)}
          >
            {item.name}
          </li>
        )}
      </For>
    </ul>
  )
}

Props Utilities

mergeProps

function mergeProps<T extends object[]>(...sources: [...T]): T[number]

Merges multiple prop objects into one. Preserves reactive getters (property descriptors) from source objects. Later sources override earlier ones.

const defaults = { color: 'red', size: 10, weight: 'normal' }
const overrides = { size: 20, weight: 'bold' }
const merged = mergeProps(defaults, overrides)
// { color: 'red', size: 20, weight: 'bold' }

Using mergeProps for default props:

function Button(rawProps: {
  variant?: 'primary' | 'secondary'
  size?: 'sm' | 'md' | 'lg'
  disabled?: boolean
  children?: VNodeChild
  onClick?: (e: MouseEvent) => void
}) {
  const props = mergeProps({ variant: 'primary', size: 'md', disabled: false } as const, rawProps)

  return (
    <button
      class={() => `btn btn-${props.variant} btn-${props.size}`}
      disabled={props.disabled}
      onClick={props.onClick}
    >
      {props.children}
    </button>
  )
}

Preserving reactive getters:

const dynamicProps = {
  get class() {
    return isActive() ? 'active' : 'inactive'
  },
  get disabled() {
    return isLoading()
  },
}

const merged = mergeProps({ class: 'default' }, dynamicProps)
// merged.class reads the getter, returning the reactive value

splitProps

function splitProps<T, K extends (keyof T)[]>(
  props: T,
  ...keys: K
): [Pick<T, K[number]>, Omit<T, K[number]>]

Splits props into two objects: one with the specified keys, one with everything else. Preserves reactive getters.

function Input(allProps: {
  label: string
  error?: string
  value: string
  onInput: (e: InputEvent) => void
  class?: string
  id?: string
}) {
  const [local, inputProps] = splitProps(allProps, 'label', 'error')

  return (
    <div class="form-field">
      <label>{local.label}</label>
      <input {...inputProps} />
      <Show when={() => local.error}>
        <span class="error">{local.error}</span>
      </Show>
    </div>
  )
}

Splitting multiple groups:

function Card(props: {
  title: string
  subtitle?: string
  padding?: string
  margin?: string
  class?: string
  onClick?: () => void
  children?: VNodeChild
}) {
  const [content, style, rest] = [
    { title: props.title, subtitle: props.subtitle, children: props.children },
    { padding: props.padding, margin: props.margin },
    { class: props.class, onClick: props.onClick },
  ]

  return (
    <div {...rest} style={() => `padding:${style.padding};margin:${style.margin}`}>
      <h3>{content.title}</h3>
      {content.subtitle && <p class="subtitle">{content.subtitle}</p>}
      {content.children}
    </div>
  )
}

Children Helper

children

function children(fn: () => VNodeChild): () => VNodeChild

Resolves and memoizes children. Useful when you need to iterate over or inspect child elements. The returned accessor resolves any function children (reactive getters) into their values.

function List(props: { children: VNodeChild }) {
  const resolved = children(() => props.children)

  createEffect(() => {
    const items = resolved()
    console.log('Child count:', Array.isArray(items) ? items.length : 1)
  })

  return <ul>{resolved()}</ul>
}

Filtering and transforming children:

function FilteredSlot(props: { type: string; children: VNodeChild }) {
  const resolved = children(() => props.children)

  const filtered = createMemo(() => {
    const items = resolved()
    if (!Array.isArray(items)) return items
    return items.filter(
      (item) => typeof item === 'object' && item !== null && (item as any).type === props.type,
    )
  })

  return <div>{filtered()}</div>
}

Lazy Loading

lazy

function lazy<P>(
  loader: () => Promise<{ default: ComponentFn<P> }>,
): ComponentFn<P> & { preload: () => Promise<{ default: ComponentFn<P> }> }

Wraps a dynamic import for code splitting. The returned component renders null until the module resolves. Call .preload() to start loading before the component is rendered.

const Dashboard = lazy(() => import('./Dashboard'))
const Settings = lazy(() => import('./Settings'))

// Preload on hover
function NavLink(props: { href: string; label: string; component: { preload: () => void } }) {
  return (
    <a href={props.href} onMouseEnter={() => props.component.preload()}>
      {props.label}
    </a>
  )
}

// Render with Suspense
;<Suspense fallback={<div class="skeleton" />}>
  <Switch>
    <Match when={() => page() === 'dashboard'}>
      <Dashboard />
    </Match>
    <Match when={() => page() === 'settings'}>
      <Settings />
    </Match>
  </Switch>
</Suspense>

Context

createContext / useContext

function createContext<T>(defaultValue: T): Context<T>
function useContext<T>(ctx: Context<T>): T

Re-exports from @pyreon/core. Same API as SolidJS.

const CounterContext = createContext({ count: () => 0, increment: () => {} })

function CounterProvider(props: { children: VNodeChild }) {
  const [count, setCount] = createSignal(0)

  const value = {
    count,
    increment: () => setCount((prev) => prev + 1),
  }

  return withContext(CounterContext, value, () => props.children)
}

function CounterDisplay() {
  const { count, increment } = useContext(CounterContext)

  return (
    <div>
      <p>Count: {count()}</p>
      <button onClick={increment}>+1</button>
    </div>
  )
}

Control Flow Components

The following are re-exported from @pyreon/core and work identically to their SolidJS counterparts.

Show

Conditionally renders children when when is truthy. Optionally renders a fallback when falsy.

<Show when={() => user()} fallback={<LoginPage />}>
  <Dashboard user={user} />
</Show>

// Nested conditions
<Show when={() => isAuthenticated()}>
  <Show when={() => hasPermission('admin')} fallback={<AccessDenied />}>
    <AdminPanel />
  </Show>
</Show>

For

Renders a list reactively with keyed reconciliation.

<For each={() => items()} by={(item) => item.id}>
  {(item) => (
    <div class="item">
      <span>{item.name}</span>
      <span>${item.price}</span>
    </div>
  )}
</For>

For with complex items:

function UserTable(props: { users: () => User[] }) {
  const [sortBy, setSortBy] = createSignal<keyof User>('name')

  const sortedUsers = createMemo(() =>
    [...props.users()].sort((a, b) => String(a[sortBy()]).localeCompare(String(b[sortBy()]))),
  )

  return (
    <table>
      <thead>
        <tr>
          <th onClick={() => setSortBy('name')}>Name</th>
          <th onClick={() => setSortBy('email')}>Email</th>
          <th onClick={() => setSortBy('role')}>Role</th>
        </tr>
      </thead>
      <tbody>
        <For each={sortedUsers} by={(u) => u.id}>
          {(user) => (
            <tr>
              <td>{user.name}</td>
              <td>{user.email}</td>
              <td>{user.role}</td>
            </tr>
          )}
        </For>
      </tbody>
    </table>
  )
}

Switch / Match

Multi-branch conditional rendering. Evaluates Match children in order; renders the first whose when() is truthy.

<Switch fallback={<p>Unknown status</p>}>
  <Match when={() => status() === 'loading'}>
    <Spinner />
  </Match>
  <Match when={() => status() === 'error'}>
    <ErrorMessage error={error} />
  </Match>
  <Match when={() => status() === 'ready'}>
    <Content data={data} />
  </Match>
</Switch>

Route-like pattern:

function Router() {
  const [path, setPath] = createSignal(window.location.pathname)

  onMount(() => {
    const handler = () => setPath(window.location.pathname)
    window.addEventListener('popstate', handler)
    return () => window.removeEventListener('popstate', handler)
  })

  return (
    <Switch fallback={<NotFound />}>
      <Match when={() => path() === '/'}>
        <Home />
      </Match>
      <Match when={() => path() === '/about'}>
        <About />
      </Match>
      <Match when={() => path().startsWith('/user/')}>
        <UserProfile id={() => path().split('/')[2]} />
      </Match>
    </Switch>
  )
}

Suspense

Shows a fallback while async children resolve.

<Suspense fallback={<LoadingSkeleton />}>
  <AsyncDashboard />
</Suspense>

ErrorBoundary

Catches errors in its subtree and renders a fallback.

<ErrorBoundary
  fallback={(err, reset) => (
    <div class="error">
      <p>Error: {String(err)}</p>
      <button onClick={reset}>Retry</button>
    </div>
  )}
>
  <UnstableComponent />
</ErrorBoundary>

Dynamic Components

Dynamic

function Dynamic<P>(props: { component: ComponentFn<P> | string } & P): VNode

A faithful thin re-export of @pyreon/core's Dynamic. Solid's <Dynamic component={X} {...rest} /> maps 1/* zero-content: unhandled mdast node "textDirective" */ onto Pyreon's Dynamic({ component, ...rest }) -- both take a component prop (a component function OR a string tag name) and forward every other prop through. No shimming is involved.

import { Dynamic } from '@pyreon/solid-compat'

function App(props: { as: 'h1' | 'h2' }) {
  // Renders <h1> or <h2> depending on props.as
  return (
    <Dynamic component={props.as} class="title">
      Hello
    </Dynamic>
  )
}

Component reference also works:

const Red = (p: { children?: unknown }) => <span style="color:red">{p.children}</span>
const Blue = (p: { children?: unknown }) => <span style="color:blue">{p.children}</span>

;<Dynamic component={isError() ? Red : Blue}>status</Dynamic>

Portals

Portal

function Portal(props: {
  mount?: Element
  useShadow?: boolean
  isSVG?: boolean
  children: VNodeChild
}): VNode

Renders children into a different DOM node than the current parent tree -- modals, tooltips, dropdowns, overlays. Solid's API is <Portal mount={el} useShadow={bool} isSVG={bool}>. This shim maps Solid's mount prop onto Pyreon's Portal target prop. When mount is omitted, it defaults to document.body -- matching Solid's default.

import { Portal } from '@pyreon/solid-compat'

function Modal(props: { onClose: () => void }) {
  // Renders at document.body level regardless of where <Modal> sits
  return (
    <Portal>
      <div class="backdrop" onClick={props.onClose}>
        ...
      </div>
    </Portal>
  )
}

Explicit mount target:

const host = document.getElementById('overlay-root')!

;<Portal mount={host}>
  <Tooltip />
</Portal>

Rendering

These correspond to Solid's solid-js/web entry point. @pyreon/solid-compat exposes them from the main entry -- there is no /web subpath, so import them directly from @pyreon/solid-compat.

render

function render(code: () => VNodeChild, element: MountableElement): () => void

Mounts an app into a DOM element and returns a dispose function. Solid's signature is render(code: () => JSX.Element, element): () => void. The code thunk is passed directly to Pyreon's mount -- VNodeChild includes the accessor form () => VNodeChildAtom, so the thunk is a valid reactive root child (it re-evaluates on signal change). mount returns its own unmount/dispose function, which is returned verbatim -- calling it removes everything and disposes effects, matching Solid's contract.

import { render } from '@pyreon/solid-compat'
import { createSignal } from '@pyreon/solid-compat'

function Counter() {
  const [n, setN] = createSignal(0)
  return <button onClick={() => setN(n() + 1)}>{n()}</button>
}

const dispose = render(() => <Counter />, document.getElementById('app')!)
// later: dispose() -- unmounts and cleans up

hydrate

function hydrate(code: () => VNodeChild, element: MountableElement): () => void

Hydrates server-rendered markup in element and returns a dispose function. Solid's signature is hydrate(code: () => JSX.Element, element): () => void. Maps onto @pyreon/runtime-dom's hydrateRoot(container, vnode), which itself returns a dispose function (returned verbatim here, matching Solid's contract). As with render, the code thunk is a valid reactive root child.

import { hydrate } from '@pyreon/solid-compat'

// Server emitted #app's HTML; reuse the DOM instead of re-creating it:
const dispose = hydrate(() => <App />, document.getElementById('app')!)

MountableElement

type MountableElement = Element

The DOM node type a Solid app can mount into -- the second argument to render / hydrate.

import { render, type MountableElement } from '@pyreon/solid-compat'

function mountApp(root: MountableElement) {
  return render(() => <App />, root)
}

Real-World Patterns

Reactive Todo App

import {
  createSignal,
  createMemo,
  createEffect,
  batch,
  Show,
  For,
  Switch,
  Match,
} from '@pyreon/solid-compat'

interface Todo {
  id: number
  text: string
  done: boolean
}

function TodoApp() {
  const [todos, setTodos] = createSignal<Todo[]>([])
  const [filter, setFilter] = createSignal<'all' | 'active' | 'done'>('all')
  const [input, setInput] = createSignal('')
  let nextId = 1

  const filteredTodos = createMemo(() => {
    switch (filter()) {
      case 'active':
        return todos().filter((t) => !t.done)
      case 'done':
        return todos().filter((t) => t.done)
      default:
        return todos()
    }
  })

  const remaining = createMemo(() => todos().filter((t) => !t.done).length)

  const addTodo = (e: SubmitEvent) => {
    e.preventDefault()
    const text = input().trim()
    if (!text) return
    batch(() => {
      setTodos((prev) => [...prev, { id: nextId++, text, done: false }])
      setInput('')
    })
  }

  const toggleTodo = (id: number) => {
    setTodos((prev) => prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t)))
  }

  const removeTodo = (id: number) => {
    setTodos((prev) => prev.filter((t) => t.id !== id))
  }

  // Persist to localStorage
  createEffect(() => {
    localStorage.setItem('todos', JSON.stringify(todos()))
  })

  return (
    <div class="todo-app">
      <form onSubmit={addTodo}>
        <input
          value={() => input()}
          onInput={(e) => setInput((e.target as HTMLInputElement).value)}
          placeholder="What needs to be done?"
        />
        <button type="submit">Add</button>
      </form>

      <div class="filters">
        <button class={() => (filter() === 'all' ? 'active' : '')} onClick={() => setFilter('all')}>
          All
        </button>
        <button
          class={() => (filter() === 'active' ? 'active' : '')}
          onClick={() => setFilter('active')}
        >
          Active ({remaining()})
        </button>
        <button
          class={() => (filter() === 'done' ? 'active' : '')}
          onClick={() => setFilter('done')}
        >
          Done
        </button>
      </div>

      <ul>
        <For each={filteredTodos} by={(t) => t.id}>
          {(todo) => (
            <li class={todo.done ? 'done' : ''}>
              <input type="checkbox" checked={todo.done} onChange={() => toggleTodo(todo.id)} />
              <span>{todo.text}</span>
              <button onClick={() => removeTodo(todo.id)}>x</button>
            </li>
          )}
        </For>
      </ul>

      <Show when={() => todos().length > 0}>
        <p class="footer">
          {remaining()} item{() => (remaining() === 1 ? '' : 's')} left
        </p>
      </Show>
    </div>
  )
}

Custom Hook: createLocalStorage

function createLocalStorage<T>(
  key: string,
  initialValue: T,
): [() => T, (v: T | ((prev: T) => T)) => void] {
  const stored = localStorage.getItem(key)
  const initial = stored ? (JSON.parse(stored) as T) : initialValue
  const [value, setValue] = createSignal<T>(initial)

  createEffect(() => {
    localStorage.setItem(key, JSON.stringify(value()))
  })

  return [value, setValue]
}

// Usage
function Settings() {
  const [theme, setTheme] = createLocalStorage('theme', 'light')
  const [fontSize, setFontSize] = createLocalStorage('fontSize', 16)

  return (
    <div>
      <select
        value={() => theme()}
        onChange={(e) => setTheme((e.target as HTMLSelectElement).value)}
      >
        <option value="light">Light</option>
        <option value="dark">Dark</option>
      </select>
      <input
        type="range"
        min="12"
        max="24"
        value={() => fontSize()}
        onInput={(e) => setFontSize(Number((e.target as HTMLInputElement).value))}
      />
    </div>
  )
}

Custom Hook: createDebounced

function createDebounced<T>(source: () => T, delay: number): () => T {
  const [debounced, setDebounced] = createSignal(source())

  createEffect(() => {
    const value = source()
    const timer = setTimeout(() => setDebounced(() => value), delay)
    onCleanup(() => clearTimeout(timer))
  })

  return debounced
}

// Usage
function SearchBox() {
  const [query, setQuery] = createSignal('')
  const debouncedQuery = createDebounced(query, 300)

  createEffect(() => {
    const q = debouncedQuery()
    if (q) fetch(`/api/search?q=${q}`) // only fires after 300ms pause
  })

  return (
    <input
      value={() => query()}
      onInput={(e) => setQuery((e.target as HTMLInputElement).value)}
      placeholder="Search..."
    />
  )
}

Migration from solid-js/store

If you use Solid's store primitives (createStore, produce, reconcile), migrate to Pyreon's equivalents:

// Before (solid-js/store)
import { createStore, produce } from 'solid-js/store'

const [state, setState] = createStore({ count: 0, items: [] })
setState('count', (prev) => prev + 1)
setState(
  produce((s) => {
    s.items.push({ id: 1, text: 'hello' })
  }),
)

// After (Pyreon)
import { createStore } from '@pyreon/reactivity'

const state = createStore({ count: 0, items: [] })
state.count++
state.items.push({ id: 1, text: 'hello' })

Key differences:

  • Pyreon's createStore returns a single proxy object (not a [state, setState] tuple)

  • Mutations are direct JavaScript (no path-based setter, no produce)

  • reconcile is a separate function: reconcile(newData, state)

Migration Checklist

  1. Replace solid-js imports with @pyreon/solid-compat.

  2. The signal API (createSignal, createEffect, createMemo) is identical -- no code changes needed.

  3. Replace solid-js/store imports with @pyreon/reactivity's createStore and reconcile. Update store mutation patterns from path-based to direct mutation.

  4. Verify any createRenderEffect usage -- it behaves identically to createEffect in Pyreon.

  5. Control flow components (Show, For, Switch, Match, Suspense, ErrorBoundary) work the same way.

  6. Replace useTransition / startTransition with direct updates (no concurrent mode in Pyreon).

  7. Replace solid-js's createResource with @pyreon/reactivity's createResource (same API shape but imported differently).

  8. Verify on() usage -- same API but backed by Pyreon's effect system.

  9. Test mergeProps and splitProps -- same behavior for property descriptors and reactive getters.

  10. Check children() helper usage -- same memoization and resolution behavior.

@pyreon/solid-compat