Skip to content

Error Reference

Every diagnostic Sygnal reports has a stable code. The runtime prints it as [Sygnal CODE] Component: message. Fix. Link:

[Sygnal SYG101] Form: Intent action 'SAVE' has no model entry, so it never does anything. Add 'SAVE' to Form.model, or remove it from Form.intent. https://sygnal.js.org/reference/errors#syg101

sygnal-check prints the same code as file:line:col SYG101 Form: …. The link at the end of every message points to the entry on this page.

  • Severity: error (something is broken; thrown or logged with console.error), warn (almost certainly a bug), info (a hint that needs a judgment call; collected but not printed).
  • Reported by: the Sygnal runtime (always on, production included), the dev checks (the sygnal/diagnostics entry, loaded by the Vite plugin in dev and in Vitest), or sygnal-check (the static checker). See Diagnostics.
  • Strict mode only: reported only with strict mode on. See Strict Mode.

From a terminal, npx --no-install sygnal-check explain SYG104 prints the same entry.

SYG1xx: Wiring: intent, model, view and EVENTS

Section titled “SYG1xx: Wiring: intent, model, view and EVENTS”

Intent action has no model entry

Severity: warn · Reported by: the dev checks (sygnal/diagnostics), sygnal-check

The intent returns an action stream whose name has no matching key in model. Events on that stream are produced but nothing handles them, so the action silently does nothing. Built-in actions (BOOTSTRAP, INITIALIZE, DISPOSE, READY, and RESOURCE, which the core writes for a resources static) and internal __* actions are never reported. HYDRATE is not built in since 6.0: it is an ordinary action name.

Fix: Add a model entry with the same name, e.g. model = { SAVE: (state) => ({ ...state, saved: true }) }, or remove or rename the intent action so it matches an existing model key.

Before:

Form.intent = ({ DOM }) => ({ SAVE: DOM.click('.save') })
Form.model = { SUBMIT: (state) => ({ ...state, saved: true }) }

After:

Form.intent = ({ DOM }) => ({ SAVE: DOM.click('.save') })
Form.model = { SAVE: (state) => ({ ...state, saved: true }) }

Model entry is unreachable

Severity: info at runtime, warn in sygnal-check · Reported by: the dev checks (sygnal/diagnostics), sygnal-check

A model entry has no intent action of the same name and is not a built-in action (BOOTSTRAP, INITIALIZE, DISPOSE, READY, RESOURCE; HYDRATE is an ordinary action since 6.0), so nothing in the intent can trigger it. A request also triggers the reply actions it names ({ url, ok: 'LOADED', error: 'FAILED' } sent to a driver sink), and so does a connections entry (message, open, close, error) or a request a resources entry derives (ok, error). A RESOURCE entry replaces the built-in reducer that writes state[name] for a resources static. It may still be reached through next('ACTION'), which the runtime cannot know in advance, so the runtime check reports it as info; it counts the ok/error string literals it finds in the source of the component’s non-STATE sink functions. The static checker also accounts for next() calls and reply-action names with string literals and reports it as warn, downgraded to info when a next() call or an ok/error value uses a non-literal name. It also counts the actions a timers static names ({ every: 100, action: 'TICK' }, { frame: 'FRAME' }), which the timer driver dispatches, and those a behavior’s timers names. A behavior’s own actions ('tip.SHOW': its timers, next() calls or replies may trigger them) are not reported; the static checker follows uses through factory functions that pass their options on ((opts) => tooltip(opts)). An agent.actions key (sygnal/ai: agents dispatch it) is a trigger too, and so are the delta and tool reply actions of an LLM request ({ messages, delta: 'DELTA', tool: 'TOOL', ok: 'DONE' }); the static checker follows a request a local helper builds (LLM: (state) => ask(state.messages)).

Fix: Add the action to the component’s intent, name it in a request (HTTP: (state) => ({ url, ok: 'ACTION' })), dispatch it with next('ACTION') from another entry, or remove the dead model entry.

Before:

Search.intent = ({ DOM }) => ({ QUERY: DOM.input('.q').value() })
Search.model = {
QUERY: (state, q) => ({ ...state, q }),
CLEAR: (state) => ({ ...state, q: '' }), // nothing triggers CLEAR
}

After:

Search.intent = ({ DOM }) => ({
QUERY: DOM.input('.q').value(),
CLEAR: DOM.click('.clear'),
})

Intent selector matched no rendered element

Severity: info · Reported by: the dev checks (sygnal/diagnostics), the Sygnal runtime (every app, production included)

A selector passed to DOM.select() in the intent has not matched any element the component rendered, so its events never fire (often a typo in the class or id). The dev entry skips selectors whose class/id/tag tokens all appear in the view source (assumed conditionally rendered); others are reported as info on the first check and escalated to warn after at least 3 renders and about 2 s of render-idle time with no match. Under renderComponent, simulateEvent() on a selector that matches no rendered element throws an error instead; with { allowMissing: true } the event is dropped and SYG103 is reported.

Fix: Check the selector against the className/id in the view. If the element is rendered by a child component, handle the event inside that child instead.

Before:

function Counter({ state }) {
return <button className="increment">{state.count}</button>
}
Counter.intent = ({ DOM }) => ({ INC: DOM.click('.inc') })

After:

Counter.intent = ({ DOM }) => ({ INC: DOM.click('.increment') })

Intent selector crosses an isolation boundary

Severity: warn · Reported by: the dev checks (sygnal/diagnostics), the Sygnal runtime (every app, production included), sygnal-check

An intent selector matches nothing in the component’s own scope, but does match elements rendered inside a child component or Collection item. Child components are isolated, so the parent’s DOM source never receives events from those elements and the action never fires. The dev entry checks the real DOM after renders, renderComponent checks the mock vtree, and sygnal-check finds the class/id, or the control (DOM.click(Remove) with <Remove> rendered by the child), only in a child component’s view.

Fix: Handle the event inside the child component and send it up with PARENT (read it in the parent with CHILD.select(Child)), or broadcast it with EVENTS.

Before:

// Lane renders <Collection of={TaskCard} from="tasks" />; .delete is inside TaskCard
Lane.intent = ({ DOM }) => ({ DELETE_TASK: DOM.click('.delete') })

After:

TaskCard.intent = ({ DOM }) => ({ DELETE: DOM.click('.delete') })
TaskCard.model = { DELETE: { PARENT: (state) => ({ taskId: state.id }) } }
Lane.intent = ({ CHILD }) => ({
DELETE_TASK: CHILD.select(TaskCard).map(p => p.taskId),
})

EVENTS type selected but never emitted, or emitted but never selected

Severity: info at runtime, warn in sygnal-check · Reported by: the dev checks (sygnal/diagnostics), sygnal-check

An EVENTS bus type is emitted with nothing selecting it, or selected with EVENTS.select(type) but never emitted. The bus is a broadcast, so an event that nobody has subscribed to when it is emitted is dropped; a mismatched name usually means a typo. At runtime the emitted-not-selected case is reported when the event is emitted, while selected-not-emitted is only reported on demand via checkEventBus() (inspect/test helpers); EVENTS.select() with no type counts as selecting everything. The static checker compares string-literal types across the scanned files and reports warn, or info when non-literal emits/selects exist.

Fix: Make the names match: emit from a model entry, e.g. { EVENTS: event('TYPE', data) }, and subscribe with EVENTS.select('TYPE') in the receiving component’s intent.

Before:

Lane.model = { REMOVE: { EVENTS: event('DELETE_LANE', s => ({ id: s.id })) } }
Board.intent = ({ EVENTS }) => ({ DELETE: EVENTS.select('LANE_DELETED') })

After:

Board.intent = ({ EVENTS }) => ({ DELETE: EVENTS.select('DELETE_LANE') })

Parent prop is overwritten by a reserved view argument

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

The view is called with { ...props, state, children, slots, context, uid }, so a prop named children, slots, context or uid passed by the parent is overwritten and never reaches the child. A state prop is also reported when its value is not a state lens (a field-name string or a { get, set } object), because Sygnal reads state as the child’s state lens rather than as data. Reported once per component and prop, as a warning, or as an error in strict mode (--strict has no static SYG106 rule; the runtime strict switches are configureStrict(true), renderComponent(C, { strict: true }), run(App, drivers, { diagnostics: { strict: true } }) and globalThis.__SYGNAL_STRICT__).

Fix: Rename the prop in both parent and child (e.g. contextValue, itemId instead of uid, or item instead of state); to give the child a slice of parent state use state="fieldName".

Before:

<UserCard context={ctx} /> // overwritten by Sygnal's context

After:

<UserCard contextValue={ctx} />

Intent selector not present in the component view

Severity: warn · Reported by: sygnal-check

A class or id selector used in the intent (DOM.select('.x'), DOM.click('.x'), …) is never rendered by the component’s own view, nor by JSX passed into it as children/slots, so the action never fires. For a control (DOM.click(Add)), the view never renders <Add>. It is reported as info instead of warn when a dynamic className/id in the view might produce the name, or when the selector itself is not a static string and could not be checked.

Fix: Fix the selector to match the view, or add the class/id to the element, e.g. className="x"; use a string literal (or module-level const) selector so it can be checked. For a control, render it in the view (<Add>Add</Add>) or listen to a control the view does render.

Before:

function Todo({ state }) {
return <button className="add-todo-btn">Add</button>
}
Todo.intent = ({ DOM }) => ({ ADD: DOM.click('.add-todo-button') })

After:

Todo.intent = ({ DOM }) => ({ ADD: DOM.click('.add-todo-btn') })

Controlled input has no input listener

Severity: warn · Reported by: sygnal-check

An <input>, <textarea> or <select> has value (or a checkbox/radio has checked) set, but the component’s intent has no input/change/keyup/keydown listener on it or an ancestor. Sygnal writes the bound value back on every render, so any re-render while the user is typing or clicking resets the field; this also applies to literal values such as value="" and to value={null}/checked={null}, which clear or uncheck the field on every render (only an absent prop leaves a field uncontrolled). A key listener that is immediately filtered to one key (e.g. Enter) does not count. Input events bubble out of a child component, so a field inside a child (a Collection item) also counts as listened to when, wherever the child is rendered, its parent listens for input on an element around it (DOM.select('.signup').events('input') on the form).

Fix: Update state on input, e.g. DOM.input('.name').value() (or DOM.change(...) for toggles/selects), or drop the value/checked prop (not set it to null) to leave the field uncontrolled and read it on blur or submit.

Before:

<input className="title" value={state.title} />
// intent: SAVE: DOM.blur('.title').value()

After:

<input className="title" value={state.title} />
// intent: TITLE: DOM.input('.title').value()

Reply action has no model entry

Severity: error · Reported by: the dev checks (sygnal/diagnostics), sygnal-check

A request sent to a driver with reply actions (makeFetchDriver, driverFromAsync, the socket driver) names its reply actions, as in { url, ok: 'LOADED', error: 'FAILED' }, and the driver delivers the reply to the sending component as that action. When the component’s model has no entry with that name, the reply is dropped, usually because of a typo. The dev entry checks each request with reply actions as it is sent; sygnal-check checks string literal ok/error values returned by non-STATE sinks the message/open/close/error names in a connections static, and the action names in a timers static. Statically, only names that look like actions (UPPER_SNAKE_CASE) or are close to a model key are reported, because the checker cannot see which driver a sink goes to, and a custom driver may use an error field for data.

Fix: Use the name of an existing model entry (the message names the closest one), or add the entry: LOADED: (state, body) => ({ ...state, data: body }).

Before:

Quote.model = {
LOAD: { HTTP: (state) => ({ url: '/api/quote', ok: 'LOADED', error: 'FIALED' }) },
LOADED: (state, quote) => ({ ...state, quote }),
FAILED: (state, { status }) => ({ ...state, status }),
}

After:

LOAD: { HTTP: (state) => ({ url: '/api/quote', ok: 'LOADED', error: 'FAILED' }) },

Unknown DOM event shorthand

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

DOM.<name>(selector) is shorthand for DOM.select(selector).events('<name>'), and the DOM source accepts any property name, so a made-up name such as DOM.key('.search'), DOM.enter(...) or DOM.keyDown(...) listens for an event the browser never fires: the action silently never happens. The dev entry reports a shorthand call whose name is not a known DOM event, once per component and name. Key names and the enriched-stream helpers (.key(), .value(), .checked(), .data()) are not events.

Fix: Use the real event and an enriched-stream helper: DOM.keydown('.search').key() (add .filter(k => k === 'Enter') for one key), DOM.input('.name').value(), DOM.change('.box').checked(). For a custom event you dispatch yourself, use the explicit form DOM.select(sel).events('my-event'), which is never reported.

Before:

Modal.intent = ({ DOM }) => ({ CLOSE: DOM.escape('document') })

After:

Modal.intent = ({ DOM }) => ({
CLOSE: DOM.keydown('document').key().filter(k => k === 'Escape'),
})

EVENTS value has no string type

Severity: error · Reported by: the dev checks (sygnal/diagnostics)

A model sent a value without a string type to the EVENTS sink, so no EVENTS.select(type) can receive it and it is dropped. The usual cause is a function: a model entry that returns event(...) (for example { EVENTS: () => event('SAVED', data) }) instead of being event(...), or any other reducer that returns a function. Each emitted value is copied into a new object, so a function arrives on the bus as {}. A reducer that returns undefined or null (see SYG217) causes it too.

Fix: Make event() the sink entry itself: SAVE: { EVENTS: event('SAVED', (state, data) => payload) }; return ABORT from a reducer to send nothing.

Before:

SAVE: { EVENTS: (state) => event('SAVED', state.id) } // returns a function

After:

SAVE: { EVENTS: event('SAVED', (state) => state.id) }

Component used as a control or selector

Severity: error · Reported by: the dev checks (sygnal/diagnostics), sygnal-check

A component was passed where DOM.select() or a DOM.<event>() shorthand expects a control or a CSS selector, as in DOM.click(TodoItem). A component is not an element: it has no single root to listen on, and its elements are isolated from the parent, so there is nothing for the listener to match. Controls look like components in JSX, which makes this an easy mistake.

Fix: Handle the event inside the child and send it up with PARENT, then read it in the parent with CHILD.select(TodoItem). Or give the parent a control and render it around the child: const { Item } = controls({ Item: 'div' }), <Item><TodoItem /></Item>, DOM.click(Item).

Control given .intent, .model or .initialState

Severity: error · Reported by: the dev checks (sygnal/diagnostics), sygnal-check

A control from controls({ ... }) was given .intent, .model or .initialState, as if it were a component. Controls are elements, not components: a control renders its element with a data-control marker and nothing else, so it has no intent, model or state, and these statics are never used.

Fix: Put the intent and model on the component that renders the control, and listen to the control there: App.intent = ({ DOM }) => ({ ADD: DOM.click(Add) }). For something with its own state and actions, write a component instead.

Control rendered but never listened to

Severity: info · Reported by: sygnal-check

A component renders a control (for example <Add>), but its intent never listens to it. A control exists to link an element to an intent, so an unused one is usually a missing intent entry or a leftover from a refactor. A control the model sends element commands to (ELEMENT: { showModal: HelpDialog }) counts as used. It is info because a control may also be used only to find the element in tests (t.query(Draft)).

Fix: Listen to it in the component’s intent (ADD: DOM.click(Add)), or render the plain element (<button>) if it needs no events.

Behavior collision or unresolvable uses entry

Severity: error · Reported by: sygnal-check, the dev checks (sygnal/diagnostics)

A component’s uses static names behaviors: TaskList.uses = { pager: pager({ pageSize: 10, next: Newer }) } runs the behavior on state.pager, with its actions named pager.NEXT. Three things break that. The host’s initialState already has the key (initialState = { pager: ... }): the behavior’s slice replaces it, so the host’s value is lost. A uses value is not what a defineBehavior() factory returns (a plain object, the factory itself without the call, undefined): it is skipped, so state[key] and its actions never exist. Or (sygnal-check) an option the behavior never reads, usually a typo: pager({ nxt: Newer }) where the pager listens to next, so the control is never listened to. sygnal-check resolves uses entries to defineBehavior() calls in the same file and through relative imports, and knows the first-party pager, selection and undo; a behavior from a package is opaque and never reported. The sygnal/diagnostics dev entry checks each component definition once; production builds don’t. A host intent or model entry with the behavior’s namespaced name ('pager.NEXT') is not a collision: it is how a host overrides or extends a behavior action.

Fix: Remove the key from the host’s initialState (set the behavior’s values through its options: pager({ pageSize: 10 })), or rename the uses key. For an unresolvable entry, call the factory: uses = { pager: pager() }, with pager = defineBehavior({ ... }). For an unknown option, use the name the behavior reads (the message lists them and suggests the closest).

Duplicate control key

Severity: error · Reported by: sygnal-check

Two controls() calls in one file declare the same key, or one call repeats a key. Every control renders data-control="<Key>" and is selected by that key, so two controls with one key match each other’s elements: a listener on one also fires for the other.

Fix: Rename one of the keys, or declare both controls in a single controls({ ... }) call.

CHILD.select() of a component this one doesn’t render

Severity: warn · Reported by: sygnal-check

An intent reads CHILD.select(TaskRow), but the component’s view doesn’t render TaskRow: a component it renders does (a grandchild, for example a Collection item inside a child). A component’s PARENT output reaches only the component that renders it, so the action never fires and nothing says why. sygnal-check follows the views it can read and reports the chain (App > ProjectSection > TaskRow). It says nothing when the view renders the component itself (by tag, as a Collection or Switchable target, or passed into a child), mentions its name some other way, has a Collection or Switchable target it can’t resolve, or when nothing it can see renders the component.

Fix: Relay the value through the component in between: in ProjectSection.intent read RELAY: CHILD.select(TaskRow), in ProjectSection.model send it on with RELAY: { PARENT: (state, data) => data }, and read CHILD.select(ProjectSection) in App. For a value many components need, broadcast it with EVENTS instead.

href() names no route or leaves out a param

Severity: error · Reported by: the dev checks (sygnal/diagnostics)

router.href(name, params) builds a link from the route table passed to makeRouter({ routes }). When name is not a route (or is the '*' not-found route), href() returns the app’s root path; when the pattern has a :param that params leaves out, that segment is empty (/tasks/). Either way the link goes somewhere else than intended. The sygnal/diagnostics dev entry checks every href() call; production builds don’t. A { to } command with the same problem is SYG620.

Fix: Use a route name from the table (the message suggests the closest one) and pass every param the pattern names: href('task', { id: task.id }).

Route params the pattern does not use

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

An href() call or a { to, params } router command passes a param that the route’s pattern has no :name for. It is ignored, so the value never reaches the URL; usually it was meant for the query string, or the route is the wrong one.

Fix: Pass only the pattern’s params, and put other values in the query: href('tasks', {}, { page: 2 }) or { to: 'tasks', query: { page: 2 } }.

Declaration static that is never sent

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

Declaration statics (route, resources, connections, head) are computed from the component’s state and sent to their driver whenever they change, with or without a model. A root component (the one passed to run()) with a model but no initialState has no state, so its state stream never emits: the route never arrives, the resources never load, the connections never open, the head never changes. Sub-components share their parent’s state, so this only happens at the root.

Fix: Give the root an initialState, even an empty object; for the router, seed the route: App.initialState = { route: router.current() }. Don’t add an initialState to a sub-component that shares its parent’s state: it would replace that state (SYG405).

SPA router inside a Vike app

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

makeRouter() intercepts link clicks and writes the history itself, and so does Vike’s client router. Running the SPA router inside a Vike app makes both handle each click and navigation. In Vike, give the router Vike’s navigate(): it then leaves links and history to Vike, navigates with navigate(), and re-reads the route after each Vike navigation.

Fix: makeRouter({ routes, navigate }) with import { navigate } from 'vike/client/router', using a route table that mirrors the Vike routes; or read the route from Vike’s page context and drop the router.

Widget dispatched an undeclared event

Severity: warn · Reported by: the dev checks (sygnal/diagnostics), sygnal-check

A widget made with defineWidget() called dispatch(name, detail) (the third parameter of mount) with a name that is not in its events list. The event is still dispatched (a bubbling CustomEvent on the host), but events is what the docs, sygnal-check and the other widget diagnostics read: a listener for the declared name never hears this one, which is usually a typo (dispatch('chnage', d)) or a declaration that was never added. The dev entry reports it once per widget and name when it is dispatched; sygnal-check reports a literal dispatch('x') inside mount() (whatever the parameter is called) when events is a literal list.

Fix: Add the name to the widget’s events (defineWidget({ events: ['pick', 'clear'], ... })), or dispatch one of the declared names.

Listener for an event the widget does not declare

Severity: warn · Reported by: sygnal-check

The intent listens for an event on a widget’s host (DOM.select('.due').events('pikc'), or a widget control: DOM.select(Due).events('pikc')) that the widget doesn’t list in its events, that no element fires natively, and that is a near-typo of a declared event, so the action most likely never fires. sygnal-check resolves the selector through the static className and id of the elements the component’s own view renders: it reports only when every element with that class or id is a widget host and the widgets’ events are literal lists. Native event names (click, focus, change…) are never reported, since the host or its content fires them; neither is a name that isn’t close to a declared one (a library such as Choices.js fires its own events on the host element), nor any name on a custom element host (tag: 'my-editor'), which may fire it itself. The runtime can’t tell which listener a selector serves, so this code is static only.

Fix: Listen for the declared event the name is close to (DOM.select('.due').events('pick').detail()), or add the name to the widget’s events and dispatch it from mount().

Widget command not declared

Severity: error · Reported by: the dev checks (sygnal/diagnostics), sygnal-check

An element command (ELEMENT: { open: '.due' }) reached a widget’s host, but the widget declares no command of that name and the host element has no such method, so it can’t run (for a non-widget element this is SYG641). The message lists the commands the widget declares. A widget’s declared command wins over a native method of the same name (focus runs the widget’s focus; close and togglePopover too, with the options object). The dev entry reports it when the command reaches the element. sygnal-check reports literal commands: as an error when the name is close to a declared command (opne), as info otherwise (a library may add the method to the host element), and not at all for a custom element host (tag: 'my-editor'), which may have the method itself.

Fix: Send one of the widget’s commands, or add the command to its definition: defineWidget({ commands: { open: (instance, options) => instance.open() } }).

Widget tag used as a selector

Severity: warn · Reported by: the dev checks (sygnal/diagnostics), sygnal-check

The tag defineWidget() returned was used where a selector is expected: DOM.select(DatePicker), DOM.click(DatePicker), ELEMENT: { open: DatePicker } or inside a template string. A widget tag is a JSX tag, not a selector (several hosts of the same widget may render), so it matches nothing and the action never fires. The dev entry reports it once per widget; sygnal-check reports it for intent selectors and element command targets.

Fix: Give the widget a className and select that: <DatePicker className="due" /> with DOM.select('.due').events('pick') and ELEMENT: { open: '.due' }. Or make it a control (the alternative form): const { Due } = controls({ Due: DatePicker }), then DOM.select(Due) and { open: Due }.

Widget event the host element also fires natively

Severity: info · Reported by: the dev checks (sygnal/diagnostics), sygnal-check

A widget declares an event whose name the browser also uses ('change', 'input', 'select', 'toggle'…). Native events of that name fire on the host or bubble up from inside it (flatpickr’s own input fires change as well), so a listener on the host gets both the widget’s CustomEvent and the native event, and .detail() is undefined for the native one. Information only: it may be what you want. The dev entry reports it once per widget when a host mounts (names the host element knows as on<name>); sygnal-check reports the literal events entries it recognises. Sygnal’s own parts are not reported (Menu’s select is its documented event, and nothing inside the menu fires a native select).

Fix: Give the widget’s event its own name ('pick' for a date picker’s selection, 'rate' for a rating) and emit that, or make the listener handle both (.filter((e) => e instanceof CustomEvent)).

Sortable item without its id attribute

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

The sortable behavior finds the item a pointer press or a key is on with event.target.closest(item) and reads its id from the item element’s attr attribute (data-id by default), then looks that id up in state[from] (by idField, id by default). An item element without the attribute can’t be matched to an entry of the list, so the press or key is ignored and the item can’t be moved. Usually the item renders its id under another attribute name, or the item selector matches an element inside the one that carries the id. Reported once per item selector, at the first press or key on such an item.

Fix: Render the id on the item element (<li className="task" data-id={state.id}>), point item at the element that carries it, or name the attribute: sortable({ from: 'tasks', item: '.task', attr: 'data-key' }).

Sortable item or handle selector matches nothing

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

At the host’s first pointer press or key press, the sortable behavior checks its selectors against what the host rendered (its children and Collection items included): item is reported when elements carry the id attribute (attr) but none matches item, and handle when items exist but none contains an element matching handle. Either way no item can be picked up, by pointer or keyboard. An empty list can’t tell a typo from no items, so the check waits for a later interaction. Reported once per selector.

Fix: Use the selector of the elements the items render: item: '.task' for <li className="task" data-id={id}>, handle: '.grip' for the drag handle inside it, or leave handle out to drag the whole item.

Sortable list key is not an array in the host state

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

sortable({ from }) reorders state[from] (each key of an array from for moves between lists). When the host starts, a from key whose value is missing or not an array is reported: nothing in it can be found, so no item can be moved. The message suggests a close array key of the state when there is one. Reported once per key; a list that arrives later should start as an empty array.

Fix: Name the array in the host’s state (from: 'tasks' for initialState: { tasks: [...] }), and start a list that is loaded later as [].

Collection viewTransitionName is not a CSS identifier

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

A <Collection> or <VirtualCollection> has a viewTransitionName that is not a string starting a CSS identifier (a letter, _, - followed by a letter or _, or --; then letters, digits, - and _). Each item’s name is <prefix>-<id>, so with a prefix such as '1card', 'my card' or 'card.x' every name is invalid CSS: the browser ignores the view-transition-name and nothing animates, with no error. The dev entry reports it once per component and prefix when the Collection renders. An empty string, 0 or false names no item and is not reported.

Fix: Use a CSS identifier as the prefix: viewTransitionName="card" (letters, digits, - and _, not starting with a digit). The ids are escaped for you.

Two items with the same view-transition name

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

After a render, two rendered elements on the page (not in a display: none subtree, such as a hidden tab panel: the browser doesn’t capture those) have the same view-transition-name from Collections with the same viewTransitionName prefix: the same item id is in two of them at once (an ‘All’ list and a ‘Favorites’ list both named card). A name must be unique when a View Transition starts, so the browser skips the whole transition (Chromium logs Unexpected duplicate view-transition-name). The same prefix on several Collections is meant for one item moving between them, never shown in two at once. The dev entry checks the rendered page after each patch while a Collection with a prefix is mounted, and reports each name once.

Fix: Give Collections that can show the same item at the same time different prefixes (viewTransitionName="all" and viewTransitionName="fav"), or set the item’s own style={{ viewTransitionName }} where it needs one.

Agent action has no model entry

Severity: warn · Reported by: sygnal-check

A component’s agent declaration (sygnal/ai) lists an action under actions that the component’s model has no entry for (and no behavior of its uses owns). The agent layer offers it as a tool, and every call dispatches an action nothing handles, so the call fails with “changed nothing” and the model retries or gives up. Usually a typo, or an entry renamed on one side only. sygnal-check reports it when the declaration and the model are object literals it can read (no spreads), and suggests the closest model entry. TypeScript catches it only when every key is wrong.

Fix: Rename the key to the model entry it means (actions: { ADD: { description: 'Add a todo', input } } for model.ADD), or add the model entry: TodoApp.model = { ..., ADD: (state, text) => ({ ...state, todos: [...state.todos, { id: state.nextId, text }] }) }.

Misspelled agent static

Severity: warn · Reported by: sygnal-check

A component has a static named like the agent declaration but spelled differently: agents, tools, Agent, tool. The agent layer (the chat behavior, WebMCP, the command bar, t.tools()) reads only Component.agent, so the component offers no tools and its read projection never reaches the model, without any error. Reported on a component (a function with other Sygnal statics) or on an object with actions or read.

Fix: Rename the static: TodoApp.agent = { name: 'todos', description: 'The todo list', read: (state) => ({ todos: state.todos }), actions: { ADD: { description: 'Add a todo', input: z.string() } } }.

LLM request without an ok action

Severity: warn · Reported by: sygnal-check

A model sends an LLM request (an object with messages, sent to the chat driver of sygnal/ai) that names no ok reply action. The driver delivers the finished reply (the assistant message, its text, toolCalls, finishReason and usage) only as the ok action, so the reply is lost: a delta action shows the text while it streams, but the final message never reaches the state and the status never leaves streaming. sygnal-check follows requests that local helpers build; a request with a spread ({ ...base, messages }) is not reported, since the spread may name ok.

Fix: Name the actions the reply arrives as, and handle them: LLM: (state) => ({ messages: state.messages, delta: 'DELTA', ok: 'DONE', error: 'FAILED' }) with DONE: (state, { message }) => ({ ...state, messages: [...state.messages, message], draft: '' }). For a whole conversation with tools, the chat behavior does this for you.

WebMCP form attribute set as a DOM property

Severity: warn · Reported by: sygnal-check

A <form> has toolname, tooldescription or toolautosubmit (or a field has toolparamdescription) written as a plain JSX attribute. WebMCP’s declarative API reads these as HTML attributes, but Sygnal’s JSX pragma turns an attribute it doesn’t know into a DOM property (form.toolname = ...), which the browser ignores: the form is never offered to the browser’s agent, and nothing reports it at run time (D269).

Fix: For a form with the form behavior, give it a tool and let it write every attribute: form(schema, { values, submit: 'SIGN_UP', tool: formTool({ name: 'sign_up', description: 'Create an account' }) }) with formTool from sygnal/ai (experimental; each field’s <label>, then its aria-label, becomes its toolparamdescription). Otherwise write them as attributes: <form attrs-toolname="sign_up" attrs-tooldescription="Create an account"> and <input name="email" attrs-toolparamdescription="The email to sign up with" /> (or attrs={{ toolname: 'sign_up' }}).

STATE reducer dropped keys from the previous state

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

A STATE reducer returned a plain object that is missing keys present (and not undefined) in the previous state, which usually means a missing ...state spread. Those keys are silently lost from state. Calculated fields, INITIALIZE and internal __* actions are excluded; reported once per component and action.

Fix: Spread the previous state: (state, data) => ({ ...state, field: data }). If removing the keys is intended, ignore the warning.

Before:

SET_NAME: (state, name) => ({ name })

After:

SET_NAME: (state, name) => ({ ...state, name })

STATE reducer returned undefined

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

A STATE reducer returned undefined, typically from a block-bodied arrow function without return. In a root component this wipes the state (a warning). Returning undefined from a Collection item’s reducer is the documented way to remove the item, so it is not reported there; in another sub-component (a child rendered by tag) it is reported as info. INITIALIZE and internal __* actions are excluded.

Fix: Return the new state, e.g. (state, data) => ({ ...state, ... }), or return ABORT to leave the state unchanged.

Before:

ADD: (state, item) => { ({ ...state, items: [...state.items, item] }) }

After:

ADD: (state, item) => ({ ...state, items: [...state.items, item] })

Invalid calculated field definition

Severity: error · Reported by: the Sygnal runtime (every app, production included)

An entry in calculated is neither a function nor a two-element [deps, fn] array where deps is an array and fn is a function. The component constructor throws, so the component is never created.

Fix: Define each field as state => value or [['a', 'b'], state => value].

Before:

Cart.calculated = { total: 'items' }

After:

Cart.calculated = { total: [['items'], state => state.items.length] }

Calculated field shadows an initialState key

Severity: warn · Reported by: the Sygnal runtime (every app, production included)

A key in calculated also exists in initialState. The calculated value overwrites the stored value of the same name, so the initialState value is never visible. Checked once when the component is constructed.

Fix: Rename either the calculated field or the initialState key.

Before:

Cart.initialState = { items: [], total: 0 }
Cart.calculated = { total: state => state.items.length }

After:

Cart.initialState = { items: [] }
Cart.calculated = { total: state => state.items.length }

Calculated field depends on an unknown key

Severity: warn · Reported by: the Sygnal runtime (every app, production included)

A calculated field declared as [deps, fn] lists a dependency that is neither another calculated field nor a key of initialState. The dependency is likely misspelled, so memoization compares an always-undefined value. Only checked when the component has an initialState.

Fix: Fix the dependency name, or add the key to initialState.

Before:

Cart.calculated = { total: [['itmes'], state => state.items.length] }

After:

Cart.calculated = { total: [['items'], state => state.items.length] }

Circular calculated field dependency

Severity: error · Reported by: the Sygnal runtime (every app, production included)

Calculated fields declared with [deps, fn] depend on each other in a cycle (the message shows the path, e.g. a -> b -> a), so no evaluation order exists. The component constructor throws.

Fix: Break the cycle so that each calculated field depends only on state keys or on fields that do not depend back on it.

Before:

Comp.calculated = {
a: [['b'], state => state.b + 1],
b: [['a'], state => state.a + 1],
}

After:

Comp.calculated = {
a: [['base'], state => state.base + 1],
b: [['a'], state => state.a + 1],
}

INITIALIZE used with a sink other than STATE

Severity: warn · Reported by: the Sygnal runtime (every app, production included)

The model defines INITIALIZE as an object with sinks other than the state sink (e.g. EVENTS or EFFECT). INITIALIZE only supports the state sink, so those other sink entries are deleted and never run.

Fix: Keep only STATE under INITIALIZE and move the other sinks to a different action (for example one triggered by BOOTSTRAP or via next()).

Before:

INITIALIZE: {
STATE: (state) => ({ ...state, ready: true }),
EFFECT: () => analytics.track('start'),
}

After:

INITIALIZE: (state) => ({ ...state, ready: true }),
BOOTSTRAP: { EFFECT: () => analytics.track('start') },

Retired in 6.0: Invalid model shorthand entry

Severity: error · Reported by: retired

Retired in 6.0 and never reported. Before 6.0 it was thrown for a malformed 'ACTION | SINK' model key (one that did not split into an action and a sink). Sygnal 6.0 removed those keys (the object form is the only form), so there is nothing to split; a removed key is reported as SYG612 (dev) and SYG504 (sygnal-check --strict).

Fix: Use the object form: SAVE: { EFFECT: (state, data, next) => … }. See https://sygnal.js.org/guide/migrating-to-6#pipe-keys.

Model entry is not a function or an object of sinks

Severity: error · Reported by: the Sygnal runtime (every app, production included)

A model entry’s value is neither a function (a STATE reducer) nor a plain object mapping sink names to reducers; for example a string, number, array or null. Model setup throws when the component is initialized.

Fix: Use a reducer function or a sink map, e.g. { STATE: (state) => ..., EVENTS: ... }.

Before:

Comp.model = { RESET: null }

After:

Comp.model = { RESET: (state) => ({ ...state, count: 0 }) }

Retired in 6.0: Duplicate model entry for the same action and sink

Severity: warn · Reported by: retired

Retired in 6.0 and never reported. Before 6.0 it warned when an 'ACTION | SINK' key and an object-form entry defined the same action and sink. With the shorthand keys removed in 6.0, an action/sink pair can be defined only once.

Fix: Nothing to change. Use one object-form entry per action: SAVE: { STATE: …, EFFECT: … }.

EFFECT handler threw

Severity: error · Reported by: the Sygnal runtime (every app, production included)

A model entry on the EFFECT sink threw an exception. The error is caught and logged with console.error (with the original error attached) rather than thrown, so the app keeps running but the effect did not complete. In diagnostics error mode the diagnostic is rethrown asynchronously. If the exception is itself a coded Sygnal error (for example SYG215 from next()), it is reported under that code instead.

Fix: Fix the exception shown in the attached error, or guard the handler against missing state or data.

next() delay is not a number

Severity: error · Reported by: the Sygnal runtime (every app, production included)

The third argument of next(type, data, delay) was given and is not a number. next() throws and the follow-up action is not dispatched. When it is called inside a reducer or EFFECT handler, the handler catches the throw and reports it under this code (not SYG216 or SYG214), so a STATE reducer leaves the state unchanged and a driver-sink reducer sends nothing.

Fix: Pass a number of milliseconds, or omit it for the default 10 ms: next('ACTION', data, 100).

Before:

next('RETRY', null, '500')

After:

next('RETRY', null, 500)

Reducer threw

Severity: error · Reported by: the Sygnal runtime (every app, production included)

A model reducer threw an exception. For a STATE reducer the error is logged and the state is left unchanged; for a driver sink (e.g. EVENTS, PARENT, custom drivers) it is logged and nothing is sent. The error is logged with console.error rather than thrown (rethrown asynchronously in diagnostics error mode). A coded Sygnal error thrown inside the reducer (for example SYG215 from next()) is reported under its own code instead.

Fix: Fix the exception shown in the attached error, or guard the reducer against missing state or data.

Driver-sink reducer returned undefined

Severity: warn · Reported by: the Sygnal runtime (every app, production included)

A reducer for a non-STATE sink (e.g. EVENTS, PARENT or a custom driver) returned undefined, often from a missing return. The undefined value is still sent to the driver.

Fix: Return the value the driver expects, or return ABORT to send nothing.

Before:

NOTIFY: { LOG: (state) => { `Saved ${state.id}` } }

After:

NOTIFY: { LOG: (state) => `Saved ${state.id}` }

Reducer returned an unsupported type

Severity: error · Reported by: the Sygnal runtime (every app, production included)

A reducer for a non-STATE sink (e.g. EVENTS, PARENT or a custom driver) returned a symbol other than ABORT. Any other value, including null, arrays and bigints, is sent to the driver as-is, like a constant sink value. It is logged with console.error (not thrown) and nothing is sent.

Fix: Return the value the driver expects, or return ABORT to send nothing.

EFFECT handler returned a value

Severity: warn · Reported by: the Sygnal runtime (every app, production included)

An EFFECT handler returned something other than undefined. EFFECT entries run only for side effects, so the return value is ignored; this usually means the logic was meant for a STATE or driver sink. Returning ABORT from an EFFECT also triggers it.

Fix: Do not return a value from EFFECT handlers; move value-producing logic to a STATE or driver sink entry.

Before:

PLAY: { EFFECT: () => player.play() } // returns a Promise

After:

PLAY: { EFFECT: () => { player.play() } }

Calculated field threw

Severity: warn · Reported by: the Sygnal runtime (every app, production included)

A calculated field function threw while computing derived state, for example by reading a property of missing data. The error is caught and the field is skipped for that update, so it is not recomputed from the new state.

Fix: Guard the calculation against missing data, e.g. state => state.user?.name ?? ''.

Before:

Profile.calculated = { name: state => state.user.name }

After:

Profile.calculated = { name: state => state.user?.name ?? '' }

set() called with a string

Severity: error · Reported by: the dev checks (sygnal/diagnostics)

set() takes an object to merge (set({ open: true })) or a function that returns one (set((state, data) => ({ ... }))). Called with a field name, set('city') spreads the string into the state, adding the keys '0', '1', … one per character, and the field itself never changes. The dev entry recognises those keys after the reducer runs and reports the field name; TypeScript also rejects a string argument.

Fix: To store the action data in a field, pass a function: set((state, city) => ({ city })). For a fixed value pass an object: set({ city: 'Paris' }).

Before:

CITY: set('city')

After:

CITY: set((state, city) => ({ city }))

STATE reducer returned the same object after it was mutated in place

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

A STATE reducer changed the state object it received (state.count++, state.done = true, delete state.x) and returned that same object. Since 6.0 a reducer that returns the object it got means “no change” (the same as ABORT), so the change is ignored: no state is emitted and nothing re-renders. The dev entry takes a shallow snapshot of the state (its keys and top-level values) before each STATE reducer runs and compares it when the reducer returns the same object; the report names the changed keys. Mutations below the top level (state.items.push(x)) are not seen. In production the mutation is silently ignored.

Fix: Return a new object: INC: (state) => ({ ...state, count: state.count + 1 }). To write updates as mutations, wrap the reducer in immer’s produce(): INC: produce((draft) => { draft.count++ }), which returns a new object when something changed and the same object when nothing did.

persist pick or omit names a key that is not in initialState

Severity: warn · Reported by: the dev checks (sygnal/diagnostics), sygnal-check

App.persist = persist({ key, pick, omit }) saves the top-level state keys listed in pick (or every key except those in omit). A name in either list that is not a key of the component’s initialState is usually a typo or a renamed key: with pick that key is never saved (and never restored), with omit the key you meant to leave out is saved after all. The dev entry reports it when the root component is created; sygnal-check reports it statically when the options and initialState are literals, with the closest key as a suggestion.

Fix: Use top-level keys of initialState: persist({ key: 'todo-app', pick: ['todos', 'filter'] }), or add the key to initialState.

persist on a component that is not the root

Severity: error · Reported by: the dev checks (sygnal/diagnostics), sygnal-check

persist({ ... }) is set up by the root component only (the one passed to run() or renderComponent()): it saves and restores the app’s whole state tree, and a sub-component’s state is a slice of it. On any other component the persist static is ignored, so nothing is saved or restored. In Vike, a Page, Layout or Wrapper rendered in the Layout/Wrapper shell is a sub-component (the shell is the app’s root), so persist isn’t supported there yet, and the message says so; a Vike page with no Layout or Wrapper is its own root, where persist works. The dev entry reports it when the component is created; sygnal-check reports it when a run() call in the scanned files renders the component and none has it as its root.

Fix: Move persist to the root component and pick the key that holds the sub-component’s state: App.persist = persist({ key: 'app', pick: ['todos'] }) for a child rendered with state="todos". In a Vike Layout/Wrapper shell, remove it and save the state yourself (STATE.watch writing to localStorage, read back in BOOTSTRAP).

Undo track, resetOn or coalesce names an unknown action

Severity: warn · Reported by: the Sygnal runtime (every app, production included), sygnal-check

undoable(model, { key, track, resetOn, coalesce }) (and the undo({ ... }) behavior) records changes to state[key] made by the actions in track, clears the history on the actions in resetOn, and groups quick repeats of the actions in coalesce into one step. A name in any of these lists that has no model entry never runs, so with track its changes are never recorded (undo skips them), with resetOn the history is never cleared (after a load, undo would bring back the old document), and with coalesce the action meant to be grouped (typing) is recorded one step per change. It is usually a typo or a renamed action. For undo() the names are the host component’s actions, including other behaviors’ namespaced ones ('pager.NEXT'). undoable() reports it when diagnostics are on (dev, renderComponent) as it wraps the model, undo() when the component is first created; sygnal-check reports it statically, with the closest model entry as a suggestion.

Fix: Use the name of a model entry (track: ['TYPE'], resetOn: ['LOADED'], coalesce: ['TYPE']), or add the entry to the model. Leave track out to record every action that changes state[key].

Form field name not in values

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

The form behavior hears input and focusout on its form element and reads the event target’s name as a path in values (email, addresses.7.city for the row with id 7). A named field inside the form whose name is not a field of values is ignored: its value never reaches state.form and it is never validated. It is usually a typo, a renamed field, a row name built with the index instead of the row’s id, or an input that belongs to something else (a search box) placed inside the form element. Reported once per name, on input; fields without a name are skipped silently, and so is a focusout from a named element that isn’t a field (a <button name="intent">). A field whose value is undefined (an optional field: values: { nick: undefined }) is a field.

Fix: Name the input after a path in values (name="email", or name={f.email.name} from state.form.fields), add the field to values, or move the unrelated input out of the form element.

form() schema is not a Standard Schema

Severity: error · Reported by: the dev checks (sygnal/diagnostics)

form(schema, options) validates with any Standard Schema (https://standardschema.dev): an object with ~standard.validate, which zod (3.24+), valibot (1.0+), arktype and others provide. Anything else (a plain object of rules, a validator function, an older library’s schema with only parse) can’t validate, so form() throws when it is called, with this code in dev; in production the first validation throws a plain TypeError.

Fix: Pass a Standard Schema object: form(z.object({ email: z.string().email() }), { ... }), form(v.object({ ... }), { ... }), or a hand-written { '~standard': { version: 1, vendor: 'app', validate: (values) => ({ value }) | ({ issues: [{ message, path }] }) } }.

Form submit dropped while one is in progress

Severity: info · Reported by: the dev checks (sygnal/diagnostics)

A form.SUBMIT (the form element’s submit, or simulateAction('form.SUBMIT')) arrived while state.form.submitting was true (a valid submit sent its request and neither form.DONE nor form.ERRORS has answered it), or while a submit was queued waiting for an async check or an async schema. The form drops it, so a double click never sends twice. Only a submit whose model entry sends on the HTTP sink (the http option) stays submitting; any other submit is done at once. Info only: it is how the form protects the submit, but a form that never leaves submitting usually means the request has no ok: 'form.DONE' / error: 'form.ERRORS'.

Fix: Disable the submit button while state.form.submitting is true, and answer the submit action with reply actions: SIGN_UP: { HTTP: (state, values) => ({ url, json: values, ok: 'form.DONE', error: 'form.ERRORS' }) }.

Form value missing from the schema output

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

On a valid submit the form dispatches the submit action with the schema’s output (trimmed, transformed values), not with state.form.values. A top-level key of values that the output doesn’t have was stripped (or renamed) by the schema, usually because the schema doesn’t declare it (z.object() and v.object() drop unknown keys), so the submit action never gets that field. Reported once per key, and only when every key of the output is a key of values: an output with keys of its own comes from a schema that reshapes the values (a transform that renames), which is left alone.

Fix: Declare the field in the schema, make the schema keep unknown keys (z.looseObject, v.looseObject), or remove the field from values if it isn’t part of the form.

Form submit action has no model entry

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

form(schema, { submit: 'SIGN_UP' }) dispatches the host action named by submit with the schema’s output when a submit is valid. When the option is missing, or the host component has no model entry of that name, a valid submit does nothing visible (the form marks it done, submitted: true, and nothing else happens). Naming one of the form’s own actions ('SUBMIT', 'DONE', 'RESET', 'ADD', 'VALIDATE', a check’s 'CHECKED_email', …) dispatches that form action instead ('form.RESET', since next() prefers the behavior’s own names): the host’s entry of that name never runs, and the form’s action does something else (RESET clears the form) or throws (SYG214 / SYG216 for 'form.ADD'). Reported when the component is created, and again at the first such submit.

Fix: Name a host action the form doesn’t define and add it to the model: submit: 'SIGN_UP' with SIGN_UP: { HTTP: (state, values) => ({ url: '/api/signup', method: 'POST', json: values, ok: 'form.DONE', error: 'form.ERRORS' }) }.

Form check names an unknown field or sets reply fields

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

check: { email: { request, error } } runs an async check for a field through a driver: the form sends request(value) with its own reply action (ok/error set to 'form.CHECKED_email') and latest: true, so a newer value’s check replaces an older one. A check key that isn’t a field of values never runs (reported when the component is created), and a request that sets ok, error or latest has them replaced by the form’s (reported when the check runs).

Fix: Use a field name from values as the key, and return only the request from request: check: { email: { request: (email) => ({ url: '/api/email-free', query: { email } }), error: (body) => !body.free && 'Taken' } }.

Form array rows without an id

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

Field arrays name their fields by row id (addresses.7.city), so errors, touched state, server errors and focus stay with a row when another row is removed. An array of objects in values whose rows have no id falls back to the index, and after a removal the next row inherits the removed row’s errors and touched state. form.ADD gives new rows the next id itself; the start values need ids too. Reported when the component is created.

Fix: Give each row a unique id in values: addresses: [{ id: 1, street: '', city: '' }], render rows with a Collection, and name their inputs addresses.${state.id}.city.

Two forms in one component listen on the same form selector

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

Each form behavior hears input, focusout and submit on its form element, found by the form option inside the host component (default 'form'). Two form uses in one component with the same selector (both left at the default, usually) both match every form element of the view, so typing in one form changes the other’s values too (when the names overlap) and each submit is handled by both. Reported when the component is created.

Fix: Give each form element its own class and pass it as the form option: uses = { login: form(loginSchema, { ..., form: '.login' }), news: form(newsSchema, { ..., form: '.news' }) } with <form className="login"> and <form className="news">.

Root component with a model but no initial state

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

A root component (the one passed to run() or renderComponent()) has a model but no initialState, so its state is undefined and its view doesn’t run: the page (or the test’s t.html()) stays empty until an action sets the whole state. A root without a model renders anyway (from initialState, or with no state), and a child gets its state from its parent, so neither is reported. The state may also come from renderComponent(C, { initialState }) or an HMR swap; then nothing is reported either. Reported once per component.

Fix: Give the root its start state: App.initialState = { count: 0 }. If the state really arrives later (from a request), start from a placeholder such as { status: 'loading' } and render that.

Agent action or query input has no JSON Schema form

Severity: error · Reported by: the Sygnal runtime (every app, production included), sygnal-check

An agent.actions or agent.queries entry’s input must be a Standard Schema that also implements Standard JSON Schema (Zod 4.2+, ArkType 2.1.28+, Valibot wrapped with toStandardJsonSchema() from @valibot/to-json-schema), or a plain JSON Schema wrapped with jsonSchema() from sygnal/ai. The agent layer sends the model the input-side JSON Schema and validates every call with the schema before the action or query runs. This input is something else: not a Standard Schema (a raw JSON Schema object, a function, an older library’s schema), a Standard Schema without a JSON Schema form (Valibot without the wrapper, Zod Mini), or one whose conversion throws even in the library’s lossy mode. The tool is not offered, because a tool the model can’t call correctly only produces failed calls and a schemaless one corrupts state. Reported once per action or query while an agent layer runs (the chat behavior, WebMCP, t.tools()); in production the tool is left out silently, and t.tools() lists it with this error. sygnal-check reports the cases it can see in the source before the app runs: a Valibot schema without toStandardJsonSchema(), a Zod Mini schema (zod/mini), and a JSON Schema object literal not wrapped with jsonSchema().

Fix: Use a supported schema: input: z.string().min(1), input: toStandardJsonSchema(v.picklist(['all', 'done'])), or input: jsonSchema({ type: 'integer' }). For Zod Mini keep its validation with jsonSchema(z.toJSONSchema(s), { validate: s }). A Date can’t come from JSON, and Zod’s JSON Schema conversion throws for z.date() and z.coerce.date() alike (the field reaches the model with no schema, SYG243): take an ISO string, z.iso.datetime() (convert it in the reducer) or z.iso.datetime().transform((s) => new Date(s)) (the model sees the string, the action gets a Date).

Agent read, query, when, until or label threw

Severity: error · Reported by: the Sygnal runtime (every app, production included)

The agent layer calls a component’s agent.read(state) (the projection an agent sees), each action’s and query’s when(state) (whether the tool is offered) and agent.label(state) (a Collection item’s label) with the instance’s current state, once per state change, a query’s run(state, input) when an agent calls it, and an action’s until(state) while its call waits. One of them threw. A throwing read gives no projection (undefined in the context and in tool results), a throwing when hides the tool, a throwing label leaves the key unlabelled, a throwing query’s call fails with { ok: false, error } carrying the message, and a throwing until ends the wait (the call answers with the state of now). The app keeps running. Reported once per declaration and function.

Fix: Make the function total over every state the component can have: guard optional fields (state.todos?.length ?? 0), and keep it a pure function of the state it gets.

WebMCP tool over a size budget

Severity: warn · Reported by: the Sygnal runtime (every app, production included)

experimentalExposeWebMcp() keeps every tool inside Chrome’s WebMCP budgets, which neither Chrome 153 nor the polyfill enforce: a tool name of at most 30 characters, a description of at most 500, a parameter description of at most 150, and a result of at most 1,500 characters of JSON. Something went over, and it was cut so the agent still gets a usable tool: a long name keeps its first 25 characters plus _ and a 4-character hash of the whole (so names stay unique), a description or parameter description ends in …, and a long result keeps ok and error, gets truncated: true, and carries its state as a cut JSON string. The state summary appended to descriptions (D250) is shortened to fit without a report. Reported once per tool and part, in development.

Fix: Shorten the agent declaration’s name, the action names or the description texts, or make read return a smaller projection (counts and the fields an agent needs, not the whole state). A Collection item tool’s key description lists every item’s label: a shorter label keeps more of them.

Agent action or query input schema converted with losses

Severity: warn · Reported by: the Sygnal runtime (every app, production included), sygnal-check

Part of an agent.actions or agent.queries entry’s input has no JSON Schema form, for example a refinement (Valibot check, ArkType narrow) or a Date, so the library’s lossy conversion left it out of the schema the model gets. The model doesn’t see that rule, so it may send values that break it; the layer still validates every call with the full schema and returns the issues to the model. Zod drops refinements without saying so, so no warning is possible there. Reported once per action or query. sygnal-check reports a Date in an input before the app runs (z.date(), z.coerce.date(), Valibot v.date(), ArkType 'Date'): JSON can’t carry a Date, so the model gets no schema for that field and the string it sends fails validation.

Fix: Put the rule in the description the model reads (.describe('at least 2 characters, must contain @')), or express it with a JSON Schema keyword (minLength, pattern, enum) instead of a refinement. For a date, take an ISO string and convert it in the reducer: z.iso.datetime() (Zod), v.pipe(v.string(), v.isoDateTime()) (Valibot), 'string.date.iso' (ArkType).

Agent read returns strings, untrusted not declared

Severity: warn · Reported by: the Sygnal runtime (every app, production included)

WebMCP marks a tool whose results may carry text from users (or other untrusted sources) with untrustedContentHint, so the agent treats that text as data, not instructions. experimentalExposeWebMcp() takes the hint from the declaration’s untrusted field. This declaration doesn’t set it and its read projection contains string values that aren’t clearly the app’s own (the rule: a string is user text unless it is under a key named id, status, type or kind, or is one of the string enum values of the same declaration’s action inputs, such as a filter name), so the layer assumes they may be user-entered and adds the hint to the declaration’s tools (and to the tools of declarations without a read, whose results carry this projection). The state summary in their descriptions is labelled as data. Reported once per declaration, in development.

Fix: Declare it: untrusted: true when read returns user-entered text (todo texts, names, messages), untrusted: false when its strings are only the app’s own (enum values, labels the app wrote).

form tool option is not made by formTool()

Severity: error · Reported by: the dev checks (sygnal/diagnostics)

form(schema, { tool }) (experimental, PLAN-6 A-3) offers the form to the browser’s agent as a declarative WebMCP tool, but only when tool is made by formTool({ name, description, autosubmit }) from sygnal/ai. The tool code lives in formTool so that apps using form without a tool don’t carry it (D291); the form behavior only calls the hook on the object it is given. A plain { name, description } object has no such hook: the form works as before but writes no toolname attributes and answers no agent. Reported once, in development.

Fix: Wrap the object: import { formTool } from 'sygnal/ai', then form(schema, { values, submit: 'SIGN_UP', tool: formTool({ name: 'sign_up', description: 'Create an account' }) }).

Agent query is not a synchronous function

Severity: error · Reported by: the Sygnal runtime (every app, production included), sygnal-check

An agent.queries entry is a read-only tool: its run(state, input) gets the component’s current state and the validated input and returns the result the model sees, as { ok: true, result }. It runs outside the action queue, so it must be a pure, synchronous function of what it gets: it can’t wait for a request, and nothing it does reaches the app’s state. This one is not: run is missing or not a function (the query is not offered), or it returned a promise (an async function, or one that calls fetch), so the call fails with { ok: false, error }. Reported once per query while an agent layer runs. sygnal-check reports a missing run and an async one before the app runs.

Fix: Compute the result from state and input and return it: run: (state, { customer }) => state.invoices.filter((i) => i.customer === customer). For data that has to be fetched, use an action instead: its model entry sets what to load (a resources entry or a driver request), and until: 'resourceName' on the action makes the call answer with the component’s read once the data has arrived.

Agent query has the tool name of an action

Severity: warn · Reported by: the Sygnal runtime (every app, production included), sygnal-check

Actions and queries of one agent declaration share its tool-name space: each becomes the tool <name>_<key>, with the key in snake case (FIND and find both give orders_find). This query’s tool name is already taken by one of the declaration’s actions, so the action keeps it and the query is not offered. Reported once per query while an agent layer runs; sygnal-check reports it before the app runs.

Fix: Rename the query so its key names what it returns (queries: { search: … } next to an action FIND), or remove the duplicate.

Agent action until names no resource

Severity: warn · Reported by: the Sygnal runtime (every app, production included), sygnal-check

An agent.actions entry’s until makes the call wait for the action’s result: a string names one of the component’s resources, and the call answers once that resource is neither loading nor refreshing. This string names no resource of the component (a typo, a resource declared on another component, or a component with no resources), so there is nothing to wait for: the call answers at once, with the state right after the action, as without until. Reported once per action while an agent layer runs; sygnal-check reports it before the app runs when the component’s resources is an object literal.

Fix: Name a key of the component’s own resources (until: 'search' for resources = { search: (state) => … }), or pass a function for a result that arrives through a reply action: until: (state) => state.status !== 'loading', with the action’s own reducer setting status: 'loading'.

RxJS operator used on an xstream stream

Severity: error · Reported by: the dev checks (sygnal/diagnostics)

An RxJS operator such as pipe, switchMap, debounceTime or tap was called on a Sygnal stream, which is an xstream stream and does not have it. With sygnal/diagnostics loaded, these names are defined on xstream’s Stream.prototype to throw a TypeError that names the xstream equivalent, instead of the bare x.switchMap is not a function. A side effect of the dev entry is that typeof stream.pipe === 'function' is true while it is loaded.

Fix: Use the xstream equivalent from the message, e.g. .map(x => inner$).flatten() for switchMap, or .compose(debounce(ms)) for debounceTime.

Before:

RESULTS: DOM.input('.q').value().pipe(debounceTime(300))

After:

import { debounce } from 'sygnal'
RESULTS: DOM.input('.q').value().compose(debounce(300))

SYG4xx: Collections, Switchable, sub-components and context

Section titled “SYG4xx: Collections, Switchable, sub-components and context”

Collection ‘from’ field is missing or not an array

Severity: warn · Reported by: the Sygnal runtime (every app, production included), the dev checks (sygnal/diagnostics), sygnal-check

A <Collection> needs an array to render. The runtime reports this when from="field" names a key that is not in the parent state (or its calculated fields), when that field is not an array, or when a from={{ get, set }} getter returns a non-array; the dev entry also reports a <Collection> with no from whose parent state is not an array. In every case the Collection renders no items: a missing from key until the key appears in the state (it then renders, since 6.0), a non-array value until it becomes an array. sygnal-check reports the same mistake statically when from is not a key of a statically known initialState, or its initial value is a literal that is clearly not an array.

Fix: Point from at an array field and initialize it in initialState, e.g. initialState = { items: [] } with <Collection of={Item} from="items" />, or make a custom get() return an array.

Before:

Todos.initialState = { list: [] }
// view: <Collection of={TodoItem} from="items" />

After:

Todos.initialState = { items: [] }
// view: <Collection of={TodoItem} from="items" />

Context is not an object

Severity: error · Reported by: the Sygnal runtime (every app, production included)

The component’s .context is set to something other than a plain object (for example a function or an array). It is logged and ignored: the component contributes no context values, and descendants only receive the context inherited from further up. It is logged, not thrown, so rendering continues.

Fix: Define context as an object of named entries: Comp.context = { theme: state => state.theme }.

Before:

App.context = (state) => ({ theme: state.theme })

After:

App.context = { theme: (state) => state.theme }

Invalid context entry

Severity: error · Reported by: the Sygnal runtime (every app, production included)

A .context entry value is not a function. Context entries are functions of state only: the state-key string ({ user: 'currentUser' }) and true entries Sygnal accepted before 6.0 were removed. That entry is skipped each time context is computed, so descendants read undefined for it; the other entries still work. It is logged, not thrown.

Fix: Write each entry as a function of state: { user: state => state.currentUser }. See https://sygnal.js.org/guide/migrating-to-6#leftovers.

Before:

App.context = { theme: 42 }

After:

App.context = { theme: (state) => state.theme }

Context stream errored

Severity: error · Reported by: the Sygnal runtime (every app, production included)

The stream that computes a component’s context emitted an error, most often because a context function threw. An errored xstream stream stops, so this component’s context stops updating for its descendants from that point on. The original error is attached to the log.

Fix: Check the attached error and make each context function safe for every state it can receive (e.g. guard against missing fields with state.user?.name).

Sub-component has initialState without isolatedState

Severity: error · Reported by: the Sygnal runtime (every app, production included), sygnal-check

A sub-component defines initialState but not isolatedState = true, so its initialState would overwrite the state slice its parent passes in. For a component rendered by tag in a parent’s view this is an error: it is thrown when the child is instantiated, and the parent catches it, logs it under this code and renders its error fallback instead of the child. For Collection items and Switchable children it is only a warning and the initial state still replaces what the parent passed. It doesn’t matter whether the tag has a state prop: <Stopwatch state="stopwatch" /> and <Stopwatch /> both throw. sygnal-check reports it at the child’s initialState when a view it can follow renders the child (an error for a tag, a warning for a Collection or Switchable target), and says nothing when isolatedState is set to anything but a literal false.

Fix: Remove initialState from the sub-component and let the parent own the state: put the child’s start values in the parent’s initialState, under the field the state prop names (App.initialState = { stopwatch: { ms: 0 } } for <Stopwatch state="stopwatch" />). Or set Child.isolatedState = true if the child should keep its own local state.

Before:

Counter.initialState = { count: 0 } // Counter is rendered by a parent

After:

Counter.initialState = { count: 0 }
Counter.isolatedState = true

View threw

Severity: error · Reported by: the Sygnal runtime (every app, production included)

The component’s view function threw while rendering. The error is logged and the component renders its onError fallback if it has one, or an empty <div data-sygnal-error> otherwise. The app keeps running, but that component shows no real content until a later render succeeds.

Fix: Fix the error in the view (often a missing field on state or props), and add Comp.onError = (error, { componentName }) => <div>…</div> if you want a custom fallback.

Before:

function Profile({ state }) {
return <h1>{state.user.name}</h1> // state.user is null at first
}

After:

function Profile({ state }) {
return <h1>{state.user?.name ?? 'Loading…'}</h1>
}
Profile.onError = (error, { componentName }) => <p>Could not render {componentName}</p>

onError handler threw

Severity: error · Reported by: the Sygnal runtime (every app, production included)

A component’s onError handler threw while handling a view error (SYG406) or a sub-component instantiation error (SYG408). Sygnal logs the second error and renders an empty <div data-sygnal-error> instead of your fallback.

Fix: Keep onError simple and make it always return a vnode, e.g. Comp.onError = () => <div class="error">Something went wrong</div>.

Sub-component failed to instantiate

Severity: error · Reported by: the Sygnal runtime (every app, production included)

Creating a sub-component (a custom component, Collection, or Switchable found in the parent’s view) threw an uncoded exception, for example from the child’s intent. The parent catches it, logs it with the original error attached, and renders its own onError fallback, or an empty <div data-sygnal-error>, in that child’s place. The rest of the parent keeps rendering. A coded Sygnal error thrown while instantiating (SYG405, SYG413, SYG414, SYG903, …) is reported under its own code instead, with the same fallback.

Fix: Read the attached error and fix its cause.

Child tried to update a calculated field

Severity: warn · Reported by: the Sygnal runtime (every app, production included)

A sub-component, Switchable, or Collection is bound to a parent state field (via its state or from prop) that is one of the parent’s calculated fields, and the child wrote a new value back to it. Calculated fields are derived from other state, so the write is ignored and the parent state is left unchanged.

Fix: Bind the child to a plain (non-calculated) state field, and change the fields the calculated value is derived from instead.

Invalid sub-component ‘state’ prop

Severity: error · Reported by: the Sygnal runtime (every app, production included)

The state prop on a sub-component or Switchable is neither a state key string nor an object with a get() function (for example a number, or a { set } object without get). It is logged and the child falls back to receiving the parent’s whole state, which is usually not what was intended.

Fix: Pass a state key string (state="user") or a lens object state={{ get: s => s.user, set: (s, user) => ({ ...s, user }) }}.

Before:

<UserCard state={42} />

After:

<UserCard state="user" />

Collection ‘of’ is missing or not a component

Severity: error · Reported by: the Sygnal runtime (every app, production included)

A <Collection> has no of prop, or of is not a component function (a component name as a string, <Collection of="Row">, is a form Sygnal 6.0 removed; it is also reported as SYG612). This is thrown while the parent’s view is processed, so the Collection cannot render.

Fix: Pass the item component function itself: <Collection of={TaskCard} from="tasks" />.

Before:

<Collection of="TaskCard" from="tasks" /> // not registered in .components

After:

<Collection of={TaskCard} from="tasks" />

Invalid Collection ‘from’ prop

Severity: error · Reported by: the Sygnal runtime (every app, production included)

The from prop on a <Collection> is neither a state key string nor an object with a get() function. It is logged (not thrown) and the Collection renders nothing.

Fix: Use a state key string (from="items") or a lens object with get and set: from={{ get: s => s.items, set: (s, items) => ({ ...s, items }) }}.

Before:

<Collection of={TaskCard} from={state.tasks} />

After:

<Collection of={TaskCard} from="tasks" />

Retired in 6.0: Unnamed component factory not found

Severity: error · Reported by: retired

Retired in 6.0 and never reported. Before 6.0 it was logged when a view rendered an element carrying neither a component factory nor its options (sygnal-factory). Since 6.0 a component vnode carries the component function itself (data.c); there are no factories or options objects to lose.

Fix: Nothing to change. Render components as JSX tags of their functions.

Retired in 6.0: Component not found

Severity: error · Reported by: retired

Retired in 6.0 and never reported. Before 6.0 it was logged when a view rendered a component by a string name that no .components registry entry matched. Sygnal 6.0 removed string tags and .components: a component is always referenced by its function. A leftover string tag renders as a plain element and is reported as SYG612 in development.

Fix: Import the component and use it as a JSX tag. See https://sygnal.js.org/guide/migrating-to-6#string-tags.

Before:

// <TaskCard /> rendered, but TaskCard is neither imported nor in .components

After:

import TaskCard from './TaskCard.jsx'

Switchable ‘of’ is missing or invalid

Severity: error · Reported by: the Sygnal runtime (every app, production included)

A <Switchable> has no of prop, of is not a plain object, or one of its values is not a component function. This is thrown while the parent’s view is processed, so the Switchable cannot render.

Fix: Pass an object mapping names to components: <Switchable of={{ list: ListView, grid: GridView }} current={state.mode} />.

Before:

<Switchable of={[ListView, GridView]} current={state.mode} />

After:

<Switchable of={{ list: ListView, grid: GridView }} current={state.mode} />

Switchable ‘current’ is missing or invalid

Severity: error · Reported by: the Sygnal runtime (every app, production included)

A <Switchable> has no current prop, current is not a string or function, or current is not one of the keys of of. This is thrown while the parent’s view is processed, so no component can be shown.

Fix: Set current to one of the keys of of, e.g. current="list" when of={{ list: ListView, grid: GridView }}, and make sure the state value it comes from is initialized.

Before:

<Switchable of={{ list: ListView, grid: GridView }} current="table" />

After:

<Switchable of={{ list: ListView, grid: GridView }} current="list" />

Portal target not found

Severity: warn · Reported by: the Sygnal runtime (every app, production included)

A <Portal target="…"> selector matched no element in the document. Sygnal retries for a short time (about 10 attempts, 5 ms apart) in case the target is rendered by a sub-component that has not settled yet, then warns and leaves the portal content unrendered.

Fix: Make sure an element matching the target selector exists in the page (for example <div id="modal-root"> in your HTML) before the Portal mounts.

Invalid Collection sort prop

Severity: error · Reported by: the Sygnal runtime (every app, production included)

The sort prop on a <Collection> has an unsupported shape: an object with more than one key, a direction that is not 'asc', 'desc', 1 or -1, or a value that is not a string, array, object or function. The invalid sort (or array element) is logged and ignored, so the items are not sorted as intended.

Fix: Use a field name (sort="title"), a single-key object (sort={{ title: 'desc' }}), a comparator function, or an array of these.

Before:

<Collection of={Row} from="rows" sort={{ title: 'up' }} />

After:

<Collection of={Row} from="rows" sort={{ title: 'desc' }} />

Retired in 6.0: Invalid switchable() ‘name$’ parameter

Severity: error · Reported by: retired

Retired in 6.0 and never reported. It was thrown by the low-level switchable() helper, which Sygnal 6.0 removed together with the component() factory. <Switchable of={...} current={...} /> is the one form.

Fix: Render <Switchable of={{ a: A, b: B }} current={state.tab} /> in a view.

JSX tag is undefined

Severity: error · Reported by: the Sygnal runtime (every app, production included)

A JSX element’s tag evaluated to undefined, usually because a component was not imported, was misspelled, or was imported as default instead of named (or vice versa). Sygnal renders an <UNDEFINED> element in its place and logs the message with console.error; it is collected like other runtime diagnostics (the JSX runtime reports it through the diagnostics core, or only prints it when no Sygnal core is loaded).

Fix: Import or define the component in the file that uses it, and check that the import style (default vs named) matches the export.

Before:

import { TaskCard } from './TaskCard.jsx' // TaskCard is a default export

After:

import TaskCard from './TaskCard.jsx'

Invalid data (dataset) key

Severity: error · Reported by: the dev checks (sygnal/diagnostics)

A view renders a data key the DOM can’t store. data={{ ... }} is written with element.dataset[key] = value, and the browser throws a SyntaxError DOMException for a key with a hyphen followed by a lower-case letter ('task-id'), or an attribute-name error for characters such as spaces, quotes or =. The patch fails, so rendering can stop, and the console shows only a bare DOMException {}. The dev entry checks every rendered dataset key before the patch and names the key; renderComponent’s mock DOM never throws, so it is also the only signal there.

Fix: Use a camelCase key in the data prop: data={{ taskId: 7 }} renders data-task-id="7" (a data-task-id="7" JSX attribute works too); read it with .data('taskId').

Before:

<li className="task" data={{ 'task-id': task.id }}>{task.title}</li>

After:

<li className="task" data={{ taskId: task.id }}>{task.title}</li>
// intent: DOM.click('.task').data('taskId')

Invalid timer spec

Severity: error · Reported by: the dev checks (sygnal/diagnostics), sygnal-check

A component’s timers static declares a timer that makeTimerDriver() can’t run, so it is not started (the other timers are). A spec is { every: ms, action } (a positive interval), { after: ms, action } (0 or more) or { frame: 'ACTION' }, each with an optional background: true. Reported for a non-positive or non-numeric every, a negative after, both every and after, a missing or non-string action, or a frame that isn’t an action name. A falsy entry (state.running && { ... }) is not an error: it means the timer is stopped. sygnal-check reports it too, for specs written as literals ({ every: 0, action: 'TICK' }); a value computed from state is checked only at run time.

Fix: Use tick: state.running && { every: 100, action: 'TICK' }, done: state.armed && { after: 5000, action: 'EXPIRE' } or frame: state.animating && { frame: 'FRAME' }. To stop a timer, return a falsy value for it instead of an invalid spec.

Context change skipped a view whose output it changes

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

A component reads context, the context changed, and Sygnal did not re-render the component, because its view read none of the changed keys during its last call. Sygnal 6.0 re-renders a component on a context change only when its view read a key that changed. With sygnal/diagnostics loaded, a sample of the skipped views is called again with the new context; when the result differs, the page shows a stale render and this is reported. A read Sygnal can’t see causes it: a context value kept from an earlier render and read later, a function that reads context after the view returned, or reflection such as Object.getOwnPropertyDescriptor(context, 'theme').

Fix: Read the entries in the view itself, context.theme or function C({ context: { theme } }), on every call. Pass a value you need later as a prop or keep it in state instead of a saved context object.

Duplicate Collection item id

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

Two or more items in a <Collection>’s array have the same id. A Collection keys its items by id (an item without one by its index in the array), so only the first item with that id renders, and the item component’s writes go to that first item. The others are not shown.

Fix: Give every item a unique id when it is created, for example from a counter in state or crypto.randomUUID(). Items with no id at all are keyed by their index.

isolatedState slice lacks initialState keys

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

An isolatedState component is bound to a slice of its parent’s state (state="key" or a lens) that already exists, so it keeps that slice and its initialState is not applied (since 6.0 the parent’s data wins). The slice lacks keys that initialState defines, so the component’s view and reducers see them as undefined.

Fix: Add resetState to the tag (<Panel state="panel" resetState />) to start the component from its initialState, replacing the slice. To keep the parent’s data, initialize the missing keys in the parent’s state.

VirtualCollection has no bounded height

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

A <VirtualCollection> is its own scroll container (overflow-y: auto) and renders the rows that fit in its height. Its element is either 0 px tall, so no row is in view, or as tall as all of its rows (its height comes from its content, e.g. no height in its class, or a max-height: 100%, calc() of a percentage or fit-content that resolves against a parent without a height; Sygnal measures whether the height follows the rows), so it never scrolls and would render every row. In the second case Sygnal renders only the rows within the viewport’s height, and rows further down the page stay empty. Reported once per VirtualCollection and case.

Fix: Give its className a bounded height: .rows { height: 400px } or max-height, or in a flex column flex: 1; min-height: 0. Check that a parent with display: none isn’t the cause of a 0 height.

VirtualCollection items without ids

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

Items of a <VirtualCollection>’s array have no id, so they are keyed by their index (as in a Collection, D169). A VirtualCollection keeps a measured height and an instance per key: when items are added, removed or reordered, both follow the position, not the item, so rows get each other’s heights and a row’s instance shows another item. Reported once per VirtualCollection.

Fix: Give every item a unique id, e.g. rows: data.map((r) => ({ id: r.key, ...r })).

VirtualCollection item does not render one element

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

The item component of a <VirtualCollection> rendered a fragment (an array of elements) or text. A VirtualCollection measures each row’s root element and sets data-index, aria-posinset and aria-setsize on it; a fragment has no root element, so its rows can’t be measured (later rows overlap or leave gaps) or announced in position. Reported once per VirtualCollection.

Fix: Return one element from the item’s view: wrap its content in a <div> (or an <li>).

VirtualCollection scroll target not in the list

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

An element command { scrollToIndex: target, index } or { scrollToId: target, id } reached a <VirtualCollection> with an index outside its rows (an index counts the rows after filter and sort, from 0) or an id that no shown item has (not in the array, or filtered out). It didn’t scroll.

Fix: Send an integer index from 0 to the number of shown rows minus 1, or the id of an item the filter keeps: { scrollToId: '.rows', id: row.id }.

Invalid VirtualCollection estimateSize or overscan

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

estimateSize must be a positive number (a row’s height in px before it is measured) or a function (item, index) => px, and overscan a number ≥ 0 (rows rendered beyond each edge). Another value is ignored: 32 px and 5 rows are used instead.

Fix: Pass estimateSize={32} (about your row height) or (item) => (item.expanded ? 120 : 32), and overscan={5}.

Sortable list shown filtered or in another order

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

sortable moves entries of state[from]: the arrow keys swap the item with its neighbour in the array, Home / End move it to the array’s ends, the announced positions count the array, and a pointer drop lands next to the entry it is dropped on. When the list is rendered from a <Collection> with sort (or filter that hides entries between shown ones), the shown order isn’t the array’s: under a sort a move changes nothing visible (the Collection sorts it back), and under a filter a keyboard step can pass hidden entries and the positions count entries nobody sees. At a keyboard pick-up or a pointer press the dev entry compares the host’s own rendered items with the array and reports this once per list.

Fix: Reorder only a list that is shown as it is stored: drop the Collection’s sort for a reorderable list (sort the array itself, once, if it needs an initial order), and while a filter hides entries hide the drag handles (or render the full list) so a move can’t pass hidden ones.

Two agent declarations with the same name

Severity: warn · Reported by: the Sygnal runtime (every app, production included), sygnal-check

Agent tools are named after the declaration (agent.name, else the component name): todos_read, todos_add. Two shown instances with different declarations, or the same non-item component rendered twice, produce the same tool names, so only the first one’s tools are offered and the other can’t be reached. (Collection items of one component are one keyed tool and are not reported.) Reported once per name while an agent layer runs. sygnal-check reports two declarations with the same literal name (or none, so the component names collide) when one component renders both, directly or through its children.

Fix: Give each declaration its own agent.name (for a component rendered twice, derive it from a prop: Panel.agent = { name: 'left_panel', ... } on a wrapper per place), or narrow the layer with agentTools(app, { from }).

Agent Collection items without unique ids

Severity: warn · Reported by: the Sygnal runtime (every app, production included), sygnal-check

A Collection item component’s agent actions become one tool with an id parameter whose values are the items’ ids. Items without an id are keyed by their index, which shifts when an item is added or removed, so a model’s call can reach the wrong item. Items whose ids repeat across Collections of the same item component (the same card id in two board lanes) are ambiguous: the call reaches the first. Reported once per declaration while an agent layer runs. sygnal-check reports an item component with an agent whose items visibly lack an id: an object literal without id in the parent’s initialState array the Collection renders, or appended to it by the parent’s model ([...state.todos, { text }]). The same id in two Collections depends on the data and is reported at run time only.

Fix: Give every item an id that is unique across all the Collections of that item component ({ id: 'todo-1', ... }), or add an owner-level action that takes the qualified key (MOVE_CARD: { input: z.object({ lane, card }) }).

Chat or command bar behavior not connected to its app

Severity: error · Reported by: the Sygnal runtime (every app, production included)

The chat and commandBar behaviors (sygnal/ai) run agent actions through the app’s runtime, which they reach through a hook layer every app picks up when it starts. The first chat() or commandBar() call installs that layer, and also adds it to the app already running that run() registered (the page’s first live app), so a component using one of them loaded lazily into that app works. Another app started before the first call (a second run() on the page, a sygnal/element custom element) can’t be found and has none: its assistant still streams replies, but sends no tools, no read context, and runs no tool calls; its command bar runs no commands (its error says so). Reported once per behavior, also when diagnostics are off.

Fix: Make the first chat() / commandBar() call happen before run(): define the host component (or call the factory once) in a module the entry imports, not only in a component loaded later.

Removed in 6.0: view with positional arguments

Severity: error · Reported by: sygnal-check · Strict mode only

Strict mode only (sygnal-check --strict). The view declares more than one parameter, as in function Lane(props, state, context). Sygnal 6.0 removed positional view arguments (D164): a view is called with one argument, so state and context are undefined in the second and third parameters. At run time the dev checks report the same form as SYG612 (positional-views).

Fix: Destructure the one argument: function Lane({ state, context, ...props }). See https://sygnal.js.org/guide/migrating-to-6#positional-views.

Before:

function Lane(props, state, context) {
return <h2>{state.title}</h2>
}

After:

function Lane({ state, context, ...props }) {
return <h2>{state.title}</h2>
}

Retired in 6.0: STATE reducer signals no change without ABORT

Severity: warn · Reported by: retired · Strict mode only

Retired in 6.0 and never reported. Before 6.0, strict mode flagged a STATE reducer that returned the state object it received (return state, cond ? next : state) instead of ABORT, because that still produced a new state. Since 6.0 returning the identical object is “no change”, the same as ABORT: no state is emitted and nothing re-renders. A reducer that mutates the state in place and then returns it is reported by SYG222 instead.

Fix: Nothing to change: return state and return ABORT are equivalent in a STATE reducer. To change the state, return a new object.

Before:

RENAME: (state, title) => title ? { ...state, title } : state

After:

import { ABORT } from 'sygnal'
RENAME: (state, title) => title ? { ...state, title } : ABORT

Side effect in a STATE reducer that returns ABORT

Severity: warn · Reported by: sygnal-check · Strict mode only

Strict mode only (sygnal-check --strict). A STATE reducer runs a call whose result is unused (such as cmd.send('play'), next('X') or localStorage.setItem(…)) and then returns ABORT. This is the old pattern for side effects; the canonical form puts side effects in an EFFECT sink so STATE reducers stay pure. The check is a heuristic.

Fix: Move the side effect to an EFFECT sink: PLAY: { EFFECT: (state, data, next) => { cmd.send('play') } }, keeping any state change in STATE.

Before:

PLAY: (state) => {
playerCmd.send('play')
return ABORT
}

After:

PLAY: { EFFECT: () => playerCmd.send('play') }

Removed in 6.0: ‘ACTION | SINK’ model key

Severity: error · Reported by: sygnal-check · Strict mode only

Strict mode only (sygnal-check --strict). A model key uses the 'ACTION | SINK' shorthand, such as 'SEND | EFFECT': …. Sygnal 6.0 removed these keys (D164): the object form is the only form. sygnal-check --strict --fix rewrites the key when no other entry handles the same action. At run time the dev checks report it as SYG612 (pipe-keys).

Fix: Use the object form: SEND: { EFFECT: (state, data, next) => … }, merging into an existing SEND entry if there is one. See https://sygnal.js.org/guide/migrating-to-6#pipe-keys.

Before:

'PLAY | EFFECT': () => playerCmd.send('play')

After:

PLAY: { EFFECT: () => playerCmd.send('play') }

Non-canonical EVENTS emit

Severity: warn · Reported by: sygnal-check · Strict mode only

Strict mode only (sygnal-check --strict). A model entry emits a global event with emit('TYPE', fn) (as the whole entry or spread into the object form), or its EVENTS sink builds the { type: 'TYPE', data } object by hand. The canonical form is an EVENTS sink using event() inside the object form. --fix rewrites the common cases and adds the event import.

Fix: Use ACTION: { EVENTS: event('TYPE', (state, data) => payload) }.

Before:

DELETE: emit('DELETE_LANE', (state) => ({ laneId: state.id }))
SAVE: { EVENTS: (state) => ({ type: 'SAVED', data: state.id }) }

After:

import { event } from 'sygnal'
DELETE: { EVENTS: event('DELETE_LANE', (state) => ({ laneId: state.id })) }
SAVE: { EVENTS: event('SAVED', (state) => state.id) }

Removed in 6.0: CHILD.select() with a component name

Severity: error · Reported by: sygnal-check · Strict mode only

Strict mode only (sygnal-check --strict). An intent calls CHILD.select('TaskCard') with the child’s name as a string. Sygnal 6.0 removed name lookups (D163): children are matched by their component function. --fix replaces the string with the identifier when that name is in scope. At run time the dev checks report it as SYG612 (child-select-name).

Fix: Pass the component function: CHILD.select(TaskCard) (import it first if needed). See https://sygnal.js.org/guide/migrating-to-6#child-select-name.

Before:

Lane.intent = ({ CHILD }) => ({ DELETE_TASK: CHILD.select('TaskCard') })

After:

import TaskCard from './TaskCard.jsx'
Lane.intent = ({ CHILD }) => ({ DELETE_TASK: CHILD.select(TaskCard) })

Prop drilled through more than 2 component levels

Severity: info · Reported by: sygnal-check · Strict mode only

Strict mode only (sygnal-check --strict). A component receives a prop from its parent and passes it on unchanged, under the same name, to its own child component, so the value crosses three component levels. Data that flows that far down belongs in .context, which reaches every descendant without each level forwarding it. This is informational only.

Fix: Provide the value as context on the top component, e.g. Parent.context = { theme: state => state.theme }, and read context.theme where it is needed.

Before:

// App → <Board theme={state.theme} /> → <Lane theme={theme} /> → <Card theme={theme} />

After:

App.context = { theme: (state) => state.theme }
// Card: function Card({ state, context }) { … context.theme … }

select()/errors() round trip where reply actions would do

Severity: warn · Reported by: the dev checks (sygnal/diagnostics), sygnal-check · Strict mode only

Strict mode only. A component sends a request with category: 'quote' to a driver with reply actions (makeFetchDriver, driverFromAsync, or a sink named HTTP) and reads the reply back in its own intent with HTTP.select('quote') or HTTP.errors('quote'). The canonical form is a request with reply actions: it names them in the request, so the intent line, the category string and the select/errors split all go away, and the reply reaches exactly the component instance that sent it. select()/errors() remain for requests without reply actions and for stream-level composition (see Alternative forms). The runtime check (configureStrict(true), renderComponent(C, { strict: true })) reports a request whose category the same component selects on a source with reply actions.

Fix: Name the reply actions in the request and remove the intent line: LOAD: { HTTP: (state) => ({ url: '/api/quote', ok: 'LOADED', error: 'FAILED' }) }, with LOADED: (state, body) => … (the parsed body) and FAILED: (state, { status }) => … in the model.

Before:

Quote.intent = ({ DOM, HTTP }) => ({
LOAD: DOM.click('.get'),
LOADED: HTTP.select('quote'),
FAILED: HTTP.errors('quote'),
})
Quote.model = {
LOAD: { HTTP: () => ({ category: 'quote', url: '/api/quote' }) },
LOADED: (state, { value }) => ({ ...state, quote: value }),
FAILED: (state, { status }) => ({ ...state, status }),
}

After:

Quote.intent = ({ DOM }) => ({ LOAD: DOM.click('.get') })
Quote.model = {
LOAD: { HTTP: () => ({ url: '/api/quote', ok: 'LOADED', error: 'FAILED' }) },
LOADED: (state, quote) => ({ ...state, quote }), // the parsed body
FAILED: (state, { status }) => ({ ...state, status }),
}

SYG6xx: Drivers, sources and component setup

Section titled “SYG6xx: Drivers, sources and component setup”

Retired in 6.0: Missing or invalid sources

Severity: error · Reported by: retired

Retired in 6.0 and never reported. It was thrown when a component made by the component({ ... }) factory was called by hand without a sources object. Sygnal 6.0 removed the factory and its sources option; components are always started by run() or renderComponent().

Fix: Start the app with run(App) (or test it with renderComponent(App)). See https://sygnal.js.org/guide/migrating-to-6#component-factory.

Intent is not a function

Severity: error · Reported by: the Sygnal runtime (every app, production included)

The component’s .intent is set but is not a function (for example an object of streams). It is thrown when the component is constructed.

Fix: Make intent a function of sources: Comp.intent = ({ DOM }) => ({ CLICK: DOM.select('.btn').events('click') }).

Before:

Comp.intent = { CLICK: xs.never() }

After:

Comp.intent = ({ DOM }) => ({ CLICK: DOM.click('.btn') })

Intent returned an invalid value

Severity: error · Reported by: the Sygnal runtime (every app, production included)

The intent function returned something other than an object of streams: often undefined because an arrow function used a block body without return, or a single stream of { type, data } objects (a form Sygnal 6.0 removed; the message names it). It is also thrown when an entry of the object is not a stream. It is thrown when the component is constructed.

Fix: Return an object of action streams, e.g. intent = ({ DOM }) => ({ CLICK: DOM.select('.btn').events('click') }) (note the parentheses around the object).

Before:

Comp.intent = ({ DOM }) => { CLICK: DOM.click('.btn') }

After:

Comp.intent = ({ DOM }) => ({ CLICK: DOM.click('.btn') })

Retired in 6.0: Invalid hmrActions

Severity: error · Reported by: retired

Retired in 6.0 and never reported. It was thrown for an hmrActions static that was not an action name or an array of them. Sygnal 6.0 removed hmrActions: sygnal/vite keeps the state across a hot update; a leftover hmrActions is reported as SYG612.

Fix: Remove hmrActions; put an action to run after the swap in the model’s BOOTSTRAP. See https://sygnal.js.org/guide/migrating-to-6#hmractions.

Before:

Comp.hmrActions = 3

After:

Comp.hmrActions = ['REFRESH']

Retired in 6.0: Intent action name contains ’|’

Severity: error · Reported by: retired

Retired in 6.0 and never reported. | was reserved in action names for the 'ACTION | SINK' model keys, which Sygnal 6.0 removed.

Fix: Nothing to change (a name without | stays the clearer choice).

Before:

Comp.intent = ({ DOM }) => ({ 'SAVE|CLOSE': DOM.click('.save') })

After:

Comp.intent = ({ DOM }) => ({ SAVE_AND_CLOSE: DOM.click('.save') })

calculated is not an object

Severity: error · Reported by: the Sygnal runtime (every app, production included)

The component’s .calculated is set but is not a plain object (for example a function or an array). It is thrown the first time calculated fields are added to an object state.

Fix: Define calculated fields as an object: Comp.calculated = { total: state => state.items.length }.

Before:

Cart.calculated = (state) => ({ total: state.items.length })

After:

Cart.calculated = { total: (state) => state.items.length }

Retired in 6.0: State source not found

Severity: error · Reported by: retired

Retired in 6.0 and never reported. It was thrown by the low-level switchable() helper when the sources had no state source under its stateSourceName. Sygnal 6.0 removed the helper and the custom source names: the state source is always STATE.

Fix: Render <Switchable> in a view; remove stateSourceName. See https://sygnal.js.org/guide/migrating-to-6#source-names.

Strict mode requested without the diagnostics entry

Severity: warn · Reported by: the Sygnal runtime (every app, production included)

run(App, drivers, { diagnostics: { strict: true } }) was called but the sygnal/diagnostics dev entry is not loaded. The strict (canonical-form) checks live in that entry, so no strict findings are reported; the option still turns diagnostics on. Printed once per page load.

Fix: Import the dev entry before run() in development (import 'sygnal/diagnostics'), or use the Sygnal Vite plugin, which injects it in dev. Do not ship it in production builds.

Before:

main.js
// without the dev entry
run(App, {}, { diagnostics: { strict: true } })

After:

main.js
import 'sygnal/diagnostics' // dev only (the Vite plugin adds it in dev)
run(App, {}, { diagnostics: { strict: true } })

Sink or source has no driver

Severity: warn · Reported by: the dev checks (sygnal/diagnostics), sygnal-check

A component’s model sends to a sink (for example HTTP: (state) => ({ url: '/api/x' })), or its intent reads a source (HTTP.select('x')), but run() got no driver with that name. Values sent to a sink without a driver are dropped silently, and a source without a driver is undefined, so the intent then fails with “Cannot read properties of undefined”. renderComponent() does not report this: in tests it records such sinks (t.requests(name)) and fakes such sources (t.respond / t.fail), so a test suite can pass while the app drops every request. sygnal-check reports the sink case before the app runs: a model sink of a component that a run() call renders, when that call’s drivers are an object literal (or absent) without that name. It says nothing when no run() call is in the checked files (Vike, Astro, a library), when the drivers can’t be listed (a variable from another module, a spread, a computed key), or for the sinks the core handles itself (STATE, EFFECT, EVENTS, PARENT, READY, DOM, CHILD, ELEMENT, PERSIST, LOG).

Fix: Pass the driver to run() under exactly that name: run(App, { HTTP: makeFetchDriver() }) for HTTP requests, run(App, { LLM: makeChatDriver({ transport: openResponses({ baseURL, model }) }) }) (from sygnal/ai) for an LLM chat sink, driverFromAsync(fn) for any promise-returning function, or your own driver. Check the spelling against the drivers you pass.

Request has a ‘then’ or ‘catch’ key

Severity: error · Reported by: the Sygnal runtime (every app, production included)

A request sent to a driver with reply actions (makeFetchDriver, driverFromAsync) has a then or catch key, for example HTTP: (state) => ({ url: '/api/x', then: 'LOADED' }). An object with a then key is a thenable, so it breaks anything that awaits it, and these keys look like a promise chain but do nothing. The driver does not send the request.

Fix: Name the reply actions with the ok / error keys: { url: '/api/x', ok: 'LOADED', error: 'FAILED' }. The ok action gets the parsed body, the error action { error, status, body, request }.

Socket message not sent or connection not opened

Severity: error · Reported by: the Sygnal runtime (every app, production included)

A value sent to a makeSocketDriver() sink could not be acted on. Either a send ({ to: 'room', json }) names a connection that this component instance has not declared, that has closed for good (reconnect: false, or a URL the browser rejected), or that is a server-sent events connection, which is read-only. Or a declared connection has neither a socket nor an sse URL. Or the value is neither { connections } nor { to, … }. Connection names are per component instance: a parent cannot send on a child’s connection. The message is dropped; a connection that is only (re)connecting is not an error, its sends are queued.

Fix: Declare the connection first, { connections: { room: { socket: '/ws/rooms/general', message: 'RECEIVED' } } }, then send on it from the same component: { to: 'room', json: { text } }. Use a WebSocket (socket:) for two-way traffic.

Removed in 6.0

Severity: error · Reported by: the dev checks (sygnal/diagnostics), sygnal-check

A component uses a form that Sygnal 6.0 removed, so it is ignored or fails at run time: a component named by a string tag or registered in .components, <Collection of="Name">, the Collection’s wrapper element (className, style, class, attrs, data / data-*, on, hook or ref on <Collection>: the items now render directly into the parent element, so these went to a <div> that no longer exists and are ignored), CHILD.select('Name'), an 'ACTION | SINK' model key, .peers, hmrActions, a view with positional parameters (function C(props, state)), DOMSourceName / stateSourceName, storeCalculatedInState, or the component({ ... }) factory. A single-stream intent throws SYG603 and string / true context entries are SYG403 instead. The dev checks report it at run time, once per form and component, in development only. sygnal-check reports the statics on a component (a function with component statics, or a view), the factory imports, and <Collection of="Name">, idfield and the wrapper attributes on the Collection imported from sygnal statically, always (not only under --strict), and --fix deletes storeCalculatedInState and default source names (a statement in a block or the module body only); .label and idfield are reported only statically; positional views, 'ACTION | SINK' keys and CHILD.select('Name') are SYG501, SYG504 and SYG506 under --strict. The message names the form and links to its section of the migration guide.

Fix: Follow the linked section of the migration guide (https://sygnal.js.org/guide/migrating-to-6): import components and use them as JSX tags, pass the component function to of and CHILD.select, put a Collection’s class or style on your own wrapping element (<ul className="x"><Collection of={Item} from="items" /></ul>), use object-form model entries, destructure the view’s one argument, and write function components with statics (or use defineComponent).

Router command not performed

Severity: error · Reported by: the Sygnal runtime (every app, production included)

A value sent to the router’s sink (makeRouter().driver) could not be acted on, so nothing was navigated. Either { to } names no route (or the '*' not-found route), or the route’s pattern needs a param that params leaves out, or the value uses the route key, which is reserved for the component’s route declaration (App.route = 'ROUTE'), or it is none of the router’s commands. The router reports this in production too.

Fix: Navigate with { to: 'task', params: { id } } (plus query, hash, replace as needed), { url: router.href(...) }, { back: true }, { forward: true }, { block: 'CONFIRM_LEAVE' } or { prefetch: 'task', params }.

Cached request is not idempotent

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

A request to makeFetchDriver() sets cache: true or staleTime but is not a GET or HEAD (a request with json or body defaults to POST), for example { url: '/api/quotes', method: 'POST', json, cache: true }. The driver’s queryCache() keeps its reply by method, URL and body, so a second identical write within staleTime is answered from the cache and never reaches the server, and identical writes in flight share one fetch. Writes should always be sent.

Fix: Remove cache / staleTime from the write. To refresh cached reads after it succeeds, tag the reads (tags: ['quotes']) and add invalidates: ['quotes'] to the write.

validate is not a Standard Schema

Severity: error · Reported by: the dev checks (sygnal/diagnostics)

A request or resource to makeFetchDriver() has a validate value that is not a Standard Schema, an object with a ~standard.validate function (zod, valibot and arktype schemas are). A plain function or another validator’s object does not qualify. The driver cannot validate the reply, so the request fails with a TypeError on its error action (or as the resource’s error).

Fix: Pass the schema object itself: validate: QuoteSchema (for zod, z.object({ ... })). To check a body with your own function, use parse: (res) => res.json().then(check) instead.

invalidate matched nothing

Severity: info · Reported by: the dev checks (sygnal/diagnostics)

A { invalidate } value sent to makeFetchDriver() matched no cache entry and no mounted resource, so nothing was refetched or marked stale. Tags are explicit: { invalidate: 'quotes' } matches only requests with tags: ['quotes']; a string starting with ’/’ is a prefix of the request’s url as written (without the driver’s baseUrl); a function is called with each request. This is info: invalidating data that no component shows at the moment is often fine.

Fix: Add tags: ['quotes'] to the resources (or cached requests) the write should refresh, or invalidate by URL prefix: { invalidate: '/quotes' }.

abort names a lane the requests do not use

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

{ abort: 'X' } cancels this component instance’s requests whose key is ‘X’; a request’s key is its key, else its ok action, else its error action. The instance sends its ok: 'X' requests with another key, so the abort matches none of them and they still deliver. (G-175.)

Fix: Abort by that key: { abort: true, key: 'search' }, or drop key from the requests so their lane is the ok action.

latest: true with a computed key

Severity: info · Reported by: sygnal-check

A request has latest: true and a key that is not a string literal, for example key: 'search-' + state.q. latest cancels this instance’s earlier requests with the same key, so a key that changes with state gives every value its own lane, and a new request never cancels the previous one: out-of-order replies can still arrive. That is right for independent lanes, such as one save per row, so this is info.

Fix: Use a fixed key (key: 'search') or none (the lane is the ok action) so the newest request cancels the others. Keep a computed key only for lanes that should run side by side (// sygnal-ignore SYG634).

Caching asked for without a queryCache

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

A request or resource sets cache: true or staleTime, or the component sends { prefetch: request }, but the sink’s makeFetchDriver() has no query cache. The cache is a separate export (D88), so a driver without cache: queryCache() caches nothing: the request is sent as an ordinary one, and a prefetch does nothing. In tests the HTTP fake has no cache unless renderComponent(C, { http: { cache: queryCache() } }) gives it one.

Fix: Give the driver a cache: makeFetchDriver({ cache: queryCache({ staleTime: 30000 }) }) with import { queryCache } from 'sygnal' (in tests, renderComponent(C, { http: { cache: queryCache() } })). Or remove cache / staleTime / the prefetch.

Element command target not found

Severity: warn · Reported by: the dev checks (sygnal/diagnostics), sygnal-check

A model entry sent an element command on the built-in ELEMENT sink, such as { focus: Email } or { showModal: HelpDialog }, and nothing matched its target. The target, a control or a selector, is looked up only in the view of the component instance that sent the command: an element inside a child component is isolated from its parent, and a Collection item reaches only its own elements. The command waits for the next render to reach the page (so it reaches an element the same action renders) and keeps looking for about 1 s, then it is dropped. A command with no target ({ focus: undefined }) is reported the same way. sygnal-check reports it before the app runs when a literal command targets a control (or a static class or id selector) that the sending component’s view never renders, or renders only inside a child component; a dynamic target, a document/body selector and a class a dynamic className might produce are left to the runtime.

Fix: Render the element in the sending component’s view, or send the command from the component that renders it: in a Collection, each item sends its own (for example on BOOTSTRAP for a new row). Check the control or selector: ELEMENT: { focus: Email } with const { Email } = controls({ Email: 'input' }) rendered as <Email />.

Unknown element command

Severity: error · Reported by: the dev checks (sygnal/diagnostics), sygnal-check

An element command (ELEMENT sink) names a method that can’t run. The first key of a command object is the method and the other keys are its options ({ scrollIntoView: Row, block: 'nearest' }). A control whose spec object declares commands is asked first; then the element’s own method runs (focus, blur, select, click, scrollIntoView, showModal, show, close, showPopover, hidePopover, togglePopover, and others such as play or reset). Reported when neither exists, for example a typo (fokus) or showModal on an element that isn’t a <dialog>; the message names the control’s declared commands. Also reported when the command is sent: a value that isn’t a command object, and a method that changes the DOM Sygnal renders (remove, append, setAttribute…), which the next render undoes or trips over. sygnal-check reports the method of a literal command object before the app runs: a slip of a documented command or of the control’s spec commands ({ fokus: Email }, did you mean ‘focus’), and a DOM-mutating method. Any other method name is left alone, since the core runs any method the element has.

Fix: Use one of the element’s methods or the control’s commands, with the method as the first key: { focus: Email, preventScroll: true }, { close: HelpDialog, returnValue: 'ok' }. Render dialogs as <dialog> and popovers with attrs: { popover: 'auto' }. To change what the page shows, change the state instead.

Persisted state could not be restored or saved

Severity: warn · Reported by: the Sygnal runtime (every app, production included)

persist({ ... }) failed to read, migrate or write the stored state: the stored entry is not JSON (edited by hand, written by something else), migrate(old, fromVersion) threw, or the storage refused the write (the quota is full, storage is blocked in a private window or by the browser’s settings). The app keeps running: after a failed restore it starts from initialState, and after a failed write the change is simply not saved (the next change tries again). It is a warning printed in production too, since a full or blocked storage only shows up on users’ machines.

Fix: For a restore failure, check the stored entry under the persist key (DevTools > Application > Local Storage) and migrate() for that stored version; a corrupt entry is replaced on the next save. For a write failure, save less (pick only what must survive a reload) or catch quota errors in a custom storage adapter.

Declaration static with no driver to take it

Severity: warn · Reported by: the dev checks (sygnal/diagnostics), sygnal-check

A component declares timers, connections, resources or browser, but no registered driver takes that static, so nothing happens and nothing else says so: the timers never fire, the connections never open, the resources stay idle, the browser sources never start. The core sends each of these statics to the source of the driver that asks for it (makeTimerDriver(), makeSocketDriver(), makeFetchDriver(), makeBrowserDriver()), and the driver is opt-in: run() doesn’t register it for you. renderComponent provides fakes for all four (the timer fake runs the real driver, the browser fake the real driver over fake sources), so the warning only appears under run(). sygnal-check reports it statically when it finds the app’s run(App, drivers) call, its drivers are an object literal it can read, and the component is rendered by App; otherwise it says nothing.

Fix: Register the driver when you start the app: run(App, { TIMER: makeTimerDriver() }) for timers, run(App, { WS: makeSocketDriver() }) for connections, run(App, { HTTP: makeFetchDriver() }) for resources, run(App, { BROWSER: makeBrowserDriver() }) for browser (the key is yours to choose; the core finds the driver by the static it takes).

Custom element prop hides an HTMLElement member

Severity: warn · Reported by: the Sygnal runtime (every app, production included)

A defineElement(tag, Component, { props }) declaration names a prop that is also a member of HTMLElement, such as title, hidden, lang, dir or translate. The element defines its own property for each prop, so the native member of that name stops working on this element: setting el.hidden = true updates the prop instead of hiding the element, and the matching attribute no longer has its native effect. sygnal/element reports it once per tag when the element first connects, through the diagnostics core when diagnostics are on (a console warning in dev otherwise).

Fix: Rename the prop (heading instead of title, collapsed instead of hidden) and update the attribute and the component’s state key that it feeds. Keep the name only when replacing the native behaviour is what you want.

viewTransitions that can’t run

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

A component declares viewTransitions (for example Board.viewTransitions = ['MOVE']), but the app’s DOM driver can’t run View Transitions, so those actions patch the page at once, without the animation, and nothing else says so. The static only asks: the transition is run by the DOM driver from makeViewTransitionDOMDriver(), which is opt-in so that apps without View Transitions don’t ship it, and run()’s default DOM driver is the plain one. Reported once per component when it starts. Not reported under renderComponent (the mock DOM and the dom: 'real' container don’t animate). Browsers without the API and prefers-reduced-motion: reduce also patch at once, silently: that is the intended fallback, not this warning. The same code reports a viewTransitions static that is not an array (Board.viewTransitions = true, or a single string): it lists no action, so nothing animates (the core tolerates the value instead of throwing in every reducer; a string is matched as a substring, which is never what was meant).

Fix: Give run() the View Transition DOM driver: run(App, { DOM: makeViewTransitionDOMDriver('#root') }) with import { makeViewTransitionDOMDriver } from 'sygnal' (use the same mount point as before). Or remove the viewTransitions static. For a value that isn’t an array, list the action names in one: Board.viewTransitions = ['MOVE'].

Widget mount threw

Severity: error · Reported by: the Sygnal runtime (every app, production included), the dev checks (sygnal/diagnostics)

A widget’s mount(el, props, dispatch) threw when its host entered the page (or on the first client patch after server rendering). The widget is not mounted. The error goes to the app’s onError hook with phase 'widget', and the component that renders the widget shows its onError fallback in the widget’s place on its next render (an empty error <div> without one); the rest of its view keeps working. Without the dev entry it is logged as [Sygnal SYG660] followed by the error.

Fix: Fix mount() (a missing library import, an option the library rejects, a prop that is undefined on the first render). Give the component that renders the widget an .onError to show a custom fallback.

Widget update threw or reported an error

Severity: error · Reported by: the Sygnal runtime (every app, production included), the dev checks (sygnal/diagnostics)

A widget failed after it mounted: either its update(instance, props, el) threw when the props it was rendered with changed (a widget without update is remounted instead, so its mount() threw), or the widget reported an error itself through error(e), mount’s fourth parameter (a failure in its own timer, callback or redraw, such as a fromZag render on a machine-driven update). The dev message says which: update() threw or reported an error through error(e). Either way, as for SYG660, the error goes to the app’s onError hook with phase 'widget' and the owner’s onError fallback replaces the widget on its next render. Without the dev entry it is logged as [Sygnal SYG661] followed by the error.

Fix: For a throwing update: fix update() for every value the view can pass (null, an empty list, a value of another type). update gets the newest props each time they change (a shallow compare), so it should not keep the props it was first given. For error(e): fix what the widget reports (the error’s stack points to its timer or callback), or give the owner an .onError for a custom fallback.

Widget unmount threw

Severity: error · Reported by: the Sygnal runtime (every app, production included), the dev checks (sygnal/diagnostics)

A widget’s unmount(instance, el) threw when its host left the page. The host is removed anyway, but what unmount didn’t finish (listeners on document, timers, observers) may keep running. The error goes to the app’s onError hook with phase 'widget'; there is no fallback to show, since the widget is gone. Without the dev entry it is logged as [Sygnal SYG662] followed by the error.

Fix: Fix unmount(), for example by guarding a library’s destroy() that throws when called twice or after its element was removed.

Invalid browser-source spec or command

Severity: error · Reported by: the dev checks (sygnal/diagnostics)

An entry of a component’s browser static can’t be started, or a command sent to the browser driver’s sink can’t be run, so it is skipped and nothing would happen. A browser entry is an object with one key that names its source (intersection, resize, media, storage, visibility, online, geolocation) and that names the action its events are delivered as (action: 'SEEN', a string); intersection and resize take a selector in the component’s own view, or true for its root element. A command names its method as one of its keys: copy, paste, setItem or removeItem (those of the sources the driver was made with). Reported once per component, entry and spec.

Fix: Write the entry as seen: { intersection: '.cover', action: 'SEEN' }, dark: { media: '(prefers-color-scheme: dark)', action: 'DARK' } (a falsy value stops it: seen: !state.seen && { ... }), and a command as COPY: { BROWSER: (state) => ({ copy: state.link, ok: 'COPIED' }) }. Clipboard reads and writes are commands, not browser entries.

Browser source not in the driver

Severity: error · Reported by: the dev checks (sygnal/diagnostics)

A browser entry (or a BROWSER command) uses a source that the app’s browser driver wasn’t made with: makeBrowserDriverWith(intersectionSource, mediaSource) takes only the sources it is given, so that the others add no bytes, and an entry of any other kind is not started. makeBrowserDriver() has every source.

Fix: Add the source to the driver, e.g. makeBrowserDriverWith(intersectionSource, mediaSource, storageSource), or use makeBrowserDriver().

Browser source failed with no error action

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

A browser source or command failed: the user denied the geolocation or clipboard permission, the API isn’t available (no navigator.clipboard outside a secure context, no navigator.geolocation), the position timed out, or storage is blocked or full. Its spec names no error action, so the component never hears about it and keeps waiting. Reported once per component, entry and kind of failure.

Fix: Name an error action and handle it: here: { geolocation: true, action: 'POS', error: 'GEO_FAILED' } with GEO_FAILED: (state, { code, message }) => ..., or COPY: { BROWSER: (state) => ({ copy: state.link, ok: 'COPIED', error: 'COPY_FAILED' }) } with COPY_FAILED: (state, { name, message }) => .... Ask for permission-gated sources from a user action, and say why.

Adapter peer dependency missing

Severity: error · Reported by: the Sygnal runtime (every app, production included)

A module imports one of Sygnal’s adapter entries, but a package that entry needs isn’t installed. The adapters keep their libraries as optional peer dependencies, so an app that doesn’t use them never installs them: sygnal/react needs react and react-dom (or a preact/compat alias), sygnal/zag needs @zag-js/vanilla, and sygnal/ui/menu, sygnal/ui/select and sygnal/ui/combobox each need @zag-js/vanilla and their own machine (@zag-js/menu, @zag-js/select, @zag-js/combobox). The sygnal/vite plugin reports it when it transforms the importing module, before the bundler fails to resolve the import inside Sygnal’s own files with a less helpful message.

Fix: Install what the message lists: npm install react react-dom for sygnal/react; npm install @zag-js/vanilla@1.45 @zag-js/menu@1.45 for sygnal/ui/menu (likewise @zag-js/select for sygnal/ui/select, @zag-js/combobox for sygnal/ui/combobox) (the Zag packages must share one version). For Preact, alias react, react-dom and react-dom/client to preact/compat (and preact/compat/client) in the bundler config.

Invalid adapter argument

Severity: error · Reported by: the Sygnal runtime (every app, production included)

fromZag(zag, render, options) got a first argument that isn’t a Zag machine package (an object with machine and connect, as import * as menu from '@zag-js/menu' gives), or a render that isn’t a function; or fromReact(Component, options) got something that isn’t a React component (a function, or an object such as memo() or forwardRef() returns). It is thrown when the widget is defined, at module load, so the app doesn’t start with a widget that can’t mount.

Fix: Pass the whole machine package and a render function: fromZag(menu, (api, props) => <div>...</div>, { events: { select: ['onSelect', (d) => d.value] } }) with import * as menu from '@zag-js/menu' (not the default import or menu.machine). For React, pass the component itself: fromReact(StarRating, { events: { rate: 'onChange' } }), not an element (<StarRating />) or a tag name.

Browser source has no element to observe

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

An intersection or resize entry of a component’s browser declaration has nothing to observe, so its action never comes. Either no DOM source reached the browser driver for the instance (the app runs without a DOM driver, or the declaration was sent from outside a component’s browser static), or, under renderComponent(C, { dom: 'real' }), the entry’s selector matches no element the component renders (a typo, or an element rendered only later). The t.browser fake still sends its initial report, so the test can pass while the real app observes nothing. Reported once per component, entry and reason.

Fix: Run the app with a DOM driver (run(App, drivers, { mountPoint })) and declare the entry in the component’s own browser static. Use a selector the component’s own view renders, or true for its root element: cover: { intersection: '.cover', action: 'SEEN' } with <img className="cover" /> in the view. Declare the entry only while its element exists: chart: state.open && { resize: '.chart', action: 'SIZE' }.

Sygnal component inside a fromZag render

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

A fromZag(zag, render) widget’s render returned a Sygnal component (<Badge />), a widget tag (<Icon />, <Menu />), or special JSX (<Transition>, <Portal>, <Collection>, <Switchable>, <Suspense>, <ClientOnly>, <VirtualCollection>, <Slot>, with or without props). The adapter patches its render into the widget host with a snabbdom patch of its own, outside the component tree, so none of these run there: a component isn’t instantiated, a widget doesn’t mount, and a marker renders as an unknown element. Only plain elements (with Zag’s prop getters spread on them) work inside the render. Reported once per widget.

Fix: Render plain elements in the render function (<span className="badge">{props.text}</span> instead of <Badge text={props.text} />). Pass data in through the widget’s props, and put components, widgets and special JSX around the widget tag in the component’s view instead of inside it.

Auth header sent from the browser to a hosted endpoint

Severity: error · Reported by: the Sygnal runtime (every app, production included)

A sygnal/ai HTTP transport (openResponses, chatCompletions, anthropicMessages, uiMessageStream, agui) running in a browser was about to send an auth header (Authorization, x-api-key, api-key, x-goog-api-key) to a host that is neither local (localhost, 127.0.0.1, [::1], *.localhost) nor the page’s own origin. A key in browser code ships to everyone who opens the page, who can then spend it. The request is refused before anything is sent: the chat request fails (its error action gets the error, or SYG678 logs it) and this message is logged, in production too. Requests to the page’s own origin (the app’s server or proxy) and to local servers such as Ollama are allowed, and so is every request outside a browser (SSR, tests).

Fix: Call the provider from your server and point the transport at that route: an AI SDK route with uiMessageStream('/api/chat'), or your own proxy with openResponses({ baseURL: '/api/llm' }), so the key stays there. Only for a key the user typed in themselves (a bring-your-own-key tool), pass dangerouslyAllowBrowser: true to the transport.

Strict mode without the strict layer

Severity: error · Reported by: the dev checks (sygnal/diagnostics)

A transport (openResponses, chatCompletions, anthropicMessages) was given strict: true. Strict mode rewrites every tool schema (and the output schema) into the provider’s strict subset, and that code is imported separately, as strictSchemas from sygnal/ai, so apps that don’t use strict mode don’t carry it (about 1.1 KB gzipped). strict takes that import, not a boolean. The transport sends its requests non-strict: the model may produce arguments outside the schemas, and the agent layer and the chat driver still validate every call. Reported in development, once per transport.

Fix: Import the strict layer and pass it: import { openResponses, strictSchemas } from 'sygnal/ai' and openResponses({ baseURL, model, strict: strictSchemas }). Or drop strict to send portable schemas.

Malformed chat stream event

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

A chat transport (the transport of makeChatDriver(), from sygnal/ai or your own) yielded an event the driver can’t use: not an object with a string type, a text or reasoning event whose delta isn’t a string, a tool-call with no tool name, or a tool-result / tool-error whose id matches no tool call of the reply. The event is skipped and the stream goes on, so the reply may miss text or a tool call. Events of an unknown type are ignored silently, as the Open Responses spec requires; this is only for known types with the wrong shape. Reported for the first five such events per driver, in development.

Fix: Fix the transport so it yields ChatEvents (see sygnal/ai): { type: 'text', delta: 'Hi' }, { type: 'reasoning', delta }, { type: 'tool-call', id, name, input }, { type: 'tool-result', id, output }, { type: 'data', name, data }, { type: 'finish', reason, usage }. A transport that parses a wire format should map each provider event to one of these and drop the ones it doesn’t know.

WebMCP not available

Severity: info · Reported by: the Sygnal runtime (every app, production included)

experimentalExposeWebMcp() found no WebMCP context: no modelContext option, and neither document.modelContext nor the older navigator.modelContext exists. That is the normal case in browsers without WebMCP (Chrome before the origin trial, Firefox, Safari) and on insecure pages, so it is only information: the call does nothing and returns a stop() whose available is false. Chrome 153 exposes WebMCP behind chrome://flags/#enable-webmcp-testing (--enable-features=WebMCP) or an origin trial token.

Fix: Nothing to fix. To try it elsewhere, install @mcp-b/webmcp-polyfill (installWebMCP()) before the call, or pass modelContext. Use stop.available to show or hide UI that depends on an agent.

Tool sent non-strict under strict mode

Severity: info · Reported by: the dev checks (sygnal/diagnostics)

A transport with strict: strictSchemas (openResponses, chatCompletions, anthropicMessages) rewrites each tool’s input schema (and the output schema) into the provider’s strict subset, so the model can only produce arguments that match. A schema with no strict form, such as a record (z.record(), additionalProperties with a schema) or a non-object root, and for Anthropic also a recursive schema, a value of any type, or a schema past Anthropic’s per-request limits (20 strict tools, 24 optional and 16 union-typed parameters across the strict schemas), is sent non-strict on its own while the other tools stay strict. The model may then send arguments outside that schema; the agent layer and the chat driver still validate every call and return the issues. Collected in development (info), once per tool and transport.

Fix: Give the schema a strict form: an object root with named properties instead of a record (for example an array of { key, value } objects). Or accept the non-strict tool: validation still protects the state.

WebMCP registerTool rejected

Severity: warn · Reported by: the Sygnal runtime (every app, production included)

experimentalExposeWebMcp() registers each agent tool with document.modelContext.registerTool(), which returns a Promise. The browser refused one: most often InvalidStateError: Duplicate tool name (another script, a second experimentalExposeWebMcp() call on the same page, or a declarative form registered the same name first), or an invalid name or empty description. The rejection is reported, never thrown, and that tool stays unavailable to the agent until its name, description or schema changes. Reported once per tool and registration, in development.

Fix: Give each exposure its own prefix (experimentalExposeWebMcp(app, { prefix: 'shop_' })), rename the clashing agent declaration or form toolname, and call stop() before exposing the same app again.

Chat request sent from outside a component

Severity: warn · Reported by: the dev checks (sygnal/diagnostics)

A request reached makeChatDriver() without the sender stamp the core puts on every value a component’s model sends. The chat driver answers only with reply actions (delta, tool, ok, error) on the component that sent the request, and it has no select() stream, so a request from anywhere else (a stream written into the driver’s sink by hand, a driver wrapper that rebuilds the request object, a test that calls the driver directly) could never be answered. It is dropped, and nothing is streamed.

Fix: Send chat requests from a component’s model: SEND: { LLM: (state) => ({ messages: state.messages, delta: 'DELTA', ok: 'DONE', error: 'FAILED' }) }. A wrapper around the driver must pass the request object through unchanged (or copy its non-enumerable sender stamp). In tests, use renderComponent’s LLM fake (t.stream('LLM', chunks)).

Chat request failed with no error action

Severity: error · Reported by: the Sygnal runtime (every app, production included)

A makeChatDriver() request failed (the network or the provider returned an error, the transport threw, or the structured output was not valid JSON or failed its schema), and the request names no error action. The chat driver has no errors() stream, so without an error action the component never hears of the failure and stays in its streaming state. The driver logs the failure with this code, in production too. A stream that was aborted (abort, latest, a removed component, the app disposed) is not a failure and is never reported.

Fix: Name an error action on the request and handle it: { messages, delta: 'DELTA', ok: 'DONE', error: 'FAILED' } with FAILED: (state, { error, issues }) => ({ ...state, status: 'error', error: error.message }). In tests, t.fail('LLM', error) drives that action.

Invalid chat request

Severity: error · Reported by: the Sygnal runtime (every app, production included)

A value sent to a makeChatDriver() sink is not a request the driver can send: it isn’t an object, it has no messages array, its coalesce is not 'frame', 'none' or a number of milliseconds, or its output is not a Standard Schema (an object with ~standard.validate). The request is not sent, so no reply action follows. Stopping a stream is { abort: key } or { abort: true }, not a request.

Fix: Send { messages: [...], ok: 'DONE', error: 'FAILED' } (add delta: 'DELTA' to stream, key to name the request, output: schema for structured output with a Zod, Valibot or ArkType schema), or { abort: 'reply' } to stop one. Return ABORT (or nothing) from the model entry when there is nothing to send.

A diagnostics check threw

Severity: warn · Reported by: the dev checks (sygnal/diagnostics), sygnal-check

A diagnostics check itself crashed: one of the sygnal/diagnostics runtime checks threw inside a hook, or a sygnal-check rule threw (or a file could not be parsed). The failure is isolated, so the app and the other checks keep running, but that check produced no results for the code involved. This points to a bug in the checker, or (for parse errors) a syntax error in your file. sygnal-check also reports problems in its config file (sygnal-check.config.json or the "sygnal-check" key in package.json) under this code: malformed JSON, an unknown key or a value of the wrong type (warnings, with the closest key suggested), and both files present (info: the package.json key is ignored). A broken config file falls back to the defaults.

Fix: For a parse error, fix the syntax error in the named file; for a config-file finding, fix or remove the named key (or the JSON syntax); otherwise please report it to Sygnal with the file or component that triggers it.

Retired in 6.0: Sub-component sink stream errored

Severity: error · Reported by: retired

Retired in 6.0 and never reported. It was logged when the stream merging a component’s sub-component sinks errored, an internal of the core Sygnal 6.0 replaced (PLAN-4.6). The 6.0 core has no such stream: an error in a child’s reducer, view or driver value is reported under that phase’s own code (SYG216, SYG406, …) and the app’s onError hook.

Fix: Nothing to change: errors are reported under their phase’s own code (SYG216, SYG406, …) and the app’s onError hook.

Retired in 6.0: EFFECT stream errored

Severity: error · Reported by: retired

Retired in 6.0 and never reported. It was logged when the stream running a component’s EFFECT entries errored, an internal of the core Sygnal 6.0 replaced (PLAN-4.6). In 6.0 an EFFECT runs synchronously with its action; a throwing EFFECT is reported as SYG216 (with the onError hook) and later actions keep running.

Fix: Nothing to change: a throwing EFFECT is reported as SYG216 and the app’s onError hook, and later actions keep running.

Retired in 6.0: Component factory returned invalid sinks

Severity: error · Reported by: retired

Retired in 6.0 and never reported. It was thrown when a component factory (the removed component() / collection() / switchable() helpers, or a hand-written one) returned something other than a sinks object. Sygnal 6.0 instantiates every component from its function; there are no factories.

Fix: Nothing to change. Pass component functions to JSX, of and CHILD.select.

Click listener on a non-interactive element

Severity: warn · Reported by: sygnal-check

The intent listens for clicks (DOM.click('.card'), DOM.select('.card').events('click'), or a control such as controls({ Card: 'div' })) on an element that is not interactive, such as a <div>, <span>, <li>, <p> or <img>, and that has no role and tabIndex. Mouse users can click it, but keyboard users can’t focus it and screen readers don’t announce it as something to activate, so the action is out of their reach. sygnal-check matches the intent selector against the component’s own view; it says nothing when the element contains a button, link or form field (a click on those bubbles to the listener), a child component, or spread props. It is a warning, also under --strict; --a11y=error (or a11y: 'error' in check() and the Vite plugin’s check options) makes it an error.

Fix: Render a <button type="button"> (or declare the control as 'button') and style it as needed. If the element must stay a <div>, add role="button" tabIndex={0} and also handle DOM.keydown(x) for Enter and Space. To keep one on purpose, add // sygnal-ignore SYG701 above the intent line.

Form field without an accessible label

Severity: warn · Reported by: sygnal-check

An <input> (other than hidden, submit, reset, button or image), <select> or <textarea> has no accessible name: no <label> wraps it, no <label for> matches its id, and it has no aria-label, aria-labelledby, title or placeholder. Screen readers then announce only “edit text” or “combo box”. A placeholder counts as a last-resort name (as axe does), though a visible label is better. sygnal-check follows the field to the component or helper that renders it and accepts a label around every place it is used; a uid('x') id matches a label with the same uid('x') call. It says nothing for spread props, a dynamic type or id, or a field passed into a child component. It is a warning, also under --strict; --a11y=error (or a11y: 'error' in check() and the Vite plugin’s check options) makes it an error.

Fix: Wrap the field: <label>Email <input type="email" /></label>, or link them by id: <label for={uid('email')}>Email</label><input id={uid('email')} /> (uid is a view prop). For a field with no visible label, add aria-label="Search".

Image without alt text

Severity: warn · Reported by: sygnal-check

An <img> has no alt attribute. Screen readers then read out the file name, or skip the image without saying what it shows. alt="" is allowed: it marks the image as decorative. Images named another way (aria-label, aria-labelledby, title) or hidden (role="presentation", aria-hidden) are not reported. It is a warning, also under --strict; --a11y=error (or a11y: 'error' in check() and the Vite plugin’s check options) makes it an error.

Fix: Add alt="what the image shows" (for a dynamic image, alt={item.name}); for a purely decorative image use alt="".

Link without href used as a button

Severity: warn · Reported by: sygnal-check

The intent listens for clicks on an <a> that has no href. Without href an <a> is not focusable and is announced as plain text, so keyboard and screen-reader users can’t trigger the action. sygnal-check matches the intent’s click selectors against the component’s own view, like SYG701. It is a warning, also under --strict; --a11y=error (or a11y: 'error' in check() and the Vite plugin’s check options) makes it an error.

Fix: For an action, render <button type="button"> (styled as a link if needed). For navigation, give the <a> a real href (with the router, href={router.href('task', { id })}).

Button without an accessible name

Severity: warn · Reported by: sygnal-check

A <button> has no text content and no aria-label, aria-labelledby or title, so screen readers announce only “button”. This is usually an icon-only button: an <i>, an <svg> without <title>, or an <img alt="">. Only literal children are checked: a button whose children include an expression ({state.label}), a function call or a child component is not reported. One call is known: an icon helper, a function named icon or renderIcon called with a string or an icon imported from an icon package (lucide, @lucide/*, @mdi/*, or a module whose name contains icon, such as @tabler/icons or ./icons.js), as in the icons recipe: <button>{icon(Trash2)}</button> is reported unless the call’s options name the icon with label, the option the recipe’s helper reads (icon(Trash2, { label: 'Delete' }); title or aria-label there doesn’t count). A helper of that name that renders its own screen-reader text (an sr-only span) is reported too: put the name on the button (aria-label), or add // sygnal-ignore SYG705 on the line. It is a warning, also under --strict; --a11y=error (or a11y: 'error' in check() and the Vite plugin’s check options) makes it an error.

Fix: Put text in the button, or for an icon-only button add aria-label="Close" and mark the icon aria-hidden="true".

Positive tabIndex

Severity: warn · Reported by: sygnal-check

An element has tabIndex greater than 0. Positive values move it ahead of every element with tabIndex={0} or none, so the Tab order no longer follows the page, and every other positive value has to be kept in step by hand. It is a warning, also under --strict; --a11y=error (or a11y: 'error' in check() and the Vite plugin’s check options) makes it an error.

Fix: Use tabIndex={0} to make an element focusable in document order, or tabIndex={-1} to focus it only from code. Change the order of the markup if the Tab order should change.

Unknown ARIA attribute or invalid role

Severity: warn · Reported by: sygnal-check

An element has an aria-* attribute that is not in WAI-ARIA (usually a typo such as aria-lable or aria-labeledby), or a role that is not a WAI-ARIA role: a typo, or an abstract role such as widget or section that authors may not use. Assistive technology ignores both, so the intended label, state or role never reaches users. The lists are WAI-ARIA 1.2 plus the 1.3 additions, and the doc-* and graphics-* role modules; dynamic role values are not checked. It is a warning, also under --strict; --a11y=error (or a11y: 'error' in check() and the Vite plugin’s check options) makes it an error.

Fix: Fix the spelling (the message suggests the closest name), or use a concrete role such as button, checkbox, tab or region. For your own data, use a data-* attribute.

Label or ARIA reference to an id that is not rendered

Severity: warn · Reported by: sygnal-check

A <label for>, aria-describedby or aria-labelledby names an id that no element renders, so the label or description is attached to nothing (often a typo, or an id that was renamed on one side only). A literal id may be rendered anywhere in the checked files; a uid('x') reference needs an element in the same component with id={uid('x')}. When some id in the project is dynamic (id={props.id}), literal references are not checked, and dynamic references never are. It is a warning, also under --strict; --a11y=error (or a11y: 'error' in check() and the Vite plugin’s check options) makes it an error.

Fix: Render the target with the same id, or fix the reference. Inside a component, use uid('x') on both sides: <input id={uid('email')} aria-describedby={uid('email-error')} /> and <p id={uid('email-error')}>.

Menu, Select or Combobox without an accessible name

Severity: warn · Reported by: sygnal-check

A <Menu>, <Select> or <Combobox> from sygnal/ui/menu, sygnal/ui/select or sygnal/ui/combobox has no label, aria-label or aria-labelledby. The part names its control from them (Menu’s trigger text is its label; Select’s trigger and Combobox’s input are labelled by the visible label, or take aria-label / aria-labelledby directly), so without any of them a screen reader announces an unnamed menu button or combobox. A dynamic value or a spread counts as a name; an empty string doesn’t. It is a warning, also under --strict; --a11y=error (or a11y: 'error' in check() and the Vite plugin’s check options) makes it an error.

Fix: Give it a visible label: <Select className="size" label="Size" items={SIZES} />. When the label is elsewhere on the page, point at it with aria-labelledby="<its id>"; for an icon-only Menu trigger, use aria-label="More actions".

Sortable handle not keyboard-accessible, or no live region

Severity: warn · Reported by: sygnal-check

A sortable use whose keyboard path or announcements can’t work. Keyboard moves start on the handle (the item when there is no handle option): sygnal-check finds the elements that match it in the host’s view and the views it renders (Collection items, children) and reports one that can’t take focus (not a button, field, link with href or summary, no tabIndex or tabIndex={-1}, not contenteditable), or a focusable one other than a <button> (SYG705 covers those) with no aria-label / aria-labelledby / title and no content. It also reports a host that renders no live region (an element with aria-live, or role="status", "alert" or "log") in those views, since the pick-up, move and drop announcements are state.<key>.message. Options that aren’t literal strings, selectors beyond tag / .class / #id (or the default [data-id]), elements with spread props and views it can’t follow are skipped. It is a warning, also under --strict; --a11y=error makes it an error.

Fix: Render the handle as <button type="button" className="grip" aria-label={'Reorder ' + state.title} aria-describedby={context.sort.helpId}>, and the message in a live region: <p role="status" aria-live="assertive">{state.sort.message}</p>. An app-wide announcer elsewhere: // sygnal-ignore SYG724.

Action reachable only by hovering

Severity: warn · Reported by: sygnal-check

An intent action’s stream comes only from mouseenter / mouseover (or pointerenter / pointerover) listeners, nothing else triggers it (no other DOM event in that stream, no next(), reply action, connection, timer or behavior), and its model entry changes the state. Keyboard users never hover and touch screens hover only on a tap, so what it shows (a tooltip, a preview, a menu) or does can’t be reached by them. An agent declaration doesn’t count as a path: it serves agents, not people. Only streams the checker can see in full are judged (DOM listeners with literal event names, xstream operators, callbacks); an entry without a STATE sink (a prefetch on hover) is not reported. It is a warning, also under --strict; --a11y=error makes it an error.

Fix: Trigger the action from focus too, and close it on blur and Escape as on mouse leave: SHOW: xs.merge(DOM.mouseenter('.card'), DOM.focusin('.card')), HIDE: xs.merge(DOM.mouseleave('.card'), DOM.focusout('.card')); give the element a way to take focus (a <button>). For a tooltip, tooltip() from sygnal/ui handles hover, focus and Escape.

Toggled state shown only by a class

Severity: warn · Reported by: sygnal-check

An element that a click listener targets shows an on/off or selected state only through a class that follows the state (className={{ done: state.done }}, className={state.filter === 'done' ? 'selected' : ''}), while the click’s own STATE reducer flips that field (done: !state.done) or, for an equality, writes it (filter: 'done'). Sighted users see the change; a screen reader announces a plain button. To keep false positives low the class must follow a field the reducer toggles or selects (a busy class such as { saving: state.saving } on a Save button is not reported), and elements that carry state natively (checkbox / radio inputs, select, option, details / summary, label) or already have aria-pressed, aria-checked, aria-expanded, aria-selected or aria-current are fine. It is a warning, also under --strict; --a11y=error makes it an error.

Fix: Expose the state with the same condition as the class: <button className={{ done: state.done }} aria-pressed={state.done}>; aria-selected for a tab or listbox option, aria-expanded for a disclosure, aria-checked with role="switch", aria-current="page" for the current link. Or use a native <input type="checkbox"> with a label.