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:
isolatedStatekeeps its parent’s data (newresetStateprop), 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:
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 srcRemoved forms
Section titled “Removed forms”| Removed | Use instead | Flagged by |
|---|---|---|
String tags, .components | import the component and use it as a JSX tag | SYG612 (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 keys | ACTION: { SINK: fn } | SYG612 (dev), SYG504 (--strict --fix) |
| Positional view arguments | function C({ state, context, ...props }) | SYG612 (dev), SYG501 (--strict) |
.peers | render the peer as a sibling | SYG612 (dev, static) |
hmrActions | BOOTSTRAP in the model | SYG612 (dev, static) |
DOMSourceName / stateSourceName | the sources are always DOM and STATE | SYG612 (dev, static; --fix for default values) |
storeCalculatedInState | nothing: calculated fields are always stored | SYG612 (dev, static; --fix) |
component({ ... }), collection(), switchable() | a function component, or defineComponent(opts) | SYG612 (static: the import) |
| Undocumented leftovers | see the table | partly SYG612 / SYG603 / SYG403 |
String tags and .components
Section titled “String tags and .components”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.
// beforePage.components = { Badge }function Page() { return <div>{h('Badge')}</div> }// afterimport Badge from './Badge'
function Page() { return <div><Badge /></div>}<Collection of="Name">
Section titled “<Collection of="Name">”of takes the item component itself:
// beforeList.components = { Row }function List() { return <ul><Collection of="Row" from="rows" /></ul> }// afterimport 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).
CHILD.select('Name')
Section titled “CHILD.select('Name')”// beforePage.intent = ({ CHILD }) => ({ PICK: CHILD.select('Row') })// afterimport 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.
'ACTION | SINK' model keys
Section titled “'ACTION | SINK' model keys”// beforeApp.model = { 'SAVE | EFFECT': (state, data) => save(data), 'PING | EVENTS': () => ({ type: 'PING' }),}// afterApp.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).
Positional view arguments
Section titled “Positional view arguments”A view is called with one argument. state, context and the props are its fields:
// beforefunction Lane(props, state, context) { return <h2>{props.title}: {state.count} {context.theme}</h2>}// afterfunction 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.
.peers
Section titled “.peers”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.
// beforeApp.peers = { Sidebar }function App({ state, peers }) { return <main>{peers.Sidebar}<Content /></main> }// afterfunction App({ state }) { return <main><Sidebar state="sidebar" /><Content /></main>}App.intent = ({ CHILD }) => ({ NAV: CHILD.select(Sidebar) })The view no longer gets a peers field.
hmrActions
Section titled “hmrActions”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:
// beforeApp.hmrActions = ['RELOAD_CONFIG']// afterApp.model = { BOOTSTRAP: { EFFECT: (state, data, next) => next('RELOAD_CONFIG') }, RELOAD_CONFIG: (state) => ({ ...state, configVersion: state.configVersion + 1 }),}SYG604 (an invalid hmrActions value) is retired.
DOMSourceName / stateSourceName
Section titled “DOMSourceName / stateSourceName”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').
storeCalculatedInState
Section titled “storeCalculatedInState”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:
// beforeimport { component } from 'sygnal'const Counter = component({ name: 'Counter', view, intent, model, initialState: { n: 0 } })// afterfunction 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.
Undocumented leftovers
Section titled “Undocumented leftovers”| Before | After |
|---|---|
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 name | the 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_UPDATING | nothing (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.
Timing
Section titled “Timing”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.x | 6.0 | |
|---|---|---|
| STATE reducer | applied in a microtask after the action | applied when the action is processed |
INITIALIZE | a 0 ms timer | at construction (synchronous) |
| intent | subscribed after a 1 ms timer | at creation: an xs.of(...) emission is applied before the first render |
BOOTSTRAP | 10 ms after mount | a microtask after the first render |
| a new child’s first render | waited until its intent listened | the same render as its creation |
| render loop guard | timers | a MessageChannel hop; no timers |
an action dispatched while another runs (a driver answering at once, an EFFECT’s next()) | could interleave | runs 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()andvi.useFakeTimers()there is nothing to advance:// 5.xrun(App); await vi.advanceTimersByTimeAsync(10) // INITIALIZE, intent, BOOTSTRAP// 6.0run(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.
Behaviour changes
Section titled “Behaviour changes”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.
Collection keys
Section titled “Collection keys”- Items without an
idunderfilterorsortare 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
idstill sees its index asstate.id, but writing its state back no longer stores that index as anid(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 setsidto the item’s own current index ({ ...state, id: 0 }on the first item) can’t be told apart from the copy it got, so thatidisn’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 withid: 0and the first id-less item no longer render the same DOM ids.- A Collection whose
fromkey 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).
Children are passed as written
Section titled “Children are passed as written”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.
Unchanged
Section titled “Unchanged”- 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,renderComponentandrenderToString.