Skip to content

Migrating to 6.0

Sygnal 6.0 runs every component on a new, smaller core: one state store per app, actions that run to completion in order, and one DOM patch per update. The canonical forms (the ones the rest of these docs use) work exactly as before. This page covers what changed:

  • Removed forms: a few alternative and undocumented forms are gone, each with its rewrite.
  • Timing: reducers apply synchronously and startup uses no timers. Only tests that drove the clock by hand notice.
  • Behaviour changes: isolatedState keeps its parent’s data (new resetState prop), Collection keys, context tracking.

Finding the old forms. In development, the dev checks (sygnal/diagnostics, which sygnal/vite loads) report each removed form they meet at run time as SYG612, once per form and component, with a link to its section below. sygnal-check reports most of them statically, and --fix rewrites some:

Terminal window
npx sygnal-check src # SYG612: statics, factory imports, <Collection of="Name">, idfield, <Collection className>
npx sygnal-check --strict src # also SYG501 (positional views), SYG504 ('A | SINK' keys), SYG506 (CHILD.select('Name'))
npx sygnal-check --strict --fix src
RemovedUse insteadFlagged by
String tags, .componentsimport the component and use it as a JSX tagSYG612 (dev, static)
<Collection of="Name"><Collection of={Item}>SYG612 (dev, static)
The Collection’s wrapper <div> (<Collection className>)your own wrapping element: <ul className="x"><Collection … /></ul>SYG612 (dev, static)
CHILD.select('Name')CHILD.select(Child)SYG612 (dev), SYG506 (--strict --fix)
'ACTION | SINK' model keysACTION: { SINK: fn }SYG612 (dev), SYG504 (--strict --fix)
Positional view argumentsfunction C({ state, context, ...props })SYG612 (dev), SYG501 (--strict)
.peersrender the peer as a siblingSYG612 (dev, static)
hmrActionsBOOTSTRAP in the modelSYG612 (dev, static)
DOMSourceName / stateSourceNamethe sources are always DOM and STATESYG612 (dev, static; --fix for default values)
storeCalculatedInStatenothing: calculated fields are always storedSYG612 (dev, static; --fix)
component({ ... }), collection(), switchable()a function component, or defineComponent(opts)SYG612 (static: the import)
Undocumented leftoverssee the tablepartly SYG612 / SYG603 / SYG403

A component was registered in its parent’s .components and rendered by name (<Badge /> resolved as a string, or h('Badge')). In 6.0 a component is always referenced by its function, which also keeps the pragma’s fast path. A leftover string tag renders as a plain element.

// before
Page.components = { Badge }
function Page() { return <div>{h('Badge')}</div> }
// after
import Badge from './Badge'
function Page() {
return <div><Badge /></div>
}

of takes the item component itself:

// before
List.components = { Row }
function List() { return <ul><Collection of="Row" from="rows" /></ul> }
// after
import Row from './Row'
function List() {
return <ul><Collection of={Row} from="rows" /></ul>
}

The Collection’s wrapper <div> (<Collection className>)

Section titled “The Collection’s wrapper <div> (<Collection className>)”

A Collection rendered its items inside a <div>, which took the Collection’s className (and style, class, attrs, data / data-*, on, hook and ref). In 6.0 the items render directly into the parent element: <ul><Collection /></ul> gives ul > li, not ul > div > li, and two Collections can share one parent. Put the class (or style) on your own wrapping element:

// before
<Collection of={TaskItem} from="tasks" className="tasks" />
// after
<ul className="tasks">
<Collection of={TaskItem} from="tasks" />
</ul>

A <div className="tasks"> keeps the old markup exactly. Check styles and selectors that relied on the <div>: a CSS rule or a test selector such as .list > div > li, or :first-child / :nth-child counted among the items when the Collection has siblings in its parent (they count the siblings now). The wrapper props are ignored, and reported as SYG612 in development and by sygnal-check. id and every other prop still go to each item. That includes id, role, title and aria-*, which in 5.x also landed on the <div>: they now only reach the items as props, with no element to label, and they are not reported (they are ordinary item props too). Move them to your wrapping element: <ul role="list" aria-label="Tasks">. A <Transition> around a Collection animated the <div>; it now animates each item as it is added or removed. A <VirtualCollection> keeps its own scroll container (its className, style and id are the container’s).

// before
Page.intent = ({ CHILD }) => ({ PICK: CHILD.select('Row') })
// after
import Row from './Row'
Page.intent = ({ CHILD }) => ({ PICK: CHILD.select(Row) })

sygnal-check --strict --fix replaces the string with the identifier when that name is in scope (SYG506). CHILD.select() with no argument (every child) is gone too: select each child component and merge the streams.

// before
App.model = {
'SAVE | EFFECT': (state, data) => save(data),
'PING | EVENTS': () => ({ type: 'PING' }),
}
// after
App.model = {
SAVE: { EFFECT: (state, data) => save(data) },
PING: { EVENTS: event('PING') },
}

sygnal-check --strict --fix rewrites them (SYG504) when no other entry handles the same action. With the keys gone, | is no longer reserved in action names (SYG605 is retired), and two entries for the same action and sink can’t happen (SYG213 is retired).

A view is called with one argument. state, context and the props are its fields:

// before
function Lane(props, state, context) {
return <h2>{props.title}: {state.count} {context.theme}</h2>
}
// after
function Lane({ state, context, ...props }) {
return <h2>{props.title}: {state.count} {context.theme}</h2>
}

renderToString already called views with one argument, so a positional view rendered differently on the server.

A peer was a sibling component that got the parent’s sources and was passed to its view as peers.Name. Render it as a sibling: it shares state through the state prop and reports to the parent through PARENT and CHILD.select.

// before
App.peers = { Sidebar }
function App({ state, peers }) { return <main>{peers.Sidebar}<Content /></main> }
// after
function App({ state }) {
return <main><Sidebar state="sidebar" /><Content /></main>
}
App.intent = ({ CHILD }) => ({ NAV: CHILD.select(Sidebar) })

The view no longer gets a peers field.

sygnal/vite keeps the app’s state across a hot update. An action to run after the swapped app mounts goes in the model’s BOOTSTRAP:

// before
App.hmrActions = ['RELOAD_CONFIG']
// after
App.model = {
BOOTSTRAP: { EFFECT: (state, data, next) => next('RELOAD_CONFIG') },
RELOAD_CONFIG: (state) => ({ ...state, configVersion: state.configVersion + 1 }),
}

SYG604 (an invalid hmrActions value) is retired.

run() always wired the root’s sources as DOM and STATE, so a renamed source only ever worked with the component() factory. Remove the statics and read DOM and STATE in the intent. sygnal-check --fix deletes them when they name the default ('DOM', 'STATE').

Calculated fields are always part of the component’s state, as they were by default. A value only the view needs is computed in the view; code that serializes state skips the calculated keys (persist() already does). sygnal-check --fix deletes the static.

The component({ ... }) factory, collection(), switchable()

Section titled “The component({ ... }) factory, collection(), switchable()”

The options factory and its sources / isolateOpts options are gone, and so are the low-level collection() and switchable() helpers. Write a function component with statics, and render <Collection> / <Switchable> in a view:

// before
import { component } from 'sygnal'
const Counter = component({ name: 'Counter', view, intent, model, initialState: { n: 0 } })
// after
function Counter({ state }) {
return <button className="inc">{state.n}</button>
}
Counter.intent = ({ DOM }) => ({ INC: DOM.click('.inc') })
Counter.model = { INC: (state) => ({ ...state, n: state.n + 1 }) }
Counter.initialState = { n: 0 }

Code that builds components from data (generators, wrappers) uses defineComponent(opts). It returns an ordinary function component that calls view, with the other options as its statics, and name as its componentName:

import { defineComponent } from 'sygnal'
const Counter = defineComponent({
name: 'Counter',
view: ({ state }) => <button className="inc">{state.n}</button>,
intent: ({ DOM }) => ({ INC: DOM.click('.inc') }),
model: { INC: (state) => ({ ...state, n: state.n + 1 }) },
initialState: { n: 0 },
})

component({ sources }) (instantiate at once) has no replacement: start the component with run(), or test it with renderComponent(). The sygnalFactory / sygnalOptions vnode props were internal: a component vnode now carries the component function (data.c); render components with JSX. SYG413, SYG414, SYG419, SYG601, SYG607 and SYG903 described these paths and are retired.

BeforeAfter
C.intent = (s) => xs.merge(...).map(e => ({ type, data })) (one stream of { type, data })C.intent = (s) => ({ TYPE: stream$ }) (SYG603 names the form when the component starts)
C.label = 'Name' as the component’s namethe function’s name, or C.componentName = 'Name'
<Collection idfield="key">items with an id field (or map them to one)
C.context = { user: 'user', all: true }C.context = { user: (state) => state.user } (SYG403 flags the old form)
CHILD.select() (every child)one CHILD.select(Child) per child, merged
window.__SYGNAL_HMR_STATE, __SYGNAL_HMR_UPDATINGnothing (already unused)

The types follow: a calculated or context entry is a function (no boolean / string entries), and Component has no peers, components, label, storeCalculatedInState or source-name statics.

The 6.0 core has one clock: actions are queued and run to completion in order, and a state change schedules one render a microtask later, which patches the DOM once.

5.x6.0
STATE reducerapplied in a microtask after the actionapplied when the action is processed
INITIALIZEa 0 ms timerat construction (synchronous)
intentsubscribed after a 1 ms timerat creation: an xs.of(...) emission is applied before the first render
BOOTSTRAP10 ms after mounta microtask after the first render
a new child’s first renderwaited until its intent listenedthe same render as its creation
render loop guardtimersa MessageChannel hop; no timers
an action dispatched while another runs (a driver answering at once, an EFFECT’s next())could interleaveruns after the current action and everything it queued (FIFO)

What can break (in tests; no documented app behaviour changes):

  • Tests that advanced fake timers to start an app. With raw run() and vi.useFakeTimers() there is nothing to advance:

    // 5.x
    run(App); await vi.advanceTimersByTimeAsync(10) // INITIALIZE, intent, BOOTSTRAP
    // 6.0
    run(App); await Promise.resolve()

    renderComponent’s waits (ready, next, settle) hid the startup timers, so tests using them don’t change.

  • Code that read STATE.stream’s last value between an action and the next microtask now sees the new state at once.

  • Tests that awaited one microtask “for the reducer” still pass; the await is no longer needed.

  • More than 100 renders under fake timers that are never advanced no longer stall.

Every non-STATE sink still sees the state from before the action’s reducer, as documented in Model.

isolatedState keeps its parent’s data; resetState

Section titled “isolatedState keeps its parent’s data; resetState”

An isolatedState child bound to a slice of its parent (<Editor state="doc" /> or a lens) seeds the slice with its initialState only while the slice is undefined. In 5.x it always overwrote the slice when the child was created, so a parent’s data was lost each time the child mounted. To start the child from its initialState on every mount, add resetState:

function Page({ state }) {
return (
<div>
<Editor state="draft" />
<Editor state="scratch" resetState />
</div>
)
}

resetState is read when the child is created, like state, and is never a prop of the child. In development, SYG425 warns when a kept slice lacks keys the child’s initialState defines, so a field the view reads would be missing. An isolatedState child without a state prop keeps a state of its own, as before. See State Management.

  • Items without an id under filter or sort are keyed by their index in the state array, not in the filtered or sorted list. Showing or hiding an item no longer re-creates the items after it.
  • Two items with the same id: only the first renders, and SYG424 warns in development. Give items unique ids.
  • An item without an id still sees its index as state.id, but writing its state back no longer stores that index as an id (in 5.x it did, and the stored id later collided with a sibling’s index after a removal). Known limit: an id-less item’s reducer that sets id to the item’s own current index ({ ...state, id: 0 } on the first item) can’t be told apart from the copy it got, so that id isn’t stored either. Assign ids when the parent creates the items.
  • uid() in an id-less item uses _i<index> for its part, so an item with id: 0 and the first id-less item no longer render the same DOM ids.
  • A Collection whose from key is missing when it is created renders once the key appears (SYG401 still warns). In 5.x it rendered nothing for its whole life.

Context changes re-render only the components that read them

Section titled “Context changes re-render only the components that read them”

A view’s context reads are recorded, and a context change re-renders only the components whose view read a changed key. This only affects a view that relied on being re-rendered for a side effect, which views shouldn’t have. In development, the dev checks re-run a sample of the skipped views and report SYG423 when one would have rendered differently (for example a view that read the context through a value kept from an earlier render).

A component’s children are the vnodes its parent wrote, before Sygnal processes them: a <Transition>, <Portal> or <Suspense> child arrives as its marker vnode (children[0].sel === 'transition'), not as the element it renders. Rendering {children} gives the same HTML as in 5.x; only a view that inspected its children’s sel or hooks sees the difference.

  • Switchable: a hidden page still renders when it is first shown, as in 5.x.
  • Every canonical form: components, intent, model, Collections, Switchable, Portals, Transitions, Suspense, lazy, statics, behaviors, persistence, the drivers, renderComponent and renderToString.