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,4003 @@
|
|
|
1
|
+
import * as ns from 'xstream';
|
|
2
|
+
import { MemoryStream, NO, NO_IL, Stream as Stream$1 } from 'xstream';
|
|
3
|
+
import 'sygnal';
|
|
4
|
+
|
|
5
|
+
const d = ns.default;
|
|
6
|
+
const xs$1 = d && typeof d.create === 'function' ? d : d && d.default && typeof d.default.create === 'function' ? d.default : ns;
|
|
7
|
+
|
|
8
|
+
var xstreamNamespace = /*#__PURE__*/Object.freeze({
|
|
9
|
+
__proto__: null,
|
|
10
|
+
MemoryStream: MemoryStream,
|
|
11
|
+
NO: NO,
|
|
12
|
+
NO_IL: NO_IL,
|
|
13
|
+
Stream: Stream$1,
|
|
14
|
+
default: xs$1
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function resolveInteropDefault(namespaceValue) {
|
|
18
|
+
if (namespaceValue &&
|
|
19
|
+
namespaceValue.default &&
|
|
20
|
+
namespaceValue.default.default &&
|
|
21
|
+
typeof namespaceValue.default.default === 'function') {
|
|
22
|
+
return namespaceValue.default.default;
|
|
23
|
+
}
|
|
24
|
+
if (namespaceValue && namespaceValue.default) {
|
|
25
|
+
return namespaceValue.default;
|
|
26
|
+
}
|
|
27
|
+
return namespaceValue;
|
|
28
|
+
}
|
|
29
|
+
function getXs(namespaceValue) {
|
|
30
|
+
const value = resolveInteropDefault(namespaceValue);
|
|
31
|
+
if (value &&
|
|
32
|
+
value.default &&
|
|
33
|
+
typeof value.default.create === 'function') {
|
|
34
|
+
return value.default;
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
const xs = getXs(xstreamNamespace);
|
|
39
|
+
const Stream = Stream$1 ||
|
|
40
|
+
(xs$1 && xs$1.Stream) ||
|
|
41
|
+
(xs && xs.Stream);
|
|
42
|
+
|
|
43
|
+
const DOCS_BASE_URL = 'https://sygnal.js.org';
|
|
44
|
+
const CODE_SEVERITY = {
|
|
45
|
+
SYG101: 'warn',
|
|
46
|
+
SYG102: 'info',
|
|
47
|
+
SYG103: 'info',
|
|
48
|
+
SYG104: 'warn',
|
|
49
|
+
SYG105: 'info',
|
|
50
|
+
SYG106: 'warn',
|
|
51
|
+
SYG110: 'warn',
|
|
52
|
+
SYG111: 'warn',
|
|
53
|
+
SYG201: 'warn',
|
|
54
|
+
SYG202: 'warn',
|
|
55
|
+
SYG301: 'error',
|
|
56
|
+
SYG401: 'warn',
|
|
57
|
+
SYG900: 'warn',
|
|
58
|
+
// ---- 1E retrofit codes (SYG206-299, 402-499, 601-699, 901-999) ----
|
|
59
|
+
SYG206: 'error',
|
|
60
|
+
SYG207: 'warn',
|
|
61
|
+
SYG208: 'warn',
|
|
62
|
+
SYG209: 'error',
|
|
63
|
+
SYG210: 'warn',
|
|
64
|
+
SYG211: 'error',
|
|
65
|
+
SYG212: 'error',
|
|
66
|
+
SYG213: 'warn',
|
|
67
|
+
SYG214: 'error',
|
|
68
|
+
SYG215: 'error',
|
|
69
|
+
SYG216: 'error',
|
|
70
|
+
SYG217: 'warn',
|
|
71
|
+
SYG218: 'error',
|
|
72
|
+
SYG219: 'warn',
|
|
73
|
+
SYG220: 'warn',
|
|
74
|
+
SYG402: 'error',
|
|
75
|
+
SYG403: 'error',
|
|
76
|
+
SYG404: 'error',
|
|
77
|
+
SYG405: 'error',
|
|
78
|
+
SYG406: 'error',
|
|
79
|
+
SYG407: 'error',
|
|
80
|
+
SYG408: 'error',
|
|
81
|
+
SYG409: 'warn',
|
|
82
|
+
SYG410: 'error',
|
|
83
|
+
SYG411: 'error',
|
|
84
|
+
SYG412: 'error',
|
|
85
|
+
SYG413: 'error',
|
|
86
|
+
SYG414: 'error',
|
|
87
|
+
SYG415: 'error',
|
|
88
|
+
SYG416: 'error',
|
|
89
|
+
SYG417: 'warn',
|
|
90
|
+
SYG418: 'error',
|
|
91
|
+
SYG419: 'error',
|
|
92
|
+
SYG420: 'error',
|
|
93
|
+
SYG601: 'error',
|
|
94
|
+
SYG602: 'error',
|
|
95
|
+
SYG603: 'error',
|
|
96
|
+
SYG604: 'error',
|
|
97
|
+
SYG605: 'error',
|
|
98
|
+
SYG606: 'error',
|
|
99
|
+
SYG607: 'error',
|
|
100
|
+
SYG608: 'warn',
|
|
101
|
+
SYG609: 'warn',
|
|
102
|
+
SYG610: 'error',
|
|
103
|
+
SYG901: 'error',
|
|
104
|
+
SYG902: 'error',
|
|
105
|
+
SYG903: 'error',
|
|
106
|
+
// ---- end 1E retrofit codes ----
|
|
107
|
+
};
|
|
108
|
+
const CODE_TITLES = {
|
|
109
|
+
// SYG1xx wiring (1A)
|
|
110
|
+
SYG101: 'Intent action has no model entry',
|
|
111
|
+
SYG102: 'Model entry is unreachable',
|
|
112
|
+
SYG103: 'Intent selector matched no rendered element',
|
|
113
|
+
SYG104: 'Intent selector crosses an isolation boundary',
|
|
114
|
+
SYG105: 'EVENTS type selected but never emitted, or emitted but never selected',
|
|
115
|
+
SYG106: 'Parent prop is overwritten by a reserved view argument',
|
|
116
|
+
SYG110: 'Intent selector not present in the component view',
|
|
117
|
+
SYG111: 'Controlled input has no input listener',
|
|
118
|
+
SYG115: 'Unknown DOM event shorthand',
|
|
119
|
+
SYG116: 'EVENTS value has no string type',
|
|
120
|
+
SYG124: 'Component used as a control or selector',
|
|
121
|
+
SYG125: 'Control given .intent, .model or .initialState',
|
|
122
|
+
SYG126: 'Control rendered but never listened to',
|
|
123
|
+
SYG127: 'Behavior collision or unresolvable uses entry',
|
|
124
|
+
SYG128: 'Duplicate control key',
|
|
125
|
+
SYG129: "CHILD.select() of a component this one doesn't render",
|
|
126
|
+
SYG112: 'Reply action has no model entry',
|
|
127
|
+
SYG130: 'href() names no route or leaves out a param',
|
|
128
|
+
SYG131: 'Route params the pattern does not use',
|
|
129
|
+
SYG132: 'Declaration static that is never sent',
|
|
130
|
+
SYG133: 'SPA router inside a Vike app',
|
|
131
|
+
// PLAN-5 W-1: widgets (defineWidget; checks/widgets.ts, checks/elementCommands.ts; SYG141 static only)
|
|
132
|
+
SYG140: 'Widget dispatched an undeclared event',
|
|
133
|
+
SYG141: 'Listener for an event the widget does not declare',
|
|
134
|
+
SYG142: 'Widget command not declared',
|
|
135
|
+
SYG143: 'Widget tag used as a selector',
|
|
136
|
+
SYG144: 'Widget event the host element also fires natively',
|
|
137
|
+
// PLAN-5 B-1 (3-D): the sortable behavior (checks/sortable.ts; sortable.ts calls the bridge's sortable hook)
|
|
138
|
+
SYG145: 'Sortable item without its id attribute',
|
|
139
|
+
SYG146: 'Sortable item or handle selector matches nothing',
|
|
140
|
+
SYG147: 'Sortable list key is not an array in the host state',
|
|
141
|
+
// PLAN-5 3-F G-419: a Collection's viewTransitionName (checks/next.ts)
|
|
142
|
+
SYG148: 'Collection viewTransitionName is not a CSS identifier',
|
|
143
|
+
SYG149: 'Two items with the same view-transition name',
|
|
144
|
+
// PLAN-5 V-1: VirtualCollection (checks/virtual.ts)
|
|
145
|
+
SYG430: 'VirtualCollection has no bounded height',
|
|
146
|
+
SYG431: 'VirtualCollection items without ids',
|
|
147
|
+
SYG432: 'VirtualCollection item does not render one element',
|
|
148
|
+
SYG433: 'VirtualCollection scroll target not in the list',
|
|
149
|
+
SYG434: 'Invalid VirtualCollection estimateSize or overscan',
|
|
150
|
+
// PLAN-5 3-H (G-449): sortable over a list shown in another order (checks/sortable.ts)
|
|
151
|
+
SYG435: 'Sortable list shown filtered or in another order',
|
|
152
|
+
// SYG2xx state & reducers (1A)
|
|
153
|
+
SYG201: 'STATE reducer dropped keys from the previous state',
|
|
154
|
+
SYG202: 'STATE reducer returned undefined',
|
|
155
|
+
// SYG3xx streams & intent construction (1A)
|
|
156
|
+
SYG301: 'RxJS operator used on an xstream stream',
|
|
157
|
+
// SYG4xx collections / switchable / sub-components / context (1A)
|
|
158
|
+
SYG401: "Collection 'from' field is missing or not an array",
|
|
159
|
+
// SYG5xx strict mode / canonical forms (2A; dev-plans/PLAN-1-canonical-forms.md)
|
|
160
|
+
SYG501: "Removed in 6.0: view with positional arguments",
|
|
161
|
+
SYG502: 'Retired in 6.0: STATE reducer signals no change without ABORT',
|
|
162
|
+
SYG503: 'Side effect in a STATE reducer that returns ABORT',
|
|
163
|
+
SYG504: "Removed in 6.0: 'ACTION | SINK' model key",
|
|
164
|
+
SYG505: 'Non-canonical EVENTS emit',
|
|
165
|
+
SYG506: "Removed in 6.0: CHILD.select() with a component name",
|
|
166
|
+
SYG507: 'Prop drilled through more than 2 component levels',
|
|
167
|
+
SYG508: 'select()/errors() round trip where reply actions would do',
|
|
168
|
+
// SYG9xx internal (0B)
|
|
169
|
+
SYG900: 'A diagnostics check threw',
|
|
170
|
+
// ---- 1E retrofit codes (SYG206-299, 402-499, 601-699, 901-999) ----
|
|
171
|
+
SYG206: 'Invalid calculated field definition',
|
|
172
|
+
SYG207: 'Calculated field shadows an initialState key',
|
|
173
|
+
SYG208: 'Calculated field depends on an unknown key',
|
|
174
|
+
SYG209: 'Circular calculated field dependency',
|
|
175
|
+
SYG210: 'INITIALIZE used with a sink other than STATE',
|
|
176
|
+
SYG211: "Retired in 6.0: Invalid model shorthand entry",
|
|
177
|
+
SYG212: 'Model entry is not a function or an object of sinks',
|
|
178
|
+
SYG213: "Retired in 6.0: Duplicate model entry for the same action and sink",
|
|
179
|
+
SYG214: 'EFFECT handler threw',
|
|
180
|
+
SYG215: 'next() delay is not a number',
|
|
181
|
+
SYG216: 'Reducer threw',
|
|
182
|
+
SYG217: 'Driver-sink reducer returned undefined',
|
|
183
|
+
SYG218: 'Reducer returned an unsupported type',
|
|
184
|
+
SYG219: 'EFFECT handler returned a value',
|
|
185
|
+
SYG220: 'Calculated field threw',
|
|
186
|
+
SYG221: 'set() called with a string',
|
|
187
|
+
SYG222: 'STATE reducer returned the same object after it was mutated in place',
|
|
188
|
+
SYG223: 'persist pick or omit names a key that is not in initialState',
|
|
189
|
+
SYG224: 'persist on a component that is not the root',
|
|
190
|
+
SYG226: 'Undo track, resetOn or coalesce names an unknown action',
|
|
191
|
+
// PLAN-5 F-1: the form behavior (checks/forms.ts)
|
|
192
|
+
SYG230: 'Form field name not in values',
|
|
193
|
+
SYG231: 'form() schema is not a Standard Schema',
|
|
194
|
+
SYG232: 'Form submit dropped while one is in progress',
|
|
195
|
+
SYG233: 'Form value missing from the schema output',
|
|
196
|
+
SYG234: 'Form submit action has no model entry',
|
|
197
|
+
SYG235: 'Form check names an unknown field or sets reply fields',
|
|
198
|
+
SYG236: 'Form array rows without an id',
|
|
199
|
+
SYG237: 'Two forms in one component listen on the same form selector',
|
|
200
|
+
// PLAN-5 2-R (G-393; checks/state.ts; dev entry)
|
|
201
|
+
SYG238: 'Root component with a model but no initial state',
|
|
202
|
+
SYG402: 'Context is not an object',
|
|
203
|
+
SYG403: 'Invalid context entry',
|
|
204
|
+
SYG404: 'Context stream errored',
|
|
205
|
+
SYG405: 'Sub-component has initialState without isolatedState',
|
|
206
|
+
SYG406: 'View threw',
|
|
207
|
+
SYG407: 'onError handler threw',
|
|
208
|
+
SYG408: 'Sub-component failed to instantiate',
|
|
209
|
+
SYG409: 'Child tried to update a calculated field',
|
|
210
|
+
SYG410: "Invalid sub-component 'state' prop",
|
|
211
|
+
SYG411: "Collection 'of' is missing or not a component",
|
|
212
|
+
SYG412: "Invalid Collection 'from' prop",
|
|
213
|
+
SYG413: "Retired in 6.0: Unnamed component factory not found",
|
|
214
|
+
SYG414: "Retired in 6.0: Component not found",
|
|
215
|
+
SYG415: "Switchable 'of' is missing or invalid",
|
|
216
|
+
SYG416: "Switchable 'current' is missing or invalid",
|
|
217
|
+
SYG417: 'Portal target not found',
|
|
218
|
+
SYG418: 'Invalid Collection sort prop',
|
|
219
|
+
SYG419: "Retired in 6.0: Invalid switchable() 'name$' parameter",
|
|
220
|
+
SYG420: 'JSX tag is undefined',
|
|
221
|
+
SYG421: 'Invalid data (dataset) key',
|
|
222
|
+
SYG422: 'Invalid timer spec',
|
|
223
|
+
// PLAN-4.6 R4: the core's dev checks (checks/next.ts; dev entry)
|
|
224
|
+
SYG423: 'Context change skipped a view whose output it changes',
|
|
225
|
+
SYG424: 'Duplicate Collection item id',
|
|
226
|
+
SYG425: 'isolatedState slice lacks initialState keys',
|
|
227
|
+
SYG601: "Retired in 6.0: Missing or invalid sources",
|
|
228
|
+
SYG602: 'Intent is not a function',
|
|
229
|
+
SYG603: 'Intent returned an invalid value',
|
|
230
|
+
SYG604: "Retired in 6.0: Invalid hmrActions",
|
|
231
|
+
SYG605: "Retired in 6.0: Intent action name contains '|'",
|
|
232
|
+
SYG606: 'calculated is not an object',
|
|
233
|
+
SYG607: "Retired in 6.0: State source not found",
|
|
234
|
+
SYG608: 'Strict mode requested without the diagnostics entry',
|
|
235
|
+
SYG609: 'Sink or source has no driver',
|
|
236
|
+
SYG610: "Request has a 'then' or 'catch' key",
|
|
237
|
+
SYG611: 'Socket message not sent or connection not opened',
|
|
238
|
+
// PLAN-4.6 R4 (D173): a form 6.0 removed, met at runtime (checks/next.ts; dev entry)
|
|
239
|
+
SYG612: 'Removed in 6.0',
|
|
240
|
+
SYG620: 'Router command not performed',
|
|
241
|
+
// PLAN-3 5-3: makeFetchDriver's cache, invalidation and validation (dev entry; SYG634 static only)
|
|
242
|
+
SYG630: 'Cached request is not idempotent',
|
|
243
|
+
SYG631: 'validate is not a Standard Schema',
|
|
244
|
+
SYG632: 'invalidate matched nothing',
|
|
245
|
+
SYG633: 'abort names a lane the requests do not use',
|
|
246
|
+
SYG634: 'latest: true with a computed key',
|
|
247
|
+
// PLAN-3 5-5 (D88)
|
|
248
|
+
SYG635: 'Caching asked for without a queryCache',
|
|
249
|
+
// PLAN-4 GS-2: element commands (reported by the dev entry, checks/elementCommands.ts)
|
|
250
|
+
SYG640: 'Element command target not found',
|
|
251
|
+
SYG641: 'Unknown element command',
|
|
252
|
+
// PLAN-4 GS-5: persist() (src/extra/persist.ts reports SYG642 with an explicit severity, like SYG611)
|
|
253
|
+
SYG642: 'Persisted state could not be restored or saved',
|
|
254
|
+
// PLAN-4 GS-7: timers (checks/timers.ts; dev entry)
|
|
255
|
+
SYG643: 'Declaration static with no driver to take it',
|
|
256
|
+
// PLAN-4 D131: sygnal/element (reported through the core bridge when diagnostics are on)
|
|
257
|
+
SYG644: 'Custom element prop hides an HTMLElement member',
|
|
258
|
+
// PLAN-4 GS-12 (D129): View Transitions (checks/viewTransitions.ts; dev entry)
|
|
259
|
+
SYG645: "viewTransitions that can't run",
|
|
260
|
+
// PLAN-5 W-1: a widget's mount / update / unmount threw (checks/widgets.ts; dev entry)
|
|
261
|
+
SYG660: 'Widget mount threw',
|
|
262
|
+
SYG661: 'Widget update threw or reported an error',
|
|
263
|
+
SYG662: 'Widget unmount threw',
|
|
264
|
+
// PLAN-5 B-3: browser sources (makeBrowserDriver; checks/browserSources.ts; dev entry)
|
|
265
|
+
SYG663: 'Invalid browser-source spec or command',
|
|
266
|
+
SYG664: 'Browser source not in the driver',
|
|
267
|
+
SYG665: 'Browser source failed with no error action',
|
|
268
|
+
// PLAN-5 W-2 (D203): the adapter entries (sygnal/react, sygnal/zag, sygnal/ui/menu|select|combobox): SYG666 from
|
|
269
|
+
// sygnal/vite (an entry imported without its optional peer dependencies), SYG667 thrown by
|
|
270
|
+
// fromZag / fromReact
|
|
271
|
+
SYG666: 'Adapter peer dependency missing',
|
|
272
|
+
SYG667: 'Invalid adapter argument',
|
|
273
|
+
// PLAN-5 2-R (G-383, G-387)
|
|
274
|
+
SYG668: 'Browser source has no element to observe',
|
|
275
|
+
// PLAN-5 2-T (G-410): Sygnal components / widget tags / special JSX inside a fromZag render
|
|
276
|
+
SYG669: 'Sygnal component inside a fromZag render',
|
|
277
|
+
// PLAN-4 GS-3: a11y lane (static only, sygnal-check)
|
|
278
|
+
SYG701: 'Click listener on a non-interactive element',
|
|
279
|
+
SYG702: 'Form field without an accessible label',
|
|
280
|
+
SYG703: 'Image without alt text',
|
|
281
|
+
SYG704: 'Link without href used as a button',
|
|
282
|
+
SYG705: 'Button without an accessible name',
|
|
283
|
+
SYG706: 'Positive tabIndex',
|
|
284
|
+
SYG707: 'Unknown ARIA attribute or invalid role',
|
|
285
|
+
SYG708: 'Label or ARIA reference to an id that is not rendered',
|
|
286
|
+
// PLAN-5 2-T (D211): Zag UI parts (sygnal-check rules/a11y/syg722-ui-name.js)
|
|
287
|
+
SYG722: 'Menu, Select or Combobox without an accessible name',
|
|
288
|
+
// PLAN-5 B-1 (3-D): static only (sygnal-check)
|
|
289
|
+
SYG724: 'Sortable handle not keyboard-accessible, or no live region',
|
|
290
|
+
SYG901: "Retired in 6.0: Sub-component sink stream errored",
|
|
291
|
+
SYG902: "Retired in 6.0: EFFECT stream errored",
|
|
292
|
+
SYG903: "Retired in 6.0: Component factory returned invalid sinks",
|
|
293
|
+
// ---- end 1E retrofit codes ----
|
|
294
|
+
};
|
|
295
|
+
// 2A: strict-mode (SYG5xx) default severities. Kept OUT of CODE_SEVERITY so the
|
|
296
|
+
// main bundle doesn't carry them (D29: zero bytes); the 'sygnal/diagnostics'
|
|
297
|
+
// entry registers them (registerCodes) and its reports pass the severity
|
|
298
|
+
// explicitly. sygnal-check keeps the same table in sygnal-check/src/codes.js.
|
|
299
|
+
const STRICT_CODE_SEVERITY = {
|
|
300
|
+
SYG501: 'error',
|
|
301
|
+
SYG502: 'warn',
|
|
302
|
+
SYG503: 'warn',
|
|
303
|
+
SYG504: 'error',
|
|
304
|
+
SYG505: 'warn',
|
|
305
|
+
SYG506: 'error',
|
|
306
|
+
SYG507: 'info',
|
|
307
|
+
SYG508: 'warn',
|
|
308
|
+
};
|
|
309
|
+
// PLAN-3 1-G (G-143): codes only the 'sygnal/diagnostics' dev entry reports. Like the strict
|
|
310
|
+
// table, kept OUT of CODE_SEVERITY (zero bytes in the main bundle); the entry registers them
|
|
311
|
+
// and passes the severity explicitly.
|
|
312
|
+
// PLAN-3 1-D: SYG112 (a reply action has no model entry) too.
|
|
313
|
+
// PLAN-3 2-A: SYG611 too, though makeSocketDriver reports it (legacy error(), severity passed
|
|
314
|
+
// explicitly): kept out of CODE_SEVERITY so the core bundle doesn't pay for an opt-in driver.
|
|
315
|
+
const DEV_CODE_SEVERITY = {
|
|
316
|
+
SYG112: 'error',
|
|
317
|
+
SYG115: 'warn',
|
|
318
|
+
SYG116: 'error',
|
|
319
|
+
// PLAN-4 1-D: controls, static (sygnal-check)
|
|
320
|
+
SYG124: 'error',
|
|
321
|
+
SYG125: 'error',
|
|
322
|
+
SYG126: 'info',
|
|
323
|
+
// PLAN-4 GS-1: behaviors (checks/behaviors.ts)
|
|
324
|
+
SYG127: 'error',
|
|
325
|
+
SYG128: 'error',
|
|
326
|
+
// PLAN-4 4-G2: static only (sygnal-check); listed so the entry knows its title
|
|
327
|
+
SYG129: 'warn',
|
|
328
|
+
SYG221: 'error',
|
|
329
|
+
// PLAN-4 GS-4 (state.ts)
|
|
330
|
+
SYG222: 'warn',
|
|
331
|
+
// PLAN-4 GS-5 (checks/persist.ts; sygnal-check statically)
|
|
332
|
+
SYG223: 'warn',
|
|
333
|
+
SYG224: 'error',
|
|
334
|
+
// PLAN-4 GS-8 (undo.ts reports it when diagnostics are on; sygnal-check statically)
|
|
335
|
+
SYG226: 'warn',
|
|
336
|
+
// PLAN-5 F-1: the form behavior (checks/forms.ts; form.ts calls the bridge's form hook)
|
|
337
|
+
SYG230: 'warn',
|
|
338
|
+
SYG231: 'error',
|
|
339
|
+
SYG232: 'info',
|
|
340
|
+
SYG233: 'warn',
|
|
341
|
+
SYG234: 'warn',
|
|
342
|
+
SYG235: 'warn',
|
|
343
|
+
SYG236: 'warn',
|
|
344
|
+
SYG237: 'warn',
|
|
345
|
+
SYG238: 'warn',
|
|
346
|
+
SYG421: 'error',
|
|
347
|
+
SYG611: 'error',
|
|
348
|
+
// PLAN-3 5-4b: router (SYG620 is reported by the router itself, like SYG611) and G-167
|
|
349
|
+
SYG130: 'error',
|
|
350
|
+
SYG131: 'warn',
|
|
351
|
+
SYG132: 'warn',
|
|
352
|
+
SYG133: 'warn',
|
|
353
|
+
SYG620: 'error',
|
|
354
|
+
// PLAN-3 5-3 (fetch.ts)
|
|
355
|
+
SYG630: 'warn',
|
|
356
|
+
SYG631: 'error',
|
|
357
|
+
SYG632: 'info',
|
|
358
|
+
SYG633: 'warn',
|
|
359
|
+
// static only (sygnal-check); listed so the entry knows its title
|
|
360
|
+
SYG634: 'info',
|
|
361
|
+
// PLAN-3 5-5 (fetch.ts)
|
|
362
|
+
SYG635: 'warn',
|
|
363
|
+
// PLAN-4 GS-2 (checks/elementCommands.ts, through the core bridge; renderComponent too)
|
|
364
|
+
SYG640: 'warn',
|
|
365
|
+
SYG641: 'error',
|
|
366
|
+
// PLAN-4 GS-5 (persist.ts, through legacy warn(): printed in production too)
|
|
367
|
+
SYG642: 'warn',
|
|
368
|
+
// PLAN-4 GS-7 (timers.ts): an invalid timer spec (not started); timers / connections /
|
|
369
|
+
// resources declared with no driver registered
|
|
370
|
+
SYG422: 'error',
|
|
371
|
+
SYG643: 'warn',
|
|
372
|
+
// PLAN-4.6 R4 (checks/next.ts): D168 context-tracking safety net, D169 duplicate ids, D174 kept
|
|
373
|
+
// slice, D173 removed forms
|
|
374
|
+
SYG423: 'warn',
|
|
375
|
+
SYG424: 'warn',
|
|
376
|
+
SYG425: 'warn',
|
|
377
|
+
SYG612: 'error',
|
|
378
|
+
// PLAN-4 D131 (element.ts, through the core bridge; a console warning in dev with diagnostics off)
|
|
379
|
+
SYG644: 'warn',
|
|
380
|
+
// PLAN-4 GS-12 (viewTransitions.ts): the static set, but the app's DOM driver isn't makeViewTransitionDOMDriver()
|
|
381
|
+
SYG645: 'warn',
|
|
382
|
+
// PLAN-5 W-1: widgets (checks/widgets.ts, checks/elementCommands.ts; SYG141 static only, listed
|
|
383
|
+
// so the entry knows its title)
|
|
384
|
+
SYG140: 'warn',
|
|
385
|
+
SYG141: 'warn',
|
|
386
|
+
SYG142: 'error',
|
|
387
|
+
SYG143: 'warn',
|
|
388
|
+
SYG144: 'info',
|
|
389
|
+
// PLAN-5 B-1 (3-D): the sortable behavior (checks/sortable.ts)
|
|
390
|
+
SYG145: 'warn',
|
|
391
|
+
SYG146: 'warn',
|
|
392
|
+
SYG147: 'warn',
|
|
393
|
+
// PLAN-5 3-F G-419 (checks/next.ts: onHostProps, onPatch)
|
|
394
|
+
SYG148: 'warn',
|
|
395
|
+
SYG149: 'warn',
|
|
396
|
+
SYG660: 'error',
|
|
397
|
+
SYG661: 'error',
|
|
398
|
+
SYG662: 'error',
|
|
399
|
+
SYG663: 'error',
|
|
400
|
+
SYG664: 'error',
|
|
401
|
+
SYG665: 'warn',
|
|
402
|
+
SYG666: 'error',
|
|
403
|
+
SYG667: 'error',
|
|
404
|
+
SYG668: 'warn',
|
|
405
|
+
SYG669: 'warn',
|
|
406
|
+
// PLAN-5 V-1: VirtualCollection (checks/virtual.ts, through the core bridge)
|
|
407
|
+
SYG430: 'warn',
|
|
408
|
+
SYG431: 'warn',
|
|
409
|
+
SYG432: 'warn',
|
|
410
|
+
SYG433: 'warn',
|
|
411
|
+
SYG434: 'warn',
|
|
412
|
+
// PLAN-5 3-H (G-449): sortable (checks/sortable.ts)
|
|
413
|
+
SYG435: 'warn',
|
|
414
|
+
// PLAN-4 GS-3: a11y lane, static only (sygnal-check; warn, also under --strict; error with --a11y=error, D144); listed so the entry knows its title
|
|
415
|
+
SYG701: 'warn',
|
|
416
|
+
SYG702: 'warn',
|
|
417
|
+
SYG703: 'warn',
|
|
418
|
+
SYG704: 'warn',
|
|
419
|
+
SYG705: 'warn',
|
|
420
|
+
SYG706: 'warn',
|
|
421
|
+
SYG707: 'warn',
|
|
422
|
+
SYG708: 'warn',
|
|
423
|
+
SYG722: 'warn',
|
|
424
|
+
// PLAN-5 B-1 (3-D): static only (sygnal-check)
|
|
425
|
+
SYG724: 'warn',
|
|
426
|
+
};
|
|
427
|
+
function getCodeInfo(code) {
|
|
428
|
+
const severity = CODE_SEVERITY[code];
|
|
429
|
+
return severity && { code, severity, title: CODE_TITLES[code] || '', docsSlug: code.toLowerCase() };
|
|
430
|
+
}
|
|
431
|
+
function listCodes() {
|
|
432
|
+
return Object.keys(CODE_SEVERITY).map(c => getCodeInfo(c));
|
|
433
|
+
}
|
|
434
|
+
function registerCodes(entries) {
|
|
435
|
+
for (const [code, severity, title] of entries) {
|
|
436
|
+
CODE_SEVERITY[code] = severity;
|
|
437
|
+
CODE_TITLES[code] = title;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
function docsUrlFor(code) {
|
|
441
|
+
return `${DOCS_BASE_URL}/reference/errors#${code.toLowerCase()}`;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Shared plumbing for the runtime checks (PLAN-1 workstream 1A).
|
|
446
|
+
*
|
|
447
|
+
* The checks are bundled separately from the main 'sygnal' entry
|
|
448
|
+
* ('sygnal/diagnostics'). They must talk to the SAME diagnostics core
|
|
449
|
+
* instance the components use, so they never import the core's functions
|
|
450
|
+
* directly: `import '../index'` only makes sure the core is loaded (in the
|
|
451
|
+
* dist build that import is rewritten to the external 'sygnal' package, see
|
|
452
|
+
* rollup.config.mjs), and the functions are read from the bridge object the
|
|
453
|
+
* core publishes on globalThis.__SYGNAL_DIAGNOSTICS__.
|
|
454
|
+
*/
|
|
455
|
+
const bridge = () => globalThis.__SYGNAL_DIAGNOSTICS__;
|
|
456
|
+
const report = (code, details) => bridge().report(code, details);
|
|
457
|
+
/**
|
|
458
|
+
* report() for code that is NOT running inside a core hook (timers, wrapped
|
|
459
|
+
* methods): never throws synchronously; an 'error'-mode DiagnosticError is
|
|
460
|
+
* rethrown asynchronously, like the core does for hooks.
|
|
461
|
+
*/
|
|
462
|
+
const reportSafely = (code, details) => {
|
|
463
|
+
try {
|
|
464
|
+
return report(code, details);
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
const raise = () => { throw err; };
|
|
468
|
+
typeof queueMicrotask === 'function' ? queueMicrotask(raise) : setTimeout(raise);
|
|
469
|
+
return undefined;
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
registerCodes(Object.keys(DEV_CODE_SEVERITY).map(code => [code, DEV_CODE_SEVERITY[code], CODE_TITLES[code]]));
|
|
473
|
+
/**
|
|
474
|
+
* reportSafely() for the dev-entry-only codes (DEV_CODE_SEVERITY, G-143): the main bundle's
|
|
475
|
+
* core doesn't know their severity, so it is passed explicitly.
|
|
476
|
+
*/
|
|
477
|
+
const devReport = (code, details) => reportSafely(code, { severity: DEV_CODE_SEVERITY[code], ...details });
|
|
478
|
+
/** Tunables (test seam: configureChecks()). */
|
|
479
|
+
const timing = {
|
|
480
|
+
/** delay after a render before DOM checks run (lets nested renders patch) */
|
|
481
|
+
settleMs: 50,
|
|
482
|
+
/** SYG103 escalates to warn after this much render-idle time ... */
|
|
483
|
+
idleMs: 2000,
|
|
484
|
+
/** ... and only once the component has rendered at least this often */
|
|
485
|
+
minRenders: 3,
|
|
486
|
+
};
|
|
487
|
+
/**
|
|
488
|
+
* Built-in actions Sygnal dispatches itself; never "missing" or "unreachable".
|
|
489
|
+
* (HYDRATE is not one since 6.0, D66: nothing dispatches it, so it is an ordinary action.)
|
|
490
|
+
*/
|
|
491
|
+
const BUILTIN_ACTIONS = new Set(['BOOTSTRAP', 'INITIALIZE', 'DISPOSE', 'READY', 'RESOURCE']);
|
|
492
|
+
/** Sinks the core handles itself: their values never go to a reply-action driver. */
|
|
493
|
+
// (PLAN-4 GS-2: ELEMENT too; `{ scrollIntoView: Row, block: 'center' }` names no action)
|
|
494
|
+
const NON_REPLY_SINK = /^(STATE|EFFECT|EVENTS|PARENT|READY|DOM|CHILD|ELEMENT|PERSIST)$/;
|
|
495
|
+
// `ok: 'X'` / `"error": "X"` in function source (minified code keeps string literals and keys)
|
|
496
|
+
const keyedNames = (keys) => new RegExp(`(?:^|[{,\\s])["']?(?:${keys})["']?\\s*:\\s*(["'\`])([\\w$.:/-]+)\\1`, 'g');
|
|
497
|
+
// (PLAN-3 5-4b: and a router `{ block: 'ACTION' }`)
|
|
498
|
+
const REPLY_IN_SOURCE = keyedNames('ok|error|block');
|
|
499
|
+
const CONNECTION_IN_SOURCE = keyedNames('message|open|close|error');
|
|
500
|
+
// G-224: a `timers` static's `action: 'TICK'` / `frame: 'FRAME'`
|
|
501
|
+
const TIMER_IN_SOURCE = keyedNames('action|frame');
|
|
502
|
+
// PLAN-5 B-3: a `browser` static's `action: 'SEEN'` / `error: 'GEO_FAILED'`
|
|
503
|
+
const BROWSER_IN_SOURCE = keyedNames('action|error');
|
|
504
|
+
const namesIn = (fn, re, out) => {
|
|
505
|
+
if (typeof fn !== 'function')
|
|
506
|
+
return;
|
|
507
|
+
let src = '';
|
|
508
|
+
try {
|
|
509
|
+
src = Function.prototype.toString.call(fn);
|
|
510
|
+
}
|
|
511
|
+
catch (_) {
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
re.lastIndex = 0;
|
|
515
|
+
for (let m = re.exec(src); m; m = re.exec(src))
|
|
516
|
+
out.add(m[2]);
|
|
517
|
+
};
|
|
518
|
+
/**
|
|
519
|
+
* Reply actions a component's requests and `connections` static can name, read from the
|
|
520
|
+
* source of its non-STATE sink functions (`HTTP: (s) => ({ url, ok: 'LOADED', error: 'FAILED' })`)
|
|
521
|
+
* and of `connections` (message/open/close/error). A heuristic for SYG102 (PLAN-3): a name
|
|
522
|
+
* built at run time is missed (SYG102 stays info, so that only costs a false hint).
|
|
523
|
+
*/
|
|
524
|
+
function replyNamesOf(component) {
|
|
525
|
+
const out = new Set();
|
|
526
|
+
const model = component && component.model;
|
|
527
|
+
if (model && typeof model === 'object') {
|
|
528
|
+
for (const key of Object.keys(model)) {
|
|
529
|
+
const value = model[key];
|
|
530
|
+
const bar = key.indexOf('|');
|
|
531
|
+
if (bar >= 0) {
|
|
532
|
+
if (!NON_REPLY_SINK.test(key.slice(bar + 1).trim()))
|
|
533
|
+
namesIn(value, REPLY_IN_SOURCE, out);
|
|
534
|
+
}
|
|
535
|
+
else if (value && typeof value === 'object') {
|
|
536
|
+
for (const sink of Object.keys(value)) {
|
|
537
|
+
if (!NON_REPLY_SINK.test(sink))
|
|
538
|
+
namesIn(value[sink], REPLY_IN_SOURCE, out);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
namesIn(component?.view?.connections, CONNECTION_IN_SOURCE, out);
|
|
544
|
+
// G-224: makeTimerDriver() replies the actions the `timers` static names (a function of the
|
|
545
|
+
// state, or an object of them)
|
|
546
|
+
const timers = component?.view?.timers;
|
|
547
|
+
if (timers && typeof timers === 'object')
|
|
548
|
+
for (const k in timers)
|
|
549
|
+
namesIn(timers[k], TIMER_IN_SOURCE, out);
|
|
550
|
+
else
|
|
551
|
+
namesIn(timers, TIMER_IN_SOURCE, out);
|
|
552
|
+
// PLAN-5 B-3: the browser driver replies the actions the `browser` static names
|
|
553
|
+
const browser = component?.view?.browser;
|
|
554
|
+
if (browser && typeof browser === 'object')
|
|
555
|
+
for (const k in browser)
|
|
556
|
+
namesIn(browser[k], BROWSER_IN_SOURCE, out);
|
|
557
|
+
else
|
|
558
|
+
namesIn(browser, BROWSER_IN_SOURCE, out);
|
|
559
|
+
// PLAN-3 5-4b: the router replies the action a `route` static names
|
|
560
|
+
const route = component?.view?.route;
|
|
561
|
+
if (typeof route === 'string')
|
|
562
|
+
out.add(route);
|
|
563
|
+
// PLAN-4 GS-5: persist() adds RESTORE (sent after hydration and by sync)
|
|
564
|
+
if (component?.view?.persist)
|
|
565
|
+
out.add('RESTORE');
|
|
566
|
+
return out;
|
|
567
|
+
}
|
|
568
|
+
/** Reply actions a component instance's requests were seen naming (replies.ts → inspect). */
|
|
569
|
+
const replySeen = new WeakMap();
|
|
570
|
+
/** Synthetic/internal actions (`__TEST_ACTION__`, `__NOOP_ACTION__`, ...) and built-ins. */
|
|
571
|
+
const isInternalAction = (action) => typeof action !== 'string' || action.startsWith('__') || BUILTIN_ACTIONS.has(action);
|
|
572
|
+
let seen = new Set();
|
|
573
|
+
/** true the first time `key` is seen (dedupes reports, e.g. per component name). */
|
|
574
|
+
const once = (key) => (seen.has(key) ? false : (seen.add(key), true));
|
|
575
|
+
/** undo once(key): the report it guarded wasn't made (G-470: diagnostics off) */
|
|
576
|
+
const unsee = (key) => void seen.delete(key);
|
|
577
|
+
/** whether once(`key`) has reported already (a check can skip its work: SYG435) */
|
|
578
|
+
const onced = (key) => seen.has(key);
|
|
579
|
+
/**
|
|
580
|
+
* Forget only the once() dedupe set (G-051: renderComponent calls it through the core bridge
|
|
581
|
+
* when an outermost instance starts, so findings are reported again in every test). Unlike
|
|
582
|
+
* resetCheckState() it keeps the checks' registries, e.g. inspect()'s live records.
|
|
583
|
+
*/
|
|
584
|
+
const resetOnce = () => { seen = new Set(); };
|
|
585
|
+
{
|
|
586
|
+
const core = bridge();
|
|
587
|
+
if (core)
|
|
588
|
+
core.resetOnce = resetOnce;
|
|
589
|
+
}
|
|
590
|
+
const resetters = [];
|
|
591
|
+
const onReset = (fn) => { resetters.push(fn); };
|
|
592
|
+
/** Clear every check's dedupe/registry state (tests). */
|
|
593
|
+
function resetCheckState() {
|
|
594
|
+
seen = new Set();
|
|
595
|
+
resetters.forEach(fn => fn());
|
|
596
|
+
}
|
|
597
|
+
const nameOf = (component) => (component && component.name) || 'component';
|
|
598
|
+
const isPlainObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value) &&
|
|
599
|
+
(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
600
|
+
/** Levenshtein distance (small strings only). */
|
|
601
|
+
function distance$1(a, b) {
|
|
602
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
603
|
+
for (let i = 1; i <= a.length; i++) {
|
|
604
|
+
let prev = row[0];
|
|
605
|
+
row[0] = i;
|
|
606
|
+
for (let j = 1; j <= b.length; j++) {
|
|
607
|
+
const tmp = row[j];
|
|
608
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
609
|
+
prev = tmp;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return row[b.length];
|
|
613
|
+
}
|
|
614
|
+
/** Closest candidate to `name` (case-insensitive match or edit distance <= 2), if any. */
|
|
615
|
+
function suggest(name, candidates) {
|
|
616
|
+
let best;
|
|
617
|
+
let bestScore = Infinity;
|
|
618
|
+
for (const c of candidates) {
|
|
619
|
+
if (c === name)
|
|
620
|
+
continue;
|
|
621
|
+
const score = c.toLowerCase() === name.toLowerCase() ? 0 : distance$1(c, name);
|
|
622
|
+
if (score < bestScore) {
|
|
623
|
+
best = c;
|
|
624
|
+
bestScore = score;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return bestScore <= Math.min(2, Math.floor(name.length / 3)) ? best : undefined;
|
|
628
|
+
}
|
|
629
|
+
const didYouMean = (name, candidates) => {
|
|
630
|
+
const s = suggest(name, candidates);
|
|
631
|
+
return s ? ` (did you mean '${s}'?)` : '';
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
const intentActions = new WeakMap();
|
|
635
|
+
// SYG609 (G-110, PLAN-2 E2): sinks/sources that no driver provides. Sinks the core handles
|
|
636
|
+
// itself never need one. renderComponent (mock DOM source) records such sinks and fakes such
|
|
637
|
+
// sources (t.requests / t.respond), so its components are skipped.
|
|
638
|
+
// PLAN-4 GS-2: ELEMENT (element commands) is built in; GS-5: PERSIST is persist()'s (an EFFECT)
|
|
639
|
+
const NO_DRIVER_NEEDED = /^(STATE|EFFECT|PARENT|READY|DOM|CHILD|ELEMENT|PERSIST)$/;
|
|
640
|
+
const DRIVER_NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
641
|
+
// (`in`, not a read: the DOM source's shorthand Proxy returns a function for any property)
|
|
642
|
+
const underTest = (c) => {
|
|
643
|
+
const dom = c?.sources?.[c.DOMSourceName || 'DOM'];
|
|
644
|
+
return !!dom && typeof dom == 'object' && '_hub' in dom;
|
|
645
|
+
};
|
|
646
|
+
const driverFix = (sink) => `Pass a driver named ${sink} to run(): run(App, { ${sink}: makeFetchDriver() }) for HTTP, or driverFromAsync(fn) / your own driver. ` +
|
|
647
|
+
`Check the spelling against the drivers you pass`;
|
|
648
|
+
const wiringCheck = {
|
|
649
|
+
id: 'wiring',
|
|
650
|
+
onIntent(component, actionNames) {
|
|
651
|
+
intentActions.set(component, actionNames || []);
|
|
652
|
+
},
|
|
653
|
+
// SYG609: an intent that reads a source no driver provides (`HTTP.select(...)` with no HTTP
|
|
654
|
+
// driver) would crash with "Cannot read properties of undefined": say why first
|
|
655
|
+
sources(component, sources) {
|
|
656
|
+
if (underTest(component) || typeof Proxy != 'function')
|
|
657
|
+
return;
|
|
658
|
+
const name = nameOf(component);
|
|
659
|
+
return new Proxy(sources, {
|
|
660
|
+
get(t, k) {
|
|
661
|
+
if (typeof k == 'string' && !(k in t) && DRIVER_NAME.test(k) && once(`SYG609:${name}:source:${k}`)) {
|
|
662
|
+
report('SYG609', {
|
|
663
|
+
component,
|
|
664
|
+
message: `${name}.intent reads the ${k} source, but no driver named ${k} was passed to run(), so it is undefined`,
|
|
665
|
+
fix: driverFix(k),
|
|
666
|
+
data: { name: k, kind: 'source', drivers: Object.keys(t).filter(n => DRIVER_NAME.test(n)) },
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
return t[k];
|
|
670
|
+
},
|
|
671
|
+
});
|
|
672
|
+
},
|
|
673
|
+
onModel(component, modelMap) {
|
|
674
|
+
const name = nameOf(component);
|
|
675
|
+
// SYG609: a sink no driver receives is dropped silently by run()
|
|
676
|
+
if (!underTest(component)) {
|
|
677
|
+
const own = new Set(component?.sourceNames || []);
|
|
678
|
+
for (const action of Object.keys(modelMap || {})) {
|
|
679
|
+
for (const sink of modelMap[action] || []) {
|
|
680
|
+
if (own.has(sink) || NO_DRIVER_NEEDED.test(sink) || sink === component?.stateSourceName)
|
|
681
|
+
continue;
|
|
682
|
+
if (!once(`SYG609:${name}:sink:${sink}`))
|
|
683
|
+
continue;
|
|
684
|
+
report('SYG609', {
|
|
685
|
+
component,
|
|
686
|
+
message: `Model entry '${action}' sends to the ${sink} sink, but no driver named ${sink} was passed to run(), so every value sent there is dropped`,
|
|
687
|
+
fix: driverFix(sink),
|
|
688
|
+
data: { name: sink, kind: 'sink', action, drivers: [...own].filter(n => DRIVER_NAME.test(n)) },
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
const intent$ = component && component.intent$;
|
|
694
|
+
const injected = new Set((intent$ && typeof intent$ === 'object' && intent$.__sygnalTestActions) || []);
|
|
695
|
+
const actions = (intentActions.get(component) || []).filter(a => !injected.has(a));
|
|
696
|
+
intentActions.delete(component);
|
|
697
|
+
const modelActions = Object.keys(modelMap || {});
|
|
698
|
+
for (const action of actions) {
|
|
699
|
+
if (isInternalAction(action) || modelActions.includes(action))
|
|
700
|
+
continue;
|
|
701
|
+
if (!once(`SYG101:${name}:${action}`))
|
|
702
|
+
continue;
|
|
703
|
+
report('SYG101', {
|
|
704
|
+
component,
|
|
705
|
+
message: `Intent action '${action}' has no model entry, so it never does anything${didYouMean(action, modelActions)}`,
|
|
706
|
+
fix: `Add '${action}' to ${name}.model, or remove it from ${name}.intent`,
|
|
707
|
+
data: { action, modelActions },
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
const replies = replyNamesOf(component);
|
|
711
|
+
// D199: a behavior's own actions ('tip.SHOW') are its business: a timer, next() or a reply
|
|
712
|
+
// may trigger them (the behavior-owned map, behaviors.ts)
|
|
713
|
+
const owned = component?._behaviorActions || {};
|
|
714
|
+
for (const action of modelActions) {
|
|
715
|
+
if (isInternalAction(action) || actions.includes(action) || injected.has(action) || replies.has(action) || action in owned)
|
|
716
|
+
continue;
|
|
717
|
+
if (!once(`SYG102:${name}:${action}`))
|
|
718
|
+
continue;
|
|
719
|
+
report('SYG102', {
|
|
720
|
+
component,
|
|
721
|
+
message: `Model entry '${action}' has no intent action with that name${didYouMean(action, actions)}`,
|
|
722
|
+
fix: `Add '${action}' to ${name}.intent, name it in a request (ok: '${action}'), or remove the model entry. If it is only dispatched with next('${action}'), ignore this`,
|
|
723
|
+
data: { action, intentActions: actions },
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
},
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
/*
|
|
730
|
+
* Tiny helpers shared by the core and the helper modules (behaviors, undo, the dev entry), kept
|
|
731
|
+
* out of the core so importing them never pulls it in.
|
|
732
|
+
*/
|
|
733
|
+
/** The "no change / send nothing" sentinel (a registered symbol: the same in every copy) */
|
|
734
|
+
/**
|
|
735
|
+
* Whether a value is ABORT. Compares the description, not the identity, in case bundlers (e.g.
|
|
736
|
+
* Vite) create duplicate module instances with separate Symbol.for() registries. (G-214: the
|
|
737
|
+
* one copy, for the core, behaviors, undo and the action log)
|
|
738
|
+
*/
|
|
739
|
+
const isAbort = (v) => typeof v == 'symbol' && v.description == 'sygnal.ABORT';
|
|
740
|
+
/**
|
|
741
|
+
* Set on a dev-only wrapper of a model function (SYG222's) to the function it wraps, so the
|
|
742
|
+
* action log and t.explain() show the user's reducer (G-214 follow-up)
|
|
743
|
+
*/
|
|
744
|
+
const ORIGINAL = Symbol.for('sygnal.original');
|
|
745
|
+
|
|
746
|
+
const SKIP = new Set(['INITIALIZE']);
|
|
747
|
+
// SYG222: the instance's STATE reducers, wrapped (see the header)
|
|
748
|
+
const WRAPPED$1 = Symbol('SYG222');
|
|
749
|
+
function watchMutation(component, action, fn) {
|
|
750
|
+
if (typeof fn !== 'function' || fn[WRAPPED$1])
|
|
751
|
+
return fn;
|
|
752
|
+
const wrapped = (state, ...rest) => {
|
|
753
|
+
if (!isPlainObject(state))
|
|
754
|
+
return fn(state, ...rest);
|
|
755
|
+
// the state's top level, and (G-214) each behavior slice's: a behavior reducer that mutates
|
|
756
|
+
// its slice gets the host state back unchanged (behaviors.ts), so state[key] looks the same
|
|
757
|
+
const snaps = [[state, ''], ...(component._uses || []).map(([k]) => [state[k], k + '.'])]
|
|
758
|
+
.filter(([o]) => isPlainObject(o)).map(([o, p]) => [o, p, Object.keys(o), Object.values(o)]);
|
|
759
|
+
const out = fn(state, ...rest);
|
|
760
|
+
if (out === state) {
|
|
761
|
+
const changed = snaps.flatMap(([o, p, keys, values]) => keys.filter((k, i) => !(k in o) || o[k] !== values[i])
|
|
762
|
+
.concat(Object.keys(o).filter(k => !keys.includes(k))).map((k) => p + k));
|
|
763
|
+
const name = nameOf(component);
|
|
764
|
+
if (changed.length && once(`SYG222:${name}:${action}`)) {
|
|
765
|
+
const list = changed.map(k => `'${k}'`).join(', ');
|
|
766
|
+
devReport('SYG222', {
|
|
767
|
+
component,
|
|
768
|
+
message: `The STATE reducer for '${action}' changed ${list} in place and returned the same object. Returning the object a reducer got means "no change" (as ABORT), so the change is ignored and nothing re-renders`,
|
|
769
|
+
fix: `Return a new object: ${changed[0].includes('.') ? `(slice, data) => ({ ...slice, ${changed[0].split('.')[1]}: … })` : `(state, data) => ({ ...state, ${changed[0]}: … })`}. To write updates as mutations, wrap the reducer in immer's produce()`,
|
|
770
|
+
data: { action, keys: changed },
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
return out;
|
|
775
|
+
};
|
|
776
|
+
wrapped[WRAPPED$1] = true;
|
|
777
|
+
// checks that read reducer source (reply actions) see the original; so do the action log
|
|
778
|
+
// and t.explain() (ORIGINAL)
|
|
779
|
+
wrapped.toString = () => fn.toString();
|
|
780
|
+
wrapped[ORIGINAL] = fn;
|
|
781
|
+
return wrapped;
|
|
782
|
+
}
|
|
783
|
+
const stateCheck = {
|
|
784
|
+
id: 'state',
|
|
785
|
+
// SYG222: checks/next.ts wraps the STATE reducers (watchMutation) through wrapHandler
|
|
786
|
+
onModel(component) {
|
|
787
|
+
// SYG238 (G-393): the initial state is set at construction (D165), before onModel
|
|
788
|
+
const n = component?.__next;
|
|
789
|
+
if (!n?.isRoot || !n.def?.model || n.state !== undefined)
|
|
790
|
+
return;
|
|
791
|
+
const name = nameOf(component);
|
|
792
|
+
if (!once(`SYG238:${name}`))
|
|
793
|
+
return;
|
|
794
|
+
devReport('SYG238', {
|
|
795
|
+
component,
|
|
796
|
+
message: `${name} is a root with a model but no initialState: its state is undefined, so it renders nothing until an action sets one`,
|
|
797
|
+
fix: `Add ${name}.initialState = { … } (the root's start state; a child gets its state from its parent instead)`,
|
|
798
|
+
data: {},
|
|
799
|
+
});
|
|
800
|
+
},
|
|
801
|
+
onReducer(component, action, prevState, nextState) {
|
|
802
|
+
if (typeof action !== 'string' || action.startsWith('__') || SKIP.has(action))
|
|
803
|
+
return;
|
|
804
|
+
const name = nameOf(component);
|
|
805
|
+
if (nextState === undefined) {
|
|
806
|
+
// G-357: returning undefined from a Collection item's reducer is the documented way to
|
|
807
|
+
// remove the item: not reported. In another sub-component it is info, in the root (where
|
|
808
|
+
// it wipes the whole app state) a warning.
|
|
809
|
+
if (component?.__next?.kind === 'item' || !once(`SYG202:${name}:${action}`))
|
|
810
|
+
return;
|
|
811
|
+
const sub = !!component?.isSubComponent;
|
|
812
|
+
report('SYG202', {
|
|
813
|
+
component,
|
|
814
|
+
severity: sub ? 'info' : 'warn',
|
|
815
|
+
message: `The STATE reducer for '${action}' returned undefined` +
|
|
816
|
+
(sub ? ' (only a Collection item removes itself this way)' : ''),
|
|
817
|
+
fix: `Return the new state, e.g. (state, data) => ({ ...state, ... }), or return ABORT to leave the state unchanged`,
|
|
818
|
+
data: { action },
|
|
819
|
+
});
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
if (!isPlainObject(prevState) || !isPlainObject(nextState))
|
|
823
|
+
return;
|
|
824
|
+
// SYG221 (G-143): set('city') spreads the string into the state: { ...state, 0: 'c', 1: 'i', ... }
|
|
825
|
+
const chars = [];
|
|
826
|
+
while (typeof nextState[chars.length] === 'string' && nextState[chars.length].length === 1 && !(chars.length in prevState)) {
|
|
827
|
+
chars.push(nextState[chars.length]);
|
|
828
|
+
}
|
|
829
|
+
if (chars.length > 0 && !(chars.length in nextState)) {
|
|
830
|
+
if (!once(`SYG221:${name}:${action}`))
|
|
831
|
+
return;
|
|
832
|
+
const field = chars.join('');
|
|
833
|
+
const update = /^[A-Za-z_$][\w$]*$/.test(field)
|
|
834
|
+
? `set((state, ${field}) => ({ ${field} }))`
|
|
835
|
+
: `set((state, value) => ({ ['${field}']: value }))`;
|
|
836
|
+
devReport('SYG221', {
|
|
837
|
+
component,
|
|
838
|
+
message: `The STATE reducer for '${action}' added the keys ${chars.map((_, i) => `'${i}'`).slice(0, 4).join(', ')}${chars.length > 4 ? ', …' : ''} ` +
|
|
839
|
+
`(one per character of '${field}'): set() was called with a string, set('${field}'). set() takes an object or a function`,
|
|
840
|
+
fix: `To store the action data in '${field}', pass a function: ${update}. For a fixed value pass an object: set({ ${field}: … })`,
|
|
841
|
+
data: { action, field },
|
|
842
|
+
});
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
const calculated = component?._calculatedFieldNames || null;
|
|
846
|
+
const dropped = Object.keys(prevState).filter(key => !(key in nextState) && prevState[key] !== undefined && !(calculated && calculated.has(key)));
|
|
847
|
+
if (dropped.length === 0 || !once(`SYG201:${name}:${action}`))
|
|
848
|
+
return;
|
|
849
|
+
const list = dropped.map(k => `'${k}'`).join(', ');
|
|
850
|
+
report('SYG201', {
|
|
851
|
+
component,
|
|
852
|
+
message: `The STATE reducer for '${action}' returned a state without ${list}, which ${dropped.length === 1 ? 'was' : 'were'} in the previous state (likely a missing ...state spread)`,
|
|
853
|
+
fix: `Spread the previous state: (state, data) => ({ ...state, ... }). If removing ${dropped.length === 1 ? 'the key' : 'these keys'} is intended, ignore this`,
|
|
854
|
+
data: { action, droppedKeys: dropped },
|
|
855
|
+
});
|
|
856
|
+
},
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
let selected$1 = new Set();
|
|
860
|
+
let emitted = new Set();
|
|
861
|
+
let selectsAll = false;
|
|
862
|
+
onReset(() => { selected$1 = new Set(); emitted = new Set(); selectsAll = false; });
|
|
863
|
+
const eventsCheck = {
|
|
864
|
+
id: 'events',
|
|
865
|
+
onBusSelect(type) {
|
|
866
|
+
if (type === undefined || type === null || type === '') {
|
|
867
|
+
selectsAll = true;
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
for (const t of [].concat(type))
|
|
871
|
+
if (typeof t === 'string')
|
|
872
|
+
selected$1.add(t);
|
|
873
|
+
},
|
|
874
|
+
onBusEmit(type, emitterName) {
|
|
875
|
+
if (typeof type !== 'string') {
|
|
876
|
+
// SYG116 (G-143): no EVENTS.select() can ever receive it. The core copies each emitted
|
|
877
|
+
// value into a new object (devtools stamp), so a function arrives here as {}.
|
|
878
|
+
if (once(`SYG116:${emitterName}`)) {
|
|
879
|
+
devReport('SYG116', {
|
|
880
|
+
component: emitterName,
|
|
881
|
+
message: `${emitterName ? emitterName + "'s model" : 'A model'} sent a value with no string 'type' to the EVENTS sink, so nothing receives it. ` +
|
|
882
|
+
`The value was probably a function (a model entry that returns event(...) instead of being event(...)), or undefined/null`,
|
|
883
|
+
fix: `Make event() the sink entry itself: ACTION: { EVENTS: event('TYPE', (state, data) => payload) }; return ABORT to send nothing`,
|
|
884
|
+
data: { type, emitter: emitterName },
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
emitted.add(type);
|
|
890
|
+
if (selectsAll || selected$1.has(type) || !once(`SYG105:emit:${type}`))
|
|
891
|
+
return;
|
|
892
|
+
report('SYG105', {
|
|
893
|
+
component: emitterName,
|
|
894
|
+
message: `EVENTS event '${type}' was emitted, but nothing selects it, so it was dropped${didYouMean(type, selected$1)}`,
|
|
895
|
+
fix: `Subscribe with EVENTS.select('${type}') in the component that should react, or check the event name`,
|
|
896
|
+
data: { type, direction: 'emitted-not-selected', emitter: emitterName },
|
|
897
|
+
});
|
|
898
|
+
},
|
|
899
|
+
};
|
|
900
|
+
/**
|
|
901
|
+
* Report SYG105 (info) for every EVENTS type that has been selected but never
|
|
902
|
+
* emitted so far, and return the bus registry. Intended for inspect tooling and
|
|
903
|
+
* tests, at a point where the session is "done".
|
|
904
|
+
*/
|
|
905
|
+
function checkEventBus() {
|
|
906
|
+
const selectedNeverEmitted = [...selected$1].filter(t => !emitted.has(t));
|
|
907
|
+
const emittedNeverSelected = selectsAll ? [] : [...emitted].filter(t => !selected$1.has(t));
|
|
908
|
+
for (const type of selectedNeverEmitted) {
|
|
909
|
+
if (!once(`SYG105:select:${type}`))
|
|
910
|
+
continue;
|
|
911
|
+
report('SYG105', {
|
|
912
|
+
message: `EVENTS.select('${type}') is used, but no component has emitted '${type}'${didYouMean(type, emitted)}`,
|
|
913
|
+
fix: `Emit it from a model entry, e.g. { EVENTS: event('${type}', ...) }, or check the event name`,
|
|
914
|
+
data: { type, direction: 'selected-not-emitted' },
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
return { selected: [...selected$1], emitted: [...emitted], selectedNeverEmitted, emittedNeverSelected };
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
registerCodes(Object.keys(STRICT_CODE_SEVERITY).map(code => [code, STRICT_CODE_SEVERITY[code], CODE_TITLES[code]]));
|
|
921
|
+
/**
|
|
922
|
+
* Turn runtime strict checks on (true), off (false), or back to the default
|
|
923
|
+
* (undefined → globalThis.__SYGNAL_STRICT__ === true).
|
|
924
|
+
*/
|
|
925
|
+
function configureStrict(on) {
|
|
926
|
+
const core = bridge();
|
|
927
|
+
if (core)
|
|
928
|
+
core.strict = on;
|
|
929
|
+
}
|
|
930
|
+
/** Whether runtime strict checks are on. */
|
|
931
|
+
function isStrictEnabled() {
|
|
932
|
+
const flag = bridge()?.strict;
|
|
933
|
+
return flag === undefined ? globalThis.__SYGNAL_STRICT__ === true : flag === true;
|
|
934
|
+
}
|
|
935
|
+
/** The strict check's runtime half is SYG508 (replies.ts); this entry keeps the flag. */
|
|
936
|
+
const strictCheck = {
|
|
937
|
+
id: 'strict',
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
// PLAN-4 GS-9: uid (the view's uid(name?) function)
|
|
941
|
+
// (R5: 6.0 passes no `peers`, D164: a prop of that name reaches the view)
|
|
942
|
+
const RESERVED$1 = ['children', 'slots', 'context', 'uid'];
|
|
943
|
+
const propsCheck = {
|
|
944
|
+
id: 'props',
|
|
945
|
+
onRender(component) {
|
|
946
|
+
const props = component && component.currentProps;
|
|
947
|
+
if (!props || typeof props !== 'object')
|
|
948
|
+
return;
|
|
949
|
+
const name = nameOf(component);
|
|
950
|
+
const severity = isStrictEnabled() ? 'error' : 'warn';
|
|
951
|
+
for (const key of RESERVED$1) {
|
|
952
|
+
if (!(key in props) || !once(`SYG106:${name}:${key}`))
|
|
953
|
+
continue;
|
|
954
|
+
report('SYG106', {
|
|
955
|
+
component,
|
|
956
|
+
severity,
|
|
957
|
+
message: `The prop '${key}' passed to ${name} is overwritten: '${key}' is a reserved view argument, so the child never sees the parent's value`,
|
|
958
|
+
fix: `Rename the prop (for example '${key}Value'), in the parent and in ${name}`,
|
|
959
|
+
data: { prop: key },
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
if ('state' in props) {
|
|
963
|
+
const value = props.state;
|
|
964
|
+
const isLens = typeof value === 'string' || (value && typeof value === 'object' && typeof value.get === 'function');
|
|
965
|
+
if (!isLens && value !== undefined && once(`SYG106:${name}:state`)) {
|
|
966
|
+
report('SYG106', {
|
|
967
|
+
component,
|
|
968
|
+
severity,
|
|
969
|
+
message: `The prop 'state' passed to ${name} is not data: Sygnal reads it as the child's state lens (a state field name or a { get, set } object), and the view's 'state' argument is the child's own state`,
|
|
970
|
+
fix: `To give ${name} a slice of the parent state use state="fieldName"; to pass data, use another prop name (for example 'item')`,
|
|
971
|
+
data: { prop: 'state', valueType: typeof value },
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
},
|
|
976
|
+
};
|
|
977
|
+
|
|
978
|
+
const describe$1 = (v) => v === null ? 'null' : v === undefined ? 'undefined' : Array.isArray(v) ? 'an array' : typeof v === 'object' ? 'an object' : `a ${typeof v}`;
|
|
979
|
+
function checkCollection(component, el) {
|
|
980
|
+
const props = (el && el.data && el.data.props) || {};
|
|
981
|
+
const from = props.from;
|
|
982
|
+
const of = props.of;
|
|
983
|
+
const ofName = typeof of === 'function' ? (of.componentName || of.label || of.name || 'component') : String(of);
|
|
984
|
+
const name = nameOf(component);
|
|
985
|
+
let state = component.currentState;
|
|
986
|
+
try {
|
|
987
|
+
if (typeof component.addCalculated === 'function')
|
|
988
|
+
state = component.addCalculated(state);
|
|
989
|
+
}
|
|
990
|
+
catch (_) { }
|
|
991
|
+
let problem;
|
|
992
|
+
let fix = `Pass the name of an array field in ${name}'s state, e.g. <Collection of={${ofName}} from="items" />, and initialize that field to [] in initialState`;
|
|
993
|
+
// Every defined `from` (string field, lens object, or an invalid value) is reported by
|
|
994
|
+
// the core itself (SYG401/SYG412, retrofitted in 1E) in every diagnostics mode (D22), so
|
|
995
|
+
// this check only covers a missing `from`.
|
|
996
|
+
if (from !== undefined)
|
|
997
|
+
return;
|
|
998
|
+
if (!Array.isArray(state) && !(state && Array.isArray(state.value))) {
|
|
999
|
+
problem = `has no 'from' prop, so it uses ${name}'s whole state, which is ${describe$1(state)}, not an array`;
|
|
1000
|
+
}
|
|
1001
|
+
if (!problem || !once(`SYG401:${name}:undefined`))
|
|
1002
|
+
return;
|
|
1003
|
+
reportSafely('SYG401', {
|
|
1004
|
+
component,
|
|
1005
|
+
message: `<Collection of={${ofName}}> ${problem}, so it renders no items`,
|
|
1006
|
+
fix,
|
|
1007
|
+
data: { from: 'undefined', of: ofName },
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
const WRAPPED = '__sygnalDiagWrapped';
|
|
1011
|
+
const collectionsCheck = {
|
|
1012
|
+
id: 'collections',
|
|
1013
|
+
onModel(component) {
|
|
1014
|
+
const original = component && component.instantiateCollection;
|
|
1015
|
+
if (typeof original !== 'function' || original[WRAPPED])
|
|
1016
|
+
return;
|
|
1017
|
+
const wrapped = function (el, ...rest) {
|
|
1018
|
+
try {
|
|
1019
|
+
checkCollection(this, el);
|
|
1020
|
+
}
|
|
1021
|
+
catch (_) { }
|
|
1022
|
+
return original.call(this, el, ...rest);
|
|
1023
|
+
};
|
|
1024
|
+
wrapped[WRAPPED] = true;
|
|
1025
|
+
component.instantiateCollection = wrapped;
|
|
1026
|
+
},
|
|
1027
|
+
};
|
|
1028
|
+
|
|
1029
|
+
function isEqualNamespace(a, b) {
|
|
1030
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
|
|
1031
|
+
return false;
|
|
1032
|
+
}
|
|
1033
|
+
for (let i = 0; i < a.length; i++) {
|
|
1034
|
+
if (a[i].type !== b[i].type || a[i].scope !== b[i].scope) {
|
|
1035
|
+
return false;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
return true;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
class ScopeChecker {
|
|
1042
|
+
constructor(namespace, isolateModule) {
|
|
1043
|
+
this.namespace = namespace;
|
|
1044
|
+
this.isolateModule = isolateModule;
|
|
1045
|
+
this._namespace = namespace.filter(n => n.type !== 'selector');
|
|
1046
|
+
}
|
|
1047
|
+
isDirectlyInScope(leaf) {
|
|
1048
|
+
const namespace = this.isolateModule.getNamespace(leaf);
|
|
1049
|
+
if (!namespace) {
|
|
1050
|
+
return false;
|
|
1051
|
+
}
|
|
1052
|
+
if (this._namespace.length > namespace.length ||
|
|
1053
|
+
!isEqualNamespace(this._namespace, namespace.slice(0, this._namespace.length))) {
|
|
1054
|
+
return false;
|
|
1055
|
+
}
|
|
1056
|
+
for (let i = this._namespace.length; i < namespace.length; i++) {
|
|
1057
|
+
if (namespace[i].type === 'total') {
|
|
1058
|
+
return false;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
return true;
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/** the key of a control, from its selector `[data-control="<Key>"]` */
|
|
1066
|
+
const controlKey = (control) => String(control).slice(15, -2);
|
|
1067
|
+
/** a control selector `[data-control="<Key>"]` as its key, else undefined */
|
|
1068
|
+
const keyOfSelector = (selector) => {
|
|
1069
|
+
const m = /^\[data-control="([^"]+)"\]$/.exec(selector);
|
|
1070
|
+
return m ? m[1] : undefined;
|
|
1071
|
+
};
|
|
1072
|
+
const isControl$1 = (x) => typeof x == 'function' && !!x.__sygnalControl;
|
|
1073
|
+
const componentName = (fn) => fn.componentName || fn.label || fn.name || 'the component';
|
|
1074
|
+
let vnodeControls = new WeakMap();
|
|
1075
|
+
let staticsSeen = new WeakSet();
|
|
1076
|
+
onReset(() => {
|
|
1077
|
+
vnodeControls = new WeakMap();
|
|
1078
|
+
staticsSeen = new WeakSet();
|
|
1079
|
+
});
|
|
1080
|
+
/** the control that rendered a vnode (only while the dev entry is installed) */
|
|
1081
|
+
const controlOfVnode = (vnode) => vnode && typeof vnode == 'object' ? vnodeControls.get(vnode) : undefined;
|
|
1082
|
+
const STATICS$1 = ['intent', 'model', 'initialState'];
|
|
1083
|
+
const describe = (v) => v === undefined || v === null ? String(v)
|
|
1084
|
+
: Array.isArray(v) ? `an array of ${v.length}`
|
|
1085
|
+
: typeof v != 'object' ? `a ${typeof v} (${JSON.stringify(v)})`
|
|
1086
|
+
: typeof v.sel != 'string' ? (v.text !== undefined ? 'a text vnode' : 'a fragment')
|
|
1087
|
+
// the pragma's component placeholder (the component function in data.c)
|
|
1088
|
+
: v.data && typeof v.data.c == 'function' ? `the component <${v.sel}>`
|
|
1089
|
+
: 'an element vnode';
|
|
1090
|
+
function onControl(control, vnode) {
|
|
1091
|
+
const key = controlKey(control);
|
|
1092
|
+
if (vnode && typeof vnode == 'object')
|
|
1093
|
+
vnodeControls.set(vnode, control);
|
|
1094
|
+
if (!staticsSeen.has(control)) {
|
|
1095
|
+
staticsSeen.add(control);
|
|
1096
|
+
const given = STATICS$1.filter(s => control[s] != null);
|
|
1097
|
+
if (given.length) {
|
|
1098
|
+
devReport('SYG125', {
|
|
1099
|
+
message: `The control ${key} has ${given.map(s => '.' + s).join(', ')}, but controls are elements, not components: ${given.length > 1 ? 'these statics are' : 'it is'} never used`,
|
|
1100
|
+
fix: `Put the intent and model on the component that renders <${key}>, and listen to the control in its intent: DOM.click(${key}). For something with its own state and actions, write a component instead`,
|
|
1101
|
+
data: { control: key, statics: given },
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
const what = describe(vnode);
|
|
1106
|
+
if (what == 'an element vnode')
|
|
1107
|
+
return;
|
|
1108
|
+
const kind = control.kind;
|
|
1109
|
+
// report() (not devReport): in 'error' mode its DiagnosticError is thrown into the render
|
|
1110
|
+
const d = report('SYG125', {
|
|
1111
|
+
severity: DEV_CODE_SEVERITY.SYG125,
|
|
1112
|
+
message: `The control ${key}'s spec${kind ? ` (kind '${kind}')` : ''} vnode() returned ${what}, but it must return one element vnode`,
|
|
1113
|
+
fix: `Return a single element built with the h argument: vnode(props, children, h) { return h('div', props, ...children) }`,
|
|
1114
|
+
data: { control: key, kind, returned: what },
|
|
1115
|
+
});
|
|
1116
|
+
if (d)
|
|
1117
|
+
throw new Error(d.text);
|
|
1118
|
+
}
|
|
1119
|
+
/** Publish the control hook on the core bridge. Returns an uninstall function. */
|
|
1120
|
+
function installControlHooks() {
|
|
1121
|
+
const core = bridge();
|
|
1122
|
+
if (!core)
|
|
1123
|
+
return () => { };
|
|
1124
|
+
core.control = onControl;
|
|
1125
|
+
return () => { if (core.control === onControl)
|
|
1126
|
+
core.control = undefined; };
|
|
1127
|
+
}
|
|
1128
|
+
const wrapped$1 = new WeakMap();
|
|
1129
|
+
/** a DOM source whose select() and event shorthands report SYG124 for a component argument */
|
|
1130
|
+
function checkedDOM$1(component, dom) {
|
|
1131
|
+
if (!dom || typeof dom != 'object')
|
|
1132
|
+
return dom;
|
|
1133
|
+
let proxy = wrapped$1.get(dom);
|
|
1134
|
+
if (proxy)
|
|
1135
|
+
return proxy;
|
|
1136
|
+
proxy = new Proxy(dom, {
|
|
1137
|
+
get(t, k) {
|
|
1138
|
+
const v = t[k];
|
|
1139
|
+
if (typeof k != 'string' || typeof v != 'function' || (k != 'select' && k in t))
|
|
1140
|
+
return v;
|
|
1141
|
+
return function (arg, ...rest) {
|
|
1142
|
+
if (typeof arg == 'function' && !isControl$1(arg)) {
|
|
1143
|
+
const name = nameOf(component), child = componentName(arg);
|
|
1144
|
+
if (once(`SYG124:${name}:${child}:${k}`)) {
|
|
1145
|
+
const call = k == 'select' ? `DOM.select(${child})` : `DOM.${k}(${child})`;
|
|
1146
|
+
devReport('SYG124', {
|
|
1147
|
+
component,
|
|
1148
|
+
message: `${name}.intent passes the component ${child} to ${call}, but a component is not an element: it has no element of its own to listen on, and its elements are isolated from ${name}`,
|
|
1149
|
+
fix: `Handle the event in ${child} and send it up with PARENT, then read it here with CHILD.select(${child}); or render a control ${name} owns around it: const { ${child}Box } = controls({ ${child}Box: 'div' }), <${child}Box><${child} /></${child}Box>, DOM.click(${child}Box)`,
|
|
1150
|
+
data: { component: child, method: k },
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
const out = v.call(t, arg, ...rest);
|
|
1155
|
+
return k == 'select' ? checkedDOM$1(component, out) : out;
|
|
1156
|
+
};
|
|
1157
|
+
},
|
|
1158
|
+
});
|
|
1159
|
+
wrapped$1.set(dom, proxy);
|
|
1160
|
+
return proxy;
|
|
1161
|
+
}
|
|
1162
|
+
const controlsCheck = {
|
|
1163
|
+
id: 'controls',
|
|
1164
|
+
sources(component, sources) {
|
|
1165
|
+
if (!sources || typeof Proxy != 'function')
|
|
1166
|
+
return;
|
|
1167
|
+
const domName = component?.DOMSourceName || 'DOM';
|
|
1168
|
+
if (!sources[domName])
|
|
1169
|
+
return;
|
|
1170
|
+
return new Proxy(sources, {
|
|
1171
|
+
get(t, k) {
|
|
1172
|
+
const v = t[k];
|
|
1173
|
+
return k === domName ? checkedDOM$1(component, v) : v;
|
|
1174
|
+
},
|
|
1175
|
+
});
|
|
1176
|
+
},
|
|
1177
|
+
};
|
|
1178
|
+
|
|
1179
|
+
const scopesOf = (ns) => ns.filter(s => s.type !== 'selector');
|
|
1180
|
+
const keyOf$1 = (scopes) => scopes.map(s => `${s.type[0]}:${s.scope}`).join('/');
|
|
1181
|
+
let pending = [];
|
|
1182
|
+
let tracked = new WeakMap();
|
|
1183
|
+
/** isolation-scope key -> the component instance that owns it (latest wins) */
|
|
1184
|
+
let owners = new Map();
|
|
1185
|
+
const ownerKeys = new WeakMap();
|
|
1186
|
+
let matched = new Set();
|
|
1187
|
+
let viewTokens = new WeakMap();
|
|
1188
|
+
onReset(() => {
|
|
1189
|
+
pending = [];
|
|
1190
|
+
tracked = new WeakMap();
|
|
1191
|
+
owners = new Map();
|
|
1192
|
+
matched = new Set();
|
|
1193
|
+
});
|
|
1194
|
+
const unref = (t) => { if (t && typeof t.unref === 'function')
|
|
1195
|
+
t.unref(); return t; };
|
|
1196
|
+
function viewSource(component) {
|
|
1197
|
+
const view = component && component.view;
|
|
1198
|
+
if (typeof view !== 'function')
|
|
1199
|
+
return '';
|
|
1200
|
+
let src = viewTokens.get(view);
|
|
1201
|
+
if (src === undefined) {
|
|
1202
|
+
try {
|
|
1203
|
+
src = Function.prototype.toString.call(view);
|
|
1204
|
+
}
|
|
1205
|
+
catch (_) {
|
|
1206
|
+
src = '';
|
|
1207
|
+
}
|
|
1208
|
+
viewTokens.set(view, src);
|
|
1209
|
+
}
|
|
1210
|
+
return src;
|
|
1211
|
+
}
|
|
1212
|
+
/** class / id / tag tokens of a selector: '.a .b-c > #d' -> ['a', 'b-c', 'd'] */
|
|
1213
|
+
const tokensOf = (selector) => (selector.replace(/\[[^\]]*\]|:[\w-]+(\([^)]*\))?/g, ' ').match(/[A-Za-z_][\w-]*/g) || [])
|
|
1214
|
+
// CT-1: a control's key (`<Add>` / `h(Add)` in the view source)
|
|
1215
|
+
.concat(Array.from(selector.matchAll(/\[data-control="([^"]+)"\]/g), m => m[1]));
|
|
1216
|
+
function appearsInView(component, selector) {
|
|
1217
|
+
const src = viewSource(component);
|
|
1218
|
+
if (!src)
|
|
1219
|
+
return false;
|
|
1220
|
+
const tokens = tokensOf(selector);
|
|
1221
|
+
if (!tokens.length)
|
|
1222
|
+
return false;
|
|
1223
|
+
return tokens.every(t => new RegExp(`(^|[^\\w-])${t.replace(/[-]/g, '\\-')}($|[^\\w-])`).test(src));
|
|
1224
|
+
}
|
|
1225
|
+
// G-144: a fragment-rooted component has several root elements
|
|
1226
|
+
function rootElementsOf(t) {
|
|
1227
|
+
try {
|
|
1228
|
+
const im = t.source._isolateModule;
|
|
1229
|
+
return (im && im.getElements(scopesOf(t.source.namespace))) || [];
|
|
1230
|
+
}
|
|
1231
|
+
catch (_) {
|
|
1232
|
+
return [];
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
function childNameFor(t, elements) {
|
|
1236
|
+
const im = t.source._isolateModule;
|
|
1237
|
+
const depth = scopesOf(t.source.namespace).length;
|
|
1238
|
+
for (const el of elements) {
|
|
1239
|
+
let ns;
|
|
1240
|
+
try {
|
|
1241
|
+
ns = im.getNamespace(el);
|
|
1242
|
+
}
|
|
1243
|
+
catch (_) {
|
|
1244
|
+
ns = undefined;
|
|
1245
|
+
}
|
|
1246
|
+
if (!ns)
|
|
1247
|
+
continue;
|
|
1248
|
+
for (let i = depth; i < ns.length; i++) {
|
|
1249
|
+
if (ns[i].type !== 'total')
|
|
1250
|
+
continue;
|
|
1251
|
+
const owner = owners.get(keyOf$1(ns.slice(0, i + 1)));
|
|
1252
|
+
if (owner)
|
|
1253
|
+
return nameOf(owner);
|
|
1254
|
+
break;
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
return undefined;
|
|
1258
|
+
}
|
|
1259
|
+
function report103(component, t, selector, severity) {
|
|
1260
|
+
if (!once(`SYG103:${severity}:${t.name}:${selector}`))
|
|
1261
|
+
return;
|
|
1262
|
+
reportSafely('SYG103', {
|
|
1263
|
+
component,
|
|
1264
|
+
severity,
|
|
1265
|
+
message: `DOM.select('${selector}') in ${t.name} has not matched any element ${t.name} rendered` +
|
|
1266
|
+
(severity === 'warn' ? ` (after ${t.renders} renders)` : ''),
|
|
1267
|
+
fix: `Check the selector against the view's className/id. If the element is rendered by a child component, handle the event inside that child`,
|
|
1268
|
+
data: { selector, renders: t.renders },
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
function sweep(component, t, escalate) {
|
|
1272
|
+
if (t.disposed)
|
|
1273
|
+
return;
|
|
1274
|
+
const roots = rootElementsOf(t).filter(r => typeof r.querySelectorAll === 'function');
|
|
1275
|
+
if (!roots.length)
|
|
1276
|
+
return;
|
|
1277
|
+
const im = t.source._isolateModule;
|
|
1278
|
+
const checker = new ScopeChecker(t.source.namespace, im);
|
|
1279
|
+
let unmatched = false;
|
|
1280
|
+
for (const selector of t.selectors) {
|
|
1281
|
+
const matchKey = `${t.name}\u0000${selector}`;
|
|
1282
|
+
if (matched.has(matchKey) || t.crossed.has(selector))
|
|
1283
|
+
continue;
|
|
1284
|
+
let all;
|
|
1285
|
+
try {
|
|
1286
|
+
all = [];
|
|
1287
|
+
for (const root of roots) {
|
|
1288
|
+
all.push(...Array.prototype.slice.call(root.querySelectorAll(selector)));
|
|
1289
|
+
if (root.matches(selector))
|
|
1290
|
+
all.push(root);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
catch (_) {
|
|
1294
|
+
continue; // invalid selector: the DOM driver reports that itself
|
|
1295
|
+
}
|
|
1296
|
+
const own = all.filter(el => { try {
|
|
1297
|
+
return checker.isDirectlyInScope(el);
|
|
1298
|
+
}
|
|
1299
|
+
catch (_) {
|
|
1300
|
+
return false;
|
|
1301
|
+
} });
|
|
1302
|
+
if (own.length) {
|
|
1303
|
+
matched.add(matchKey);
|
|
1304
|
+
continue;
|
|
1305
|
+
}
|
|
1306
|
+
if (all.length) {
|
|
1307
|
+
t.crossed.add(selector);
|
|
1308
|
+
if (!once(`SYG104:${t.name}:${selector}`))
|
|
1309
|
+
continue;
|
|
1310
|
+
const child = childNameFor(t, all) || 'a child component';
|
|
1311
|
+
// CT-1: a control is named by its identifier
|
|
1312
|
+
const control = keyOfSelector(selector);
|
|
1313
|
+
reportSafely('SYG104', {
|
|
1314
|
+
component,
|
|
1315
|
+
message: `${control ? `The control ${control}` : `DOM.select('${selector}')`} in ${t.name} matches elements inside ${child} (isolated), so ${t.name} never receives their events`,
|
|
1316
|
+
fix: `Handle the event in ${child}${control ? ` (DOM.<event>(${control}) in its intent)` : ''} and send it up with PARENT (read it here with CHILD.select(${child})), or use EVENTS`,
|
|
1317
|
+
data: control ? { selector, child, control } : { selector, child },
|
|
1318
|
+
});
|
|
1319
|
+
continue;
|
|
1320
|
+
}
|
|
1321
|
+
if (appearsInView(component, selector))
|
|
1322
|
+
continue;
|
|
1323
|
+
unmatched = true;
|
|
1324
|
+
if (escalate && t.renders >= timing.minRenders)
|
|
1325
|
+
report103(component, t, selector, 'warn');
|
|
1326
|
+
else
|
|
1327
|
+
report103(component, t, selector, 'info');
|
|
1328
|
+
}
|
|
1329
|
+
if (unmatched && !escalate) {
|
|
1330
|
+
clearTimeout(t.idleTimer);
|
|
1331
|
+
t.idleTimer = unref(setTimeout(() => { t.idleTimer = undefined; sweep(component, t, true); }, timing.idleMs));
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
/**
|
|
1335
|
+
* inspect (2B, read-only): a component's intent selectors on a real DOM source
|
|
1336
|
+
* and whether a sweep has seen them match (true), only match inside a child
|
|
1337
|
+
* (crossed), or neither yet. undefined when the component isn't tracked.
|
|
1338
|
+
*/
|
|
1339
|
+
function selectorStatus(component) {
|
|
1340
|
+
const t = tracked.get(component);
|
|
1341
|
+
return t && t.selectors.map(selector => ({
|
|
1342
|
+
selector,
|
|
1343
|
+
matched: matched.has(`${t.name}\u0000${selector}`) ? true : t.crossed.has(selector) ? false : null,
|
|
1344
|
+
crossed: t.crossed.has(selector),
|
|
1345
|
+
}));
|
|
1346
|
+
}
|
|
1347
|
+
const domCheck = {
|
|
1348
|
+
id: 'dom',
|
|
1349
|
+
onSelector(source, selector) {
|
|
1350
|
+
const ns = source && source.namespace;
|
|
1351
|
+
if (!Array.isArray(ns) || typeof selector !== 'string' || !selector)
|
|
1352
|
+
return;
|
|
1353
|
+
const prefix = ns.filter(s => s.type === 'selector').map(s => s.scope).join(' ');
|
|
1354
|
+
pending.push({ key: keyOf$1(scopesOf(ns)), selector: prefix ? `${prefix} ${selector}` : selector });
|
|
1355
|
+
if (pending.length > 1000)
|
|
1356
|
+
pending.shift();
|
|
1357
|
+
},
|
|
1358
|
+
onIntent(component) {
|
|
1359
|
+
const claimed = pending;
|
|
1360
|
+
pending = [];
|
|
1361
|
+
const source = component && component.sources && component.sources[component.DOMSourceName || 'DOM'];
|
|
1362
|
+
if (!source || !source._isolateModule || !Array.isArray(source.namespace))
|
|
1363
|
+
return;
|
|
1364
|
+
const key = keyOf$1(scopesOf(source.namespace));
|
|
1365
|
+
const name = nameOf(component);
|
|
1366
|
+
owners.set(key, component);
|
|
1367
|
+
ownerKeys.set(component, key);
|
|
1368
|
+
const selectors = [...new Set(claimed.filter(p => p.key === key).map(p => p.selector))];
|
|
1369
|
+
if (selectors.length)
|
|
1370
|
+
tracked.set(component, { name, source, selectors, renders: 0, crossed: new Set() });
|
|
1371
|
+
},
|
|
1372
|
+
onRender(component) {
|
|
1373
|
+
const t = tracked.get(component);
|
|
1374
|
+
if (!t || t.disposed)
|
|
1375
|
+
return;
|
|
1376
|
+
t.renders++;
|
|
1377
|
+
clearTimeout(t.idleTimer);
|
|
1378
|
+
t.idleTimer = undefined;
|
|
1379
|
+
if (t.settleTimer)
|
|
1380
|
+
return;
|
|
1381
|
+
t.settleTimer = unref(setTimeout(() => {
|
|
1382
|
+
t.settleTimer = undefined;
|
|
1383
|
+
sweep(component, t, false);
|
|
1384
|
+
}, timing.settleMs));
|
|
1385
|
+
},
|
|
1386
|
+
onDispose(component) {
|
|
1387
|
+
const key = ownerKeys.get(component);
|
|
1388
|
+
if (key !== undefined && owners.get(key) === component)
|
|
1389
|
+
owners.delete(key);
|
|
1390
|
+
const t = tracked.get(component);
|
|
1391
|
+
if (!t)
|
|
1392
|
+
return;
|
|
1393
|
+
t.disposed = true;
|
|
1394
|
+
clearTimeout(t.settleTimer);
|
|
1395
|
+
clearTimeout(t.idleTimer);
|
|
1396
|
+
tracked.delete(component);
|
|
1397
|
+
},
|
|
1398
|
+
};
|
|
1399
|
+
|
|
1400
|
+
// Standard DOM / UI event names (lower case). In a browser, `on<name>` on an element or the
|
|
1401
|
+
// window also counts, so newer events are accepted there too.
|
|
1402
|
+
const KNOWN = new Set(('abort afterprint animationcancel animationend animationiteration animationstart auxclick ' +
|
|
1403
|
+
'beforeinput beforeprint beforetoggle beforeunload blur cancel canplay canplaythrough change click close ' +
|
|
1404
|
+
'compositionend compositionstart compositionupdate contextlost contextmenu contextrestored copy cuechange cut ' +
|
|
1405
|
+
'dblclick drag dragend dragenter dragleave dragover dragstart drop durationchange emptied ended error ' +
|
|
1406
|
+
'focus focusin focusout formdata fullscreenchange fullscreenerror gotpointercapture hashchange input invalid ' +
|
|
1407
|
+
'keydown keypress keyup languagechange load loadeddata loadedmetadata loadstart lostpointercapture message ' +
|
|
1408
|
+
'messageerror mousedown mouseenter mouseleave mousemove mouseout mouseover mouseup offline online pagehide ' +
|
|
1409
|
+
'pageshow paste pause play playing pointercancel pointerdown pointerenter pointerleave pointermove pointerout ' +
|
|
1410
|
+
'pointerover pointerrawupdate pointerup popstate progress ratechange rejectionhandled reset resize scroll ' +
|
|
1411
|
+
'scrollend search securitypolicyviolation seeked seeking select selectionchange selectstart slotchange stalled ' +
|
|
1412
|
+
'storage submit suspend timeupdate toggle touchcancel touchend touchmove touchstart transitioncancel ' +
|
|
1413
|
+
'transitionend transitionrun transitionstart unhandledrejection unload visibilitychange volumechange waiting wheel').split(' '));
|
|
1414
|
+
const isKnown = (name) => {
|
|
1415
|
+
// PLAN-5 0-S6: a hyphenated name is a custom element's event (DOM['wa-hover']('.food')): no
|
|
1416
|
+
// standard DOM event has a hyphen, and a bracketed name is no typo for one
|
|
1417
|
+
if (KNOWN.has(name) || name.includes('-'))
|
|
1418
|
+
return true;
|
|
1419
|
+
const on = 'on' + name;
|
|
1420
|
+
const g = globalThis;
|
|
1421
|
+
try {
|
|
1422
|
+
return !!((g.HTMLElement && on in g.HTMLElement.prototype) || (g.window && on in g.window) ||
|
|
1423
|
+
(g.Document && on in g.Document.prototype));
|
|
1424
|
+
}
|
|
1425
|
+
catch (_) {
|
|
1426
|
+
return false;
|
|
1427
|
+
}
|
|
1428
|
+
};
|
|
1429
|
+
// Names agents reach for that are enriched-stream methods or key names, not events
|
|
1430
|
+
const KEY_FIX = (sel, key) => `Listen for the real event and read the key: DOM.keydown(${sel}).key()` +
|
|
1431
|
+
(key ? `.filter(k => k === '${key}')` : '');
|
|
1432
|
+
const SPECIAL = {
|
|
1433
|
+
key: sel => KEY_FIX(sel),
|
|
1434
|
+
keys: sel => KEY_FIX(sel),
|
|
1435
|
+
enter: sel => KEY_FIX(sel, 'Enter'),
|
|
1436
|
+
escape: sel => KEY_FIX(sel, 'Escape'),
|
|
1437
|
+
esc: sel => KEY_FIX(sel, 'Escape'),
|
|
1438
|
+
value: sel => `Listen for the real event and read the value: DOM.input(${sel}).value()`,
|
|
1439
|
+
checked: sel => `Listen for the real event and read the box: DOM.change(${sel}).checked()`,
|
|
1440
|
+
data: sel => `Listen for the real event and read the attribute: DOM.click(${sel}).data('name')`,
|
|
1441
|
+
doubleclick: sel => `Use DOM.dblclick(${sel})`,
|
|
1442
|
+
};
|
|
1443
|
+
const fixFor = (name, selector) => {
|
|
1444
|
+
const sel = typeof selector === 'string' ? `'${selector}'` : 'sel';
|
|
1445
|
+
const special = SPECIAL[name.toLowerCase()];
|
|
1446
|
+
if (special)
|
|
1447
|
+
return special(sel);
|
|
1448
|
+
const s = suggest(name, KNOWN);
|
|
1449
|
+
return (s ? `Did you mean DOM.${s}(${sel})? ` : '') +
|
|
1450
|
+
`If '${name}' is a custom event you dispatch yourself, use the explicit form DOM.select(${sel}).events('${name}')`;
|
|
1451
|
+
};
|
|
1452
|
+
const wrapped = new WeakMap();
|
|
1453
|
+
function checkedDOM(component, dom) {
|
|
1454
|
+
if (!dom || typeof dom != 'object')
|
|
1455
|
+
return dom;
|
|
1456
|
+
let proxy = wrapped.get(dom);
|
|
1457
|
+
if (proxy)
|
|
1458
|
+
return proxy;
|
|
1459
|
+
const name = nameOf(component);
|
|
1460
|
+
proxy = new Proxy(dom, {
|
|
1461
|
+
get(t, k) {
|
|
1462
|
+
const v = t[k];
|
|
1463
|
+
if (typeof k != 'string' || k in t || typeof v != 'function' || isKnown(k))
|
|
1464
|
+
return v;
|
|
1465
|
+
return function (selector, ...rest) {
|
|
1466
|
+
if (once(`SYG115:${name}:${k}`)) {
|
|
1467
|
+
devReport('SYG115', {
|
|
1468
|
+
component,
|
|
1469
|
+
message: `${name}.intent uses DOM.${k}(${typeof selector == 'string' ? `'${selector}'` : '…'}), but '${k}' is not a DOM event, so it never fires`,
|
|
1470
|
+
fix: fixFor(k, selector),
|
|
1471
|
+
data: { event: k, selector },
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
return v.call(this, selector, ...rest);
|
|
1475
|
+
};
|
|
1476
|
+
},
|
|
1477
|
+
});
|
|
1478
|
+
wrapped.set(dom, proxy);
|
|
1479
|
+
return proxy;
|
|
1480
|
+
}
|
|
1481
|
+
const shorthandCheck = {
|
|
1482
|
+
id: 'shorthand',
|
|
1483
|
+
sources(component, sources) {
|
|
1484
|
+
if (!sources || typeof Proxy != 'function')
|
|
1485
|
+
return;
|
|
1486
|
+
const domName = component?.DOMSourceName || 'DOM';
|
|
1487
|
+
if (!sources[domName])
|
|
1488
|
+
return;
|
|
1489
|
+
return new Proxy(sources, {
|
|
1490
|
+
get(t, k) {
|
|
1491
|
+
const v = t[k];
|
|
1492
|
+
return k === domName ? checkedDOM(component, v) : v;
|
|
1493
|
+
},
|
|
1494
|
+
});
|
|
1495
|
+
},
|
|
1496
|
+
};
|
|
1497
|
+
|
|
1498
|
+
/** The element commands Sygnal documents and types (the core runs any method of the element) */
|
|
1499
|
+
const NATIVE_COMMAND_NAMES = ['focus', 'blur', 'select', 'click', 'scrollIntoView', 'showModal', 'show', 'close', 'showPopover', 'hidePopover', 'togglePopover'];
|
|
1500
|
+
const core = () => globalThis.__SYGNAL_DIAGNOSTICS__;
|
|
1501
|
+
const send = (code, details) => {
|
|
1502
|
+
try {
|
|
1503
|
+
core()?.report(code, { severity: DEV_CODE_SEVERITY[code], ...details });
|
|
1504
|
+
}
|
|
1505
|
+
catch (err) {
|
|
1506
|
+
// 'error' mode: rethrown asynchronously (never into the caller)
|
|
1507
|
+
setTimeout(() => { throw err; });
|
|
1508
|
+
}
|
|
1509
|
+
};
|
|
1510
|
+
const isControl = (x) => typeof x == 'function' && !!x.__sygnalControl;
|
|
1511
|
+
const keyOf = (control) => String(control).slice(15, -2);
|
|
1512
|
+
/** a command as written: `{ focus: Email, preventScroll: true }` */
|
|
1513
|
+
const describeCommand = (cmd) => {
|
|
1514
|
+
if (!cmd || typeof cmd != 'object')
|
|
1515
|
+
return JSON.stringify(cmd) ?? String(cmd);
|
|
1516
|
+
const parts = Object.keys(cmd).map(k => {
|
|
1517
|
+
const v = cmd[k];
|
|
1518
|
+
let text;
|
|
1519
|
+
if (isControl(v))
|
|
1520
|
+
text = keyOf(v);
|
|
1521
|
+
else
|
|
1522
|
+
try {
|
|
1523
|
+
text = JSON.stringify(v) ?? String(v);
|
|
1524
|
+
}
|
|
1525
|
+
catch (_) {
|
|
1526
|
+
text = String(v);
|
|
1527
|
+
}
|
|
1528
|
+
return `${k}: ${text}`;
|
|
1529
|
+
});
|
|
1530
|
+
return `{ ${parts.join(', ')} }`;
|
|
1531
|
+
};
|
|
1532
|
+
const distance = (a, b) => {
|
|
1533
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
1534
|
+
for (let i = 1; i <= a.length; i++) {
|
|
1535
|
+
let prev = row[0];
|
|
1536
|
+
row[0] = i;
|
|
1537
|
+
for (let j = 1; j <= b.length; j++) {
|
|
1538
|
+
const cur = row[j];
|
|
1539
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] == b[j - 1] ? 0 : 1));
|
|
1540
|
+
prev = cur;
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
return row[b.length];
|
|
1544
|
+
};
|
|
1545
|
+
const closest = (name, names) => {
|
|
1546
|
+
let best, d = 3;
|
|
1547
|
+
for (const n of names) {
|
|
1548
|
+
const x = n.toLowerCase() == name.toLowerCase() ? 0 : distance(name, n);
|
|
1549
|
+
if (x < d) {
|
|
1550
|
+
d = x;
|
|
1551
|
+
best = n;
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
return best;
|
|
1555
|
+
};
|
|
1556
|
+
const tagOf = (el) => el && typeof el.tagName == 'string' ? `<${el.tagName.toLowerCase()}>` : 'element';
|
|
1557
|
+
const notACommand = (component, name, cmd) => send('SYG641', {
|
|
1558
|
+
component,
|
|
1559
|
+
message: `${name} sent ${describeCommand(cmd)} to the ELEMENT sink, which is not a command`,
|
|
1560
|
+
fix: `Send { <method>: control or selector, ...options }, e.g. { focus: Email }, or an array of them`,
|
|
1561
|
+
data: { command: cmd },
|
|
1562
|
+
});
|
|
1563
|
+
/** SYG640 (no `el`) or SYG641 (an element, but no command of that name) for `cmd` sent by `component` */
|
|
1564
|
+
function reportElementCommand(component, cmd, el) {
|
|
1565
|
+
// a disposed instance's pending commands are dropped silently
|
|
1566
|
+
if (component && component._disposed)
|
|
1567
|
+
return;
|
|
1568
|
+
const name = (component && component.name) || 'component';
|
|
1569
|
+
const method = cmd && typeof cmd == 'object' ? Object.keys(cmd)[0] : undefined;
|
|
1570
|
+
if (method === undefined)
|
|
1571
|
+
return notACommand(component, name, cmd);
|
|
1572
|
+
const target = cmd[method];
|
|
1573
|
+
const what = `ELEMENT ${describeCommand(cmd)}`;
|
|
1574
|
+
const control = isControl(target) ? keyOf(target) : undefined;
|
|
1575
|
+
const data = { method, command: cmd, ...(control ? { control } : { selector: target == null ? target : String(target) }) };
|
|
1576
|
+
if (!el) {
|
|
1577
|
+
const missing = target == null || target === '';
|
|
1578
|
+
return send('SYG640', {
|
|
1579
|
+
component,
|
|
1580
|
+
message: missing
|
|
1581
|
+
? `${what} in ${name} has no target (${String(target)}), so the command was dropped`
|
|
1582
|
+
: `${what} in ${name} matched no element: nothing in ${name}'s own view matches ${control ? `the control ${control}` : `'${String(target)}'`} (waited 1 s), so the command was dropped`,
|
|
1583
|
+
fix: missing
|
|
1584
|
+
? `Give the method a control or a selector: { ${method}: Email }`
|
|
1585
|
+
: `Render the element in ${name}'s view, or send the command from the component that renders it: a child component's elements are isolated from its parent, and a command reaches only its sender's own elements (in a Collection, each item sends its own)`,
|
|
1586
|
+
data,
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
// PLAN-5 W-1: the matched element is a widget's host (selector or control target): SYG142 names
|
|
1590
|
+
// the widget's declared commands
|
|
1591
|
+
const widget = el.__sw?.w;
|
|
1592
|
+
if (widget) {
|
|
1593
|
+
const declared = Object.keys(widget.commands);
|
|
1594
|
+
const hint = closest(method, [...declared, ...NATIVE_COMMAND_NAMES]);
|
|
1595
|
+
return send('SYG142', {
|
|
1596
|
+
component,
|
|
1597
|
+
message: `${what} in ${name}: the matched ${tagOf(el)} is a widget's host, and the widget declares no '${method}' command` +
|
|
1598
|
+
(hint ? ` (did you mean '${hint}'?)` : '') + (declared.length ? `; it declares: ${declared.join(', ')}` : '; it declares none'),
|
|
1599
|
+
fix: `Add '${method}' to the widget's commands (defineWidget({ commands: { ${method}: (instance, options) => … } })), or send one it declares`,
|
|
1600
|
+
data: { ...data, element: tagOf(el), commands: declared },
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
const commands = control && target.spec && typeof target.spec == 'object' && target.spec.commands
|
|
1604
|
+
? Object.keys(target.spec.commands) : [];
|
|
1605
|
+
data.element = tagOf(el);
|
|
1606
|
+
if (commands.length)
|
|
1607
|
+
data.commands = commands;
|
|
1608
|
+
let message, fix;
|
|
1609
|
+
if (NATIVE_COMMAND_NAMES.includes(method)) {
|
|
1610
|
+
message = `${what} in ${name}: the matched ${tagOf(el)} has no ${method}() method${commands.length ? `, and the control ${control}'s spec declares no '${method}' command (it declares: ${commands.join(', ')})` : ''}`;
|
|
1611
|
+
fix = /^(show|showModal|close)$/.test(method)
|
|
1612
|
+
? `${method}() works on a <dialog> element: render the target as a <dialog>`
|
|
1613
|
+
: /Popover$/.test(method)
|
|
1614
|
+
? `The popover methods need an element with the popover attribute (attrs: { popover: 'auto' }) in a browser with the Popover API`
|
|
1615
|
+
: `Target an element that has ${method}()`;
|
|
1616
|
+
}
|
|
1617
|
+
else {
|
|
1618
|
+
const hint = closest(method, [...commands, ...NATIVE_COMMAND_NAMES]);
|
|
1619
|
+
message = `${what} in ${name}: '${method}' is not an element command` + (hint ? ` (did you mean '${hint}'?)` : '') +
|
|
1620
|
+
(el.tagName ? `: the matched ${tagOf(el)} has no such method` : '') +
|
|
1621
|
+
(commands.length ? `, and the control ${control}'s spec declares only: ${commands.join(', ')}` : '');
|
|
1622
|
+
fix = `Use ${commands.length ? `one of its commands (${commands.join(', ')}) or ` : ''}one of: ${NATIVE_COMMAND_NAMES.join(', ')} (or another method of the element, like play). The first key of a command is the method, the others are its options: { scrollIntoView: Row, block: 'nearest' }`;
|
|
1623
|
+
}
|
|
1624
|
+
send('SYG641', { component, message, fix, data });
|
|
1625
|
+
}
|
|
1626
|
+
// methods that change the DOM snabbdom owns (the next patch undoes them or trips over them)
|
|
1627
|
+
const DOM_MUTATORS = /^(remove|append|prepend|before|after|replaceWith|replaceChildren|appendChild|removeChild|insertBefore|replaceChild|insertAdjacent(Element|HTML|Text)|(set|remove|toggle)Attribute(NS|Node)?|attachShadow|setHTML(Unsafe)?|normalize)$/;
|
|
1628
|
+
/** SYG641 when it is sent: not a command object, or a method that changes the DOM Sygnal renders */
|
|
1629
|
+
function checkSentCommand(component, cmd) {
|
|
1630
|
+
if (cmd == null)
|
|
1631
|
+
return;
|
|
1632
|
+
const name = (component && component.name) || 'component';
|
|
1633
|
+
if (typeof cmd != 'object' || Array.isArray(cmd) || !Object.keys(cmd).length)
|
|
1634
|
+
return notACommand(component, name, cmd);
|
|
1635
|
+
const method = Object.keys(cmd)[0], target = cmd[method];
|
|
1636
|
+
if (!DOM_MUTATORS.test(method) || (isControl(target) && target.spec?.commands?.[method]))
|
|
1637
|
+
return;
|
|
1638
|
+
send('SYG641', {
|
|
1639
|
+
component,
|
|
1640
|
+
message: `ELEMENT ${describeCommand(cmd)} in ${name}: ${method}() changes the DOM that ${name}'s view renders, so the next render undoes it or fails`,
|
|
1641
|
+
fix: `Change the state and render the result instead; element commands are for what a view can't express (focus, scrolling, dialogs, popovers, media)`,
|
|
1642
|
+
data: { method, command: cmd },
|
|
1643
|
+
});
|
|
1644
|
+
}
|
|
1645
|
+
/** The dev entry check: checkSentCommand runs on every ELEMENT value through the core's onElementCommand hook (checks/next.ts) */
|
|
1646
|
+
const elementCommandsCheck = {
|
|
1647
|
+
id: 'elementCommands',
|
|
1648
|
+
};
|
|
1649
|
+
/** Publish the element command hook on the core bridge. Returns an uninstall function. */
|
|
1650
|
+
function installElementCommandHooks() {
|
|
1651
|
+
const c = core();
|
|
1652
|
+
if (!c)
|
|
1653
|
+
return () => { };
|
|
1654
|
+
c.elementCommand = reportElementCommand;
|
|
1655
|
+
return () => { if (c.elementCommand === reportElementCommand)
|
|
1656
|
+
c.elementCommand = undefined; };
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
/**
|
|
1660
|
+
* PLAN-5 W-1: widget diagnostics (dev entry; `defineWidget` in src/extra/widget.ts carries no text).
|
|
1661
|
+
*
|
|
1662
|
+
* The widget module calls `__SYGNAL_DIAGNOSTICS__.widget(code, widgetTag, owner, extra)`:
|
|
1663
|
+
* 140 dispatch(name) (mount's third parameter) — SYG140 (warn) when `name` is not in the widget's `events`: sygnal-check and
|
|
1664
|
+
* the docs read `events`, so an undeclared name is a typo or a missing declaration
|
|
1665
|
+
* 143 the widget tag used as a selector (DOM.select(DatePicker), `${DatePicker}`) — SYG143
|
|
1666
|
+
* (warn, once per widget): it matches nothing
|
|
1667
|
+
* 144 a host mounted — SYG144 (info, once per widget): a declared event name the host element
|
|
1668
|
+
* also fires natively (`'change'` on an `<input>` host), so the intent sees both; not
|
|
1669
|
+
* for the first-party Zag parts (`def.$own`: Menu's `select`, D211)
|
|
1670
|
+
* 660/661/662 mount / update / unmount threw (`extra` is the error) — SYG660–662 (error);
|
|
1671
|
+
* 661 with a fifth argument (G-580): the widget reported the error through mount's
|
|
1672
|
+
* error(e) (its own timer or callback), worded as such (data.phase 'error')
|
|
1673
|
+
* 669 fromZag (G-410): its render returned Sygnal components / widget tags / special JSX
|
|
1674
|
+
* (`extra`: their tags, e.g. ['<Badge>', '<Transition>']) — SYG669 (warn, once per widget)
|
|
1675
|
+
* It returns the reported diagnostic (the widget module logs a bare `[Sygnal SYG66x]` otherwise).
|
|
1676
|
+
*
|
|
1677
|
+
* SYG141 (listening to an event a widget doesn't declare) is static only (sygnal-check): the
|
|
1678
|
+
* runtime doesn't see which listener a selector serves. SYG142 for an undeclared command sent to a
|
|
1679
|
+
* widget's host is reported by the element command check (checks/elementCommands.ts).
|
|
1680
|
+
*/
|
|
1681
|
+
/** a widget's name for messages: `name` from its definition, else its host tag */
|
|
1682
|
+
const widgetName = (w) => w?.def?.name ? `widget ${w.def.name}` : `widget <${w?.def?.tag || 'div'}>`;
|
|
1683
|
+
const ids = new WeakMap();
|
|
1684
|
+
let next = 0;
|
|
1685
|
+
const idOf = (w) => {
|
|
1686
|
+
let id = ids.get(w);
|
|
1687
|
+
if (id === undefined)
|
|
1688
|
+
ids.set(w, id = ++next);
|
|
1689
|
+
return id;
|
|
1690
|
+
};
|
|
1691
|
+
const PHASE = { 660: 'mount', 661: 'update', 662: 'unmount' };
|
|
1692
|
+
function reportWidget(code, w, owner, x, reported) {
|
|
1693
|
+
const name = widgetName(w), component = owner ? nameOf(owner) : name;
|
|
1694
|
+
if (code == 140) {
|
|
1695
|
+
if (w.events.includes(x) || !once(`SYG140:${idOf(w)}:${x}`))
|
|
1696
|
+
return;
|
|
1697
|
+
return devReport('SYG140', {
|
|
1698
|
+
component,
|
|
1699
|
+
message: `${name} dispatched '${x}', which is not one of its declared events (${w.events.length ? w.events.join(', ') : 'it declares none'})`,
|
|
1700
|
+
fix: `Add '${x}' to the widget's events (defineWidget({ events: [${[...w.events, x].map((e) => `'${e}'`).join(', ')}] })), or dispatch a declared name`,
|
|
1701
|
+
data: { event: x, events: w.events },
|
|
1702
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
if (code == 143) {
|
|
1705
|
+
if (!once(`SYG143:${idOf(w)}`))
|
|
1706
|
+
return;
|
|
1707
|
+
return devReport('SYG143', {
|
|
1708
|
+
component: name,
|
|
1709
|
+
message: `${name} was used as a selector (DOM.select(${w.def.name || 'Widget'}) or in a template string); a widget tag matches nothing`,
|
|
1710
|
+
fix: `Give the widget a className and select that (<${w.def.name || 'Widget'} className="due" />, DOM.select('.due')), or make it a control: controls({ Due: ${w.def.name || 'Widget'} })`,
|
|
1711
|
+
});
|
|
1712
|
+
}
|
|
1713
|
+
if (code == 144) {
|
|
1714
|
+
// D211: not for Sygnal's own parts (Menu's `select` is its documented event)
|
|
1715
|
+
if (!x || w.def.$own || !once(`SYG144:${idOf(w)}`))
|
|
1716
|
+
return;
|
|
1717
|
+
const native = w.events.filter((e) => 'on' + e in x);
|
|
1718
|
+
if (!native.length)
|
|
1719
|
+
return;
|
|
1720
|
+
return devReport('SYG144', {
|
|
1721
|
+
component: name,
|
|
1722
|
+
message: `${name} declares ${native.map((e) => `'${e}'`).join(', ')}, which its <${x.localName}> host also fires natively, so a listener gets both the widget's events and the element's`,
|
|
1723
|
+
fix: `Name the widget's event differently (e.g. 'pick' for a date picker's change), or read only what both carry`,
|
|
1724
|
+
data: { events: native, element: x.localName },
|
|
1725
|
+
});
|
|
1726
|
+
}
|
|
1727
|
+
if (code == 669) {
|
|
1728
|
+
// G-470: once per widget tag, counted only when reported (not with diagnostics off)
|
|
1729
|
+
const k = `SYG669:${idOf(w)}`;
|
|
1730
|
+
if (!once(k))
|
|
1731
|
+
return;
|
|
1732
|
+
const d = devReport('SYG669', {
|
|
1733
|
+
component: name,
|
|
1734
|
+
message: `${name}'s fromZag render returned ${x.join(', ')}; the adapter patches its render outside the component tree, so they don't run there (a component isn't instantiated, a widget doesn't mount, special JSX renders as an unknown element)`,
|
|
1735
|
+
fix: `Render plain elements in the fromZag render (with Zag's prop getters spread on them), pass data in through the widget's props, and put components, widgets and special JSX around the widget tag in the view`,
|
|
1736
|
+
data: { found: x },
|
|
1737
|
+
});
|
|
1738
|
+
if (!d)
|
|
1739
|
+
unsee(k);
|
|
1740
|
+
return d;
|
|
1741
|
+
}
|
|
1742
|
+
const phase = PHASE[code];
|
|
1743
|
+
if (!phase)
|
|
1744
|
+
return;
|
|
1745
|
+
const sev = DEV_CODE_SEVERITY[`SYG${code}`];
|
|
1746
|
+
// G-580: a 661 from error(e) is not a throwing update()
|
|
1747
|
+
const by = code == 661 && reported;
|
|
1748
|
+
const d = devReport(`SYG${code}`, {
|
|
1749
|
+
component,
|
|
1750
|
+
message: (by ? `${name} reported an error through error(e) (mount's fourth parameter)` : `${name}'s ${phase}() threw`) + `: ${x?.message ?? x}` +
|
|
1751
|
+
(code < 662 ? `; ${owner ? `${nameOf(owner)}'s onError fallback renders in its place` : 'it is not mounted'}` : ''),
|
|
1752
|
+
fix: code < 662
|
|
1753
|
+
? `Fix ${by ? 'the failure the widget reports through error(e) (its own timer, callback or redraw)' : `the widget's ${phase}()`}, or give ${owner ? nameOf(owner) : 'the component'} an .onError for a custom fallback`
|
|
1754
|
+
: `Fix the widget's unmount()`,
|
|
1755
|
+
data: { phase: by ? 'error' : phase, error: x },
|
|
1756
|
+
severity: sev,
|
|
1757
|
+
});
|
|
1758
|
+
// the error with its stack (the text is printed by report() in 'warn' mode, collected otherwise)
|
|
1759
|
+
if (d)
|
|
1760
|
+
console.error(x);
|
|
1761
|
+
return d;
|
|
1762
|
+
}
|
|
1763
|
+
/** Publish the widget hook on the core bridge. Returns an uninstall function. */
|
|
1764
|
+
function installWidgetHooks() {
|
|
1765
|
+
const c = globalThis.__SYGNAL_DIAGNOSTICS__;
|
|
1766
|
+
if (!c)
|
|
1767
|
+
return () => { };
|
|
1768
|
+
c.widget = reportWidget;
|
|
1769
|
+
return () => { if (c.widget === reportWidget)
|
|
1770
|
+
c.widget = undefined; };
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
/**
|
|
1774
|
+
* PLAN-5 V-1: `<VirtualCollection>` diagnostics (dev entry; src/extra/virtual.ts carries no text).
|
|
1775
|
+
*
|
|
1776
|
+
* The VirtualCollection host calls `__SYGNAL_DIAGNOSTICS__.virtual(code, owner, extra)`:
|
|
1777
|
+
* 430 the container has no bounded height: `extra.reason` 'height' (laid out 0 px tall) or
|
|
1778
|
+
* 'grows' (as tall as all its rows and taller than the viewport, so it would render every
|
|
1779
|
+
* row); the window is clamped to the viewport (once per host and reason)
|
|
1780
|
+
* 431 items without `id` (index keys; once per host)
|
|
1781
|
+
* 432 an item renders a fragment or text, not one element (once per host)
|
|
1782
|
+
* 433 scrollToIndex / scrollToId for an index or id not in the (filtered) list
|
|
1783
|
+
* 434 estimateSize / overscan not a positive number (or a function, for estimateSize)
|
|
1784
|
+
*/
|
|
1785
|
+
const tag = (owner) => `<VirtualCollection> in ${nameOf(owner)}`;
|
|
1786
|
+
function reportVirtual(code, owner, x = {}) {
|
|
1787
|
+
const component = owner ? nameOf(owner) : 'VirtualCollection';
|
|
1788
|
+
if (code == 430) {
|
|
1789
|
+
const grows = x.reason == 'grows';
|
|
1790
|
+
return devReport('SYG430', {
|
|
1791
|
+
component,
|
|
1792
|
+
message: grows
|
|
1793
|
+
? `${tag(owner)} grows with its rows (it is ${Math.round(x.height)} px tall, as tall as all of them), so it is not a scroll container; only the rows within the viewport's height render`
|
|
1794
|
+
: `${tag(owner)} is 0 px tall, so no row is in view`,
|
|
1795
|
+
fix: 'Give its className a bounded height (height or max-height, e.g. .rows { height: 400px }, or flex: 1 with min-height: 0 in a flex column); it scrolls itself (overflow-y: auto)',
|
|
1796
|
+
data: x,
|
|
1797
|
+
});
|
|
1798
|
+
}
|
|
1799
|
+
if (code == 431) {
|
|
1800
|
+
return devReport('SYG431', {
|
|
1801
|
+
component,
|
|
1802
|
+
message: `${tag(owner)} has items without an id (${x.count} items): they are keyed by their index, so a row's instance and measured height follow its position, not its item, when items are added, removed or reordered`,
|
|
1803
|
+
fix: "Give every item a unique id (e.g. rows: data.map((r, i) => ({ id: r.key ?? i, ...r })))",
|
|
1804
|
+
data: x,
|
|
1805
|
+
});
|
|
1806
|
+
}
|
|
1807
|
+
if (code == 432) {
|
|
1808
|
+
return devReport('SYG432', {
|
|
1809
|
+
component,
|
|
1810
|
+
message: `${tag(owner)}: its item component ${x.component} renders a fragment or text, so its rows can't be measured, positioned or given aria-posinset`,
|
|
1811
|
+
fix: `Make ${x.component}'s view return one element (wrap its content in a <div> or <li>)`,
|
|
1812
|
+
data: x,
|
|
1813
|
+
});
|
|
1814
|
+
}
|
|
1815
|
+
if (code == 433) {
|
|
1816
|
+
const what = 'id' in x ? `id ${JSON.stringify(x.id) ?? String(x.id)}` : `index ${x.index}`;
|
|
1817
|
+
return devReport('SYG433', {
|
|
1818
|
+
component,
|
|
1819
|
+
message: `${tag(owner)}: ${'id' in x ? 'scrollToId' : 'scrollToIndex'} for ${what}, which is not in its list (${x.count} rows after filter); it didn't scroll`,
|
|
1820
|
+
fix: 'id' in x
|
|
1821
|
+
? 'Send the id of an item in the array the filter keeps: { scrollToId: \'.rows\', id }'
|
|
1822
|
+
: `Send an integer index from 0 to ${Math.max(0, x.count - 1)} into the shown (filtered, sorted) rows: { scrollToIndex: '.rows', index }`,
|
|
1823
|
+
data: x,
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
if (code == 434) {
|
|
1827
|
+
return devReport('SYG434', {
|
|
1828
|
+
component,
|
|
1829
|
+
message: `${tag(owner)}: ${x.prop}={${typeof x.value == 'string' ? JSON.stringify(x.value) : String(x.value)}} is not ${x.prop == 'overscan' ? 'a number ≥ 0; 5 is used' : 'a positive number or a function; 32 is used'}`,
|
|
1830
|
+
fix: x.prop == 'overscan' ? 'Pass overscan={5} (rows rendered beyond each edge)' : 'Pass estimateSize={32} (about a row\'s height in px), or (item, index) => px',
|
|
1831
|
+
data: x,
|
|
1832
|
+
});
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
/** Publish the VirtualCollection hook on the core bridge. Returns an uninstall function. */
|
|
1836
|
+
function installVirtualHooks() {
|
|
1837
|
+
const c = globalThis.__SYGNAL_DIAGNOSTICS__;
|
|
1838
|
+
if (!c)
|
|
1839
|
+
return () => { };
|
|
1840
|
+
c.virtual = reportVirtual;
|
|
1841
|
+
return () => { if (c.virtual === reportVirtual)
|
|
1842
|
+
c.virtual = undefined; };
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
const what$1 = (v) => v === null || v === undefined ? String(v)
|
|
1846
|
+
: typeof v == 'function' ? 'a function (a defineBehavior factory not called?)'
|
|
1847
|
+
: typeof v == 'object' ? 'a plain object'
|
|
1848
|
+
: `a ${typeof v}`;
|
|
1849
|
+
const behaviorsCheck = {
|
|
1850
|
+
id: 'behaviors',
|
|
1851
|
+
onModel(component) {
|
|
1852
|
+
const view = component?.view, uses = view?.uses;
|
|
1853
|
+
if (!uses || typeof uses != 'object' || !once('SYG127:' + nameOf(component)))
|
|
1854
|
+
return;
|
|
1855
|
+
const init = view.initialState;
|
|
1856
|
+
for (const key of Object.keys(uses)) {
|
|
1857
|
+
const b = uses[key];
|
|
1858
|
+
if (typeof b?.merge != 'function') {
|
|
1859
|
+
devReport('SYG127', {
|
|
1860
|
+
component,
|
|
1861
|
+
message: `uses entry '${key}' is ${what$1(b)}, not a defineBehavior() result; it was skipped, so state.${key} and its '${key}.*' actions don't exist`,
|
|
1862
|
+
fix: `Use a defineBehavior() factory's result: uses = { ${key}: behavior(options) }`,
|
|
1863
|
+
data: { key, reason: 'unresolvable' },
|
|
1864
|
+
});
|
|
1865
|
+
}
|
|
1866
|
+
else if (init && typeof init == 'object' && key in init) {
|
|
1867
|
+
devReport('SYG127', {
|
|
1868
|
+
component,
|
|
1869
|
+
message: `uses key '${key}' is also a key of ${nameOf(component)}'s initialState; the behavior's slice replaces that value`,
|
|
1870
|
+
fix: `Remove '${key}' from initialState (pass its values as the behavior's options), or rename the uses key`,
|
|
1871
|
+
data: { key, reason: 'initialState' },
|
|
1872
|
+
});
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
},
|
|
1876
|
+
};
|
|
1877
|
+
|
|
1878
|
+
const isRow = (r) => r?.id != null;
|
|
1879
|
+
// the key of segment `s` in `v`: an array's row with that id, else the index unless the element
|
|
1880
|
+
// there is a row (G-380: a row is named by its id only; -1, no element), an object's key
|
|
1881
|
+
const key = (v, s) => {
|
|
1882
|
+
if (!Array.isArray(v))
|
|
1883
|
+
return s;
|
|
1884
|
+
const i = v.findIndex(r => isRow(r) && r.id + '' == s);
|
|
1885
|
+
return i < 0 && !isRow(v[+s]) ? +s : i;
|
|
1886
|
+
};
|
|
1887
|
+
/** whether `name` is a field of `values`: its path exists (G-379: also when the value is undefined) */
|
|
1888
|
+
const hasField = (values, name) => {
|
|
1889
|
+
let v = values;
|
|
1890
|
+
for (const s of name.split('.')) {
|
|
1891
|
+
const k = v && typeof v == 'object' ? key(v, s) : -1;
|
|
1892
|
+
if (!(k in Object(v)))
|
|
1893
|
+
return false;
|
|
1894
|
+
v = v[k];
|
|
1895
|
+
}
|
|
1896
|
+
return true;
|
|
1897
|
+
};
|
|
1898
|
+
|
|
1899
|
+
// the form's own action names (G-577: read from the form's model, so CHECKED_<field> counts)
|
|
1900
|
+
const ownActions = (b) => Object.keys(b?.model || {});
|
|
1901
|
+
const SUBMIT_FIX = (key, a, own) => `Name the submit after a host action the form doesn't define (not ${own.join(', ')}): submit: 'SAVE', and rename the model entry '${a}' to SAVE: { HTTP: (state, values) => ({ url, json: values, ok: '${key}.DONE', error: '${key}.ERRORS' }) }`;
|
|
1902
|
+
const RESERVED = ['ok', 'error', 'latest'];
|
|
1903
|
+
const what = (v) => v === null || v === undefined ? String(v)
|
|
1904
|
+
: typeof v == 'function' ? 'a function'
|
|
1905
|
+
: Array.isArray(v) ? 'an array'
|
|
1906
|
+
: typeof v == 'object' ? `an object without ~standard.validate${typeof v.parse == 'function' ? ' (a validator from before Standard Schema?)' : ''}`
|
|
1907
|
+
: `a ${typeof v}`;
|
|
1908
|
+
function reportForm(code, a, b, c) {
|
|
1909
|
+
if (code == 231) {
|
|
1910
|
+
const err = new TypeError(`[Sygnal SYG231] form(schema, options): the schema is ${what(a)}, not a Standard Schema. ` +
|
|
1911
|
+
`Pass a Standard Schema object: a zod, valibot or arktype schema, or an object with '~standard': { version: 1, vendor, validate }. ${docsUrlFor('SYG231')}`);
|
|
1912
|
+
err.code = 'SYG231';
|
|
1913
|
+
throw err;
|
|
1914
|
+
}
|
|
1915
|
+
if (code == 230) {
|
|
1916
|
+
if (!once(`SYG230:${a}`))
|
|
1917
|
+
return;
|
|
1918
|
+
devReport('SYG230', {
|
|
1919
|
+
component: 'form',
|
|
1920
|
+
message: `form: a field named '${a}' changed, but '${a}' isn't a field of the form's values (${Object.keys(b || {}).join(', ') || 'none'}); the change is ignored`,
|
|
1921
|
+
fix: `Name the input after a path in values (name="email", name="addresses.<row id>.city"), add '${a}' to values, or move the input out of the form element`,
|
|
1922
|
+
data: { name: a },
|
|
1923
|
+
});
|
|
1924
|
+
}
|
|
1925
|
+
else if (code == 232) {
|
|
1926
|
+
devReport('SYG232', {
|
|
1927
|
+
component: 'form',
|
|
1928
|
+
message: `form: a submit was dropped: ${a?.submitting ? 'the previous submit is still being sent (no form.DONE / form.ERRORS yet)' : 'a submit is already queued, waiting for an async check or an async schema'}`,
|
|
1929
|
+
fix: `Disable the submit button while state.form.submitting (or queued) is true; answer the submit with ok: 'form.DONE', error: 'form.ERRORS'`,
|
|
1930
|
+
});
|
|
1931
|
+
}
|
|
1932
|
+
else if (code == 233) {
|
|
1933
|
+
if (!b || typeof b != 'object' || Object.keys(b).some(k => !(k in (a || {}))))
|
|
1934
|
+
return;
|
|
1935
|
+
for (const k of Object.keys(a || {})) {
|
|
1936
|
+
if (!(k in b) && once(`SYG233:${k}`))
|
|
1937
|
+
devReport('SYG233', {
|
|
1938
|
+
component: 'form',
|
|
1939
|
+
message: `form: values.${k} is not in the schema's output (stripped or renamed), so the submit action doesn't get it under that name (a schema strips keys it doesn't declare)`,
|
|
1940
|
+
fix: `Declare '${k}' in the schema (or make the schema keep unknown keys), or remove it from values; if the schema renames it on purpose, ignore this`,
|
|
1941
|
+
data: { key: k },
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
else if (code == 234) {
|
|
1946
|
+
if (!once(`SYG234:run:${b}:${a}`))
|
|
1947
|
+
return;
|
|
1948
|
+
devReport('SYG234', {
|
|
1949
|
+
component: 'form',
|
|
1950
|
+
message: `form '${b}': a valid submit dispatched '${b}.${a}' (the form's own ${a} action), not the host's '${a}': submit: '${a}' names one of the form's own actions, which next() prefers`,
|
|
1951
|
+
fix: SUBMIT_FIX(b, a, ownActions(c)),
|
|
1952
|
+
data: { key: b, submit: a },
|
|
1953
|
+
});
|
|
1954
|
+
}
|
|
1955
|
+
else if (code == 235) {
|
|
1956
|
+
const set = RESERVED.filter(k => b && k in b);
|
|
1957
|
+
if (set.length && once(`SYG235:req:${a}`))
|
|
1958
|
+
devReport('SYG235', {
|
|
1959
|
+
component: 'form',
|
|
1960
|
+
message: `form: the check for '${a}' returns a request with ${set.map(k => `'${k}'`).join(', ')}; the form sets them itself (its reply action and latest: true), so they are replaced`,
|
|
1961
|
+
fix: `Return only the request: check: { ${a}: { request: (v) => ({ url, query }), error: (body) => message } }`,
|
|
1962
|
+
data: { field: a, keys: set },
|
|
1963
|
+
});
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
/** row ids missing in `values`: the names of the arrays of objects that have a row without an id */
|
|
1967
|
+
const missingIds = (v, at = '', out = []) => {
|
|
1968
|
+
if (v && typeof v == 'object') {
|
|
1969
|
+
if (Array.isArray(v) && v.some(r => r && typeof r == 'object' && !Array.isArray(r) && r.id == null))
|
|
1970
|
+
out.push(at);
|
|
1971
|
+
for (const k in v)
|
|
1972
|
+
missingIds(v[k], at ? `${at}.${k}` : k, out);
|
|
1973
|
+
}
|
|
1974
|
+
return out;
|
|
1975
|
+
};
|
|
1976
|
+
const formsCheck = {
|
|
1977
|
+
id: 'forms',
|
|
1978
|
+
onModel(component) {
|
|
1979
|
+
const view = component?.view, uses = view?.uses;
|
|
1980
|
+
if (!uses || typeof uses != 'object')
|
|
1981
|
+
return;
|
|
1982
|
+
const name = nameOf(component), sels = {};
|
|
1983
|
+
for (const key of Object.keys(uses)) {
|
|
1984
|
+
const b = uses[key];
|
|
1985
|
+
if (!b?.form)
|
|
1986
|
+
continue;
|
|
1987
|
+
// G-374: each form listens on its selector inside the host, so two with one selector hear
|
|
1988
|
+
// each other's fields and submits
|
|
1989
|
+
const sel = b.options?.form || 'form';
|
|
1990
|
+
if (sel in sels) {
|
|
1991
|
+
if (once(`SYG237:${name}:${sel}`))
|
|
1992
|
+
devReport('SYG237', {
|
|
1993
|
+
component,
|
|
1994
|
+
message: `${name}'s forms '${sels[sel]}' and '${key}' both listen on '${sel}', so each hears the other's fields and submits`,
|
|
1995
|
+
fix: `Give each form element its own class and pass it: ${sels[sel]}: form(schema, { ..., form: '.${sels[sel]}' }), ${key}: form(schema, { ..., form: '.${key}' })`,
|
|
1996
|
+
data: { keys: [sels[sel], key], form: sel },
|
|
1997
|
+
});
|
|
1998
|
+
}
|
|
1999
|
+
else
|
|
2000
|
+
sels[sel] = key;
|
|
2001
|
+
if (!once(`SYG23x:${name}:${key}`))
|
|
2002
|
+
continue;
|
|
2003
|
+
const o = b.options || {}, values = o.values || {};
|
|
2004
|
+
const model = Object.keys(view.model || {}).map(a => a.split('|')[0].trim()), own = ownActions(b);
|
|
2005
|
+
const clash = own.includes(o.submit);
|
|
2006
|
+
if (typeof o.submit != 'string' || !model.includes(o.submit) || clash) {
|
|
2007
|
+
devReport('SYG234', {
|
|
2008
|
+
component,
|
|
2009
|
+
message: typeof o.submit != 'string' ? `${name}'s form '${key}' has no submit action, so a valid submit dispatches nothing`
|
|
2010
|
+
: clash ? `${name}'s form '${key}' names submit: '${o.submit}', one of the form's own actions; a valid submit dispatches the form's '${key}.${o.submit}'${model.includes(o.submit) ? `, never ${name}'s '${o.submit}' entry` : ''}`
|
|
2011
|
+
: `${name}'s form '${key}' dispatches '${o.submit}' on a valid submit, but ${name} has no model entry '${o.submit}'`,
|
|
2012
|
+
fix: clash ? SUBMIT_FIX(key, o.submit, own)
|
|
2013
|
+
: `Name a host action and add it to the model: submit: 'SIGN_UP', model: { SIGN_UP: { HTTP: (state, values) => ({ url, json: values, ok: '${key}.DONE', error: '${key}.ERRORS' }) } }`,
|
|
2014
|
+
data: { key, submit: o.submit },
|
|
2015
|
+
});
|
|
2016
|
+
}
|
|
2017
|
+
for (const f of Object.keys(o.check || {})) {
|
|
2018
|
+
if (!hasField(values, f))
|
|
2019
|
+
devReport('SYG235', {
|
|
2020
|
+
component,
|
|
2021
|
+
message: `${name}'s form '${key}' has a check for '${f}', which isn't a field of its values; it never runs`,
|
|
2022
|
+
fix: `Use a field name from values (${Object.keys(values).join(', ')})`,
|
|
2023
|
+
data: { key, field: f },
|
|
2024
|
+
});
|
|
2025
|
+
}
|
|
2026
|
+
for (const at of missingIds(values))
|
|
2027
|
+
devReport('SYG236', {
|
|
2028
|
+
component,
|
|
2029
|
+
message: `${name}'s form '${key}': values.${at} has rows without an id, so its fields are named by index and errors, touched state and focus don't follow a row when another is removed`,
|
|
2030
|
+
fix: `Give each row a unique id: ${at}: [{ id: 1, ... }] (form.ADD numbers new rows itself)`,
|
|
2031
|
+
data: { key, field: at },
|
|
2032
|
+
});
|
|
2033
|
+
}
|
|
2034
|
+
},
|
|
2035
|
+
};
|
|
2036
|
+
/** Install the `form` hook on the core bridge (form.ts calls it). */
|
|
2037
|
+
function installFormHooks() {
|
|
2038
|
+
const c = globalThis.__SYGNAL_DIAGNOSTICS__;
|
|
2039
|
+
if (!c)
|
|
2040
|
+
return () => { };
|
|
2041
|
+
c.form = reportForm;
|
|
2042
|
+
return () => { if (c.form === reportForm)
|
|
2043
|
+
c.form = undefined; };
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
/**
|
|
2047
|
+
* PLAN-5 B-1 (3-D): `sortable` behavior diagnostics (dev entry; src/extra/sortable.ts carries no
|
|
2048
|
+
* text). The behavior calls `__SYGNAL_DIAGNOSTICS__.sortable(code, ...args)`:
|
|
2049
|
+
* 145 (item element, item selector, attr) a press or key on an item whose element has no
|
|
2050
|
+
* `attr`: SYG145 (warn, once per selector); the event is ignored
|
|
2051
|
+
* 146 (host root, item, handle, attr) the host's first pointerdown / keydown: SYG146 (warn,
|
|
2052
|
+
* once per selector) when elements carry `attr` under the root but none matches `item`,
|
|
2053
|
+
* or items exist but none contains `handle`. Returns true once it could decide (an empty
|
|
2054
|
+
* list can't tell: the check runs again at the next interaction)
|
|
2055
|
+
* 147 (from key, host state, uses key) when the host starts: a `from` key whose value isn't
|
|
2056
|
+
* an array: SYG147 (warn, once per key)
|
|
2057
|
+
* 435 (3-H G-449) (fn, uses key, from keys, idOf): the host's own items' ids as shown, per
|
|
2058
|
+
* container (fn() returns [the item elements, the root]; the behavior only asks in dev), or nothing when
|
|
2059
|
+
* every list has been reported (3-L G-478: no scan). A row's place is its `data-index`
|
|
2060
|
+
* when it has one (VirtualCollection's pinned focused row is out of DOM order). (shown
|
|
2061
|
+
* groups, host state, from keys, idField, uses key) at a keyboard pick-up or a pointer
|
|
2062
|
+
* press: SYG435 (warn, once per uses key and list) when a list's shown items (those in
|
|
2063
|
+
* the container holding most of them: an element elsewhere with a matching id is no list
|
|
2064
|
+
* item; a row's container is its nearest ancestor holding another row, 3-O G-505) aren't
|
|
2065
|
+
* one run of the array in its order (a Collection's sort, or a filter hiding entries
|
|
2066
|
+
* between shown ones)
|
|
2067
|
+
*/
|
|
2068
|
+
const all = (root, sel) => {
|
|
2069
|
+
try {
|
|
2070
|
+
return [...root.querySelectorAll(sel)];
|
|
2071
|
+
}
|
|
2072
|
+
catch (_) {
|
|
2073
|
+
return [];
|
|
2074
|
+
}
|
|
2075
|
+
};
|
|
2076
|
+
function reportSortable(code, a, b, c, d, e) {
|
|
2077
|
+
if (code == 145) {
|
|
2078
|
+
if (once(`SYG145:${b}:${c}`))
|
|
2079
|
+
devReport('SYG145', {
|
|
2080
|
+
component: 'sortable',
|
|
2081
|
+
message: `sortable: an item ('${b}') has no ${c} attribute, so it can't be moved (the behavior finds each item in the list by that id)`,
|
|
2082
|
+
fix: `Render the item's id on its element: <li className="${String(b).replace(/^\./, '')}" ${c}={item.id}>, or name the attribute: sortable({ ..., attr: 'data-key' })`,
|
|
2083
|
+
data: { item: b, attr: c },
|
|
2084
|
+
});
|
|
2085
|
+
return;
|
|
2086
|
+
}
|
|
2087
|
+
if (code == 146) {
|
|
2088
|
+
const root = a, item = b, handle = c, attr = d;
|
|
2089
|
+
if (!root?.querySelectorAll)
|
|
2090
|
+
return true;
|
|
2091
|
+
const items = all(root, item).filter(el => el !== root);
|
|
2092
|
+
if (!items.length) {
|
|
2093
|
+
// nothing carries the id attribute either: an empty list (decide later)
|
|
2094
|
+
if (!all(root, `[${attr}]`).length)
|
|
2095
|
+
return false;
|
|
2096
|
+
report146('item', item, `no element under the host matches it, though elements with ${attr} are rendered`, `Use the selector of the items' elements (the element with ${attr}), e.g. item: '.task'`);
|
|
2097
|
+
return true;
|
|
2098
|
+
}
|
|
2099
|
+
if (handle && !items.some(el => all(el, handle).length)) {
|
|
2100
|
+
report146('handle', handle, `no item ('${item}') contains an element that matches it`, `Use the selector of the drag handle inside each item, e.g. handle: '.grip', or leave handle out to drag the whole item`);
|
|
2101
|
+
}
|
|
2102
|
+
return true;
|
|
2103
|
+
}
|
|
2104
|
+
if (code == 435) {
|
|
2105
|
+
// (fn, uses key, from keys, idOf) the shown ids per container: computed only here (dev)
|
|
2106
|
+
if (typeof a == 'function') {
|
|
2107
|
+
if ((c || []).every((l) => onced(`SYG435:${b}:${l}`)))
|
|
2108
|
+
return;
|
|
2109
|
+
try {
|
|
2110
|
+
const by = new Map(), [els, root] = a(), n = new Map();
|
|
2111
|
+
// a row's place: its data-index (a VirtualCollection row below the host's root; its
|
|
2112
|
+
// pinned row isn't in DOM order)
|
|
2113
|
+
const rows = els.map((el) => {
|
|
2114
|
+
const r0 = el.closest?.('[data-index]'), row = r0 && r0 !== root && root.contains?.(r0) ? r0 : el;
|
|
2115
|
+
return [el, row, Number(row === el && !r0 ? NaN : row.getAttribute('data-index'))];
|
|
2116
|
+
});
|
|
2117
|
+
// 3-O G-505: a row's container is its nearest ancestor holding another row (items each in
|
|
2118
|
+
// their own wrapper, <li><div data-id>, a table cell, a Collection item's root, are still
|
|
2119
|
+
// one list); `n` counts the rows below each ancestor
|
|
2120
|
+
const up = (row, f) => { for (let p = row.parentElement; p; p = p === root ? null : p.parentElement)
|
|
2121
|
+
if (f(p))
|
|
2122
|
+
return p; };
|
|
2123
|
+
for (const [, row] of rows)
|
|
2124
|
+
up(row, p => void n.set(p, (n.get(p) || 0) + 1));
|
|
2125
|
+
for (const [el, row, at] of rows) {
|
|
2126
|
+
const p = up(row, p => n.get(p) > 1) || row.parentElement;
|
|
2127
|
+
by.has(p) || by.set(p, []);
|
|
2128
|
+
by.get(p).push([isNaN(at) ? by.get(p).length : at, String(d(el))]);
|
|
2129
|
+
}
|
|
2130
|
+
return [...by.values()].map(g => g.sort((x, y) => x[0] - y[0]).map(x => x[1]));
|
|
2131
|
+
}
|
|
2132
|
+
catch (_) {
|
|
2133
|
+
return;
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
// (shown groups, host state, from keys, idField, uses key) at a keyboard pick-up or a press
|
|
2137
|
+
const groups = Array.isArray(a?.[0]) ? a : [a || []];
|
|
2138
|
+
for (const l of c || []) {
|
|
2139
|
+
if (onced(`SYG435:${e}:${l}`))
|
|
2140
|
+
continue;
|
|
2141
|
+
const at = new Map();
|
|
2142
|
+
if (Array.isArray(b?.[l]))
|
|
2143
|
+
b[l].forEach((x, i) => at.set(String(x?.[d]), i));
|
|
2144
|
+
// the list's container: the one holding most of its entries (others: stray matches)
|
|
2145
|
+
let dom = [];
|
|
2146
|
+
for (const g of groups) {
|
|
2147
|
+
const ix = g.filter(i => at.has(i)).map(i => at.get(i));
|
|
2148
|
+
if (ix.length > dom.length)
|
|
2149
|
+
dom = ix;
|
|
2150
|
+
}
|
|
2151
|
+
// a contiguous run in the array's order is fine (all of it, or a window of it)
|
|
2152
|
+
if (!dom.some((v, j) => v !== dom[0] + j))
|
|
2153
|
+
continue;
|
|
2154
|
+
if (once(`SYG435:${e}:${l}`))
|
|
2155
|
+
devReport('SYG435', {
|
|
2156
|
+
component: 'sortable',
|
|
2157
|
+
message: `sortable '${e}': state.${l} is shown ${dom.length < at.size ? 'with entries hidden between the shown ones' : 'in another order'} (a Collection with ${dom.length < at.size ? 'filter' : 'sort'}?), but moves and the announced positions follow the array: a keyboard step can pass entries nobody sees, or change nothing visible`,
|
|
2158
|
+
fix: `Reorder a list that is shown as it is stored: drop the Collection's sort (sort state.${l} itself), and hide the drag handles while a filter hides entries`,
|
|
2159
|
+
data: { from: l, key: e },
|
|
2160
|
+
});
|
|
2161
|
+
}
|
|
2162
|
+
return;
|
|
2163
|
+
}
|
|
2164
|
+
if (code == 147) {
|
|
2165
|
+
const st = b || {}, arrays = Object.keys(st).filter(k => Array.isArray(st[k]));
|
|
2166
|
+
const near = suggest(a, arrays);
|
|
2167
|
+
if (once(`SYG147:${c}:${a}`))
|
|
2168
|
+
devReport('SYG147', {
|
|
2169
|
+
component: 'sortable',
|
|
2170
|
+
message: `sortable '${c}': from names '${a}', but state.${a} is ${st[a] === undefined ? 'missing' : 'not an array'} when the host starts${near ? ` (did you mean '${near}'?)` : ''}; nothing in it can be moved`,
|
|
2171
|
+
fix: `Name the host state's list: sortable({ from: '${near || arrays[0] || 'items'}', ... })${arrays.length ? ` (arrays in the state: ${arrays.join(', ')})` : `, and start it as an array (initialState: { ${a}: [] })`}`,
|
|
2172
|
+
data: { from: a, key: c },
|
|
2173
|
+
});
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
const report146 = (option, selector, why, fix) => {
|
|
2177
|
+
if (once(`SYG146:${option}:${selector}`))
|
|
2178
|
+
devReport('SYG146', {
|
|
2179
|
+
component: 'sortable',
|
|
2180
|
+
message: `sortable: ${option} '${selector}' matches nothing: ${why}, so no item can be dragged`,
|
|
2181
|
+
fix,
|
|
2182
|
+
data: { option, selector },
|
|
2183
|
+
});
|
|
2184
|
+
};
|
|
2185
|
+
/** Install the `sortable` hook on the core bridge (sortable.ts calls it). */
|
|
2186
|
+
function installSortableHooks() {
|
|
2187
|
+
const c = globalThis.__SYGNAL_DIAGNOSTICS__;
|
|
2188
|
+
if (!c)
|
|
2189
|
+
return () => { };
|
|
2190
|
+
c.sortable = reportSortable;
|
|
2191
|
+
return () => { if (c.sortable === reportSortable)
|
|
2192
|
+
c.sortable = undefined; };
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
const camel = (key) => key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
2196
|
+
const BAD_CHAR = /[\s"'<>\/=`]/;
|
|
2197
|
+
function problem(key) {
|
|
2198
|
+
if (/-[a-z]/.test(key))
|
|
2199
|
+
return `has a hyphen followed by a lower-case letter, which a dataset name can't have`;
|
|
2200
|
+
if (BAD_CHAR.test(key))
|
|
2201
|
+
return `contains a character an HTML attribute name can't have`;
|
|
2202
|
+
return undefined;
|
|
2203
|
+
}
|
|
2204
|
+
function walk(vnode, visit, depth = 0) {
|
|
2205
|
+
if (!vnode || typeof vnode != 'object' || depth > 500)
|
|
2206
|
+
return;
|
|
2207
|
+
const ds = vnode.data && vnode.data.dataset;
|
|
2208
|
+
if (ds && typeof ds == 'object')
|
|
2209
|
+
visit(ds, vnode.sel);
|
|
2210
|
+
const children = vnode.children;
|
|
2211
|
+
if (Array.isArray(children))
|
|
2212
|
+
for (const c of children)
|
|
2213
|
+
walk(c, visit, depth + 1);
|
|
2214
|
+
}
|
|
2215
|
+
const datasetCheck = {
|
|
2216
|
+
id: 'dataset',
|
|
2217
|
+
onRender(component, rootVnode) {
|
|
2218
|
+
walk(rootVnode, (dataset, sel) => {
|
|
2219
|
+
for (const key of Object.keys(dataset)) {
|
|
2220
|
+
const why = problem(key);
|
|
2221
|
+
if (!why || !once(`SYG421:${key}`))
|
|
2222
|
+
continue;
|
|
2223
|
+
const good = camel(key).replace(new RegExp(BAD_CHAR.source, 'g'), '');
|
|
2224
|
+
const attr = good.replace(/[A-Z]/g, c => '-' + c.toLowerCase());
|
|
2225
|
+
const name = good || 'name';
|
|
2226
|
+
devReport('SYG421', {
|
|
2227
|
+
component,
|
|
2228
|
+
message: `The view of ${nameOf(component)} renders the data key '${key}'${sel ? ` on <${String(sel).split(/[.#]/)[0]}>` : ''} ` +
|
|
2229
|
+
`(from data={{ '${key}': … }}), but '${key}' ${why}. ` +
|
|
2230
|
+
`Writing it throws a DOMException, which can stop rendering`,
|
|
2231
|
+
fix: `Use a camelCase key in the data prop: data={{ ${name}: … }} renders the attribute data-${attr || 'name'}; read it with .data('${name}')`,
|
|
2232
|
+
data: { key, suggested: good, attribute: `data-${attr}` },
|
|
2233
|
+
});
|
|
2234
|
+
}
|
|
2235
|
+
});
|
|
2236
|
+
},
|
|
2237
|
+
};
|
|
2238
|
+
|
|
2239
|
+
const REPLY_KEYS = ['ok', 'error'];
|
|
2240
|
+
// PLAN-3 5-4b: on the router's sink, `{ route }` (the route static) and `{ block }` name reply actions
|
|
2241
|
+
const ROUTER_KEYS = ['route', 'block'];
|
|
2242
|
+
const CONNECTION_KEYS = ['message', 'open', 'close', 'error'];
|
|
2243
|
+
/** [action, key, connection name?] for every reply action name a sink value names */
|
|
2244
|
+
function replyNames(req, router) {
|
|
2245
|
+
const out = [];
|
|
2246
|
+
for (const key of router ? REPLY_KEYS.concat(ROUTER_KEYS) : REPLY_KEYS)
|
|
2247
|
+
if (typeof req[key] === 'string')
|
|
2248
|
+
out.push([req[key], key]);
|
|
2249
|
+
const conns = req.connections;
|
|
2250
|
+
if (conns && typeof conns === 'object') {
|
|
2251
|
+
for (const name of Object.keys(conns)) {
|
|
2252
|
+
const spec = conns[name];
|
|
2253
|
+
if (!spec || typeof spec !== 'object')
|
|
2254
|
+
continue;
|
|
2255
|
+
for (const key of CONNECTION_KEYS)
|
|
2256
|
+
if (typeof spec[key] === 'string')
|
|
2257
|
+
out.push([spec[key], key, name]);
|
|
2258
|
+
const events = spec.events;
|
|
2259
|
+
if (events && typeof events === 'object') {
|
|
2260
|
+
for (const ev of Object.keys(events))
|
|
2261
|
+
if (typeof events[ev] === 'string')
|
|
2262
|
+
out.push([events[ev], `events.${ev}`, name]);
|
|
2263
|
+
}
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
2266
|
+
return out;
|
|
2267
|
+
}
|
|
2268
|
+
/** instance → 'SINK\0category' → the select/errors call that read it */
|
|
2269
|
+
const selected = new WeakMap();
|
|
2270
|
+
function readingSource(component, sink, source) {
|
|
2271
|
+
const wrapped = Object.create(source);
|
|
2272
|
+
for (const method of ['select', 'errors']) {
|
|
2273
|
+
if (typeof source[method] !== 'function')
|
|
2274
|
+
continue;
|
|
2275
|
+
wrapped[method] = (category, ...rest) => {
|
|
2276
|
+
if (typeof category === 'string') {
|
|
2277
|
+
let reads = selected.get(component);
|
|
2278
|
+
if (!reads)
|
|
2279
|
+
selected.set(component, reads = new Map());
|
|
2280
|
+
reads.set(`${sink}\0${category}`, `${sink}.${method}('${category}')`);
|
|
2281
|
+
}
|
|
2282
|
+
return source[method](category, ...rest);
|
|
2283
|
+
};
|
|
2284
|
+
}
|
|
2285
|
+
return wrapped;
|
|
2286
|
+
}
|
|
2287
|
+
function checkRequest(component, sink, req, modelActions) {
|
|
2288
|
+
if (!req || typeof req !== 'object')
|
|
2289
|
+
return;
|
|
2290
|
+
const name = nameOf(component);
|
|
2291
|
+
let replies = false;
|
|
2292
|
+
for (const [action, key, connection] of replyNames(req, !!component?.sources?.[sink]?.__sygnalRouter)) {
|
|
2293
|
+
replies = true;
|
|
2294
|
+
let seen = replySeen.get(component);
|
|
2295
|
+
if (!seen)
|
|
2296
|
+
replySeen.set(component, seen = new Set());
|
|
2297
|
+
seen.add(action);
|
|
2298
|
+
if (modelActions.includes(action) || !once(`SYG112:${name}:${action}`))
|
|
2299
|
+
continue;
|
|
2300
|
+
const near = suggest(action, modelActions.filter(a => !a.startsWith('__')));
|
|
2301
|
+
devReport('SYG112', {
|
|
2302
|
+
component,
|
|
2303
|
+
message: (connection
|
|
2304
|
+
? `${sink} connection '${connection}' names '${action}' as its ${key} reply action, but ${name} has no model entry '${action}', so those events are dropped`
|
|
2305
|
+
: `A ${sink} request names '${action}' as its ${key} reply action, but ${name} has no model entry '${action}', so the reply is dropped`) +
|
|
2306
|
+
(near ? ` (did you mean '${near}'?)` : ''),
|
|
2307
|
+
fix: near
|
|
2308
|
+
? `Use the existing entry: ${key.startsWith('events.') ? `events: { '${key.slice(7)}': '${near}' }` : `${key}: '${near}'`}`
|
|
2309
|
+
: `Add '${action}' to ${name}.model, e.g. ${action}: (state, data) => ({ ...state, ... }), or fix the name`,
|
|
2310
|
+
data: { action, key, sink, ...(connection ? { connection } : {}), ...(near ? { suggestion: near } : {}), modelActions },
|
|
2311
|
+
});
|
|
2312
|
+
}
|
|
2313
|
+
// SYG508 (strict): a request without reply actions whose replies this instance reads back with select()/errors()
|
|
2314
|
+
if (replies || 'abort' in req || typeof req.category !== 'string' || !isStrictEnabled())
|
|
2315
|
+
return;
|
|
2316
|
+
const read = selected.get(component)?.get(`${sink}\0${req.category}`);
|
|
2317
|
+
if (!read || !once(`SYG508:${name}:${sink}:${req.category}`))
|
|
2318
|
+
return;
|
|
2319
|
+
// (inside a sink stream: reportSafely, so 'error' mode can't break the stream)
|
|
2320
|
+
reportSafely('SYG508', {
|
|
2321
|
+
severity: STRICT_CODE_SEVERITY.SYG508,
|
|
2322
|
+
component,
|
|
2323
|
+
message: `${name} sends a ${sink} request with category: '${req.category}' and reads the reply back with ${read}`,
|
|
2324
|
+
fix: `Use reply actions instead: name them in the request, ${sink}: (state) => ({ url, ok: 'LOADED', error: 'FAILED' }), ` +
|
|
2325
|
+
`handle LOADED (the parsed body) and FAILED ({ error, status, body, request }) in the model, and remove ${read} from the intent`,
|
|
2326
|
+
data: { sink, category: req.category, read },
|
|
2327
|
+
});
|
|
2328
|
+
}
|
|
2329
|
+
const repliesCheck = {
|
|
2330
|
+
id: 'replies',
|
|
2331
|
+
sources(component, sources) {
|
|
2332
|
+
if (!isStrictEnabled() || typeof Proxy != 'function')
|
|
2333
|
+
return;
|
|
2334
|
+
const cache = new Map();
|
|
2335
|
+
return new Proxy(sources, {
|
|
2336
|
+
get(t, k) {
|
|
2337
|
+
const v = t[k];
|
|
2338
|
+
if (typeof k != 'string' || !v || v.__sygnalReplies !== true)
|
|
2339
|
+
return v;
|
|
2340
|
+
if (!cache.has(k))
|
|
2341
|
+
cache.set(k, readingSource(component, k, v));
|
|
2342
|
+
return cache.get(k);
|
|
2343
|
+
},
|
|
2344
|
+
});
|
|
2345
|
+
},
|
|
2346
|
+
};
|
|
2347
|
+
|
|
2348
|
+
const PARAM = /:(\w+)/g;
|
|
2349
|
+
const paramsOf = (pat) => (pat.match(PARAM) || []).map(p => p.slice(1));
|
|
2350
|
+
function checkHref(routes, name, params) {
|
|
2351
|
+
const names = Object.keys(routes).filter(k => routes[k] != '*');
|
|
2352
|
+
const pat = routes[name];
|
|
2353
|
+
if (pat == null || pat == '*') {
|
|
2354
|
+
if (!once(`SYG130:${name}`))
|
|
2355
|
+
return;
|
|
2356
|
+
const near = suggest(String(name), names);
|
|
2357
|
+
devReport('SYG130', {
|
|
2358
|
+
message: `href('${name}') names no route, so the link points to '/'` + (near ? ` (did you mean '${near}'?)` : ''),
|
|
2359
|
+
fix: `Use one of the route names: ${names.join(', ')}`,
|
|
2360
|
+
data: { name, routes: names, ...(near ? { suggestion: near } : {}) },
|
|
2361
|
+
});
|
|
2362
|
+
return;
|
|
2363
|
+
}
|
|
2364
|
+
const wanted = paramsOf(pat);
|
|
2365
|
+
const given = params && typeof params == 'object' ? params : {};
|
|
2366
|
+
const missing = wanted.filter(k => given[k] == null);
|
|
2367
|
+
const extra = Object.keys(given).filter(k => !wanted.includes(k));
|
|
2368
|
+
if (missing.length && once(`SYG130:${name}:${missing}`)) {
|
|
2369
|
+
devReport('SYG130', {
|
|
2370
|
+
message: `href('${name}') leaves out ${missing.map(k => `'${k}'`).join(', ')} (${pat}), so that segment is empty`,
|
|
2371
|
+
fix: `href('${name}', { ${wanted.join(', ')} })`,
|
|
2372
|
+
data: { name, pattern: pat, missing },
|
|
2373
|
+
});
|
|
2374
|
+
}
|
|
2375
|
+
if (extra.length && once(`SYG131:${name}:${extra}`)) {
|
|
2376
|
+
devReport('SYG131', {
|
|
2377
|
+
message: `Route '${name}' (${pat}) has no param ${extra.map(k => `'${k}'`).join(', ')}; it is ignored`,
|
|
2378
|
+
fix: wanted.length ? `Pass only ${wanted.join(', ')}; put other values in the query: href('${name}', params, { ${extra[0]} })` : `Put it in the query: href('${name}', {}, { ${extra[0]} })`,
|
|
2379
|
+
data: { name, pattern: pat, extra },
|
|
2380
|
+
});
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
/** install the hook the router's href() calls (removed by the returned function) */
|
|
2384
|
+
function installRouterHooks() {
|
|
2385
|
+
const core = bridge();
|
|
2386
|
+
if (!core)
|
|
2387
|
+
return () => { };
|
|
2388
|
+
core.routerHref = checkHref;
|
|
2389
|
+
return () => { if (core.routerHref === checkHref)
|
|
2390
|
+
core.routerHref = undefined; };
|
|
2391
|
+
}
|
|
2392
|
+
const routerCheck = {
|
|
2393
|
+
id: 'router',
|
|
2394
|
+
onModel(component) {
|
|
2395
|
+
const sources = component?.sources;
|
|
2396
|
+
if (!sources || typeof sources != 'object')
|
|
2397
|
+
return;
|
|
2398
|
+
const name = nameOf(component);
|
|
2399
|
+
for (const n of component.sourceNames || Object.keys(sources)) {
|
|
2400
|
+
const src = sources[n];
|
|
2401
|
+
const k = src?.__sygnalStatic;
|
|
2402
|
+
if (typeof k == 'string' && component.view?.[k] != null) {
|
|
2403
|
+
// statics are computed from state (G-167: with or without a model): a root whose state
|
|
2404
|
+
// never emits (a model but no initialState) never sends one
|
|
2405
|
+
if (!component.isSubComponent && component.initialState === undefined && once(`SYG132:${name}:${k}`)) {
|
|
2406
|
+
devReport('SYG132', {
|
|
2407
|
+
component,
|
|
2408
|
+
message: `${name} declares ${name}.${k}, but a root without initialState has no state, so ${k} is never sent to ${n}`,
|
|
2409
|
+
fix: `Add ${name}.initialState (e.g. { }${k == 'route' ? ', or { route: router.current() }' : ''})`,
|
|
2410
|
+
data: { static: k, source: n, reason: 'state' },
|
|
2411
|
+
});
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
const opts = src?.__sygnalRouter;
|
|
2415
|
+
const g = globalThis;
|
|
2416
|
+
if (opts && typeof opts == 'object' && !opts.navigate && g.document?.getElementById?.('vike_pageContext') && once('SYG133')) {
|
|
2417
|
+
devReport('SYG133', {
|
|
2418
|
+
component,
|
|
2419
|
+
message: `The SPA router (${n}) runs inside a Vike app: Vike owns links and history, so both would handle a click`,
|
|
2420
|
+
fix: "Pass Vike's navigate: makeRouter({ routes, navigate }) with import { navigate } from 'vike/client/router', or use Vike's routing",
|
|
2421
|
+
data: { source: n },
|
|
2422
|
+
});
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
},
|
|
2426
|
+
};
|
|
2427
|
+
|
|
2428
|
+
/*
|
|
2429
|
+
* Standard Schema (https://standardschema.dev) without a dependency: any validator that exposes
|
|
2430
|
+
* `schema['~standard'].validate(value)` (zod, valibot, arktype, ...), sync or async.
|
|
2431
|
+
* Used by makeFetchDriver's `validate` (PLAN-3 §1.6, D80); PLAN-4's forms and server functions
|
|
2432
|
+
* reuse it.
|
|
2433
|
+
*/
|
|
2434
|
+
/** true for a Standard Schema object (it has `~standard.validate`) */
|
|
2435
|
+
const isStandardSchema = (schema) => !!schema && (typeof schema == 'object' || typeof schema == 'function') && typeof schema['~standard']?.validate == 'function';
|
|
2436
|
+
|
|
2437
|
+
const IDEMPOTENT = /^(GET|HEAD)$/i;
|
|
2438
|
+
/** instance → reply key lanes it has sent, and ok name → the other key it was sent under */
|
|
2439
|
+
const lanes = new WeakMap();
|
|
2440
|
+
const fetchSink = (component, name) => component?.sources?.[name]?.__sygnalStatic === 'resources';
|
|
2441
|
+
/** 5-5: the sink's driver has no queryCache() */
|
|
2442
|
+
const noCache = (component, sink) => {
|
|
2443
|
+
const i = component?.sources?.[sink]?.__inspect?.();
|
|
2444
|
+
return !!i && i.cache === undefined;
|
|
2445
|
+
};
|
|
2446
|
+
const reportNoCache = (component, sink, what, url) => {
|
|
2447
|
+
if (!noCache(component, sink) || !once(`SYG635:${nameOf(component)}:${what}:${url}`))
|
|
2448
|
+
return;
|
|
2449
|
+
devReport('SYG635', {
|
|
2450
|
+
component,
|
|
2451
|
+
message: `${sink} ${url ? url + ' ' : ''}uses ${what}, but the ${sink} driver has no queryCache(), so nothing is cached`,
|
|
2452
|
+
fix: `Give the driver a cache: makeFetchDriver({ cache: queryCache({ staleTime: 30000 }) }) (import { queryCache } from 'sygnal'); in tests renderComponent(C, { http: { cache: queryCache() } }). Or remove ${what}`,
|
|
2453
|
+
data: { sink, url, what },
|
|
2454
|
+
});
|
|
2455
|
+
};
|
|
2456
|
+
function checkOne(component, sink, req, where) {
|
|
2457
|
+
if (!req || typeof req !== 'object')
|
|
2458
|
+
return;
|
|
2459
|
+
const name = nameOf(component);
|
|
2460
|
+
const url = typeof req.url === 'string' ? req.url : '';
|
|
2461
|
+
const method = String(req.method || (req.json !== undefined || req.body !== undefined ? 'POST' : 'GET')).toUpperCase();
|
|
2462
|
+
if ((req.cache === true || req.staleTime != null) && !IDEMPOTENT.test(method) && once(`SYG630:${name}:${method}:${url}`)) {
|
|
2463
|
+
devReport('SYG630', {
|
|
2464
|
+
component,
|
|
2465
|
+
message: `${where} ${method} ${url} sets ${req.cache === true ? 'cache: true' : 'staleTime'}, so a cached reply answers it instead of sending it again`,
|
|
2466
|
+
fix: `Cache reads only: remove ${req.cache === true ? 'cache' : 'staleTime'} from the ${method}, and refresh the cached reads after it succeeds with invalidates: ['tag']`,
|
|
2467
|
+
data: { sink, method, url },
|
|
2468
|
+
});
|
|
2469
|
+
}
|
|
2470
|
+
if (req.cache === true || req.staleTime != null)
|
|
2471
|
+
reportNoCache(component, sink, req.cache === true ? 'cache: true' : 'staleTime', url);
|
|
2472
|
+
if ('validate' in req && !isStandardSchema(req.validate) && once(`SYG631:${name}:${url}`)) {
|
|
2473
|
+
devReport('SYG631', {
|
|
2474
|
+
component,
|
|
2475
|
+
message: `${where} ${url} has validate: ${typeof req.validate}, which is not a Standard Schema, so the request fails`,
|
|
2476
|
+
fix: "Pass a Standard Schema object (zod, valibot, arktype, ...: anything with a '~standard'.validate function), e.g. validate: QuoteSchema",
|
|
2477
|
+
data: { sink, url },
|
|
2478
|
+
});
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
function check(component, sink, req) {
|
|
2482
|
+
if (!req || typeof req !== 'object')
|
|
2483
|
+
return;
|
|
2484
|
+
const name = nameOf(component);
|
|
2485
|
+
const res = req.resources;
|
|
2486
|
+
if (res && typeof res === 'object') {
|
|
2487
|
+
for (const k of Object.keys(res))
|
|
2488
|
+
checkOne(component, sink, typeof res[k] === 'string' ? { url: res[k] } : res[k], `${name}.resources.${k}:`);
|
|
2489
|
+
return;
|
|
2490
|
+
}
|
|
2491
|
+
if (req.prefetch) {
|
|
2492
|
+
const p = req.prefetch;
|
|
2493
|
+
reportNoCache(component, sink, '{ prefetch }', typeof p === 'string' ? p : String(p?.url ?? ''));
|
|
2494
|
+
return;
|
|
2495
|
+
}
|
|
2496
|
+
if ('invalidate' in req) {
|
|
2497
|
+
const count = component.sources[sink]?.__matches?.(req.invalidate);
|
|
2498
|
+
const what = typeof req.invalidate === 'function' ? 'the predicate' : JSON.stringify(req.invalidate);
|
|
2499
|
+
if (count === 0 && once(`SYG632:${name}:${what}`)) {
|
|
2500
|
+
devReport('SYG632', {
|
|
2501
|
+
component,
|
|
2502
|
+
message: `${sink} { invalidate: ${what} } matched no cache entry and no mounted resource`,
|
|
2503
|
+
fix: "Give the resources (or cached requests) to refresh tags: ['quotes'] and invalidate that tag, or a URL prefix starting with '/' ('/api/quotes')",
|
|
2504
|
+
data: { sink, invalidate: what },
|
|
2505
|
+
});
|
|
2506
|
+
}
|
|
2507
|
+
return;
|
|
2508
|
+
}
|
|
2509
|
+
let l = lanes.get(component);
|
|
2510
|
+
if (!l)
|
|
2511
|
+
lanes.set(component, (l = { used: new Set(), keyed: new Map() }));
|
|
2512
|
+
const { ok, error, key, abort } = req;
|
|
2513
|
+
if (typeof abort === 'string' && key === undefined) {
|
|
2514
|
+
const other = l.keyed.get(abort);
|
|
2515
|
+
if (other !== undefined && !l.used.has(abort) && once(`SYG633:${name}:${abort}`)) {
|
|
2516
|
+
devReport('SYG633', {
|
|
2517
|
+
component,
|
|
2518
|
+
message: `${sink} { abort: '${abort}' } cancels the requests with key '${abort}', but ${name} sends its ok: '${abort}' requests with key: '${other}', so nothing is cancelled`,
|
|
2519
|
+
fix: `Cancel by that key: { abort: true, key: '${other}' } (or drop the key, so the lane is the ok action)`,
|
|
2520
|
+
data: { sink, abort, key: other },
|
|
2521
|
+
});
|
|
2522
|
+
}
|
|
2523
|
+
return;
|
|
2524
|
+
}
|
|
2525
|
+
if (typeof ok === 'string' || typeof error === 'string') {
|
|
2526
|
+
const lane = key ?? ok ?? error;
|
|
2527
|
+
l.used.add(lane);
|
|
2528
|
+
if (typeof ok === 'string' && key !== undefined && key !== ok)
|
|
2529
|
+
l.keyed.set(ok, String(key));
|
|
2530
|
+
}
|
|
2531
|
+
checkOne(component, sink, req, `A ${sink} request`);
|
|
2532
|
+
}
|
|
2533
|
+
const fetchCheck = {
|
|
2534
|
+
id: 'fetch',
|
|
2535
|
+
onModel(component) {
|
|
2536
|
+
const model$ = component?.model$;
|
|
2537
|
+
if (!model$ || typeof model$ !== 'object')
|
|
2538
|
+
return;
|
|
2539
|
+
for (const sink of Object.keys(model$)) {
|
|
2540
|
+
const s$ = model$[sink];
|
|
2541
|
+
if (!fetchSink(component, sink) || !s$ || typeof s$.map !== 'function')
|
|
2542
|
+
continue;
|
|
2543
|
+
model$[sink] = s$.map((req) => {
|
|
2544
|
+
try {
|
|
2545
|
+
check(component, sink, req);
|
|
2546
|
+
}
|
|
2547
|
+
catch (_) { }
|
|
2548
|
+
return req;
|
|
2549
|
+
});
|
|
2550
|
+
}
|
|
2551
|
+
},
|
|
2552
|
+
};
|
|
2553
|
+
|
|
2554
|
+
const NEEDS = {
|
|
2555
|
+
timers: 'makeTimerDriver()',
|
|
2556
|
+
connections: 'makeSocketDriver()',
|
|
2557
|
+
resources: 'makeFetchDriver()',
|
|
2558
|
+
browser: 'makeBrowserDriver()',
|
|
2559
|
+
};
|
|
2560
|
+
const KEY = { timers: 'TIMER', connections: 'WS', resources: 'HTTP', browser: 'BROWSER' };
|
|
2561
|
+
const brief$1 = (v) => { try {
|
|
2562
|
+
return JSON.stringify(v);
|
|
2563
|
+
}
|
|
2564
|
+
catch (_) {
|
|
2565
|
+
return String(v);
|
|
2566
|
+
} };
|
|
2567
|
+
/** why a timer spec can't run ('' when it can; mirrors timers.ts's valid()) */
|
|
2568
|
+
function timerSpecProblem(s) {
|
|
2569
|
+
if (!s || typeof s != 'object')
|
|
2570
|
+
return 'is not an object ({ every, action }, { after, action } or { frame: action })';
|
|
2571
|
+
if ('frame' in s && s.frame)
|
|
2572
|
+
return typeof s.frame == 'string' ? '' : 'frame must be the action name (a string)';
|
|
2573
|
+
if (!s.action || typeof s.action != 'string')
|
|
2574
|
+
return 'has no action (a string)';
|
|
2575
|
+
if (s.every == null && s.after == null)
|
|
2576
|
+
return 'has neither every nor after';
|
|
2577
|
+
if (s.every != null && s.after != null)
|
|
2578
|
+
return 'has both every and after';
|
|
2579
|
+
if (s.every != null)
|
|
2580
|
+
return s.every > 0 && s.every < 1 / 0 ? '' : `every must be a positive number of ms (got ${brief$1(s.every)})`;
|
|
2581
|
+
return s.after >= 0 && s.after < 1 / 0 ? '' : `after must be a number of ms, 0 or more (got ${brief$1(s.after)})`;
|
|
2582
|
+
}
|
|
2583
|
+
function checkSpec(name, spec, component) {
|
|
2584
|
+
const problem = timerSpecProblem(spec) || 'is invalid';
|
|
2585
|
+
if (!once(`SYG422:${component}:${name}:${brief$1(spec)}`))
|
|
2586
|
+
return;
|
|
2587
|
+
devReport('SYG422', {
|
|
2588
|
+
component,
|
|
2589
|
+
message: `Timer '${name}' ${problem}; it is not started`,
|
|
2590
|
+
fix: `Declare ${name}: { every: 100, action: 'TICK' }, { after: 5000, action: 'EXPIRE' } or { frame: 'FRAME' } (falsy to stop it)`,
|
|
2591
|
+
data: { name, spec },
|
|
2592
|
+
});
|
|
2593
|
+
}
|
|
2594
|
+
/** install the hook makeTimerDriver() calls for an invalid spec (removed by the returned function) */
|
|
2595
|
+
function installTimerHooks() {
|
|
2596
|
+
const core = bridge();
|
|
2597
|
+
if (!core)
|
|
2598
|
+
return () => { };
|
|
2599
|
+
core.timerSpec = checkSpec;
|
|
2600
|
+
return () => { if (core.timerSpec === checkSpec)
|
|
2601
|
+
core.timerSpec = undefined; };
|
|
2602
|
+
}
|
|
2603
|
+
const timersCheck = {
|
|
2604
|
+
id: 'timers',
|
|
2605
|
+
onModel(component) {
|
|
2606
|
+
const sources = component?.sources, view = component?.view;
|
|
2607
|
+
if (!sources || typeof sources != 'object' || !view)
|
|
2608
|
+
return;
|
|
2609
|
+
const names = component.sourceNames || Object.keys(sources);
|
|
2610
|
+
for (const k in NEEDS) {
|
|
2611
|
+
if (view[k] == null)
|
|
2612
|
+
continue;
|
|
2613
|
+
if (names.some((n) => sources[n]?.__sygnalStatic === k))
|
|
2614
|
+
continue;
|
|
2615
|
+
const name = nameOf(component);
|
|
2616
|
+
if (!once(`SYG643:${name}:${k}`))
|
|
2617
|
+
continue;
|
|
2618
|
+
devReport('SYG643', {
|
|
2619
|
+
component,
|
|
2620
|
+
message: `${name} declares ${name}.${k}, but no ${NEEDS[k]} driver is registered, so ${k == 'timers' ? 'its timers never run' : k == 'connections' ? 'its connections never open' : k == 'browser' ? 'its browser sources never start' : 'its resources are never fetched'}`,
|
|
2621
|
+
fix: `Register the driver: run(App, { ${KEY[k]}: ${NEEDS[k]} })`,
|
|
2622
|
+
data: { static: k, driver: NEEDS[k] },
|
|
2623
|
+
});
|
|
2624
|
+
}
|
|
2625
|
+
},
|
|
2626
|
+
};
|
|
2627
|
+
|
|
2628
|
+
/**
|
|
2629
|
+
* Browser sources (PLAN-5 B-3, src/extra/browserSources.ts). Dev-entry-only codes
|
|
2630
|
+
* (DEV_CODE_SEVERITY), reported when the driver calls
|
|
2631
|
+
* `__SYGNAL_DIAGNOSTICS__.browserSource(code, name, spec, componentName, extra)`:
|
|
2632
|
+
*
|
|
2633
|
+
* SYG663 — a `browser` entry the driver can't start (no known kind, no `action`, an intersection
|
|
2634
|
+
* / resize target that is neither a selector nor `true`), or a BROWSER sink command
|
|
2635
|
+
* with an unknown method. It isn't started / run.
|
|
2636
|
+
* SYG664 — a kind the driver wasn't made with (`makeBrowserDriverWith(...)` without its source).
|
|
2637
|
+
* SYG665 — a source or command failed (permission denied, API missing, storage blocked) and its
|
|
2638
|
+
* spec names no `error` action, so nothing else would say so.
|
|
2639
|
+
* SYG668 — an intersection / resize declaration with nothing to observe: no DOM source reached
|
|
2640
|
+
* the driver for the instance (extra 'dom'), or, in renderComponent's fake with
|
|
2641
|
+
* `dom: 'real'`, its selector matches no element of the component (extra 'none').
|
|
2642
|
+
*
|
|
2643
|
+
* A component declaring `browser` with no browser driver is SYG643 (checks/timers.ts).
|
|
2644
|
+
*/
|
|
2645
|
+
const KINDS = ['intersection', 'resize', 'media', 'storage', 'visibility', 'online', 'geolocation'];
|
|
2646
|
+
const SOURCE = {
|
|
2647
|
+
intersection: 'intersectionSource', resize: 'resizeSource', media: 'mediaSource', storage: 'storageSource',
|
|
2648
|
+
visibility: 'visibilitySource', online: 'onlineSource', geolocation: 'geolocationSource',
|
|
2649
|
+
copy: 'clipboardSource', paste: 'clipboardSource', setItem: 'storageSource', removeItem: 'storageSource',
|
|
2650
|
+
};
|
|
2651
|
+
const brief = (v) => { try {
|
|
2652
|
+
return JSON.stringify(v);
|
|
2653
|
+
}
|
|
2654
|
+
catch (_) {
|
|
2655
|
+
return String(v);
|
|
2656
|
+
} };
|
|
2657
|
+
/** why a declared spec can't start ('' when it can; mirrors browserDriver's checks) */
|
|
2658
|
+
function browserSpecProblem(s) {
|
|
2659
|
+
if (!s || typeof s != 'object')
|
|
2660
|
+
return `is not an object ({ ${KINDS.join(' | ')}: …, action })`;
|
|
2661
|
+
const k = KINDS.find(k => k in s);
|
|
2662
|
+
if (!k)
|
|
2663
|
+
return `names no source (one of ${KINDS.join(', ')})`;
|
|
2664
|
+
if (!s.action || typeof s.action != 'string')
|
|
2665
|
+
return 'has no action (a string)';
|
|
2666
|
+
if ((k == 'intersection' || k == 'resize') && s[k] !== true && (typeof s[k] != 'string' || !s[k]))
|
|
2667
|
+
return `${k} must be a selector in the component or true (its root element), got ${brief(s[k])}`;
|
|
2668
|
+
return '';
|
|
2669
|
+
}
|
|
2670
|
+
function onBrowserSource(code, name, spec, component, extra, commands) {
|
|
2671
|
+
if (code == 'SYG663' && extra === 0) {
|
|
2672
|
+
// an unknown command (extra 0; `commands`: the driver's)
|
|
2673
|
+
if (!once(`SYG663:${component}:cmd:${name}`))
|
|
2674
|
+
return;
|
|
2675
|
+
devReport('SYG663', {
|
|
2676
|
+
component,
|
|
2677
|
+
message: `BROWSER command '${name}' is unknown; it is not run`,
|
|
2678
|
+
fix: `Name the method as a key of the command: ${(commands || []).map(c => `{ ${c}: … }`).join(', ') || 'this driver takes no commands'}`,
|
|
2679
|
+
data: { name, command: spec },
|
|
2680
|
+
});
|
|
2681
|
+
return;
|
|
2682
|
+
}
|
|
2683
|
+
if (code == 'SYG663') {
|
|
2684
|
+
if (!once(`SYG663:${component}:${name}:${brief(spec)}`))
|
|
2685
|
+
return;
|
|
2686
|
+
devReport('SYG663', {
|
|
2687
|
+
component,
|
|
2688
|
+
message: `browser entry '${name}' ${browserSpecProblem(spec) || 'is invalid'}; it is not started`,
|
|
2689
|
+
fix: `Declare ${name}: { media: '(prefers-color-scheme: dark)', action: 'DARK' }, { intersection: '.card', action: 'SEEN' }, … (falsy to stop it)`,
|
|
2690
|
+
data: { name, spec },
|
|
2691
|
+
});
|
|
2692
|
+
}
|
|
2693
|
+
else if (code == 'SYG664') {
|
|
2694
|
+
if (!once(`SYG664:${component}:${name}`))
|
|
2695
|
+
return;
|
|
2696
|
+
devReport('SYG664', {
|
|
2697
|
+
component,
|
|
2698
|
+
message: `browser entry '${name}' uses the ${extra} source, which this browser driver wasn't made with; it is not started`,
|
|
2699
|
+
fix: `Add it: makeBrowserDriverWith(${SOURCE[extra]}, …), or use makeBrowserDriver() (every source)`,
|
|
2700
|
+
data: { name, spec, kind: extra },
|
|
2701
|
+
});
|
|
2702
|
+
}
|
|
2703
|
+
else if (code == 'SYG668') {
|
|
2704
|
+
if (!once(`SYG668:${component}:${name}:${extra}`))
|
|
2705
|
+
return;
|
|
2706
|
+
const k = KINDS.find(k => spec && k in spec) || 'intersection', target = spec?.[k];
|
|
2707
|
+
devReport('SYG668', {
|
|
2708
|
+
component,
|
|
2709
|
+
message: extra == 'none'
|
|
2710
|
+
? `browser entry '${name}' observes ${brief(target)}, which matches no element of the component; it reports nothing`
|
|
2711
|
+
: `browser entry '${name}' (${k}) has no DOM to observe: the component's DOM source didn't reach the browser driver; it reports nothing`,
|
|
2712
|
+
fix: extra == 'none'
|
|
2713
|
+
? `Use a selector the component's own view renders (or true for its root element), and declare the entry only while that element exists`
|
|
2714
|
+
: `Run the app with a DOM driver (run() with a mount point) and declare intersection / resize in a component's own \`browser\` static`,
|
|
2715
|
+
data: { name, spec, reason: extra },
|
|
2716
|
+
});
|
|
2717
|
+
}
|
|
2718
|
+
else if (code == 'SYG665') {
|
|
2719
|
+
if (!once(`SYG665:${component}:${name}:${extra?.name ?? extra?.code}`))
|
|
2720
|
+
return;
|
|
2721
|
+
devReport('SYG665', {
|
|
2722
|
+
component,
|
|
2723
|
+
message: `browser ${spec && 'action' in spec ? `entry '${name}'` : `command '${name}'`} failed (${extra?.message || extra?.name || 'error'}) and names no error action`,
|
|
2724
|
+
fix: `Name one to handle it, e.g. { ..., error: 'FAILED' }, and a model entry FAILED: (state, { message }) => ...`,
|
|
2725
|
+
data: { name, spec, failure: extra },
|
|
2726
|
+
});
|
|
2727
|
+
}
|
|
2728
|
+
}
|
|
2729
|
+
/** install the hook the browser driver calls (removed by the returned function) */
|
|
2730
|
+
function installBrowserSourceHooks() {
|
|
2731
|
+
const core = bridge();
|
|
2732
|
+
if (!core)
|
|
2733
|
+
return () => { };
|
|
2734
|
+
core.browserSource = onBrowserSource;
|
|
2735
|
+
return () => { if (core.browserSource === onBrowserSource)
|
|
2736
|
+
core.browserSource = undefined; };
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
const persistCheck = {
|
|
2740
|
+
id: 'persist',
|
|
2741
|
+
onModel(component) {
|
|
2742
|
+
const view = component?.view, p = view?.persist;
|
|
2743
|
+
if (!p)
|
|
2744
|
+
return;
|
|
2745
|
+
const name = nameOf(component);
|
|
2746
|
+
if (component.isSubComponent) {
|
|
2747
|
+
// 3-B2: under Vike's Layout/Wrapper shell (onRenderClient's `__vike` source) the Page,
|
|
2748
|
+
// Layouts and Wrappers are all sub-components of the shell root
|
|
2749
|
+
const vike = component.sources?.__vike;
|
|
2750
|
+
if (once('SYG224:' + name))
|
|
2751
|
+
devReport('SYG224', {
|
|
2752
|
+
component,
|
|
2753
|
+
message: vike
|
|
2754
|
+
? `${name}.persist is set, but ${name} renders in Vike's Layout/Wrapper shell (the shell is the app's root), so its state is never saved or restored: persist() isn't supported on Vike pages, layouts or wrappers yet`
|
|
2755
|
+
: `${name}.persist is set, but ${name} is not the root component, so its state is never saved or restored (persist() works on the root only)`,
|
|
2756
|
+
fix: vike
|
|
2757
|
+
? `Remove persist from ${name}. Save the state yourself (STATE.watch writing to localStorage, read back in BOOTSTRAP), or use persist() on a component you run() yourself, or on a Vike page with no Layout or Wrapper (it is its own root)`
|
|
2758
|
+
: `Move persist to the root component (the one passed to run()) and pick the keys it holds: persist({ key, pick: ['${name.toLowerCase()}'] })`,
|
|
2759
|
+
data: { key: p.options?.key, ...(vike && { vike: true }) },
|
|
2760
|
+
});
|
|
2761
|
+
return;
|
|
2762
|
+
}
|
|
2763
|
+
const o = p.options, init = view.initialState;
|
|
2764
|
+
if (!o || !init || typeof init != 'object')
|
|
2765
|
+
return;
|
|
2766
|
+
const keys = Object.keys(init);
|
|
2767
|
+
for (const opt of ['pick', 'omit']) {
|
|
2768
|
+
for (const k of Array.isArray(o[opt]) ? o[opt] : []) {
|
|
2769
|
+
if (keys.includes(k) || !once(`SYG223:${name}:${opt}:${k}`))
|
|
2770
|
+
continue;
|
|
2771
|
+
devReport('SYG223', {
|
|
2772
|
+
component,
|
|
2773
|
+
message: `persist ${opt} names '${k}', which is not a key of ${name}.initialState, so it is ${opt == 'pick' ? 'never saved' : 'never left out'}${didYouMean(k, keys)}`,
|
|
2774
|
+
fix: `Use top-level keys of initialState in ${opt} (${keys.join(', ')}), or add '${k}' to initialState`,
|
|
2775
|
+
data: { option: opt, key: k, stateKeys: keys },
|
|
2776
|
+
});
|
|
2777
|
+
}
|
|
2778
|
+
}
|
|
2779
|
+
},
|
|
2780
|
+
};
|
|
2781
|
+
|
|
2782
|
+
const viewTransitionsCheck = {
|
|
2783
|
+
id: 'viewTransitions',
|
|
2784
|
+
onModel(component) {
|
|
2785
|
+
const list = component?.view?.viewTransitions;
|
|
2786
|
+
if (list == null)
|
|
2787
|
+
return;
|
|
2788
|
+
if (!Array.isArray(list)) {
|
|
2789
|
+
const name = nameOf(component);
|
|
2790
|
+
if (!once(`SYG645:${name}`))
|
|
2791
|
+
return;
|
|
2792
|
+
const actions = Object.keys(component.model || {}).filter(a => !a.includes('|'));
|
|
2793
|
+
devReport('SYG645', {
|
|
2794
|
+
component,
|
|
2795
|
+
message: `${name}.viewTransitions is not an array (it is ${typeof list == 'string' ? `'${list}'` : typeof list}), so no action is listed for a View Transition`,
|
|
2796
|
+
fix: `List the action names in an array: ${name}.viewTransitions = [${(typeof list == 'string' ? [list] : actions.slice(0, 2)).map(a => `'${a}'`).join(', ') || "'ACTION'"}] (an array of action names)`,
|
|
2797
|
+
data: { value: list },
|
|
2798
|
+
});
|
|
2799
|
+
return;
|
|
2800
|
+
}
|
|
2801
|
+
if (!list.length)
|
|
2802
|
+
return;
|
|
2803
|
+
const im = component.sources?.[component.DOMSourceName || 'DOM']?._isolateModule;
|
|
2804
|
+
// the core's DOM source proxy answers any unknown key with an event shorthand (a function)
|
|
2805
|
+
if (typeof im != 'object' || !im || im.vtDriver)
|
|
2806
|
+
return;
|
|
2807
|
+
if (typeof document != 'undefined' && document.querySelector?.('.sygnal-test'))
|
|
2808
|
+
return;
|
|
2809
|
+
const name = nameOf(component);
|
|
2810
|
+
if (!once(`SYG645:${name}`))
|
|
2811
|
+
return;
|
|
2812
|
+
devReport('SYG645', {
|
|
2813
|
+
component,
|
|
2814
|
+
message: `${name} declares ${name}.viewTransitions (${list.map((a) => `'${a}'`).join(', ')}), but the app's DOM driver can't run View Transitions, so those actions patch without one`,
|
|
2815
|
+
fix: `Give run() the View Transition DOM driver: run(App, { DOM: makeViewTransitionDOMDriver('#root') })`,
|
|
2816
|
+
data: { actions: list },
|
|
2817
|
+
});
|
|
2818
|
+
},
|
|
2819
|
+
};
|
|
2820
|
+
|
|
2821
|
+
/**
|
|
2822
|
+
* PLAN-4.5 G-275: marks an initial state that a wrapper built from its caller's values (an
|
|
2823
|
+
* element's host props, Vike's pageContext.data or hydrated state, renderComponent's
|
|
2824
|
+
* initialState option). The dev statics freeze (D152, checks/statics.ts) leaves a marked object
|
|
2825
|
+
* and everything in it alone: the caller may still change those values. A non-enumerable symbol
|
|
2826
|
+
* key: spreads, JSON and Object.keys don't see it.
|
|
2827
|
+
*/
|
|
2828
|
+
const OWNED = Symbol.for('sygnal.owned');
|
|
2829
|
+
|
|
2830
|
+
const plain = (v) => {
|
|
2831
|
+
if (!v || typeof v != 'object')
|
|
2832
|
+
return false;
|
|
2833
|
+
const p = Object.getPrototypeOf(v);
|
|
2834
|
+
return Array.isArray(v) || p === Object.prototype || p === null;
|
|
2835
|
+
};
|
|
2836
|
+
const deepFreeze = (v) => {
|
|
2837
|
+
if (!plain(v) || Object.isFrozen(v) || v[OWNED])
|
|
2838
|
+
return;
|
|
2839
|
+
Object.freeze(v);
|
|
2840
|
+
for (const k of Object.keys(v))
|
|
2841
|
+
deepFreeze(v[k]);
|
|
2842
|
+
};
|
|
2843
|
+
const freeze = (o) => {
|
|
2844
|
+
if (!o || (typeof o != 'object' && typeof o != 'function'))
|
|
2845
|
+
return;
|
|
2846
|
+
deepFreeze(o.initialState);
|
|
2847
|
+
for (const k of ['model', 'context', 'calculated'])
|
|
2848
|
+
if (plain(o[k]))
|
|
2849
|
+
Object.freeze(o[k]);
|
|
2850
|
+
};
|
|
2851
|
+
const staticsCheck = {
|
|
2852
|
+
id: 'statics',
|
|
2853
|
+
onIntent(component) {
|
|
2854
|
+
if (typeof component?.view == 'function')
|
|
2855
|
+
freeze(component.view);
|
|
2856
|
+
freeze(component?._o);
|
|
2857
|
+
},
|
|
2858
|
+
};
|
|
2859
|
+
|
|
2860
|
+
/**
|
|
2861
|
+
* SYG301 — an RxJS operator was called on an xstream stream (error).
|
|
2862
|
+
*
|
|
2863
|
+
* Without help this fails with a bare "x.switchMap is not a function" inside
|
|
2864
|
+
* the intent function. Mechanism: when 'sygnal/diagnostics' is loaded, every
|
|
2865
|
+
* RxJS operator name below that xstream's Stream does NOT already have is
|
|
2866
|
+
* defined on Stream.prototype (non-enumerable) as a method that throws an
|
|
2867
|
+
* enriched TypeError: same "stream.<op> is not a function" shape, plus the
|
|
2868
|
+
* xstream equivalent and the SYG301 docs link. The error is also reported
|
|
2869
|
+
* (collected / printed per the diagnostics mode; in 'error' mode report()
|
|
2870
|
+
* throws the DiagnosticError itself). It works anywhere a stream is used
|
|
2871
|
+
* (intent, peers, drivers), with no hook in the core.
|
|
2872
|
+
*
|
|
2873
|
+
* Caveat: `typeof stream.pipe === 'function'` is true while this entry is
|
|
2874
|
+
* loaded (dev only).
|
|
2875
|
+
*/
|
|
2876
|
+
/** RxJS operator -> xstream equivalent. */
|
|
2877
|
+
const RXJS_HINTS = {
|
|
2878
|
+
pipe: `chain xstream operators directly (.map(...).filter(...)), or use .compose(operator) for extra operators`,
|
|
2879
|
+
switchMap: `.map(x => inner$).flatten()`,
|
|
2880
|
+
mergeMap: `.map(x => inner$).compose(flattenConcurrently) (import { flattenConcurrently } from 'sygnal')`,
|
|
2881
|
+
flatMap: `.map(x => inner$).compose(flattenConcurrently) (import { flattenConcurrently } from 'sygnal')`,
|
|
2882
|
+
concatMap: `.map(x => inner$).compose(flattenSequentially) (import { flattenSequentially } from 'sygnal')`,
|
|
2883
|
+
exhaustMap: `.map(x => inner$).flatten() (xstream has no exhaustMap; ignore new values while busy with .filter())`,
|
|
2884
|
+
switchAll: `.flatten()`,
|
|
2885
|
+
mergeAll: `.compose(flattenConcurrently) (import { flattenConcurrently } from 'sygnal')`,
|
|
2886
|
+
concatAll: `.compose(flattenSequentially) (import { flattenSequentially } from 'sygnal')`,
|
|
2887
|
+
debounceTime: `.compose(debounce(ms)) (import { debounce } from 'sygnal')`,
|
|
2888
|
+
debounce: `.compose(debounce(ms)) (import { debounce } from 'sygnal')`,
|
|
2889
|
+
throttleTime: `.compose(throttle(ms)) (import { throttle } from 'sygnal')`,
|
|
2890
|
+
throttle: `.compose(throttle(ms)) (import { throttle } from 'sygnal')`,
|
|
2891
|
+
auditTime: `.compose(throttle(ms)) (import { throttle } from 'sygnal')`,
|
|
2892
|
+
delay: `.compose(delay(ms)) (import { delay } from 'sygnal')`,
|
|
2893
|
+
distinctUntilChanged: `.compose(dropRepeats()) (import { dropRepeats } from 'sygnal')`,
|
|
2894
|
+
withLatestFrom: `.compose(sampleCombine(other$)) (import { sampleCombine } from 'sygnal')`,
|
|
2895
|
+
combineLatest: `xs.combine(a$, b$) (import { xs } from 'sygnal')`,
|
|
2896
|
+
combineLatestWith: `xs.combine(a$, b$) (import { xs } from 'sygnal')`,
|
|
2897
|
+
merge: `xs.merge(a$, b$) (import { xs } from 'sygnal')`,
|
|
2898
|
+
mergeWith: `xs.merge(a$, b$) (import { xs } from 'sygnal')`,
|
|
2899
|
+
concat: `concat(a$, b$) (import { concat } from 'sygnal')`,
|
|
2900
|
+
scan: `.fold((acc, x) => next, seed)`,
|
|
2901
|
+
reduce: `.fold((acc, x) => next, seed).last()`,
|
|
2902
|
+
skip: `.drop(n)`,
|
|
2903
|
+
first: `.take(1)`,
|
|
2904
|
+
takeUntil: `.endWhen(other$)`,
|
|
2905
|
+
tap: `.debug(fn)`,
|
|
2906
|
+
do: `.debug(fn)`,
|
|
2907
|
+
catchError: `.replaceError(err => fallback$)`,
|
|
2908
|
+
catch: `.replaceError(err => fallback$)`,
|
|
2909
|
+
pluck: `.map(x => x.field)`,
|
|
2910
|
+
share: `xstream streams are already shared (hot); use .remember() to replay the latest value`,
|
|
2911
|
+
shareReplay: `.remember()`,
|
|
2912
|
+
publishReplay: `.remember()`,
|
|
2913
|
+
finalize: `.debug({ complete: fn }) or handle completion in the subscriber`,
|
|
2914
|
+
};
|
|
2915
|
+
const SYG301_URL = docsUrlFor('SYG301');
|
|
2916
|
+
function rxjsError(op) {
|
|
2917
|
+
const hint = RXJS_HINTS[op];
|
|
2918
|
+
const message = `stream.${op} is not a function: '${op}' is an RxJS operator, but Sygnal streams are xstream streams`;
|
|
2919
|
+
const fix = `Use ${hint}`;
|
|
2920
|
+
// In 'error' mode report() throws the DiagnosticError itself (we are about to throw anyway).
|
|
2921
|
+
const d = report('SYG301', { message, fix, data: { operator: op, hint } });
|
|
2922
|
+
const err = new TypeError(d ? d.text : `[Sygnal SYG301] ${message}. ${fix}. ${SYG301_URL}`);
|
|
2923
|
+
err.diagnostic = d;
|
|
2924
|
+
return err;
|
|
2925
|
+
}
|
|
2926
|
+
/** Install the hints on xstream's Stream.prototype. Returns an uninstall function. */
|
|
2927
|
+
function installRxjsHints(proto) {
|
|
2928
|
+
const added = [];
|
|
2929
|
+
if (!proto)
|
|
2930
|
+
return () => { };
|
|
2931
|
+
for (const op of Object.keys(RXJS_HINTS)) {
|
|
2932
|
+
if (op in proto)
|
|
2933
|
+
continue;
|
|
2934
|
+
Object.defineProperty(proto, op, {
|
|
2935
|
+
configurable: true,
|
|
2936
|
+
writable: true,
|
|
2937
|
+
enumerable: false,
|
|
2938
|
+
value: function rxjsOperatorHint() { throw rxjsError(op); },
|
|
2939
|
+
});
|
|
2940
|
+
added.push(op);
|
|
2941
|
+
}
|
|
2942
|
+
return () => { for (const op of added)
|
|
2943
|
+
delete proto[op]; };
|
|
2944
|
+
}
|
|
2945
|
+
|
|
2946
|
+
/**
|
|
2947
|
+
* PLAN-4 2-C (GS-10): the action log. Records every action a component instance runs as
|
|
2948
|
+
* { type, data, component, instance, sinks, cause, time }
|
|
2949
|
+
* for renderComponent's t.actions / t.explain() (src/extra/testing.ts), inspect({ actions }) in
|
|
2950
|
+
* the 'sygnal/diagnostics' dev entry (./inspect.ts) and the DevTools action log
|
|
2951
|
+
* (src/extra/devtoolsActions.ts).
|
|
2952
|
+
*
|
|
2953
|
+
* 0 B in apps: nothing in the core calls this. actionHooks() is a hook layer of the core
|
|
2954
|
+
* (04-hooks-contract §3.3): onAction opens the record (the core passes the cause: 'intent',
|
|
2955
|
+
* 'next', 'reply', 'built-in', 'simulateAction'; an intent action a behavior owns is
|
|
2956
|
+
* 'behavior'), wrapHandler sees which sinks produced a value. (The old core's version patched
|
|
2957
|
+
* each instance; R5 removed it.)
|
|
2958
|
+
*
|
|
2959
|
+
* Sinks: a function reducer counts when it returns a value: not ABORT (or another symbol, which
|
|
2960
|
+
* the core drops), and for STATE not the state it was given (GS-4: no change); EFFECT counts when
|
|
2961
|
+
* the handler ran without ABORT. A constant / `true` entry always sends. A throwing reducer
|
|
2962
|
+
* sends nothing.
|
|
2963
|
+
*/
|
|
2964
|
+
const BUILT_IN = /^(BOOTSTRAP|INITIALIZE|DISPOSE|RESOURCE)$/;
|
|
2965
|
+
/** now on the test's fake clock when there is one (vi.useFakeTimers()), else Date.now() */
|
|
2966
|
+
const clockNow = () => {
|
|
2967
|
+
const c = setTimeout.clock;
|
|
2968
|
+
return c ? c.now : Date.now();
|
|
2969
|
+
};
|
|
2970
|
+
/**
|
|
2971
|
+
* PLAN-4.6 R4: the same log on the core, from its hooks (04 §3.3) instead of instance
|
|
2972
|
+
* patches: onAction opens the record (the core passes the cause: 'intent', 'next', 'reply',
|
|
2973
|
+
* 'built-in', 'simulateAction'), wrapHandler sees which sinks produced a value. Same records,
|
|
2974
|
+
* same cause rules: a built-in type is 'built-in' unless simulated; an intent action a behavior
|
|
2975
|
+
* owns is 'behavior'. `only(inst)`: the instances to record (renderComponent: its own tree).
|
|
2976
|
+
*/
|
|
2977
|
+
function actionHooks(listener, only) {
|
|
2978
|
+
const cur = new WeakMap();
|
|
2979
|
+
// INITIALIZE: the core writes the initial state at creation (no action, unless the model
|
|
2980
|
+
// has an INITIALIZE entry); today's log has an INITIALIZE record with STATE, so it is opened here
|
|
2981
|
+
const init = new WeakMap();
|
|
2982
|
+
return {
|
|
2983
|
+
onCreate(inst) {
|
|
2984
|
+
const d = inst.def;
|
|
2985
|
+
if ((only && !only(inst)) || d.initialState === undefined || !(inst.isRoot || d.isolated))
|
|
2986
|
+
return;
|
|
2987
|
+
const rec = { type: 'INITIALIZE', data: d.initialState, component: inst.name, instance: String(inst.id), sinks: [], cause: 'built-in', time: clockNow() };
|
|
2988
|
+
init.set(inst, rec);
|
|
2989
|
+
try {
|
|
2990
|
+
listener.action(rec, inst);
|
|
2991
|
+
}
|
|
2992
|
+
catch (_) { /* ignore */ }
|
|
2993
|
+
addSink(listener, rec, ['STATE'], 'STATE', undefined);
|
|
2994
|
+
},
|
|
2995
|
+
onAction(inst, a) {
|
|
2996
|
+
if (only && !only(inst))
|
|
2997
|
+
return;
|
|
2998
|
+
const type = String(a.type), owned = inst.def.behaviorActions;
|
|
2999
|
+
const hs = inst.def.handlers.get(type) || [], order = hs.map((h) => h[0]);
|
|
3000
|
+
const opened = type == 'INITIALIZE' && init.get(inst);
|
|
3001
|
+
if (opened) {
|
|
3002
|
+
init.delete(inst);
|
|
3003
|
+
cur.set(inst, [opened, order]);
|
|
3004
|
+
return;
|
|
3005
|
+
}
|
|
3006
|
+
const cause = a.cause == 'simulateAction' ? a.cause : BUILT_IN.test(type) ? 'built-in'
|
|
3007
|
+
: a.cause == 'next' || a.cause == 'reply' ? a.cause
|
|
3008
|
+
: owned && Object.prototype.hasOwnProperty.call(owned, type) ? 'behavior' : 'intent';
|
|
3009
|
+
const rec = { type, data: a.data, component: inst.name, instance: String(inst.id), sinks: [], cause, time: clockNow(), ...(a.source !== undefined && { source: a.source }) };
|
|
3010
|
+
cur.set(inst, [rec, order]);
|
|
3011
|
+
try {
|
|
3012
|
+
listener.action(rec, inst);
|
|
3013
|
+
}
|
|
3014
|
+
catch (_) { /* a listener never breaks the app */ }
|
|
3015
|
+
// a constant / `true` entry always sends (EFFECT runs functions only)
|
|
3016
|
+
for (const [sink, h] of hs)
|
|
3017
|
+
if (typeof h != 'function' && sink != 'EFFECT')
|
|
3018
|
+
addSink(listener, rec, order, sink, undefined);
|
|
3019
|
+
},
|
|
3020
|
+
wrapHandler(inst, type, sink, fn) {
|
|
3021
|
+
const c = cur.get(inst);
|
|
3022
|
+
if (!c || c[0].type !== type || typeof fn != 'function')
|
|
3023
|
+
return;
|
|
3024
|
+
const [rec, order] = c, isState = sink == 'STATE', isEffect = sink == 'EFFECT';
|
|
3025
|
+
return function (state) {
|
|
3026
|
+
const r = fn.apply(this, arguments);
|
|
3027
|
+
if (!isAbort(r) && (isEffect || typeof r != 'symbol') && !(isState && r === state))
|
|
3028
|
+
addSink(listener, rec, order, sink, fn[ORIGINAL] || fn);
|
|
3029
|
+
return r;
|
|
3030
|
+
};
|
|
3031
|
+
},
|
|
3032
|
+
};
|
|
3033
|
+
}
|
|
3034
|
+
function addSink(l, rec, order, sink, reducer) {
|
|
3035
|
+
if (!rec.sinks.includes(sink)) {
|
|
3036
|
+
rec.sinks.push(sink);
|
|
3037
|
+
rec.sinks.sort((a, b) => order.indexOf(a) - order.indexOf(b));
|
|
3038
|
+
}
|
|
3039
|
+
try {
|
|
3040
|
+
l.sink?.(rec, sink, reducer);
|
|
3041
|
+
}
|
|
3042
|
+
catch (_) { /* ignore */ }
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
let records = new Map();
|
|
3046
|
+
let pendingSelects = [];
|
|
3047
|
+
/** every EVENTS type seen on the bus */
|
|
3048
|
+
let busTypes = new Set();
|
|
3049
|
+
/** PLAN-4 2-C (GS-10): the most recent actions of any instance, oldest first */
|
|
3050
|
+
const RECENT_MAX = 200;
|
|
3051
|
+
let recent = [];
|
|
3052
|
+
let epoch = clockNow();
|
|
3053
|
+
const actionListener = {
|
|
3054
|
+
action(r) { if (recent.push(r) > RECENT_MAX)
|
|
3055
|
+
recent.shift(); },
|
|
3056
|
+
};
|
|
3057
|
+
onReset(() => {
|
|
3058
|
+
records = new Map();
|
|
3059
|
+
pendingSelects = [];
|
|
3060
|
+
busTypes = new Set();
|
|
3061
|
+
recent = [];
|
|
3062
|
+
epoch = clockNow();
|
|
3063
|
+
});
|
|
3064
|
+
const uniq = (list) => [...new Set(list)];
|
|
3065
|
+
const NEXT_KIND = { root: 'root', child: 'child', item: 'collection-item', page: 'switchable' };
|
|
3066
|
+
function kindOf(c) {
|
|
3067
|
+
// PLAN-4.6 R4: the core's instance view says it (checks/next.ts)
|
|
3068
|
+
return (c && c.__next && NEXT_KIND[c.__next.kind]) || 'child';
|
|
3069
|
+
}
|
|
3070
|
+
function ensure(c) {
|
|
3071
|
+
let r = records.get(c);
|
|
3072
|
+
if (!r) {
|
|
3073
|
+
const parent = c && c.__next && c.__next.parentId;
|
|
3074
|
+
r = {
|
|
3075
|
+
instance: c,
|
|
3076
|
+
id: String(c && c._componentNumber),
|
|
3077
|
+
name: nameOf(c),
|
|
3078
|
+
parentId: typeof parent === 'number' ? String(parent) : null,
|
|
3079
|
+
kind: kindOf(c),
|
|
3080
|
+
intentActions: [],
|
|
3081
|
+
injected: new Set(),
|
|
3082
|
+
modelMap: {},
|
|
3083
|
+
fired: new Set(),
|
|
3084
|
+
eventsSelected: new Set(),
|
|
3085
|
+
eventsEmitted: new Set(),
|
|
3086
|
+
controls: new Map(),
|
|
3087
|
+
};
|
|
3088
|
+
records.set(c, r);
|
|
3089
|
+
}
|
|
3090
|
+
return r;
|
|
3091
|
+
}
|
|
3092
|
+
let devtools;
|
|
3093
|
+
function attachDevtools() {
|
|
3094
|
+
const dt = globalThis.__SYGNAL_DEVTOOLS__;
|
|
3095
|
+
if (!dt || typeof dt !== 'object')
|
|
3096
|
+
return;
|
|
3097
|
+
devtools = dt;
|
|
3098
|
+
if (dt.inspect !== inspect) {
|
|
3099
|
+
try {
|
|
3100
|
+
dt.inspect = inspect;
|
|
3101
|
+
}
|
|
3102
|
+
catch (_) { /* frozen: ignore */ }
|
|
3103
|
+
}
|
|
3104
|
+
}
|
|
3105
|
+
const inspectCheck = {
|
|
3106
|
+
id: 'inspect',
|
|
3107
|
+
onBusSelect(type) {
|
|
3108
|
+
if (type === undefined || type === null || type === '')
|
|
3109
|
+
pendingSelects.push('*');
|
|
3110
|
+
else
|
|
3111
|
+
for (const t of [].concat(type))
|
|
3112
|
+
if (typeof t === 'string')
|
|
3113
|
+
pendingSelects.push(t);
|
|
3114
|
+
if (pendingSelects.length > 1000)
|
|
3115
|
+
pendingSelects.shift();
|
|
3116
|
+
},
|
|
3117
|
+
onIntent(component, actionNames) {
|
|
3118
|
+
attachDevtools();
|
|
3119
|
+
const r = ensure(component);
|
|
3120
|
+
const intent$ = component && component.intent$;
|
|
3121
|
+
r.intentActions = actionNames || [];
|
|
3122
|
+
r.injected = new Set((intent$ && typeof intent$ === 'object' && intent$.__sygnalTestActions) || []);
|
|
3123
|
+
for (const t of pendingSelects)
|
|
3124
|
+
r.eventsSelected.add(t);
|
|
3125
|
+
pendingSelects = [];
|
|
3126
|
+
},
|
|
3127
|
+
onModel(component, modelMap) {
|
|
3128
|
+
const r = ensure(component);
|
|
3129
|
+
r.modelMap = modelMap || {};
|
|
3130
|
+
},
|
|
3131
|
+
onRender(component, root) {
|
|
3132
|
+
const r = records.get(component);
|
|
3133
|
+
if (r)
|
|
3134
|
+
collectControls(root, r.controls, true);
|
|
3135
|
+
},
|
|
3136
|
+
onReducer(component, action) {
|
|
3137
|
+
const r = records.get(component);
|
|
3138
|
+
if (r && typeof action === 'string')
|
|
3139
|
+
r.fired.add(action);
|
|
3140
|
+
},
|
|
3141
|
+
onBusEmit(type) {
|
|
3142
|
+
if (typeof type === 'string')
|
|
3143
|
+
busTypes.add(type);
|
|
3144
|
+
},
|
|
3145
|
+
onDispose(component) {
|
|
3146
|
+
records.delete(component);
|
|
3147
|
+
},
|
|
3148
|
+
};
|
|
3149
|
+
/** PLAN-4.6 R4 (onSink): an EVENTS value the instance sent */
|
|
3150
|
+
function eventEmitted(component, ev) {
|
|
3151
|
+
const r = records.get(component);
|
|
3152
|
+
if (r && ev && typeof ev.type === 'string')
|
|
3153
|
+
r.eventsEmitted.add(ev.type);
|
|
3154
|
+
}
|
|
3155
|
+
/**
|
|
3156
|
+
* CT-1: record the controls in a component's rendered tree (data-control="<Key>"), stopping at
|
|
3157
|
+
* isolated child components (their root carries data.isolate on the real DOM, a '.___<scope>'
|
|
3158
|
+
* class in the mock DOM): the controls inside belong to the child.
|
|
3159
|
+
*/
|
|
3160
|
+
function collectControls(v, out, root) {
|
|
3161
|
+
if (!v || typeof v != 'object')
|
|
3162
|
+
return;
|
|
3163
|
+
if (!root && typeof v.sel == 'string' && ((v.data && v.data.isolate) || v.sel.includes('.___')))
|
|
3164
|
+
return;
|
|
3165
|
+
const key = v.data && v.data.attrs && v.data.attrs['data-control'];
|
|
3166
|
+
if (typeof key == 'string' && !out.has(key)) {
|
|
3167
|
+
const control = controlOfVnode(v);
|
|
3168
|
+
const spec = control && control.spec;
|
|
3169
|
+
out.set(key, spec && typeof spec == 'object'
|
|
3170
|
+
? { element: null, kind: String(spec.kind) }
|
|
3171
|
+
: { element: typeof spec == 'string' ? spec : typeof v.sel == 'string' ? v.sel.split(/[.#]/)[0] : null });
|
|
3172
|
+
}
|
|
3173
|
+
for (const k of [].concat(v.children || [], (v.data && v.data.portalChildren) || []))
|
|
3174
|
+
collectControls(k, out, false);
|
|
3175
|
+
}
|
|
3176
|
+
/** CT-1: the controls a component rendered, `listened` from its intent's selectors */
|
|
3177
|
+
function controlsOf(r, selectors) {
|
|
3178
|
+
if (!r.controls.size)
|
|
3179
|
+
return undefined;
|
|
3180
|
+
const listened = new Set();
|
|
3181
|
+
for (const s of selectors)
|
|
3182
|
+
for (const m of s.selector.matchAll(/\[data-control="([^"]+)"\]/g))
|
|
3183
|
+
listened.add(m[1]);
|
|
3184
|
+
return [...r.controls].map(([name, { element, kind }]) => ({ name, element, ...(kind ? { kind } : {}), listened: listened.has(name) }));
|
|
3185
|
+
}
|
|
3186
|
+
/** CT-1: mark a selector that is a control ([data-control="<Key>"]) with its key */
|
|
3187
|
+
const withControl = (s) => {
|
|
3188
|
+
const key = keyOfSelector(s.selector);
|
|
3189
|
+
return key ? { ...s, control: key } : s;
|
|
3190
|
+
};
|
|
3191
|
+
const slim = (d) => {
|
|
3192
|
+
const out = { code: d.code, severity: d.severity, message: d.message };
|
|
3193
|
+
if (d.component)
|
|
3194
|
+
out.component = d.component;
|
|
3195
|
+
if (d.fix)
|
|
3196
|
+
out.fix = d.fix;
|
|
3197
|
+
if (d.docsUrl)
|
|
3198
|
+
out.docsUrl = d.docsUrl;
|
|
3199
|
+
if (d.data !== undefined) {
|
|
3200
|
+
// keep it JSON-safe (data can hold errors, vnodes, ...)
|
|
3201
|
+
try {
|
|
3202
|
+
out.data = JSON.parse(JSON.stringify(d.data));
|
|
3203
|
+
}
|
|
3204
|
+
catch (_) { /* drop */ }
|
|
3205
|
+
}
|
|
3206
|
+
return out;
|
|
3207
|
+
};
|
|
3208
|
+
function actionsOf(r) {
|
|
3209
|
+
const intent = (r.intentActions || []).filter(a => !r.injected.has(a));
|
|
3210
|
+
const names = uniq([...intent, ...Object.keys(r.modelMap)]).filter(a => typeof a === 'string' && !a.startsWith('__'));
|
|
3211
|
+
const replies = replySeen.get(r.instance);
|
|
3212
|
+
return names.map(name => ({
|
|
3213
|
+
name,
|
|
3214
|
+
trigger: BUILTIN_ACTIONS.has(name) ? 'builtin'
|
|
3215
|
+
: intent.includes(name) ? 'intent'
|
|
3216
|
+
: replies?.has(name) ? 'reply'
|
|
3217
|
+
: !r.injected.has(name) && r.fired.has(name) ? 'next'
|
|
3218
|
+
: 'unknown',
|
|
3219
|
+
sinks: [...(r.modelMap[name] || [])],
|
|
3220
|
+
}));
|
|
3221
|
+
}
|
|
3222
|
+
function stateKeysOf(c, calculated) {
|
|
3223
|
+
const s = c && c.currentState;
|
|
3224
|
+
return isPlainObject(s) ? Object.keys(s).filter(k => !calculated.includes(k)) : [];
|
|
3225
|
+
}
|
|
3226
|
+
/** PLAN-3 5-3: the instance's resources (`resources` static) and their state slots */
|
|
3227
|
+
function resourcesOf(c) {
|
|
3228
|
+
const res = c && c.view && c.view.resources;
|
|
3229
|
+
if (!res || typeof res !== 'object')
|
|
3230
|
+
return undefined;
|
|
3231
|
+
const s = c.currentState || {};
|
|
3232
|
+
return Object.keys(res).map(name => {
|
|
3233
|
+
const r = s[name] || {};
|
|
3234
|
+
return { name, status: r.status ?? 'idle', refreshing: !!r.refreshing, hasData: r.data !== undefined, error: r.error ? String(r.error.message ?? r.error) : undefined };
|
|
3235
|
+
});
|
|
3236
|
+
}
|
|
3237
|
+
/** PLAN-3 5-3: the cache entries of every makeFetchDriver source the instances use, by sink name (sinks with an empty cache left out) */
|
|
3238
|
+
function cacheOf(recs) {
|
|
3239
|
+
const out = {};
|
|
3240
|
+
for (const r of recs) {
|
|
3241
|
+
const sources = r.instance && r.instance.sources;
|
|
3242
|
+
if (!sources || typeof sources !== 'object')
|
|
3243
|
+
continue;
|
|
3244
|
+
for (const k of Object.keys(sources)) {
|
|
3245
|
+
const src = sources[k];
|
|
3246
|
+
if (out[k] || !src || src.__sygnalStatic !== 'resources' || typeof src.__inspect !== 'function')
|
|
3247
|
+
continue;
|
|
3248
|
+
try {
|
|
3249
|
+
const list = src.__inspect().cache;
|
|
3250
|
+
if (list.length)
|
|
3251
|
+
out[k] = list;
|
|
3252
|
+
}
|
|
3253
|
+
catch (_) { /* a driver without the cache listing */ }
|
|
3254
|
+
}
|
|
3255
|
+
}
|
|
3256
|
+
return out;
|
|
3257
|
+
}
|
|
3258
|
+
const isScope = (s) => s.startsWith('.___');
|
|
3259
|
+
function mockSelectors(mock) {
|
|
3260
|
+
const out = {};
|
|
3261
|
+
mock.listeners.forEach((path, key) => {
|
|
3262
|
+
const scope = path.filter(isScope).pop();
|
|
3263
|
+
const id = mock.scopeIds.get(scope || '');
|
|
3264
|
+
if (id === undefined)
|
|
3265
|
+
return;
|
|
3266
|
+
const sels = path.filter(s => !isScope(s)).join(' ').split(/[\s>]+/).filter(Boolean);
|
|
3267
|
+
const page = !sels.length || /^(document|body)$/.test(sels[0]);
|
|
3268
|
+
const { own, child } = mock.vtree && !page ? mock.probe(sels, scope) : { own: false, child: undefined };
|
|
3269
|
+
(out[id] = out[id] || []).push({
|
|
3270
|
+
selector: sels.join(' '),
|
|
3271
|
+
events: uniq(mock.evTypes[key] || []),
|
|
3272
|
+
matched: mock.vtree && !page ? own : null,
|
|
3273
|
+
isolationHit: !own && child ? mock.owners.get(child) || 'a child component' : null,
|
|
3274
|
+
});
|
|
3275
|
+
});
|
|
3276
|
+
return out;
|
|
3277
|
+
}
|
|
3278
|
+
function selectorsOf(r, options, diags) {
|
|
3279
|
+
const given = options.selectors && options.selectors[r.id];
|
|
3280
|
+
if (given)
|
|
3281
|
+
return given.map(s => withControl({ ...s }));
|
|
3282
|
+
return (selectorStatus(r.instance) || []).map(({ selector, matched, crossed }) => {
|
|
3283
|
+
const hit = diags.find(d => d.code === 'SYG104' && d.component === r.name && d.data && d.data.selector === selector);
|
|
3284
|
+
const missed = diags.some(d => d.code === 'SYG103' && d.component === r.name && d.data && d.data.selector === selector);
|
|
3285
|
+
return withControl({
|
|
3286
|
+
selector,
|
|
3287
|
+
events: null,
|
|
3288
|
+
matched: matched === null && missed ? false : matched,
|
|
3289
|
+
isolationHit: crossed ? ((hit && hit.data.child) || 'a child component') : null,
|
|
3290
|
+
});
|
|
3291
|
+
});
|
|
3292
|
+
}
|
|
3293
|
+
function childrenOf(r, all) {
|
|
3294
|
+
const via = { root: 'tag', child: 'tag', 'collection-item': 'collection', switchable: 'switchable' };
|
|
3295
|
+
const out = new Map();
|
|
3296
|
+
for (const c of all) {
|
|
3297
|
+
if (c.parentId !== r.id)
|
|
3298
|
+
continue;
|
|
3299
|
+
const key = `${c.name}\u0000${via[c.kind]}`;
|
|
3300
|
+
const entry = out.get(key);
|
|
3301
|
+
if (entry)
|
|
3302
|
+
entry.count = (entry.count || 1) + 1;
|
|
3303
|
+
else
|
|
3304
|
+
out.set(key, { name: c.name, via: via[c.kind], count: 1 });
|
|
3305
|
+
}
|
|
3306
|
+
return [...out.values()];
|
|
3307
|
+
}
|
|
3308
|
+
/** The app graph of the live component instances (see ./public.d.ts). */
|
|
3309
|
+
function inspect(options = {}) {
|
|
3310
|
+
// report SYG105 (info) for EVENTS types selected but never emitted so far
|
|
3311
|
+
try {
|
|
3312
|
+
checkEventBus();
|
|
3313
|
+
}
|
|
3314
|
+
catch (_) { /* 'error' mode: info never throws; be safe */ }
|
|
3315
|
+
let diags = options.diagnostics || [];
|
|
3316
|
+
if (!options.diagnostics) {
|
|
3317
|
+
attachDevtools();
|
|
3318
|
+
try {
|
|
3319
|
+
diags = (devtools && typeof devtools.getDiagnostics === 'function' && devtools.getDiagnostics()) || [];
|
|
3320
|
+
}
|
|
3321
|
+
catch (_) {
|
|
3322
|
+
diags = [];
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
const mock = options.mock;
|
|
3326
|
+
if (mock)
|
|
3327
|
+
options = { ...options, selectors: { ...mockSelectors(mock), ...options.selectors } };
|
|
3328
|
+
const all = [...records.values()];
|
|
3329
|
+
const ids = options.ids && new Set(options.ids.map(String));
|
|
3330
|
+
const recs = ids ? all.filter(r => ids.has(r.id)) : all;
|
|
3331
|
+
const names = new Set(recs.map(r => r.name));
|
|
3332
|
+
const components = recs.map(r => {
|
|
3333
|
+
const c = r.instance;
|
|
3334
|
+
const calculated = c && c.calculated && typeof c.calculated === 'object' ? Object.keys(c.calculated) : [];
|
|
3335
|
+
const selectors = selectorsOf(r, options, diags);
|
|
3336
|
+
const controls = controlsOf(r, selectors);
|
|
3337
|
+
return {
|
|
3338
|
+
name: r.name,
|
|
3339
|
+
id: r.id,
|
|
3340
|
+
parentId: r.parentId,
|
|
3341
|
+
kind: r.kind,
|
|
3342
|
+
actions: actionsOf(r),
|
|
3343
|
+
stateKeys: stateKeysOf(c, calculated),
|
|
3344
|
+
calculated,
|
|
3345
|
+
contextProvides: c && isPlainObject(c.context) ? Object.keys(c.context) : [],
|
|
3346
|
+
contextConsumes: null,
|
|
3347
|
+
eventsEmitted: [...r.eventsEmitted],
|
|
3348
|
+
eventsSelected: [...r.eventsSelected],
|
|
3349
|
+
children: childrenOf(r, all),
|
|
3350
|
+
selectors,
|
|
3351
|
+
...(controls ? { controls } : {}),
|
|
3352
|
+
diagnostics: diags.filter(d => d && d.component === r.name).map(slim),
|
|
3353
|
+
...(resourcesOf(c) ? { resources: resourcesOf(c) } : {}),
|
|
3354
|
+
};
|
|
3355
|
+
});
|
|
3356
|
+
const cache = cacheOf(recs) || {};
|
|
3357
|
+
const events = {};
|
|
3358
|
+
const ev = (type) => events[type] || (events[type] = { emitters: [], selectors: [] });
|
|
3359
|
+
const add = (list, name) => { if (!list.includes(name))
|
|
3360
|
+
list.push(name); };
|
|
3361
|
+
for (const c of components) {
|
|
3362
|
+
for (const t of c.eventsEmitted)
|
|
3363
|
+
add(ev(t).emitters, c.name);
|
|
3364
|
+
for (const t of c.eventsSelected)
|
|
3365
|
+
add(ev(t).selectors, c.name);
|
|
3366
|
+
}
|
|
3367
|
+
// emitted on the bus by something that isn't a listed component (e.g. a driver, a pruned component)
|
|
3368
|
+
if (!ids)
|
|
3369
|
+
for (const t of busTypes)
|
|
3370
|
+
ev(t);
|
|
3371
|
+
return {
|
|
3372
|
+
version: 1,
|
|
3373
|
+
source: 'runtime',
|
|
3374
|
+
components,
|
|
3375
|
+
events,
|
|
3376
|
+
diagnostics: diags.filter(d => d && (!d.component || !names.has(d.component))).map(slim),
|
|
3377
|
+
...(Object.keys(cache).length ? { cache } : {}),
|
|
3378
|
+
...(options.actions ? { recentActions: recentActions(options.actions, ids) } : {}),
|
|
3379
|
+
};
|
|
3380
|
+
}
|
|
3381
|
+
/** a JSON-safe copy of an action's data (dropped when it doesn't serialize, or is large: a DOM event) */
|
|
3382
|
+
function jsonData(d) {
|
|
3383
|
+
if (d === undefined)
|
|
3384
|
+
return {};
|
|
3385
|
+
try {
|
|
3386
|
+
const text = JSON.stringify(d);
|
|
3387
|
+
return text === undefined || text.length > 1000 ? {} : { data: JSON.parse(text) };
|
|
3388
|
+
}
|
|
3389
|
+
catch (_) {
|
|
3390
|
+
return {};
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
/** PLAN-4 2-C: the last `limit` actions (true: all kept) of the listed instances */
|
|
3394
|
+
function recentActions(limit, ids) {
|
|
3395
|
+
const list = ids ? recent.filter(r => ids.has(r.instance)) : recent;
|
|
3396
|
+
const n = limit === true ? list.length : Math.max(0, Math.floor(limit));
|
|
3397
|
+
return list.slice(list.length - n).map(r => ({
|
|
3398
|
+
type: r.type,
|
|
3399
|
+
...jsonData(r.data),
|
|
3400
|
+
component: r.component,
|
|
3401
|
+
instance: r.instance,
|
|
3402
|
+
sinks: [...r.sinks],
|
|
3403
|
+
cause: r.cause,
|
|
3404
|
+
at: r.time - epoch,
|
|
3405
|
+
}));
|
|
3406
|
+
}
|
|
3407
|
+
/** Publish inspect() on the core bridge and the devtools object. Returns an uninstall function. */
|
|
3408
|
+
function installInspect() {
|
|
3409
|
+
const core = bridge();
|
|
3410
|
+
if (core)
|
|
3411
|
+
core.inspect = inspect;
|
|
3412
|
+
attachDevtools();
|
|
3413
|
+
return () => {
|
|
3414
|
+
if (core && core.inspect === inspect)
|
|
3415
|
+
core.inspect = undefined;
|
|
3416
|
+
const dt = globalThis.__SYGNAL_DEVTOOLS__;
|
|
3417
|
+
if (dt && dt.inspect === inspect) {
|
|
3418
|
+
try {
|
|
3419
|
+
delete dt.inspect;
|
|
3420
|
+
}
|
|
3421
|
+
catch (_) { /* ignore */ }
|
|
3422
|
+
}
|
|
3423
|
+
};
|
|
3424
|
+
}
|
|
3425
|
+
|
|
3426
|
+
/**
|
|
3427
|
+
* PLAN-4.6 R4: the dev entry's checks on the next component core (src/core/).
|
|
3428
|
+
*
|
|
3429
|
+
* The core has no instance to patch and no diagnostics calls of its own: it reads this
|
|
3430
|
+
* entry's hooks once per app (`__SYGNAL_DIAGNOSTICS__.nextHooks(api)`, 04-hooks-contract §2.2)
|
|
3431
|
+
* and calls them with read-only InstanceViews. This module maps them onto the existing checks:
|
|
3432
|
+
*
|
|
3433
|
+
* - each instance gets a facade with the fields the checks read from today's instances (name,
|
|
3434
|
+
* view, model, sources, currentState, ...), so the DiagnosticCheck hooks (onIntent, onModel,
|
|
3435
|
+
* onRender, onReducer, onDispose, sources) run unchanged through the core's dispatchers;
|
|
3436
|
+
* - what the checks did by patching an instance becomes a hook: SYG222's reducer wrapping
|
|
3437
|
+
* (wrapHandler), the reply / fetch / EVENTS taps of a sink (onSink), the ELEMENT check
|
|
3438
|
+
* (onElementCommand), the Collection check (onHostProps), the action log (actionLog.ts);
|
|
3439
|
+
* - the core's own dev codes: SYG423 (D168: a view context tracking skipped would have
|
|
3440
|
+
* rendered differently), SYG424 (D169/D177: duplicate Collection ids), SYG425 (D174: an
|
|
3441
|
+
* isolatedState child kept a slice that lacks its initialState keys), SYG148 / SYG149 (G-419:
|
|
3442
|
+
* a Collection's viewTransitionName that isn't an identifier, the same name twice on the
|
|
3443
|
+
* page after a patch, G-460: on rendered elements), SYG612 (D173: a form
|
|
3444
|
+
* 6.0 removed, met at runtime; once per form and component, with a link to the migration
|
|
3445
|
+
* guide).
|
|
3446
|
+
*
|
|
3447
|
+
* Only while diagnostics are on (the checks' own rule); production builds never load this.
|
|
3448
|
+
*/
|
|
3449
|
+
const MIGRATE = 'https://sygnal.js.org/guide/migrating-to-6';
|
|
3450
|
+
/** the removed forms (D162-D164) by migration-guide anchor: what it was, how to write it now */
|
|
3451
|
+
const REMOVED = {
|
|
3452
|
+
'string-tags': ['a component named by a string tag (<Name /> as a string, looked up in .components)', 'Import the component and use it as a JSX tag: <Badge />'],
|
|
3453
|
+
'components': ['the .components registry', 'Import each component and use it as a JSX tag; remove .components'],
|
|
3454
|
+
'collection-of-name': ['<Collection of="Name"> (a name)', 'Pass the component itself: <Collection of={Item} from="items" />'],
|
|
3455
|
+
'collection-wrapper': ["the Collection's wrapper element (className, style, class, attrs, data-*, on, ref or hook on <Collection>)", 'Collection renders its items directly into its parent: put them on your own wrapping element, <ul className="x"><Collection of={Item} from="items" /></ul>'],
|
|
3456
|
+
'child-select-name': ["CHILD.select('Name') (a name)", 'Pass the component itself: CHILD.select(Item)'],
|
|
3457
|
+
'pipe-keys': ["'ACTION | SINK' model keys", "Use the object form: ACTION: { SINK: (state, data) => ... }"],
|
|
3458
|
+
'peers': ['.peers', 'Render the peer as a sibling in the parent view'],
|
|
3459
|
+
'hmractions': ['hmrActions', 'Remove hmrActions; sygnal/vite keeps the state across a hot update'],
|
|
3460
|
+
'positional-views': ['a view with positional parameters (props, state, context, peers)', 'Destructure the one argument: function C({ state, context, ...props })'],
|
|
3461
|
+
'source-names': ['DOMSourceName / stateSourceName', "Remove them: the sources are always DOM and STATE"],
|
|
3462
|
+
'storecalculatedinstate': ['storeCalculatedInState', 'Remove it: calculated fields are always part of the state the view and reducers get'],
|
|
3463
|
+
'component-factory': ['the component({ ... }) factory (with sources / isolateOpts)', 'Write a function component with statics (C.model, C.intent, C.initialState), or use defineComponent({ ... })'],
|
|
3464
|
+
};
|
|
3465
|
+
/** 4-H: the marker's props / data buckets the removed Collection wrapper element took */
|
|
3466
|
+
const WRAPPER = ['className', 'style', 'class', 'attrs', 'dataset', 'on', 'hook'];
|
|
3467
|
+
const STATICS = [['components', 'components'], ['peers', 'peers'], ['hmrActions', 'hmractions'], ['DOMSourceName', 'source-names'], ['stateSourceName', 'source-names'], ['storeCalculatedInState', 'storecalculatedinstate']];
|
|
3468
|
+
function removed(component, name, anchor, detail) {
|
|
3469
|
+
if (!once(`SYG612:${name}:${anchor}`))
|
|
3470
|
+
return;
|
|
3471
|
+
const [what, fix] = REMOVED[anchor];
|
|
3472
|
+
devReport('SYG612', {
|
|
3473
|
+
component: name,
|
|
3474
|
+
message: `${name} uses ${what}${detail ? ` (${detail})` : ''}, which Sygnal 6.0 removed, so it is ignored or fails`,
|
|
3475
|
+
fix: `${fix}. See ${MIGRATE}#${anchor}`,
|
|
3476
|
+
data: { form: anchor, guide: `${MIGRATE}#${anchor}`, ...(detail ? { detail } : {}) },
|
|
3477
|
+
});
|
|
3478
|
+
}
|
|
3479
|
+
/** the removed forms a definition shows (statics, view arity, model keys), once per function */
|
|
3480
|
+
let seenDefs = new WeakSet();
|
|
3481
|
+
// G-332: resetChecks() forgets them (as the once() dedupe)
|
|
3482
|
+
onReset(() => { seenDefs = new WeakSet(); });
|
|
3483
|
+
function removedStatics(f) {
|
|
3484
|
+
const view = f.view;
|
|
3485
|
+
if (!view || seenDefs.has(view))
|
|
3486
|
+
return;
|
|
3487
|
+
seenDefs.add(view);
|
|
3488
|
+
const name = f.name;
|
|
3489
|
+
if (view.isSygnalComponent)
|
|
3490
|
+
removed(f, name, 'component-factory');
|
|
3491
|
+
for (const [k, anchor] of STATICS)
|
|
3492
|
+
if (view[k] !== undefined && !(k == 'components' && isPlainObject(view[k]) && !Object.keys(view[k]).length))
|
|
3493
|
+
removed(f, name, anchor, k);
|
|
3494
|
+
// G-343: a defineComponent() wrapper's own arity is 1; the view it calls is __sygnalView
|
|
3495
|
+
const arity = typeof view == 'function' ? (view.__sygnalView || view).length : 0;
|
|
3496
|
+
if (arity > 1 && !view.__sygnalLazy && !view.isSygnalComponent)
|
|
3497
|
+
removed(f, name, 'positional-views', `${arity} parameters`);
|
|
3498
|
+
const model = view.model;
|
|
3499
|
+
if (isPlainObject(model))
|
|
3500
|
+
for (const k of Object.keys(model))
|
|
3501
|
+
if (k.includes('|'))
|
|
3502
|
+
removed(f, name, 'pipe-keys', `'${k}'`);
|
|
3503
|
+
}
|
|
3504
|
+
/** string tags left in a rendered vnode (the core renders them as elements) */
|
|
3505
|
+
function removedTags(f, v, depth = 0) {
|
|
3506
|
+
if (!v || typeof v != 'object' || depth > 300)
|
|
3507
|
+
return;
|
|
3508
|
+
const sel = v.sel;
|
|
3509
|
+
// G-329: a component name is PascalCase; an all-caps tag (h('SPAN'), h(el.tagName)) is an element
|
|
3510
|
+
const tag = typeof sel == 'string' ? sel.split(/[.#]/)[0] : '';
|
|
3511
|
+
if (/^[A-Z]/.test(tag) && /[a-z]/.test(tag) && !(v.data && v.data.c))
|
|
3512
|
+
removed(f, f.name, 'string-tags', `<${tag}>`);
|
|
3513
|
+
const kids = v.children;
|
|
3514
|
+
if (Array.isArray(kids))
|
|
3515
|
+
for (const k of kids)
|
|
3516
|
+
removedTags(f, k, depth + 1);
|
|
3517
|
+
}
|
|
3518
|
+
/** the instance fields the checks read from today's Component instances */
|
|
3519
|
+
class Facade {
|
|
3520
|
+
constructor(__next) {
|
|
3521
|
+
this.__next = __next;
|
|
3522
|
+
}
|
|
3523
|
+
get name() { return this.__next.name; }
|
|
3524
|
+
get view() { return this.__next.def.view; }
|
|
3525
|
+
get model() {
|
|
3526
|
+
const m = {};
|
|
3527
|
+
this.__next.def.handlers.forEach((hs, a) => { const e = m[a] = {}; for (const [s, fn] of hs)
|
|
3528
|
+
e[s] = fn; });
|
|
3529
|
+
return m;
|
|
3530
|
+
}
|
|
3531
|
+
get modelMap() {
|
|
3532
|
+
const m = {};
|
|
3533
|
+
this.__next.def.handlers.forEach((hs, a) => { m[a] = hs.map(h => h[0]); });
|
|
3534
|
+
// the built-in INITIALIZE (today's model map ends with it for every component)
|
|
3535
|
+
if (!m.INITIALIZE)
|
|
3536
|
+
m.INITIALIZE = ['STATE'];
|
|
3537
|
+
return m;
|
|
3538
|
+
}
|
|
3539
|
+
get sources() { return this._so || this.__next.sources; }
|
|
3540
|
+
get sourceNames() { return Object.keys(this.__next.sources); }
|
|
3541
|
+
get stateSourceName() { return 'STATE'; }
|
|
3542
|
+
get DOMSourceName() { return 'DOM'; }
|
|
3543
|
+
get isSubComponent() { return !this.__next.isRoot; }
|
|
3544
|
+
get initialState() { return this.__next.def.initialState; }
|
|
3545
|
+
get currentState() { return this.__next.state; }
|
|
3546
|
+
get currentProps() { return this.__next.props; }
|
|
3547
|
+
get currentContext() { return this.__next.context; }
|
|
3548
|
+
get _componentNumber() { return this.__next.id; }
|
|
3549
|
+
get calculated() { return this.__next.def.view.calculated; }
|
|
3550
|
+
get _calculatedFieldNames() { return this.__next.def.calcNames; }
|
|
3551
|
+
get context() { return this.__next.def.view.context; }
|
|
3552
|
+
get _uses() { return Object.entries(this.__next.def.view.uses || {}); }
|
|
3553
|
+
get _behaviorActions() { return this.__next.def.behaviorActions; }
|
|
3554
|
+
get intent$() { return { __sygnalTestActions: this.__next.def.testActions || [] }; }
|
|
3555
|
+
get _disposed() { return this.__next.disposed; }
|
|
3556
|
+
/** the state already has its calculated fields */
|
|
3557
|
+
addCalculated(s) { return s; }
|
|
3558
|
+
}
|
|
3559
|
+
const facades = new WeakMap();
|
|
3560
|
+
const fac = (iv) => {
|
|
3561
|
+
let f = facades.get(iv);
|
|
3562
|
+
if (!f)
|
|
3563
|
+
facades.set(iv, f = new Facade(iv));
|
|
3564
|
+
return f;
|
|
3565
|
+
};
|
|
3566
|
+
/** structural vnode equality (functions equal: a view may make new closures every call) */
|
|
3567
|
+
function same(a, b, d = 0) {
|
|
3568
|
+
if (a === b || (typeof a == 'function' && typeof b == 'function'))
|
|
3569
|
+
return true;
|
|
3570
|
+
if (!a || !b || typeof a != 'object' || typeof b != 'object')
|
|
3571
|
+
return a !== a && b !== b;
|
|
3572
|
+
if (d > 300)
|
|
3573
|
+
return true;
|
|
3574
|
+
if (Array.isArray(a) != Array.isArray(b))
|
|
3575
|
+
return false;
|
|
3576
|
+
const ka = Object.keys(a).filter(k => k != 'elm' && a[k] !== undefined), kb = Object.keys(b).filter(k => k != 'elm' && b[k] !== undefined);
|
|
3577
|
+
if (ka.length != kb.length)
|
|
3578
|
+
return false;
|
|
3579
|
+
for (const k of ka)
|
|
3580
|
+
if (!same(a[k], b[k], d + 1))
|
|
3581
|
+
return false;
|
|
3582
|
+
return true;
|
|
3583
|
+
}
|
|
3584
|
+
const SAMPLE = 16, CAP = 20;
|
|
3585
|
+
/** a CSS identifier's start, then identifier characters (`<prefix>-<id>` is one when the prefix starts one) */
|
|
3586
|
+
const IDENT = /^(--|-?[A-Za-z_\u0080-\uffff])[\w\u0080-\uffff-]*$/;
|
|
3587
|
+
/** G-419 SYG148: a Collection's viewTransitionName that is not a CSS identifier (true: a valid one) */
|
|
3588
|
+
function vtPrefix(name, p) {
|
|
3589
|
+
if (typeof p == 'string' && IDENT.test(p))
|
|
3590
|
+
return true;
|
|
3591
|
+
const shown = typeof p == 'string' ? JSON.stringify(p) : `{${typeof p}}`;
|
|
3592
|
+
if (once(`SYG148:${name}:${String(p)}`))
|
|
3593
|
+
devReport('SYG148', {
|
|
3594
|
+
component: name,
|
|
3595
|
+
message: `A Collection in ${name} has viewTransitionName=${shown}, which is not a CSS identifier, so every item's view-transition-name (${typeof p == 'string' ? p : '<prefix>'}-<id>) is invalid CSS: the browser ignores it and nothing animates`,
|
|
3596
|
+
fix: 'Use a CSS identifier as the prefix: letters, digits, - and _, not starting with a digit (viewTransitionName="card")',
|
|
3597
|
+
data: { value: p },
|
|
3598
|
+
});
|
|
3599
|
+
return false;
|
|
3600
|
+
}
|
|
3601
|
+
/**
|
|
3602
|
+
* G-460: an element the browser renders (a View Transition captures only those): not in a
|
|
3603
|
+
* display: none subtree (a hidden tab panel, a list shown only at another width). checkVisibility()
|
|
3604
|
+
* where there is one; else (jsdom) no display: none on it or an ancestor
|
|
3605
|
+
*/
|
|
3606
|
+
function rendered(el) {
|
|
3607
|
+
if (!el?.isConnected)
|
|
3608
|
+
return false;
|
|
3609
|
+
if (typeof el.checkVisibility == 'function')
|
|
3610
|
+
return el.checkVisibility();
|
|
3611
|
+
const cs = el.ownerDocument?.defaultView?.getComputedStyle;
|
|
3612
|
+
for (let n = el; n && n.nodeType == 1; n = n.parentNode)
|
|
3613
|
+
if (n.hidden || cs?.(n).display == 'none')
|
|
3614
|
+
return false;
|
|
3615
|
+
return true;
|
|
3616
|
+
}
|
|
3617
|
+
/**
|
|
3618
|
+
* G-419 SYG149: two elements of the patched page with the same view-transition-name from
|
|
3619
|
+
* Collections with the same prefix (the same id shown in two of them): the browser skips the
|
|
3620
|
+
* whole View Transition. Walks the vnode tree (dev only, while a named Collection is mounted);
|
|
3621
|
+
* G-460: a name found twice is checked on the DOM after the patch (a microtask later; when a View
|
|
3622
|
+
* Transition defers the patch, again a little later): only rendered elements count. Returns the
|
|
3623
|
+
* groups of vnodes with one name (none: an empty list)
|
|
3624
|
+
*/
|
|
3625
|
+
function vtDuplicates(root, prefixes) {
|
|
3626
|
+
const seen = new Map(), dup = [];
|
|
3627
|
+
const stack = [root];
|
|
3628
|
+
while (stack.length) {
|
|
3629
|
+
const v = stack.pop();
|
|
3630
|
+
if (!v || typeof v != 'object')
|
|
3631
|
+
continue;
|
|
3632
|
+
const st = v.data?.style, n = st?.viewTransitionName;
|
|
3633
|
+
if (typeof n == 'string' && prefixes.has(st.viewTransitionClass)) {
|
|
3634
|
+
const g = seen.get(n);
|
|
3635
|
+
if (!g)
|
|
3636
|
+
seen.set(n, [v]);
|
|
3637
|
+
else if (g.push(v) == 2)
|
|
3638
|
+
dup.push(g);
|
|
3639
|
+
}
|
|
3640
|
+
if (Array.isArray(v.children))
|
|
3641
|
+
for (let i = v.children.length; i--;)
|
|
3642
|
+
stack.push(v.children[i]);
|
|
3643
|
+
}
|
|
3644
|
+
return dup;
|
|
3645
|
+
}
|
|
3646
|
+
// G-538: a fragment root never gets an element (the DOM driver flattens it into the container):
|
|
3647
|
+
// it has run when each of its children has one (a kept child has its old one: a View Transition
|
|
3648
|
+
// patch of a fragment root whose children are all kept is checked before it runs)
|
|
3649
|
+
const patched = (v) => v.elm || !v.sel && v.children?.every((c) => !c || patched(c));
|
|
3650
|
+
/**
|
|
3651
|
+
* G-527: the patches waiting for their SYG149 check ([root vnode, duplicate groups], newest last,
|
|
3652
|
+
* at most 8) and one chain of checks over them: a microtask after the first, then every 50 ms
|
|
3653
|
+
* while some wait. The patch of a root has run when it has its element (a View Transition patches
|
|
3654
|
+
* in its update callback, and replaces a held vnode it hasn't patched yet; kept vnodes have their
|
|
3655
|
+
* old elements before that): each check takes the newest patch that ran and drops it and the older
|
|
3656
|
+
* ones. A newer patch joins the queue and doesn't restart the chain, so patches closer than 50 ms
|
|
3657
|
+
* (View Transitions back to back) can't starve it. A patch without duplicates joins only a queue
|
|
3658
|
+
* that waits (once it has run, the older ones are moot). G-501: the chain stops once the app (or
|
|
3659
|
+
* every named Collection) is gone, and after 10 tries without a patched root (the mock DOM; a root
|
|
3660
|
+
* that never gets its element isn't checked on its children's old elements)
|
|
3661
|
+
*/
|
|
3662
|
+
function vtChecker(live) {
|
|
3663
|
+
let q = [], busy = false, tries = 0;
|
|
3664
|
+
const vtRendered = () => {
|
|
3665
|
+
if (!live()) {
|
|
3666
|
+
q = [];
|
|
3667
|
+
busy = false;
|
|
3668
|
+
return;
|
|
3669
|
+
}
|
|
3670
|
+
let i = q.length;
|
|
3671
|
+
while (i-- && !patched(q[i][0]))
|
|
3672
|
+
;
|
|
3673
|
+
if (i >= 0) {
|
|
3674
|
+
vtReport(q[i][1]);
|
|
3675
|
+
q = q.slice(i + 1);
|
|
3676
|
+
tries = 0;
|
|
3677
|
+
}
|
|
3678
|
+
else
|
|
3679
|
+
tries++;
|
|
3680
|
+
if (!q.length || tries > 10) {
|
|
3681
|
+
q = [];
|
|
3682
|
+
busy = false;
|
|
3683
|
+
return;
|
|
3684
|
+
}
|
|
3685
|
+
setTimeout(vtRendered, 50);
|
|
3686
|
+
};
|
|
3687
|
+
return (root, dup) => {
|
|
3688
|
+
if (!dup.length && !q.length)
|
|
3689
|
+
return;
|
|
3690
|
+
q.push([root, dup]);
|
|
3691
|
+
if (q.length > 8)
|
|
3692
|
+
q.shift();
|
|
3693
|
+
if (!busy) {
|
|
3694
|
+
busy = true;
|
|
3695
|
+
tries = 0;
|
|
3696
|
+
queueMicrotask(vtRendered);
|
|
3697
|
+
}
|
|
3698
|
+
};
|
|
3699
|
+
}
|
|
3700
|
+
function vtReport(dup) {
|
|
3701
|
+
for (const g of dup) {
|
|
3702
|
+
const v = g[0], st = v.data.style, n = st.viewTransitionName;
|
|
3703
|
+
if (g.filter(x => rendered(x.elm)).length < 2 || !once(`SYG149:${n}`))
|
|
3704
|
+
continue;
|
|
3705
|
+
devReport('SYG149', {
|
|
3706
|
+
component: 'Collection',
|
|
3707
|
+
message: `Two elements on the page have view-transition-name '${n}': Collections with viewTransitionName="${st.viewTransitionClass}" show the item with that id at the same time. Names must be unique when a View Transition starts, so the browser skips every transition while both are shown`,
|
|
3708
|
+
fix: `Give Collections that can show the same item at once different prefixes (viewTransitionName="${st.viewTransitionClass}" and another), or name the item yourself with its own style`,
|
|
3709
|
+
data: { name: n, prefix: st.viewTransitionClass },
|
|
3710
|
+
});
|
|
3711
|
+
}
|
|
3712
|
+
}
|
|
3713
|
+
/** The hooks one app gets (installChecks publishes this on the bridge). */
|
|
3714
|
+
function nextHooks(_api) {
|
|
3715
|
+
const core = bridge(), H = core.hooks;
|
|
3716
|
+
const on = () => H.on();
|
|
3717
|
+
const modelOf = (f) => f.modelMap;
|
|
3718
|
+
const wired = new WeakSet();
|
|
3719
|
+
const wire = (iv, names) => {
|
|
3720
|
+
const f = fac(iv);
|
|
3721
|
+
if (wired.has(f))
|
|
3722
|
+
return;
|
|
3723
|
+
wired.add(f);
|
|
3724
|
+
H.onIntent(f, names);
|
|
3725
|
+
H.onModel(f, modelOf(f));
|
|
3726
|
+
};
|
|
3727
|
+
// D168: the views context tracking skipped, checked on a sample (each instance's first two
|
|
3728
|
+
// skips, then 1 in 16; at most CAP per tick)
|
|
3729
|
+
const skips = new WeakMap();
|
|
3730
|
+
let budget = 0;
|
|
3731
|
+
const log = actionHooks(actionListener, () => on());
|
|
3732
|
+
// G-419: the viewTransitionName prefixes this app's Collections use; G-460: per instance that
|
|
3733
|
+
// renders them, so the SYG149 walk stops once every one of them is disposed
|
|
3734
|
+
const vtp = new Map();
|
|
3735
|
+
// G-527: one chain of SYG149 checks over the latest patches (G-501: it stops once every instance
|
|
3736
|
+
// that renders a named Collection is disposed, the app's dispose included)
|
|
3737
|
+
const vtCheck = vtChecker(() => vtp.size && on());
|
|
3738
|
+
return {
|
|
3739
|
+
onCreate(iv) {
|
|
3740
|
+
if (!on())
|
|
3741
|
+
return;
|
|
3742
|
+
log.onCreate(iv);
|
|
3743
|
+
const f = fac(iv);
|
|
3744
|
+
removedStatics(f);
|
|
3745
|
+
if (!iv.def.intent)
|
|
3746
|
+
wire(iv, []);
|
|
3747
|
+
},
|
|
3748
|
+
wrapSources(iv, so) {
|
|
3749
|
+
const f = fac(iv);
|
|
3750
|
+
f._so = so;
|
|
3751
|
+
let out;
|
|
3752
|
+
try {
|
|
3753
|
+
out = H.sourcesFor(f);
|
|
3754
|
+
}
|
|
3755
|
+
finally {
|
|
3756
|
+
f._so = undefined;
|
|
3757
|
+
}
|
|
3758
|
+
if (!on() || typeof Proxy != 'function')
|
|
3759
|
+
return out;
|
|
3760
|
+
// D173: CHILD.select('Name')
|
|
3761
|
+
const s = out || so;
|
|
3762
|
+
return new Proxy(s, {
|
|
3763
|
+
get(t, k) {
|
|
3764
|
+
const v = t[k];
|
|
3765
|
+
if (k !== 'CHILD' || !v || typeof v.select != 'function')
|
|
3766
|
+
return v;
|
|
3767
|
+
return { ...v, select: (x, ...r) => { if (typeof x == 'string')
|
|
3768
|
+
removed(f, f.name, 'child-select-name', `CHILD.select('${x}')`); return v.select(x, ...r); } };
|
|
3769
|
+
},
|
|
3770
|
+
});
|
|
3771
|
+
},
|
|
3772
|
+
onIntent(iv, names) {
|
|
3773
|
+
wire(iv, names);
|
|
3774
|
+
},
|
|
3775
|
+
onRender(iv, vnode) {
|
|
3776
|
+
const f = fac(iv);
|
|
3777
|
+
H.onRender(f, vnode);
|
|
3778
|
+
if (on())
|
|
3779
|
+
removedTags(f, vnode);
|
|
3780
|
+
},
|
|
3781
|
+
onReducer(iv, type, prev, next) {
|
|
3782
|
+
H.onReducer(fac(iv), type, prev, next, 'STATE');
|
|
3783
|
+
},
|
|
3784
|
+
onDispose(iv) {
|
|
3785
|
+
vtp.delete(iv);
|
|
3786
|
+
H.onDispose(fac(iv));
|
|
3787
|
+
},
|
|
3788
|
+
onAction(iv, a) { log.onAction(iv, a); },
|
|
3789
|
+
wrapHandler(iv, type, sink, fn) {
|
|
3790
|
+
let h = log.wrapHandler(iv, type, sink, fn) || fn;
|
|
3791
|
+
// SYG222: the STATE reducers, wrapped (today the check rewrote the instance's model)
|
|
3792
|
+
if (on() && sink == 'STATE' && typeof fn == 'function')
|
|
3793
|
+
h = watchMutation(fac(iv), type, h);
|
|
3794
|
+
return h === fn ? undefined : h;
|
|
3795
|
+
},
|
|
3796
|
+
onSink(iv, type, sink, v) {
|
|
3797
|
+
if (!on())
|
|
3798
|
+
return;
|
|
3799
|
+
const f = fac(iv), src = iv.sources[sink];
|
|
3800
|
+
try {
|
|
3801
|
+
if (sink == 'EVENTS')
|
|
3802
|
+
eventEmitted(f, v);
|
|
3803
|
+
if (src && src.__sygnalReplies === true)
|
|
3804
|
+
checkRequest(f, sink, v, Object.keys(f.modelMap));
|
|
3805
|
+
if (src && src.__sygnalStatic === 'resources')
|
|
3806
|
+
check(f, sink, v);
|
|
3807
|
+
}
|
|
3808
|
+
catch (_) { /* a check never breaks the app */ }
|
|
3809
|
+
},
|
|
3810
|
+
onElementCommand(iv, cmd) {
|
|
3811
|
+
if (!on())
|
|
3812
|
+
return;
|
|
3813
|
+
const f = fac(iv);
|
|
3814
|
+
for (const c of [].concat(cmd))
|
|
3815
|
+
checkSentCommand(f, c);
|
|
3816
|
+
},
|
|
3817
|
+
onHostProps(owner, sel, props, data) {
|
|
3818
|
+
if (!on() || (sel != 'collection' && sel != 'virtual-collection'))
|
|
3819
|
+
return;
|
|
3820
|
+
const f = fac(owner);
|
|
3821
|
+
if (typeof props.of == 'string')
|
|
3822
|
+
removed(f, f.name, 'collection-of-name', `of="${props.of}"`);
|
|
3823
|
+
// 4-H (D229): what went only to the removed wrapper div (VirtualCollection keeps its container)
|
|
3824
|
+
if (sel == 'collection') {
|
|
3825
|
+
const w = WRAPPER.filter(k => k == 'className' ? props.className != null : data?.[k] != null);
|
|
3826
|
+
if (w.length)
|
|
3827
|
+
removed(f, f.name, 'collection-wrapper', w.map(k => k == 'dataset' ? 'data-*' : k == 'hook' ? 'ref / hook' : k).join(', '));
|
|
3828
|
+
}
|
|
3829
|
+
try {
|
|
3830
|
+
checkCollection(f, { data: { props } });
|
|
3831
|
+
}
|
|
3832
|
+
catch (_) { /* ignore */ }
|
|
3833
|
+
// G-419: SYG148 for a prefix that isn't an identifier; a valid one is checked for
|
|
3834
|
+
// duplicate names after each patch (SYG149). G-460: a falsy one names nothing (as the host)
|
|
3835
|
+
const p = props.viewTransitionName;
|
|
3836
|
+
if (p && vtPrefix(f.name, p)) {
|
|
3837
|
+
let s = vtp.get(owner);
|
|
3838
|
+
if (!s)
|
|
3839
|
+
vtp.set(owner, s = new Set());
|
|
3840
|
+
s.add(p);
|
|
3841
|
+
}
|
|
3842
|
+
},
|
|
3843
|
+
onPatch(vnode) {
|
|
3844
|
+
if (!vtp.size || !on())
|
|
3845
|
+
return;
|
|
3846
|
+
const all = new Set();
|
|
3847
|
+
for (const s of vtp.values())
|
|
3848
|
+
for (const p of s)
|
|
3849
|
+
all.add(p);
|
|
3850
|
+
try {
|
|
3851
|
+
vtCheck(vnode, vtDuplicates(vnode, all));
|
|
3852
|
+
}
|
|
3853
|
+
catch (_) { /* a check never breaks the app */ }
|
|
3854
|
+
},
|
|
3855
|
+
onDuplicateKey(owner, key) {
|
|
3856
|
+
if (!on())
|
|
3857
|
+
return;
|
|
3858
|
+
const name = owner.name;
|
|
3859
|
+
if (!once(`SYG424:${name}:${String(key)}`))
|
|
3860
|
+
return;
|
|
3861
|
+
devReport('SYG424', {
|
|
3862
|
+
component: name,
|
|
3863
|
+
message: `A Collection in ${name} has more than one item with the id ${JSON.stringify(key) ?? String(key)} (duplicate ids); only the first of them renders, and their writes go to it`,
|
|
3864
|
+
fix: 'Give every item a unique id (or no id: an item without one is keyed by its index)',
|
|
3865
|
+
data: { id: key },
|
|
3866
|
+
});
|
|
3867
|
+
},
|
|
3868
|
+
onStateSeed(iv, slice, init) {
|
|
3869
|
+
if (!on() || !isPlainObject(init) || !isPlainObject(slice))
|
|
3870
|
+
return;
|
|
3871
|
+
// G-333: a key initialState sets to undefined is no data the slice lacks
|
|
3872
|
+
const missing = Object.keys(init).filter(k => init[k] !== undefined && !(k in slice));
|
|
3873
|
+
const name = iv.name;
|
|
3874
|
+
if (!missing.length || !once(`SYG425:${name}:${missing.join(',')}`))
|
|
3875
|
+
return;
|
|
3876
|
+
const list = missing.map(k => `'${k}'`).join(', ');
|
|
3877
|
+
devReport('SYG425', {
|
|
3878
|
+
component: name,
|
|
3879
|
+
message: `${name} has isolatedState and is bound to a slice of its parent's state that already exists, so it keeps that slice instead of its initialState; the slice has no ${list}, which ${name}.initialState defines`,
|
|
3880
|
+
fix: `To start ${name} from its initialState instead, add resetState to the tag (<${name} state="..." resetState />). To keep the parent's data, initialize ${list} in the parent's state`,
|
|
3881
|
+
data: { missing, slice: Object.keys(slice) },
|
|
3882
|
+
});
|
|
3883
|
+
},
|
|
3884
|
+
onContextSkip(iv, render, prev, next, keys) {
|
|
3885
|
+
if (!on() || !prev || !next)
|
|
3886
|
+
return;
|
|
3887
|
+
const n = (skips.get(iv) || 0) + 1;
|
|
3888
|
+
skips.set(iv, n);
|
|
3889
|
+
if ((n > 2 && n % SAMPLE) || budget >= CAP)
|
|
3890
|
+
return;
|
|
3891
|
+
if (!budget++)
|
|
3892
|
+
queueMicrotask(() => { budget = 0; });
|
|
3893
|
+
let a, b, c;
|
|
3894
|
+
try {
|
|
3895
|
+
a = render(prev);
|
|
3896
|
+
b = render(next);
|
|
3897
|
+
c = render(prev);
|
|
3898
|
+
}
|
|
3899
|
+
catch (_) {
|
|
3900
|
+
return;
|
|
3901
|
+
}
|
|
3902
|
+
// G-327: a control render: a view whose output differs between two calls with the same
|
|
3903
|
+
// context (a render counter, Date.now(), Math.random(), generated ids) proves nothing
|
|
3904
|
+
if (same(a, b) || !same(a, c))
|
|
3905
|
+
return;
|
|
3906
|
+
const name = iv.name;
|
|
3907
|
+
const changed = Object.keys(next).filter(k => next[k] !== prev[k]);
|
|
3908
|
+
if (!once(`SYG423:${name}`))
|
|
3909
|
+
return;
|
|
3910
|
+
reportSafely('SYG423', {
|
|
3911
|
+
severity: DEV_CODE_SEVERITY.SYG423,
|
|
3912
|
+
component: name,
|
|
3913
|
+
message: `The context keys ${changed.map(k => `'${k}'`).join(', ')} changed and ${name}'s view was not re-rendered, because the view read none of them during its last call (it read: ${keys.length ? keys.map(k => `'${k}'`).join(', ') : 'nothing'}). Calling it with the new context renders something else, so the page shows a stale render`,
|
|
3914
|
+
fix: `Read context entries in the view itself (context.theme), not through a value kept from an earlier render or a function that runs later; Object.getOwnPropertyDescriptor and similar reflection aren't tracked`,
|
|
3915
|
+
data: { keysRead: keys, changed },
|
|
3916
|
+
});
|
|
3917
|
+
},
|
|
3918
|
+
};
|
|
3919
|
+
}
|
|
3920
|
+
|
|
3921
|
+
/** Every runtime check in this entry. */
|
|
3922
|
+
const checks = [
|
|
3923
|
+
wiringCheck,
|
|
3924
|
+
stateCheck,
|
|
3925
|
+
eventsCheck,
|
|
3926
|
+
propsCheck,
|
|
3927
|
+
collectionsCheck,
|
|
3928
|
+
domCheck,
|
|
3929
|
+
shorthandCheck,
|
|
3930
|
+
controlsCheck,
|
|
3931
|
+
elementCommandsCheck,
|
|
3932
|
+
behaviorsCheck,
|
|
3933
|
+
formsCheck,
|
|
3934
|
+
datasetCheck,
|
|
3935
|
+
strictCheck,
|
|
3936
|
+
repliesCheck,
|
|
3937
|
+
routerCheck,
|
|
3938
|
+
fetchCheck,
|
|
3939
|
+
timersCheck,
|
|
3940
|
+
persistCheck,
|
|
3941
|
+
viewTransitionsCheck,
|
|
3942
|
+
staticsCheck,
|
|
3943
|
+
inspectCheck,
|
|
3944
|
+
];
|
|
3945
|
+
/**
|
|
3946
|
+
* (Re-)register all checks with the core and install the RxJS hints. Called
|
|
3947
|
+
* on import. Idempotent across duplicate copies of this entry: the previous
|
|
3948
|
+
* installation (stored on the core bridge) is removed first.
|
|
3949
|
+
*/
|
|
3950
|
+
function installChecks() {
|
|
3951
|
+
const core = bridge();
|
|
3952
|
+
if (!core)
|
|
3953
|
+
throw new Error(`[Sygnal] 'sygnal/diagnostics' could not find the Sygnal diagnostics core`);
|
|
3954
|
+
if (typeof core.__uninstallChecks === 'function')
|
|
3955
|
+
core.__uninstallChecks();
|
|
3956
|
+
const unregister = checks.map(check => core.registerCheck(check));
|
|
3957
|
+
const uninstallHints = installRxjsHints(Stream && Stream.prototype);
|
|
3958
|
+
const uninstallInspect = installInspect();
|
|
3959
|
+
const uninstallRouter = installRouterHooks();
|
|
3960
|
+
const uninstallControls = installControlHooks();
|
|
3961
|
+
const uninstallTimers = installTimerHooks();
|
|
3962
|
+
const uninstallBrowser = installBrowserSourceHooks();
|
|
3963
|
+
const uninstallElementCommands = installElementCommandHooks();
|
|
3964
|
+
const uninstallWidgets = installWidgetHooks();
|
|
3965
|
+
const uninstallVirtual = installVirtualHooks();
|
|
3966
|
+
const uninstallForms = installFormHooks();
|
|
3967
|
+
const uninstallSortable = installSortableHooks();
|
|
3968
|
+
(core.layers || (core.layers = new Set())).add(nextHooks);
|
|
3969
|
+
const uninstall = () => {
|
|
3970
|
+
core.layers?.delete(nextHooks);
|
|
3971
|
+
unregister.forEach(fn => fn());
|
|
3972
|
+
uninstallHints();
|
|
3973
|
+
uninstallInspect();
|
|
3974
|
+
uninstallRouter();
|
|
3975
|
+
uninstallControls();
|
|
3976
|
+
uninstallTimers();
|
|
3977
|
+
uninstallBrowser();
|
|
3978
|
+
uninstallElementCommands();
|
|
3979
|
+
uninstallWidgets();
|
|
3980
|
+
uninstallVirtual();
|
|
3981
|
+
uninstallForms();
|
|
3982
|
+
uninstallSortable();
|
|
3983
|
+
if (core.__uninstallChecks === uninstall)
|
|
3984
|
+
core.__uninstallChecks = undefined;
|
|
3985
|
+
};
|
|
3986
|
+
core.__uninstallChecks = uninstall;
|
|
3987
|
+
return uninstall;
|
|
3988
|
+
}
|
|
3989
|
+
/** Tune the timing of the DOM checks (mostly for tests). */
|
|
3990
|
+
function configureChecks(options = {}) {
|
|
3991
|
+
for (const key of Object.keys(timing)) {
|
|
3992
|
+
if (typeof options[key] === 'number')
|
|
3993
|
+
timing[key] = options[key];
|
|
3994
|
+
}
|
|
3995
|
+
}
|
|
3996
|
+
/** Forget what the checks have seen and reported (dedupe state, registries). For tests. */
|
|
3997
|
+
function resetChecks() {
|
|
3998
|
+
resetCheckState();
|
|
3999
|
+
}
|
|
4000
|
+
installChecks();
|
|
4001
|
+
|
|
4002
|
+
export { RXJS_HINTS, checkEventBus, checks, configureChecks, configureStrict, getCodeInfo, inspect, installChecks, isStrictEnabled, listCodes, resetChecks };
|
|
4003
|
+
//# sourceMappingURL=diagnostics.esm.js.map
|