sygnal 5.4.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +646 -0
- package/README.md +24 -9
- package/dist/astro/client.cjs.js +16 -9
- package/dist/astro/client.cjs.js.map +1 -1
- package/dist/astro/client.mjs +16 -9
- package/dist/astro/client.mjs.map +1 -1
- package/dist/astro/index.cjs.js +362 -91
- package/dist/astro/index.cjs.js.map +1 -1
- package/dist/astro/index.mjs +362 -92
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/server.cjs.js +3396 -165
- package/dist/astro/server.cjs.js.map +1 -1
- package/dist/astro/server.mjs +3396 -165
- package/dist/astro/server.mjs.map +1 -1
- package/dist/devtools.cjs.js +1431 -0
- package/dist/devtools.cjs.js.map +1 -0
- package/dist/devtools.esm.js +1419 -0
- package/dist/devtools.esm.js.map +1 -0
- package/dist/diagnostics.cjs.js +3032 -296
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +3032 -296
- package/dist/diagnostics.esm.js.map +1 -1
- package/dist/element.cjs.js +230 -0
- package/dist/element.cjs.js.map +1 -0
- package/dist/element.esm.js +228 -0
- package/dist/element.esm.js.map +1 -0
- package/dist/guide/accessibility.md +257 -0
- package/dist/guide/adapters.md +221 -0
- package/dist/guide/behaviors.md +312 -0
- package/dist/guide/browser-sources.md +203 -0
- package/dist/guide/drag-and-drop.md +342 -0
- package/dist/guide/element-commands.md +306 -0
- package/dist/guide/error-boundaries.md +169 -0
- package/dist/guide/forms-reference.md +381 -0
- package/dist/guide/forms.md +250 -0
- package/dist/guide/http.md +299 -0
- package/dist/guide/inputs.md +173 -0
- package/dist/guide/persistence.md +245 -0
- package/dist/guide/recipes/carousel.md +155 -0
- package/dist/guide/recipes/charts.md +177 -0
- package/dist/guide/recipes/code-editor.md +135 -0
- package/dist/guide/recipes/data-grid.md +155 -0
- package/dist/guide/recipes/data-table.md +196 -0
- package/dist/guide/recipes/i18n.md +222 -0
- package/dist/guide/recipes/icons.md +115 -0
- package/dist/guide/recipes/overview.md +33 -0
- package/dist/guide/recipes/rich-text.md +154 -0
- package/dist/guide/resources.md +471 -0
- package/dist/guide/ssr.md +255 -0
- package/dist/guide/timers.md +178 -0
- package/dist/guide/ui/accordion.md +93 -0
- package/dist/guide/ui/combobox.md +144 -0
- package/dist/guide/ui/dialog.md +124 -0
- package/dist/guide/ui/disclosure.md +60 -0
- package/dist/guide/ui/menu.md +125 -0
- package/dist/guide/ui/overview.md +166 -0
- package/dist/guide/ui/popover.md +101 -0
- package/dist/guide/ui/select.md +114 -0
- package/dist/guide/ui/tabs.md +161 -0
- package/dist/guide/ui/toaster.md +152 -0
- package/dist/guide/ui/tooltip.md +103 -0
- package/dist/guide/undo.md +178 -0
- package/dist/guide/virtual-collections.md +183 -0
- package/dist/guide/web-components.md +179 -0
- package/dist/guide/widgets.md +217 -0
- package/dist/index.cjs.js +13178 -5769
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +2908 -276
- package/dist/index.esm.js +13149 -5779
- package/dist/index.esm.js.map +1 -1
- package/dist/jsx-dev-runtime.cjs.js +26 -334
- package/dist/jsx-dev-runtime.cjs.js.map +1 -1
- package/dist/jsx-dev-runtime.esm.js +19 -327
- package/dist/jsx-dev-runtime.esm.js.map +1 -1
- package/dist/jsx-runtime.cjs.js +26 -334
- package/dist/jsx-runtime.cjs.js.map +1 -1
- package/dist/jsx-runtime.esm.js +19 -327
- package/dist/jsx-runtime.esm.js.map +1 -1
- package/dist/jsx.cjs.js +10 -314
- package/dist/jsx.cjs.js.map +1 -1
- package/dist/jsx.esm.js +8 -315
- package/dist/jsx.esm.js.map +1 -1
- package/dist/react.cjs.js +117 -0
- package/dist/react.cjs.js.map +1 -0
- package/dist/react.esm.js +115 -0
- package/dist/react.esm.js.map +1 -0
- package/dist/shims/globalthis.cjs +20 -0
- package/dist/sygnal.min.js +1 -1
- package/dist/sygnal.min.js.map +1 -1
- package/dist/ui-combobox.cjs.js +169 -0
- package/dist/ui-combobox.cjs.js.map +1 -0
- package/dist/ui-combobox.esm.js +148 -0
- package/dist/ui-combobox.esm.js.map +1 -0
- package/dist/ui-menu.cjs.js +87 -0
- package/dist/ui-menu.cjs.js.map +1 -0
- package/dist/ui-menu.esm.js +66 -0
- package/dist/ui-menu.esm.js.map +1 -0
- package/dist/ui-select.cjs.js +104 -0
- package/dist/ui-select.cjs.js.map +1 -0
- package/dist/ui-select.esm.js +83 -0
- package/dist/ui-select.esm.js.map +1 -0
- package/dist/ui.cjs.js +738 -0
- package/dist/ui.cjs.js.map +1 -0
- package/dist/ui.esm.js +727 -0
- package/dist/ui.esm.js.map +1 -0
- package/dist/vike/ClientOnly.cjs.js +4 -14
- package/dist/vike/ClientOnly.cjs.js.map +1 -1
- package/dist/vike/ClientOnly.mjs +4 -14
- package/dist/vike/ClientOnly.mjs.map +1 -1
- package/dist/vike/{+config.js → config/+config.js} +9 -1
- package/dist/vike/config/+config.js.map +1 -0
- package/dist/vike/config/package.json +6 -0
- package/dist/vike/onRenderClient.cjs.js +165 -95
- package/dist/vike/onRenderClient.cjs.js.map +1 -1
- package/dist/vike/onRenderClient.mjs +165 -95
- package/dist/vike/onRenderClient.mjs.map +1 -1
- package/dist/vike/onRenderHtml.cjs.js +59 -24
- package/dist/vike/onRenderHtml.cjs.js.map +1 -1
- package/dist/vike/onRenderHtml.mjs +60 -25
- package/dist/vike/onRenderHtml.mjs.map +1 -1
- package/dist/vite/plugin.cjs.js +323 -88
- package/dist/vite/plugin.cjs.js.map +1 -1
- package/dist/vite/plugin.mjs +323 -89
- package/dist/vite/plugin.mjs.map +1 -1
- package/dist/zag.cjs.js +408 -0
- package/dist/zag.cjs.js.map +1 -0
- package/dist/zag.esm.js +405 -0
- package/dist/zag.esm.js.map +1 -0
- package/llms.txt +166 -103
- package/package.json +98 -14
- package/src/astro/client.ts +27 -16
- package/src/astro/index.d.ts +24 -0
- package/src/astro/index.ts +69 -3
- package/src/astro/server.ts +8 -2
- package/src/collection.ts +6 -93
- package/src/core/actions.ts +134 -0
- package/src/core/cell.ts +202 -0
- package/src/core/debug.ts +23 -0
- package/src/core/define.ts +242 -0
- package/src/core/hooks.ts +314 -0
- package/src/core/hosts/collection.ts +325 -0
- package/src/core/hosts/switchable.ts +146 -0
- package/src/core/instance.ts +592 -0
- package/src/core/markers/clientonly.ts +13 -0
- package/src/core/markers/lazy.ts +40 -0
- package/src/core/markers/portal.ts +96 -0
- package/src/core/markers/suspense.ts +41 -0
- package/src/core/markers/transition.ts +70 -0
- package/src/core/registry.ts +25 -0
- package/src/core/runtime.ts +573 -0
- package/src/core/statics.ts +104 -0
- package/src/core/teardown.ts +60 -0
- package/src/core/view.ts +37 -0
- package/src/cycle/dom/DocumentDOMSource.ts +13 -8
- package/src/cycle/dom/ElementFinder.ts +7 -9
- package/src/cycle/dom/EventDelegator.ts +174 -223
- package/src/cycle/dom/IsolateModule.ts +65 -35
- package/src/cycle/dom/MainDOMSource.ts +9 -2
- package/src/cycle/dom/PriorityQueue.ts +4 -2
- package/src/cycle/dom/SymbolTree.ts +18 -47
- package/src/cycle/dom/classNameModule.ts +9 -7
- package/src/cycle/dom/controlledInputModule.ts +23 -6
- package/src/cycle/dom/enrichEventStream.ts +25 -47
- package/src/cycle/dom/fragment.ts +7 -0
- package/src/cycle/dom/isolate.ts +30 -27
- package/src/cycle/dom/makeDOMDriver.ts +115 -56
- package/src/cycle/dom/mockDOMSource.ts +21 -1
- package/src/cycle/dom/modules.ts +16 -3
- package/src/cycle/dom/propsModule.ts +65 -0
- package/src/cycle/dom/selectModule.ts +22 -17
- package/src/cycle/dom/snabbdom.ts +1 -6
- package/src/cycle/dom/thunk.ts +3 -0
- package/src/cycle/dom/utils.ts +98 -0
- package/src/cycle/dom/viewTransition.ts +43 -0
- package/src/cycle/state/StateSource.ts +19 -3
- package/src/cycle/state/objIsEqual.ts +50 -0
- package/src/cycle/state/types.ts +0 -10
- package/src/defineComponent.ts +34 -0
- package/src/devtools.d.ts +151 -0
- package/src/devtools.ts +34 -0
- package/src/element.d.ts +59 -0
- package/src/element.ts +235 -0
- package/src/extra/backoff.ts +9 -0
- package/src/extra/behaviors.ts +169 -0
- package/src/extra/browserSignals.ts +23 -0
- package/src/extra/browserSources.ts +326 -0
- package/src/extra/command.ts +3 -3
- package/src/extra/controls.ts +47 -0
- package/src/extra/copyAsTest.ts +286 -0
- package/src/extra/devtools.ts +101 -31
- package/src/extra/devtoolsActions.ts +379 -0
- package/src/extra/devtoolsHook.ts +9 -0
- package/src/extra/devtoolsNext.ts +83 -0
- package/src/extra/diagnostics/checks/actionLog.ts +108 -0
- package/src/extra/diagnostics/checks/behaviors.ts +49 -0
- package/src/extra/diagnostics/checks/browserSources.ts +96 -0
- package/src/extra/diagnostics/checks/collections.ts +1 -1
- package/src/extra/diagnostics/checks/controls.ts +148 -0
- package/src/extra/diagnostics/checks/dataset.ts +57 -0
- package/src/extra/diagnostics/checks/dom.ts +20 -11
- package/src/extra/diagnostics/checks/elementCommands.ts +175 -0
- package/src/extra/diagnostics/checks/events.ts +17 -2
- package/src/extra/diagnostics/checks/fetch.ts +135 -0
- package/src/extra/diagnostics/checks/forms.ts +163 -0
- package/src/extra/diagnostics/checks/index.ts +97 -4
- package/src/extra/diagnostics/checks/inspect.ts +140 -32
- package/src/extra/diagnostics/checks/next.ts +417 -0
- package/src/extra/diagnostics/checks/persist.ts +54 -0
- package/src/extra/diagnostics/checks/props.ts +10 -5
- package/src/extra/diagnostics/checks/public.d.ts +98 -7
- package/src/extra/diagnostics/checks/replies.ts +128 -0
- package/src/extra/diagnostics/checks/router.ts +100 -0
- package/src/extra/diagnostics/checks/rxjsHints.ts +1 -1
- package/src/extra/diagnostics/checks/shared.ts +86 -2
- package/src/extra/diagnostics/checks/shorthand.ts +115 -0
- package/src/extra/diagnostics/checks/sortable.ts +129 -0
- package/src/extra/diagnostics/checks/state.ts +100 -7
- package/src/extra/diagnostics/checks/statics.ts +48 -0
- package/src/extra/diagnostics/checks/strict.ts +10 -67
- package/src/extra/diagnostics/checks/timers.ts +80 -0
- package/src/extra/diagnostics/checks/viewTransitions.ts +47 -0
- package/src/extra/diagnostics/checks/virtual.ts +73 -0
- package/src/extra/diagnostics/checks/widgets.ts +109 -0
- package/src/extra/diagnostics/checks/wiring.ts +66 -10
- package/src/extra/diagnostics/codes.ts +268 -23
- package/src/extra/diagnostics/index.ts +34 -9
- package/src/extra/diagnostics/legacy.ts +17 -0
- package/src/extra/driverFactories.ts +83 -19
- package/src/extra/elementCommands.ts +53 -0
- package/src/extra/fetchDriver.ts +519 -0
- package/src/extra/focusWithin.ts +27 -0
- package/src/extra/form.ts +268 -0
- package/src/extra/formHelpers.ts +139 -0
- package/src/extra/head.ts +106 -0
- package/src/extra/hmr.ts +2 -3
- package/src/extra/owned.ts +24 -0
- package/src/extra/pager.ts +50 -0
- package/src/extra/persist.ts +133 -0
- package/src/extra/pwa.ts +1 -1
- package/src/extra/queryCache.ts +107 -0
- package/src/extra/reducers.ts +1 -1
- package/src/extra/reduxDevtools.ts +92 -0
- package/src/extra/replies.ts +54 -0
- package/src/extra/router.ts +323 -0
- package/src/extra/run.ts +90 -217
- package/src/extra/selection.ts +76 -0
- package/src/extra/socketDriver.ts +265 -0
- package/src/extra/sortable.ts +377 -0
- package/src/extra/ssr.ts +427 -160
- package/src/extra/standardSchema.ts +27 -0
- package/src/extra/testing.ts +2433 -175
- package/src/extra/timers.ts +95 -0
- package/src/extra/undo.ts +261 -0
- package/src/extra/viewTransitions.ts +38 -0
- package/src/extra/virtual.ts +513 -0
- package/src/extra/widget.ts +201 -0
- package/src/index.d.ts +2631 -117
- package/src/index.ts +25 -4
- package/src/jsx-runtime.ts +15 -1
- package/src/jsx.ts +2 -1
- package/src/lazy.ts +50 -7
- package/src/portal.ts +3 -1
- package/src/pragma/index.ts +256 -133
- package/src/react-peers.d.ts +5 -0
- package/src/react.d.ts +43 -0
- package/src/react.ts +110 -0
- package/src/shared.ts +48 -0
- package/src/slot.ts +1 -1
- package/src/suspense.ts +3 -1
- package/src/switchable.ts +6 -121
- package/src/transition.ts +3 -1
- package/src/ui/accordion.ts +64 -0
- package/src/ui/dialog.ts +129 -0
- package/src/ui/disclosure.ts +35 -0
- package/src/ui/popover.ts +45 -0
- package/src/ui/shared.ts +94 -0
- package/src/ui/tabs.ts +82 -0
- package/src/ui/toaster.ts +198 -0
- package/src/ui/tooltip.ts +70 -0
- package/src/ui/zag/combobox.ts +115 -0
- package/src/ui/zag/menu.ts +40 -0
- package/src/ui/zag/select.ts +54 -0
- package/src/ui/zag/shared.ts +43 -0
- package/src/ui-combobox.d.ts +20 -0
- package/src/ui-combobox.ts +6 -0
- package/src/ui-menu.d.ts +30 -0
- package/src/ui-menu.ts +6 -0
- package/src/ui-select.d.ts +17 -0
- package/src/ui-select.ts +6 -0
- package/src/ui-zag-types.d.ts +42 -0
- package/src/ui.d.ts +208 -0
- package/src/ui.ts +15 -0
- package/src/vike/+config.ts +9 -1
- package/src/vike/ClientOnly.ts +1 -1
- package/src/vike/onRenderClient.ts +144 -96
- package/src/vike/onRenderHtml.ts +69 -26
- package/src/vike/types.ts +7 -1
- package/src/vite/globalthis-shim.ts +18 -0
- package/src/vite/globalthis.ts +56 -0
- package/src/vite/plugin.d.ts +22 -3
- package/src/vite/plugin.ts +222 -24
- package/src/zag.d.ts +64 -0
- package/src/zag.ts +238 -0
- package/dist/vike/+config.cjs.js +0 -66
- package/dist/vike/+config.cjs.js.map +0 -1
- package/dist/vike/+config.js.map +0 -1
- package/src/component.ts +0 -2301
- package/src/cycle/isolate/index.ts +0 -196
- package/src/cycle/run/index.ts +0 -151
- package/src/cycle/run/internals.ts +0 -143
- package/src/cycle/state/Collection.ts +0 -184
- package/src/cycle/state/index.ts +0 -5
- package/src/cycle/state/pickCombine.ts +0 -201
- package/src/cycle/state/pickMerge.ts +0 -122
- package/src/cycle/state/withState.ts +0 -47
- package/src/pragma/fn.ts +0 -55
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to Sygnal are listed here. Versions follow [semantic versioning](https://semver.org). Releases before 5.4.0 are described in the [GitHub releases](https://github.com/tpresley/sygnal/releases) and tags.
|
|
4
|
+
|
|
5
|
+
## 6.0.0 — 2026-10-08
|
|
6
|
+
|
|
7
|
+
Network calls get a first-class layer. A request names the actions its answer becomes (`HTTP: (state) => ({ url, ok: 'LOADED', error: 'FAILED' })`), so there is no `select()` round trip; `makeFetchDriver()` replaces hand-written `fetch` drivers and request-id bookkeeping; `makeSocketDriver()` and the `connections` static open, close and reconnect WebSockets and server-sent events from state. `renderComponent()` tests answer requests and script sockets without wiring a driver. Tests can run on fake timers, against a real DOM, and read `t.state`. This release also fixes Switchable, calculated-field, Collection and Vike bugs that the agent evals found, and makes apps about 6 KB smaller: DevTools leave production builds (about 2 KB, every bundler) and `sygnal/vite` drops xstream's `globalthis` polyfill (about 4 KB).
|
|
8
|
+
|
|
9
|
+
6.0 also smooths the everyday parts of writing a component. `controls()` link views and intents by identifier, as an alternative to class selectors. Behaviors (`uses`) package state, intent and model for reuse, with first-party `pager`, `selection` and `undo`. A built-in `ELEMENT` sink focuses fields, opens dialogs and scrolls rows into view from the model. `persist()` saves the root's state, `STATE.watch()` reacts to a state change, and the `timers` static declares intervals and timeouts from state. `uid()` gives stable ids for labels, `run(…, { onError })` reports every error from one place, `viewTransitions` animates renders with the View Transitions API, and `sygnal/element` publishes a component as a custom element. Tests and DevTools get an action log (`t.actions`, "Copy as test"), and `sygnal-check` gets an accessibility lane (SYG701–708). One change breaks old code: a STATE reducer that returns the object it received now means "no change". The core grows by about 0.8 KB gzipped for all of this; every helper is 0 bytes unless imported.
|
|
10
|
+
|
|
11
|
+
6.0 runs every component on a new, smaller core (PLAN-4.6). One store per app runs actions to completion, in order; a state change is applied synchronously and renders the page once, in one DOM patch, a microtask later, with no startup timers. Mounting and Collection operations are about twice as fast, a Collection item costs 1 stream instead of 22, and unmounting 1,000 components arms no timer. The canonical forms work unchanged. A few alternative and undocumented forms are removed (`'ACTION | SINK'` keys, positional view arguments, `CHILD.select('Name')`, `.components` and string tags, `.peers`, `hmrActions`, custom source names, `storeCalculatedInState`, the `component()` factory); [Migrating to 6.0](https://sygnal.js.org/guide/migrating-to-6/) shows the rewrite of each, and the dev checks and `sygnal-check` find them (SYG612).
|
|
12
|
+
|
|
13
|
+
6.0 also brings ready-made parts for what apps usually take from other libraries. `defineWidget()` turns a third-party widget (a date picker, a chart, an editor) into a JSX tag, `fromZag()` and `fromReact()` adapt Zag.js machines and React components, and web components work in both directions. The `form` behavior validates with any Standard Schema; `sortable` reorders lists by pointer and keyboard; `<VirtualCollection>` renders only the rows in view; browser sources watch intersection, size, media queries, storage and more from state; `lazy()` can wait until a placeholder is visible or the browser is idle; and `sygnal/ui` adds headless Dialog, Popover, Tooltip, Tabs, Accordion, Disclosure and Toaster on native HTML, plus Menu, Select and Combobox on Zag. Hydration now adopts the server's markup instead of replacing it, and `<Collection>` renders its items without a wrapper element (a breaking change). Each part is 0 bytes unless imported; the core grows by about 1.3 KB gzipped, mostly for hydration, fragments and the patch-error guard.
|
|
14
|
+
|
|
15
|
+
Covers `sygnal`, `sygnal-check` and `create-sygnal-app`. A few fixes change behavior that an app or test could have relied on, and the TypeScript declarations are stricter in several places; both are listed under [Breaking changes](#breaking-changes-runtime-behavior) and [Migration](#migration).
|
|
16
|
+
|
|
17
|
+
**Measured impact** (agent evals; Opus 5.5 unless noted; [REPORT-v2](evals/agent-ergonomics/results/REPORT-v2.md), [REPORT-v3](evals/agent-ergonomics/results/REPORT-v3.md), [REPORT-v4](evals/agent-ergonomics/results/REPORT-v4.md), [REPORT-v5](evals/agent-ergonomics/results/REPORT-v5.md)). On tiers 1–3 (15 tasks shared with React), Sygnal agents finish in 40.5 s on average against 49.6 s on 5.4.0, which cuts the gap to React from 1.50× to 1.22×; the TypeScript tier went from 1.40× to 1.30×. Every Opus trial passes in both versions.
|
|
18
|
+
- A one-request HTTP task is now faster than React. The gaps on debounced search and form-plus-lookup fell by 70% and 50% during PLAN-3.
|
|
19
|
+
- The new network tier passes 20/20 on Opus. WebSocket chat with `makeSocketDriver` and `connections` takes 63 s, against 88 s with a hand-written driver before PLAN-3 (React: 42 s), and the router task is within 1.14× of React.
|
|
20
|
+
- List/detail caching went from 2.25× React to 1.37× once the cache path was fixed (`updates`, guides shipped in the package, a recipe): agents now use `queryCache()` in 5/5 trials, up from 0/5. The quote-resource task went from 2.0× to 1.4×.
|
|
21
|
+
- Haiku 4.5 now passes the WebSocket task 10/10 (was 1/5), and its pass rate on the 15 tasks shared with React is 75% against React's 65% (not significant at n = 5).
|
|
22
|
+
- `resources` ships as an advanced form: a resources-first skill was not faster (+3%).
|
|
23
|
+
- On four everyday tasks (autosave draft, undo editor, stopwatch, accessible signup), the 6.0 APIs (`persist`, behaviors, timers, element commands, `uid`) and the guides shipped in the package bring Sygnal agents from 76.8 s to 59.1 s per trial and from 19/20 to 20/20 passing; the gap to React fell from 1.68× to 1.45×, and failed test runs per trial from 0.65 to 0.10. In an A/B, `controls()` made Haiku pass less often (92% → 73%), so they ship as an alternative form.
|
|
24
|
+
- On five tasks for the new ecosystem parts (a checkout form with a field array, a command menu, a Chart.js widget, a 10,000-row list, a keyboard-sortable list), Sygnal and React pass about equally: Opus 23/25 vs 25/25, Sonnet 5.5 24/25 vs 23/25, Haiku 4.5 5/25 vs 9/25, none significant. Sygnal takes 1.23–1.43× React's time on them.
|
|
25
|
+
- The new parts didn't slow the older tasks down: learn time and peak context stayed at PLAN-4's level, and the four everyday tasks got faster again (59.1 → 55.5 s). On tasks that change existing apps, Sonnet passes 90/90 in both frameworks, and Sygnal's time ratio shrinks as the app grows (1.23× at about 160 lines, 1.07× at about 650).
|
|
26
|
+
|
|
27
|
+
**Companion packages.**
|
|
28
|
+
- **`sygnal-check` 0.2.0:** the static rules for 6.0 (reply actions, controls, behaviors, `persist`, timers, element commands, widgets, forms, `sortable`, the UI parts), the accessibility lane (SYG701–708, SYG722, SYG724), SYG612 for the removed forms, and explanations for every new code; details under Added and Changed. Its caret range doesn't reach 0.2.0 from `^0.1.0`, so update the dev dependency.
|
|
29
|
+
- **`create-sygnal-app` 2.0.0:** the templates generate Sygnal 6 apps (`sygnal` ^6.0.0, `sygnal-check` ^0.2.0), wrap their Collections in their own element, show the new logo, and their `AGENTS.md` covers `makeFetchDriver()` reply actions, `ABORT` and SYG222, and reading the whole `npm test` output. The package has a `README.md`.
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- **Reply actions** ([HTTP guide](https://sygnal.js.org/guide/http/)). A request to `makeFetchDriver`, `driverFromAsync` or `makeSocketDriver` names its continuation actions, and the reply arrives as that action on exactly the component instance that sent it, with no intent wiring:
|
|
34
|
+
```jsx
|
|
35
|
+
LOAD: { STATE: (state) => ({ ...state, status: 'loading' }),
|
|
36
|
+
HTTP: (state, id) => ({ url: `/api/quotes/${id}`, ok: 'LOADED', error: 'FAILED', latest: true }) },
|
|
37
|
+
LOADED: (state, quote) => ({ ...state, status: 'done', quote }), // the parsed body
|
|
38
|
+
FAILED: (state, { status }) => ({ ...state, status: 'error' }), // { error, status, body, request }
|
|
39
|
+
```
|
|
40
|
+
- `latest: true` cancels the instance's earlier requests with the same `ok` action (or the same `key`); `{ abort: 'LOADED' }` / `{ abort: true, key }` cancel them; a disposed instance's requests are aborted at once;
|
|
41
|
+
- a request with only `ok` still sends failures to `errors()` (and one with only `error` sends successes to `select()`); requests without `ok`/`error` work as before (`category` + `select()`/`errors()`);
|
|
42
|
+
- `driverFromAsync` requests take the same reply actions (`ok` gets the resolved value, `error` gets `{ error, request }`).
|
|
43
|
+
- **`makeFetchDriver()`**, an HTTP driver over `fetch`. `run(App, { HTTP: makeFetchDriver() })`:
|
|
44
|
+
- a request is `{ url, ok, error, key, method, query, json, body, headers, latest, timeoutMs, parse, init, category }` or a URL string; POST when `json`/`body` is set, else GET. Other `fetch()` options go under `init` (`init: { credentials: 'include' }`); any other key is app data that isn't sent and comes back on `request`;
|
|
45
|
+
- replies without reply actions: `HTTP.select(category)` emits `{ category, value, status, request }` for 2xx responses; `HTTP.errors(category)` emits `{ error, category, request, status, body }` for non-2xx, network errors, parse errors and timeouts;
|
|
46
|
+
- plain requests (no reply actions) are isolated per component instance like `@cycle/http`: a component's `select()` sees the replies to its own and its descendants' requests; the root sees everything. Header names are sent lowercased (`Headers` semantics);
|
|
47
|
+
- driver options `baseUrl`, `headers`, `init`, `latest`, `timeoutMs`, `parse` and `fetch`; disposing the app aborts everything in flight; no requests during SSR;
|
|
48
|
+
- 0 bytes when unused (about 2.7 KB gzipped standalone, with the reply-actions helper). New types `FetchRequest`, `FetchResponse`, `FetchError`, `FetchFailure`, `FetchSource`, `FetchDriverOptions`, `FetchInit`, `ReplyRequest` and `AsyncRequest`.
|
|
49
|
+
- **`makeSocketDriver()` and the `connections` static** (WebSocket and server-sent events; [sockets guide](https://sygnal.js.org/guide/sockets/)). A component declares its connections as a function of state; Sygnal sends the set to the driver whenever it changes, and the driver opens, closes and reconnects:
|
|
50
|
+
```jsx
|
|
51
|
+
Chat.connections = (state) => ({ room: state.room && {
|
|
52
|
+
socket: `/ws/rooms/${state.room}`, message: 'RECEIVED', open: 'CONNECTED', close: 'DROPPED' } })
|
|
53
|
+
Chat.model = { SEND: { WS: (state) => ({ to: 'room', json: { text: state.draft } }) }, /* RECEIVED, CONNECTED, DROPPED */ }
|
|
54
|
+
// run(Chat, { WS: makeSocketDriver() })
|
|
55
|
+
```
|
|
56
|
+
- connections are compared per instance and name: a new name opens, a removed or falsy one closes, a changed URL reconnects; dispose closes them;
|
|
57
|
+
- reply actions: `message` (JSON-parsed when it parses), `open` (`{ reconnected }`), `close` (`{ code, reason, willReconnect }`, only for closes the app didn't make), `error`; other events on `WS.select(name)`;
|
|
58
|
+
- reconnects with jittered backoff by default (`reconnect: { delayMs, maxDelayMs, jitter }`, or `false`; a fixed delay with `jitter: false`); sends are queued while connecting; connections to the same URL share one socket; `sse:` uses `EventSource`, with `events: { name: 'ACTION' }` for named events; nothing opens during SSR;
|
|
59
|
+
- a `{ to }` send in the same action that opens or changes a connection goes to the new connection;
|
|
60
|
+
- 0 bytes when unused (about 2.6 KB gzipped standalone); the `connections` static costs about 100 B in the core. New types `Connections`, `SocketRequest` and the spec types.
|
|
61
|
+
- **`resources` (experimental)**: reads declared from state for `makeFetchDriver()`: `Quote.resources = { quote: (state) => state.id && `/api/quotes/${state.id}` }`. Sygnal keeps `state.quote` as `{ status: 'idle' | 'loading' | 'success' | 'error', data, error }` through the built-in `RESOURCE` action (a model `RESOURCE` entry replaces it); a changed request is fetched with latest semantics, so a stale reply is never shown; a falsy request aborts and goes idle; `{ refresh: 'quote' }` on the HTTP sink refetches; `ok`/`error` on the request also dispatch those actions. Tests answer it with `t.respond('HTTP', body, 'quote')` (`resourceSink` option, default `'HTTP'`). Types `Resource<D, E>` and `ResourceRequest`.
|
|
62
|
+
- **`makeRouter()` / `makeRouterDriver()`**, an SPA router driver ([router guide](https://sygnal.js.org/guide/router/)). `export const router = makeRouter({ routes: { home: '/', task: '/tasks/:id', notFound: '*' } })` gives a typed `href()`, `match()`, `current(url?)` (the first route, also for SSR) and `driver` (`run(App, { ROUTER: router.driver })`). A component declares `App.route = 'ROUTE'` and its reducer stores `{ name, params, query, hash, path }`; guards and redirects stay in the model (the first declarer owns redirects; the other declarers get each route right after its `ROUTE` entry ran, in the same render, so a navigation patches the page once and every declarer has its first route when the app first appears). Links are plain `<a href={href('task', { id })}>`: the driver intercepts same-origin clicks at the document level (modifier keys, `target`, `download`, `rel="external"`, SVG, shadow DOM, `<base>` and `data-router-ignore` respected). Commands `{ to, params, query, hash, replace }`, `{ url }`, `{ back }`, `{ forward }`, `{ go }`, `{ block: 'ACTION' }` (unsaved-changes guard, with `proceed`) and `{ prefetch }`; scroll restoration and focus after navigation; hash mode; no-op during SSR; Vike mode (`makeRouter({ routes, navigate })` with Vike's `navigate`). A redirect loop doesn't hang the page: after 32 navigations in a row with no pause, each next one waits a task (a development warning, once; [Guards and Redirects](https://sygnal.js.org/guide/router/#guards-and-redirects)). About 2.8 KB gzipped in an app that uses it.
|
|
63
|
+
- **`makeHeadDriver()`**: `App.head = (state) => ({ title, meta, link })` (or `HEAD` sink values) sets the document title, meta and link tags, merged in mount order and removed on dispose, with `titleTemplate`. SSR: `renderToString(App, { head: list })` and `renderHead(list)`; Vike's `onRenderHtml` collects `head` statics. About 0.8 KB gzipped.
|
|
64
|
+
- **Resources reload and the query cache** ([resources guide](https://sygnal.js.org/guide/resources/)):
|
|
65
|
+
- a refetch of the same request (`{ refresh }`, `{ invalidate }`, focus, polling, a hidden page shown again) keeps `data` and `error` with `refreshing: true`; a new request clears `data` unless `keepPrevious: true`; a failed refetch keeps `data`;
|
|
66
|
+
- `queryCache({ staleTime, gcTime, refetchOnFocus, refetchOnReconnect, initial })`, passed as `makeFetchDriver({ cache: queryCache() })`: an opt-in cache (a separate export, so apps that only make requests don't ship it) with stale-while-revalidate, de-duplication of identical requests across components, and refetch of stale resources on focus and reconnect; `cache` / `staleTime` on a request opt one in (SYG635 without a `queryCache`);
|
|
67
|
+
- SSR cache seeding: `cache.set(request, data)`, `cache.dehydrate()` / `hydrate()` / `queryCache({ initial })`, and `renderToString(App, { cache })`, which renders cached resources as `'success'`; with Vike, set `pageContext.queryCache` in `+data` and the extension renders from it and hydrates the client's fetch-driver cache (no loading flash, no refetch while fresh);
|
|
68
|
+
- `{ prefetch: request }` on the HTTP sink and `cache.prefetch(request)` warm the cache without a reply; with the router's `prefetch` option a route's data is prefetched on hover;
|
|
69
|
+
- `refetchEvery: ms` on a resource polls, paused while the page is hidden;
|
|
70
|
+
- `{ invalidate: tag | tags | '/prefix' | fn }` from any component, and `invalidates: [...]` on a write (applied on success); explicit `tags` on requests and resources;
|
|
71
|
+
- `updates` on a request (React Query's `setQueryData`): after a 2xx, the reply becomes the data of the sending component's named resources and their `queryCache()` entries at once (`updates: 'item'`, `['a', 'b']`, or `{ items: (list, reply) => newList }`), before the `ok` action and `invalidates`; their reads in flight are aborted, and other resources showing the same entry update too;
|
|
72
|
+
- an invalidation aborts mounted resources' older reads and stops a shared cache fetch already in flight from writing the cache, so a reply from before a write never overwrites newer data;
|
|
73
|
+
- the Resources and HTTP guides ship in the package (`node_modules/sygnal/dist/guide/{resources,http}.md`, copied at build) so agents can read them offline; SKILL.md and `llms.txt` link there first, and teach a list/detail + save recipe (`staleTime` on the request, `queryCache()` in `main.js` and in every test, `updates` + `invalidates` on the save, "Updating…" from `refreshing`);
|
|
74
|
+
- the PLAN-4 guides ship in the package too: `node_modules/sygnal/dist/guide/{persistence,timers,element-commands,behaviors,accessibility,undo}.md` (copied at build; `scripts/copy-guides.mjs` accepts a docs section prefix such as `advanced/undo`), and SKILL.md and `llms.txt` link them locally instead of by site URL.
|
|
75
|
+
- the guides for the ecosystem parts ship too: `dist/guide/{widgets,web-components,adapters,forms,forms-reference,inputs,browser-sources,virtual-collections,drag-and-drop,ssr,error-boundaries}.md`, the UI parts in `dist/guide/ui/` and the nine recipes in `dist/guide/recipes/` (about 86 KB more in the package).
|
|
76
|
+
- `retry: n | { count, delayMs, maxDelayMs, jitter }` (default 0; the driver option applies to GET/HEAD): network errors, 408, 429 (`Retry-After` in seconds) and 5xx, with `attempts` on the failure;
|
|
77
|
+
- `validate: schema` (any Standard Schema) on requests and resources; a failure carries `issues`;
|
|
78
|
+
- tests: `renderComponent(C, { http })`, `t.cache()`, `t.focus()`, `t.online()`; `inspect()` lists resources per instance and cache entries.
|
|
79
|
+
- **Switchable `instance`**: `<Switchable of={pages} current={name} instance={key} />`. When `instance` changes, the current page is disposed and created again with fresh state (a page shown again after its key changed while hidden is re-created on show); `switchable()` accepts `[name, instance]` pairs. The router recipe uses `instance={state.route.path}`.
|
|
80
|
+
- **Hidden Switchable pages pause their declarations**: while a page is hidden, it and everything inside it declare only the `connections` / `resources` entries marked `background: true`; the others close or abort, and are declared again when the page is shown. A `route` declaration stays live.
|
|
81
|
+
- **Async EFFECTs** ([EFFECT](https://sygnal.js.org/advanced/effect/)). `EFFECT: async (state, data, next, { signal }) => { … next('DONE', value) }` for async work that isn't HTTP (IndexedDB, clipboard, workers): a returned promise is expected, a rejection is reported as SYG214, `next()` after the component is disposed does nothing, and `signal` is an `AbortSignal` aborted on DISPOSE (EFFECT only).
|
|
82
|
+
- **`controls()`** links views and intents by identifier ([guide](https://sygnal.js.org/guide/controls/), [API](https://sygnal.js.org/reference/api/#controls)), an [alternative form](https://sygnal.js.org/advanced/alternative-forms/#controls-instead-of-class-selectors) to class selectors. `const { Draft, Add } = controls({ Draft: 'input', Add: 'button' })` returns element tokens: the view renders `<Add>Add</Add>` (a `<button data-control="Add">`, every prop passed through), and anything that takes a selector takes the control instead: `DOM.click(Add)`, `DOM.input(Draft).value()`, `DOM.select('document').select(Add)`, element commands, behavior options, and in tests `t.simulateEvent(Add, 'click', { within })`, `t.query(Draft)` and `t.queryAll`. A control resolves to `[data-control="Add"]` and also works inside template-string selectors.
|
|
83
|
+
- class selectors stay canonical: the docs, examples, templates and agent docs use them, and no strict rule flags them. In the A/B eval controls didn't pay for themselves (a smaller model read a control's name as the button's label), so they are opt-in;
|
|
84
|
+
- a control is an element, not a component: no state, no isolation scope, no wrapper. Isolation is unchanged, so a control in a Collection item matches only that item's element;
|
|
85
|
+
- typed from its element: `Draft` takes `<input>` props and `t.query(Draft)` is an `HTMLInputElement`;
|
|
86
|
+
- diagnostics: SYG124 (a component passed where a control or selector is expected: `DOM.click(Child)` is not supported, use `CHILD.select` + `PARENT`), SYG125 (a control given `.intent`, `.model` or `.initialState`), SYG126 (rendered but never listened to, info), SYG128 (a duplicate key); SYG104 and SYG110 match controls by identifier;
|
|
87
|
+
- a spec object `{ kind, vnode(props, children, h), commands }` in place of a tag is the extension point for third-party widgets.
|
|
88
|
+
- **Behaviors** ([guide](https://sygnal.js.org/guide/behaviors/)). `defineBehavior({ initialState, intent, model, calculated })` packages state, intent and model without a view. A component lists the behaviors it uses under state keys:
|
|
89
|
+
```jsx
|
|
90
|
+
TaskList.uses = { pager: pager({ pageSize: 10, next: '.newer', prev: '.older' }) } // state.pager; actions 'pager.NEXT'
|
|
91
|
+
```
|
|
92
|
+
- the behavior's reducers and calculated fields work on its slice (`state.pager`); its actions are named after the key (`pager.NEXT`); its intent gets the host's sources plus the options;
|
|
93
|
+
- a host model entry for `'pager.NEXT'` runs after the behavior's; a host intent action of the same name replaces the behavior's trigger;
|
|
94
|
+
- first-party behaviors `pager()`, `selection()` (single or multi, select-all; `isSelected()`) and `undo()`, and `undoable(model, { key, limit, track, coalesceMs, resetOn })`, which wraps a model's STATE reducers with undo/redo history ([undo](https://sygnal.js.org/advanced/undo/));
|
|
95
|
+
- `undo()` / `undoable()` take an array `key` (`key: ['todo', 'done']`): those state keys are recorded together, one `{ todo, done }` value per step (a sortable's lists);
|
|
96
|
+
- `undo()` / `undoable()` take `coalesce: ['TYPE']`: only the listed actions' quick changes join one step (within `coalesceMs`, 500 by default with `coalesce`), and every other action is always its own step, so two quick clicks on a button stay two steps while typing is one. Without `coalesce`, `coalesceMs` joins any action's repeats, as before ([undo](https://sygnal.js.org/advanced/undo/#grouping-only-some-actions)).
|
|
97
|
+
- SYG127 (a `uses` key already in `initialState`, or a value that isn't a behavior) and SYG226 (`track` / `resetOn` / `coalesce` naming an unknown action); types `UsesState` and `UsesActions`;
|
|
98
|
+
- about 30 B in the core; 0 bytes unless imported (in an app: `pager` about 0.95 KB gzipped, `selection` 1.2 KB, `undo` 1.6 KB).
|
|
99
|
+
- **Element commands** ([guide](https://sygnal.js.org/guide/element-commands/)). The built-in `ELEMENT` sink calls a method of an element the component rendered, with no driver to register:
|
|
100
|
+
```jsx
|
|
101
|
+
SUBMIT: { STATE: (s) => ({ ...s, errors: validate(s) }), ELEMENT: (s) => (validate(s).email ? { focus: '.email' } : ABORT) },
|
|
102
|
+
OPEN_HELP: { ELEMENT: { showModal: '.help' } },
|
|
103
|
+
```
|
|
104
|
+
- the first key is the method (`focus`, `blur`, `select`, `click`, `scrollIntoView`, `showModal`, `show`, `close`, `showPopover`, `hidePopover`, `togglePopover`), the other keys its options (`close` gets `returnValue`); an array runs in order. Any other method the element has also runs (declare it in `ElementCommandRegistry` for TypeScript);
|
|
105
|
+
- the target, a selector (or a control), is looked up in the sending instance's own view, and the command runs after the next patch at which it exists, so it reaches elements the same action renders. A control's spec `commands` are asked first;
|
|
106
|
+
- SYG640 (target not found after about 1 s, warn), SYG641 (unknown or DOM-mutating method); nothing runs during SSR;
|
|
107
|
+
- tests: `t.commands('ELEMENT')` lists what was sent; with `dom: 'real'` the commands also run (jsdom gets fakes for `<dialog>`, popovers and `scrollIntoView`);
|
|
108
|
+
- about 220 B in the core.
|
|
109
|
+
- **`persist()`** ([guide](https://sygnal.js.org/guide/persistence/)). `TodoApp.persist = persist({ key: 'todo-app', pick: ['todos', 'filter'], version: 2, migrate })` saves the root component's state in `localStorage` and restores it at startup:
|
|
110
|
+
- restored synchronously before `INITIALIZE`, merged into `initialState`. Over server-rendered markup (a `run()` mount point with children, a hydrated Astro island or Vike page) it restores in the built-in `RESTORE` action after the first render instead, so hydration matches; `hydrate: true | false` overrides the detection;
|
|
111
|
+
- stored as `{ version, state }` JSON: the `pick` keys, or all but `omit`, never calculated fields; another version goes through `migrate`;
|
|
112
|
+
- `format: 'plain'` stores the picked keys themselves (`{"title":"…","body":"…"}`) instead of `{ version, state }`, for an entry another program reads or writes; `version` / `migrate` don't apply (a TypeScript error). `t.storage<Entry>(key)` reads it typed ([a plain format](https://sygnal.js.org/guide/persistence/#a-plain-format)).
|
|
113
|
+
- written after `debounceMs` (100) without a change, on `pagehide` and on dispose; `sync: true` applies other tabs' writes; `storage: 'local' | 'session'` or a synchronous `{ getItem, setItem, removeItem }` adapter;
|
|
114
|
+
- `PERSIST: { clear: true }` in a model entry removes the stored copy;
|
|
115
|
+
- root component only (SYG224; Vike pages are not supported in 6.0), SYG223 (a `pick` / `omit` key not in `initialState`), SYG642 (a failed read, migrate or write; the app continues on `initialState`). Nothing is read or written during SSR;
|
|
116
|
+
- tests: `renderComponent(App, { storage })` seeds the fake storage and `t.storage(key)` reads it;
|
|
117
|
+
- about 20 B in the core, about 0.9 KB gzipped in an app that uses it.
|
|
118
|
+
- **`STATE.watch(selector, { immediate })`** ([intent](https://sygnal.js.org/guide/intent/#reacting-to-state-changes-statewatch)): a stream of `selector(state)` that emits only when the selected value changes (compared structurally), for "when X changes, do Y" (`SAVE: STATE.watch(s => s.text).compose(debounce(1000))`). In a Collection item, `state` is the item's; the stream ends on dispose.
|
|
119
|
+
- **Timers** ([guide](https://sygnal.js.org/guide/timers/)). A component declares its timers from state, and `makeTimerDriver()` runs them:
|
|
120
|
+
```jsx
|
|
121
|
+
Stopwatch.timers = (state) => ({ tick: state.running && { every: 100, action: 'TICK' } })
|
|
122
|
+
// run(Stopwatch, { TIMER: makeTimerDriver() })
|
|
123
|
+
```
|
|
124
|
+
- `{ every: ms, action }` repeats without drift (data `{ n, t }`), `{ after: ms, action }` fires once (`{ t }`), `{ frame: 'ACTION' }` runs every animation frame (`{ t, dt }`);
|
|
125
|
+
- compared by name whenever the state changes: a new name starts, a falsy or missing one stops, a changed spec restarts. The action reaches the declaring instance with every sink of its model entry;
|
|
126
|
+
- a hidden Switchable page's timers stop (and start from scratch when shown) unless `background: true`; dispose stops them; nothing runs during SSR;
|
|
127
|
+
- SYG422 (an invalid spec, not started) and SYG643 (dev: `timers`, `connections` or `resources` declared with no driver to take them);
|
|
128
|
+
- tests: `renderComponent()` runs the real driver on the test's clock (fake timers included); `t.timers()` lists the running ones;
|
|
129
|
+
- 0 bytes in the core; about 0.6 KB gzipped in an app that uses it.
|
|
130
|
+
- **View Transitions** ([guide](https://sygnal.js.org/guide/view-transitions/)). `Board.viewTransitions = ['MOVE']` with `run(App, { DOM: makeViewTransitionDOMDriver('#root') })` applies the render that a listed action's state change causes inside `document.startViewTransition()`. The renders of one action are folded into one transition; `App.viewTransitions = ['ROUTE']` animates route changes. The first render, `prefers-reduced-motion: reduce` and browsers without the API apply at once; `renderComponent()` never animates. SYG645 (dev) when the static is set without the driver. About 30 B in the core; the driver is about 350 B gzipped in an app that uses it.
|
|
131
|
+
- **`uid()`** ([forms](https://sygnal.js.org/guide/inputs/#labels-and-ids-uid)), a view prop (and `props.uid` in reducers) for `id` / `for` / `aria-*` pairs: `<label for={uid('email')}>` + `<input id={uid('email')}>`.
|
|
132
|
+
- ids come from the instance's position in the tree and its Collection item key, never a counter: unique per instance, stable across renders and reorders, and equal between `renderToString` and hydration ([SSR](https://sygnal.js.org/integration/ssr/#stable-ids-uid));
|
|
133
|
+
- the root is `u`; `run(App, drivers, { uid })`, `renderToString(App, { uid })` and an Astro island's `uid` prop give each app on a page its own; Vike Pages, Layouts and Wrappers get matching ids on both sides automatically;
|
|
134
|
+
- ids are opaque strings: path parts are encoded so that different keys never collide (`'0.2'` becomes `0_46_2`); don't parse them;
|
|
135
|
+
- `sygnal-check` matches `uid('x')` references for SYG702 and SYG708.
|
|
136
|
+
- **App-level error hook** ([error boundaries](https://sygnal.js.org/advanced/error-boundaries/#app-level-error-hook)). `run(App, drivers, { onError: (error, { componentName, action, phase, driver }) => … })` reports every error of the app to one place, such as an error tracker:
|
|
137
|
+
- reporting only: called once per error, after the component's own `onError` picked its fallback, in every diagnostics mode (production included). If the hook throws, the error is logged once and swallowed;
|
|
138
|
+
- `phase` is `'view'`, `'reducer'`, `'effect'`, `'intent'` (an intent stream errored; it stops emitting), `'context'` (a `.context` entry threw; it keeps its last value), `'declaration'` (a static a driver reads, such as `connections`, threw), `'instantiate'`, `'driver'` (a driver threw synchronously while taking a sink value), `'dispose'` (a stream's `stop()` threw during teardown), `'widget'` (a `defineWidget` widget's `mount`, `update` or `unmount` threw) or `'patch'` (the DOM patch threw; see Fixed);
|
|
139
|
+
- each `run()` has its own hook. Also `renderToString(App, { onError })` (phase `'view'`), `renderComponent(C, { onError })`, the Vike config `sygnalOnError` (`pages/+sygnalOnError.js`; Vike's own `onError` is a different, server-only hook), and the Astro integration option `sygnal({ onError: './src/onError.js' })`.
|
|
140
|
+
- **`sygnal/element`** ([API](https://sygnal.js.org/reference/api/#sygnalelement)): `defineElement(tag, Component, { props, events, shadow, styles })` publishes a component as a custom element. Attributes and properties become props, sinks become DOM events (`events: { PARENT: 'task-picked' }`), an optional shadow root takes `styles`, disconnecting disposes, and `sygnal/vite` hot-swaps it in dev. It works in a plain HTML page and inside other frameworks (checked with React 19). A separate entry: 0 bytes in the core, about 1.6 KB gzipped. SYG644 (dev) for a prop that hides an `HTMLElement` member.
|
|
141
|
+
- **Action log in tests** ([testing](https://sygnal.js.org/integration/testing/#action-log-tactions-and-texplain)):
|
|
142
|
+
- `t.actions` lists every action the rendered tree ran, live: `{ type, data, component, instance, sinks, cause, at }`, with `cause` one of `'intent'`, `'next'`, `'reply'`, `'built-in'`, `'simulateAction'` or `'behavior'`, and `sinks` the sinks that produced a value;
|
|
143
|
+
- `t.explain(predicate)` returns the first action whose resulting state matches, with that state and its STATE reducer;
|
|
144
|
+
- `t.inspect({ actions: true })` and the dev entry's `inspect({ actions })` add `recentActions` in the same shape;
|
|
145
|
+
- also new on `renderComponent()`: `t.commands()`, `t.timers()`, `t.storage(key)` and the `storage`, `timerSink` and `onError` options (above). 0 bytes in production.
|
|
146
|
+
- **DevTools: action log, "Copy as test", Redux DevTools** ([debugging](https://sygnal.js.org/integration/debugging/#the-action-log)):
|
|
147
|
+
- the panel's Actions tab lists every action of every instance with its cause, sinks and data, filtered by component or name; selecting one shows its state change as a diff;
|
|
148
|
+
- [Copy as test](https://sygnal.js.org/integration/debugging/#copy-as-test) turns a session into a `renderComponent` test: the starting state, the replayed actions, `t.respond` / `t.fail` for `makeFetchDriver()` replies, and a final-state assertion when the replay is complete. `configureCopyAsTest()` on the bridge sets imports and drivers;
|
|
149
|
+
- `sygnal/devtools` exports `getActions`, `onAction`, `clearActions`, `copyAsTest`, `copyAsTestResult`, `getSession`, `recordActions`, `isRecording` and `connectReduxDevtools`;
|
|
150
|
+
- [Redux DevTools](https://sygnal.js.org/integration/debugging/#redux-devtools): `sygnal({ devtools: { redux: true } })` in the dev server (or `connectReduxDevtools(app)`) sends the actions and the root's state to the Redux DevTools extension, with time travel;
|
|
151
|
+
- dev-only: the `sygnal/devtools` entry grows to about 17 KB gzipped, and production builds still contain none of it.
|
|
152
|
+
- **Accessibility checks** ([guide](https://sygnal.js.org/guide/accessibility/)), a new SYG7xx lane in `sygnal-check` and the Vite plugin's dev checker. Static only; warnings, also under `--strict`; `--a11y=error` makes them errors; `// sygnal-ignore SYG70x` silences one:
|
|
153
|
+
- [SYG701](https://sygnal.js.org/reference/errors#syg701): a click listener on a non-interactive element (`div`, `span`, `li`, …) without `role` and `tabIndex`;
|
|
154
|
+
- [SYG702](https://sygnal.js.org/reference/errors#syg702): a form field without an accessible label;
|
|
155
|
+
- [SYG703](https://sygnal.js.org/reference/errors#syg703): an `<img>` without `alt`;
|
|
156
|
+
- [SYG704](https://sygnal.js.org/reference/errors#syg704): a click listener on an `<a>` without `href`;
|
|
157
|
+
- [SYG705](https://sygnal.js.org/reference/errors#syg705): a `<button>` with no accessible name;
|
|
158
|
+
- [SYG706](https://sygnal.js.org/reference/errors#syg706): a positive `tabIndex`;
|
|
159
|
+
- [SYG707](https://sygnal.js.org/reference/errors#syg707): an `aria-*` attribute or `role` that doesn't exist;
|
|
160
|
+
- [SYG708](https://sygnal.js.org/reference/errors#syg708): `<label for>` or `aria-describedby` / `aria-labelledby` naming an id that isn't rendered.
|
|
161
|
+
- **Test fakes for drivers** ([testing](https://sygnal.js.org/integration/testing/)). In `renderComponent()`, a sink with no driver, in the component or any child, is recorded, and its source is a fake that behaves like the real driver:
|
|
162
|
+
- HTTP: reply actions are answered to the sending instance; `latest`, `abort` and isolation follow `makeFetchDriver`;
|
|
163
|
+
- `await t.respond(name, value, target?)` answers, and `await t.fail(name, 404 | error, target?)` fails, the newest pending request that matches `target`: an `ok`/`error` action name, key or category, a partial request compared by value (`{ url: '/items/2' }`), a predicate, or `{ request, category, status, body, nth }` (`nth` picks one request of `t.requests(name)` by position, `0` the first, `-1` the newest, counting only the matches of `request`/`category`, so a test can answer the older of two identical requests). They **throw at the call** when nothing matching is pending (unless simulated input is still queued, the call comes right after a `simulate*` call on a sink that carries `resources`, whose requests leave two microtasks later, or the component isn't ready yet), and return a promise that resolves after the reply has been reduced and rendered;
|
|
164
|
+
- the HTTP fake runs the real `makeFetchDriver` over an in-memory `fetch`, so `latest`, `abort`, `timeoutMs` (on fake timers), isolation, reply actions and `resources` behave exactly as in the app; `t.respond` sends a JSON (or text) response the driver parses, `t.fail(404)` is an HTTP error response, `t.fail(error)` a network failure;
|
|
165
|
+
- `t.requests(name)` lists requests only, as objects (a string request is `{ url }`, a resource fetch `{ url, …, resource: name }`); `t.sinkValues(name)` keeps everything, `{ abort }`, `{ resources }` and `{ refresh }` values included;
|
|
166
|
+
- resources: `t.respond('HTTP', body, 'quote')` by resource name right after a `simulate*` call that changes the request waits for that fetch (it is sent after the state change);
|
|
167
|
+
- router: `renderComponent(App, { router, url })` (the `makeRouter()` object; required when a component declares `route`) runs the real router driver over an in-memory history; `t.navigate(url | { to, params })`, `t.back()`, `t.forward()` (throw when they can't act, resolve after render), `t.location`, `t.sent('ROUTER')`; link clicks go through the driver's interception (mock DOM and `dom: 'real'`); scroll and focus off by default (`routerScroll`, `routerFocus`);
|
|
168
|
+
- head: with no HEAD driver, `t.head()` returns the merged `{ title, meta, link }` (`headSink`, `titleTemplate`);
|
|
169
|
+
- sockets: the fake runs the real `makeSocketDriver` over in-memory sockets. `t.connections(name)`, `t.open(name, target?)`, `t.push(name, data, target?)`, `t.drop(name, { code, reason }?, target?)` and `t.sent(name, to?)`; options `autoConnect` (default `true`; `false` holds connections in 'connecting' until `t.open`) and `socketSink` (default `'WS'`: the fake that receives the `connections` static, created even when no model entry names it).
|
|
170
|
+
- **More `renderComponent()` options and results** ([testing](https://sygnal.js.org/integration/testing/)):
|
|
171
|
+
- `dom: 'real'` mounts into a real container (jsdom or happy-dom), so `checked`, `value`, `disabled`, focus, refs and Portals are real; `simulateEvent` then dispatches real events on any CSS selector, and `t.container`, `t.query(sel)` and `t.queryAll(sel)` read the DOM ([Real DOM](https://sygnal.js.org/integration/testing/#real-dom));
|
|
172
|
+
- `t.query(sel)` and `t.queryAll(sel)` also work on the default mock DOM: they return read-only snapshots of what the view rendered (`textContent`, `value`, `checked`, `disabled`, `getAttribute`, `classList`, `dataset`, `querySelector`, `closest`, …); `focus()` and similar calls point to `dom: 'real'`. Mock-DOM selectors (also for `simulateEvent`) gain `:checked`, `:disabled` and `:enabled`;
|
|
173
|
+
- `timeoutMs` (2000), `settleMs` (20) and `eventWaitMs` (300) tune the waits; a timeout error names a model `next('X', data, ms)` that is still pending and a recorded state that already matched;
|
|
174
|
+
- `t.state`, the latest state (`t.states.at(-1)`), read-only;
|
|
175
|
+
- `t.sinkValues(name)` also records the sinks of children, grandchildren and Collection items, including their DISPOSE output.
|
|
176
|
+
- **Fake timers in tests.** `renderComponent()` works with `vi.useFakeTimers()` (and Jest's modern fake timers): `ready()`, `next()`, `waitForState()` and `settle()` advance the fake clock themselves, so tests of `debounce`, `delay` or `next('X', data, ms)` run in milliseconds ([Fake timers](https://sygnal.js.org/integration/testing/#fake-timers)). Before, every wait hung under fake timers.
|
|
177
|
+
- **`run(App, drivers, { diagnostics: { strict: true } })`** turns on the runtime strict checks (and `'warn'` diagnostics unless a `mode` is given); `dispose()` restores the previous setting.
|
|
178
|
+
- **New diagnostic codes:**
|
|
179
|
+
- [SYG608](https://sygnal.js.org/reference/errors#syg608) (warn): strict mode requested from `run()` without the `sygnal/diagnostics` entry loaded;
|
|
180
|
+
- [SYG609](https://sygnal.js.org/reference/errors#syg609) (warn, dev entry): a component sends to a sink, or reads a source, that has no driver under `run()` (before, the values were dropped silently and the source was `undefined`);
|
|
181
|
+
- [SYG112](https://sygnal.js.org/reference/errors#syg112) (error, dev entry and `sygnal-check`): a request's `ok`/`error` reply action, or a connection's `message`/`open`/`close`/`error`/`events` action, has no model entry (the reply would be dropped); suggests the nearest model key;
|
|
182
|
+
- [SYG115](https://sygnal.js.org/reference/errors#syg115) (warn, dev entry): a `DOM.<name>` shorthand that isn't a DOM event (`DOM.key('.x')`); the fix names `DOM.keydown(sel).key()`;
|
|
183
|
+
- [SYG116](https://sygnal.js.org/reference/errors#syg116) (error, dev entry): an EVENTS value without a string `type` (a function or `undefined`);
|
|
184
|
+
- [SYG221](https://sygnal.js.org/reference/errors#syg221) (error, dev entry): `set()` called with a string (`set('field')`);
|
|
185
|
+
- [SYG421](https://sygnal.js.org/reference/errors#syg421) (error, dev entry): a `data={{ ... }}` key the DOM can't store (`'task-id'`), which made rendering stop with a bare `DOMException {}`;
|
|
186
|
+
- [SYG508](https://sygnal.js.org/reference/errors#syg508) (strict): a component reads `HTTP.select('c')` / `errors('c')` for its own `category: 'c'` requests where reply actions would do;
|
|
187
|
+
- [SYG610](https://sygnal.js.org/reference/errors#syg610) (error): a request with a `then` or `catch` key (it would be a thenable); it is not sent;
|
|
188
|
+
- [SYG611](https://sygnal.js.org/reference/errors#syg611) (error, dev entry): a socket send to a connection that doesn't exist, has died or is SSE, or an invalid connection spec;
|
|
189
|
+
- [SYG620](https://sygnal.js.org/reference/errors#syg620) (error): a router command that wasn't performed;
|
|
190
|
+
- [SYG630](https://sygnal.js.org/reference/errors#syg630)–[SYG635](https://sygnal.js.org/reference/errors#syg635) (dev entry; SYG634 in `sygnal-check`): a cached request that isn't idempotent, `validate` that isn't a Standard Schema, `invalidate` matching nothing (info), `abort` naming a lane its requests don't use, `latest: true` with a computed `key` (info), `cache` / `staleTime` / `prefetch` without a `queryCache()`;
|
|
191
|
+
- [SYG130](https://sygnal.js.org/reference/errors#syg130)–[SYG133](https://sygnal.js.org/reference/errors#syg133) (dev entry): `href()` / `{ to }` with an unknown route or a missing param, an extra param, a declaration static a root without `initialState` never sends, and the SPA router inside a Vike app;
|
|
192
|
+
- [SYG124](https://sygnal.js.org/reference/errors#syg124)–[SYG126](https://sygnal.js.org/reference/errors#syg126) and [SYG128](https://sygnal.js.org/reference/errors#syg128) (controls: a component used as a control or selector, a control given `.intent`/`.model`/`.initialState`, a control never listened to (info), a duplicate control key);
|
|
193
|
+
- [SYG127](https://sygnal.js.org/reference/errors#syg127) (error): a behavior collision or a `uses` value that isn't a behavior; [SYG226](https://sygnal.js.org/reference/errors#syg226) (warn): `undo` / `undoable` `track` or `resetOn` names an unknown action;
|
|
194
|
+
- [SYG222](https://sygnal.js.org/reference/errors#syg222) (warn, dev entry): a STATE reducer changed the state in place and returned it, so the change is ignored;
|
|
195
|
+
- [SYG223](https://sygnal.js.org/reference/errors#syg223) (warn), [SYG224](https://sygnal.js.org/reference/errors#syg224) (error) and [SYG642](https://sygnal.js.org/reference/errors#syg642) (warn): `persist` names a key not in `initialState`, is on a component that isn't the root, or failed to read, migrate or write;
|
|
196
|
+
- [SYG422](https://sygnal.js.org/reference/errors#syg422) (error): an invalid timer spec; [SYG643](https://sygnal.js.org/reference/errors#syg643) (warn, dev entry and `sygnal-check`): `timers`, `connections` or `resources` declared with no driver to take them;
|
|
197
|
+
- [SYG640](https://sygnal.js.org/reference/errors#syg640) (warn) and [SYG641](https://sygnal.js.org/reference/errors#syg641) (error): an element command's target not found, an unknown element command;
|
|
198
|
+
- [SYG644](https://sygnal.js.org/reference/errors#syg644) (warn, dev): a `defineElement` prop that hides an `HTMLElement` member; [SYG645](https://sygnal.js.org/reference/errors#syg645) (warn, dev entry): `viewTransitions` without the View Transition DOM driver;
|
|
199
|
+
- [SYG701](https://sygnal.js.org/reference/errors#syg701)–[SYG708](https://sygnal.js.org/reference/errors#syg708): the accessibility lane above.
|
|
200
|
+
- **`sygnal/vite` `nativeGlobalThis`** (default `true`). xstream loads the `globalthis` npm polyfill and its dependency chain; the plugin now aliases it to a stub that returns the native `globalThis` in dev, build and Vitest, which makes a typical app about 4 KB gzipped smaller (kanban example: 42.1 → 38.1 KB). The Astro integration adds it to `astro build` too. A `globalthis` alias of your own wins; `nativeGlobalThis: false` keeps the polyfill ([details](https://sygnal.js.org/integration/bundler-config/#native-globalthis)). The stub is also exported as `sygnal/shims/globalthis` for other bundlers.
|
|
201
|
+
- **Collection `sort`** accepts `1`/`-1` per field and arrays of field names, sort objects and comparators (new `SortSpec` type).
|
|
202
|
+
- **`class`** accepts strings, arrays and clsx-style mixes: `class={['btn', { active: on }]}`.
|
|
203
|
+
- **Types** ([TypeScript guide](https://sygnal.js.org/integration/typescript/)):
|
|
204
|
+
- a typed sub-component takes `state="slice"`, a lens or no `state` in JSX, plus its own props (`ViewProps`, `ElementProps` and `StateProp` exported);
|
|
205
|
+
- `Component`'s 8th parameter `PROVIDED_CONTEXT` types a component's own `.context` separately from the context it reads;
|
|
206
|
+
- an intent annotated `IntentSources<State>` is accepted on a component with `calculated` fields;
|
|
207
|
+
- constants on non-STATE sinks (`LOG: 'saved'`) type-check (`NonStateSinkValue`);
|
|
208
|
+
- `renderComponent()` infers the state type from the component, and `RenderResult<State>` types `t.state`, `t.states`, `t.next(s => …)` and `t.waitForState`, so typed tests need no `any`;
|
|
209
|
+
- `Component.connections`, reply-action request fields, and `signal` on the EFFECT props;
|
|
210
|
+
- `run()`'s `mountPoint` option accepts an `Element` as well as a selector (the runtime already did); `fragments` is marked deprecated (it has no effect);
|
|
211
|
+
- the 6.0 additions: `Control` / `ControlSpec`, `ElementCommand` and the `ElementCommandRegistry` interface, `UsesState` / `UsesActions`, `Persist`, `TimerSpec` / `Timers`, `UidFunction` (`uid` on view and reducer props), `StateSource.watch`, `TestAction` / `ExplainedAction` / `ActiveTimer` for `t.actions`, `t.explain` and `t.timers`.
|
|
212
|
+
- **Docs:** new pages for [HTTP](https://sygnal.js.org/guide/http/), [sockets](https://sygnal.js.org/guide/sockets/), [custom drivers](https://sygnal.js.org/guide/custom-drivers/) and [server functions](https://sygnal.js.org/integration/server-functions/) (Telefunc through a `driverFromAsync` with reply actions, with security rules for exposing server functions); sections on sinks seeing the state from before the action, extracting a component without changing its markup, latest-only responses, HTTP, fake timers, the real DOM mode, and TypeScript sub-components and context. For 6.0's ergonomics: new pages for [behaviors](https://sygnal.js.org/guide/behaviors/), [element commands](https://sygnal.js.org/guide/element-commands/), [persistence](https://sygnal.js.org/guide/persistence/), [timers](https://sygnal.js.org/guide/timers/), [View Transitions](https://sygnal.js.org/guide/view-transitions/), [accessibility](https://sygnal.js.org/guide/accessibility/), [controls](https://sygnal.js.org/guide/controls/) (also an entry on [alternative forms](https://sygnal.js.org/advanced/alternative-forms/#controls-instead-of-class-selectors)) and [undo](https://sygnal.js.org/advanced/undo/); sections on the [app-level error hook](https://sygnal.js.org/advanced/error-boundaries/#app-level-error-hook), [`uid()`](https://sygnal.js.org/guide/inputs/#labels-and-ids-uid), [`STATE.watch`](https://sygnal.js.org/guide/intent/#reacting-to-state-changes-statewatch), [Immer](https://sygnal.js.org/guide/model/#writing-updates-as-mutations-with-immer), [testing with Testing Library](https://sygnal.js.org/integration/testing/#testing-with-testing-library), the [action log](https://sygnal.js.org/integration/testing/#action-log-tactions-and-texplain) and [big lists](https://sygnal.js.org/guide/collections/#big-lists-collection-or-mapped-rows). For the ecosystem parts: pages for [widgets](https://sygnal.js.org/guide/widgets/), [web components](https://sygnal.js.org/guide/web-components/), [adapters](https://sygnal.js.org/guide/adapters/), [forms](https://sygnal.js.org/guide/forms/) (with a [forms reference](https://sygnal.js.org/guide/forms-reference/)), [browser sources](https://sygnal.js.org/guide/browser-sources/), [virtual collections](https://sygnal.js.org/guide/virtual-collections/), [UI parts](https://sygnal.js.org/ui/overview/), and a [Recipes](https://sygnal.js.org/recipes/overview/) section (charts, code editor, rich text, carousel, data table, data grid, icons, i18n) whose code is tested.
|
|
213
|
+
- **`sygnal-check`:** explanations for every new code (`sygnal-check explain SYG112`), SYG112 and SYG508 as static rules, `ok`/`error` reply-action names and `connections` names counted as triggers by SYG102, a `'reply'` action trigger in `--graph` / `inspect()`, and the updated severity semantics below. For 6.0's ergonomics:
|
|
214
|
+
- controls are resolved in the same file and through relative imports and re-exports: SYG110 and SYG104 by identifier, SYG124–SYG126, SYG128, and SYG111 looks through controls;
|
|
215
|
+
- `--fix --controls` converts a single-class intent selector into a control when the class is on exactly one element of the component's own view; the class stays when CSS, another source file or `--keep-classes` needs it. Opt-in, since controls are an alternative form (plain `--fix` leaves selectors alone); running it again changes nothing;
|
|
216
|
+
- `uses` is resolved to `defineBehavior` factories (same file, relative imports) and the first-party behaviors, so SYG101, SYG102, SYG104 and SYG110 see behavior actions and controls; a behavior from a package is opaque (no findings). SYG127 and SYG226 are static too;
|
|
217
|
+
- `persist` (SYG223, SYG224), timers (SYG422; timer actions count as triggers for SYG102), element commands (SYG640, SYG641; the `close` and `toggle` events commands cause count as triggers), SYG643 when the scanned `run()` call registers no driver for `timers`, `connections` or `resources`;
|
|
218
|
+
- the accessibility lane (SYG701–708), warnings unless `--a11y=error` (`check(…, { a11y: 'error' })`, the MCP tools' `a11y` argument, `check: { a11y: 'error' }` in the Vite plugin);
|
|
219
|
+
- static checks for three traps the agent evals hit (REPORT-v4), each silent unless every fact is in the source:
|
|
220
|
+
- [SYG405](https://sygnal.js.org/reference/errors#syg405) at a sub-component's `initialState` when a view renders it without `isolatedState = true` (an error for `<Stopwatch state="stopwatch" />` or `<Stopwatch />`, which throw at run time; a warning for a Collection or Switchable target);
|
|
221
|
+
- [SYG129](https://sygnal.js.org/reference/errors#syg129) (new, warn): `CHILD.select(TaskRow)` in a component that doesn't render `TaskRow` while a component it renders does (a grandchild, such as a Collection item inside a child). `PARENT` reaches only the direct parent; the fix relays it through the component in between;
|
|
222
|
+
- [SYG609](https://sygnal.js.org/reference/errors#syg609) (warn): a model sink such as `HTTP` or `WS` that the scanned `run()` call registers no driver for, so the app drops every value sent there while `renderComponent()`'s fakes keep its tests passing;
|
|
223
|
+
- `--graph` lists controls, behavior-owned actions, element commands and timers;
|
|
224
|
+
- the SYG502 rule is removed (retired, see Changed).
|
|
225
|
+
- **`create-sygnal-app`:** `README.md` in the package.
|
|
226
|
+
|
|
227
|
+
- **`defineComponent(opts)`** (PLAN-4.6, D162): builds a component from an options object (`{ name, view, model, intent, initialState, ... }`) for code that makes components from data. It returns an ordinary function component that calls `view`, with the other options as its statics and `name` as its `componentName`; it replaces the removed `component()` factory. Typed (`DefineComponentOptions`).
|
|
228
|
+
- **`sygnal-check` finds the forms 6.0 removed** (PLAN-4.6 R5): [SYG612](https://sygnal.js.org/reference/errors#syg612) (error, always on) for `.components`, `.peers`, `hmrActions`, `storeCalculatedInState`, `DOMSourceName` / `stateSourceName` and `.label` on a component (a function with component statics or a view; not on other objects), `component` / `collection` / `switchable` imported from `sygnal`, and `<Collection of="Name">` and `idfield` on sygnal's `Collection`; `--fix` deletes `storeCalculatedInState` and default source names (a statement in a block or the module body; not the body of an `if`). SYG501, SYG504 and SYG506 (`--strict`, with `--fix`) are errors now and say the form was removed.
|
|
229
|
+
- **Dev diagnostics of the 6.0 component core** (PLAN-4.6 R4, `sygnal/diagnostics`): [SYG423](https://sygnal.js.org/reference/errors#syg423) (warn) when a context change skipped a view that renders differently with the new context (a sample of the skipped views is called again, D168); [SYG424](https://sygnal.js.org/reference/errors#syg424) (warn) for duplicate Collection ids (only the first renders, D169/D177); [SYG425](https://sygnal.js.org/reference/errors#syg425) (warn) when an `isolatedState` child keeps an existing slice that lacks keys its `initialState` defines, suggesting `resetState` (D174); [SYG612](https://sygnal.js.org/reference/errors#syg612) (error, once per form and component) for a form 6.0 removed met at run time, with a link to its section of the migration guide (D173).
|
|
230
|
+
- **`resetState` prop** (PLAN-4.6, D174). `<Editor state="doc" resetState />` replaces an `isolatedState` child's slice with its `initialState` when the child is created; without it an existing slice is kept and `initialState` only fills a missing one. It is read at creation, like `state`, and is not a prop of the child. In the JSX types next to `state`.
|
|
231
|
+
- **`defineWidget()`** (PLAN-5 W-1, [widgets guide](https://sygnal.js.org/guide/widgets/)): wraps a framework-agnostic widget (flatpickr, Chart.js, Tiptap) as a JSX tag. `defineWidget({ tag, mount(el, props, dispatch), update(instance, props), unmount(instance), events, commands, fallback })`; the view renders `<DatePicker className="due" value={state.due} />`, the intent reads `DOM.select('.due').events('pick').detail()`, and the model sends `ELEMENT: { open: '.due' }`. The host is opaque to Sygnal and keeps its instance across renders and keyed moves; `update` gets the newest props when they change (shallow), (`style`/`attrs` objects by their entries), and a widget without it is remounted; `dispatch` sends a bubbling `CustomEvent`; declared commands win over native methods of the same name (`focus`, `close`, `togglePopover`, with the options object); `className` toggles only its own tokens on the host (classes the library adds stay); `ref` gets the host element; a host that something else replaces (a plain element, another widget, the error fallback) unmounts; a `mount`/`update` that throws goes to `onError` with phase `'widget'` and the owner's `onError` fallback renders in that instance's place until its props change or the fallback leaves the page; widgets work inside `<Portal>` (unmounted when it is removed) and `<Transition>`; `renderToString` renders the host with `fallback` inside. The same widget is a control spec (`controls({ Due: DatePicker })`). Tests: `t.widget(selector | control)` (`props`, `instance`, `dispatch`; `emit` is an alias). 0 bytes unless used (about 1.3 KB gzipped when used); the `ELEMENT` sink grows by 9 B for the command lookup. Diagnostics: SYG140 (undeclared dispatch), SYG141 (listener for a near-typo of a declared widget event, `sygnal-check`), SYG142 (undeclared command; `sygnal-check`: info unless a near-typo, none for a custom-element host), SYG143 (the widget tag used as a selector), SYG144 (info: a declared event name the browser also fires), SYG660–662 (`mount`/`update`/`unmount` threw). `sygnal-check` treats a widget tag as its host element (no false SYG110/SYG640; SYG702 for an `input` host).
|
|
232
|
+
- **Web components** (PLAN-5 W-3, [guide](https://sygnal.js.org/guide/web-components/)): a "Web components" guide for using custom-element libraries (tested with Web Awesome 3 in Chromium, Firefox and WebKit) and publishing components with `sygnal/element`. `.detail(fn?)` reads a `CustomEvent`'s payload, next to `.value()` (7 B). `DOM.select(…).events(name)` and `DOM.select('document').events(name)` type-check with any event name (`'wa-hover'`). The dev entry no longer reports hyphenated event shorthands (`DOM['wa-hover'](sel)`) as SYG115. `renderToString` writes a custom element's camelCase HTML properties as their attributes (`tabIndex` → `tabindex`), `ariaX` as `aria-x`, other camelCase props both kebab-case and lowercase (`withClear` → `with-clear withclear`: Web Awesome / Stencil vs Lit's default), and leaves out its function and object props.
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
- **`focusWithin(selector)`** (PLAN-5, D194; no core bytes): an `ELEMENT` target that focuses an element inside the sender's children, `{ focus: focusWithin('[data-id="3"] .title') }`, for a parent that adds a Collection row or submits a form of child fields (a plain selector stays in the sender's isolated scope). `renderComponent()` checks it on the mock DOM (SYG640 when nothing in the view matches). [Guide](https://sygnal.js.org/guide/element-commands/#focusing-inside-children-focuswithin).
|
|
236
|
+
- **Browser sources** (PLAN-5 B-3, [Browser Sources guide](https://sygnal.js.org/guide/browser-sources/)). A `browser` static declares what to watch from state, in the `timers` shape, and each change arrives as the component's own action: `Card.browser = (state) => ({ seen: !state.seen && { intersection: '.cover', action: 'SEEN' }, dark: { media: '(prefers-color-scheme: dark)', action: 'DARK' } })` with `run(App, { BROWSER: makeBrowserDriver() })`. Sources: `intersection` and `resize` (a selector in the component's own view, or `true` for its root element; each Collection item watches its own), `media`, `storage` (read and observe a key, other tabs' writes included; `persist()` stays the way to keep state), `visibility`, `online`, `geolocation` (permission-gated, `error` action). The clipboard is a pair of commands on the driver's sink (`{ copy: text, ok }`, `{ paste: true, ok, error }`), and so are storage writes (`setItem`, `removeItem`). Compared by name on every state change (a new name starts, a falsy one stops, a changed spec restarts); paused on hidden Switchable pages unless `background: true`; nothing runs during SSR. `makeBrowserDriverWith(intersectionSource, mediaSource, …)` takes only the sources it is given. `renderComponent` provides a fake (it sends the observers' initial "not visible" / zero-size report) driven by `t.browser` (`intersect`, `resize`, `media`, `storage`, `visibility`, `online`, `geolocation`, `clipboard`, `deny`, `active`) and the `browser` option. 0 bytes unless used; when used about 0.8 KB gzipped for the driver plus 0.1–0.3 KB per source (2.0 KB with every source). A command is recognised by any of its method keys, and a handler that throws (a value JSON can't encode) fails through its `error` action; a same-page storage write notifies only when the value changed (an echoing model settles). One `IntersectionObserver` / `ResizeObserver` per driver for each kind of options. Dev diagnostics SYG663 (invalid spec or command), SYG664 (a source the driver wasn't made with), SYG665 (a failure with no `error` action), SYG668 (an `intersection` / `resize` entry with nothing to observe), and SYG643 for `browser` without a driver; `sygnal-check` counts browser actions as triggers (SYG102/SYG112) and reports the missing driver (SYG643).
|
|
237
|
+
- **Deferred lazy loading** (PLAN-5 B-4, D103, [Lazy Loading](https://sygnal.js.org/advanced/lazy-loading/#loading-when-visible-or-idle)). `lazy(() => import('./Chart.jsx'), { when: 'visible' })` starts the import when a placeholder enters the viewport (`rootMargin` to start earlier), `when: 'idle'` when the browser is idle after it is on the page (`requestIdleCallback` with a 2 s timeout; a timeout where there is none). The placeholder (`data-sygnal-lazy="deferred"`, `placeholderHeight` for its min-height) stays in its own place: a Suspense boundary waits for it only once the import has started; `renderToString` renders the placeholder and never loads; `Chart.load()` starts the import now (preloading, tests). 0 bytes in the core; apps that use `lazy()` carry about 0.5 KB more (gzipped).
|
|
238
|
+
- **Collection move transitions** (PLAN-5 A-1, [View Transitions guide](https://sygnal.js.org/guide/view-transitions/#collection-items)). `<Collection of={Card} from="todo" viewTransitionName="card" />` gives each item with an `id` `view-transition-name: card-<id>` (the id escaped to a CSS identifier) and `view-transition-class: card` on its root element, the item's own style winning, so a reorder or a move in a `viewTransitions` action animates each item between its places, and between Collections with the same prefix. Id-less items get no name; SSR renders the same names. Every current evergreen engine runs same-document View Transitions, so there is no FLIP fallback; the guide has AutoAnimate and Motion recipes for drag-sort lists and element animations. `<VirtualCollection>` takes the same prop (only its rendered rows animate). Dev diagnostics [SYG148](https://sygnal.js.org/reference/errors#syg148) (a prefix that isn't a CSS identifier) and [SYG149](https://sygnal.js.org/reference/errors#syg149) (one name on two rendered elements, so the browser skips the transition). About 120 B gzipped in the core.
|
|
239
|
+
- **`defineBehavior()` extensions** (PLAN-5, D197; no core bytes). `timers: (slice, options, key) => ({ name: spec })` declares timers for the host (named `'<key>.<name>'`, alongside the host's own `timers`); model handlers get the use's options and key after `props` (`(slice, data, next, props, options, key)`) and the intent gets `(sources, options, key)`, so a behavior can name its reply actions (`ok: key + '.LOADED'`); a `HOST` model entry is a reducer on the host's whole state (with a `STATE` in the same entry, `STATE` runs first and `HOST` gets its result). Two markers (3-H): `persist: false` (the slice is UI state: a root's `persist()` neither saves nor restores it) and `undoStep: ['DONE']` (with the `undo` behavior on the same host, the behavior's other actions are steps of one gesture: not recorded on their own; `DONE` records the value from before the gesture as one entry, in either `uses` order; `state.history.base` holds it meanwhile; a gesture that brings an array or plain object back entry by entry records nothing; only the behavior that started a gesture completes it). Existing behaviors are unchanged. [Guide](https://sygnal.js.org/guide/behaviors/#options-the-key-host-state-and-timers).
|
|
240
|
+
- **Forms with validation: the `form` behavior** (PLAN-5 F-1, D193, [forms guide](https://sygnal.js.org/guide/forms/)). `Signup.uses = { form: form(schema, { values, submit: 'SIGN_UP' }) }` with any Standard Schema (zod, valibot, arktype or a hand-written object; no validator dependency). Fields inside the form element are matched by `name` (a path in `values`; array rows by id, `addresses.7.city`), so there is no intent per field; the view reads `state.form.fields.email` (`{ name, value, error, invalid, touched, dirty, pending }`), `state.form.submitting` and `state.form.error`. An invalid submit shows every error and focuses the first invalid field (Collection rows included, through `focusWithin`); a valid one dispatches the host's `submit` action with the schema's output, and the host answers with `ok: 'form.DONE'` / `error: 'form.ERRORS'` (server errors land on their fields and the first is focused; other messages become the form-level error). A second submit while one is in progress (or waiting for an async schema) is dropped. A submit whose host entry sends no request on the `http` sink (option, default `'HTTP'`), such as a wizard step that only updates state or tells its parent, is done at once (`submitted`, no `form.DONE` to send), and a host that remounts mid-submit starts unstuck ([Submit without a request](https://sygnal.js.org/guide/forms/#submit-without-a-request)). `resetOnShow: true` starts the form over each time its host starts or its form element appears again (off by default, so a wizard keeps its values), and `values` may be a function of the host's state ([Start empty on each visit](https://sygnal.js.org/guide/forms/#start-empty-on-each-visit)). Field types: text-like inputs, textareas, radios and selects give their value; a checkbox its `checked`, and same-named checkboxes on an array value a list of the checked values; `<select multiple>` the selected values; form-associated custom fields and checkboxes/switches the same way; `type="file"` is ignored; numbers and dates stay strings (coerce in the schema). The first validation runs when the component starts (`valid` is false until the schema answered; after that an async schema re-validating keeps the last answer's `valid`, so `disabled={!valid}` doesn't flicker, and `validating` / `validated` say where it is). Two forms in one component each take their own `form` selector, which also scopes the focus on an invalid submit. Also: `show: 'blur' | 'input' | 'submit'`, field arrays (`form.ADD` / `form.REMOVE`, rows in a Collection, named by row id only), async schemas, async per-field `check`s through the HTTP driver (on blur, `latest`; a submit waits for them), `form.RESET`. The functions it is built on are exported as the escape hatch for a hand-written form: `checkForm`, `formErrors`, `setField`, `getField`, `hasField`, `fieldName`, `fieldNames`, `replyErrors`, `focusInvalid(names, within?)`. 0 bytes unless used (about 2.4 KB gzipped when used, plus `defineBehavior`'s 0.9 KB; the helpers alone about 0.6 KB). Dev diagnostics SYG230–237 (a field name not in `values`, a schema that isn't a Standard Schema, a dropped submit, a value the schema strips, a `submit` action the model lacks or one named after a form action, a `check` for an unknown field or a check request that sets reply fields, rows without an `id`, two forms on one selector); `sygnal-check` knows `form` (option typos are SYG127; fields inside the form element are listened to, SYG111).
|
|
241
|
+
- **New runtime dependency: `@tanstack/virtual-core`** (`^3.17.11`, MIT, no dependencies of its own) for `<VirtualCollection>`; it is side-effect free, so apps that don't use VirtualCollection bundle none of it (D209).
|
|
242
|
+
- **Sortable lists: the `sortable` behavior** (PLAN-5 B-1, [Drag and Drop guide](https://sygnal.js.org/guide/drag-and-drop/#sortable-lists)). `TaskList.uses = { sort: sortable({ from: 'tasks', item: '.task', handle: '.grip' }) }` reorders `state.tasks` by mouse, pen, touch and keyboard, over a Collection's items with nothing wired per item (it listens on the host's root). Keyboard: Space / Enter on a handle lifts the item, the arrows and Home / End move it live, the cross axis moves it to the next list (`from: ['todo', 'done']`), Space / Enter or Tab drops, Escape puts it back; focus stays on the moved item's handle (through `focusWithin`), and a lifted item is dropped where it is when focus moves to another element or a pointer is pressed (a press on a handle then starts a pointer drag at once); a held Space / Enter doesn't drop and lift again. Pointer: past a 4 px `threshold` a drag starts, `state.sort.over` / `after` drive a drop indicator, the list reorders on release (over nothing or outside the list: cancelled; Escape cancels; over another list's item, its upper or lower half lands before or after it), text selection and the browser's native drag (`dragstart`) are blocked while pressed, touch needs `touch-action: none` on the handle; no pointer capture. `state.sort.message` is the live-region text (lift / move / drop / cancel / stay; `label` and `messages` options), `state.sort.helpId` a `uid()` id for the instructions element (unique per host; set at the first focus, press or key inside the host, so an untouched host writes nothing and hydration matches the server's markup), and `sort.DROPPED` (`{ id, list, index, fromList, fromIndex }`) fires once per completed move for the host to save the order. A host removed mid-drag cancels the drag (no DROPPED). A cancelled keyboard drag (Escape, the host removed or made again) puts the lists back only when nothing else changed them since its last arrow key (they are still the arrays the drag made); after any other change (an edit, an insert, new data, an undo, another tab's copy) the item stays where it is and the drag just ends (Escape announces it: `messages.stay`). With the `undo` behavior a drag is one undo step (recorded at the drop; cancelled drags record nothing and leave nothing pending; undo / redo during a drag never record a half-moved order; with several lists, `undo({ key: ['todo', 'done'] })` records them together; a `track` naming none of `sort`'s actions leaves drags unrecorded, and quick drops join only when `coalesce` names one of them); `persist()` never saves the drag state, and drag state that comes back another way doesn't resume a drag. Nested sortables: an event belongs to the innermost one, and a host's items are the item elements below its root that aren't inside another of them (ids may repeat between levels). Moves follow the array's order: render it unsorted and unfiltered (documented; SYG435 in development). Options: `from`, `item`, `handle`, `axis`, `threshold`, `attr`, `idField`, `label`, `messages`. 0 bytes unless used (about 2.4 KB gzipped when used, plus `defineBehavior`'s 1.0 KB). Dev diagnostics SYG145 (an item without its id attribute), SYG146 (an `item` / `handle` selector that matches nothing at the first interaction), SYG147 (`from` isn't an array in the host's state), SYG435 (a list shown in another order than its array's, or with entries hidden between the shown ones: a Collection's `sort` / `filter`; items each in their own wrapper count as one list); `sygnal-check` knows `sortable` (option typos are SYG127; its item / handle may be rendered by Collection items, so no SYG104) and reports SYG724 (a11y lane: a handle that can't take focus or has no name, or a host without a live region). `makeDragDriver` stays the tool for native HTML5 drag and drop (files, drops across components).
|
|
243
|
+
- **`<VirtualCollection>`** (PLAN-5 V-1, [guide](https://sygnal.js.org/guide/virtual-collections/)): a Collection that renders only the rows in view, for long lists. `<VirtualCollection of={Row} from="rows" className="rows" estimateSize={32} />` takes Collection's `of` / `from` / `filter` / `sort` (same keys, duplicates and missing-`from` handling; other props go to every item), plus `estimateSize` (a number or `(item, index) => px`, default 32), `overscan` (default 5), `role` (default `list`), `tabIndex` (default 0), `aria-label` / `-labelledby` / `-describedby`, `id` and `style` for its element, which is its own scroll container (`overflow-y: auto`; give its class a bounded height). Only the rows in view plus `overscan` have instances; a row scrolled out is disposed and made again when it returns, and its state is its array element, so state survives (focus, uncontrolled input text and in-flight effects don't). Rows can have any height: each is measured once rendered (a `ResizeObserver` follows changes). Rows get `data-index`, and `aria-setsize` / `aria-posinset` with their role (in a `list`, `role="listitem"` unless they have one). Jump with element commands on the container: `ELEMENT: { scrollToIndex: '.rows', index, align?, behavior? }` and `{ scrollToId: '.rows', id, ... }` (the row needn't be rendered). Without layout (mock DOM, jsdom, `renderToString`) it renders the first 10 rows' estimate plus `overscan`. Built on `@tanstack/virtual-core` (MIT), now a regular dependency of `sygnal` (`^3.17.11`), so its patch and minor releases reach apps through npm (D209). 0 bytes unless used; about 8.8 KB gzipped when used (virtual-core ≈ 6.0 KB of it). Diagnostics (dev entry): SYG430 (no bounded height: 0 px, or it grows with its rows, in which case only a viewport's height of rows renders), SYG431 (items without `id`), SYG432 (an item renders a fragment), SYG433 (`scrollToIndex` / `scrollToId` target not in the list), SYG434 (invalid `estimateSize` / `overscan`). `sygnal-check` treats it as a Collection (its rows are isolated items; SYG401/104). Creating 10,000 rows takes about 7 ms (a plain Collection: about 140 ms); see the guide for the numbers and the threshold (from about 1,000 rows).
|
|
244
|
+
- **`sygnal/ui`: headless UI parts on native HTML** (PLAN-5 U-1 and T-1, D202, [UI parts guide](https://sygnal.js.org/ui/overview/)). A new subpath entry, unstyled (your classes, plus `data-state` / `data-value` / `data-kind` hooks), tree-shaken per part, 0 bytes in the core:
|
|
245
|
+
- **`dialog()`**: a behavior over `<dialog>`: `uses = { profile: dialog({ dialog: '.profile', trigger: '.edit', close: '.cancel' }) }` opens it with `showModal()` (the browser traps the focus and closes it on Escape) and keeps `{ open, returnValue }` from its `close` and `toggle` events. Options `modal`, `cancelable`, and `returnFocus` (default on: the trigger gets the focus back when the browser lost it, as Safari does after a mouse click).
|
|
246
|
+
- **`popover()`**: a behavior over the Popover API: a `popovertarget` button opens it, `state.x.open` follows its `toggle` event, and `x.OPEN` / `x.CLOSE` / `x.TOGGLE` drive it from the model.
|
|
247
|
+
- **`tooltip()`**: a `popover="manual"` tip placed by CSS anchor positioning, shown on hover or focus after `showDelay` (500 ms) and hidden after `hideDelay` (100 ms) through `timers`; it stays open while the pointer is on it, and Escape hides it.
|
|
248
|
+
- **`tabs()` + `tabsAttrs()`, `accordion()` + `accordionAttrs()`, `disclosure()` + `disclosureAttrs()`**: behaviors over your own buttons and panels. The attribute helpers give roles, ids from `uid` (unique per instance), `aria-selected` / `aria-expanded` / `aria-controls` / `aria-labelledby`, the roving `tabindex` and `hidden`, to spread on the elements: `<button className="tab" {...tabsAttrs(state.tabs, uid).tab('general')}>`. The arrow keys, Home and End move the focus (WAI-ARIA APG); tabs have automatic or manual activation and either orientation; accordions have `multiple`, `collapsible` and `expanded`.
|
|
249
|
+
- **`<Toaster />`**: render it once; any component sends `EVENTS: event('TOAST', { text, kind, timeoutMs })` (`event('TOAST_DISMISS', id)` removes one). Toasts are Collection items with Transition and a `timers` timeout (5 s; `0` = until dismissed), in persistent `role="status"` and `role="alert"` regions; a toast with the `id` of a shown one replaces it; the timeouts pause while hovered or focused (`pauseOnHover={false}` turns that off). Its region is a `popover="manual"` element that moves into the topmost open modal `<dialog>` while one is open (D198), so toasts stay clickable, reachable with Tab and announced above a modal.
|
|
250
|
+
|
|
251
|
+
The browser floor is current evergreen Chromium, Firefox and Safari (D195); the guide has a Floating UI recipe for browsers without CSS anchor positioning. When used, gzipped: Dialog 0.4 KB, Popover 0.2 KB, Tooltip 0.3 KB, Tabs and Accordion about 1 KB each, Disclosure 0.3 KB, each plus `defineBehavior` (about 1 KB, shared); Toaster 1.1 KB. sygnal-check knows the parts (option typos are SYG127; `<Toaster />` counts as selecting TOAST and TOAST_DISMISS). Tests: 30 unit tests, tree-shaking tests, and 23 browser tests in Chromium, Firefox and WebKit (keyboard, pointer, focus, roles and names, the toast-over-modal matrix). Known: Safari 26 misplaces a tooltip whose trigger is inside a `position: fixed` bar on a scrolled page (documented, with the Floating UI recipe as the workaround).
|
|
252
|
+
|
|
253
|
+
- **Adapters: `fromZag` (`sygnal/zag`) and `fromReact` (`sygnal/react`)** (PLAN-5 W-2, D203, [adapters guide](https://sygnal.js.org/guide/adapters/)). Both make [widget](https://sygnal.js.org/guide/widgets/) tags, so the view, intent, model and tests use them like any widget (`<Stars className="rating" value={state.rating} />`, `DOM.select('.rating').events('rate').detail()`, `ELEMENT` commands, `t.widget()`).
|
|
254
|
+
- **`fromZag(zag, render, { events, props, commands })`** runs a [Zag.js](https://zagjs.com) machine (`import * as menu from '@zag-js/menu'`) on the widget host with `@zag-js/vanilla`, one machine per host, and renders its parts with Sygnal JSX and Zag's prop getters (`<button {...api.getTriggerProps()}>`) through a patch of its own. The machine always sees the newest props (a controlled `open` / `value` drives it); machine callbacks become dispatched events (`events: { select: ['onSelect', (d) => d.value] }`). The render is plain elements only (SYG669 in dev for a component, widget tag or special JSX inside it). A render error stops the machine and goes to the widget error path (SYG660 while mounting, SYG661 later, with the owner's `onError` fallback). Inside `<Transition>`, the content stays while the host leaves. `zagProps(bag)` normalises one prop bag for snabbdom.
|
|
255
|
+
- **`fromReact(Component, { events, props, commands })`** renders a React component in a React root per host (`react-dom/client`, `flushSync`), with the newest props on every change; callbacks become dispatched events (`events: { rate: 'onChange' }`, or `['onChange']`). Props are routed (D215): `className`, `class`, `id`, `style`, `attrs`, `tabIndex`, `hidden` stay on the host; `aria-*`, `role`, `title` go to the component only (an icon button named by `aria-label` names its own `<button>`); `data-*` to both (`ownProps` sends a host prop to the component instead, `hostProps` puts a component-only one on the host too). The root unmounts after the host has left the document, inside shadow roots too (a `<Transition>` leave keeps it; a destroyed host left in the page unmounts after 10 s). Works with `preact/compat` through a bundler alias (`react`, `react-dom` → `preact/compat`, `react-dom/client` → `preact/compat/client`). An escape hatch: context and providers don't cross the boundary, and the mock DOM doesn't run it (`dom: 'real'`).
|
|
256
|
+
- **Optional peer dependencies** (D209): `react` / `react-dom` (≥ 18) and `@zag-js/vanilla`, `@zag-js/menu`, `@zag-js/select`, `@zag-js/combobox` (pinned `~1.45.0`, one version for all), installed only by apps that import an adapter. `sygnal/vite` reports a missing one when a module imports the entry ([SYG666](https://sygnal.js.org/reference/errors#syg666), from real imports only; a string that mentions an entry is ignored). Wrong arguments throw [SYG667](https://sygnal.js.org/reference/errors#syg667) when the widget is defined.
|
|
257
|
+
- `defineWidget` gains `ownProps` (props that stay off the host) and a fourth `mount` parameter, `error(e)`, for failures a widget catches itself (SYG661 + the `onError` fallback).
|
|
258
|
+
- Size when used (gzipped, small app): `fromZag` about 2 KB plus Zag (the dialog machine: 20 KB in all); `fromReact` plus React about 68 KB, plus `preact/compat` about 7.6 KB. 0 bytes in the core and in apps that don't import them.
|
|
259
|
+
- **Menu, Select and Combobox: `sygnal/ui/menu`, `sygnal/ui/select`, `sygnal/ui/combobox`** (PLAN-5 U-1, D202/D211; [Menu](https://sygnal.js.org/ui/menu/), [Select](https://sygnal.js.org/ui/select/), [Combobox](https://sygnal.js.org/ui/combobox/)): WAI-ARIA menu button, select-only combobox and editable combobox as widget tags on Zag's machines (keyboard, focus, typeahead, `aria-activedescendant`, positioning). One subpath per part, each needing only `@zag-js/vanilla` and its own machine: `<Select className="size" label="Size" items={['S', 'M', 'L']} value={state.size} />` with `DOM.select('.size').events('value-change').detail()`. Events `select` (Menu), `value-change`, `input-change` (Combobox), `open-change`; commands `open`, `close`, `clear`, `focus`. `label`, `aria-label`, `aria-labelledby` and `aria-describedby` go on the control (not the host). Forms: Select renders a hidden native `<select>`, Combobox hidden inputs (the value, one per value with `multiple`). Combobox filters by label (or `filter={(item, text) => …}`, or `filter={false}` for server search) and composes the app's own `onOpenChange` / `onInputValueChange`. `positioning={{ strategy: 'fixed' }}` escapes containers that clip. `renderComponent({ dom: 'real' })` provides what Zag needs in jsdom (`ResizeObserver`, `CSS.escape`, `Element#scrollTo`) while a test runs. `sygnal-check` knows the tags (SYG110/141/142/143) and reports one without an accessible name ([SYG722](https://sygnal.js.org/reference/errors#syg722)). Gzipped, Zag included: Menu 33 KB, Select 33 KB, Combobox 34 KB, all three 47 KB.
|
|
260
|
+
- **`renderComponent(C, { context })`** (PLAN-5 D214, [Testing](https://sygnal.js.org/integration/testing/#context)): the context a component's ancestors would give it, for testing a child alone (`renderComponent(Cart, { initialState: { items: 1 }, context: { t: translator('fr') } })`). The view, reducers and children read it; the component's own `.context` entries win over a key of the same name. 0 B in the core.
|
|
261
|
+
- **`npm run test:recipes`** (repository): the docs recipes' code and its docs-sync check; `--browser` runs their real-browser tests in Chromium, Firefox and WebKit.
|
|
262
|
+
|
|
263
|
+
### Changed
|
|
264
|
+
|
|
265
|
+
- **A STATE reducer that returns the object it received means "no change"** ([Model](https://sygnal.js.org/guide/model/#aborting-an-action)), exactly like `ABORT`: no state is emitted and nothing re-renders, in components and Collection items alike. The entry's other sinks are unchanged. Before, it emitted the same object as a new state and re-rendered. A reducer that changes the state in place and returns it therefore has no effect; the dev entry reports it as SYG222. Immer's `produce()` works as a STATE reducer as is (a recipe that changes nothing returns the original).
|
|
266
|
+
- **`ABORT` on typing restores a controlled field** (PLAN-5, D205; [Controlled Inputs](https://sygnal.js.org/guide/inputs/#controlled-inputs)). An action triggered by an `input` or `change` event whose STATE reducer makes no change (`ABORT`, or the state it received) re-renders its component, so a field with a bound `value`/`checked` (native, or a form-associated custom element) shows the state's value again, as React does: `PIN: (state, pin) => /^\d*$/.test(pin) ? { ...state, pin } : ABORT` keeps letters out of the field. Before, nothing rendered and the field kept the refused text. `ABORT` from any other action still renders nothing. Core: +27 B.
|
|
267
|
+
- **Strict SYG502 is retired** ([strict mode](https://sygnal.js.org/guide/strict-mode/#syg502-retired-in-60)). It flagged `return state` for "no change", which is now the same as `ABORT`. The code is never reported. Static detection of a bare `return;` (or a reducer body that can end without returning) went with it; at runtime, a root STATE reducer returning `undefined` is still SYG202. `ABORT` stays the form the docs use.
|
|
268
|
+
- **New reserved names:**
|
|
269
|
+
- the prop `uid` (the view's [`uid()`](https://sygnal.js.org/guide/inputs/#labels-and-ids-uid)): a parent can't pass its own (SYG106, an error under strict mode);
|
|
270
|
+
- the statics `uses`, `persist`, `timers` and `viewTransitions`, which Sygnal reads (`viewTransitions` must be an array of action names);
|
|
271
|
+
- the sink `ELEMENT`, built in (element commands) for every component; on a root with `persist()`, the sink `PERSIST` and the action `RESTORE` (a `RESTORE` model entry replaces the built-in one);
|
|
272
|
+
- in `renderComponent()`, the sink `TIMER` (or the `timerSink` option) is served by the timer fake unless a driver is passed under that name.
|
|
273
|
+
- **The accessibility lane is on by default** in `sygnal-check` and the Vite plugin's dev checker, so existing projects may see new SYG7xx warnings. They stay warnings under `--strict` and `diagnostics.strict` (strict mode is about canonical forms, and an upgrade shouldn't fail on markup nobody touched), so they never open the Vite overlay unless you opt in with `--a11y=error` / `check: { a11y: 'error' }`. An earlier 6.0 pre-release plan made them errors under `--strict`; that was dropped before release (D144). Fix them, or silence one with `// sygnal-ignore SYG70x`. Nothing changes at runtime.
|
|
274
|
+
- **`run()` is scoped to its app.** Hot module replacement keeps each app's own state: `hmr()` reads the app's own state stream, and a hot swap is visible only to that app. The page-wide `window.__SYGNAL_HMR_PERSISTED_STATE`, `__SYGNAL_HMR_UPDATING` and `__SYGNAL_HMR_STATE` are gone, and a swap no longer writes the kept state into the component's `initialState` static. A `run()` without the `diagnostics` option keeps the current mode while another app is live (it reset it before). The first live app keeps `window.__SYGNAL_DEVTOOLS_APP__`, and disposing it gives the slot back.
|
|
275
|
+
- **Mock DOM: events that don't bubble in the browser don't bubble in `renderComponent()`.** `focus`, `blur`, `mouseenter`/`mouseleave`, `pointerenter`/`pointerleave`, `load`, `unload`, `scroll`, `scrollend`, `invalid`, `close`, `cancel`, `toggle`, `beforetoggle`, `error` and `abort` reach only listeners on the target element, not its ancestors; with `dom: 'real'`, `simulateEvent` dispatches them with `bubbles: false`.
|
|
276
|
+
- **Collection items keep their identity.** After an item writes its state back, the other items that have an id are the same objects in the parent's array, and an item component receives its item as is (before: `{ ...item }` copies). Items without an id are still copied with their index as the id. With duplicate ids, the first match still wins.
|
|
277
|
+
- **A Collection removal can wait for a moved item** (part of the G-213 fix under Fixed). While an item that moved into another Collection in the same update is still rendering its first view, the removal waits for it (at most 100 ms), so a moved item is never painted missing; only new items of that update hold a removal. A plain delete is rendered at once when its Collection is the only one alive on the page, and one task later when several are (a delete can't be told from the first half of a move between them then). First renders and pure reorders are not delayed.
|
|
278
|
+
- **`renderToString()` marks its root element with `data-sygnal-ssr=""`** ([SSR](https://sygnal.js.org/integration/ssr/)): `<div class="counter" data-sygnal-ssr="">…</div>` (on a fragment, its first element). The first client render removes it. It tells Sygnal's markup apart from other content in `run()`'s mount point, such as a client-only app's loading placeholder, so a [`persist()`](https://sygnal.js.org/guide/persistence/#server-rendering-hydrate) app restores its saved state after the first render only over server HTML. Every app's SSR output changes by this attribute.
|
|
279
|
+
- **Vike:** the Page, Layouts and Wrappers get matching `uid()` roots on the server and the client (an internal `id` per shell, `w0`, `l0`, `p`), so ids survive hydration under a Layout or Wrapper. A Vike page doesn't support `persist()` in 6.0 (SYG224 says so).
|
|
280
|
+
- **Astro:** island roots forward the `persist`, `uses`, `timers` and `viewTransitions` statics. Islands get no drivers, so `timers` (like `connections` and `resources`) can't run in an island yet.
|
|
281
|
+
- **Switchable pages stay alive and keep their state** ([Switchable](https://sygnal.js.org/guide/switchable/)). Every page is instantiated once and kept for the Switchable's lifetime. A hidden page keeps its own state and its sub-components, and its reducers, `EVENTS`/`PARENT`/`EFFECT` and `.context` see the current state, but it doesn't re-render while hidden: it renders the current state once when it is shown again. Before, a page's sub-components were re-created (their state reset) on every switch.
|
|
282
|
+
- **Severity and codes follow one rule** (`error` = the operation failed, thrown or caught and logged while the app keeps running; `warn` = likely mistake). A coded Sygnal error caught by a reducer, EFFECT or a parent is now reported under its own code (SYG215, SYG405, SYG413, SYG414, SYG903, …) instead of SYG216, SYG214 or SYG408. SYG405 is an `error` by default (still a warning for Collection and Switchable items). SYG420 (JSX tag is undefined) is now collected like other diagnostics.
|
|
283
|
+
- **A component's statics are shared by its instances, and frozen in dev** (PLAN-4.5, D152). `initialState`, `model`, `context` and `calculated` are no longer copied for each instance. With diagnostics on (the `sygnal/diagnostics` entry, which `sygnal/vite` injects in dev), they are frozen when the first instance is created, `initialState` deeply (its plain objects and arrays), so a reducer or view that changes them in place (`state.items.push(x)` on the initial state) throws where it happens and is reported (SYG216 in a reducer, SYG406 in a view) instead of silently changing every instance. Production builds freeze nothing.
|
|
284
|
+
- **SYG106 is an error in strict mode** (a parent prop named `state`, `children`, `slots`, `context` or `peers`); a warning otherwise.
|
|
285
|
+
- **Non-STATE reducers may return `null`, arrays and bigints**; they are sent to the driver as-is. Only a symbol other than `ABORT` is SYG218. Before, these were rejected with SYG218/SYG216 and nothing was sent.
|
|
286
|
+
- **`renderComponent()` waits:**
|
|
287
|
+
- `ready()` is also a cursor: a `next()` right after `await t.ready()` also matches states produced by the calls buffered before it;
|
|
288
|
+
- a `next()` right after another wait starts after the state that wait returned, so `await t.next(a); await t.next(b)` sees a `b` that arrived while `a` was rendering;
|
|
289
|
+
- `t.html()` throws before the first render (it returned `''`), naming `await t.ready()`;
|
|
290
|
+
- `t.html()` serializes like `innerHTML`: text escapes only `& < >` (`Couldn't`, not `Couldn't`), attributes only `& "`. `renderToString()` output is unchanged;
|
|
291
|
+
- `dispose()` rejects waits still pending instead of leaving them open.
|
|
292
|
+
- **`sygnal-check` SYG111** also reports a literal `<select value="a">` with no change listener: the runtime puts the selection back on every render, like any controlled field.
|
|
293
|
+
- **Vike:**
|
|
294
|
+
- `sygnal/config` (also `sygnal/vike` and `sygnal/vike/config`) is a single ESM file, `dist/vike/config/+config.js`; the CommonJS `+config.cjs.js` build is gone. This removes the `unexpected export { module.exports }` and `MODULE_TYPELESS_PACKAGE_JSON` warnings on every dev start;
|
|
295
|
+
- `urlPathname` is no longer in `passToClient` (Vike provides it on the client, and listing it logged a warning); the client falls back to `window.location.pathname`;
|
|
296
|
+
- in dev, `sygnal/vike/onRenderClient` is kept out of dependency pre-bundling, so the client entry and your pages share one Sygnal core;
|
|
297
|
+
- Pages, Layouts and Wrappers keep their function names (or `componentName`) in diagnostics; the root is `VikeLayoutWrapper` when a Layout or Wrapper is configured.
|
|
298
|
+
- **Agent context:** `llms.txt` and the `sygnal-dev` skill cover `makeFetchDriver`, the test fakes, fake timers, `dom: 'real'`, `t.state` and TypeScript. The skill's `references/component-patterns.md` is removed (its content is in `SKILL.md`), and the agent docs no longer recommend the `sygnal-check` MCP server (the server itself is unchanged). They teach reply actions as the canonical HTTP form, `connections` for sockets, and the socket fakes; the old `category` + `select()` round trip is on the alternative-forms page. They also cover the 6.0 ergonomics (behaviors, element commands, `persist`, `timers`, `STATE.watch`, `uid()`, `t.actions`, the accessibility lane; controls stay out of them, as an alternative form), drop the "never return `state`" rule, and cover the ecosystem parts (widgets, `form`, `sortable`, `<VirtualCollection>`, browser sources, `sygnal/ui`, the adapters), linking the guides shipped in `dist/guide`. Two rules came from the evals: a component that reads a resource declares it in its own `.resources`, and a form that should start empty on each visit uses `resetOnShow`.
|
|
299
|
+
|
|
300
|
+
- **The core is about 250 B smaller** (gzipped) with the same behavior, which pays for the `connections` static.
|
|
301
|
+
- **`create-sygnal-app` templates:** `AGENTS.md` tells agents to read the whole `npm test` output instead of piping it through `tail`, which hid the failure.
|
|
302
|
+
|
|
303
|
+
### Fixed
|
|
304
|
+
|
|
305
|
+
- **Hydration adopts the server's markup** (PLAN-5 3-J, G-456). The first client render over server HTML (`renderToString`, Astro islands, Vike pages) replaced every element with a `class` or `id`, every component root and `Collection` item, and each sibling after them, and on the elements it kept it removed `href`, `type` (a checkbox became a text field), `title`, inline `style` and `<option value>`. So the focus, typed text and scroll position from before the app started were lost. It now keeps each element the client renders with the same tag, with its attributes, and replaces only what differs ([What the first client render keeps](https://sygnal.js.org/integration/ssr/#what-the-first-client-render-keeps)). A mismatch still ends as a fresh client render. The mount point's own attributes (`<div id="app" data-theme="dark">`) are no longer removed (G-466). The DOM driver no longer bundles snabbdom's `toVNode`.
|
|
306
|
+
- **`<option value="">`** (PLAN-5 4-G, G-553) renders its `value` attribute, so a placeholder option submits `""` instead of its label text (the empty value was skipped as unchanged on creation, before the option had its text).
|
|
307
|
+
- **Form fields, SSR and Vike** (PLAN-5 3-K): `<input form="f">`, `<input list="l">` and `<button form="f">` (any element's `form`) no longer throw in the patch: the JSX pragma writes `form` and `list` as attributes (the DOM properties are read-only). `renderToString` writes a `<textarea value>` as its text and marks the `<option>` matching a `<select value>` (an array for `multiple`) as `selected`, so the server page shows them before hydration. On the Vike server, a Layout written as documented (`<main>{children}</main>`) renders the Page inside it, not after it. Combobox `allowCustomValue` submits the selected item's value unless the user typed since the last selection (with `selectionBehavior` `'preserve'` / `'clear'`, after the items change, or a `defaultValue` before the items load it sent the text, `''` or a label). SYG669 no longer reports a plain `<slot>` element that holds a form field, and reports again in a later app or test; a `fromZag` destroy hook that stops the widget runs once. sygnal-check SYG705: only `label` in the icon helper's options names the icon (the icons recipe's helper reads only `label`).
|
|
308
|
+
- **Hydration review fixes** (PLAN-5 3-M; [What the first client render keeps](https://sygnal.js.org/integration/ssr/#what-the-first-client-render-keeps)). Elements after a component that returns a fragment (`<>…</>`) or after a `false` / `null` child (`{cond && <X />}`) were paired with the wrong server elements and replaced, losing typed text and focus; a fragment's elements are now adopted one by one (G-481). `data-*` the client renders in `attrs` (and `data-sygnal-suspense`) were removed (G-482). A `style` the client renders is written again, so a declaration only the server wrote (a loading spinner's `position: fixed`) no longer stays (G-483). A server-rendered `<textarea>` (since 3-K, `renderToString` writes its value as text) was emptied; it keeps its value, and an uncontrolled one keeps what the user typed (G-484). An element with a `create` / `init` hook (a `thunk`), or with its own `insert` hook next to a `ref` or `autoFocus`, is made again so its hooks run (G-485). An element whose hyperscript selector has a class or id (`h('p.card')`; JSX never makes one), and the Portal placeholder, is made again rather than adopted with the server's class / id (G-486). An `open` the client doesn't render (a `<details>` the user opened) stays (G-487). The JSX pragma keeps `form` and `list` as properties on custom elements (3-K had made them attributes on every tag, breaking Lit / `sygnal/element` properties of those names; G-492). A plain `<slot>` element (in a `sygnal/element` `shadow: true` component, or `renderToString`) is no longer taken for the `<Slot>` marker and swallowed (G-480).
|
|
309
|
+
- **Fragments and hydration follow-ups** (PLAN-5 3-Q). Fragments (`<>…</>`) no longer go to snabbdom as fragment nodes: the DOM driver splices their children into the parent before each render. A fragment whose first child changed (a different tag, a `<Portal>`, `<Transition>`, `lazy()` or `<ClientOnly>` child, or any element made again at hydration) made a later insert before it throw `NotFoundError` in the DOM driver, after which the app stopped updating (G-518); a `Collection` item or `<Fragment key>` returning several elements didn't move when the list was reordered, and a new item landed in the wrong place (G-522); a child added at a fragment's end (`{cond && <b />}`) landed at the end of the parent (G-517). A fragment among a `<Portal>`'s children renders its elements. The `run()` option `fragments` has no effect any more. A DOM patch that throws (a hook, a read-only property) is reported once to `run(…, { onError })` with phase `'patch'` (logged when there is no `onError`); the app's DOM stops updating while its state and events go on, and the mount point is marked `data-sygnal-error="patch"` until the app is disposed (dispose still removes what it mounted). This also applies to a DOM driver you pass in, such as `makeViewTransitionDOMDriver`. Before, the app stopped updating silently ([After a patch error](https://sygnal.js.org/advanced/error-boundaries/#after-a-patch-error)). Hydration: `form` is an attribute on custom elements too, so a form-associated element (`static formAssociated`, a read-only `form`) renders and is linked to its `<form id>` (3-M had made it a property; `list` stays one; G-519). Adjacent text in a view (`Hello, {name}! <input>`) no longer shifts the elements after it, so the input keeps its typed text and focus (G-520). An element with your own `insert` hook is made again at hydration also when the hook has a `postpatch`, so `insert` runs (G-521). `open` on a `<details>` / `<dialog>` the client renders without the key stays (documented: write `open={isOpen}` to control it; G-523).
|
|
310
|
+
- **Zag parts and adapters** (PLAN-5 3-G): `<Combobox name form>` no longer throws on render (`form` goes on the hidden inputs as an attribute); `fromZag` releases its rendered content (refs, destroy hooks, listeners) after a render error stopped the machine and when a mount fails; SYG669 also reports `<Suspense>`, `<ClientOnly>`, `<Transition>` without props and `<Slot>` inside a `fromZag` render, and stops walking the render once it has reported; `fromReact` unmounts a React root whose host is removed inside a shadow root (`sygnal/element` `shadow: true`), and after 10 s when a destroyed host stays in the page; SYG666 sees `` import(`sygnal/zag`) ``; `renderComponent({ dom: 'real' })`'s `CSS.escape` stub escapes a leading digit (the CSSOM algorithm).
|
|
311
|
+
- **sygnal-check SYG705** reports an icon-only button whose only content is an icon helper call, `<button>{icon(Trash2)}</button>` (a function named `icon` or `renderIcon` with an icon imported from an icon package, or a string, and no `label`), as in the icons recipe.
|
|
312
|
+
- **A root with a model but no `initialState` renders nothing**, as before, but no longer silently: the dev entry reports [SYG238](https://sygnal.js.org/reference/errors#syg238) (PLAN-5, G-393). Give the root an `initialState`.
|
|
313
|
+
- **A model without `INITIALIZE` is no longer written to** (G-252). The first instance of such a component added its default `INITIALIZE` reducer to the shared `model` object, which kept that instance (and its streams) in memory for the life of the page and gave every later instance the first one's calculated-field cache. Each instance now has its own.
|
|
314
|
+
- **`renderComponent` `ready()`** no longer resolves before the first render when that render takes longer than 30 ms (a loaded machine or a slow view). Before, `t.query()` could return `null` right after `await t.ready()`.
|
|
315
|
+
- **Vike docs:** custom drivers go in `pages/+drivers.js`. The [Vike guide](https://sygnal.js.org/integration/vike/#custom-drivers) showed `drivers` inside `+config.js`, which Vike rejects: `vike build` fails with "must be defined using a separate file +drivers.js", and in `vike dev` the page never hydrates.
|
|
316
|
+
- **Switchable.**
|
|
317
|
+
- A page's `PARENT` never reached the parent's `CHILD.select(Page)`: only sinks that were also sources were forwarded.
|
|
318
|
+
- The Switchable could stay on the previous page after a switch (a page shown again while its stream chain was being torn down never rendered).
|
|
319
|
+
- `stateSourceName` wasn't passed on by the stream form of `switchable()`.
|
|
320
|
+
- **State.**
|
|
321
|
+
- After a child's lens write (a Collection item or a `state="slice"` child), the stored root state (`STATE` stream, devtools, `t.state`/`t.states`) kept stale calculated fields; the view was right.
|
|
322
|
+
- `.context` reading a calculated field lagged one update behind a Collection item's write.
|
|
323
|
+
- An `isolatedState` sub-component with `initialState` and no model never applied its `initialState`.
|
|
324
|
+
- A root component with an `intent` but no `model` rendered nothing under `run()` (its `initialState` was never applied), while `renderComponent()` rendered it.
|
|
325
|
+
- A component static that is neither a function nor an object (such as `App.route = 'ROUTE'`) was iterated character by character and threw SYG216; it is sent to its driver as is.
|
|
326
|
+
- A sub-component with a `model` but no `intent` never got `BOOTSTRAP`.
|
|
327
|
+
- A child rendered inside another child was instantiated once per ancestor; the duplicates ran BOOTSTRAP and timers and wrote state.
|
|
328
|
+
- A stream from `STATE.select(…)` didn't end when its component was disposed (the select dropped the end stream).
|
|
329
|
+
- **Collections.**
|
|
330
|
+
- A Collection in a child component ignored a change to its `filter` or `sort` prop until some item's state changed.
|
|
331
|
+
- `sort` without `filter` sorted the parent's state array in place; sorting now only changes what renders.
|
|
332
|
+
- Moving an item from one Collection to another (a kanban card to another lane) painted frames without it: 0–1 per move, 4–8 in rapid moves, even with no animation. The old Collection's removal rendered before the moved item's new instance did; it now waits for it (see Changed).
|
|
333
|
+
- **Rendering and forms.**
|
|
334
|
+
- A string or array `class` became one class name or `[object Object]`.
|
|
335
|
+
- A prop removed on re-render (`title`, `disabled`, `href`, …) stayed on the element, and `src={null}` / `title={null}` were written as the text "null". `null` and `undefined` props are never written; a removed prop is cleared.
|
|
336
|
+
- `value={null}` and `checked={null}` clear a controlled field and keep it controlled, as in 5.4.0; leaving the prop out makes the field uncontrolled.
|
|
337
|
+
- A `<select>`'s `value` was applied before its new options were patched, so it could select nothing.
|
|
338
|
+
- A `data-task-id="7"` JSX attribute became the dataset key `task-id`, which the DOM rejects: rendering stopped with a bare `DOMException {}`. It is now the key `taskId`, so the attribute renders as written and `.data('taskId')` reads it.
|
|
339
|
+
- A controlled input dropped keystrokes typed faster than the app rendered ('Hello world' became 'Hlowrd' with keys 1–2 ms apart; in Chromium, a 40 ms view with keys 30 ms apart lost about half): a render of an older state wrote its older `value` over newer text. A render now leaves a field the user changed after the rendered state until the newer render arrives; `value={null}`, resets and model rewrites still land. A model that rewrites the typed text back to the value it already had (a length cap) re-renders, so the field shows the state again (PLAN-4.5).
|
|
340
|
+
- **DOM events.**
|
|
341
|
+
- A component whose view returns a fragment (`<>…</>`), also as a Collection item or Switchable page, lost its DOM isolation under the real DOM driver: its own intent never fired, and the parent's selectors matched its elements. Every top-level element of a fragment now carries the component's scope, and `DOM.select(...).elements()` searches all of them. The mock DOM was already right.
|
|
342
|
+
- The real and mock DOM disagreed on events from inside a child component. Both now follow browser bubbling: a listener on an element the parent rendered itself (`<div className="slot"><Child /></div>` with `DOM.click('.slot')`) hears events from inside the child, after the child's own listeners; the real DOM driver stopped them at the child. The parent still can't select elements inside a child (SYG104).
|
|
343
|
+
- A `<dialog>`'s `close` and `cancel`, a popover's `beforetoggle`, and the media and image events `abort`, `error`, `loadstart` and `progress` never reached intent: they don't bubble, and the driver listened for them at the root, even with `useCapture: true`. They are now listened for on the element, so `DOM.close(dialog)` and `DOM.select('img').events('error')` fire.
|
|
344
|
+
- **Several apps on one page** (two `run()` calls, or an app plus custom elements). Hot module replacement could restore another app's state, and an app started during another app's hot swap took that app's state and skipped its own `INITIALIZE`; a second `run()` reset the diagnostics mode of the first. Each app is now independent (see Changed).
|
|
345
|
+
- **Drivers.**
|
|
346
|
+
- `driverFromAsync` lost replies and errors that arrived before the first `select()` / `errors()` listener (a request sent on `BOOTSTRAP`); they are buffered and delivered once a listener subscribes.
|
|
347
|
+
- **Testing.**
|
|
348
|
+
- Waits hung under fake timers.
|
|
349
|
+
- A child component's sink with no driver went to a no-op driver, so its output couldn't be asserted.
|
|
350
|
+
- **Vike.**
|
|
351
|
+
- After client navigation with both a Wrapper and a Layout, the page's `+data` was lost. Shell components now each get a lens onto their own slice (serialized 5.4.0 state still hydrates).
|
|
352
|
+
- Resources in a page under a Layout or Wrapper lost their first state write (they showed `idle`): the page no longer re-applies `initialState` over its root slice.
|
|
353
|
+
- In dev, the pre-bundled client entry inlined a second copy of the Sygnal core.
|
|
354
|
+
- **Types.**
|
|
355
|
+
- The root component's view `state` was untyped (`RootComponent` props were `any`).
|
|
356
|
+
- Typed sub-components couldn't be used in JSX with `state="slice"` or without `state`.
|
|
357
|
+
- `CHILD.select(Child)` silently became `any` for an annotated child without a `PARENT` type.
|
|
358
|
+
- DOM shorthands (`DOM.keydown('.x')`) were streams of plain `Event`.
|
|
359
|
+
- `event()` with an unregistered name gave two errors; it gives one.
|
|
360
|
+
- `SortObject` allowed a per-field function, which the runtime rejects (SYG418).
|
|
361
|
+
- A `PARENT: false` constant was typed as `never`.
|
|
362
|
+
- `npm run build` printed 56 TypeScript diagnostics; it prints none, and `test:types` type-checks the whole source.
|
|
363
|
+
- **A view that returns the same root vnode object again** (a cached or memoized tree) kept its child components (G-255). Before, they were disposed on that render and their placeholders reached the DOM as bare tags.
|
|
364
|
+
- **`Transition`, `Portal`, `ClientOnly` and lazy components inside a fragment** (`<>…</>`) work (G-256). Before, they reached the DOM as `<transition>`, `<portal>`… elements.
|
|
365
|
+
- **A keystroke re-rendered every component that received an equal state** (PLAN-4.5 regression, G-259): the rule that puts a controlled field back to the model's value after an input, even when the state is equal, applied to every component in every app. It now applies only to a component whose last view had a form field; a 20-row Collection next to a search box no longer re-renders each row per keystroke.
|
|
366
|
+
- **A render loop no longer freezes the page** (G-260, G-283). An app that stores a value read from the DOM after each patch that is never the same twice (a measurement that changes the layout, a timestamp) re-rendered in an endless chain of microtasks. After 100 chained renders the next one waits for a macrotask (a `MessageChannel` message, no timer), so timers, input and painting still run (the loop itself is a bug in the app: compare the value before storing it).
|
|
367
|
+
- **The DOM source of an app whose view replaces its mount point** (`<div id="app">` mounted at `#app`) emits for a `Transition`'s leave and a late `Portal` again (G-276; the listener for it is removed when the app is disposed, so apps run and disposed on the same mount point aren't kept in memory, G-285), and a nested app or custom element's change no longer makes the outer app's DOM source emit (G-277). A `Transition` leave on an element that also has a `style.remove` transition emits once the element is gone, not before (G-279).
|
|
368
|
+
- **Dev statics freeze** (D152 follow-ups). Host values in a `sygnal/element` (`el.items = hostArray`) and Vike's `pageContext.data` or hydrated state are no longer frozen, so the host can still change them (G-275); `renderComponent`'s `initialState` option is copied (shallow) rather than marked in place, so passing a component's own `initialState`, a sealed fixture or a Proxy no longer exempts the static from the freeze, freezes the fixture or throws (G-289); the options given to `defineComponent({ view, model, initialState })` are statics, frozen like any component's (G-280).
|
|
369
|
+
- **Messages.** SYG218 says "returned null" / "returned an array" instead of "returned a object".
|
|
370
|
+
- **Component core** (PLAN-4.6; behaviour that differs from 5.4.0):
|
|
371
|
+
- Collection item keys: an `id` of `0` (or any id but `undefined` / `null`) is the item's id; before, a falsy id was replaced by the item's index. Ids compare as strings (`'1'` and `1` are one item, as before), and an id never collides with an id-less item's index (G-307). An id-less item that writes itself back no longer stores the index it was given as `id`, so removing an item before it can't make it collide with a sibling (G-306).
|
|
372
|
+
- A Collection whose `from` key is missing when it is created renders once the key appears (D178); before, it rendered nothing for its whole life. SYG401 still warns, now saying it "renders nothing until it exists".
|
|
373
|
+
- `lazy()` works as a Collection's `of` and as a Switchable page (G-317); before, it stayed on its loading placeholder.
|
|
374
|
+
- A `Portal` whose target appears after it renders mounts its latest content once (before: an update during the retry could mount it twice), and one removed while retrying never mounts (G-316).
|
|
375
|
+
- A `viewTransitions` action whose new state is equal to the old one asks for no View Transition (it renders the same view).
|
|
376
|
+
- `sygnal/diagnostics`, `sygnal/devtools`, `renderComponent`, `sygnal/element`, Vike, Astro, `renderToString` and `run().hmr()` work on it unchanged. A hot swap starts the new component with the kept state at once (no re-sends 0 and 20 ms later) and sends no `BOOTSTRAP` to the components that start with it, as before.
|
|
377
|
+
- A `Portal` and a plain `div` that take turns at the same position no longer add the portal's content to the target again each time it opens (G-328). The portal's hidden placeholder is now `<div class="sygnal-portal" data-sygnal-portal="…" style="display: none">` (the class is new): it shows in `t.html()` snapshots, and a CSS rule on `.sygnal-portal` or on `div:empty`-style selectors may match it.
|
|
378
|
+
- A component's `children` are the vnodes its parent wrote: a `<Transition>`, `<Portal>` or `<Suspense>` child arrives as its marker (`children[0].sel === 'transition'`), not processed; rendering `{children}` gives the same HTML.
|
|
379
|
+
- A stream's `stop()` that throws while a removed component's streams stop is logged and reported to `run(…, { onError })` with the new phase `'dispose'` (before, swallowed); the other streams still stop.
|
|
380
|
+
- `defineComponent()` copies statics already on the view (the options override them) and names an anonymous inline view `'Component'` (not `'view'`).
|
|
381
|
+
- A prop named `peers` reaches the child's view (the view argument has no `peers` field any more), so SYG106 no longer reports it.
|
|
382
|
+
- A `<Collection>` on a hidden `Switchable` page follows its array while hidden: a removed item is disposed (its connections close, its timers stop) and a new one starts its `background: true` statics, without a view call (G-319).
|
|
383
|
+
- A `Portal` first reached by a patch rather than an insert (hydrating server markup, or replacing a plain `div` at the same position) mounts its content (G-318).
|
|
384
|
+
- A declaration static (`connections`, `resources`, ...) keeps following the state while a sibling's render throws (G-320).
|
|
385
|
+
- `uid()` in an item without an `id` uses `_i<index>` for its part, so an item whose `id` is `0` and the first id-less item no longer render the same DOM ids (G-322); `renderToString` matches.
|
|
386
|
+
- An `isolatedState` child's state starts as a shallow copy of its `initialState`, as the `INITIALIZE` reducer made it, so a reducer that mutates the state never changes the shared static.
|
|
387
|
+
- **JSX attributes and the DOM driver** (PLAN-5, D196).
|
|
388
|
+
- An `aria-*` boolean renders as `"true"` / `"false"`: `aria-invalid={true}` was written as `aria-invalid=""` and `aria-expanded={false}` removed the attribute, which assistive technology reads as neither. `false` renders `"false"` only on the ARIA states whose values include false (`aria-expanded`, `aria-hidden`, `aria-pressed`, `aria-checked`, `aria-selected`, `aria-disabled`, `aria-invalid`, `aria-current`, `aria-haspopup`, `aria-busy`, `aria-modal`, `aria-required`, `aria-readonly`, `aria-multiline`, `aria-multiselectable`, `aria-atomic`, `aria-grabbed`); on a string or id-reference attribute it removes the attribute as before (`aria-describedby={hasError && id}`), and `null` removes any `aria-*` (it was written as `"null"`). `attrs={{ 'aria-x': true }}` and `attrs-aria-x` are passed as written.
|
|
389
|
+
- `role`, `for`, `tabindex`, `aria-*` and the attributes below are routed to attributes on element tags only: a component gets them as props. Before, a component placeholder sent them to attributes it never reads, so `<Menu role="menu">` or `<Field aria-label="Email">` gave the component `undefined` (since 5.x for `role`/`for`/`tabindex`/`aria-*`).
|
|
390
|
+
- `popovertarget`, `popovertargetaction`, `commandfor`, `command`, `closedby`, `interestfor` and `anchor` are written as attributes. As props they set a property the browser ignores, so `<button popovertarget="menu">` and `<button commandfor="dlg" command="show-modal">` did nothing.
|
|
391
|
+
- An element a hook moved out of its component's DOM (a notification region re-parented into an open modal `<dialog>`) no longer makes every event inside it throw `No root element found` (G-356). Setting `__sygnalHome` on the moved element to the element it came from keeps its events in the component, bubbling through the home ([Portals](https://sygnal.js.org/advanced/portals/#moving-an-element-with-a-hook)).
|
|
392
|
+
- When an element is recreated around unchanged content (`state.ordered ? <ol>{items}</ol> : <ul>{items}</ul>`, a new `key`), the destroy hooks of that content get the elements being removed (G-564). Before, a component's or Collection item's unchanged DOM was given the new elements before the old ones were destroyed: a `defineWidget` inside was never unmounted (its new instance mounted beside it) and a `hook.destroy` saw the new element.
|
|
393
|
+
- A component view (a Collection item's too) that returns a string or a number renders it as text, on the client as `renderToString` does (G-567). Before, the client rendered the text `undefined`, and `renderToString` left a number out.
|
|
394
|
+
- A form-associated custom element (`static formAssociated = true`: Web Awesome's `wa-input`, `wa-rating`, `wa-switch`, …) with a `value` or `checked` prop is controlled like `<input>`: a render puts the state's value back when the model refused the user's (before, the element kept showing the refused value). The value is written as the prop, so a number stays a number. Other custom elements are left alone.
|
|
395
|
+
- **Diagnostics and `sygnal-check` with behaviors** (PLAN-5, D199).
|
|
396
|
+
- SYG102 is no longer reported for a behavior's own actions (`'tip.SHOW'`, which its timers, `next()` calls or replies trigger), in the dev checks and in `sygnal-check`; `sygnal-check` counts a behavior's `timers` actions as triggers (an action named by an option: the use's literal value) and its options read in `timers` or in model handlers as known (no false SYG127).
|
|
397
|
+
- `sygnal-check` follows `uses` through factory functions that pass their options on (`(opts) => base(opts)`, `(opts = {}) => base({ delay: 300, ...opts })`, `(opts) => defineBehavior({ ... })(opts)`): option typos (SYG127) and unknown host entries (SYG102) are reported as for a direct use. A wrapper that changes the options stays opaque.
|
|
398
|
+
- SYG202 is not reported for a Collection item's reducer that returns `undefined`, the documented self-removal (G-357; it was info). Another sub-component still gets the info.
|
|
399
|
+
- SYG111 accepts a parent's input listener around a child's fields: a Collection item's (or a child's) controlled field counts as listened to when, wherever it is rendered, its parent listens for input on an element around it (a form-level `DOM.select('.signup').events('input')`).
|
|
400
|
+
- `renderComponent()`: the mock DOM's event target has `name`, `id`, `type` and `getAttribute()`, so a listener that delegates by `e.target.name` works as on the real DOM; `t.fail(sink, { status, body })` is an HTTP error response, as documented (it was treated as a network failure).
|
|
401
|
+
|
|
402
|
+
### Performance
|
|
403
|
+
|
|
404
|
+
- **Collection item lookups are O(1).** An item's write-back and lookup no longer scan the list (two O(n²) paths), with no API change and about 40 B in the core. In a 1,000-row Collection (median of 10 runs, Chromium), editing one row went from 16.2 to 8.3 ms and swapping two rows from 10.7 to 5.5 ms. Unchanged items keep their identity (see Changed).
|
|
405
|
+
- **A new component core** (PLAN-4.6; replaces PLAN-4.5's render scheduler and lazy wiring). One store per app runs actions to completion and renders the page once per change, in one DOM patch: selecting a row in a 1,000-item Collection went from 1,001 patches (5.4.0) to 1, updating every 10th row from 102 to 1, a click 30 components deep from 51 to 1. A Collection item costs 1 xstream stream (5.4.0: 122), unmounting 1,000 items arms no timer (5.4.0: 70,013 `setTimeout` calls), and a 1,000-row Collection holds 6.5 MB of heap instead of 17.6 MB (10,000 rows: 49 MB instead of 158 MB). Against PLAN-4.5's core (median of 10, Chromium, the same machine and run): mounting 1,000 components 38.9 → 17.8 ms (1.4× React, was 3×), updating one of them 3.3 → 1.5 ms, unmounting them 7.1 → 2.8 ms; Collection create 1k 44.9 → 26.9 ms, replace 56.6 → 23.6 ms, select 9.3 → 3.4 ms, remove a row 12.2 → 1.4 ms (as fast as React), create 10k 899 → 238 ms (React: 297 ms); a click 30 deep 2.3 → 1.4 ms; a keystroke in a 1,000-item page 2.0 → 1.6 ms. Pages that map rows in one component are unchanged (they are bound by the JSX pragma, snabbdom and the DOM driver).
|
|
406
|
+
- **Removing many components at once no longer slows down with their number** (G-290). The DOM driver listed every sibling scope each time it removed one, so clearing n components side by side (a Switchable page switch, a Collection clear) took time in n². Each scope now counts its children. In the timing report (median of 10, Chromium), clearing a 2,000-item Collection went from 18.6 to 16.3 ms and showing another 500-component Switchable page from 8.8 to 7.3 ms. No size change.
|
|
407
|
+
- **A performance baseline** against React 19 and Vue 3.5 (create, edit, swap, append and clear on 1,000 rows, plus a js-framework-benchmark implementation): `npm --prefix browser-tests run perf`, results in `benchmarks/RESULTS.md`. The [Collections guide](https://sygnal.js.org/guide/collections/#big-lists-collection-or-mapped-rows) now says when to map rows in one component instead.
|
|
408
|
+
|
|
409
|
+
### Breaking changes (runtime behavior)
|
|
410
|
+
|
|
411
|
+
These are fixes, but code or tests may depend on the old behavior:
|
|
412
|
+
|
|
413
|
+
- **A STATE reducer that returns the object it received is "no change"** (see Changed). Code that returned the same object to force a re-render, or changed the state in place and returned it, now does nothing.
|
|
414
|
+
- **SYG502 is retired.** Strict mode no longer reports `return state`, and `sygnal-check --strict` no longer reports a bare `return;` in a reducer.
|
|
415
|
+
- **Reserved names:** a `uid` prop passed by a parent is overwritten by the view's `uid()`; statics named `uses`, `persist`, `timers` or `viewTransitions` are read by Sygnal (a `viewTransitions` that isn't an array lists no action; SYG645 in development); a model's `ELEMENT` sink runs element commands and is not sent to a driver of that name; in `renderComponent()`, a `TIMER` sink without a driver goes to the timer fake.
|
|
416
|
+
- **Mock DOM:** a test that sent a non-bubbling event (`focus`, `blur`, `close`, `toggle`, `scroll`, `error`, …) to an element and expected an ancestor's listener to hear it now gets nothing, as in the browser.
|
|
417
|
+
- **HMR globals:** `window.__SYGNAL_HMR_PERSISTED_STATE`, `__SYGNAL_HMR_UPDATING` and `__SYGNAL_HMR_STATE` no longer exist; a hot swap no longer writes into the component's `initialState` static.
|
|
418
|
+
- **Collections:** unchanged items with an id are the same objects after another item writes back (no `{ ...item }` copies).
|
|
419
|
+
- **Node.js:** `engines.node` is `^20.19.0 || >=22.12.0` (was `>=12.0.0`, which the code no longer met): the range of Vite 7 and 8, which `sygnal/vite` needs, and the versions with `require(esm)`, which `require('sygnal/config')` uses. The browser bundles are unaffected.
|
|
420
|
+
- **SSR markup:** `renderToString()` output has `data-sygnal-ssr=""` on its root element, writes a `<textarea>`'s value as its text, marks the `<option>` matching a `<select value>` as `selected`, and writes custom elements' camelCase props as attributes (leaving out function and object props), so a test or snapshot that compares the HTML exactly changes.
|
|
421
|
+
- **Hydration keeps the server's elements** (see Fixed): the first client render adopts matching server elements instead of making them again, and removes the attributes the client doesn't render. An element with a `create` / `init` hook, or its own `insert` hook, is still made again, but a custom element that is already upgraded and built its own children is adopted without its `connectedCallback` running again; wrap such an element in `<ClientOnly>` ([What the first client render keeps](https://sygnal.js.org/integration/ssr/#what-the-first-client-render-keeps)).
|
|
422
|
+
- **SSR Portal wrapper:** a `Portal` with more than one child renders, in `renderToString()`, inside `<div class="sygnal-portal" data-sygnal-portal="<target>">` (before: `<div data-sygnal-portal="">`), the client placeholder's selector (hydration makes the placeholder again in its place, 3-M). SSR snapshots of such a Portal change, and a CSS rule that hides `.sygnal-portal` (the client's hidden placeholder) also hides that server-rendered portal content until hydration moves it to the target.
|
|
423
|
+
|
|
424
|
+
- **Hidden Switchable pages don't re-render** and keep their sub-components' state across switches (before: re-created on each switch). Code that relied on a page resetting when it is switched away should reset its state explicitly (for example on the action that switches).
|
|
425
|
+
- **`t.html()` throws before the first render** instead of returning `''`, and **escapes like `innerHTML`**, so stored snapshots containing `'` or `"` in text change.
|
|
426
|
+
- **`next()` cursor semantics:** a `next()` right after `await t.ready()` or after another wait can now match a state that the old `next()` skipped; a test that awaited `t.next()` to skip such a state needs a more specific predicate.
|
|
427
|
+
- **Codes and severities:** SYG405 is an `error`; errors caught by a handler are reported under their own code, so `ignore: ['SYG408']` or `['SYG216']` no longer silences a SYG405 or a SYG215. SYG106 is an error under strict mode. In `diagnostics: 'error'` mode, these throw.
|
|
428
|
+
- **Events bubble out of child components in the real DOM**, so a parent listener on its own element around a child (or around a Collection) now also fires for events from inside the child; a handler that should ignore them can check `e.target`, or the child can call `e.stopPropagation()`. In `renderComponent()` (mock DOM), one event now reaches listeners innermost first, like the browser (before: parent before child), so the order of actions recorded from one event can change.
|
|
429
|
+
- **Driver sinks receive `null`, arrays and bigints** from reducers instead of nothing (and an error).
|
|
430
|
+
- **Vike:** `require('sygnal/config')` loads the ESM config (Node's `require(esm)`); nothing points at `dist/vike/+config.js` or `+config.cjs.js` any more. `pageContext.urlPathname` isn't serialized to the client.
|
|
431
|
+
- **`HYDRATE` is no longer a built-in action.** Nothing dispatched it except the legacy `@cycle/http` path below. A model entry named `HYDRATE` is now an ordinary action (SYG102 if nothing triggers it).
|
|
432
|
+
- **Legacy `@cycle/http` hydration removed:** an `HTTP` source's `select('initial')` no longer becomes `HYDRATE`, and the component option `requestSourceName` is gone.
|
|
433
|
+
- **Async EFFECTs:** a returned promise no longer warns (SYG219); its rejection is reported as SYG214; `next()` called from an EFFECT after the component is disposed does nothing.
|
|
434
|
+
- **Reserved request keys:** `ok`, `error` and `key` on requests to `makeFetchDriver`, `driverFromAsync` and `makeSocketDriver` name reply actions (a 5.4.0 `driverFromAsync` request that used `ok`/`error` as data keys now gets reply actions); a request with a `then` or `catch` key is refused (SYG610).
|
|
435
|
+
- **Strict mode** reports the `select('c')` round trip for a component's own `category: 'c'` requests (SYG508), so strict-clean 5.4.0 code using `driverFromAsync` + `QUOTE.select('quote')` for its own requests gets a finding.
|
|
436
|
+
- **`sygnal/vite`** aliases `globalthis` for every dependency in the app, not only xstream. Set `nativeGlobalThis: false` if a dependency needs the polyfill package.
|
|
437
|
+
- **DevTools are no longer in production builds** ([Debugging](https://sygnal.js.org/integration/debugging/#devtools-extension)). `run()` no longer installs the DevTools bridge (`window.__SYGNAL_DEVTOOLS__`); the new dev-only entry `sygnal/devtools` does on import, and `sygnal/vite` injects it in dev (`vite`, the Vike and Astro dev servers; `devtools: false` opts out), never in `vite build`. With `sygnal/vite` nothing changes in dev, and the bridge (about 2 KB gzipped) leaves every production bundle. `getDevTools()` from `sygnal` returns `undefined` when the bridge isn't installed (before: a bridge object even outside a browser), so `getDevTools().inspect()` needs the bridge loaded. The UMD build (`sygnal.min.js`) has no DevTools.
|
|
438
|
+
- **Forms removed in 6.0** (PLAN-4.6, D162–D164): `'ACTION | SINK'` model keys, positional view arguments `view(props, state, context, peers)` (the view gets one argument, with no `peers` field), `CHILD.select('Name')` and `CHILD.select()` without an argument, `.components` with string tags and `<Collection of="Name">`, `.peers`, `hmrActions`, `DOMSourceName` / `stateSourceName`, `storeCalculatedInState: false`, the `component({ ... })` factory with `sources` / `isolateOpts`, the `collection()` and `switchable()` helpers, a single-stream intent, `.label` as a component's name, Collection `idfield`, and string / `true` context entries. Each is ignored or fails at run time. What reports each (as the table in [Migrating to 6.0](https://sygnal.js.org/guide/migrating-to-6/) lists it): in development the dev checks report SYG612 once, with a link to the form's section, for string tags and `.components`, `<Collection of="Name">`, `CHILD.select('Name')`, `'ACTION | SINK'` keys, positional views, `.peers`, `hmrActions`, the source names and `storeCalculatedInState`; `sygnal-check` reports SYG612 statically for the statics on a component, `<Collection of="Name">`, the factory imports, and (only statically) `.label` and `idfield`, and SYG501 / SYG504 / SYG506 under `--strict` for positional views, `'ACTION | SINK'` keys and `CHILD.select('Name')`; a single-stream intent throws SYG603 naming the form when the component starts; string / `true` context entries are logged as SYG403 and skipped.
|
|
439
|
+
- **Render timing** (PLAN-4.5, PLAN-4.6 D165). A STATE reducer is applied when its action is processed (not in a microtask); `INITIALIZE` runs when the component is created, its intent is subscribed at creation (a synchronous `xs.of(...)` emission is applied before the first render), and `BOOTSTRAP` runs a microtask after the first render (not 10 ms later). The page renders once per change, in a microtask after the actions, in one DOM patch: two components that re-render for one change are in the same patch, so nothing sees the DOM in between, and a new component renders in the same patch as its creation. Actions run to completion in order: one dispatched while another runs (an EFFECT's `next()`, a driver answering at once, an EVENTS cascade) runs after it and everything it queued. Code or tests that waited a fixed delay still work (everything comes sooner); a test that advanced fake timers to start an app (`run(App); await vi.advanceTimersByTimeAsync(10)`) has nothing to advance; code that read `STATE.stream`'s last value right after an action sees the new state.
|
|
440
|
+
- **Context changes re-render only the components that read them** (PLAN-4.6, D168): a view's context reads are recorded, and a context change skips views that read none of the changed keys. Only a view that relied on being re-rendered for a side effect notices; the dev checks report one that would have rendered differently (SYG423).
|
|
441
|
+
- **Collection keys** (PLAN-4.6, D169, D177): id-less items under `filter` / `sort` are keyed by their index in the state array (showing or hiding one no longer re-creates the items after it); with duplicate ids only the first item renders (SYG424 in development); an `id` of `0` is an id.
|
|
442
|
+
- **Teardown is synchronous** (PLAN-4.5). A disposed component (removed from a view or a Collection, a Switchable page re-created, the app disposed) runs its `DISPOSE` action and completes its streams within the dispose, so its `DISPOSE` EFFECT and sinks run before the parent's next patch, and it handles no action sent after it (before: until the next macrotask, so a `next('ACTION', data, 0)` sent just before the dispose still ran). The streams left without listeners stop at the next macrotask, as xstream would stop them, without a timer each.
|
|
443
|
+
- **The DOM source emits after Sygnal's patches only** (PLAN-4.5). `DOM.select(…).elements()` (and anything built on the DOM driver's root element) emitted on every change inside the app's root, watched with a `MutationObserver`; it now emits after each patch. Changes another script makes to the app's DOM no longer make it emit. Sygnal's own changes outside a patch still emit: a `Transition` removing its element after the leave transition, a `Portal` mounting into a target that appeared later.
|
|
444
|
+
- **In dev, mutating a component's statics throws** (PLAN-4.5, D152). With diagnostics on, a component's static `initialState` is deep-frozen and its `model`, `context` and `calculated` are frozen (values passed in, such as `renderComponent()`'s `initialState` option, are not), so code that changed the initial state in place (an item of an initial array, say) gets a TypeError (SYG216 or SYG406) in dev. Create new objects instead; production is unchanged.
|
|
445
|
+
- **JSX: nested prop objects are passed by reference** (PLAN-4.5). The JSX pragma no longer deep-copies `style`, `attrs`, `props`, `on`, `hook`, `class` and `data` objects, or a component's object and array props: the vnode holds the object you passed, as in React, Vue and snabbdom. Changing such an object in place and rendering it again can leave the DOM as it was (the next diff compares the object with itself); create a new object instead. An entry set to `undefined` is still dropped, and an object Sygnal adds to (`attrs={…}` plus `aria-label`, a `ref` on an element with a `hook`) is copied, never written to.
|
|
446
|
+
- **`aria-*` booleans** (PLAN-5, D196): `aria-hidden={false}` (or another ARIA state with a false value, such as `aria-expanded` or `aria-pressed`) now renders `aria-hidden="false"` instead of removing the attribute, and `true` renders `"true"` instead of `""`. A CSS selector such as `[aria-hidden]` or a test that checked the attribute's absence changes. `false` on other `aria-*` attributes still removes them.
|
|
447
|
+
- **`ABORT` during typing re-renders** (PLAN-5, D205): a STATE reducer that refuses an `input`/`change` action (`ABORT`, or the same state) re-renders its component, so a controlled field shows the state's value instead of what was typed. A view that counted renders, or an uncontrolled-looking field with a bound `value` that relied on keeping the typed text, changes.
|
|
448
|
+
- **Component props named `role`, `for`, `tabindex`, `aria-*`** (PLAN-5, G-370): a component now receives them (they were dropped). A component that spreads its props onto an element now passes them on to it.
|
|
449
|
+
|
|
450
|
+
- **`<Collection>` has no wrapper element** (PLAN-5 4-H, D229, G-554): its items render directly into the parent element (`<ul><Collection /></ul>` gives `ul > li`, before `ul > div > li`, invalid HTML that lost the list semantics), next to any siblings, and two Collections can share one parent. The `<div>` it rendered before is gone, with the props that only went to it: `className`, `style`, `class`, `attrs`, `data` / `data-*`, `on`, `hook` and `ref` on `<Collection>` are ignored, reported as SYG612 in development and by `sygnal-check`, and `className` is a type error. CSS and test selectors that went through the `<div>` (`.list > div > li`), `:first-child` / `:nth-child` counted among the items when the Collection has siblings, and `t.html()` / `renderToString()` snapshots change. `renderToString()` renders the items the same way, so hydration adopts them. `<VirtualCollection>` keeps its scroll container. `id`, `role`, `title` and `aria-*` on `<Collection>` (which also went to the `<div>`) now reach only the items, as props; put them on your wrapping element. A `<Transition>` around a Collection animated the `<div>`; it now applies to each item (an added item enters, a removed one leaves; 4-I G-559). Suspense, `READY: false` and `renderComponent`'s `t.html()` handle a Collection (or any fragment) as a component's root (4-I G-556 to G-558).
|
|
451
|
+
- **`isolatedState` with a `state` prop keeps the parent's data** (PLAN-4.6, D174; with the 6.0 component core). An `isolatedState` child bound to a slice (`<Editor state="doc" />`) uses its `initialState` only while `state.doc` is `undefined`; an existing slice is kept (before: the child's `INITIALIZE` replaced it whenever the child was created). A parent that relied on a re-mounted child starting fresh adds the new `resetState` prop (see Added).
|
|
452
|
+
|
|
453
|
+
### Breaking changes (TypeScript)
|
|
454
|
+
|
|
455
|
+
Type-level only; JavaScript and runtime behavior are unaffected:
|
|
456
|
+
|
|
457
|
+
| Change | Before | Now | Migration |
|
|
458
|
+
|---|---|---|---|
|
|
459
|
+
| `CHILD.select(Child)` of a `Component<…>`-annotated child without a `PARENT` type | `Stream<any>` | `Stream<unknown>` | Declare the payload in the 7th parameter: `Component<S, P, D, A, C, X, { PARENT: Payload }>`, or annotate at the call site |
|
|
460
|
+
| DOM shorthands `DOM.click('.x')`, `DOM.keydown('.x')`, … | `Stream<Event>` | the event's own type (`MouseEvent`/`PointerEvent`, `KeyboardEvent`, … from `HTMLElementEventMap`) | Fix handlers annotated with the wrong event type. xstream streams are invariant, so `const clicks: Stream<Event> = DOM.click('.x')` no longer compiles: drop the annotation or use the specific type (`Stream<MouseEvent>`; `click` is `PointerEvent` in recent DOM typings). Custom event names (`DOM['my-event']`) are still `Event` |
|
|
461
|
+
| `RootComponent` view props | `any` | `{}` (state typed by `STATE`) | A root view gets no props: drop props destructured from it, or type the component as `Component<S, Props>` |
|
|
462
|
+
| View `context` | optional (`context?.x`) | required in `ViewProps` | Calling a view directly (`App({ state })` in a test) needs `context: {}`; `context?.` still compiles |
|
|
463
|
+
| `event()` | two overloads | one signature `event(type, payload?)` | No change for valid calls. With an empty `SygnalEvents` registry, a static payload was `any` and is now a plain value: an `interface`-typed object fails ("Index signature … is missing"). Declare it as a `type` alias, register the event in `SygnalEvents`, or pass a payload function (`event('X', () => payload)`) |
|
|
464
|
+
| `SortObject` | `'asc' \| 'desc' \| SortFunction` per field | `'asc' \| 'desc' \| 1 \| -1` (`sort` is `SortSpec`) | Use a comparator `sort={(a, b) => …}` or an array `[{ priority: -1 }, cmp]` (the runtime already rejected per-field functions, SYG418) |
|
|
465
|
+
| Non-STATE sink values | `true` or a reducer | also constants (`NonStateSinkValue`) | None (wider); an untyped sink's constant can't be a function |
|
|
466
|
+
| JSX attributes of a typed sub-component | the view's props, `state` required | own props + optional `state` (`ElementProps`) | Passing `context=` or `slots=` in JSX is now a type error (they were overwritten at runtime, SYG106) |
|
|
467
|
+
| `HYDRATE` built-in action key | always allowed in typed models (`HYDRATE?: any`) | an ordinary action | List it in ACTIONS if you dispatch it; for SSR data use Vike `+data` / `hydrateState` |
|
|
468
|
+
| `set()` argument | any | `Partial<S> & object` or a reducer | `set('field')` was always wrong (SYG221): use `set((state, v) => ({ field: v }))` |
|
|
469
|
+
| `renderComponent` / `RenderResult` | not generic (`any`) | `renderComponent` infers the state; `RenderResult<S = any>` | None for untyped tests. Typed tests may now report real errors in predicates; for a handle declared before assignment use `let t: RenderResult<State>` |
|
|
470
|
+
| `Component` statics (PLAN-4.6) | `peers`, `components`, `label`, `storeCalculatedInState`, `DOMSourceName`, `stateSourceName` | gone (`componentName` added) | Remove them; see [Migrating to 6.0](https://sygnal.js.org/guide/migrating-to-6/) |
|
|
471
|
+
| View signature | `(props, state, context, peers)` | `(props)` | Destructure the one argument: `function C({ state, context, ...props })` |
|
|
472
|
+
| `CHILD.select` | also a `string` name | the component function only | `CHILD.select(Child)` |
|
|
473
|
+
| `calculated` / `context` entries | also `boolean` (and strings at run time) | functions only (`[deps, fn]` for calculated) | Write `(state) => …` |
|
|
474
|
+
| `component()` / `ComponentFactoryOptions`, `collection()`, `switchable()` | exported | removed; `defineComponent()` / `DefineComponentOptions` added | Function components with statics, or `defineComponent({ view, ... })` |
|
|
475
|
+
| `CollectionProps` | `idfield` | removed | Key items by `id` |
|
|
476
|
+
| `CollectionProps` (4-H, D229) | `className` passed through | `className?: never` | Put the class on your own element around the Collection |
|
|
477
|
+
| `FetchRequest` reply-action keys | `ok`/`error`/`key`/`then` were free app fields | `ok`/`error`/`key` are `string`, `then`/`catch` are `never`, `abort` is `true \| string` | Rename app fields with those names, or nest them |
|
|
478
|
+
|
|
479
|
+
`FetchInit`, `FetchRequest` and the other fetch and socket types are new.
|
|
480
|
+
|
|
481
|
+
### Removed
|
|
482
|
+
|
|
483
|
+
- `dist/vike/+config.cjs.js` (CommonJS Vike config); see Vike above.
|
|
484
|
+
- `skills/sygnal-dev/references/component-patterns.md` (content folded into `SKILL.md`).
|
|
485
|
+
- `HYDRATE` as a built-in action, the `@cycle/http` `select('initial')` hydration path and the `requestSourceName` component option.
|
|
486
|
+
- The strict rule SYG502 (runtime and `sygnal-check`); the code stays in the reference, marked retired.
|
|
487
|
+
- The page-wide HMR globals `window.__SYGNAL_HMR_PERSISTED_STATE`, `__SYGNAL_HMR_UPDATING` and `__SYGNAL_HMR_STATE`.
|
|
488
|
+
- The forms listed under Breaking changes (PLAN-4.6, D162–D164) and their exports: `component`, `collection`, `switchable`.
|
|
489
|
+
- The 5.x component core (`src/component.ts`) and the Cycle.js chains it drove (`withState`, `makeCollection`, `pickCombine` / `pickMerge`, `isolate`, the render scheduler).
|
|
490
|
+
- The codes that described removed forms or the old core, now "Retired in 6.0" in the reference (numbers kept, never reported): SYG211, SYG213, SYG413, SYG414, SYG419, SYG601, SYG604, SYG605, SYG607, SYG901, SYG902, SYG903.
|
|
491
|
+
- The Peer Components guide page.
|
|
492
|
+
- The `extend` runtime dependency: the JSX pragma sorts props into snabbdom's modules in one pass without deep copies (about 280 B gzipped less in an app). The runtime dependencies are `snabbdom`, `xstream` and, new, `@tanstack/virtual-core` (side-effect free: only `<VirtualCollection>` bundles it).
|
|
493
|
+
|
|
494
|
+
### Migration
|
|
495
|
+
|
|
496
|
+
Most apps need no changes. Check these:
|
|
497
|
+
|
|
498
|
+
- **Removed forms:** follow [Migrating to 6.0](https://sygnal.js.org/guide/migrating-to-6/). `npx sygnal-check src` (SYG612) and `npx sygnal-check --strict --fix src` (SYG501/504/506) find most of them; in development the dev checks report the rest at run time.
|
|
499
|
+
- **`<Collection className="x">`** (and `style`, `data-*`, ... on it): wrap the Collection in your own element, `<ul className="x"><Collection … /></ul>`; a `<div className="x">` keeps the old markup exactly. Check CSS and test selectors that went through the old `<div>` ([Migrating to 6.0](https://sygnal.js.org/guide/migrating-to-6/#collection-wrapper)).
|
|
500
|
+
- **`isolatedState` children** that should start fresh every time they mount: add `resetState` to the tag.
|
|
501
|
+
- **Tests that drove the clock to start an app** (`run()` under fake timers): drop the `advanceTimersByTimeAsync` before the first assertion; `await Promise.resolve()` is enough. `renderComponent` tests don't change.
|
|
502
|
+
- **Returning the same state object:** a reducer that returned `state` to force a re-render must return a new object (`{ ...state }`). One that changed the state in place and returned it must return a new object (the dev entry's SYG222 points to it), or wrap the reducer in Immer's `produce()` ([recipe](https://sygnal.js.org/guide/model/#writing-updates-as-mutations-with-immer)). `return state` for "no change" can stay; the docs keep `ABORT`.
|
|
503
|
+
- **SYG502:** nothing to do. Entries in `ignore` lists and `// sygnal-ignore SYG502` comments are harmless and can be removed.
|
|
504
|
+
- **Accessibility warnings in CI:** `sygnal-check` exits 1 on warnings by default, so the new SYG7xx findings fail a CI step that runs it, with or without `--strict`. Fix them, silence the ones you keep on purpose (`// sygnal-ignore SYG70x`), or run with `--fail-on=error` while you work through them; `--a11y=error` makes them errors once the app is clean. New static findings can appear too: SYG405 (a child with `initialState` that a view renders, which already throws at run time), SYG129 (`CHILD.select()` of a grandchild, which never fired) and SYG609 (a sink with no driver in `run()`, which was dropped).
|
|
505
|
+
- **Reserved names:** rename a `uid` prop passed to a child; rename a static of your own named `uses`, `persist`, `timers` or `viewTransitions`; rename a custom driver registered as `ELEMENT` (it receives nothing now: a model's `ELEMENT` entries go to the built-in sink only, like `EFFECT`); in tests, pass a driver of your own named `TIMER` in `drivers` (or set `timerSink`).
|
|
506
|
+
- **Tests of non-bubbling events** in the mock DOM: send the event to the element that has the listener.
|
|
507
|
+
- **HMR:** code that read or set `window.__SYGNAL_HMR_PERSISTED_STATE` (or relied on the swap writing `initialState`) has nothing to replace it with: each app's `hmr()` keeps its own state.
|
|
508
|
+
- **Collection removals in tests:** with more than one Collection on the page, await `t.settle()` (or `t.next()` / `t.waitForState()`) before reading the DOM after an item is removed.
|
|
509
|
+
- **SSR HTML compared exactly:** update snapshots and string comparisons of `renderToString()` output for the `data-sygnal-ssr=""` attribute on the root element, or strip it before comparing (`html.replace(' data-sygnal-ssr=""', '')`). Code that post-processes the server HTML should keep the attribute, or pass `hydrate: true` to `persist()` (see the [persistence guide](https://sygnal.js.org/guide/persistence/#server-rendering-hydrate)).
|
|
510
|
+
|
|
511
|
+
- **Switchable:** if a page should start fresh each time it's shown, reset its state on the switching action.
|
|
512
|
+
- **Tests:** replace `expect(t.html()).toBe('')` before a render with `await t.ready()` first; update `t.html()` snapshots containing `'`/`"` in text; tighten `next()` predicates that relied on skipping a state; update `ignore` lists and console expectations that named SYG408, SYG216 or SYG214 for errors that now carry their own code.
|
|
513
|
+
- **HTTP:** replace a hand-written `driverFromAsync(fetch…)` driver and request-id/ABORT bookkeeping with `makeFetchDriver()`. Move the reply wiring from the intent into the request: `HTTP: (state, id) => ({ url, ok: 'LOADED', error: 'FAILED', latest: true })` replaces `category: 'quote'` + `LOADED: HTTP.select('quote')` / `FAILED: HTTP.errors('quote')` (strict mode flags the old form, SYG508). `category` + `select()` keeps working for requests without reply actions and stream composition. In tests, drop the driver and use `await t.respond('HTTP', body, 'LOADED')` / `t.fail`; a test that expected answering a superseded request to do nothing now gets a synchronous throw. `makeFetchDriver` sends header names in lowercase (`Headers` semantics): a `fetch` stub that reads `init.headers['Content-Type']` must read `content-type` or use `new Headers(init.headers).get(…)`.
|
|
514
|
+
- **WebSockets:** replace a hand-written socket driver and connection-generation ids with `makeSocketDriver()` plus `Component.connections = (state) => ({ … })`; register it as `WS` (or pass `socketSink` to `renderComponent`).
|
|
515
|
+
- **`HYDRATE` / `requestSourceName`:** pass SSR data through Vike `+data` or the SSR state handoff (`hydrateState`); drop `requestSourceName`. A model entry named `HYDRATE` must now be triggered like any other action.
|
|
516
|
+
- **Async EFFECT:** tests that expected SYG219 for an async EFFECT, or relied on `next()` firing after dispose, need updating.
|
|
517
|
+
- **Requests with `ok`/`error`/`key`/`then`/`catch` data keys:** rename them or nest them under `value`.
|
|
518
|
+
- **Vike:** if you imported `sygnal/dist/vike/+config.js` or `+config.cjs.js` directly, import `sygnal/config` instead. If client code read `pageContext.urlPathname` without Client Routing, use `window.location.pathname`.
|
|
519
|
+
- **Bundles:** if a dependency needs the real `globalthis` package, set `sygnal({ nativeGlobalThis: false })`.
|
|
520
|
+
- **Node.js:** use Node 20.19 or 22.12 and later for tests, SSR and builds.
|
|
521
|
+
- **DevTools:** DevTools are no longer in production builds. With `sygnal/vite` nothing changes in dev; without it, `import 'sygnal/devtools'` in your development entry, before `run()`. Code that called `getDevTools()` unconditionally should use `getDevTools()?.…`.
|
|
522
|
+
- **TypeScript:** see the table above. The most common fixes are adding `{ PARENT: Payload }` to children selected with `CHILD.select`, and correcting DOM event annotations.
|
|
523
|
+
- **Fake timers:** tests that switched fake timers off around `renderComponent()` can now keep them on.
|
|
524
|
+
|
|
525
|
+
## 5.4.0 — 2026-10-01
|
|
526
|
+
|
|
527
|
+
Sygnal's silent failures are now loud, and coding agents get one clear way to write each concept. Every runtime warning and error has a code (`SYGnnn`) with a fix and a docs link. New dev-only checks catch wiring mistakes as they happen, a static checker (`sygnal-check`) catches them before the app runs, and the test helpers drive components the way a user would. This release also fixes a long list of rendering, state and tooling bugs that these checks and the agent evals found.
|
|
528
|
+
|
|
529
|
+
Nothing here is a breaking API change. The new checks run only in development and in tests, and the forms that strict mode doesn't recommend keep working.
|
|
530
|
+
|
|
531
|
+
**Measured impact.** In the agent eval ([`evals/agent-ergonomics/results/REPORT.md`](evals/agent-ergonomics/results/REPORT.md)), agents built the same features in Sygnal and in React. Every trial passed in both frameworks before and after this release, so speed is the measure. On the standard tasks, Sygnal trials went from 74.8 s to 50.3 s, the gap to React shrank from 46.1 s to 15.8 s (66% smaller), and failed test runs per trial fell from 1.7 to 0.1. On the harder tasks, trials went from 92.2 s to 78.5 s, the gap to React shrank from 29.0 s to 15.4 s, and iterations per trial (2.4) now match React's (2.5).
|
|
532
|
+
|
|
533
|
+
### Added
|
|
534
|
+
|
|
535
|
+
- **Coded diagnostics.** Every runtime warning and error carries a `SYGnnn` code, a fix and a link to the [error reference](https://sygnal.js.org/reference/errors). Choose a mode with `run(App, drivers, { diagnostics: 'off' | 'collect' | 'warn' | 'error' })` (or `{ mode, ignore }`). You can also read findings with `getDiagnostics()`, `clearDiagnostics()` and `onDiagnostic()`.
|
|
536
|
+
- **`sygnal/diagnostics`**, a dev-only entry with runtime checks:
|
|
537
|
+
- intent/model wiring (actions with no reducer, reducers with no action);
|
|
538
|
+
- selectors that match nothing, or that only match inside a child component's isolation boundary;
|
|
539
|
+
- EVENTS that are emitted but never selected, or selected but never emitted;
|
|
540
|
+
- reducer return shapes, reserved-prop collisions, and Collection `from` problems;
|
|
541
|
+
- hints when an RxJS operator is used on an xstream stream.
|
|
542
|
+
|
|
543
|
+
It also provides `inspect()`, a machine-readable app graph. These checks add 0 bytes to production bundles.
|
|
544
|
+
- **Strict mode** for the canonical forms (SYG501–507). Turn it on with `sygnal-check --strict`, at runtime with `renderComponent(C, { strict: true })`, or in dev through the Vite plugin. Other forms still run; strict mode only reports them.
|
|
545
|
+
- **`event()`**, the canonical way to emit a global event from a model entry: `{ STATE: …, EVENTS: event('SAVED', state => state.id) }`.
|
|
546
|
+
- **Test helpers on `renderComponent()`:**
|
|
547
|
+
- `simulateEvent(selector, type, init?)` sends DOM events with bubbling and isolation, the enriched `.value()` / `.data()` / … API, and structural selectors (`:nth-child`, `>`, `:not()`, …);
|
|
548
|
+
- `ready()`, `next(predicate)` and `settle()` wait for renders and states;
|
|
549
|
+
- `html()` returns the rendered markup;
|
|
550
|
+
- `sinkValues()` and `emitted()` return what each sink produced, and `simulateAction` now drives every sink;
|
|
551
|
+
- `expectNoDiagnostics()` fails a test on any Sygnal warning, and the `strict` and `diagnostics` options configure the checks;
|
|
552
|
+
- `inspect()` returns the app graph.
|
|
553
|
+
- **Vite plugin dev integration.** In `vite` dev (never in `vite build`), the plugin:
|
|
554
|
+
- loads the runtime diagnostics;
|
|
555
|
+
- runs `sygnal-check` on start and on every save, printing to the terminal and the browser console;
|
|
556
|
+
- adds the diagnostics setup file to Vitest;
|
|
557
|
+
- turns on dev mode for Vike and Astro.
|
|
558
|
+
|
|
559
|
+
New options: `diagnostics`, `check` and `vitestSetup`. The plugin also configures JSX under Vite 7 as well as Vite 8.
|
|
560
|
+
- **`sygnal-check`**, a new separate package (`npm i -D sygnal-check`) that reads your source and never runs it:
|
|
561
|
+
- the same wiring rules across the whole project, plus a controlled-input rule (SYG111);
|
|
562
|
+
- `--strict`, and `--fix` for the mechanical canonical-form rewrites;
|
|
563
|
+
- `--graph [--json]`, the static app graph, in the same shape as `inspect()`;
|
|
564
|
+
- `explain <code>`, which explains any SYG code;
|
|
565
|
+
- `mcp`, an MCP server with `check`, `graph` and `explain` tools;
|
|
566
|
+
- `// sygnal-ignore SYGnnn` comments to silence one finding.
|
|
567
|
+
- **Agent context:**
|
|
568
|
+
- `llms.txt`, a normative spec for language models, ships in the package (`node_modules/sygnal/llms.txt`) and at https://sygnal.js.org/llms.txt;
|
|
569
|
+
- a rewritten `sygnal-dev` agent skill (`skills/sygnal-dev`);
|
|
570
|
+
- every `create-sygnal-app` template now has an `AGENTS.md` (and a `CLAUDE.md` that imports it), a strict-mode starter test, and `sygnal-check` as a dev dependency.
|
|
571
|
+
- **New exports:**
|
|
572
|
+
- the xstream extras `concat`, `flattenConcurrently` and `flattenSequentially`, next to the existing `debounce`, `throttle`, `delay`, `dropRepeats` and `sampleCombine`, now as tree-shakable ESM ports;
|
|
573
|
+
- `errors()` on `driverFromAsync` sources;
|
|
574
|
+
- `getDevTools` and `Suspense` type declarations;
|
|
575
|
+
- typed links: `ActionsOf`, `IntentSources`, the `SygnalEvents` registry, typed `CHILD.select(Component)` and typed Collection `from`;
|
|
576
|
+
- `isolatedState` and `idfield` in the types, and `LazyComponent`.
|
|
577
|
+
- **Docs:**
|
|
578
|
+
- a generated [error reference](https://sygnal.js.org/reference/errors) for all 64 codes;
|
|
579
|
+
- new pages on diagnostics, strict mode, agents and the alternative forms;
|
|
580
|
+
- the guide pages rewritten to the canonical forms.
|
|
581
|
+
|
|
582
|
+
### Changed
|
|
583
|
+
|
|
584
|
+
- **`simulateEvent` throws when its selector matches nothing.** The error names the selector after a short wait for a render. Before, the event was dropped silently and the test timed out later. Pass `{ allowMissing: true }` to get the old behavior (the event is dropped and reported as SYG103).
|
|
585
|
+
- **Model shorthand (`'ACTION | SINK'`) and `emit()` are now "alternative forms".** They keep working, but the docs, `llms.txt` and the templates use the object form and `event()`, and strict mode reports them (SYG504 for shorthand, SYG505 for `emit()`; `sygnal-check --fix` rewrites both).
|
|
586
|
+
- **Messages carry codes.** Existing console warnings and errors now start with `[Sygnal SYGnnn]`, including in production, and several misleading messages were corrected.
|
|
587
|
+
- **Inputs are fully controlled.** `value` and `checked` always reflect state after a render, as in React, even when renders are coalesced. A field with a `value` but no input listener gets a SYG111 warning.
|
|
588
|
+
- **Non-STATE sinks see the same state as STATE.** Every sink of one action (EVENTS, PARENT, EFFECT, drivers) now reads the same state snapshot, and runs synchronously unless a same-tick STATE reducer is still pending.
|
|
589
|
+
- **`driverFromAsync`** delivers `null` and `undefined` results to `select()`. Rejections go to the new `errors()` source and are still logged when nothing listens.
|
|
590
|
+
- **`data-sygnal-ready`** now appears only on child components that aren't ready yet, so extracting markup into a sub-component no longer changes the DOM.
|
|
591
|
+
- **`lazy()`** returns `LazyComponent<PROPS>`, which is used in JSX without a `state` prop.
|
|
592
|
+
|
|
593
|
+
### Fixed
|
|
594
|
+
|
|
595
|
+
- **Plain Node.**
|
|
596
|
+
- `run()` threw `xs.create is not a function` under plain Node CommonJS (`require('sygnal')`) and native Node ESM; only bundlers worked.
|
|
597
|
+
- The re-exported xstream extras (`debounce`, `concat`, …) were `{ default }` objects instead of functions there.
|
|
598
|
+
- Both now work, with a test that runs a whole app in each.
|
|
599
|
+
- **State and sinks.**
|
|
600
|
+
- EVENTS and other sinks could see stale state when an action arrived in the same tick as a state change.
|
|
601
|
+
- Two same-tick actions inside a Collection item lost the first update.
|
|
602
|
+
- An `isolatedState` sub-component without a `state` prop replaced its parent's state.
|
|
603
|
+
- Returning `ABORT` from a PARENT, EVENTS, EFFECT or driver sink reported an error instead of sending nothing.
|
|
604
|
+
- **Rendering.**
|
|
605
|
+
- A Collection didn't re-render when its items were only reordered.
|
|
606
|
+
- An element patched from one text child to several children kept the old text.
|
|
607
|
+
- A removed `className` stayed on a reused element.
|
|
608
|
+
- A controlled input wasn't cleared when actions arrived in the same tick.
|
|
609
|
+
- **Collections and disposal.**
|
|
610
|
+
- Nested Collection and Switchable items weren't disposed with their parent item (a leak, and DISPOSE never fired).
|
|
611
|
+
- Two Collections whose items shared ids shared isolation scopes, so events could cross between them.
|
|
612
|
+
- An invalid `from` was reported twice.
|
|
613
|
+
- **DOM events.** `.data('taskId')` failed when the event target was a nested child of the `data-task-id` element. `.data('task-id')` works too.
|
|
614
|
+
- **`driverFromAsync`.** A rejected promise was only logged, so loading UIs hung. A `null` result crashed. Teardown printed a spurious warning.
|
|
615
|
+
- **Testing.**
|
|
616
|
+
- `renderComponent`'s mock DOM lacked the enriched event API (`.value()`, `.data()`).
|
|
617
|
+
- Events sent right after `renderComponent()` were lost.
|
|
618
|
+
- `simulateAction` ignored non-STATE sinks.
|
|
619
|
+
- Switchable didn't render under the mock DOM.
|
|
620
|
+
- `waitForState` could resolve before children re-rendered.
|
|
621
|
+
- `hmrActions` and `components` were ignored.
|
|
622
|
+
- **Vite plugin.**
|
|
623
|
+
- The HMR transform broke test files and other files that call `run()`: it produced invalid code or `__sygnal is not defined`.
|
|
624
|
+
- Under Vite 8, the dependency scanner compiled JSX for React in every Sygnal app.
|
|
625
|
+
- JSX wasn't configured under Vite 7.
|
|
626
|
+
- The diagnostics setup file didn't load under jsdom.
|
|
627
|
+
- **Astro.** `sygnal/astro/client` bundled a second copy of the Sygnal core. Island props were nested on the client but spread on the server. Island roots were named "Wrapped" in diagnostics.
|
|
628
|
+
- **Vike.** `import vikeSygnal from 'sygnal/config'` failed type-checking (no default export).
|
|
629
|
+
- **Devtools.** EVENTS were always attributed to the root component, and devtools stamps broke `toEqual` on sink output.
|
|
630
|
+
- **Types.**
|
|
631
|
+
- `lazy()` components required a `state` prop in JSX.
|
|
632
|
+
- `Suspense`, `getDevTools`, `isolatedState` and `idfield` had no declarations.
|
|
633
|
+
- `npm run build` now bundles the declarations, and `prepublishOnly` runs it. The published 5.0.0–5.1.1 declarations referenced a file missing from the package; 5.1.2–5.3.7 were complete.
|
|
634
|
+
- **Bundle size.** Unused xstream extras no longer ship in every app.
|
|
635
|
+
|
|
636
|
+
### Migration notes
|
|
637
|
+
|
|
638
|
+
No code changes are required. What an existing app may notice:
|
|
639
|
+
|
|
640
|
+
- **New warnings in development.** Under `vite` dev and in tests, the runtime checks and `sygnal-check` report wiring problems as `[Sygnal SYGnnn]` warnings in the terminal and console. They don't change behavior. Run `npx --no-install sygnal-check explain SYGnnn` or see the error reference for each code. To turn them down:
|
|
641
|
+
- Vite plugin: `sygnal({ diagnostics: 'off', check: false, vitestSetup: false })`, or `diagnostics: { ignore: ['SYG105'] }` to drop single codes;
|
|
642
|
+
- `run(App, drivers, { diagnostics: 'off' })` for a running app;
|
|
643
|
+
- `// sygnal-ignore SYG110` on a line for `sygnal-check`.
|
|
644
|
+
- **Tests that relied on a silent no-match.** `simulateEvent` with a selector that matches nothing now throws instead of doing nothing. Fix the selector, or pass `{ allowMissing: true }` where a missing element is expected.
|
|
645
|
+
- **Tests that compare console output or markup.** Messages now start with a code, and `data-sygnal-ready` is gone from ready child components.
|
|
646
|
+
- **Inputs.** If a field relied on a stale `value` (for example, "save on blur" without an input listener), it now resets to state on every render. Keep the draft in state with an input listener.
|