sygnal 5.4.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +646 -0
- package/README.md +24 -9
- package/dist/astro/client.cjs.js +16 -9
- package/dist/astro/client.cjs.js.map +1 -1
- package/dist/astro/client.mjs +16 -9
- package/dist/astro/client.mjs.map +1 -1
- package/dist/astro/index.cjs.js +362 -91
- package/dist/astro/index.cjs.js.map +1 -1
- package/dist/astro/index.mjs +362 -92
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/server.cjs.js +3396 -165
- package/dist/astro/server.cjs.js.map +1 -1
- package/dist/astro/server.mjs +3396 -165
- package/dist/astro/server.mjs.map +1 -1
- package/dist/devtools.cjs.js +1431 -0
- package/dist/devtools.cjs.js.map +1 -0
- package/dist/devtools.esm.js +1419 -0
- package/dist/devtools.esm.js.map +1 -0
- package/dist/diagnostics.cjs.js +3032 -296
- package/dist/diagnostics.cjs.js.map +1 -1
- package/dist/diagnostics.esm.js +3032 -296
- package/dist/diagnostics.esm.js.map +1 -1
- package/dist/element.cjs.js +230 -0
- package/dist/element.cjs.js.map +1 -0
- package/dist/element.esm.js +228 -0
- package/dist/element.esm.js.map +1 -0
- package/dist/guide/accessibility.md +257 -0
- package/dist/guide/adapters.md +221 -0
- package/dist/guide/behaviors.md +312 -0
- package/dist/guide/browser-sources.md +203 -0
- package/dist/guide/drag-and-drop.md +342 -0
- package/dist/guide/element-commands.md +306 -0
- package/dist/guide/error-boundaries.md +169 -0
- package/dist/guide/forms-reference.md +381 -0
- package/dist/guide/forms.md +250 -0
- package/dist/guide/http.md +299 -0
- package/dist/guide/inputs.md +173 -0
- package/dist/guide/persistence.md +245 -0
- package/dist/guide/recipes/carousel.md +155 -0
- package/dist/guide/recipes/charts.md +177 -0
- package/dist/guide/recipes/code-editor.md +135 -0
- package/dist/guide/recipes/data-grid.md +155 -0
- package/dist/guide/recipes/data-table.md +196 -0
- package/dist/guide/recipes/i18n.md +222 -0
- package/dist/guide/recipes/icons.md +115 -0
- package/dist/guide/recipes/overview.md +33 -0
- package/dist/guide/recipes/rich-text.md +154 -0
- package/dist/guide/resources.md +471 -0
- package/dist/guide/ssr.md +255 -0
- package/dist/guide/timers.md +178 -0
- package/dist/guide/ui/accordion.md +93 -0
- package/dist/guide/ui/combobox.md +144 -0
- package/dist/guide/ui/dialog.md +124 -0
- package/dist/guide/ui/disclosure.md +60 -0
- package/dist/guide/ui/menu.md +125 -0
- package/dist/guide/ui/overview.md +166 -0
- package/dist/guide/ui/popover.md +101 -0
- package/dist/guide/ui/select.md +114 -0
- package/dist/guide/ui/tabs.md +161 -0
- package/dist/guide/ui/toaster.md +152 -0
- package/dist/guide/ui/tooltip.md +103 -0
- package/dist/guide/undo.md +178 -0
- package/dist/guide/virtual-collections.md +183 -0
- package/dist/guide/web-components.md +179 -0
- package/dist/guide/widgets.md +217 -0
- package/dist/index.cjs.js +13178 -5769
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +2908 -276
- package/dist/index.esm.js +13149 -5779
- package/dist/index.esm.js.map +1 -1
- package/dist/jsx-dev-runtime.cjs.js +26 -334
- package/dist/jsx-dev-runtime.cjs.js.map +1 -1
- package/dist/jsx-dev-runtime.esm.js +19 -327
- package/dist/jsx-dev-runtime.esm.js.map +1 -1
- package/dist/jsx-runtime.cjs.js +26 -334
- package/dist/jsx-runtime.cjs.js.map +1 -1
- package/dist/jsx-runtime.esm.js +19 -327
- package/dist/jsx-runtime.esm.js.map +1 -1
- package/dist/jsx.cjs.js +10 -314
- package/dist/jsx.cjs.js.map +1 -1
- package/dist/jsx.esm.js +8 -315
- package/dist/jsx.esm.js.map +1 -1
- package/dist/react.cjs.js +117 -0
- package/dist/react.cjs.js.map +1 -0
- package/dist/react.esm.js +115 -0
- package/dist/react.esm.js.map +1 -0
- package/dist/shims/globalthis.cjs +20 -0
- package/dist/sygnal.min.js +1 -1
- package/dist/sygnal.min.js.map +1 -1
- package/dist/ui-combobox.cjs.js +169 -0
- package/dist/ui-combobox.cjs.js.map +1 -0
- package/dist/ui-combobox.esm.js +148 -0
- package/dist/ui-combobox.esm.js.map +1 -0
- package/dist/ui-menu.cjs.js +87 -0
- package/dist/ui-menu.cjs.js.map +1 -0
- package/dist/ui-menu.esm.js +66 -0
- package/dist/ui-menu.esm.js.map +1 -0
- package/dist/ui-select.cjs.js +104 -0
- package/dist/ui-select.cjs.js.map +1 -0
- package/dist/ui-select.esm.js +83 -0
- package/dist/ui-select.esm.js.map +1 -0
- package/dist/ui.cjs.js +738 -0
- package/dist/ui.cjs.js.map +1 -0
- package/dist/ui.esm.js +727 -0
- package/dist/ui.esm.js.map +1 -0
- package/dist/vike/ClientOnly.cjs.js +4 -14
- package/dist/vike/ClientOnly.cjs.js.map +1 -1
- package/dist/vike/ClientOnly.mjs +4 -14
- package/dist/vike/ClientOnly.mjs.map +1 -1
- package/dist/vike/{+config.js → config/+config.js} +9 -1
- package/dist/vike/config/+config.js.map +1 -0
- package/dist/vike/config/package.json +6 -0
- package/dist/vike/onRenderClient.cjs.js +165 -95
- package/dist/vike/onRenderClient.cjs.js.map +1 -1
- package/dist/vike/onRenderClient.mjs +165 -95
- package/dist/vike/onRenderClient.mjs.map +1 -1
- package/dist/vike/onRenderHtml.cjs.js +59 -24
- package/dist/vike/onRenderHtml.cjs.js.map +1 -1
- package/dist/vike/onRenderHtml.mjs +60 -25
- package/dist/vike/onRenderHtml.mjs.map +1 -1
- package/dist/vite/plugin.cjs.js +323 -88
- package/dist/vite/plugin.cjs.js.map +1 -1
- package/dist/vite/plugin.mjs +323 -89
- package/dist/vite/plugin.mjs.map +1 -1
- package/dist/zag.cjs.js +408 -0
- package/dist/zag.cjs.js.map +1 -0
- package/dist/zag.esm.js +405 -0
- package/dist/zag.esm.js.map +1 -0
- package/llms.txt +166 -103
- package/package.json +98 -14
- package/src/astro/client.ts +27 -16
- package/src/astro/index.d.ts +24 -0
- package/src/astro/index.ts +69 -3
- package/src/astro/server.ts +8 -2
- package/src/collection.ts +6 -93
- package/src/core/actions.ts +134 -0
- package/src/core/cell.ts +202 -0
- package/src/core/debug.ts +23 -0
- package/src/core/define.ts +242 -0
- package/src/core/hooks.ts +314 -0
- package/src/core/hosts/collection.ts +325 -0
- package/src/core/hosts/switchable.ts +146 -0
- package/src/core/instance.ts +592 -0
- package/src/core/markers/clientonly.ts +13 -0
- package/src/core/markers/lazy.ts +40 -0
- package/src/core/markers/portal.ts +96 -0
- package/src/core/markers/suspense.ts +41 -0
- package/src/core/markers/transition.ts +70 -0
- package/src/core/registry.ts +25 -0
- package/src/core/runtime.ts +573 -0
- package/src/core/statics.ts +104 -0
- package/src/core/teardown.ts +60 -0
- package/src/core/view.ts +37 -0
- package/src/cycle/dom/DocumentDOMSource.ts +13 -8
- package/src/cycle/dom/ElementFinder.ts +7 -9
- package/src/cycle/dom/EventDelegator.ts +174 -223
- package/src/cycle/dom/IsolateModule.ts +65 -35
- package/src/cycle/dom/MainDOMSource.ts +9 -2
- package/src/cycle/dom/PriorityQueue.ts +4 -2
- package/src/cycle/dom/SymbolTree.ts +18 -47
- package/src/cycle/dom/classNameModule.ts +9 -7
- package/src/cycle/dom/controlledInputModule.ts +23 -6
- package/src/cycle/dom/enrichEventStream.ts +25 -47
- package/src/cycle/dom/fragment.ts +7 -0
- package/src/cycle/dom/isolate.ts +30 -27
- package/src/cycle/dom/makeDOMDriver.ts +115 -56
- package/src/cycle/dom/mockDOMSource.ts +21 -1
- package/src/cycle/dom/modules.ts +16 -3
- package/src/cycle/dom/propsModule.ts +65 -0
- package/src/cycle/dom/selectModule.ts +22 -17
- package/src/cycle/dom/snabbdom.ts +1 -6
- package/src/cycle/dom/thunk.ts +3 -0
- package/src/cycle/dom/utils.ts +98 -0
- package/src/cycle/dom/viewTransition.ts +43 -0
- package/src/cycle/state/StateSource.ts +19 -3
- package/src/cycle/state/objIsEqual.ts +50 -0
- package/src/cycle/state/types.ts +0 -10
- package/src/defineComponent.ts +34 -0
- package/src/devtools.d.ts +151 -0
- package/src/devtools.ts +34 -0
- package/src/element.d.ts +59 -0
- package/src/element.ts +235 -0
- package/src/extra/backoff.ts +9 -0
- package/src/extra/behaviors.ts +169 -0
- package/src/extra/browserSignals.ts +23 -0
- package/src/extra/browserSources.ts +326 -0
- package/src/extra/command.ts +3 -3
- package/src/extra/controls.ts +47 -0
- package/src/extra/copyAsTest.ts +286 -0
- package/src/extra/devtools.ts +101 -31
- package/src/extra/devtoolsActions.ts +379 -0
- package/src/extra/devtoolsHook.ts +9 -0
- package/src/extra/devtoolsNext.ts +83 -0
- package/src/extra/diagnostics/checks/actionLog.ts +108 -0
- package/src/extra/diagnostics/checks/behaviors.ts +49 -0
- package/src/extra/diagnostics/checks/browserSources.ts +96 -0
- package/src/extra/diagnostics/checks/collections.ts +1 -1
- package/src/extra/diagnostics/checks/controls.ts +148 -0
- package/src/extra/diagnostics/checks/dataset.ts +57 -0
- package/src/extra/diagnostics/checks/dom.ts +20 -11
- package/src/extra/diagnostics/checks/elementCommands.ts +175 -0
- package/src/extra/diagnostics/checks/events.ts +17 -2
- package/src/extra/diagnostics/checks/fetch.ts +135 -0
- package/src/extra/diagnostics/checks/forms.ts +163 -0
- package/src/extra/diagnostics/checks/index.ts +97 -4
- package/src/extra/diagnostics/checks/inspect.ts +140 -32
- package/src/extra/diagnostics/checks/next.ts +417 -0
- package/src/extra/diagnostics/checks/persist.ts +54 -0
- package/src/extra/diagnostics/checks/props.ts +10 -5
- package/src/extra/diagnostics/checks/public.d.ts +98 -7
- package/src/extra/diagnostics/checks/replies.ts +128 -0
- package/src/extra/diagnostics/checks/router.ts +100 -0
- package/src/extra/diagnostics/checks/rxjsHints.ts +1 -1
- package/src/extra/diagnostics/checks/shared.ts +86 -2
- package/src/extra/diagnostics/checks/shorthand.ts +115 -0
- package/src/extra/diagnostics/checks/sortable.ts +129 -0
- package/src/extra/diagnostics/checks/state.ts +100 -7
- package/src/extra/diagnostics/checks/statics.ts +48 -0
- package/src/extra/diagnostics/checks/strict.ts +10 -67
- package/src/extra/diagnostics/checks/timers.ts +80 -0
- package/src/extra/diagnostics/checks/viewTransitions.ts +47 -0
- package/src/extra/diagnostics/checks/virtual.ts +73 -0
- package/src/extra/diagnostics/checks/widgets.ts +109 -0
- package/src/extra/diagnostics/checks/wiring.ts +66 -10
- package/src/extra/diagnostics/codes.ts +268 -23
- package/src/extra/diagnostics/index.ts +34 -9
- package/src/extra/diagnostics/legacy.ts +17 -0
- package/src/extra/driverFactories.ts +83 -19
- package/src/extra/elementCommands.ts +53 -0
- package/src/extra/fetchDriver.ts +519 -0
- package/src/extra/focusWithin.ts +27 -0
- package/src/extra/form.ts +268 -0
- package/src/extra/formHelpers.ts +139 -0
- package/src/extra/head.ts +106 -0
- package/src/extra/hmr.ts +2 -3
- package/src/extra/owned.ts +24 -0
- package/src/extra/pager.ts +50 -0
- package/src/extra/persist.ts +133 -0
- package/src/extra/pwa.ts +1 -1
- package/src/extra/queryCache.ts +107 -0
- package/src/extra/reducers.ts +1 -1
- package/src/extra/reduxDevtools.ts +92 -0
- package/src/extra/replies.ts +54 -0
- package/src/extra/router.ts +323 -0
- package/src/extra/run.ts +90 -217
- package/src/extra/selection.ts +76 -0
- package/src/extra/socketDriver.ts +265 -0
- package/src/extra/sortable.ts +377 -0
- package/src/extra/ssr.ts +427 -160
- package/src/extra/standardSchema.ts +27 -0
- package/src/extra/testing.ts +2433 -175
- package/src/extra/timers.ts +95 -0
- package/src/extra/undo.ts +261 -0
- package/src/extra/viewTransitions.ts +38 -0
- package/src/extra/virtual.ts +513 -0
- package/src/extra/widget.ts +201 -0
- package/src/index.d.ts +2631 -117
- package/src/index.ts +25 -4
- package/src/jsx-runtime.ts +15 -1
- package/src/jsx.ts +2 -1
- package/src/lazy.ts +50 -7
- package/src/portal.ts +3 -1
- package/src/pragma/index.ts +256 -133
- package/src/react-peers.d.ts +5 -0
- package/src/react.d.ts +43 -0
- package/src/react.ts +110 -0
- package/src/shared.ts +48 -0
- package/src/slot.ts +1 -1
- package/src/suspense.ts +3 -1
- package/src/switchable.ts +6 -121
- package/src/transition.ts +3 -1
- package/src/ui/accordion.ts +64 -0
- package/src/ui/dialog.ts +129 -0
- package/src/ui/disclosure.ts +35 -0
- package/src/ui/popover.ts +45 -0
- package/src/ui/shared.ts +94 -0
- package/src/ui/tabs.ts +82 -0
- package/src/ui/toaster.ts +198 -0
- package/src/ui/tooltip.ts +70 -0
- package/src/ui/zag/combobox.ts +115 -0
- package/src/ui/zag/menu.ts +40 -0
- package/src/ui/zag/select.ts +54 -0
- package/src/ui/zag/shared.ts +43 -0
- package/src/ui-combobox.d.ts +20 -0
- package/src/ui-combobox.ts +6 -0
- package/src/ui-menu.d.ts +30 -0
- package/src/ui-menu.ts +6 -0
- package/src/ui-select.d.ts +17 -0
- package/src/ui-select.ts +6 -0
- package/src/ui-zag-types.d.ts +42 -0
- package/src/ui.d.ts +208 -0
- package/src/ui.ts +15 -0
- package/src/vike/+config.ts +9 -1
- package/src/vike/ClientOnly.ts +1 -1
- package/src/vike/onRenderClient.ts +144 -96
- package/src/vike/onRenderHtml.ts +69 -26
- package/src/vike/types.ts +7 -1
- package/src/vite/globalthis-shim.ts +18 -0
- package/src/vite/globalthis.ts +56 -0
- package/src/vite/plugin.d.ts +22 -3
- package/src/vite/plugin.ts +222 -24
- package/src/zag.d.ts +64 -0
- package/src/zag.ts +238 -0
- package/dist/vike/+config.cjs.js +0 -66
- package/dist/vike/+config.cjs.js.map +0 -1
- package/dist/vike/+config.js.map +0 -1
- package/src/component.ts +0 -2301
- package/src/cycle/isolate/index.ts +0 -196
- package/src/cycle/run/index.ts +0 -151
- package/src/cycle/run/internals.ts +0 -143
- package/src/cycle/state/Collection.ts +0 -184
- package/src/cycle/state/index.ts +0 -5
- package/src/cycle/state/pickCombine.ts +0 -201
- package/src/cycle/state/pickMerge.ts +0 -122
- package/src/cycle/state/withState.ts +0 -47
- package/src/pragma/fn.ts +0 -55
package/dist/diagnostics.esm.js
CHANGED
|
@@ -40,6 +40,407 @@ const Stream = Stream$1 ||
|
|
|
40
40
|
(xs$1 && xs$1.Stream) ||
|
|
41
41
|
(xs && xs.Stream);
|
|
42
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
|
+
|
|
43
444
|
/**
|
|
44
445
|
* Shared plumbing for the runtime checks (PLAN-1 workstream 1A).
|
|
45
446
|
*
|
|
@@ -68,6 +469,12 @@ const reportSafely = (code, details) => {
|
|
|
68
469
|
return undefined;
|
|
69
470
|
}
|
|
70
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 });
|
|
71
478
|
/** Tunables (test seam: configureChecks()). */
|
|
72
479
|
const timing = {
|
|
73
480
|
/** delay after a render before DOM checks run (lets nested renders patch) */
|
|
@@ -77,13 +484,98 @@ const timing = {
|
|
|
77
484
|
/** ... and only once the component has rendered at least this often */
|
|
78
485
|
minRenders: 3,
|
|
79
486
|
};
|
|
80
|
-
/**
|
|
81
|
-
|
|
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();
|
|
82
570
|
/** Synthetic/internal actions (`__TEST_ACTION__`, `__NOOP_ACTION__`, ...) and built-ins. */
|
|
83
571
|
const isInternalAction = (action) => typeof action !== 'string' || action.startsWith('__') || BUILTIN_ACTIONS.has(action);
|
|
84
572
|
let seen = new Set();
|
|
85
573
|
/** true the first time `key` is seen (dedupes reports, e.g. per component name). */
|
|
86
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);
|
|
87
579
|
/**
|
|
88
580
|
* Forget only the once() dedupe set (G-051: renderComponent calls it through the core bridge
|
|
89
581
|
* when an outermost instance starts, so findings are reported again in every test). Unlike
|
|
@@ -106,7 +598,7 @@ const nameOf = (component) => (component && component.name) || 'component';
|
|
|
106
598
|
const isPlainObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value) &&
|
|
107
599
|
(Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
108
600
|
/** Levenshtein distance (small strings only). */
|
|
109
|
-
function distance(a, b) {
|
|
601
|
+
function distance$1(a, b) {
|
|
110
602
|
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
111
603
|
for (let i = 1; i <= a.length; i++) {
|
|
112
604
|
let prev = row[0];
|
|
@@ -126,7 +618,7 @@ function suggest(name, candidates) {
|
|
|
126
618
|
for (const c of candidates) {
|
|
127
619
|
if (c === name)
|
|
128
620
|
continue;
|
|
129
|
-
const score = c.toLowerCase() === name.toLowerCase() ? 0 : distance(c, name);
|
|
621
|
+
const score = c.toLowerCase() === name.toLowerCase() ? 0 : distance$1(c, name);
|
|
130
622
|
if (score < bestScore) {
|
|
131
623
|
best = c;
|
|
132
624
|
bestScore = score;
|
|
@@ -140,13 +632,64 @@ const didYouMean = (name, candidates) => {
|
|
|
140
632
|
};
|
|
141
633
|
|
|
142
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`;
|
|
143
648
|
const wiringCheck = {
|
|
144
649
|
id: 'wiring',
|
|
145
650
|
onIntent(component, actionNames) {
|
|
146
651
|
intentActions.set(component, actionNames || []);
|
|
147
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
|
+
},
|
|
148
673
|
onModel(component, modelMap) {
|
|
149
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
|
+
}
|
|
150
693
|
const intent$ = component && component.intent$;
|
|
151
694
|
const injected = new Set((intent$ && typeof intent$ === 'object' && intent$.__sygnalTestActions) || []);
|
|
152
695
|
const actions = (intentActions.get(component) || []).filter(a => !injected.has(a));
|
|
@@ -164,44 +707,113 @@ const wiringCheck = {
|
|
|
164
707
|
data: { action, modelActions },
|
|
165
708
|
});
|
|
166
709
|
}
|
|
167
|
-
|
|
168
|
-
//
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const hmr = [].concat(component?.hmrActions || []);
|
|
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 || {};
|
|
172
714
|
for (const action of modelActions) {
|
|
173
|
-
if (isInternalAction(action) || actions.includes(action) ||
|
|
715
|
+
if (isInternalAction(action) || actions.includes(action) || injected.has(action) || replies.has(action) || action in owned)
|
|
174
716
|
continue;
|
|
175
717
|
if (!once(`SYG102:${name}:${action}`))
|
|
176
718
|
continue;
|
|
177
719
|
report('SYG102', {
|
|
178
720
|
component,
|
|
179
721
|
message: `Model entry '${action}' has no intent action with that name${didYouMean(action, actions)}`,
|
|
180
|
-
fix: `Add '${action}' to ${name}.intent, or remove the model entry. If it is only dispatched with next('${action}'), ignore this`,
|
|
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`,
|
|
181
723
|
data: { action, intentActions: actions },
|
|
182
724
|
});
|
|
183
725
|
}
|
|
184
726
|
},
|
|
185
727
|
};
|
|
186
728
|
|
|
187
|
-
|
|
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
|
+
}
|
|
188
783
|
const stateCheck = {
|
|
189
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
|
+
},
|
|
190
801
|
onReducer(component, action, prevState, nextState) {
|
|
191
802
|
if (typeof action !== 'string' || action.startsWith('__') || SKIP.has(action))
|
|
192
803
|
return;
|
|
193
804
|
const name = nameOf(component);
|
|
194
805
|
if (nextState === undefined) {
|
|
195
|
-
|
|
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}`))
|
|
196
810
|
return;
|
|
197
|
-
// Returning undefined from a Collection item's reducer is the documented way to remove
|
|
198
|
-
// the item, so only the root (where it wipes the whole app state) is a warning.
|
|
199
811
|
const sub = !!component?.isSubComponent;
|
|
200
812
|
report('SYG202', {
|
|
201
813
|
component,
|
|
202
814
|
severity: sub ? 'info' : 'warn',
|
|
203
815
|
message: `The STATE reducer for '${action}' returned undefined` +
|
|
204
|
-
(sub ? ' (
|
|
816
|
+
(sub ? ' (only a Collection item removes itself this way)' : ''),
|
|
205
817
|
fix: `Return the new state, e.g. (state, data) => ({ ...state, ... }), or return ABORT to leave the state unchanged`,
|
|
206
818
|
data: { action },
|
|
207
819
|
});
|
|
@@ -209,6 +821,27 @@ const stateCheck = {
|
|
|
209
821
|
}
|
|
210
822
|
if (!isPlainObject(prevState) || !isPlainObject(nextState))
|
|
211
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
|
+
}
|
|
212
845
|
const calculated = component?._calculatedFieldNames || null;
|
|
213
846
|
const dropped = Object.keys(prevState).filter(key => !(key in nextState) && prevState[key] !== undefined && !(calculated && calculated.has(key)));
|
|
214
847
|
if (dropped.length === 0 || !once(`SYG201:${name}:${action}`))
|
|
@@ -223,10 +856,10 @@ const stateCheck = {
|
|
|
223
856
|
},
|
|
224
857
|
};
|
|
225
858
|
|
|
226
|
-
let selected = new Set();
|
|
859
|
+
let selected$1 = new Set();
|
|
227
860
|
let emitted = new Set();
|
|
228
861
|
let selectsAll = false;
|
|
229
|
-
onReset(() => { selected = new Set(); emitted = new Set(); selectsAll = false; });
|
|
862
|
+
onReset(() => { selected$1 = new Set(); emitted = new Set(); selectsAll = false; });
|
|
230
863
|
const eventsCheck = {
|
|
231
864
|
id: 'events',
|
|
232
865
|
onBusSelect(type) {
|
|
@@ -236,17 +869,29 @@ const eventsCheck = {
|
|
|
236
869
|
}
|
|
237
870
|
for (const t of [].concat(type))
|
|
238
871
|
if (typeof t === 'string')
|
|
239
|
-
selected.add(t);
|
|
872
|
+
selected$1.add(t);
|
|
240
873
|
},
|
|
241
874
|
onBusEmit(type, emitterName) {
|
|
242
|
-
if (typeof type !== 'string')
|
|
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
|
+
}
|
|
243
887
|
return;
|
|
888
|
+
}
|
|
244
889
|
emitted.add(type);
|
|
245
|
-
if (selectsAll || selected.has(type) || !once(`SYG105:emit:${type}`))
|
|
890
|
+
if (selectsAll || selected$1.has(type) || !once(`SYG105:emit:${type}`))
|
|
246
891
|
return;
|
|
247
892
|
report('SYG105', {
|
|
248
893
|
component: emitterName,
|
|
249
|
-
message: `EVENTS event '${type}' was emitted, but nothing selects it, so it was dropped${didYouMean(type, selected)}`,
|
|
894
|
+
message: `EVENTS event '${type}' was emitted, but nothing selects it, so it was dropped${didYouMean(type, selected$1)}`,
|
|
250
895
|
fix: `Subscribe with EVENTS.select('${type}') in the component that should react, or check the event name`,
|
|
251
896
|
data: { type, direction: 'emitted-not-selected', emitter: emitterName },
|
|
252
897
|
});
|
|
@@ -258,8 +903,8 @@ const eventsCheck = {
|
|
|
258
903
|
* tests, at a point where the session is "done".
|
|
259
904
|
*/
|
|
260
905
|
function checkEventBus() {
|
|
261
|
-
const selectedNeverEmitted = [...selected].filter(t => !emitted.has(t));
|
|
262
|
-
const emittedNeverSelected = selectsAll ? [] : [...emitted].filter(t => !selected.has(t));
|
|
906
|
+
const selectedNeverEmitted = [...selected$1].filter(t => !emitted.has(t));
|
|
907
|
+
const emittedNeverSelected = selectsAll ? [] : [...emitted].filter(t => !selected$1.has(t));
|
|
263
908
|
for (const type of selectedNeverEmitted) {
|
|
264
909
|
if (!once(`SYG105:select:${type}`))
|
|
265
910
|
continue;
|
|
@@ -269,10 +914,32 @@ function checkEventBus() {
|
|
|
269
914
|
data: { type, direction: 'selected-not-emitted' },
|
|
270
915
|
});
|
|
271
916
|
}
|
|
272
|
-
return { selected: [...selected], emitted: [...emitted], selectedNeverEmitted, emittedNeverSelected };
|
|
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;
|
|
273
934
|
}
|
|
935
|
+
/** The strict check's runtime half is SYG508 (replies.ts); this entry keeps the flag. */
|
|
936
|
+
const strictCheck = {
|
|
937
|
+
id: 'strict',
|
|
938
|
+
};
|
|
274
939
|
|
|
275
|
-
|
|
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'];
|
|
276
943
|
const propsCheck = {
|
|
277
944
|
id: 'props',
|
|
278
945
|
onRender(component) {
|
|
@@ -280,12 +947,13 @@ const propsCheck = {
|
|
|
280
947
|
if (!props || typeof props !== 'object')
|
|
281
948
|
return;
|
|
282
949
|
const name = nameOf(component);
|
|
283
|
-
|
|
950
|
+
const severity = isStrictEnabled() ? 'error' : 'warn';
|
|
951
|
+
for (const key of RESERVED$1) {
|
|
284
952
|
if (!(key in props) || !once(`SYG106:${name}:${key}`))
|
|
285
953
|
continue;
|
|
286
954
|
report('SYG106', {
|
|
287
955
|
component,
|
|
288
|
-
severity
|
|
956
|
+
severity,
|
|
289
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`,
|
|
290
958
|
fix: `Rename the prop (for example '${key}Value'), in the parent and in ${name}`,
|
|
291
959
|
data: { prop: key },
|
|
@@ -297,7 +965,7 @@ const propsCheck = {
|
|
|
297
965
|
if (!isLens && value !== undefined && once(`SYG106:${name}:state`)) {
|
|
298
966
|
report('SYG106', {
|
|
299
967
|
component,
|
|
300
|
-
severity
|
|
968
|
+
severity,
|
|
301
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`,
|
|
302
970
|
fix: `To give ${name} a slice of the parent state use state="fieldName"; to pass data, use another prop name (for example 'item')`,
|
|
303
971
|
data: { prop: 'state', valueType: typeof value },
|
|
@@ -307,7 +975,7 @@ const propsCheck = {
|
|
|
307
975
|
},
|
|
308
976
|
};
|
|
309
977
|
|
|
310
|
-
const describe = (v) => v === null ? 'null' : v === undefined ? 'undefined' : Array.isArray(v) ? 'an array' : typeof v === 'object' ? 'an object' : `a ${typeof v}`;
|
|
978
|
+
const describe$1 = (v) => v === null ? 'null' : v === undefined ? 'undefined' : Array.isArray(v) ? 'an array' : typeof v === 'object' ? 'an object' : `a ${typeof v}`;
|
|
311
979
|
function checkCollection(component, el) {
|
|
312
980
|
const props = (el && el.data && el.data.props) || {};
|
|
313
981
|
const from = props.from;
|
|
@@ -328,7 +996,7 @@ function checkCollection(component, el) {
|
|
|
328
996
|
if (from !== undefined)
|
|
329
997
|
return;
|
|
330
998
|
if (!Array.isArray(state) && !(state && Array.isArray(state.value))) {
|
|
331
|
-
problem = `has no 'from' prop, so it uses ${name}'s whole state, which is ${describe(state)}, not an array`;
|
|
999
|
+
problem = `has no 'from' prop, so it uses ${name}'s whole state, which is ${describe$1(state)}, not an array`;
|
|
332
1000
|
}
|
|
333
1001
|
if (!problem || !once(`SYG401:${name}:undefined`))
|
|
334
1002
|
return;
|
|
@@ -394,8 +1062,122 @@ class ScopeChecker {
|
|
|
394
1062
|
}
|
|
395
1063
|
}
|
|
396
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
|
+
|
|
397
1179
|
const scopesOf = (ns) => ns.filter(s => s.type !== 'selector');
|
|
398
|
-
const keyOf = (scopes) => scopes.map(s => `${s.type[0]}:${s.scope}`).join('/');
|
|
1180
|
+
const keyOf$1 = (scopes) => scopes.map(s => `${s.type[0]}:${s.scope}`).join('/');
|
|
399
1181
|
let pending = [];
|
|
400
1182
|
let tracked = new WeakMap();
|
|
401
1183
|
/** isolation-scope key -> the component instance that owns it (latest wins) */
|
|
@@ -428,7 +1210,9 @@ function viewSource(component) {
|
|
|
428
1210
|
return src;
|
|
429
1211
|
}
|
|
430
1212
|
/** class / id / tag tokens of a selector: '.a .b-c > #d' -> ['a', 'b-c', 'd'] */
|
|
431
|
-
const tokensOf = (selector) => (selector.replace(/\[[^\]]*\]|:[\w-]+(\([^)]*\))?/g, ' ').match(/[A-Za-z_][\w-]*/g) || [])
|
|
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]));
|
|
432
1216
|
function appearsInView(component, selector) {
|
|
433
1217
|
const src = viewSource(component);
|
|
434
1218
|
if (!src)
|
|
@@ -438,13 +1222,14 @@ function appearsInView(component, selector) {
|
|
|
438
1222
|
return false;
|
|
439
1223
|
return tokens.every(t => new RegExp(`(^|[^\\w-])${t.replace(/[-]/g, '\\-')}($|[^\\w-])`).test(src));
|
|
440
1224
|
}
|
|
441
|
-
|
|
1225
|
+
// G-144: a fragment-rooted component has several root elements
|
|
1226
|
+
function rootElementsOf(t) {
|
|
442
1227
|
try {
|
|
443
1228
|
const im = t.source._isolateModule;
|
|
444
|
-
return im && im.
|
|
1229
|
+
return (im && im.getElements(scopesOf(t.source.namespace))) || [];
|
|
445
1230
|
}
|
|
446
1231
|
catch (_) {
|
|
447
|
-
return
|
|
1232
|
+
return [];
|
|
448
1233
|
}
|
|
449
1234
|
}
|
|
450
1235
|
function childNameFor(t, elements) {
|
|
@@ -463,7 +1248,7 @@ function childNameFor(t, elements) {
|
|
|
463
1248
|
for (let i = depth; i < ns.length; i++) {
|
|
464
1249
|
if (ns[i].type !== 'total')
|
|
465
1250
|
continue;
|
|
466
|
-
const owner = owners.get(keyOf(ns.slice(0, i + 1)));
|
|
1251
|
+
const owner = owners.get(keyOf$1(ns.slice(0, i + 1)));
|
|
467
1252
|
if (owner)
|
|
468
1253
|
return nameOf(owner);
|
|
469
1254
|
break;
|
|
@@ -486,8 +1271,8 @@ function report103(component, t, selector, severity) {
|
|
|
486
1271
|
function sweep(component, t, escalate) {
|
|
487
1272
|
if (t.disposed)
|
|
488
1273
|
return;
|
|
489
|
-
const
|
|
490
|
-
if (!
|
|
1274
|
+
const roots = rootElementsOf(t).filter(r => typeof r.querySelectorAll === 'function');
|
|
1275
|
+
if (!roots.length)
|
|
491
1276
|
return;
|
|
492
1277
|
const im = t.source._isolateModule;
|
|
493
1278
|
const checker = new ScopeChecker(t.source.namespace, im);
|
|
@@ -498,9 +1283,12 @@ function sweep(component, t, escalate) {
|
|
|
498
1283
|
continue;
|
|
499
1284
|
let all;
|
|
500
1285
|
try {
|
|
501
|
-
all =
|
|
502
|
-
|
|
503
|
-
all.push(root);
|
|
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
|
+
}
|
|
504
1292
|
}
|
|
505
1293
|
catch (_) {
|
|
506
1294
|
continue; // invalid selector: the DOM driver reports that itself
|
|
@@ -520,11 +1308,13 @@ function sweep(component, t, escalate) {
|
|
|
520
1308
|
if (!once(`SYG104:${t.name}:${selector}`))
|
|
521
1309
|
continue;
|
|
522
1310
|
const child = childNameFor(t, all) || 'a child component';
|
|
1311
|
+
// CT-1: a control is named by its identifier
|
|
1312
|
+
const control = keyOfSelector(selector);
|
|
523
1313
|
reportSafely('SYG104', {
|
|
524
1314
|
component,
|
|
525
|
-
message: `DOM.select('${selector}') in ${t.name} matches elements inside ${child} (isolated), so ${t.name} never receives their events`,
|
|
526
|
-
fix: `Handle the event in ${child} and send it up with PARENT (read it here with CHILD.select(${child})), or use EVENTS`,
|
|
527
|
-
data: { selector, child },
|
|
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 },
|
|
528
1318
|
});
|
|
529
1319
|
continue;
|
|
530
1320
|
}
|
|
@@ -561,7 +1351,7 @@ const domCheck = {
|
|
|
561
1351
|
if (!Array.isArray(ns) || typeof selector !== 'string' || !selector)
|
|
562
1352
|
return;
|
|
563
1353
|
const prefix = ns.filter(s => s.type === 'selector').map(s => s.scope).join(' ');
|
|
564
|
-
pending.push({ key: keyOf(scopesOf(ns)), selector: prefix ? `${prefix} ${selector}` : selector });
|
|
1354
|
+
pending.push({ key: keyOf$1(scopesOf(ns)), selector: prefix ? `${prefix} ${selector}` : selector });
|
|
565
1355
|
if (pending.length > 1000)
|
|
566
1356
|
pending.shift();
|
|
567
1357
|
},
|
|
@@ -571,7 +1361,7 @@ const domCheck = {
|
|
|
571
1361
|
const source = component && component.sources && component.sources[component.DOMSourceName || 'DOM'];
|
|
572
1362
|
if (!source || !source._isolateModule || !Array.isArray(source.namespace))
|
|
573
1363
|
return;
|
|
574
|
-
const key = keyOf(scopesOf(source.namespace));
|
|
1364
|
+
const key = keyOf$1(scopesOf(source.namespace));
|
|
575
1365
|
const name = nameOf(component);
|
|
576
1366
|
owners.set(key, component);
|
|
577
1367
|
ownerKeys.set(component, key);
|
|
@@ -607,254 +1397,1466 @@ const domCheck = {
|
|
|
607
1397
|
},
|
|
608
1398
|
};
|
|
609
1399
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
//
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
SYG218: 'error',
|
|
639
|
-
SYG219: 'warn',
|
|
640
|
-
SYG220: 'warn',
|
|
641
|
-
SYG402: 'error',
|
|
642
|
-
SYG403: 'error',
|
|
643
|
-
SYG404: 'error',
|
|
644
|
-
SYG405: 'warn',
|
|
645
|
-
SYG406: 'error',
|
|
646
|
-
SYG407: 'error',
|
|
647
|
-
SYG408: 'error',
|
|
648
|
-
SYG409: 'warn',
|
|
649
|
-
SYG410: 'error',
|
|
650
|
-
SYG411: 'error',
|
|
651
|
-
SYG412: 'error',
|
|
652
|
-
SYG413: 'error',
|
|
653
|
-
SYG414: 'error',
|
|
654
|
-
SYG415: 'error',
|
|
655
|
-
SYG416: 'error',
|
|
656
|
-
SYG417: 'warn',
|
|
657
|
-
SYG418: 'error',
|
|
658
|
-
SYG419: 'error',
|
|
659
|
-
SYG420: 'error',
|
|
660
|
-
SYG601: 'error',
|
|
661
|
-
SYG602: 'error',
|
|
662
|
-
SYG603: 'error',
|
|
663
|
-
SYG604: 'error',
|
|
664
|
-
SYG605: 'error',
|
|
665
|
-
SYG606: 'error',
|
|
666
|
-
SYG607: 'error',
|
|
667
|
-
SYG901: 'error',
|
|
668
|
-
SYG902: 'error',
|
|
669
|
-
SYG903: 'error',
|
|
670
|
-
// ---- end 1E retrofit codes ----
|
|
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
|
+
}
|
|
671
1428
|
};
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
// SYG3xx streams & intent construction (1A)
|
|
686
|
-
SYG301: 'RxJS operator used on an xstream stream',
|
|
687
|
-
// SYG4xx collections / switchable / sub-components / context (1A)
|
|
688
|
-
SYG401: "Collection 'from' field is missing or not an array",
|
|
689
|
-
// SYG5xx strict mode / canonical forms (2A; dev-plans/PLAN-1-canonical-forms.md)
|
|
690
|
-
SYG501: 'View uses positional arguments',
|
|
691
|
-
SYG502: 'STATE reducer signals no change without ABORT',
|
|
692
|
-
SYG503: 'Side effect in a STATE reducer that returns ABORT',
|
|
693
|
-
SYG504: "'ACTION | SINK' shorthand model key",
|
|
694
|
-
SYG505: 'Non-canonical EVENTS emit',
|
|
695
|
-
SYG506: 'CHILD.select() with a string component name',
|
|
696
|
-
SYG507: 'Prop drilled through more than 2 component levels',
|
|
697
|
-
// SYG9xx internal (0B)
|
|
698
|
-
SYG900: 'A diagnostics check threw',
|
|
699
|
-
// ---- 1E retrofit codes (SYG206-299, 402-499, 601-699, 901-999) ----
|
|
700
|
-
SYG206: 'Invalid calculated field definition',
|
|
701
|
-
SYG207: 'Calculated field shadows an initialState key',
|
|
702
|
-
SYG208: 'Calculated field depends on an unknown key',
|
|
703
|
-
SYG209: 'Circular calculated field dependency',
|
|
704
|
-
SYG210: 'INITIALIZE used with a sink other than STATE',
|
|
705
|
-
SYG211: 'Invalid model shorthand entry',
|
|
706
|
-
SYG212: 'Model entry is not a function or an object of sinks',
|
|
707
|
-
SYG213: 'Duplicate model entry for the same action and sink',
|
|
708
|
-
SYG214: 'EFFECT handler threw',
|
|
709
|
-
SYG215: 'next() delay is not a number',
|
|
710
|
-
SYG216: 'Reducer threw',
|
|
711
|
-
SYG217: 'Driver-sink reducer returned undefined',
|
|
712
|
-
SYG218: 'Reducer returned an unsupported type',
|
|
713
|
-
SYG219: 'EFFECT handler returned a value',
|
|
714
|
-
SYG220: 'Calculated field threw',
|
|
715
|
-
SYG402: 'Context is not an object',
|
|
716
|
-
SYG403: 'Invalid context entry',
|
|
717
|
-
SYG404: 'Context stream errored',
|
|
718
|
-
SYG405: 'Sub-component has initialState without isolatedState',
|
|
719
|
-
SYG406: 'View threw',
|
|
720
|
-
SYG407: 'onError handler threw',
|
|
721
|
-
SYG408: 'Sub-component failed to instantiate',
|
|
722
|
-
SYG409: 'Child tried to update a calculated field',
|
|
723
|
-
SYG410: "Invalid sub-component 'state' prop",
|
|
724
|
-
SYG411: "Collection 'of' is missing or not a component",
|
|
725
|
-
SYG412: "Invalid Collection 'from' prop",
|
|
726
|
-
SYG413: 'Unnamed component factory not found',
|
|
727
|
-
SYG414: 'Component not found',
|
|
728
|
-
SYG415: "Switchable 'of' is missing or invalid",
|
|
729
|
-
SYG416: "Switchable 'current' is missing or invalid",
|
|
730
|
-
SYG417: 'Portal target not found',
|
|
731
|
-
SYG418: 'Invalid Collection sort prop',
|
|
732
|
-
SYG419: "Invalid switchable() 'name$' parameter",
|
|
733
|
-
SYG420: 'JSX tag is undefined',
|
|
734
|
-
SYG601: 'Missing or invalid sources',
|
|
735
|
-
SYG602: 'Intent is not a function',
|
|
736
|
-
SYG603: 'Intent returned an invalid value',
|
|
737
|
-
SYG604: 'Invalid hmrActions',
|
|
738
|
-
SYG605: "Intent action name contains '|'",
|
|
739
|
-
SYG606: 'calculated is not an object',
|
|
740
|
-
SYG607: 'State source not found',
|
|
741
|
-
SYG901: 'Sub-component sink stream errored',
|
|
742
|
-
SYG902: 'EFFECT stream errored',
|
|
743
|
-
SYG903: 'Component factory returned invalid sinks',
|
|
744
|
-
// ---- end 1E retrofit codes ----
|
|
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})`,
|
|
745
1442
|
};
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
SYG504: 'warn',
|
|
755
|
-
SYG505: 'warn',
|
|
756
|
-
SYG506: 'warn',
|
|
757
|
-
SYG507: 'info',
|
|
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}')`;
|
|
758
1451
|
};
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
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;
|
|
762
1480
|
}
|
|
763
|
-
|
|
764
|
-
|
|
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 });
|
|
765
1625
|
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
+
});
|
|
770
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
|
+
});
|
|
771
2593
|
}
|
|
772
|
-
|
|
773
|
-
|
|
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; };
|
|
774
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
|
+
};
|
|
775
2627
|
|
|
776
|
-
registerCodes(Object.keys(STRICT_CODE_SEVERITY).map(code => [code, STRICT_CODE_SEVERITY[code], CODE_TITLES[code]]));
|
|
777
2628
|
/**
|
|
778
|
-
*
|
|
779
|
-
* (
|
|
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).
|
|
780
2644
|
*/
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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);
|
|
785
2653
|
}
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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 '';
|
|
790
2669
|
}
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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',
|
|
794
2741
|
onModel(component) {
|
|
795
|
-
|
|
2742
|
+
const view = component?.view, p = view?.persist;
|
|
2743
|
+
if (!p)
|
|
796
2744
|
return;
|
|
797
2745
|
const name = nameOf(component);
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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;
|
|
810
2762
|
}
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
if (!model || typeof model !== 'object')
|
|
2763
|
+
const o = p.options, init = view.initialState;
|
|
2764
|
+
if (!o || !init || typeof init != 'object')
|
|
814
2765
|
return;
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
fix: `Use the object form: ${rewrite} (merge it into an existing '${action}' entry if there is one)`,
|
|
828
|
-
data: { key, action, sink },
|
|
829
|
-
});
|
|
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
|
+
}
|
|
830
2778
|
}
|
|
831
2779
|
},
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
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
|
+
});
|
|
837
2799
|
return;
|
|
838
|
-
let same = nextState === prevState;
|
|
839
|
-
if (!same && typeof component?.addCalculated === 'function') {
|
|
840
|
-
// with calculated fields the reducer saw addCalculated(prev) (memoized: same object)
|
|
841
|
-
try {
|
|
842
|
-
same = component.addCalculated(prevState) === nextState;
|
|
843
|
-
}
|
|
844
|
-
catch (_) { }
|
|
845
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;
|
|
846
2809
|
const name = nameOf(component);
|
|
847
|
-
if (!
|
|
2810
|
+
if (!once(`SYG645:${name}`))
|
|
848
2811
|
return;
|
|
849
|
-
|
|
2812
|
+
devReport('SYG645', {
|
|
850
2813
|
component,
|
|
851
|
-
message:
|
|
852
|
-
fix: `
|
|
853
|
-
data: {
|
|
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 },
|
|
854
2817
|
});
|
|
855
2818
|
},
|
|
856
2819
|
};
|
|
857
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
|
+
|
|
858
2860
|
/**
|
|
859
2861
|
* SYG301 — an RxJS operator was called on an xstream stream (error).
|
|
860
2862
|
*
|
|
@@ -866,7 +2868,7 @@ const strictCheck = {
|
|
|
866
2868
|
* xstream equivalent and the SYG301 docs link. The error is also reported
|
|
867
2869
|
* (collected / printed per the diagnostics mode; in 'error' mode report()
|
|
868
2870
|
* throws the DiagnosticError itself). It works anywhere a stream is used
|
|
869
|
-
* (intent, peers, drivers), with no hook in
|
|
2871
|
+
* (intent, peers, drivers), with no hook in the core.
|
|
870
2872
|
*
|
|
871
2873
|
* Caveat: `typeof stream.pipe === 'function'` is true while this entry is
|
|
872
2874
|
* loaded (dev only).
|
|
@@ -941,32 +2943,134 @@ function installRxjsHints(proto) {
|
|
|
941
2943
|
delete proto[op]; };
|
|
942
2944
|
}
|
|
943
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
|
+
|
|
944
3045
|
let records = new Map();
|
|
945
3046
|
let pendingSelects = [];
|
|
946
3047
|
/** every EVENTS type seen on the bus */
|
|
947
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
|
+
};
|
|
948
3057
|
onReset(() => {
|
|
949
3058
|
records = new Map();
|
|
950
3059
|
pendingSelects = [];
|
|
951
3060
|
busTypes = new Set();
|
|
3061
|
+
recent = [];
|
|
3062
|
+
epoch = clockNow();
|
|
952
3063
|
});
|
|
953
3064
|
const uniq = (list) => [...new Set(list)];
|
|
3065
|
+
const NEXT_KIND = { root: 'root', child: 'child', item: 'collection-item', page: 'switchable' };
|
|
954
3066
|
function kindOf(c) {
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
return 'root';
|
|
958
|
-
// Collection items get PARENT: null (src/component.ts instantiateCollection)
|
|
959
|
-
if ('PARENT' in s && s.PARENT === null)
|
|
960
|
-
return 'collection-item';
|
|
961
|
-
// switchable() adds a lower-case `state` source next to the component's own (src/switchable.ts)
|
|
962
|
-
if (c.stateSourceName !== 'state' && 'state' in s && s.state !== s[c.stateSourceName])
|
|
963
|
-
return 'switchable';
|
|
964
|
-
return 'child';
|
|
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';
|
|
965
3069
|
}
|
|
966
3070
|
function ensure(c) {
|
|
967
3071
|
let r = records.get(c);
|
|
968
3072
|
if (!r) {
|
|
969
|
-
const parent = c && c.
|
|
3073
|
+
const parent = c && c.__next && c.__next.parentId;
|
|
970
3074
|
r = {
|
|
971
3075
|
instance: c,
|
|
972
3076
|
id: String(c && c._componentNumber),
|
|
@@ -979,6 +3083,7 @@ function ensure(c) {
|
|
|
979
3083
|
fired: new Set(),
|
|
980
3084
|
eventsSelected: new Set(),
|
|
981
3085
|
eventsEmitted: new Set(),
|
|
3086
|
+
controls: new Map(),
|
|
982
3087
|
};
|
|
983
3088
|
records.set(c, r);
|
|
984
3089
|
}
|
|
@@ -1013,7 +3118,7 @@ const inspectCheck = {
|
|
|
1013
3118
|
attachDevtools();
|
|
1014
3119
|
const r = ensure(component);
|
|
1015
3120
|
const intent$ = component && component.intent$;
|
|
1016
|
-
r.intentActions =
|
|
3121
|
+
r.intentActions = actionNames || [];
|
|
1017
3122
|
r.injected = new Set((intent$ && typeof intent$ === 'object' && intent$.__sygnalTestActions) || []);
|
|
1018
3123
|
for (const t of pendingSelects)
|
|
1019
3124
|
r.eventsSelected.add(t);
|
|
@@ -1022,17 +3127,11 @@ const inspectCheck = {
|
|
|
1022
3127
|
onModel(component, modelMap) {
|
|
1023
3128
|
const r = ensure(component);
|
|
1024
3129
|
r.modelMap = modelMap || {};
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
r.eventsEmitted.add(ev.type);
|
|
1031
|
-
return ev;
|
|
1032
|
-
});
|
|
1033
|
-
tapped.__sygnalInspect = true;
|
|
1034
|
-
model$.EVENTS = tapped;
|
|
1035
|
-
}
|
|
3130
|
+
},
|
|
3131
|
+
onRender(component, root) {
|
|
3132
|
+
const r = records.get(component);
|
|
3133
|
+
if (r)
|
|
3134
|
+
collectControls(root, r.controls, true);
|
|
1036
3135
|
},
|
|
1037
3136
|
onReducer(component, action) {
|
|
1038
3137
|
const r = records.get(component);
|
|
@@ -1047,6 +3146,48 @@ const inspectCheck = {
|
|
|
1047
3146
|
records.delete(component);
|
|
1048
3147
|
},
|
|
1049
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
|
+
};
|
|
1050
3191
|
const slim = (d) => {
|
|
1051
3192
|
const out = { code: d.code, severity: d.severity, message: d.message };
|
|
1052
3193
|
if (d.component)
|
|
@@ -1067,12 +3208,14 @@ const slim = (d) => {
|
|
|
1067
3208
|
function actionsOf(r) {
|
|
1068
3209
|
const intent = (r.intentActions || []).filter(a => !r.injected.has(a));
|
|
1069
3210
|
const names = uniq([...intent, ...Object.keys(r.modelMap)]).filter(a => typeof a === 'string' && !a.startsWith('__'));
|
|
3211
|
+
const replies = replySeen.get(r.instance);
|
|
1070
3212
|
return names.map(name => ({
|
|
1071
3213
|
name,
|
|
1072
3214
|
trigger: BUILTIN_ACTIONS.has(name) ? 'builtin'
|
|
1073
3215
|
: intent.includes(name) ? 'intent'
|
|
1074
|
-
:
|
|
1075
|
-
: '
|
|
3216
|
+
: replies?.has(name) ? 'reply'
|
|
3217
|
+
: !r.injected.has(name) && r.fired.has(name) ? 'next'
|
|
3218
|
+
: 'unknown',
|
|
1076
3219
|
sinks: [...(r.modelMap[name] || [])],
|
|
1077
3220
|
}));
|
|
1078
3221
|
}
|
|
@@ -1080,6 +3223,38 @@ function stateKeysOf(c, calculated) {
|
|
|
1080
3223
|
const s = c && c.currentState;
|
|
1081
3224
|
return isPlainObject(s) ? Object.keys(s).filter(k => !calculated.includes(k)) : [];
|
|
1082
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
|
+
}
|
|
1083
3258
|
const isScope = (s) => s.startsWith('.___');
|
|
1084
3259
|
function mockSelectors(mock) {
|
|
1085
3260
|
const out = {};
|
|
@@ -1103,16 +3278,16 @@ function mockSelectors(mock) {
|
|
|
1103
3278
|
function selectorsOf(r, options, diags) {
|
|
1104
3279
|
const given = options.selectors && options.selectors[r.id];
|
|
1105
3280
|
if (given)
|
|
1106
|
-
return given.map(s => ({ ...s }));
|
|
3281
|
+
return given.map(s => withControl({ ...s }));
|
|
1107
3282
|
return (selectorStatus(r.instance) || []).map(({ selector, matched, crossed }) => {
|
|
1108
3283
|
const hit = diags.find(d => d.code === 'SYG104' && d.component === r.name && d.data && d.data.selector === selector);
|
|
1109
3284
|
const missed = diags.some(d => d.code === 'SYG103' && d.component === r.name && d.data && d.data.selector === selector);
|
|
1110
|
-
return {
|
|
3285
|
+
return withControl({
|
|
1111
3286
|
selector,
|
|
1112
3287
|
events: null,
|
|
1113
3288
|
matched: matched === null && missed ? false : matched,
|
|
1114
3289
|
isolationHit: crossed ? ((hit && hit.data.child) || 'a child component') : null,
|
|
1115
|
-
};
|
|
3290
|
+
});
|
|
1116
3291
|
});
|
|
1117
3292
|
}
|
|
1118
3293
|
function childrenOf(r, all) {
|
|
@@ -1157,6 +3332,8 @@ function inspect(options = {}) {
|
|
|
1157
3332
|
const components = recs.map(r => {
|
|
1158
3333
|
const c = r.instance;
|
|
1159
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);
|
|
1160
3337
|
return {
|
|
1161
3338
|
name: r.name,
|
|
1162
3339
|
id: r.id,
|
|
@@ -1170,10 +3347,13 @@ function inspect(options = {}) {
|
|
|
1170
3347
|
eventsEmitted: [...r.eventsEmitted],
|
|
1171
3348
|
eventsSelected: [...r.eventsSelected],
|
|
1172
3349
|
children: childrenOf(r, all),
|
|
1173
|
-
selectors
|
|
3350
|
+
selectors,
|
|
3351
|
+
...(controls ? { controls } : {}),
|
|
1174
3352
|
diagnostics: diags.filter(d => d && d.component === r.name).map(slim),
|
|
3353
|
+
...(resourcesOf(c) ? { resources: resourcesOf(c) } : {}),
|
|
1175
3354
|
};
|
|
1176
3355
|
});
|
|
3356
|
+
const cache = cacheOf(recs) || {};
|
|
1177
3357
|
const events = {};
|
|
1178
3358
|
const ev = (type) => events[type] || (events[type] = { emitters: [], selectors: [] });
|
|
1179
3359
|
const add = (list, name) => { if (!list.includes(name))
|
|
@@ -1194,8 +3374,36 @@ function inspect(options = {}) {
|
|
|
1194
3374
|
components,
|
|
1195
3375
|
events,
|
|
1196
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) } : {}),
|
|
1197
3379
|
};
|
|
1198
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
|
+
}
|
|
1199
3407
|
/** Publish inspect() on the core bridge and the devtools object. Returns an uninstall function. */
|
|
1200
3408
|
function installInspect() {
|
|
1201
3409
|
const core = bridge();
|
|
@@ -1215,6 +3423,501 @@ function installInspect() {
|
|
|
1215
3423
|
};
|
|
1216
3424
|
}
|
|
1217
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
|
+
|
|
1218
3921
|
/** Every runtime check in this entry. */
|
|
1219
3922
|
const checks = [
|
|
1220
3923
|
wiringCheck,
|
|
@@ -1223,7 +3926,20 @@ const checks = [
|
|
|
1223
3926
|
propsCheck,
|
|
1224
3927
|
collectionsCheck,
|
|
1225
3928
|
domCheck,
|
|
3929
|
+
shorthandCheck,
|
|
3930
|
+
controlsCheck,
|
|
3931
|
+
elementCommandsCheck,
|
|
3932
|
+
behaviorsCheck,
|
|
3933
|
+
formsCheck,
|
|
3934
|
+
datasetCheck,
|
|
1226
3935
|
strictCheck,
|
|
3936
|
+
repliesCheck,
|
|
3937
|
+
routerCheck,
|
|
3938
|
+
fetchCheck,
|
|
3939
|
+
timersCheck,
|
|
3940
|
+
persistCheck,
|
|
3941
|
+
viewTransitionsCheck,
|
|
3942
|
+
staticsCheck,
|
|
1227
3943
|
inspectCheck,
|
|
1228
3944
|
];
|
|
1229
3945
|
/**
|
|
@@ -1240,10 +3956,30 @@ function installChecks() {
|
|
|
1240
3956
|
const unregister = checks.map(check => core.registerCheck(check));
|
|
1241
3957
|
const uninstallHints = installRxjsHints(Stream && Stream.prototype);
|
|
1242
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);
|
|
1243
3969
|
const uninstall = () => {
|
|
3970
|
+
core.layers?.delete(nextHooks);
|
|
1244
3971
|
unregister.forEach(fn => fn());
|
|
1245
3972
|
uninstallHints();
|
|
1246
3973
|
uninstallInspect();
|
|
3974
|
+
uninstallRouter();
|
|
3975
|
+
uninstallControls();
|
|
3976
|
+
uninstallTimers();
|
|
3977
|
+
uninstallBrowser();
|
|
3978
|
+
uninstallElementCommands();
|
|
3979
|
+
uninstallWidgets();
|
|
3980
|
+
uninstallVirtual();
|
|
3981
|
+
uninstallForms();
|
|
3982
|
+
uninstallSortable();
|
|
1247
3983
|
if (core.__uninstallChecks === uninstall)
|
|
1248
3984
|
core.__uninstallChecks = undefined;
|
|
1249
3985
|
};
|