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.
Files changed (316) hide show
  1. package/CHANGELOG.md +646 -0
  2. package/README.md +24 -9
  3. package/dist/astro/client.cjs.js +16 -9
  4. package/dist/astro/client.cjs.js.map +1 -1
  5. package/dist/astro/client.mjs +16 -9
  6. package/dist/astro/client.mjs.map +1 -1
  7. package/dist/astro/index.cjs.js +362 -91
  8. package/dist/astro/index.cjs.js.map +1 -1
  9. package/dist/astro/index.mjs +362 -92
  10. package/dist/astro/index.mjs.map +1 -1
  11. package/dist/astro/server.cjs.js +3396 -165
  12. package/dist/astro/server.cjs.js.map +1 -1
  13. package/dist/astro/server.mjs +3396 -165
  14. package/dist/astro/server.mjs.map +1 -1
  15. package/dist/devtools.cjs.js +1431 -0
  16. package/dist/devtools.cjs.js.map +1 -0
  17. package/dist/devtools.esm.js +1419 -0
  18. package/dist/devtools.esm.js.map +1 -0
  19. package/dist/diagnostics.cjs.js +3032 -296
  20. package/dist/diagnostics.cjs.js.map +1 -1
  21. package/dist/diagnostics.esm.js +3032 -296
  22. package/dist/diagnostics.esm.js.map +1 -1
  23. package/dist/element.cjs.js +230 -0
  24. package/dist/element.cjs.js.map +1 -0
  25. package/dist/element.esm.js +228 -0
  26. package/dist/element.esm.js.map +1 -0
  27. package/dist/guide/accessibility.md +257 -0
  28. package/dist/guide/adapters.md +221 -0
  29. package/dist/guide/behaviors.md +312 -0
  30. package/dist/guide/browser-sources.md +203 -0
  31. package/dist/guide/drag-and-drop.md +342 -0
  32. package/dist/guide/element-commands.md +306 -0
  33. package/dist/guide/error-boundaries.md +169 -0
  34. package/dist/guide/forms-reference.md +381 -0
  35. package/dist/guide/forms.md +250 -0
  36. package/dist/guide/http.md +299 -0
  37. package/dist/guide/inputs.md +173 -0
  38. package/dist/guide/persistence.md +245 -0
  39. package/dist/guide/recipes/carousel.md +155 -0
  40. package/dist/guide/recipes/charts.md +177 -0
  41. package/dist/guide/recipes/code-editor.md +135 -0
  42. package/dist/guide/recipes/data-grid.md +155 -0
  43. package/dist/guide/recipes/data-table.md +196 -0
  44. package/dist/guide/recipes/i18n.md +222 -0
  45. package/dist/guide/recipes/icons.md +115 -0
  46. package/dist/guide/recipes/overview.md +33 -0
  47. package/dist/guide/recipes/rich-text.md +154 -0
  48. package/dist/guide/resources.md +471 -0
  49. package/dist/guide/ssr.md +255 -0
  50. package/dist/guide/timers.md +178 -0
  51. package/dist/guide/ui/accordion.md +93 -0
  52. package/dist/guide/ui/combobox.md +144 -0
  53. package/dist/guide/ui/dialog.md +124 -0
  54. package/dist/guide/ui/disclosure.md +60 -0
  55. package/dist/guide/ui/menu.md +125 -0
  56. package/dist/guide/ui/overview.md +166 -0
  57. package/dist/guide/ui/popover.md +101 -0
  58. package/dist/guide/ui/select.md +114 -0
  59. package/dist/guide/ui/tabs.md +161 -0
  60. package/dist/guide/ui/toaster.md +152 -0
  61. package/dist/guide/ui/tooltip.md +103 -0
  62. package/dist/guide/undo.md +178 -0
  63. package/dist/guide/virtual-collections.md +183 -0
  64. package/dist/guide/web-components.md +179 -0
  65. package/dist/guide/widgets.md +217 -0
  66. package/dist/index.cjs.js +13178 -5769
  67. package/dist/index.cjs.js.map +1 -1
  68. package/dist/index.d.ts +2908 -276
  69. package/dist/index.esm.js +13149 -5779
  70. package/dist/index.esm.js.map +1 -1
  71. package/dist/jsx-dev-runtime.cjs.js +26 -334
  72. package/dist/jsx-dev-runtime.cjs.js.map +1 -1
  73. package/dist/jsx-dev-runtime.esm.js +19 -327
  74. package/dist/jsx-dev-runtime.esm.js.map +1 -1
  75. package/dist/jsx-runtime.cjs.js +26 -334
  76. package/dist/jsx-runtime.cjs.js.map +1 -1
  77. package/dist/jsx-runtime.esm.js +19 -327
  78. package/dist/jsx-runtime.esm.js.map +1 -1
  79. package/dist/jsx.cjs.js +10 -314
  80. package/dist/jsx.cjs.js.map +1 -1
  81. package/dist/jsx.esm.js +8 -315
  82. package/dist/jsx.esm.js.map +1 -1
  83. package/dist/react.cjs.js +117 -0
  84. package/dist/react.cjs.js.map +1 -0
  85. package/dist/react.esm.js +115 -0
  86. package/dist/react.esm.js.map +1 -0
  87. package/dist/shims/globalthis.cjs +20 -0
  88. package/dist/sygnal.min.js +1 -1
  89. package/dist/sygnal.min.js.map +1 -1
  90. package/dist/ui-combobox.cjs.js +169 -0
  91. package/dist/ui-combobox.cjs.js.map +1 -0
  92. package/dist/ui-combobox.esm.js +148 -0
  93. package/dist/ui-combobox.esm.js.map +1 -0
  94. package/dist/ui-menu.cjs.js +87 -0
  95. package/dist/ui-menu.cjs.js.map +1 -0
  96. package/dist/ui-menu.esm.js +66 -0
  97. package/dist/ui-menu.esm.js.map +1 -0
  98. package/dist/ui-select.cjs.js +104 -0
  99. package/dist/ui-select.cjs.js.map +1 -0
  100. package/dist/ui-select.esm.js +83 -0
  101. package/dist/ui-select.esm.js.map +1 -0
  102. package/dist/ui.cjs.js +738 -0
  103. package/dist/ui.cjs.js.map +1 -0
  104. package/dist/ui.esm.js +727 -0
  105. package/dist/ui.esm.js.map +1 -0
  106. package/dist/vike/ClientOnly.cjs.js +4 -14
  107. package/dist/vike/ClientOnly.cjs.js.map +1 -1
  108. package/dist/vike/ClientOnly.mjs +4 -14
  109. package/dist/vike/ClientOnly.mjs.map +1 -1
  110. package/dist/vike/{+config.js → config/+config.js} +9 -1
  111. package/dist/vike/config/+config.js.map +1 -0
  112. package/dist/vike/config/package.json +6 -0
  113. package/dist/vike/onRenderClient.cjs.js +165 -95
  114. package/dist/vike/onRenderClient.cjs.js.map +1 -1
  115. package/dist/vike/onRenderClient.mjs +165 -95
  116. package/dist/vike/onRenderClient.mjs.map +1 -1
  117. package/dist/vike/onRenderHtml.cjs.js +59 -24
  118. package/dist/vike/onRenderHtml.cjs.js.map +1 -1
  119. package/dist/vike/onRenderHtml.mjs +60 -25
  120. package/dist/vike/onRenderHtml.mjs.map +1 -1
  121. package/dist/vite/plugin.cjs.js +323 -88
  122. package/dist/vite/plugin.cjs.js.map +1 -1
  123. package/dist/vite/plugin.mjs +323 -89
  124. package/dist/vite/plugin.mjs.map +1 -1
  125. package/dist/zag.cjs.js +408 -0
  126. package/dist/zag.cjs.js.map +1 -0
  127. package/dist/zag.esm.js +405 -0
  128. package/dist/zag.esm.js.map +1 -0
  129. package/llms.txt +166 -103
  130. package/package.json +98 -14
  131. package/src/astro/client.ts +27 -16
  132. package/src/astro/index.d.ts +24 -0
  133. package/src/astro/index.ts +69 -3
  134. package/src/astro/server.ts +8 -2
  135. package/src/collection.ts +6 -93
  136. package/src/core/actions.ts +134 -0
  137. package/src/core/cell.ts +202 -0
  138. package/src/core/debug.ts +23 -0
  139. package/src/core/define.ts +242 -0
  140. package/src/core/hooks.ts +314 -0
  141. package/src/core/hosts/collection.ts +325 -0
  142. package/src/core/hosts/switchable.ts +146 -0
  143. package/src/core/instance.ts +592 -0
  144. package/src/core/markers/clientonly.ts +13 -0
  145. package/src/core/markers/lazy.ts +40 -0
  146. package/src/core/markers/portal.ts +96 -0
  147. package/src/core/markers/suspense.ts +41 -0
  148. package/src/core/markers/transition.ts +70 -0
  149. package/src/core/registry.ts +25 -0
  150. package/src/core/runtime.ts +573 -0
  151. package/src/core/statics.ts +104 -0
  152. package/src/core/teardown.ts +60 -0
  153. package/src/core/view.ts +37 -0
  154. package/src/cycle/dom/DocumentDOMSource.ts +13 -8
  155. package/src/cycle/dom/ElementFinder.ts +7 -9
  156. package/src/cycle/dom/EventDelegator.ts +174 -223
  157. package/src/cycle/dom/IsolateModule.ts +65 -35
  158. package/src/cycle/dom/MainDOMSource.ts +9 -2
  159. package/src/cycle/dom/PriorityQueue.ts +4 -2
  160. package/src/cycle/dom/SymbolTree.ts +18 -47
  161. package/src/cycle/dom/classNameModule.ts +9 -7
  162. package/src/cycle/dom/controlledInputModule.ts +23 -6
  163. package/src/cycle/dom/enrichEventStream.ts +25 -47
  164. package/src/cycle/dom/fragment.ts +7 -0
  165. package/src/cycle/dom/isolate.ts +30 -27
  166. package/src/cycle/dom/makeDOMDriver.ts +115 -56
  167. package/src/cycle/dom/mockDOMSource.ts +21 -1
  168. package/src/cycle/dom/modules.ts +16 -3
  169. package/src/cycle/dom/propsModule.ts +65 -0
  170. package/src/cycle/dom/selectModule.ts +22 -17
  171. package/src/cycle/dom/snabbdom.ts +1 -6
  172. package/src/cycle/dom/thunk.ts +3 -0
  173. package/src/cycle/dom/utils.ts +98 -0
  174. package/src/cycle/dom/viewTransition.ts +43 -0
  175. package/src/cycle/state/StateSource.ts +19 -3
  176. package/src/cycle/state/objIsEqual.ts +50 -0
  177. package/src/cycle/state/types.ts +0 -10
  178. package/src/defineComponent.ts +34 -0
  179. package/src/devtools.d.ts +151 -0
  180. package/src/devtools.ts +34 -0
  181. package/src/element.d.ts +59 -0
  182. package/src/element.ts +235 -0
  183. package/src/extra/backoff.ts +9 -0
  184. package/src/extra/behaviors.ts +169 -0
  185. package/src/extra/browserSignals.ts +23 -0
  186. package/src/extra/browserSources.ts +326 -0
  187. package/src/extra/command.ts +3 -3
  188. package/src/extra/controls.ts +47 -0
  189. package/src/extra/copyAsTest.ts +286 -0
  190. package/src/extra/devtools.ts +101 -31
  191. package/src/extra/devtoolsActions.ts +379 -0
  192. package/src/extra/devtoolsHook.ts +9 -0
  193. package/src/extra/devtoolsNext.ts +83 -0
  194. package/src/extra/diagnostics/checks/actionLog.ts +108 -0
  195. package/src/extra/diagnostics/checks/behaviors.ts +49 -0
  196. package/src/extra/diagnostics/checks/browserSources.ts +96 -0
  197. package/src/extra/diagnostics/checks/collections.ts +1 -1
  198. package/src/extra/diagnostics/checks/controls.ts +148 -0
  199. package/src/extra/diagnostics/checks/dataset.ts +57 -0
  200. package/src/extra/diagnostics/checks/dom.ts +20 -11
  201. package/src/extra/diagnostics/checks/elementCommands.ts +175 -0
  202. package/src/extra/diagnostics/checks/events.ts +17 -2
  203. package/src/extra/diagnostics/checks/fetch.ts +135 -0
  204. package/src/extra/diagnostics/checks/forms.ts +163 -0
  205. package/src/extra/diagnostics/checks/index.ts +97 -4
  206. package/src/extra/diagnostics/checks/inspect.ts +140 -32
  207. package/src/extra/diagnostics/checks/next.ts +417 -0
  208. package/src/extra/diagnostics/checks/persist.ts +54 -0
  209. package/src/extra/diagnostics/checks/props.ts +10 -5
  210. package/src/extra/diagnostics/checks/public.d.ts +98 -7
  211. package/src/extra/diagnostics/checks/replies.ts +128 -0
  212. package/src/extra/diagnostics/checks/router.ts +100 -0
  213. package/src/extra/diagnostics/checks/rxjsHints.ts +1 -1
  214. package/src/extra/diagnostics/checks/shared.ts +86 -2
  215. package/src/extra/diagnostics/checks/shorthand.ts +115 -0
  216. package/src/extra/diagnostics/checks/sortable.ts +129 -0
  217. package/src/extra/diagnostics/checks/state.ts +100 -7
  218. package/src/extra/diagnostics/checks/statics.ts +48 -0
  219. package/src/extra/diagnostics/checks/strict.ts +10 -67
  220. package/src/extra/diagnostics/checks/timers.ts +80 -0
  221. package/src/extra/diagnostics/checks/viewTransitions.ts +47 -0
  222. package/src/extra/diagnostics/checks/virtual.ts +73 -0
  223. package/src/extra/diagnostics/checks/widgets.ts +109 -0
  224. package/src/extra/diagnostics/checks/wiring.ts +66 -10
  225. package/src/extra/diagnostics/codes.ts +268 -23
  226. package/src/extra/diagnostics/index.ts +34 -9
  227. package/src/extra/diagnostics/legacy.ts +17 -0
  228. package/src/extra/driverFactories.ts +83 -19
  229. package/src/extra/elementCommands.ts +53 -0
  230. package/src/extra/fetchDriver.ts +519 -0
  231. package/src/extra/focusWithin.ts +27 -0
  232. package/src/extra/form.ts +268 -0
  233. package/src/extra/formHelpers.ts +139 -0
  234. package/src/extra/head.ts +106 -0
  235. package/src/extra/hmr.ts +2 -3
  236. package/src/extra/owned.ts +24 -0
  237. package/src/extra/pager.ts +50 -0
  238. package/src/extra/persist.ts +133 -0
  239. package/src/extra/pwa.ts +1 -1
  240. package/src/extra/queryCache.ts +107 -0
  241. package/src/extra/reducers.ts +1 -1
  242. package/src/extra/reduxDevtools.ts +92 -0
  243. package/src/extra/replies.ts +54 -0
  244. package/src/extra/router.ts +323 -0
  245. package/src/extra/run.ts +90 -217
  246. package/src/extra/selection.ts +76 -0
  247. package/src/extra/socketDriver.ts +265 -0
  248. package/src/extra/sortable.ts +377 -0
  249. package/src/extra/ssr.ts +427 -160
  250. package/src/extra/standardSchema.ts +27 -0
  251. package/src/extra/testing.ts +2433 -175
  252. package/src/extra/timers.ts +95 -0
  253. package/src/extra/undo.ts +261 -0
  254. package/src/extra/viewTransitions.ts +38 -0
  255. package/src/extra/virtual.ts +513 -0
  256. package/src/extra/widget.ts +201 -0
  257. package/src/index.d.ts +2631 -117
  258. package/src/index.ts +25 -4
  259. package/src/jsx-runtime.ts +15 -1
  260. package/src/jsx.ts +2 -1
  261. package/src/lazy.ts +50 -7
  262. package/src/portal.ts +3 -1
  263. package/src/pragma/index.ts +256 -133
  264. package/src/react-peers.d.ts +5 -0
  265. package/src/react.d.ts +43 -0
  266. package/src/react.ts +110 -0
  267. package/src/shared.ts +48 -0
  268. package/src/slot.ts +1 -1
  269. package/src/suspense.ts +3 -1
  270. package/src/switchable.ts +6 -121
  271. package/src/transition.ts +3 -1
  272. package/src/ui/accordion.ts +64 -0
  273. package/src/ui/dialog.ts +129 -0
  274. package/src/ui/disclosure.ts +35 -0
  275. package/src/ui/popover.ts +45 -0
  276. package/src/ui/shared.ts +94 -0
  277. package/src/ui/tabs.ts +82 -0
  278. package/src/ui/toaster.ts +198 -0
  279. package/src/ui/tooltip.ts +70 -0
  280. package/src/ui/zag/combobox.ts +115 -0
  281. package/src/ui/zag/menu.ts +40 -0
  282. package/src/ui/zag/select.ts +54 -0
  283. package/src/ui/zag/shared.ts +43 -0
  284. package/src/ui-combobox.d.ts +20 -0
  285. package/src/ui-combobox.ts +6 -0
  286. package/src/ui-menu.d.ts +30 -0
  287. package/src/ui-menu.ts +6 -0
  288. package/src/ui-select.d.ts +17 -0
  289. package/src/ui-select.ts +6 -0
  290. package/src/ui-zag-types.d.ts +42 -0
  291. package/src/ui.d.ts +208 -0
  292. package/src/ui.ts +15 -0
  293. package/src/vike/+config.ts +9 -1
  294. package/src/vike/ClientOnly.ts +1 -1
  295. package/src/vike/onRenderClient.ts +144 -96
  296. package/src/vike/onRenderHtml.ts +69 -26
  297. package/src/vike/types.ts +7 -1
  298. package/src/vite/globalthis-shim.ts +18 -0
  299. package/src/vite/globalthis.ts +56 -0
  300. package/src/vite/plugin.d.ts +22 -3
  301. package/src/vite/plugin.ts +222 -24
  302. package/src/zag.d.ts +64 -0
  303. package/src/zag.ts +238 -0
  304. package/dist/vike/+config.cjs.js +0 -66
  305. package/dist/vike/+config.cjs.js.map +0 -1
  306. package/dist/vike/+config.js.map +0 -1
  307. package/src/component.ts +0 -2301
  308. package/src/cycle/isolate/index.ts +0 -196
  309. package/src/cycle/run/index.ts +0 -151
  310. package/src/cycle/run/internals.ts +0 -143
  311. package/src/cycle/state/Collection.ts +0 -184
  312. package/src/cycle/state/index.ts +0 -5
  313. package/src/cycle/state/pickCombine.ts +0 -201
  314. package/src/cycle/state/pickMerge.ts +0 -122
  315. package/src/cycle/state/withState.ts +0 -47
  316. package/src/pragma/fn.ts +0 -55
@@ -0,0 +1,129 @@
1
+ /**
2
+ * PLAN-5 B-1 (3-D): `sortable` behavior diagnostics (dev entry; src/extra/sortable.ts carries no
3
+ * text). The behavior calls `__SYGNAL_DIAGNOSTICS__.sortable(code, ...args)`:
4
+ * 145 (item element, item selector, attr) a press or key on an item whose element has no
5
+ * `attr`: SYG145 (warn, once per selector); the event is ignored
6
+ * 146 (host root, item, handle, attr) the host's first pointerdown / keydown: SYG146 (warn,
7
+ * once per selector) when elements carry `attr` under the root but none matches `item`,
8
+ * or items exist but none contains `handle`. Returns true once it could decide (an empty
9
+ * list can't tell: the check runs again at the next interaction)
10
+ * 147 (from key, host state, uses key) when the host starts: a `from` key whose value isn't
11
+ * an array: SYG147 (warn, once per key)
12
+ * 435 (3-H G-449) (fn, uses key, from keys, idOf): the host's own items' ids as shown, per
13
+ * container (fn() returns [the item elements, the root]; the behavior only asks in dev), or nothing when
14
+ * every list has been reported (3-L G-478: no scan). A row's place is its `data-index`
15
+ * when it has one (VirtualCollection's pinned focused row is out of DOM order). (shown
16
+ * groups, host state, from keys, idField, uses key) at a keyboard pick-up or a pointer
17
+ * press: SYG435 (warn, once per uses key and list) when a list's shown items (those in
18
+ * the container holding most of them: an element elsewhere with a matching id is no list
19
+ * item; a row's container is its nearest ancestor holding another row, 3-O G-505) aren't
20
+ * one run of the array in its order (a Collection's sort, or a filter hiding entries
21
+ * between shown ones)
22
+ */
23
+ import {devReport, once, onced, suggest} from './shared'
24
+
25
+ const all = (root: any, sel: string): any[] => {
26
+ try { return [...root.querySelectorAll(sel)] } catch (_) { return [] }
27
+ }
28
+
29
+ export function reportSortable(code: number, a?: any, b?: any, c?: any, d?: any, e?: any): any {
30
+ if (code == 145) {
31
+ if (once(`SYG145:${b}:${c}`)) devReport('SYG145', {
32
+ component: 'sortable',
33
+ 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)`,
34
+ 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' })`,
35
+ data: {item: b, attr: c},
36
+ })
37
+ return
38
+ }
39
+ if (code == 146) {
40
+ const root = a, item = b, handle = c, attr = d
41
+ if (!root?.querySelectorAll) return true
42
+ const items = all(root, item).filter(el => el !== root)
43
+ if (!items.length) {
44
+ // nothing carries the id attribute either: an empty list (decide later)
45
+ if (!all(root, `[${attr}]`).length) return false
46
+ 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'`)
47
+ return true
48
+ }
49
+ if (handle && !items.some(el => all(el, handle).length)) {
50
+ 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`)
51
+ }
52
+ return true
53
+ }
54
+ if (code == 435) {
55
+ // (fn, uses key, from keys, idOf) the shown ids per container: computed only here (dev)
56
+ if (typeof a == 'function') {
57
+ if ((c || []).every((l: string) => onced(`SYG435:${b}:${l}`))) return
58
+ try {
59
+ const by = new Map<any, any[]>(), [els, root] = a(), n = new Map<any, number>()
60
+ // a row's place: its data-index (a VirtualCollection row below the host's root; its
61
+ // pinned row isn't in DOM order)
62
+ const rows = els.map((el: any) => {
63
+ const r0 = el.closest?.('[data-index]'), row = r0 && r0 !== root && root.contains?.(r0) ? r0 : el
64
+ return [el, row, Number(row === el && !r0 ? NaN : row.getAttribute('data-index'))]
65
+ })
66
+ // 3-O G-505: a row's container is its nearest ancestor holding another row (items each in
67
+ // their own wrapper, <li><div data-id>, a table cell, a Collection item's root, are still
68
+ // one list); `n` counts the rows below each ancestor
69
+ const up = (row: any, f: (p: any) => any) => { for (let p = row.parentElement; p; p = p === root ? null : p.parentElement) if (f(p)) return p }
70
+ for (const [, row] of rows) up(row, p => void n.set(p, (n.get(p) || 0) + 1))
71
+ for (const [el, row, at] of rows) {
72
+ const p = up(row, p => n.get(p)! > 1) || row.parentElement
73
+ by.has(p) || by.set(p, [])
74
+ by.get(p)!.push([isNaN(at) ? by.get(p)!.length : at, String(d(el))])
75
+ }
76
+ return [...by.values()].map(g => g.sort((x, y) => x[0] - y[0]).map(x => x[1]))
77
+ } catch (_) { return }
78
+ }
79
+ // (shown groups, host state, from keys, idField, uses key) at a keyboard pick-up or a press
80
+ const groups: string[][] = Array.isArray(a?.[0]) ? a : [a || []]
81
+ for (const l of c || []) {
82
+ if (onced(`SYG435:${e}:${l}`)) continue
83
+ const at = new Map<string, number>()
84
+ if (Array.isArray(b?.[l])) b[l].forEach((x: any, i: number) => at.set(String(x?.[d]), i))
85
+ // the list's container: the one holding most of its entries (others: stray matches)
86
+ let dom: number[] = []
87
+ for (const g of groups) {
88
+ const ix = g.filter(i => at.has(i)).map(i => at.get(i)!)
89
+ if (ix.length > dom.length) dom = ix
90
+ }
91
+ // a contiguous run in the array's order is fine (all of it, or a window of it)
92
+ if (!dom.some((v, j) => v !== dom[0] + j)) continue
93
+ if (once(`SYG435:${e}:${l}`)) devReport('SYG435', {
94
+ component: 'sortable',
95
+ 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`,
96
+ 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`,
97
+ data: {from: l, key: e},
98
+ })
99
+ }
100
+ return
101
+ }
102
+ if (code == 147) {
103
+ const st = b || {}, arrays = Object.keys(st).filter(k => Array.isArray(st[k]))
104
+ const near = suggest(a, arrays)
105
+ if (once(`SYG147:${c}:${a}`)) devReport('SYG147', {
106
+ component: 'sortable',
107
+ 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`,
108
+ 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}: [] })`}`,
109
+ data: {from: a, key: c},
110
+ })
111
+ }
112
+ }
113
+
114
+ const report146 = (option: string, selector: string, why: string, fix: string) => {
115
+ if (once(`SYG146:${option}:${selector}`)) devReport('SYG146', {
116
+ component: 'sortable',
117
+ message: `sortable: ${option} '${selector}' matches nothing: ${why}, so no item can be dragged`,
118
+ fix,
119
+ data: {option, selector},
120
+ })
121
+ }
122
+
123
+ /** Install the `sortable` hook on the core bridge (sortable.ts calls it). */
124
+ export function installSortableHooks(): () => void {
125
+ const c = (globalThis as any).__SYGNAL_DIAGNOSTICS__
126
+ if (!c) return () => {}
127
+ c.sortable = reportSortable
128
+ return () => { if (c.sortable === reportSortable) c.sortable = undefined }
129
+ }
@@ -1,37 +1,108 @@
1
1
  /**
2
2
  * SYG201 — STATE reducer dropped keys that existed in the previous state (warn)
3
3
  * SYG202 — STATE reducer returned undefined (warn)
4
+ * SYG221 — set() called with a string (error, G-143): the string is spread
5
+ * into the state as keys '0', '1', ... (one per character)
6
+ * SYG238 — G-393: a root with a model but no initialState (and no state from renderComponent's
7
+ * `initialState` or an HMR swap): its state is undefined, so it renders nothing until an
8
+ * action sets one (the 5.x / PLAN-4 behaviour; a root without a model renders from
9
+ * `initialState || true`, G-172). onModel, at construction, once per component name.
10
+ * SYG222 — STATE reducer returned the object it got after mutating it in place (warn, dev;
11
+ * PLAN-4 GS-4): since 6.0 the same object back means "no change", so the mutation
12
+ * is ignored. Mechanism: onIntent (called before the model is read) wraps the
13
+ * instance's STATE reducers; the wrapper takes a shallow snapshot (keys and top-level
14
+ * values) before the reducer runs and compares it when the reducer returns the same
15
+ * reference; a behavior slice (state[key] of a `uses` entry) is snapshotted the same
16
+ * way (keys reported as 'key.field', G-214). Nested mutations (state.list.push) are
17
+ * below the snapshot. Zero bytes
18
+ * in the core: the wrapping only happens with the dev entry and diagnostics on.
4
19
  *
5
20
  * Mechanism: onReducer(component, action, prevState, nextState). Reported once
6
21
  * per action per component name. Not reported for:
7
- * - synthetic actions (`__*`) and INITIALIZE/HYDRATE (whole-state replacement)
22
+ * - synthetic actions (`__*`) and INITIALIZE (whole-state replacement; HYDRATE is an
23
+ * ordinary action since 6.0, D66)
24
+ * - SYG202: a Collection item (`() => undefined` is its documented self-removal, G-357)
8
25
  * - keys that are calculated fields (they are re-derived after the reducer)
9
26
  * - keys whose previous value was undefined
10
27
  * The set()/toggle() helpers always spread the previous state, so they can't
11
28
  * drop keys and never trigger SYG201.
12
29
  */
13
30
  import type {DiagnosticCheck} from '../index'
14
- import {report, once, nameOf, isPlainObject} from './shared'
31
+ import {report, devReport, once, nameOf, isPlainObject} from './shared'
32
+ import {ORIGINAL} from '../../../shared'
15
33
 
16
- const SKIP = new Set(['INITIALIZE', 'HYDRATE'])
34
+ const SKIP = new Set(['INITIALIZE'])
35
+
36
+ // SYG222: the instance's STATE reducers, wrapped (see the header)
37
+ const WRAPPED = Symbol('SYG222')
38
+
39
+ export function watchMutation(component: any, action: string, fn: any): any {
40
+ if (typeof fn !== 'function' || fn[WRAPPED]) return fn
41
+ const wrapped: any = (state: any, ...rest: any[]) => {
42
+ if (!isPlainObject(state)) return fn(state, ...rest)
43
+ // the state's top level, and (G-214) each behavior slice's: a behavior reducer that mutates
44
+ // its slice gets the host state back unchanged (behaviors.ts), so state[key] looks the same
45
+ const snaps = [[state, ''], ...(component._uses || []).map(([k]: any) => [state[k], k + '.'])]
46
+ .filter(([o]) => isPlainObject(o)).map(([o, p]) => [o, p, Object.keys(o), Object.values(o)])
47
+ const out = fn(state, ...rest)
48
+ if (out === state) {
49
+ const changed = snaps.flatMap(([o, p, keys, values]: any) => keys.filter((k: string, i: number) => !(k in o) || o[k] !== values[i])
50
+ .concat(Object.keys(o).filter(k => !keys.includes(k))).map((k: string) => p + k))
51
+ const name = nameOf(component)
52
+ if (changed.length && once(`SYG222:${name}:${action}`)) {
53
+ const list = changed.map(k => `'${k}'`).join(', ')
54
+ devReport('SYG222', {
55
+ component,
56
+ 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`,
57
+ 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()`,
58
+ data: {action, keys: changed},
59
+ })
60
+ }
61
+ }
62
+ return out
63
+ }
64
+ wrapped[WRAPPED] = true
65
+ // checks that read reducer source (reply actions) see the original; so do the action log
66
+ // and t.explain() (ORIGINAL)
67
+ wrapped.toString = () => fn.toString()
68
+ wrapped[ORIGINAL] = fn
69
+ return wrapped
70
+ }
17
71
 
18
72
  export const stateCheck: DiagnosticCheck = {
19
73
  id: 'state',
20
74
 
75
+ // SYG222: checks/next.ts wraps the STATE reducers (watchMutation) through wrapHandler
76
+
77
+ onModel(component) {
78
+ // SYG238 (G-393): the initial state is set at construction (D165), before onModel
79
+ const n = component?.__next
80
+ if (!n?.isRoot || !n.def?.model || n.state !== undefined) return
81
+ const name = nameOf(component)
82
+ if (!once(`SYG238:${name}`)) return
83
+ devReport('SYG238', {
84
+ component,
85
+ message: `${name} is a root with a model but no initialState: its state is undefined, so it renders nothing until an action sets one`,
86
+ fix: `Add ${name}.initialState = { … } (the root's start state; a child gets its state from its parent instead)`,
87
+ data: {},
88
+ })
89
+ },
90
+
21
91
  onReducer(component, action, prevState, nextState) {
22
92
  if (typeof action !== 'string' || action.startsWith('__') || SKIP.has(action)) return
23
93
  const name = nameOf(component)
24
94
 
25
95
  if (nextState === undefined) {
26
- if (!once(`SYG202:${name}:${action}`)) return
27
- // Returning undefined from a Collection item's reducer is the documented way to remove
28
- // the item, so only the root (where it wipes the whole app state) is a warning.
96
+ // G-357: returning undefined from a Collection item's reducer is the documented way to
97
+ // remove the item: not reported. In another sub-component it is info, in the root (where
98
+ // it wipes the whole app state) a warning.
99
+ if (component?.__next?.kind === 'item' || !once(`SYG202:${name}:${action}`)) return
29
100
  const sub = !!component?.isSubComponent
30
101
  report('SYG202', {
31
102
  component,
32
103
  severity: sub ? 'info' : 'warn',
33
104
  message: `The STATE reducer for '${action}' returned undefined` +
34
- (sub ? ' (this removes the item if the component is a Collection item)' : ''),
105
+ (sub ? ' (only a Collection item removes itself this way)' : ''),
35
106
  fix: `Return the new state, e.g. (state, data) => ({ ...state, ... }), or return ABORT to leave the state unchanged`,
36
107
  data: {action},
37
108
  })
@@ -39,6 +110,28 @@ export const stateCheck: DiagnosticCheck = {
39
110
  }
40
111
 
41
112
  if (!isPlainObject(prevState) || !isPlainObject(nextState)) return
113
+
114
+ // SYG221 (G-143): set('city') spreads the string into the state: { ...state, 0: 'c', 1: 'i', ... }
115
+ const chars: string[] = []
116
+ while (typeof nextState[chars.length] === 'string' && nextState[chars.length].length === 1 && !(chars.length in prevState)) {
117
+ chars.push(nextState[chars.length])
118
+ }
119
+ if (chars.length > 0 && !(chars.length in nextState)) {
120
+ if (!once(`SYG221:${name}:${action}`)) return
121
+ const field = chars.join('')
122
+ const update = /^[A-Za-z_$][\w$]*$/.test(field)
123
+ ? `set((state, ${field}) => ({ ${field} }))`
124
+ : `set((state, value) => ({ ['${field}']: value }))`
125
+ devReport('SYG221', {
126
+ component,
127
+ message: `The STATE reducer for '${action}' added the keys ${chars.map((_, i) => `'${i}'`).slice(0, 4).join(', ')}${chars.length > 4 ? ', …' : ''} ` +
128
+ `(one per character of '${field}'): set() was called with a string, set('${field}'). set() takes an object or a function`,
129
+ fix: `To store the action data in '${field}', pass a function: ${update}. For a fixed value pass an object: set({ ${field}: … })`,
130
+ data: {action, field},
131
+ })
132
+ return
133
+ }
134
+
42
135
  const calculated: Set<string> | null = component?._calculatedFieldNames || null
43
136
  const dropped = Object.keys(prevState).filter(key =>
44
137
  !(key in nextState) && prevState[key] !== undefined && !(calculated && calculated.has(key)))
@@ -0,0 +1,48 @@
1
+ /**
2
+ * PLAN-4.5 D152: shared statics are frozen in dev (dev entry only; the core never freezes).
3
+ *
4
+ * Since P45-B a component's `initialState`, `model`, `context` and `calculated` statics are
5
+ * shared by all its instances (no per-instance deep copy). Mutating one in place (a reducer's
6
+ * `state.items.push(x)` on the initial state, a view that writes into state) would change every
7
+ * instance, and every later one. With diagnostics on, `initialState` is deep-frozen (its plain
8
+ * objects and arrays; class instances such as Map or Date are left as they are) and the other
9
+ * statics are frozen at the top level, so such a mutation throws a TypeError where it happens
10
+ * (in a reducer: reported as SYG216 with the error attached; in a view: SYG406).
11
+ *
12
+ * Frozen: the component function's own statics, and the options given to component() (G-280:
13
+ * `component({ view, model, initialState })`; the instance's `_o`, the values before behaviors or
14
+ * persist() made per-instance copies). Not frozen: the instance's values (G-268), and an
15
+ * initial state a wrapper built from its caller's objects (G-275: marked with `owned()` by
16
+ * sygnal/element (host props), Vike (pageContext.data, hydrated state) and renderComponent's
17
+ * `initialState` option; the caller may still change those). Mechanism: onIntent, which runs
18
+ * during construction, before the model is wired.
19
+ */
20
+ import type {DiagnosticCheck} from '../index'
21
+ import {OWNED} from '../../owned'
22
+
23
+ const plain = (v: any): boolean => {
24
+ if (!v || typeof v != 'object') return false
25
+ const p = Object.getPrototypeOf(v)
26
+ return Array.isArray(v) || p === Object.prototype || p === null
27
+ }
28
+
29
+ const deepFreeze = (v: any): void => {
30
+ if (!plain(v) || Object.isFrozen(v) || v[OWNED]) return
31
+ Object.freeze(v)
32
+ for (const k of Object.keys(v)) deepFreeze(v[k])
33
+ }
34
+
35
+ const freeze = (o: any): void => {
36
+ if (!o || (typeof o != 'object' && typeof o != 'function')) return
37
+ deepFreeze(o.initialState)
38
+ for (const k of ['model', 'context', 'calculated']) if (plain(o[k])) Object.freeze(o[k])
39
+ }
40
+
41
+ export const staticsCheck: DiagnosticCheck = {
42
+ id: 'statics',
43
+
44
+ onIntent(component) {
45
+ if (typeof component?.view == 'function') freeze(component.view)
46
+ freeze(component?._o)
47
+ },
48
+ }
@@ -13,13 +13,14 @@
13
13
  *
14
14
  * | Code | Rule (canonical form) | Runtime mechanism |
15
15
  * |--------|------------------------------------------|-----------------------------------------|
16
- * | SYG501 | C1 destructure the view's first argument | onModel: component.view.length > 1 |
17
- * | SYG502 | C3 return ABORT for "no change" | onReducer: returned the previous state |
18
- * | | | object (undefined stays SYG202) |
19
- * | SYG504 | C5 object form, no 'ACTION | SINK' keys | onModel: raw model keys containing '|' |
20
- * | SYG503, SYG505, SYG506, SYG507: static only (sygnal-check --strict); the
21
- * runtime can't tell emit() from { EVENTS }, a side effect from a pure
22
- * reducer, or see CHILD.select() arguments without a core hook.
16
+ * | SYG508 | reply actions, not select()/errors() | replies.ts: a request with a category |
17
+ * | | round trip (PLAN-3 §1.1) | the same instance select()ed on a |
18
+ * | | | reply-capable source |
19
+ *
20
+ * SYG501 (positional views) and SYG504 ('ACTION | SINK' keys) are forms Sygnal 6.0 removed
21
+ * (D164): at run time they are SYG612 (checks/next.ts, strict or not); `sygnal-check --strict`
22
+ * still reports them under their own codes, with --fix. SYG502 is retired (PLAN-4 GS-4). SYG503,
23
+ * SYG505, SYG506, SYG507: static only (sygnal-check --strict).
23
24
  *
24
25
  * Every finding is reported once per component name (+ action / key).
25
26
  * The SYG5xx severities are registered here (registerCodes) and passed
@@ -28,7 +29,7 @@
28
29
  import type {DiagnosticCheck} from '../index'
29
30
  import {CODE_TITLES, STRICT_CODE_SEVERITY, registerCodes} from '../codes'
30
31
  import type {DiagnosticSeverity} from '../codes'
31
- import {bridge, report, once, nameOf, isInternalAction} from './shared'
32
+ import {bridge} from './shared'
32
33
 
33
34
  registerCodes(Object.keys(STRICT_CODE_SEVERITY).map(code =>
34
35
  [code, STRICT_CODE_SEVERITY[code], CODE_TITLES[code]] as [string, DiagnosticSeverity, string]))
@@ -48,65 +49,7 @@ export function isStrictEnabled(): boolean {
48
49
  return flag === undefined ? (globalThis as any).__SYGNAL_STRICT__ === true : flag === true
49
50
  }
50
51
 
51
- const strictReport = (code: string, details: Parameters<typeof report>[1]) =>
52
- report(code, {severity: STRICT_CODE_SEVERITY[code], ...details})
53
-
52
+ /** The strict check's runtime half is SYG508 (replies.ts); this entry keeps the flag. */
54
53
  export const strictCheck: DiagnosticCheck = {
55
54
  id: 'strict',
56
-
57
- onModel(component) {
58
- if (!isStrictEnabled() || !component) return
59
- const name = nameOf(component)
60
-
61
- // SYG501 — view(props, state, context, peers): positional use of the 2nd+ args.
62
- // (A default value or a rest parameter stops Function.length, so this can
63
- // miss `(props, state = {})`, but it never flags a single-argument view.)
64
- const view = component.view
65
- if (typeof view === 'function' && view.length > 1 && !view.__sygnalLazy && once(`SYG501:${name}`)) {
66
- const extra = ['state', 'context', 'peers'].slice(0, view.length - 1)
67
- strictReport('SYG501', {
68
- component,
69
- message: `The view takes ${view.length} positional arguments (props, ${extra.join(', ')})`,
70
- fix: `Destructure the first argument instead: function ${name}({ ${extra.join(', ')}, ...props })`,
71
- data: {arity: view.length},
72
- })
73
- }
74
-
75
- // SYG504 — 'ACTION | SINK' shorthand keys
76
- const model = component.model
77
- if (!model || typeof model !== 'object') return
78
- for (const key of Object.keys(model)) {
79
- if (!key.includes('|')) continue
80
- const [action, sink] = key.split('|').map(s => s.trim())
81
- if (!action || !sink || !once(`SYG504:${name}:${key}`)) continue
82
- const rewrite = sink === 'EVENTS'
83
- ? `${action}: { EVENTS: event('TYPE', (state, data) => payload) }`
84
- : `${action}: { ${sink}: (state, data, next) => ... }`
85
- strictReport('SYG504', {
86
- component,
87
- message: `Model key '${key}' uses the 'ACTION | SINK' shorthand`,
88
- fix: `Use the object form: ${rewrite} (merge it into an existing '${action}' entry if there is one)`,
89
- data: {key, action, sink},
90
- })
91
- }
92
- },
93
-
94
- onReducer(component, action, prevState, nextState) {
95
- if (!isStrictEnabled() || isInternalAction(action)) return // incl. INITIALIZE/HYDRATE/DISPOSE
96
- // undefined is SYG202 (state check, every mode); only the identical object here.
97
- if (!prevState || typeof prevState !== 'object' || nextState === undefined) return
98
- let same = nextState === prevState
99
- if (!same && typeof component?.addCalculated === 'function') {
100
- // with calculated fields the reducer saw addCalculated(prev) (memoized: same object)
101
- try { same = component.addCalculated(prevState) === nextState } catch (_) {}
102
- }
103
- const name = nameOf(component)
104
- if (!same || !once(`SYG502:${name}:${action}`)) return
105
- strictReport('SYG502', {
106
- component,
107
- message: `The STATE reducer for '${action}' returned the unchanged state object`,
108
- fix: `Return ABORT for "no change": ${action}: (state, data) => cond ? { ...state, ... } : ABORT`,
109
- data: {action},
110
- })
111
- },
112
55
  }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Timers and declaration statics without a driver (PLAN-4 GS-7). Dev-entry-only codes
3
+ * (DEV_CODE_SEVERITY):
4
+ *
5
+ * SYG643 — a component declares a static that only a registered driver acts on (`timers`:
6
+ * makeTimerDriver(), `connections`: makeSocketDriver(), `resources`: makeFetchDriver(),
7
+ * `browser`: makeBrowserDriver())
8
+ * and no source of the app takes it (no `__sygnalStatic` marker for it), so nothing
9
+ * happens and nothing else says so. Also closes PLAN-3's "connections without a driver
10
+ * report nothing" gap. (`route` and `head` are left out: renderComponent asks for the
11
+ * router itself, and `head` is also collected by renderToString without a driver.)
12
+ * SYG422 — a timer spec makeTimerDriver() can't run (non-positive `every`, negative `after`,
13
+ * `every` with `after`, no action, `frame` that isn't an action name): it isn't started.
14
+ * The driver calls `__SYGNAL_DIAGNOSTICS__.timerSpec(name, spec, componentName)`, which
15
+ * installTimerHooks() sets.
16
+ */
17
+ import type {DiagnosticCheck} from '../index'
18
+ import {bridge, devReport, once, nameOf} from './shared'
19
+
20
+ const NEEDS: Record<string, string> = {
21
+ timers: 'makeTimerDriver()',
22
+ connections: 'makeSocketDriver()',
23
+ resources: 'makeFetchDriver()',
24
+ browser: 'makeBrowserDriver()',
25
+ }
26
+ const KEY: Record<string, string> = {timers: 'TIMER', connections: 'WS', resources: 'HTTP', browser: 'BROWSER'}
27
+
28
+ const brief = (v: any) => { try { return JSON.stringify(v) } catch (_) { return String(v) } }
29
+
30
+ /** why a timer spec can't run ('' when it can; mirrors timers.ts's valid()) */
31
+ export function timerSpecProblem(s: any): string {
32
+ if (!s || typeof s != 'object') return 'is not an object ({ every, action }, { after, action } or { frame: action })'
33
+ if ('frame' in s && s.frame) return typeof s.frame == 'string' ? '' : 'frame must be the action name (a string)'
34
+ if (!s.action || typeof s.action != 'string') return 'has no action (a string)'
35
+ if (s.every == null && s.after == null) return 'has neither every nor after'
36
+ if (s.every != null && s.after != null) return 'has both every and after'
37
+ if (s.every != null) return s.every > 0 && s.every < 1 / 0 ? '' : `every must be a positive number of ms (got ${brief(s.every)})`
38
+ return s.after >= 0 && s.after < 1 / 0 ? '' : `after must be a number of ms, 0 or more (got ${brief(s.after)})`
39
+ }
40
+
41
+ function checkSpec(name: string, spec: any, component?: string) {
42
+ const problem = timerSpecProblem(spec) || 'is invalid'
43
+ if (!once(`SYG422:${component}:${name}:${brief(spec)}`)) return
44
+ devReport('SYG422', {
45
+ component,
46
+ message: `Timer '${name}' ${problem}; it is not started`,
47
+ fix: `Declare ${name}: { every: 100, action: 'TICK' }, { after: 5000, action: 'EXPIRE' } or { frame: 'FRAME' } (falsy to stop it)`,
48
+ data: {name, spec},
49
+ })
50
+ }
51
+
52
+ /** install the hook makeTimerDriver() calls for an invalid spec (removed by the returned function) */
53
+ export function installTimerHooks(): () => void {
54
+ const core = bridge()
55
+ if (!core) return () => {}
56
+ core.timerSpec = checkSpec
57
+ return () => { if (core.timerSpec === checkSpec) core.timerSpec = undefined }
58
+ }
59
+
60
+ export const timersCheck: DiagnosticCheck = {
61
+ id: 'timers',
62
+
63
+ onModel(component) {
64
+ const sources = component?.sources, view = component?.view
65
+ if (!sources || typeof sources != 'object' || !view) return
66
+ const names = component.sourceNames || Object.keys(sources)
67
+ for (const k in NEEDS) {
68
+ if (view[k] == null) continue
69
+ if (names.some((n: string) => sources[n]?.__sygnalStatic === k)) continue
70
+ const name = nameOf(component)
71
+ if (!once(`SYG643:${name}:${k}`)) continue
72
+ devReport('SYG643', {
73
+ component,
74
+ 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'}`,
75
+ fix: `Register the driver: run(App, { ${KEY[k]}: ${NEEDS[k]} })`,
76
+ data: {static: k, driver: NEEDS[k]},
77
+ })
78
+ }
79
+ },
80
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * View Transitions (PLAN-4 GS-12, D129). Dev-entry-only code (DEV_CODE_SEVERITY):
3
+ *
4
+ * SYG645 — a component declares `viewTransitions`, but its app's DOM driver can't run them (run()'s
5
+ * default makeDOMDriver, not makeViewTransitionDOMDriver()), so its actions patch at once
6
+ * and nothing else says so. The helper marks its IsolateModule (`vtDriver`). Not reported
7
+ * for renderComponent's mock DOM (no IsolateModule) or its `dom: 'real'` container
8
+ * (`.sygnal-test`): tests don't animate.
9
+ * Also (G-228): the static is not an array (`true`, `'MOVE'`): no action is listed (the core
10
+ * reads it with `includes?.()`, so a string matches substrings; anything else matches none).
11
+ */
12
+ import type {DiagnosticCheck} from '../index'
13
+ import {devReport, once, nameOf} from './shared'
14
+
15
+ export const viewTransitionsCheck: DiagnosticCheck = {
16
+ id: 'viewTransitions',
17
+
18
+ onModel(component) {
19
+ const list = component?.view?.viewTransitions
20
+ if (list == null) return
21
+ if (!Array.isArray(list)) {
22
+ const name = nameOf(component)
23
+ if (!once(`SYG645:${name}`)) return
24
+ const actions = Object.keys(component.model || {}).filter(a => !a.includes('|'))
25
+ devReport('SYG645', {
26
+ component,
27
+ message: `${name}.viewTransitions is not an array (it is ${typeof list == 'string' ? `'${list}'` : typeof list}), so no action is listed for a View Transition`,
28
+ 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)`,
29
+ data: {value: list},
30
+ })
31
+ return
32
+ }
33
+ if (!list.length) return
34
+ const im = component.sources?.[component.DOMSourceName || 'DOM']?._isolateModule
35
+ // the core's DOM source proxy answers any unknown key with an event shorthand (a function)
36
+ if (typeof im != 'object' || !im || im.vtDriver) return
37
+ if (typeof document != 'undefined' && document.querySelector?.('.sygnal-test')) return
38
+ const name = nameOf(component)
39
+ if (!once(`SYG645:${name}`)) return
40
+ devReport('SYG645', {
41
+ component,
42
+ message: `${name} declares ${name}.viewTransitions (${list.map((a: any) => `'${a}'`).join(', ')}), but the app's DOM driver can't run View Transitions, so those actions patch without one`,
43
+ fix: `Give run() the View Transition DOM driver: run(App, { DOM: makeViewTransitionDOMDriver('#root') })`,
44
+ data: {actions: list},
45
+ })
46
+ },
47
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * PLAN-5 V-1: `<VirtualCollection>` diagnostics (dev entry; src/extra/virtual.ts carries no text).
3
+ *
4
+ * The VirtualCollection host calls `__SYGNAL_DIAGNOSTICS__.virtual(code, owner, extra)`:
5
+ * 430 the container has no bounded height: `extra.reason` 'height' (laid out 0 px tall) or
6
+ * 'grows' (as tall as all its rows and taller than the viewport, so it would render every
7
+ * row); the window is clamped to the viewport (once per host and reason)
8
+ * 431 items without `id` (index keys; once per host)
9
+ * 432 an item renders a fragment or text, not one element (once per host)
10
+ * 433 scrollToIndex / scrollToId for an index or id not in the (filtered) list
11
+ * 434 estimateSize / overscan not a positive number (or a function, for estimateSize)
12
+ */
13
+ import {devReport, nameOf} from './shared'
14
+
15
+ const tag = (owner: any) => `<VirtualCollection> in ${nameOf(owner)}`
16
+
17
+ export function reportVirtual(code: number, owner: any, x: any = {}): any {
18
+ const component = owner ? nameOf(owner) : 'VirtualCollection'
19
+ if (code == 430) {
20
+ const grows = x.reason == 'grows'
21
+ return devReport('SYG430', {
22
+ component,
23
+ message: grows
24
+ ? `${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`
25
+ : `${tag(owner)} is 0 px tall, so no row is in view`,
26
+ 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)',
27
+ data: x,
28
+ })
29
+ }
30
+ if (code == 431) {
31
+ return devReport('SYG431', {
32
+ component,
33
+ 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`,
34
+ fix: "Give every item a unique id (e.g. rows: data.map((r, i) => ({ id: r.key ?? i, ...r })))",
35
+ data: x,
36
+ })
37
+ }
38
+ if (code == 432) {
39
+ return devReport('SYG432', {
40
+ component,
41
+ 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`,
42
+ fix: `Make ${x.component}'s view return one element (wrap its content in a <div> or <li>)`,
43
+ data: x,
44
+ })
45
+ }
46
+ if (code == 433) {
47
+ const what = 'id' in x ? `id ${JSON.stringify(x.id) ?? String(x.id)}` : `index ${x.index}`
48
+ return devReport('SYG433', {
49
+ component,
50
+ 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`,
51
+ fix: 'id' in x
52
+ ? 'Send the id of an item in the array the filter keeps: { scrollToId: \'.rows\', id }'
53
+ : `Send an integer index from 0 to ${Math.max(0, x.count - 1)} into the shown (filtered, sorted) rows: { scrollToIndex: '.rows', index }`,
54
+ data: x,
55
+ })
56
+ }
57
+ if (code == 434) {
58
+ return devReport('SYG434', {
59
+ component,
60
+ 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'}`,
61
+ 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',
62
+ data: x,
63
+ })
64
+ }
65
+ }
66
+
67
+ /** Publish the VirtualCollection hook on the core bridge. Returns an uninstall function. */
68
+ export function installVirtualHooks(): () => void {
69
+ const c = (globalThis as any).__SYGNAL_DIAGNOSTICS__
70
+ if (!c) return () => {}
71
+ c.virtual = reportVirtual
72
+ return () => { if (c.virtual === reportVirtual) c.virtual = undefined }
73
+ }