pyreon

@pyreon/solid-compat provides a SolidJS-compatible API -- createSignal, createEffect, createMemo, control flow components, and more -- all running on Pyreon's reactive engine. The API names map closely to Solid's, but the update model is coarser: a Solid createSignal setter bumps an internal version signal that re-runs the whole component body (inside runUntracked), rather than updating only the exact DOM node Solid's compiler would. Output and lifecycle stay correct (child instances are preserved, effects and cleanups fire as expected) -- but reactivity is per-component re-run, not Solid's fine-grained per-node. Some SolidJS APIs are also not implemented -- see Not supported.

@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, startTransitionNo-op stubs -- run synchronously, isPending is always false (see Transitions)
createResourceBuilt-in async primitiveProvided -- loading / error / latest / state (see createResource state)

What Maps and What Differs

The API names and shapes line up closely with Solid:

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

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

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

The key difference is the update granularity. In real Solid a signal setter updates only the precise DOM nodes the compiler bound to it. In this shim every createSignal setter bumps an internal version signal that re-runs the entire component body (inside runUntracked to avoid tracking signal reads in the reactive accessor). The rendered output and lifecycle are correct -- child component instances are preserved across re-runs, and effects / cleanups fire as expected -- but the model is coarse per-component re-render, not Solid's fine-grained per-node reactivity. Most Solid code still works after a simple import swap; just don't rely on Solid's exact update-granularity guarantees (and check Not supported for APIs this layer omits).

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)
}

Resources

createResource

function createResource<T>(
  fetcher: (info: { value: T | undefined }) => Promise<T> | T,
  options?: { initialValue?: T },
): [Resource<T>, { mutate: Setter<T>; refetch: () => void }]

function createResource<T, S>(
  source: (() => S) | true,
  fetcher: (source: S, info: { value: T | undefined }) => Promise<T> | T,
  options?: { initialValue?: T },
): [Resource<T>, { mutate: Setter<T>; refetch: () => void }]

Async data fetching with reactive source tracking. When a source accessor is supplied, the fetcher re-runs whenever the source changes; a falsy source (false / null / undefined) skips the fetch. While loading and read inside a <Suspense> boundary, the accessor throws the fetch promise so Suspense can catch it; on error it throws the error so an <ErrorBoundary> can catch it.

const [data, { mutate, refetch }] = createResource(
  userId,
  async (id) => fetch(`/api/users/${id}`).then((r) => r.json()),
)

data() // the resolved value (or undefined while pending)
data.loading // boolean
data.error // Error | undefined
data.latest // last resolved value (kept during a refresh)
data.state // see below

createResource state

resource.state mirrors real SolidJS and is derived reactively from the resource's loading / error / value signals. It is one of five values:

StateMeaning
'unresolved'No value yet AND not loading (initial, before the first fetch)
'pending'Loading AND no resolved value yet (first load)
'ready'Has a resolved value, not loading
'refreshing'Loading BUT a previous resolved value exists
'errored'The fetch rejected
const [data, { refetch }] = createResource(() => fetch('/api/x').then((r) => r.json()))

data.state // 'pending'   (first load, no value yet)
// ...after it resolves:
data.state // 'ready'
refetch()
data.state // 'refreshing' (loading again, but data() still holds the prior value)

There is no .pending property -- it is not a real Solid Resource field. Use .state === 'pending' (or .loading) instead.

Transitions

startTransition and useTransition are no-op stubs kept for source compatibility. Pyreon has no concurrent / time-slicing scheduler, so:

  • startTransition(fn) runs fn synchronously -- it does not defer or batch work into a low-priority transition.

  • useTransition() returns [isPending, start] where isPending() is always false and start(fn) runs fn synchronously.

const [isPending, start] = useTransition()
start(() => setTab('profile')) // runs synchronously
isPending() // always false

If you depend on Solid's concurrent-rendering behavior (deferred updates, pending indicators driven by isPending), it is not available here.

Not supported

Several real SolidJS APIs are not implemented. They are not exported, so importing them fails at build time rather than silently misbehaving:

  • Mutable stores -- createMutable, modifyMutable. Use createStore (this package) or @pyreon/reactivity's createStore.

  • onError -- register error handling via <ErrorBoundary> instead.

  • isServer -- use @pyreon/reactivity's isServer / isClient.

  • SSR entry points -- renderToString, renderToStringAsync, renderToStream, HydrationScript, NoHydration, Assets. Solid's canonical SSR pipeline is out of scope; use Pyreon's own SSR (@pyreon/runtime-server / @pyreon/server).

  • dom-expressions codegen helpers -- template, insert, spread, classList, delegateEvents, etc. This package uses Pyreon's own JSX compiler, not Solid's babel-plugin-jsx-dom-expressions.

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. useTransition / startTransition are no-op stubs (run synchronously, isPending always false) -- don't rely on concurrent-mode behavior.

  7. createResource is provided by this package directly (loading / error / latest / state); there is no .pending field.

  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