sygnal 5.3.7 → 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 +77 -47
- package/dist/astro/client.cjs.js +33 -8114
- package/dist/astro/client.cjs.js.map +1 -0
- package/dist/astro/client.mjs +33 -8114
- package/dist/astro/client.mjs.map +1 -0
- package/dist/astro/index.cjs.js +1039 -9
- package/dist/astro/index.cjs.js.map +1 -0
- package/dist/astro/index.mjs +1038 -9
- package/dist/astro/index.mjs.map +1 -0
- package/dist/astro/server.cjs.js +3400 -166
- package/dist/astro/server.cjs.js.map +1 -0
- package/dist/astro/server.mjs +3400 -166
- package/dist/astro/server.mjs.map +1 -0
- 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 +4033 -0
- package/dist/diagnostics.cjs.js.map +1 -0
- package/dist/diagnostics.esm.js +4003 -0
- package/dist/diagnostics.esm.js.map +1 -0
- 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 +15283 -6279
- package/dist/index.cjs.js.map +1 -0
- package/dist/index.d.ts +3739 -370
- package/dist/index.esm.js +15230 -6274
- package/dist/index.esm.js.map +1 -0
- package/dist/jsx-dev-runtime.cjs.js +27 -329
- package/dist/jsx-dev-runtime.cjs.js.map +1 -0
- package/dist/jsx-dev-runtime.esm.js +20 -322
- package/dist/jsx-dev-runtime.esm.js.map +1 -0
- package/dist/jsx-runtime.cjs.js +27 -329
- package/dist/jsx-runtime.cjs.js.map +1 -0
- package/dist/jsx-runtime.esm.js +20 -322
- package/dist/jsx-runtime.esm.js.map +1 -0
- package/dist/jsx.cjs.js +11 -309
- package/dist/jsx.cjs.js.map +1 -0
- package/dist/jsx.esm.js +9 -310
- package/dist/jsx.esm.js.map +1 -0
- 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 +2 -1
- package/dist/sygnal.min.js.map +1 -0
- 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 +5 -14
- package/dist/vike/ClientOnly.cjs.js.map +1 -0
- package/dist/vike/ClientOnly.mjs +5 -14
- package/dist/vike/ClientOnly.mjs.map +1 -0
- package/dist/vike/{+config.js → config/+config.js} +10 -1
- package/dist/vike/config/+config.js.map +1 -0
- package/dist/vike/config/package.json +6 -0
- package/dist/vike/onRenderClient.cjs.js +166 -95
- package/dist/vike/onRenderClient.cjs.js.map +1 -0
- package/dist/vike/onRenderClient.mjs +166 -95
- package/dist/vike/onRenderClient.mjs.map +1 -0
- package/dist/vike/onRenderHtml.cjs.js +60 -24
- package/dist/vike/onRenderHtml.cjs.js.map +1 -0
- package/dist/vike/onRenderHtml.mjs +61 -25
- package/dist/vike/onRenderHtml.mjs.map +1 -0
- package/dist/vite/plugin.cjs.js +926 -65
- package/dist/vite/plugin.cjs.js.map +1 -0
- package/dist/vite/plugin.mjs +925 -65
- package/dist/vite/plugin.mjs.map +1 -0
- 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 +313 -0
- package/package.json +109 -16
- package/src/astro/client.ts +40 -18
- package/src/astro/index.d.ts +48 -1
- package/src/astro/index.ts +106 -9
- package/src/astro/server.ts +11 -3
- package/src/collection.ts +6 -90
- 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 +13 -2
- package/src/cycle/dom/PriorityQueue.ts +4 -2
- package/src/cycle/dom/SymbolTree.ts +18 -47
- package/src/cycle/dom/classNameModule.ts +76 -0
- package/src/cycle/dom/controlledInputModule.ts +50 -0
- package/src/cycle/dom/enrichEventStream.ts +29 -45
- package/src/cycle/dom/fragment.ts +7 -0
- package/src/cycle/dom/isolate.ts +30 -27
- package/src/cycle/dom/makeDOMDriver.ts +115 -57
- package/src/cycle/dom/mockDOMSource.ts +83 -9
- package/src/cycle/dom/modules.ts +21 -4
- 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 +20 -4
- 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 +109 -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 +67 -0
- 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 +250 -0
- package/src/extra/diagnostics/checks/elementCommands.ts +175 -0
- package/src/extra/diagnostics/checks/events.ts +83 -0
- 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 +202 -0
- package/src/extra/diagnostics/checks/inspect.ts +427 -0
- 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 +56 -0
- package/src/extra/diagnostics/checks/public.d.ts +302 -0
- 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 +88 -0
- package/src/extra/diagnostics/checks/shared.ts +203 -0
- 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 +148 -0
- package/src/extra/diagnostics/checks/statics.ts +48 -0
- package/src/extra/diagnostics/checks/strict.ts +55 -0
- 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 +121 -0
- package/src/extra/diagnostics/codes.ts +477 -0
- package/src/extra/diagnostics/index.ts +353 -0
- package/src/extra/diagnostics/legacy.ts +76 -0
- package/src/extra/driverFactories.ts +172 -60
- package/src/extra/elementCommands.ts +53 -0
- package/src/extra/eventDriver.ts +4 -0
- package/src/extra/fetchDriver.ts +519 -0
- package/src/extra/flatten.ts +75 -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 +13 -6
- 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 +94 -210
- 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 +3158 -190
- 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/extra/xstreamExtras.ts +269 -0
- package/src/index.d.ts +3012 -87
- package/src/index.ts +32 -10
- 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 +262 -134
- 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 -119
- 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 +27 -4
- package/src/vite/globalthis-shim.ts +18 -0
- package/src/vite/globalthis.ts +56 -0
- package/src/vite/plugin.d.ts +114 -2
- package/src/vite/plugin.ts +920 -56
- package/src/zag.d.ts +64 -0
- package/src/zag.ts +238 -0
- package/dist/astro/astro/client.d.ts +0 -5
- package/dist/astro/astro/index.d.ts +0 -16
- package/dist/astro/astro/server.d.ts +0 -12
- package/dist/astro/client.d.ts +0 -5
- package/dist/astro/client.esm.js +0 -4665
- package/dist/astro/collection.d.ts +0 -12
- package/dist/astro/component.d.ts +0 -22
- package/dist/astro/cycle/dom/BodyDOMSource.d.ts +0 -10
- package/dist/astro/cycle/dom/DOMSource.d.ts +0 -11
- package/dist/astro/cycle/dom/DocumentDOMSource.d.ts +0 -12
- package/dist/astro/cycle/dom/ElementFinder.d.ts +0 -8
- package/dist/astro/cycle/dom/EventDelegator.d.ts +0 -34
- package/dist/astro/cycle/dom/IsolateModule.d.ts +0 -23
- package/dist/astro/cycle/dom/MainDOMSource.d.ts +0 -32
- package/dist/astro/cycle/dom/PriorityQueue.d.ts +0 -7
- package/dist/astro/cycle/dom/ScopeChecker.d.ts +0 -9
- package/dist/astro/cycle/dom/SymbolTree.d.ts +0 -9
- package/dist/astro/cycle/dom/VNodeWrapper.d.ts +0 -8
- package/dist/astro/cycle/dom/enrichEventStream.d.ts +0 -24
- package/dist/astro/cycle/dom/fromEvent.d.ts +0 -6
- package/dist/astro/cycle/dom/index.d.ts +0 -8
- package/dist/astro/cycle/dom/isolate.d.ts +0 -9
- package/dist/astro/cycle/dom/makeDOMDriver.d.ts +0 -11
- package/dist/astro/cycle/dom/mockDOMSource.d.ts +0 -17
- package/dist/astro/cycle/dom/modules.d.ts +0 -5
- package/dist/astro/cycle/dom/snabbdom.d.ts +0 -22
- package/dist/astro/cycle/dom/styleModule.d.ts +0 -13
- package/dist/astro/cycle/dom/thunk.d.ts +0 -11
- package/dist/astro/cycle/dom/utils.d.ts +0 -8
- package/dist/astro/cycle/isolate/index.d.ts +0 -42
- package/dist/astro/cycle/run/adapt.d.ts +0 -6
- package/dist/astro/cycle/run/index.d.ts +0 -36
- package/dist/astro/cycle/run/internals.d.ts +0 -8
- package/dist/astro/cycle/run/types.d.ts +0 -49
- package/dist/astro/cycle/state/Collection.d.ts +0 -29
- package/dist/astro/cycle/state/StateSource.d.ts +0 -20
- package/dist/astro/cycle/state/index.d.ts +0 -5
- package/dist/astro/cycle/state/pickCombine.d.ts +0 -3
- package/dist/astro/cycle/state/pickMerge.d.ts +0 -3
- package/dist/astro/cycle/state/types.d.ts +0 -18
- package/dist/astro/cycle/state/withState.d.ts +0 -17
- package/dist/astro/extra/classes.d.ts +0 -7
- package/dist/astro/extra/devtools.d.ts +0 -78
- package/dist/astro/extra/dragDriver.d.ts +0 -20
- package/dist/astro/extra/driverFactories.d.ts +0 -12
- package/dist/astro/extra/eventDriver.d.ts +0 -9
- package/dist/astro/extra/exactState.d.ts +0 -1
- package/dist/astro/extra/hmr.d.ts +0 -17
- package/dist/astro/extra/logDriver.d.ts +0 -2
- package/dist/astro/extra/processDrag.d.ts +0 -19
- package/dist/astro/extra/processForm.d.ts +0 -15
- package/dist/astro/extra/run.d.ts +0 -13
- package/dist/astro/extra/xstreamCompat.d.ts +0 -5
- package/dist/astro/index.d.ts +0 -19
- package/dist/astro/index.esm.js +0 -27
- package/dist/astro/jsx-dev-runtime.d.ts +0 -1
- package/dist/astro/jsx-runtime.d.ts +0 -4
- package/dist/astro/jsx.d.ts +0 -2
- package/dist/astro/pragma/fn.d.ts +0 -7
- package/dist/astro/pragma/index.d.ts +0 -7
- package/dist/astro/pragma/is.d.ts +0 -10
- package/dist/astro/server.d.ts +0 -12
- package/dist/astro/server.esm.js +0 -25
- package/dist/astro/switchable.d.ts +0 -7
- package/dist/collection.d.ts +0 -12
- package/dist/component.d.ts +0 -22
- package/dist/cycle/dom/BodyDOMSource.d.ts +0 -10
- package/dist/cycle/dom/DOMSource.d.ts +0 -11
- package/dist/cycle/dom/DocumentDOMSource.d.ts +0 -12
- package/dist/cycle/dom/ElementFinder.d.ts +0 -8
- package/dist/cycle/dom/EventDelegator.d.ts +0 -34
- package/dist/cycle/dom/IsolateModule.d.ts +0 -23
- package/dist/cycle/dom/MainDOMSource.d.ts +0 -32
- package/dist/cycle/dom/PriorityQueue.d.ts +0 -7
- package/dist/cycle/dom/ScopeChecker.d.ts +0 -9
- package/dist/cycle/dom/SymbolTree.d.ts +0 -9
- package/dist/cycle/dom/VNodeWrapper.d.ts +0 -8
- package/dist/cycle/dom/enrichEventStream.d.ts +0 -24
- package/dist/cycle/dom/fromEvent.d.ts +0 -6
- package/dist/cycle/dom/index.d.ts +0 -8
- package/dist/cycle/dom/isolate.d.ts +0 -9
- package/dist/cycle/dom/makeDOMDriver.d.ts +0 -11
- package/dist/cycle/dom/mockDOMSource.d.ts +0 -17
- package/dist/cycle/dom/modules.d.ts +0 -5
- package/dist/cycle/dom/snabbdom.d.ts +0 -22
- package/dist/cycle/dom/styleModule.d.ts +0 -13
- package/dist/cycle/dom/thunk.d.ts +0 -11
- package/dist/cycle/dom/utils.d.ts +0 -8
- package/dist/cycle/isolate/index.d.ts +0 -42
- package/dist/cycle/run/adapt.d.ts +0 -6
- package/dist/cycle/run/index.d.ts +0 -36
- package/dist/cycle/run/internals.d.ts +0 -8
- package/dist/cycle/run/types.d.ts +0 -49
- package/dist/cycle/state/Collection.d.ts +0 -29
- package/dist/cycle/state/StateSource.d.ts +0 -20
- package/dist/cycle/state/index.d.ts +0 -5
- package/dist/cycle/state/pickCombine.d.ts +0 -3
- package/dist/cycle/state/pickMerge.d.ts +0 -3
- package/dist/cycle/state/types.d.ts +0 -18
- package/dist/cycle/state/withState.d.ts +0 -17
- package/dist/extra/classes.d.ts +0 -7
- package/dist/extra/devtools.d.ts +0 -78
- package/dist/extra/dragDriver.d.ts +0 -20
- package/dist/extra/driverFactories.d.ts +0 -12
- package/dist/extra/eventDriver.d.ts +0 -9
- package/dist/extra/exactState.d.ts +0 -1
- package/dist/extra/hmr.d.ts +0 -17
- package/dist/extra/logDriver.d.ts +0 -2
- package/dist/extra/processDrag.d.ts +0 -19
- package/dist/extra/processForm.d.ts +0 -15
- package/dist/extra/run.d.ts +0 -13
- package/dist/extra/xstreamCompat.d.ts +0 -5
- package/dist/jsx-dev-runtime.d.ts +0 -1
- package/dist/jsx-dev-runtime.js +0 -224
- package/dist/jsx-runtime.d.ts +0 -4
- package/dist/jsx-runtime.js +0 -224
- package/dist/jsx.d.ts +0 -2
- package/dist/pragma/fn.d.ts +0 -7
- package/dist/pragma/index.d.ts +0 -7
- package/dist/pragma/is.d.ts +0 -10
- package/dist/switchable.d.ts +0 -7
- package/dist/vike/+config.cjs.js +0 -65
- package/src/component.ts +0 -2256
- 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 -174
- package/src/cycle/state/index.ts +0 -5
- package/src/cycle/state/pickCombine.ts +0 -167
- package/src/cycle/state/pickMerge.ts +0 -122
- package/src/cycle/state/withState.ts +0 -47
- package/src/pragma/fn.ts +0 -55
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
<!-- Generated from docs/src/content/docs/guide by scripts/copy-guides.mjs; online: https://sygnal.js.org/guide/ -->
|
|
2
|
+
# Forms
|
|
3
|
+
|
|
4
|
+
A form with validation is one [behavior](./behaviors.md): `form(schema, options)` in the component's `uses`. It keeps the values, errors and touched fields in `state.form`, validates with any [Standard Schema](https://standardschema.dev) validator (zod, valibot, arktype, or your own object), and handles the submit. Sygnal has no validator dependency.
|
|
5
|
+
|
|
6
|
+
This page is the recipe and what you need around it. The [Forms reference](./forms-reference.md) has the full slice and action tables, async checks (is this email taken?), two forms in one component, rows as components, resetting, the helpers for a form without the behavior, and `processForm()`. Controlled inputs, `uid()` and `autoFocus` are on [Inputs, Labels and Focus](./inputs.md).
|
|
7
|
+
|
|
8
|
+
## The recipe
|
|
9
|
+
|
|
10
|
+
In this demo, a stand-in server answers the request: `taken@example.com` is already registered, any other address gets an account.
|
|
11
|
+
|
|
12
|
+
```js live-server
|
|
13
|
+
let nextId = 1
|
|
14
|
+
|
|
15
|
+
export default {
|
|
16
|
+
'POST /api/signup': ({ json }) => json.email === 'taken@example.com'
|
|
17
|
+
? { status: 422, json: { errors: { email: 'Already registered' } } }
|
|
18
|
+
: { status: 201, json: { id: nextId++ } },
|
|
19
|
+
}
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Validation, a list of rows the user adds and removes, the submit to the server, the pending button, and the server's errors:
|
|
23
|
+
|
|
24
|
+
```jsx live
|
|
25
|
+
import { form } from 'sygnal'
|
|
26
|
+
import { z } from 'zod'
|
|
27
|
+
|
|
28
|
+
const signupSchema = z.object({
|
|
29
|
+
name: z.string().trim().min(1, 'Enter your name'),
|
|
30
|
+
email: z.string().trim().toLowerCase().email('Enter a valid email address'),
|
|
31
|
+
addresses: z.array(z.object({ city: z.string().trim().min(1, 'Enter a city') })).min(1, 'Add at least one address'),
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
function Signup({ state, uid }) {
|
|
35
|
+
const f = state.form.fields
|
|
36
|
+
const rows = state.form.values.addresses
|
|
37
|
+
return (
|
|
38
|
+
<form className="signup" noValidate>
|
|
39
|
+
<label for={uid('name')}>Name</label>
|
|
40
|
+
<input id={uid('name')} name="name" value={f.name.value} aria-invalid={f.name.invalid} aria-describedby={uid('name-error')} />
|
|
41
|
+
<p id={uid('name-error')}>{f.name.error}</p>
|
|
42
|
+
|
|
43
|
+
<label for={uid('email')}>Email</label>
|
|
44
|
+
<input id={uid('email')} name="email" type="email" value={f.email.value} aria-invalid={f.email.invalid} aria-describedby={uid('email-error')} />
|
|
45
|
+
<p id={uid('email-error')}>{f.email.error}</p>
|
|
46
|
+
|
|
47
|
+
{rows.map((row, i) => {
|
|
48
|
+
const city = f[`addresses.${row.id}.city`]
|
|
49
|
+
return (
|
|
50
|
+
<fieldset className="address">
|
|
51
|
+
<legend>Address {i + 1}</legend>
|
|
52
|
+
<label for={uid(`city-${row.id}`)}>City</label>
|
|
53
|
+
<input id={uid(`city-${row.id}`)} name={city.name} value={city.value} aria-invalid={city.invalid} aria-describedby={uid(`city-${row.id}-error`)} />
|
|
54
|
+
<p id={uid(`city-${row.id}-error`)}>{city.error}</p>
|
|
55
|
+
<button type="button" className="remove" data-id={row.id} disabled={rows.length === 1}>Remove</button>
|
|
56
|
+
</fieldset>
|
|
57
|
+
)
|
|
58
|
+
})}
|
|
59
|
+
<p>{f.addresses.error}</p>
|
|
60
|
+
<button type="button" className="add">Add address</button>
|
|
61
|
+
|
|
62
|
+
<p role="alert">{state.form.error}</p>
|
|
63
|
+
<button type="submit" disabled={state.form.submitting}>{state.form.submitting ? 'Signing up…' : 'Sign up'}</button>
|
|
64
|
+
<p className="done">{state.accountId ? `Account ${state.accountId} created.` : ''}</p>
|
|
65
|
+
</form>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
Signup.initialState = { accountId: null }
|
|
70
|
+
|
|
71
|
+
Signup.uses = {
|
|
72
|
+
form: form(signupSchema, { values: { name: '', email: '', addresses: [{ id: 1, city: '' }] }, submit: 'SIGN_UP' }),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
Signup.intent = ({ DOM }) => ({
|
|
76
|
+
'form.ADD': DOM.click('.add').mapTo({ field: 'addresses', value: { city: '' } }),
|
|
77
|
+
'form.REMOVE': DOM.click('.remove').map((e) => ({ field: 'addresses', id: e.target.dataset.id })),
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
Signup.model = {
|
|
81
|
+
SIGN_UP: { HTTP: (state, values) => ({ url: '/api/signup', method: 'POST', json: values, ok: 'form.DONE', error: 'form.ERRORS' }) },
|
|
82
|
+
'form.DONE': (state, account) => ({ ...state, accountId: account.id }),
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`main.js` runs it with `run(Signup, { HTTP: makeFetchDriver() })`. There is no intent per field: the two intent entries are the row buttons.
|
|
87
|
+
|
|
88
|
+
## What the form does
|
|
89
|
+
|
|
90
|
+
- **Fields are matched by `name`.** The behavior listens for `input`, `focusout` and `submit` on the form element (option `form`, default `'form'`), so every field inside it whose `name` is a path in `values` is controlled: `value={f.email.value}` stays in sync as the user types. Array rows are named by their `id`: `addresses.7.city`.
|
|
91
|
+
- **`state.form.fields[name]`** is what the view needs for each field: `value`, `error` (the message to show, `''` for none), `invalid` (`!!error`, for `aria-invalid`), `touched`, `dirty` and `pending` (an [async check](./forms-reference.md#async-checks) is running), plus `name`.
|
|
92
|
+
- **When errors show**: every change is validated, but a field's schema error shows once the field has lost focus (`show: 'blur'`, the default), or while typing (`show: 'input'`), or only after a submit (`show: 'submit'`). After the first submit every error shows, and each follows the typing. Server errors show at once.
|
|
93
|
+
- **An invalid submit** shows every error, focuses the first invalid field in page order (rows included) and sends nothing.
|
|
94
|
+
- **A valid submit** dispatches the `submit` action (`SIGN_UP`) with the schema's output: the trimmed, transformed values (`email` lower-cased here; the rows without their `id`, because `z.object` strips keys it doesn't declare), not the raw ones. Your model entry for it sends the request, with `ok: 'form.DONE'` and `error: 'form.ERRORS'` as its reply actions.
|
|
95
|
+
- **Pending**: when the submit entry sends a request (an `HTTP` sink), `state.form.submitting` is `true` from the valid submit until `form.DONE` or `form.ERRORS` arrives. Use it for the button's text and `disabled`. A second submit meanwhile is dropped, so a double click sends once.
|
|
96
|
+
- **`form.DONE`** marks the submit saved: `submitting` turns off, `submitted` on, and the saved values become the new start values (`dirty` is `false` again). A host model entry with the same name runs after the form's and gets the reply body: `'form.DONE': (state, account) => …` above shows the new account's id.
|
|
97
|
+
- **Labels and errors**: each field has a label and its error text is linked with `aria-describedby`, with ids from [`uid()`](./inputs.md#labels-and-ids-uid) (a row's ids include its `id`, so they stay unique), so the form passes the [accessibility checks](./accessibility.md). `aria-invalid={f.email.invalid}` renders `"true"` or `"false"`.
|
|
98
|
+
|
|
99
|
+
Reserve the height of the error lines in your CSS (`min-height`). A field's error appears when it loses focus, which happens on the mouse*down* of a click elsewhere: if the new line pushes the button down before the mouse*up*, the click is lost.
|
|
100
|
+
|
|
101
|
+
```css live
|
|
102
|
+
.signup p { min-height: 1.5em; }
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Submit without a request
|
|
106
|
+
|
|
107
|
+
A submit that sends nothing (a wizard step's "Next", a dialog's "OK") is an entry with `STATE`, `PARENT` or `EVENTS` and no `HTTP`. It is done at once: `submitting` stays `false`, `submitted` turns on, and you send no `form.DONE`.
|
|
108
|
+
|
|
109
|
+
```jsx
|
|
110
|
+
AccountStep.uses = { form: form(accountSchema, { values: { email: '', password: '' }, submit: 'NEXT' }) }
|
|
111
|
+
AccountStep.model = {
|
|
112
|
+
NEXT: { PARENT: (state, values) => ({ type: 'NEXT', email: values.email }) },
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**Wizards**: each step is a child with a `state` prop (`<AccountStep state="account" />`); the parent switches steps on its `PARENT` message. The step's `form` slice lives in the parent's state, so after "Back" the step mounts with its values kept (and `submitting` off, even if it unmounted mid-request). Make "Next" a `type="submit"` button inside the `<form>`; in a test, simulate `'submit'` on the form (the mock DOM doesn't turn a click into a submit).
|
|
117
|
+
|
|
118
|
+
## Start empty on each visit
|
|
119
|
+
|
|
120
|
+
The values live in state, so they survive a visit (a [Switchable](https://sygnal.js.org/guide/switchable/) page shares its parent's state and stays alive while hidden). For a form that starts empty each time it is shown, set `resetOnShow: true`; a start value from state goes in a `values` function:
|
|
121
|
+
|
|
122
|
+
```jsx
|
|
123
|
+
NewExpensePage.uses = {
|
|
124
|
+
form: form(expenseSchema, {
|
|
125
|
+
values: (state) => ({ ...EMPTY, category: state.settings.defaultCategory }),
|
|
126
|
+
submit: 'SAVE',
|
|
127
|
+
resetOnShow: true,
|
|
128
|
+
}),
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Typing while it is shown is kept; a wizard step leaves it off. [The rule](./forms-reference.md#saving-and-resetting).
|
|
133
|
+
|
|
134
|
+
## Server errors and failed submits
|
|
135
|
+
|
|
136
|
+
The server has the last word. With `error: 'form.ERRORS'` on the request, a failed reply goes to the form, which turns `submitting` off, puts the reply's errors on their fields, shows them at once and focuses the first one in page order. A `422` reply's body can be either shape:
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{ "errors": { "password": "Too common", "addresses.1.city": "Unknown city" } }
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
```json
|
|
143
|
+
{ "errors": [{ "path": ["addresses", 0, "city"], "message": "Unknown city" }, { "path": ["email"], "message": "Already registered" }] }
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
- A map from field name to a message (or a list of messages: the first shows), or a list of `{ path, message }` issues. In an issue path, a number is the row's position in the array the server got, and it becomes that row's `id` (`addresses.0.city` names the first row's city, whatever its `id`). In a map, a name is a field name, so rows go by `id`.
|
|
147
|
+
- `form.ERRORS` also accepts the map or the list itself (`t.simulateAction('form.ERRORS', { email: 'Taken' })`).
|
|
148
|
+
- A message for a name that isn't a field (`{ "message": "Down for maintenance" }`) becomes the form-level `state.form.error`. A reply with no message at all (a `500` with an empty body, a network error) shows `"Request failed (500)"` there, or `"Request failed"` without a status. The recipe renders it in `<p role="alert">`.
|
|
149
|
+
- Editing a field clears its server error, and the form-level one.
|
|
150
|
+
|
|
151
|
+
For your own text on failures other than a `422`, add a host entry: it runs after the form's and gets the [error reply](./http.md) `{ error, status, body, request }` (`status` is `undefined` for a network error):
|
|
152
|
+
|
|
153
|
+
```js
|
|
154
|
+
Checkout.model = {
|
|
155
|
+
'form.SUBMIT': (state) => ({ ...state, failed: false }),
|
|
156
|
+
'form.ERRORS': (state, reply) => ({ ...state, failed: reply.status !== 422 }),
|
|
157
|
+
}
|
|
158
|
+
// view: <p role="alert">{state.failed ? 'Could not place the order. Try again.' : ''}</p>
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## Field arrays
|
|
162
|
+
|
|
163
|
+
Rows of an array of objects are named by their `id`, not their position, so a row's errors, touched state and focus stay with it when another row is removed. Render the rows inline with `.map()`, as in the recipe:
|
|
164
|
+
|
|
165
|
+
- **Names**: `name={f[`addresses.${row.id}.city`].name}`. Each row needs an `id` in the start values ([SYG236](https://sygnal.js.org/reference/errors/#syg236)).
|
|
166
|
+
- **Add**: `form.ADD` with `{ field, value }` appends `{ id, ...value }` with the next free id: `'form.ADD': DOM.click('.add').mapTo({ field: 'addresses', value: { city: '' } })`.
|
|
167
|
+
- **Remove**: `form.REMOVE` with `{ field, id }` removes that row (the id can be the string from `data-id`): `'form.REMOVE': DOM.click('.remove').map((e) => ({ field: 'addresses', id: e.target.dataset.id }))`.
|
|
168
|
+
- **Numbering**: the row's position is the map's index (`Address {i + 1}`); it renumbers after a remove, while the row's `id`, name and ids stay.
|
|
169
|
+
- **The array itself is a field** (`f.addresses`), for an array-level error such as "Add at least one address" (`z.array(…).min(1, …)`).
|
|
170
|
+
|
|
171
|
+
No child component is needed for a row. When a row is big enough to be a component of its own, see [rows as components](./forms-reference.md#rows-as-components).
|
|
172
|
+
|
|
173
|
+
## Field types
|
|
174
|
+
|
|
175
|
+
What `state.form.values[name]` gets from each kind of field, and how to bind it:
|
|
176
|
+
|
|
177
|
+
| Field | Value | Bind |
|
|
178
|
+
|---|---|---|
|
|
179
|
+
| `<input>` (text, email, password, search, tel, url), `<textarea>` | The text | `value={f.email.value}` |
|
|
180
|
+
| `<input type="number">`, `range`, `date`, `time` | The text as the browser gives it (`'42'`, `'2026-10-05'`; `''` when empty) | `value={f.age.value}`; convert in the schema: `z.coerce.number()`, `v.pipe(v.string(), v.transform(Number), v.number())` |
|
|
181
|
+
| `<input type="radio">` (same `name`) | The checked radio's `value` | `checked={f.size.value === 'm'}` |
|
|
182
|
+
| `<input type="checkbox">` on a boolean | `checked` | `checked={f.agree.value}` |
|
|
183
|
+
| Checkboxes sharing a `name` on an array | The array of the checked boxes' `value`s (checking adds it, unchecking removes it) | `values: { tags: [] }`, `<input type="checkbox" name="tags" value="news" checked={f.tags.value.includes('news')} />` |
|
|
184
|
+
| `<select>` | The selected option's `value` | `value={f.country.value}` |
|
|
185
|
+
| `<select multiple>` | The array of selected `value`s | `selected={f.colors.value.includes('red')}` on each `<option>` |
|
|
186
|
+
| A form-associated custom element (`<wa-input>`, `<wa-select>`) | Its `value` | `value={f.nick.value}` |
|
|
187
|
+
| A form-associated custom checkbox or switch (`<wa-checkbox>`, `<wa-switch>`: a hyphenated tag with a boolean `checked`) | `checked` (as a group on an array, like checkboxes) | `checked={f.news.value}` |
|
|
188
|
+
| `<input type="file">` | Not handled: the form ignores it | Leave `value` unbound; read `e.target.files` in your own intent and keep the files out of `values` |
|
|
189
|
+
|
|
190
|
+
## Options
|
|
191
|
+
|
|
192
|
+
| Option | |
|
|
193
|
+
|---|---|
|
|
194
|
+
| `values` | The start values (or a function of the host's state). Field names are paths in it: `email`, `address.city`, and `addresses.7.city` for the row with `id` 7 |
|
|
195
|
+
| `submit` | The host action a valid submit dispatches with the schema's output ([SYG234](https://sygnal.js.org/reference/errors/#syg234) when the model has no such entry). An entry with an `HTTP` sink keeps the submit pending until its reply; any other is done at once ([submit without a request](#submit-without-a-request)) |
|
|
196
|
+
| `show` | `'blur'` (default), `'input'` or `'submit'`: when a schema error shows |
|
|
197
|
+
| `form` | The form element's selector, default `'form'`. Give each form its own when a component has two: `form: '.login'` and `form: '.news'` ([two forms](./forms-reference.md#two-forms-in-one-component), [SYG237](https://sygnal.js.org/reference/errors/#syg237)) |
|
|
198
|
+
| `check` | [Async checks](./forms-reference.md#async-checks) by field name |
|
|
199
|
+
| `http` | The driver sink of the requests, default `'HTTP'`: the checks' requests go to it, and a submit entry with this sink waits for its reply |
|
|
200
|
+
| `resetOnShow` | `true`: start over each time the form is shown ([start empty on each visit](#start-empty-on-each-visit)) |
|
|
201
|
+
|
|
202
|
+
The form's actions are named after the `uses` key (`form.ADD` for `uses = { form: … }`): `form.CHANGE`, `form.BLUR`, `form.SUBMIT`, `form.ADD`, `form.REMOVE`, `form.ERRORS`, `form.DONE` and `form.RESET` ([the actions](./forms-reference.md#the-actions)). A host model entry with one of those names runs after the form's, on the full state; trigger one from elsewhere with an intent action of that name, or `t.simulateAction('form.RESET')` in a test. Don't name `submit` after one ([SYG234](https://sygnal.js.org/reference/errors/#syg234)).
|
|
203
|
+
|
|
204
|
+
## Testing
|
|
205
|
+
|
|
206
|
+
Simulate events on the fields by `name`, answer the requests, and read `t.state.form`:
|
|
207
|
+
|
|
208
|
+
```jsx
|
|
209
|
+
import { renderComponent } from 'sygnal'
|
|
210
|
+
import { it, expect } from 'vitest'
|
|
211
|
+
import Signup from './Signup.jsx'
|
|
212
|
+
|
|
213
|
+
it('validates, adds a row, posts the cleaned values and shows the server errors', async () => {
|
|
214
|
+
const t = renderComponent(Signup, { strict: true })
|
|
215
|
+
await t.ready()
|
|
216
|
+
t.simulateEvent('[name="email"]', 'input', { value: 'nope' })
|
|
217
|
+
t.simulateEvent('[name="email"]', 'focusout')
|
|
218
|
+
await t.settle()
|
|
219
|
+
expect(t.state.form.fields.email.error).toBe('Enter a valid email address')
|
|
220
|
+
|
|
221
|
+
t.simulateEvent('[name="name"]', 'input', { value: ' Ada ' })
|
|
222
|
+
t.simulateEvent('[name="email"]', 'input', { value: 'Ada@Example.com' })
|
|
223
|
+
t.simulateEvent('[name="addresses.1.city"]', 'input', { value: 'London' })
|
|
224
|
+
t.simulateEvent('.add', 'click')
|
|
225
|
+
t.simulateEvent('[name="addresses.2.city"]', 'input', { value: 'Paris' })
|
|
226
|
+
t.simulateEvent('.signup', 'submit')
|
|
227
|
+
await t.settle()
|
|
228
|
+
expect(t.state.form.submitting).toBe(true)
|
|
229
|
+
expect(t.requests('HTTP').at(-1)).toMatchObject({ url: '/api/signup', json: { name: 'Ada', email: 'ada@example.com', addresses: [{ city: 'London' }, { city: 'Paris' }] } })
|
|
230
|
+
|
|
231
|
+
await t.fail('HTTP', { status: 422, body: { errors: [{ path: ['addresses', 1, 'city'], message: 'Unknown city' }] } })
|
|
232
|
+
expect(t.state.form.fields['addresses.2.city'].error).toBe('Unknown city')
|
|
233
|
+
expect(t.state.form.submitting).toBe(false)
|
|
234
|
+
t.expectNoDiagnostics()
|
|
235
|
+
})
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
- `simulateEvent` takes a CSS selector (`'[name="email"]'`, `'.remove[data-id="2"]'`), never an element from `t.query()`.
|
|
239
|
+
- `await t.respond('HTTP', body)` and `await t.fail('HTTP', { status, body })` answer the newest request the component has sent (check `t.requests('HTTP')` first) and resolve once the reply is reduced and rendered: read `t.state` right after them. `t.next(pred)` would wait for a further state and time out.
|
|
240
|
+
- `t.state.form.fields[name]` is what the view shows; `t.query('[name="email"]').getAttribute('aria-invalid')` checks the markup.
|
|
241
|
+
- The focus on a failed submit is an [element command](./element-commands.md): `t.commands('ELEMENT').at(-1).focus.within` is the selector of the invalid fields on the default mock DOM; with `renderComponent(Signup, { dom: 'real' })` the field is focused (`document.activeElement`).
|
|
242
|
+
|
|
243
|
+
## More
|
|
244
|
+
|
|
245
|
+
On the [Forms reference](./forms-reference.md):
|
|
246
|
+
|
|
247
|
+
- [the slice](./forms-reference.md#the-slice) (`values`, `initial`, `errors`, `touched`, `server`, `submitCount`, `queued`, `validating`, `valid`, `dirty`…) and [the actions](./forms-reference.md#the-actions) with their data;
|
|
248
|
+
- [any Standard Schema](./forms-reference.md#any-standard-schema): a hand-written schema, async schemas;
|
|
249
|
+
- [async checks](./forms-reference.md#async-checks) on blur, [two forms in one component](./forms-reference.md#two-forms-in-one-component), [rows as components](./forms-reference.md#rows-as-components), [saving and resetting](./forms-reference.md#saving-and-resetting);
|
|
250
|
+
- [the helpers](./forms-reference.md#without-the-behavior-the-helpers) for a form without the behavior, the [diagnostics](./forms-reference.md#diagnostics), and [`processForm()`](./forms-reference.md#processform) for a form without validation.
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
<!-- Generated from docs/src/content/docs/guide by scripts/copy-guides.mjs; online: https://sygnal.js.org/guide/ -->
|
|
2
|
+
# HTTP
|
|
3
|
+
|
|
4
|
+
For HTTP, use the built-in fetch driver instead of calling `fetch` in a component. Register it once in `main.js`:
|
|
5
|
+
|
|
6
|
+
```javascript
|
|
7
|
+
import { run, makeFetchDriver } from 'sygnal'
|
|
8
|
+
import App from './App.jsx'
|
|
9
|
+
|
|
10
|
+
run(App, { HTTP: makeFetchDriver() })
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
A model entry sends a request to the `HTTP` sink and names the actions that receive the reply: `ok` for a 2xx response, `error` for everything else. The intent has no line for the reply.
|
|
14
|
+
|
|
15
|
+
In this demo, a stand-in server answers: quote 1 slowly, and quote 2 with a 404.
|
|
16
|
+
|
|
17
|
+
```js live-server
|
|
18
|
+
const quotes = { 1: { id: 1, text: 'Start where you are.' } }
|
|
19
|
+
|
|
20
|
+
export default {
|
|
21
|
+
'GET /api/quotes/:id': ({ params }) => (quotes[params.id]
|
|
22
|
+
? { json: quotes[params.id], delayMs: 1500 }
|
|
23
|
+
: { status: 404, json: { message: 'No such quote' }, delayMs: 400 }),
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```jsx live
|
|
28
|
+
import { ABORT } from 'sygnal'
|
|
29
|
+
|
|
30
|
+
function Quote({ state }) {
|
|
31
|
+
return (
|
|
32
|
+
<div className="quote">
|
|
33
|
+
<button className="pick" data={{ id: 1 }}>First</button>
|
|
34
|
+
<button className="pick" data={{ id: 2 }}>Second</button>
|
|
35
|
+
<button className="refresh">Refresh</button>
|
|
36
|
+
<p className="text">{state.status === 'loading' ? 'Loading…' : state.error || state.quote?.text}</p>
|
|
37
|
+
</div>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
Quote.initialState = { id: null, status: 'idle', quote: null, error: '' }
|
|
41
|
+
Quote.intent = ({ DOM }) => ({
|
|
42
|
+
SHOW: DOM.click('.pick').data('id', Number),
|
|
43
|
+
REFRESH: DOM.click('.refresh'),
|
|
44
|
+
})
|
|
45
|
+
Quote.model = {
|
|
46
|
+
SHOW: {
|
|
47
|
+
STATE: (state, id) => ({ ...state, id, status: 'loading', error: '' }),
|
|
48
|
+
HTTP: (state, id) => ({ url: `/api/quotes/${id}`, ok: 'LOADED', error: 'FAILED', latest: true }), // id from data: state.id is still the old one here
|
|
49
|
+
},
|
|
50
|
+
REFRESH: {
|
|
51
|
+
STATE: (state) => (state.id === null ? ABORT : { ...state, status: 'loading', error: '' }),
|
|
52
|
+
HTTP: (state) => (state.id === null ? ABORT : { url: `/api/quotes/${state.id}`, ok: 'LOADED', error: 'FAILED', latest: true }),
|
|
53
|
+
},
|
|
54
|
+
LOADED: (state, quote) => ({ ...state, status: 'done', quote }), // the parsed body
|
|
55
|
+
FAILED: (state, { status }) => ({ ...state, status: 'error', error: status === 404 ? 'No such quote.' : 'Could not load the quote.' }),
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Click First, then Second before quote 1 arrives: with `latest: true` the first request is aborted (see [Only the Latest Response](#only-the-latest-response)), and the 404 goes to `FAILED`.
|
|
60
|
+
|
|
61
|
+
- The **`ok` action** gets the parsed body: JSON when the response says so, otherwise text (see `parse` below).
|
|
62
|
+
- The **`error` action** gets `{ error, status, body, request }`. For a non-2xx response, `status` and the parsed `body` are set and `error.message` reads like `'HTTP 404 Not Found: /api/quotes/9'`. For a network error, a body that doesn't parse, a timeout (`error.name === 'TimeoutError'`) or no `fetch`, `status` is `undefined`. `request` is the request as the model sent it.
|
|
63
|
+
- The reply goes to **exactly the component instance that sent the request**. Two `<Quote>` components, or every item of a Collection, can use the same action names without seeing each other's replies, and need no request ids.
|
|
64
|
+
- If the instance is removed (a Collection item deleted, a page left), its requests are aborted and nothing arrives.
|
|
65
|
+
- A name with no model entry is reported as [SYG112](https://sygnal.js.org/reference/errors/#syg112), with the closest model key. A request with a `then` or `catch` key is not sent ([SYG610](https://sygnal.js.org/reference/errors/#syg610)): use `ok` and `error`.
|
|
66
|
+
|
|
67
|
+
### Build the request from (state, data)
|
|
68
|
+
|
|
69
|
+
Every sink of one action sees the state from **before** that action ([Model](https://sygnal.js.org/guide/model/#sinks-see-the-state-before-the-action)). In `SHOW` above, `STATE` sets `id`, but the `HTTP` sink still sees the previous `state.id`, so it builds the URL from `data`. `REFRESH` doesn't change `id`, so it can read it from `state`.
|
|
70
|
+
|
|
71
|
+
## Only the Latest Response
|
|
72
|
+
|
|
73
|
+
Responses arrive in the order the server answers, not the order the requests were sent. With `latest: true`, sending a request aborts this instance's earlier requests **with the same key** that are still in flight, and their replies never arrive. The abort happens when the newer request is sent: a reply that lands before that still arrives. For example, with a debounce in front of the request, an older reply that comes back while the user is still typing (the newer request isn't sent yet) is delivered, so a reducer that must ignore it needs its own check, such as comparing the reply with the current input. The key is the `ok` action (else the `error` action), or an explicit `key`. A search box needs no request ids and no stale checks. In this demo, a stand-in server searches a few book titles, taking most of a second:
|
|
74
|
+
|
|
75
|
+
```js live-server
|
|
76
|
+
const books = ['Dune', 'Dune Messiah', 'Children of Dune', 'Foundation', 'Solaris']
|
|
77
|
+
|
|
78
|
+
export default {
|
|
79
|
+
'GET /api/search': ({ query }) => {
|
|
80
|
+
const q = query.q.toLowerCase()
|
|
81
|
+
const results = books.filter(b => b.toLowerCase().includes(q)).map(title => ({ title }))
|
|
82
|
+
return { json: { results }, delayMs: 900 }
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```jsx live
|
|
88
|
+
import { ABORT, debounce } from 'sygnal'
|
|
89
|
+
|
|
90
|
+
function Search({ state }) {
|
|
91
|
+
return <div><input className="q" aria-label="Search" value={state.query} /><p className="status">{state.status}</p><ul>{state.results.map(r => <li>{r.title}</li>)}</ul></div>
|
|
92
|
+
}
|
|
93
|
+
Search.initialState = { query: '', status: '', results: [] }
|
|
94
|
+
Search.intent = ({ DOM }) => ({
|
|
95
|
+
TYPE: DOM.input('.q').value(),
|
|
96
|
+
SEARCH: DOM.input('.q').value().compose(debounce(300)).filter(q => q !== ''),
|
|
97
|
+
})
|
|
98
|
+
Search.model = {
|
|
99
|
+
TYPE: {
|
|
100
|
+
STATE: (state, query) => (query === '' ? { ...state, query, status: '', results: [] } : { ...state, query }),
|
|
101
|
+
HTTP: (state, query) => (query === '' ? { abort: 'RESULTS' } : ABORT), // clearing cancels the request in flight
|
|
102
|
+
},
|
|
103
|
+
SEARCH: {
|
|
104
|
+
STATE: (state) => ({ ...state, status: 'Searching…' }),
|
|
105
|
+
HTTP: (state, q) => ({ url: '/api/search', query: { q }, ok: 'RESULTS', error: 'FAILED', latest: true }),
|
|
106
|
+
},
|
|
107
|
+
RESULTS: (state, body) => ({ ...state, status: '', results: body.results }),
|
|
108
|
+
FAILED: (state, { status }) => ({ ...state, status: status === 404 ? 'Not found.' : 'Search failed.', results: [] }),
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- **Cancel without sending**: `{ abort: 'RESULTS' }` aborts this instance's requests in flight whose key is `'RESULTS'`; `{ abort: true, key: 'search' }` does the same for an explicit key. Nothing is delivered for a cancelled request.
|
|
113
|
+
- **`key`** groups requests with different reply actions, or splits one action into separate groups: `` { url: `/api/items/${id}`, ok: 'ITEM', key: `item-${id}`, latest: true } `` keeps one request per item in flight.
|
|
114
|
+
- `makeFetchDriver({ latest: true })` makes every request latest-only (a request can still say `latest: false`). Prefer `latest: true` on the request: tests see it too.
|
|
115
|
+
|
|
116
|
+
## Requests
|
|
117
|
+
|
|
118
|
+
A request is an object (or a URL string, for a plain GET with no reply actions):
|
|
119
|
+
|
|
120
|
+
| Field | Default | Meaning |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| `url` | (required) | The URL, prefixed with the driver's `baseUrl` |
|
|
123
|
+
| `ok` | — | Action that receives the parsed body of a 2xx response |
|
|
124
|
+
| `error` | — | Action that receives `{ error, status, body, request }` |
|
|
125
|
+
| `key` | the `ok` action, else `error` | The group for `latest` and `abort` |
|
|
126
|
+
| `method` | `'POST'` with `json`/`body`, else `'GET'` | HTTP method |
|
|
127
|
+
| `query` | — | Object appended as a query string, before any `#fragment`: `{ q: 'dune' }` → `?q=dune` (URL-encoded; `null`/`undefined` values skipped; an array repeats the key: `{ tag: ['a', 'b'] }` → `?tag=a&tag=b`) |
|
|
128
|
+
| `json` | — | Body sent as `JSON.stringify(json)` with `Content-Type: application/json` |
|
|
129
|
+
| `body` | — | Raw body (string, `FormData`, `Blob`, …) |
|
|
130
|
+
| `headers` | — | Object or `Headers`, merged over the driver's `headers` case-insensitively (sent with lowercase names) |
|
|
131
|
+
| `latest` | driver's `latest` (false) | Abort this instance's requests with the same key still in flight |
|
|
132
|
+
| `timeoutMs` | driver's `timeoutMs` (none) | Fail with a `TimeoutError` after this many ms |
|
|
133
|
+
| `parse` | `'auto'` | How the body becomes the `ok` data: `'auto'` (JSON when the content-type says JSON, otherwise text; 204 → `null`), `'json'`, `'text'`, `'response'` (the `Response` itself), or a function `res => value` |
|
|
134
|
+
| `init` | driver's `init` | Other `fetch()` options: `{ credentials: 'include', mode, cache, redirect, referrer, referrerPolicy, integrity, keepalive, priority }` |
|
|
135
|
+
| `cache`, `staleTime` | — | Answer from the driver's cache (GET/HEAD only): see [Resources and Caching](./resources.md#the-query-cache) |
|
|
136
|
+
| `tags`, `invalidates` | — | Tags for [invalidation](./resources.md#invalidation); `invalidates: ['quotes']` refreshes the tagged reads after a 2xx reply |
|
|
137
|
+
| `updates` | — | After a 2xx reply, write it into this component's resources (and their cache entries): `updates: 'item'`, or `{ items: (list, reply) => newList }` ([Writing a reply into the cache](./resources.md#writing-a-reply-into-the-cache)) |
|
|
138
|
+
| `retry` | driver's `retry` for GET/HEAD (0) | Retry network errors, 408, 429 and 5xx: a count or `{ count, delayMs, maxDelayMs, jitter }` ([Retries](./resources.md#retries)) |
|
|
139
|
+
| `validate` | — | A Standard Schema the body must pass ([Validation](./resources.md#validation)) |
|
|
140
|
+
|
|
141
|
+
Any other field is yours (an id, the query text): it isn't sent, and it comes back on the failure's `request`. A sink that returns `ABORT`, `null` or `undefined` sends nothing.
|
|
142
|
+
|
|
143
|
+
A request that names only `ok` sends its failures to `HTTP.errors()` (logged when nothing listens), and one that names only `error` sends its successes to `HTTP.select()`. Name both.
|
|
144
|
+
|
|
145
|
+
## Driver Options
|
|
146
|
+
|
|
147
|
+
```javascript
|
|
148
|
+
makeFetchDriver({
|
|
149
|
+
baseUrl: '/api', // prefix for every url
|
|
150
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
151
|
+
init: { credentials: 'include' }, // fetch() options for every request
|
|
152
|
+
latest: false, // default for every request
|
|
153
|
+
timeoutMs: 10000, // default for every request (default: none)
|
|
154
|
+
parse: 'auto', // default for every request
|
|
155
|
+
fetch: myFetch, // default: globalThis.fetch, read at each request
|
|
156
|
+
cache: queryCache(), // the query cache (default: none), see Resources and Caching
|
|
157
|
+
retry: 2, // default for GET/HEAD requests (default: 0)
|
|
158
|
+
})
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
- Disposing the app (or a hot reload) aborts every request in flight; nothing is delivered after.
|
|
162
|
+
- Server rendering (`renderToString`, Vike, Astro) runs views only, so no request is made on the server; drivers run on the client. Server data for the first render comes from Vike's [`+data`](https://sygnal.js.org/integration/vike/) or [`hydrateState`](./ssr.md), and resources can be rendered from a seeded query cache ([Server rendering](./resources.md#server-rendering)).
|
|
163
|
+
|
|
164
|
+
## Testing Without a Driver
|
|
165
|
+
|
|
166
|
+
`renderComponent` needs no driver for `HTTP`. The requests the component sends are recorded, and the source is a fake that replies like the real driver. Answer a request with `t.respond` or fail it with `t.fail`, and `await` the call:
|
|
167
|
+
|
|
168
|
+
```js
|
|
169
|
+
import { it, expect, afterEach } from 'vitest'
|
|
170
|
+
import { renderComponent } from 'sygnal'
|
|
171
|
+
import Quote from './Quote.jsx'
|
|
172
|
+
|
|
173
|
+
let t
|
|
174
|
+
afterEach(() => t?.dispose())
|
|
175
|
+
|
|
176
|
+
it('shows the picked quote, then a missing one', async () => {
|
|
177
|
+
t = renderComponent(Quote, { strict: true })
|
|
178
|
+
t.simulateEvent('.pick[data-id="2"]', 'click')
|
|
179
|
+
await t.respond('HTTP', { text: 'Two' }, 'LOADED') // resolves once LOADED is reduced and rendered
|
|
180
|
+
expect(t.requests('HTTP')[0].url).toBe('/api/quotes/2')
|
|
181
|
+
expect(t.html()).toContain('Two')
|
|
182
|
+
|
|
183
|
+
t.simulateEvent('.refresh', 'click')
|
|
184
|
+
await t.fail('HTTP', 404) // an HTTP status, an Error, or a message
|
|
185
|
+
expect(t.state.error).toBe('No such quote.')
|
|
186
|
+
t.expectNoDiagnostics()
|
|
187
|
+
})
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
- The third argument picks the request: an action, key or category name (`'LOADED'`), a partial request (`{ url: '/api/quotes/2' }`), or a predicate. Without it, the newest pending request is answered.
|
|
191
|
+
- A request that was superseded by `latest: true` or aborted is no longer pending, and answering it **throws at the call**: `expect(() => t.respond('HTTP', [], { query: { q: 'du' } })).toThrow()` asserts that a stale request can't land. (A call made while earlier input is still queued, such as a debounced search, waits up to 1 s for its request instead.)
|
|
192
|
+
- `t.requests('HTTP')` lists the requests sent; `t.sinkValues('HTTP')` also has the `{ abort }` commands.
|
|
193
|
+
|
|
194
|
+
The [Testing guide](https://sygnal.js.org/integration/testing/#answering-requests-respond-and-fail) has the full matching rules. In a running app, a sink with no driver is reported as [SYG609](https://sygnal.js.org/reference/errors/#syg609) (with the dev diagnostics on).
|
|
195
|
+
|
|
196
|
+
## Reads That Follow State: resources
|
|
197
|
+
|
|
198
|
+
For data a component shows, declare it instead of loading it in the model: `` Quote.resources = { quote: (state) => state.id && `/api/quotes/${state.id}` } `` fetches whenever the request changes and writes `state.quote = { status, data, error, refreshing }`. [Resources and Caching](./resources.md) covers refetching, the opt-in cache, invalidation, retries and validation.
|
|
199
|
+
|
|
200
|
+
## Requests Without Reply Actions: select() and errors()
|
|
201
|
+
|
|
202
|
+
A request without `ok`/`error` has no reply actions: its reply goes to the `HTTP` source, read in the intent with `HTTP.select(category)` (`{ category, value, status, request }`) and `HTTP.errors(category)` (`{ error, category, request, status, body }`). Use it to compose replies as streams, or for replies that a component other than the sender handles. Reading your own request back this way is the [alternative form](https://sygnal.js.org/advanced/alternative-forms/#selecterrors-round-trip) of reply actions, flagged in strict mode as [SYG508](https://sygnal.js.org/reference/errors/#syg508).
|
|
203
|
+
|
|
204
|
+
## Recipes
|
|
205
|
+
|
|
206
|
+
### Optimistic update with rollback
|
|
207
|
+
|
|
208
|
+
Change the state at once and send the write in the same action. Put the previous value on the request as your own field: fields the driver doesn't know aren't sent, and they come back on the failure's `request`, so the `error` action can restore it. In this demo, a stand-in server saves the first todo and refuses the second:
|
|
209
|
+
|
|
210
|
+
```js live-server
|
|
211
|
+
const todos = { 1: { id: 1, title: 'Write', done: false } } // no todo 2: a 500
|
|
212
|
+
|
|
213
|
+
export default {
|
|
214
|
+
'PATCH /api/todos/:id': ({ params, json }) => {
|
|
215
|
+
const todo = todos[params.id]
|
|
216
|
+
if (!todo) return { status: 500, json: { message: 'Disk full' }, delayMs: 1000 }
|
|
217
|
+
return { json: Object.assign(todo, json), delayMs: 1000 }
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
```jsx live
|
|
223
|
+
function Todos({ state }) {
|
|
224
|
+
return (
|
|
225
|
+
<div>
|
|
226
|
+
<ul>{state.todos.map(t => <li className={t.done ? 'todo done' : 'todo'}><button className="toggle" data-id={String(t.id)}>{t.done ? '✓ ' : ''}{t.title}</button></li>)}</ul>
|
|
227
|
+
<p className="error">{state.error}</p>
|
|
228
|
+
</div>
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
Todos.initialState = { todos: [{ id: 1, title: 'Write', done: false }, { id: 2, title: 'Ship', done: false }], error: '' }
|
|
232
|
+
Todos.intent = ({ DOM }) => ({ TOGGLE: DOM.click('.toggle').data('id', Number) })
|
|
233
|
+
Todos.model = {
|
|
234
|
+
TOGGLE: {
|
|
235
|
+
STATE: (state, id) => ({ ...state, error: '', todos: state.todos.map(t => (t.id === id ? { ...t, done: !t.done } : t)) }),
|
|
236
|
+
HTTP: (state, id) => {
|
|
237
|
+
const before = state.todos.find(t => t.id === id) // the state before this action
|
|
238
|
+
return { url: `/api/todos/${id}`, method: 'PATCH', json: { done: !before.done }, ok: 'SAVED', error: 'SAVE_FAILED', before }
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
SAVED: (state, saved) => ({ ...state, todos: state.todos.map(t => (t.id === saved.id ? saved : t)) }),
|
|
242
|
+
SAVE_FAILED: (state, { request }) => ({ // request is the request as sent, with your own fields
|
|
243
|
+
...state,
|
|
244
|
+
error: 'Could not save.',
|
|
245
|
+
todos: state.todos.map(t => (t.id === request.before.id ? request.before : t)),
|
|
246
|
+
}),
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
`SAVED` replaces the item with the server's version. In a test, `await t.fail('HTTP', 500)` and check that the item is back.
|
|
251
|
+
|
|
252
|
+
### Save status
|
|
253
|
+
|
|
254
|
+
A write's status is ordinary state: set it when the request is sent and in the reply actions. Ignoring a second click while saving keeps one request in flight. In this demo, a stand-in server rejects an empty name:
|
|
255
|
+
|
|
256
|
+
```js live-server
|
|
257
|
+
export default {
|
|
258
|
+
'PUT /api/profile': ({ json }) => (json.name.trim() === ''
|
|
259
|
+
? { status: 422, json: { name: 'Required' }, delayMs: 800 }
|
|
260
|
+
: { json, delayMs: 800 }),
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
```jsx live
|
|
265
|
+
import { ABORT } from 'sygnal'
|
|
266
|
+
|
|
267
|
+
function Profile({ state }) {
|
|
268
|
+
const label = { idle: '', saving: 'Saving…', saved: 'Saved.', failed: 'Could not save.' }[state.save]
|
|
269
|
+
return (
|
|
270
|
+
<form>
|
|
271
|
+
<input className="name" aria-label="Name" value={state.name} />
|
|
272
|
+
<button className="save" disabled={state.save === 'saving'}>Save</button>
|
|
273
|
+
<p className="save-status">{label}</p>
|
|
274
|
+
</form>
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
Profile.initialState = { name: '', save: 'idle' } // 'idle' | 'saving' | 'saved' | 'failed'
|
|
278
|
+
Profile.intent = ({ DOM }) => ({ NAME: DOM.input('.name').value(), SAVE: DOM.click('.save') })
|
|
279
|
+
Profile.model = {
|
|
280
|
+
NAME: (state, name) => ({ ...state, name, save: 'idle' }),
|
|
281
|
+
SAVE: {
|
|
282
|
+
STATE: (state) => (state.save === 'saving' ? ABORT : { ...state, save: 'saving' }),
|
|
283
|
+
HTTP: (state) => (state.save === 'saving' ? ABORT : { url: '/api/profile', method: 'PUT', json: { name: state.name }, ok: 'SAVED', error: 'SAVE_FAILED' }),
|
|
284
|
+
},
|
|
285
|
+
SAVED: (state) => ({ ...state, save: 'saved' }),
|
|
286
|
+
SAVE_FAILED: (state) => ({ ...state, save: 'failed' }),
|
|
287
|
+
}
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
To refresh the reads a save changed, add `invalidates: ['profile']` to the request ([Invalidation](./resources.md#invalidation)). To show the saved record at once, add `updates: 'profile'` when the profile is a resource ([Writing a reply into the cache](./resources.md#writing-a-reply-into-the-cache)).
|
|
291
|
+
|
|
292
|
+
For reads, see the [pagination and infinite list recipes](./resources.md#recipes).
|
|
293
|
+
|
|
294
|
+
## Related
|
|
295
|
+
|
|
296
|
+
- [Resources and Caching](./resources.md): declarative reads, the query cache, invalidation, retries, validation
|
|
297
|
+
- [Sockets](https://sygnal.js.org/guide/sockets/): WebSocket and server-sent events with `makeSocketDriver()`
|
|
298
|
+
- [Custom Drivers](https://sygnal.js.org/guide/custom-drivers/): `driverFromAsync()` for any promise-returning function, and hand-written drivers
|
|
299
|
+
- [Server Functions](https://sygnal.js.org/integration/server-functions/): calling Telefunc functions through a driver with reply actions
|