@pyreon/svelte-compat shims the Svelte APIs your code actuallyimports — svelte/store (writable / readable / derived / get /readonly) and the svelte lifecycle / context / dispatch / client surface — all running on Pyreon's reactive engine. Migrate Svelte store and lifecycle code by swapping the import path; no rewrite. Components are plain functions returning JSX that run on Pyreon via a shared compat JSX runtime, re-rendering when a store they subscribe to changes.
Scope
This is a runtime shim, not the Svelte compiler. It covers what code imports at runtime — the same boundary @pyreon/solid-compat draws around Solid's compiler:
svelte/store—writable,readable,derived,get,readonlysvelte—onMount,onDestroy,beforeUpdate,afterUpdate,tick,setContext,getContext,hasContext,getAllContexts,createEventDispatcher,mount,unmount,flushSynccontrol flow —
Show,For,Switch,Match,Suspense,ErrorBoundary(re-exported from@pyreon/core)
It does not implement the .svelte single-file-component compiler or the Svelte 5 rune syntax — $state / $derived / $effect / the$store auto-subscription sugar are compiler constructs, not runtime imports, so they have no shim. Use Pyreon's own primitives (signal / computed / effect) for in-component reactive state, and subscribe a store into a component-local for the $store equivalent (see Stores in a component).
Key Differences from Svelte
@pyreon/svelte-compat is a runtime-import shim, not a .sveltecompiler: it shims what Svelte code imports (stores, lifecycle, context, dispatch, the Svelte-5 client API), running each on Pyreon's reactive engine. It does not implement the single-file-component compiler or the rune / $: / $store syntax — those are compile-time constructs (the same boundary @pyreon/solid-compat draws around Solid's compiler). Components are plain functions returning JSX. The store contract (subscribe / set / update, lazy start/stop,safe_not_equal dedup) and onMount / onDestroy / tick are faithful to Svelte; the genuine differences:
| Behavior | Svelte | @pyreon/svelte-compat |
|---|---|---|
| Component format | .svelte single-file components compiled by the Svelte compiler | Plain functions returning JSX — no .svelte SFC compiler |
Rune / $: / $store syntax | $state / $derived / $effect / $: / $store auto-subscribe | Not shimmed — compiler syntax, not runtime imports. Map to signal / computed / effect and a .subscribe() component-local (see Migration) |
| Update model | Fine-grained, compiler-generated targeted DOM updates | The compat component re-renders its subtree (teardown + rebuild) on store change — output is correct, but not Svelte's per-binding patch |
onDestroy / onMount cleanup on an ancestor re-render | Fires only on real unmount | Also fires during the transient teardown when a parent re-renders a preserved child — a parent re-render tears down + rebuilds the child subtree, running the child's onDestroy / onMount-cleanup each time (see the caveat below). A resource closed there (ws.close(), clearInterval) is torn down mid-life. Known limitation — keep true-unmount-only cleanup aware of this, or avoid re-rendering the ancestor. |
beforeUpdate / afterUpdate | Run before / after every reactive update | Map to a post-first-render hook only — the wrapper re-renders by teardown + rebuild (no per-update diff), so they do not re-fire per update |
getAllContexts() | Returns the full Map of provided context | Returns a fresh empty Map (best-effort stub) — Pyreon contexts are not enumerable per-component. setContext / getContext / hasContext resolve correctly; only enumerate-everything is unsupported |
createEventDispatcher | Compiler-forwarded on:foo, bubbles across the component tree | Forwards to the direct parent's matching prop (on<Type> / on:<type> / on<type>) with a CustomEvent; no cross-level bubbling, no on:foo compiler transform. Props are captured at the createEventDispatcher() call site |
| Control flow | {#if} / {#each} / {#await} block syntax | <Show> / <For> / <Suspense> etc. re-exported from @pyreon/core (recognized as native, not wrapped) |
Installation
npm install @pyreon/svelte-compatbun add @pyreon/svelte-compatpnpm add @pyreon/svelte-compatyarn add @pyreon/svelte-compatQuick Start
Replace your Svelte store imports:
// Before
import { writable, derived } from 'svelte/store'
// After
import { writable, derived } from '@pyreon/svelte-compat'import { writable, derived, get } from '@pyreon/svelte-compat'
const count = writable(0)
const doubled = derived(count, (n) => n * 2)
const unsub = doubled.subscribe((v) => console.log('doubled:', v))
count.set(5) // logs "doubled: 10"
count.update((n) => n + 1) // logs "doubled: 12"
unsub()
get(count) // 6 — synchronous readTwo import surfaces
@pyreon/svelte-compat ships two entry points that mirror Svelte's own module split:
| Specifier | Mirrors | Exports |
|---|---|---|
@pyreon/svelte-compat | svelte | everything — stores and lifecycle / context / dispatch / mount / control flow |
@pyreon/svelte-compat/store | svelte/store | exactly the store API: writable, readable, derived, get, readonly |
The functions re-exported from /store are the same identities as the main entry — importing writable from either gives you the same function. Use /store when you want the subpath to match Svelte's realsvelte/store shape (no lifecycle leakage).
// Mirrors `import { writable } from 'svelte/store'`
import { writable, derived } from '@pyreon/svelte-compat/store'Stores
The store implementation is a faithful, signal-free Svelte store — a plain Set of subscribers notified synchronously on set / update, exactly like Svelte's own writable. It is not backed by a Pyreon signal: Svelte's store contract (synchronous notify, lazy start/stop, two-phase invalidate → run) maps cleanly onto a subscriber set, andderived subscribes to its inputs explicitly, so no signal auto-tracking is needed.
writable
function writable<T>(value?: T, start?: StartStopNotifier<T>): Writable<T>
interface Writable<T> extends Readable<T> {
set(value: T): void
update(updater: (value: T) => T): void
}Creates a writable store. subscribe invokes the subscriberimmediately with the current value (Svelte's contract), then again on every change. set replaces the value; update applies an updater to the current value.
import { writable } from '@pyreon/svelte-compat'
const count = writable(0)
const unsub = count.subscribe((v) => console.log(v)) // logs 0 immediately
count.set(5) // logs 5
count.update((n) => n + 1) // logs 6
unsub()
count.set(99) // ignored — unsubscribedChange detection (safe_not_equal). Like Svelte, primitives are deduplicated (setting 5 when the value is already 5 does not notify), but objects and functions always notify — so an in-place-mutated store object still propagates:
const cart = writable({ items: [] as string[] })
cart.update((c) => {
c.items.push('apple') // in-place mutation
return c // same reference — still notifies (object identity rule)
})readable
function readable<T>(value?: T, start?: StartStopNotifier<T>): Readable<T>
interface Readable<T> {
subscribe(run: (value: T) => void, invalidate?: (value?: T) => void): () => void
}A writable with set / update hidden — a read-only store whose value is driven entirely by its start notifier.
import { readable } from '@pyreon/svelte-compat'
const now = readable(new Date(), (set) => {
const id = setInterval(() => set(new Date()), 1000)
return () => clearInterval(id) // stop notifier
})readonly
function readonly<T>(store: Readable<T>): Readable<T>Returns a read-only view of an existing store — exposes onlysubscribe, hiding set / update on a writable. The underlying store keeps driving the view's value.
import { writable, readonly } from '@pyreon/svelte-compat'
const _count = writable(0)
export const count = readonly(_count) // consumers can subscribe but not writederived
function derived<S, T>(
stores: S,
fn: ((values: StoresValues<S>) => T)
| ((values: StoresValues<S>, set: (v: T) => void, update: (fn: Updater<T>) => void) => Unsubscriber | void),
initialValue?: T,
): Readable<T>Derives a store from one or more source stores. stores is a single store or an array of stores; the value is computed by fn. Two forms, distinguished by fn's arity (Svelte's exact rule):
Sync —
(values) => result. Returns the derived value directly.Async / cleanup —
(values, set, update?) => stop. Pushes values asynchronously viaset/update; may return a cleanup that runs before the next recomputation and on final unsubscribe.
import { writable, derived } from '@pyreon/svelte-compat'
// Single source, sync
const n = writable(2)
const doubled = derived(n, (v) => v * 2)
// Multiple sources, sync — values arrive as a tuple
const a = writable(1)
const b = writable(2)
const sum = derived([a, b], ([x, y]) => x + y)Async / cleanup form — model a real async source (fetch, timer). TheinitialValue is shown until the async result lands; the returned cleanup cancels an in-flight result before the next run:
const id = writable(1)
const user = derived(
id,
(currentId, set) => {
let cancelled = false
fetch(`/api/user/${currentId}`)
.then((r) => r.json())
.then((u) => !cancelled && set(u))
return () => {
cancelled = true // cleanup before next run / on final unsubscribe
}
},
null,
)get
function get<T>(store: Readable<T>): TReads a store's current value synchronously. Implemented by subscribing and immediately unsubscribing — so it never leaves a live subscription (and never triggers the lazy start to stay running).
import { writable, get } from '@pyreon/svelte-compat'
const count = writable(123)
get(count) // 123
count.set(456)
get(count) // 456Lazy start / stop notifier
writable / readable's second argument is the lazy notifier. It runs when the subscriber count goes 0 → 1 and its returned function runs at1 → 0 — exactly Svelte's contract. Use it for resources that should only be live while something is listening:
import { readable } from '@pyreon/svelte-compat'
let started = 0
let stopped = 0
const ticker = readable(0, (set) => {
started++
const id = setInterval(() => set(Date.now()), 1000)
return () => {
stopped++
clearInterval(id)
}
})
const u1 = ticker.subscribe(() => {}) // started === 1 (0 → 1)
const u2 = ticker.subscribe(() => {}) // started === 1 (not re-run)
u1() // stopped === 0 (still 1 subscriber)
u2() // stopped === 1 (1 → 0)Stores in a component
Without the $store compiler sugar, subscribe the store into a component-local. The compat layer makes a subscription created inside a compat component body the faithful equivalent of Svelte's $storeauto-subscription: the subscriber carries the component's re-render trigger, so a store write re-renders the component, and the subscription is automatically torn down on unmount.
import { writable } from '@pyreon/svelte-compat'
const count = writable(0)
function Counter() {
let c = 0
count.subscribe((v) => (c = v)) // $count equivalent — re-renders on change
return (
<button type="button" onClick={() => count.update((n) => n + 1)}>
clicked {c} times
</button>
)
}Lifecycle
onMount
function onMount(fn: () => (() => void) | void): voidRuns fn once after the component's first render. If fn returns a function, that function runs on destroy — exactly Svelte's contract.
import { onMount } from '@pyreon/svelte-compat'
function Clock() {
let time = ''
onMount(() => {
const id = setInterval(() => (time = new Date().toLocaleTimeString()), 1000)
return () => clearInterval(id) // runs on destroy
})
return <span>{time}</span>
}onMount is hook-index-stable across re-renders: it registers exactly once and never re-fires when the component re-renders, matching Svelte. Called outside a compat component, it falls back to Pyreon's ownonMount.
onDestroy
function onDestroy(fn: () => void): voidRuns fn when the component unmounts. Like onMount, it is hook-index-stable (registered once) and falls back to Pyreon'sonUnmount outside a compat component.
import { onMount, onDestroy } from '@pyreon/svelte-compat'
function Subscription() {
const socket = new WebSocket('wss://example.com')
onDestroy(() => socket.close())
return <span>live</span>
}beforeUpdate / afterUpdate
function beforeUpdate(fn: () => void): void
function afterUpdate(fn: () => void): voidbeforeUpdate runs once before the first render commits;afterUpdate runs after the first render (it is implemented on top of onMount). See the boundary note below — these do not carry Svelte's per-tick timing.
import { beforeUpdate, afterUpdate } from '@pyreon/svelte-compat'
function Logger() {
beforeUpdate(() => console.log('about to render'))
afterUpdate(() => console.log('rendered'))
return <div />
}tick
function tick(): Promise<void>Resolves after the current microtask, letting you await queued work — the same shape as Svelte's tick.
import { tick } from '@pyreon/svelte-compat'
count.set(42)
await tick() // queued re-renders / microtasks have flushedContext
Svelte's context is keyed by an arbitrary value (commonly a Symbol). The compat layer backs each key with a Pyreon context registered in a global registry, so setContext / getContext / hasContext resolve through Pyreon's context tree.
setContext
function setContext<T>(key: unknown, context: T): TProvides context for descendants of the current component, and returns the provided value (Svelte's contract).
getContext
function getContext<T>(key: unknown): TReads the nearest value provided up-tree for key.
hasContext
function hasContext(key: unknown): booleanReports whether a value was provided up-tree for key (true when the resolved value is not undefined).
import { setContext, getContext, hasContext } from '@pyreon/svelte-compat'
const THEME = Symbol('theme')
function Provider(props: { children?: unknown }) {
setContext(THEME, 'dark')
return props.children
}
function Consumer() {
if (!hasContext(THEME)) return <span>no theme</span>
const theme = getContext<string>(THEME)
return <span data-theme={theme}>{theme}</span>
}getAllContexts
function getAllContexts<T extends Map<unknown, unknown>>(): TBest-effort — returns an empty Map. See the boundary note.
Events — createEventDispatcher
function createEventDispatcher<EventMap>(): <Type extends keyof EventMap & string>(
type: Type,
detail?: EventMap[Type],
) => booleanReturns a dispatch(type, detail?) function. In Svelte, <Child on:foo>is compiled into a prop; here the dispatcher forwards to the current component's matching prop with a CustomEvent, and returns!event.defaultPrevented.
The handler prop is resolved in this order:
on<Type>—dispatch('ping')→onPing(the idiomatic JSX prop)on:<type>—on:ping(Svelte-directive-style prop name)on<type>—onping(lowercase fallback)
import { createEventDispatcher, onMount } from '@pyreon/svelte-compat'
function Child() {
const dispatch = createEventDispatcher<{ ping: number }>()
onMount(() => dispatch('ping', 7)) // detail is the CustomEvent.detail
return <span>child</span>
}
function Parent() {
// dispatch('ping', …) resolves to the `onPing` prop here
return <Child onPing={(e: CustomEvent<number>) => console.log(e.detail)} />
}Svelte 5 client API — mount / unmount / flushSync
mount
function mount<P>(Component: (props: P) => VNodeChild, options: {
target: Element
props?: P
context?: Map<unknown, unknown>
}): PMounts a compat component into target. Routes through the compat JSX runtime so the component runs inside the shared wrapper (lifecycle + store-driven re-render), exactly as a JSX-rendered child would. Returns the props object — in Svelte 5 mount returns the component's exports; here the props object is the surface (and it carries the disposer used byunmount).
import { mount, unmount } from '@pyreon/svelte-compat'
function App(props: { label: string }) {
return <div id="app">{props.label}</div>
}
const instance = mount(App, {
target: document.getElementById('root')!,
props: { label: 'hello' },
})
// later
unmount(instance)unmount
function unmount(mounted: Record<symbol, unknown>): voidDisposes a component previously mounted with mount. Pass the object returned by mount. Calling it on an object that was never mounted is a safe no-op.
flushSync
function flushSync<T>(fn?: () => T): T | undefinedRuns fn (if provided) and returns its result. Pyreon batches reactive work synchronously, so this is effectively just invoking fn — the "flush" Svelte 5 needs after a synchronous state change is already guaranteed by Pyreon's synchronous notify model.
import { flushSync } from '@pyreon/svelte-compat'
const result = flushSync(() => {
count.set(1)
return get(count) // 1 — already applied
})Control flow
Show, For, Switch, Match, Suspense, and ErrorBoundary are re-exported from @pyreon/core and are recognized as native components by the compat JSX runtime — they are not wrapped, so they keep their own internal reactivity. Svelte's own control flow is block syntax ({#if} / {#each} / {#await}), which is a compiler construct with no runtime import; use these Pyreon components in JSX instead.
import { Show, For, Switch, Match } from '@pyreon/svelte-compat'
function List(props: { items: () => Item[]; status: () => string }) {
return (
<Switch fallback={<p>idle</p>}>
<Match when={() => props.status() === 'loading'}>
<Spinner />
</Match>
<Match when={() => props.status() === 'ready'}>
<Show when={() => props.items().length > 0} fallback={<p>empty</p>}>
<ul>
<For each={props.items} by={(i) => i.id}>
{(item) => <li>{item.name}</li>}
</For>
</ul>
</Show>
</Match>
</Switch>
)
}| Svelte block | Pyreon control-flow component |
|---|---|
{#if} / {:else} | <Show when fallback> |
{#each} | <For each by> |
{#if} chains | <Switch> + <Match when> |
{#await} | <Suspense fallback> |
<svelte:boundary> | <ErrorBoundary fallback> |
Enabling compat mode in Vite
// vite.config.ts
import pyreon from '@pyreon/vite-plugin'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [pyreon({ compat: 'svelte' })],
})With compat: 'svelte', import … from 'svelte' resolves to@pyreon/svelte-compat and import … from 'svelte/store' resolves to@pyreon/svelte-compat/store, and JSX routes through the compat runtime automatically — so existing import paths keep working unchanged.
The nativeCompat marker
The compat JSX runtime wraps every user component so it returns a reactive accessor (enabling store-driven re-renders). Pyreon framework components — context Providers, RouterView, PyreonUI, and the control-flow primitives above — must NOT be wrapped: their bodies useprovide() / onMount() / effect() at setup scope, which only work inside Pyreon's own setup frame. Those components are marked withnativeCompat() from @pyreon/core, and the compat runtime routes a marked component through h(type, props) directly (no wrapper).
If you write your own Pyreon-flavored helper component that usesprovide() / onMount() and render it in a compat: 'svelte' app, mark it so it runs in the native setup frame:
import { nativeCompat, provide, createContext } from '@pyreon/core'
const ThemeCtx = createContext('light')
function ThemeProvider(props: { value: string; children?: unknown }) {
provide(ThemeCtx, props.value) // needs the native setup frame
return props.children
}
nativeCompat(ThemeProvider) // route through h() directly — no compat wrapperSee the native marker contract for the full rules. The 24 framework components that ship marked (RouterView, PyreonUI, FormProvider, …) work automatically — this is only for user-defined Pyreon-flavored helpers in compat-scaffolded apps.
Migration
Rune syntax — $state / $derived / $effect
These are compiler runes, not runtime imports — there is nothing to shim. Map them onto Pyreon's primitives:
// Svelte 5 runes (compiler syntax)
let count = $state(0)
let doubled = $derived(count * 2)
$effect(() => console.log(doubled))
// Pyreon equivalents (runtime primitives)
import { signal, computed, effect } from '@pyreon/reactivity'
const count = signal(0)
const doubled = computed(() => count() * 2)
effect(() => console.log(doubled()))| Svelte 5 rune | Pyreon equivalent |
|---|---|
$state(v) | signal(v) — read s(), write s.set(v) |
$derived(expr) | computed(() => expr) |
$effect(() => …) | effect(() => …) |
$props() | the component's props argument |
$bindable() | a signal passed down + a callback prop |
The $: reactive label
Svelte 3/4's $: reactive statement is also a compiler construct. Replace the reactive value form with a computed, and the reactiveside-effect form with an effect:
// Svelte: $: doubled = count * 2
const doubled = computed(() => count() * 2)
// Svelte: $: console.log(count)
effect(() => console.log(count()))Store store — direct port
Store code ports with zero changes beyond the import path. The store contract — subscribe(run, invalidate?) → unsubscribe, lazystart(set, update?) → stop, two-phase invalidate → run, immediate initial subscriber call, safe_not_equal change detection — matches Svelte exactly.
// Before
import { writable, derived, get } from 'svelte/store'
// After — same code, new path
import { writable, derived, get } from '@pyreon/svelte-compat/store'Migration checklist
Stores — swap
svelte/store→@pyreon/svelte-compat/store. No code changes; the store contract is identical.Lifecycle — swap
svelte→@pyreon/svelte-compat.onMount/onDestroy/tickare exact. AuditbeforeUpdate/afterUpdate: they map to a post-first-render hook, not per-tick (see the boundary).Context —
setContext/getContext/hasContextport directly. Replace anygetAllContextsusage with explicit per-key passing — it returns an emptyMap.Events —
createEventDispatcherports, but the parent must pass the handler as a prop (onFoo/on:foo); there is noon:foobubbling across levels.$storeauto-subscription — replace with a.subscribe()into a component-local (re-renders the component) or, for new code, a Pyreonsignal()read directly in JSX.Runes (
$state/$derived/$effect) and$:— these are compiler syntax, not imports. Rewrite tosignal/computed/effectfrom@pyreon/reactivity..sveltefiles — there is no SFC compiler. Write components as plain functions returning JSX.Control flow — replace
{#if}/{#each}/{#await}blocks with<Show>/<For>/<Suspense>components.Svelte 5
mount/unmount/flushSync— port directly; the shapes match.
API Reference
svelte/store
| API | Signature | Behavior |
|---|---|---|
writable(value?, start?) | (value?: T, start?: StartStopNotifier<T>) => Writable<T> | Readable + set / update. Signal-free subscriber Set. start is the lazy 0→1 / 1→0 notifier. safe_not_equal dedup (objects/functions always notify). |
readable(value?, start?) | (value?: T, start?: StartStopNotifier<T>) => Readable<T> | A writable with set / update hidden. |
readonly(store) | (store: Readable<T>) => Readable<T> | A read-only view of a store exposing only subscribe. |
derived(stores, fn, init?) | (stores, fn, initialValue?) => Readable<T> | Single store or array; sync (values) => T or async (values, set, update?) => stop. Form chosen by fn's arity. |
get(store) | (store: Readable<T>) => T | Synchronous one-shot read (subscribe + immediately unsubscribe). |
svelte lifecycle / context / events / client
| API | Signature | Behavior |
|---|---|---|
onMount(fn) | (fn: () => (() => void) | void) => void | Runs after first render. A returned function runs on destroy. Hook-index-stable (registers once). |
onDestroy(fn) | (fn: () => void) => void | Runs on unmount. Hook-index-stable. |
beforeUpdate(fn) | (fn: () => void) => void | Runs once before the first render commits (see boundary — not per-tick). |
afterUpdate(fn) | (fn: () => void) => void | Runs after the first render, on top of onMount (see boundary — not per-tick). |
tick() | () => Promise<void> | Resolves after the current microtask. |
setContext(key, context) | (key: unknown, context: T) => T | Provides a value for descendants; returns it. |
getContext(key) | (key: unknown) => T | Reads the nearest provided value up-tree. |
hasContext(key) | (key: unknown) => boolean | Whether a value was provided up-tree (resolved value not undefined). |
getAllContexts() | () => Map<unknown, unknown> | Best-effort — returns an empty Map (see boundary). |
createEventDispatcher() | () => (type, detail?) => boolean | Returns dispatch(type, detail?); forwards to the parent's on<Type> / on:<type> / on<type> prop with a CustomEvent; returns !defaultPrevented. |
mount(Component, options) | (Component, { target, props?, context? }) => P | Svelte-5 client API — mounts a component into target; returns the props object (carries the disposer). |
unmount(mounted) | (mounted: Record<symbol, unknown>) => void | Disposes a component mounted via mount; no-op on a non-mounted object. |
flushSync(fn?) | (fn?: () => T) => T | undefined | Runs fn and returns its result (Pyreon batches synchronously). |
Control flow (re-exported from @pyreon/core)
| Export | Replaces (Svelte block) |
|---|---|
Show | {#if} / {:else} |
For | {#each} |
Switch | {#if} chains |
Match | a {#if} branch |
Suspense | {#await} |
ErrorBoundary | <svelte:boundary> |
Types
| Type | Shape |
|---|---|
Readable<T> | { subscribe(run, invalidate?): Unsubscriber } |
Writable<T> | Readable<T> & { set(value): void; update(updater): void } |
Subscriber<T> | (value: T) => void |
Invalidator<T> | (value?: T) => void |
Unsubscriber | () => void |
Updater<T> | (value: T) => T |
StartStopNotifier<T> | (set, update) => Unsubscriber | void — lazy notifier, runs on first subscriber |
Documented boundaries
No
.svelteSFC compiler, no rune /$:syntax.$state/$derived/$effect/$:/ the$storeauto-subscription sugar are compiler constructs, not runtime imports — there is no shim. Map them tosignal/computed/effectand a.subscribe()component-local (see Migration).beforeUpdate/afterUpdatemap to a post-first-render hook, not Svelte's per-tick timing — the wrapper re-renders by teardown + rebuild (no per-update diff). They do not re-fire on each store-driven re-render. Most Svelte interop usesonMount/onDestroy, which are exact.getAllContextsreturns an emptyMap(Pyreon contexts are not enumerable per-component).setContext/getContext/hasContextresolve correctly.createEventDispatcherforwards to the parent's matching prop (onFoo/on:foo/onfoo) — no bubbling across component levels, noon:foocompiler forwarding.Stores are signal-free — a faithful Svelte subscriber
Set, not a Pyreon signal. The re-render of a component subscribing to a store is a teardown + rebuild, not a fine-grained per-binding patch.onDestroy/onMountcleanup fires on every ancestor re-render— a parent re-render tears down + rebuilds a preserved child's subtree, so the child'sonDestroyandonMount-returned cleanup run during the transient teardown, not only on true unmount (see Key Differences). Known open limitation; a proper fix is tracked.
See also
Reactivity primitives —
signal/computed/effectfor new Pyreon code