sygnal 5.3.7 → 5.4.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 (226) hide show
  1. package/README.md +53 -38
  2. package/dist/astro/client.cjs.js +18 -8106
  3. package/dist/astro/client.cjs.js.map +1 -0
  4. package/dist/astro/client.mjs +17 -8105
  5. package/dist/astro/client.mjs.map +1 -0
  6. package/dist/astro/index.cjs.js +768 -9
  7. package/dist/astro/index.cjs.js.map +1 -0
  8. package/dist/astro/index.mjs +768 -9
  9. package/dist/astro/index.mjs.map +1 -0
  10. package/dist/astro/server.cjs.js +4 -1
  11. package/dist/astro/server.cjs.js.map +1 -0
  12. package/dist/astro/server.mjs +4 -1
  13. package/dist/astro/server.mjs.map +1 -0
  14. package/dist/diagnostics.cjs.js +1297 -0
  15. package/dist/diagnostics.cjs.js.map +1 -0
  16. package/dist/diagnostics.esm.js +1267 -0
  17. package/dist/diagnostics.esm.js.map +1 -0
  18. package/dist/index.cjs.js +2981 -1386
  19. package/dist/index.cjs.js.map +1 -0
  20. package/dist/index.d.ts +848 -111
  21. package/dist/index.esm.js +2997 -1411
  22. package/dist/index.esm.js.map +1 -0
  23. package/dist/jsx-dev-runtime.cjs.js +15 -9
  24. package/dist/jsx-dev-runtime.cjs.js.map +1 -0
  25. package/dist/jsx-dev-runtime.esm.js +15 -9
  26. package/dist/jsx-dev-runtime.esm.js.map +1 -0
  27. package/dist/jsx-runtime.cjs.js +15 -9
  28. package/dist/jsx-runtime.cjs.js.map +1 -0
  29. package/dist/jsx-runtime.esm.js +15 -9
  30. package/dist/jsx-runtime.esm.js.map +1 -0
  31. package/dist/jsx.cjs.js +15 -9
  32. package/dist/jsx.cjs.js.map +1 -0
  33. package/dist/jsx.esm.js +15 -9
  34. package/dist/jsx.esm.js.map +1 -0
  35. package/dist/sygnal.min.js +2 -1
  36. package/dist/sygnal.min.js.map +1 -0
  37. package/dist/vike/+config.cjs.js +1 -0
  38. package/dist/vike/+config.cjs.js.map +1 -0
  39. package/dist/vike/+config.js +1 -0
  40. package/dist/vike/+config.js.map +1 -0
  41. package/dist/vike/ClientOnly.cjs.js +1 -0
  42. package/dist/vike/ClientOnly.cjs.js.map +1 -0
  43. package/dist/vike/ClientOnly.mjs +1 -0
  44. package/dist/vike/ClientOnly.mjs.map +1 -0
  45. package/dist/vike/onRenderClient.cjs.js +1 -0
  46. package/dist/vike/onRenderClient.cjs.js.map +1 -0
  47. package/dist/vike/onRenderClient.mjs +1 -0
  48. package/dist/vike/onRenderClient.mjs.map +1 -0
  49. package/dist/vike/onRenderHtml.cjs.js +1 -0
  50. package/dist/vike/onRenderHtml.cjs.js.map +1 -0
  51. package/dist/vike/onRenderHtml.mjs +1 -0
  52. package/dist/vike/onRenderHtml.mjs.map +1 -0
  53. package/dist/vite/plugin.cjs.js +670 -44
  54. package/dist/vite/plugin.cjs.js.map +1 -0
  55. package/dist/vite/plugin.mjs +670 -44
  56. package/dist/vite/plugin.mjs.map +1 -0
  57. package/llms.txt +250 -0
  58. package/package.json +15 -6
  59. package/src/astro/client.ts +13 -2
  60. package/src/astro/index.d.ts +24 -1
  61. package/src/astro/index.ts +40 -9
  62. package/src/astro/server.ts +3 -1
  63. package/src/collection.ts +5 -2
  64. package/src/component.ts +375 -330
  65. package/src/cycle/dom/MainDOMSource.ts +4 -0
  66. package/src/cycle/dom/classNameModule.ts +74 -0
  67. package/src/cycle/dom/controlledInputModule.ts +33 -0
  68. package/src/cycle/dom/enrichEventStream.ts +9 -3
  69. package/src/cycle/dom/makeDOMDriver.ts +1 -2
  70. package/src/cycle/dom/mockDOMSource.ts +62 -8
  71. package/src/cycle/dom/modules.ts +5 -1
  72. package/src/cycle/state/Collection.ts +13 -3
  73. package/src/cycle/state/StateSource.ts +1 -1
  74. package/src/cycle/state/pickCombine.ts +35 -1
  75. package/src/cycle/state/withState.ts +1 -1
  76. package/src/extra/devtools.ts +8 -0
  77. package/src/extra/diagnostics/checks/collections.ts +67 -0
  78. package/src/extra/diagnostics/checks/dom.ts +241 -0
  79. package/src/extra/diagnostics/checks/events.ts +68 -0
  80. package/src/extra/diagnostics/checks/index.ts +109 -0
  81. package/src/extra/diagnostics/checks/inspect.ts +319 -0
  82. package/src/extra/diagnostics/checks/props.ts +51 -0
  83. package/src/extra/diagnostics/checks/public.d.ts +211 -0
  84. package/src/extra/diagnostics/checks/rxjsHints.ts +88 -0
  85. package/src/extra/diagnostics/checks/shared.ts +119 -0
  86. package/src/extra/diagnostics/checks/state.ts +55 -0
  87. package/src/extra/diagnostics/checks/strict.ts +112 -0
  88. package/src/extra/diagnostics/checks/wiring.ts +65 -0
  89. package/src/extra/diagnostics/codes.ts +232 -0
  90. package/src/extra/diagnostics/index.ts +328 -0
  91. package/src/extra/diagnostics/legacy.ts +59 -0
  92. package/src/extra/driverFactories.ts +106 -58
  93. package/src/extra/eventDriver.ts +4 -0
  94. package/src/extra/flatten.ts +75 -0
  95. package/src/extra/reducers.ts +12 -5
  96. package/src/extra/run.ts +11 -0
  97. package/src/extra/testing.ts +890 -180
  98. package/src/extra/xstreamExtras.ts +269 -0
  99. package/src/index.d.ts +428 -17
  100. package/src/index.ts +7 -6
  101. package/src/pragma/index.ts +14 -9
  102. package/src/switchable.ts +10 -8
  103. package/src/vike/types.ts +21 -4
  104. package/src/vite/plugin.d.ts +95 -2
  105. package/src/vite/plugin.ts +720 -54
  106. package/dist/astro/astro/client.d.ts +0 -5
  107. package/dist/astro/astro/index.d.ts +0 -16
  108. package/dist/astro/astro/server.d.ts +0 -12
  109. package/dist/astro/client.d.ts +0 -5
  110. package/dist/astro/client.esm.js +0 -4665
  111. package/dist/astro/collection.d.ts +0 -12
  112. package/dist/astro/component.d.ts +0 -22
  113. package/dist/astro/cycle/dom/BodyDOMSource.d.ts +0 -10
  114. package/dist/astro/cycle/dom/DOMSource.d.ts +0 -11
  115. package/dist/astro/cycle/dom/DocumentDOMSource.d.ts +0 -12
  116. package/dist/astro/cycle/dom/ElementFinder.d.ts +0 -8
  117. package/dist/astro/cycle/dom/EventDelegator.d.ts +0 -34
  118. package/dist/astro/cycle/dom/IsolateModule.d.ts +0 -23
  119. package/dist/astro/cycle/dom/MainDOMSource.d.ts +0 -32
  120. package/dist/astro/cycle/dom/PriorityQueue.d.ts +0 -7
  121. package/dist/astro/cycle/dom/ScopeChecker.d.ts +0 -9
  122. package/dist/astro/cycle/dom/SymbolTree.d.ts +0 -9
  123. package/dist/astro/cycle/dom/VNodeWrapper.d.ts +0 -8
  124. package/dist/astro/cycle/dom/enrichEventStream.d.ts +0 -24
  125. package/dist/astro/cycle/dom/fromEvent.d.ts +0 -6
  126. package/dist/astro/cycle/dom/index.d.ts +0 -8
  127. package/dist/astro/cycle/dom/isolate.d.ts +0 -9
  128. package/dist/astro/cycle/dom/makeDOMDriver.d.ts +0 -11
  129. package/dist/astro/cycle/dom/mockDOMSource.d.ts +0 -17
  130. package/dist/astro/cycle/dom/modules.d.ts +0 -5
  131. package/dist/astro/cycle/dom/snabbdom.d.ts +0 -22
  132. package/dist/astro/cycle/dom/styleModule.d.ts +0 -13
  133. package/dist/astro/cycle/dom/thunk.d.ts +0 -11
  134. package/dist/astro/cycle/dom/utils.d.ts +0 -8
  135. package/dist/astro/cycle/isolate/index.d.ts +0 -42
  136. package/dist/astro/cycle/run/adapt.d.ts +0 -6
  137. package/dist/astro/cycle/run/index.d.ts +0 -36
  138. package/dist/astro/cycle/run/internals.d.ts +0 -8
  139. package/dist/astro/cycle/run/types.d.ts +0 -49
  140. package/dist/astro/cycle/state/Collection.d.ts +0 -29
  141. package/dist/astro/cycle/state/StateSource.d.ts +0 -20
  142. package/dist/astro/cycle/state/index.d.ts +0 -5
  143. package/dist/astro/cycle/state/pickCombine.d.ts +0 -3
  144. package/dist/astro/cycle/state/pickMerge.d.ts +0 -3
  145. package/dist/astro/cycle/state/types.d.ts +0 -18
  146. package/dist/astro/cycle/state/withState.d.ts +0 -17
  147. package/dist/astro/extra/classes.d.ts +0 -7
  148. package/dist/astro/extra/devtools.d.ts +0 -78
  149. package/dist/astro/extra/dragDriver.d.ts +0 -20
  150. package/dist/astro/extra/driverFactories.d.ts +0 -12
  151. package/dist/astro/extra/eventDriver.d.ts +0 -9
  152. package/dist/astro/extra/exactState.d.ts +0 -1
  153. package/dist/astro/extra/hmr.d.ts +0 -17
  154. package/dist/astro/extra/logDriver.d.ts +0 -2
  155. package/dist/astro/extra/processDrag.d.ts +0 -19
  156. package/dist/astro/extra/processForm.d.ts +0 -15
  157. package/dist/astro/extra/run.d.ts +0 -13
  158. package/dist/astro/extra/xstreamCompat.d.ts +0 -5
  159. package/dist/astro/index.d.ts +0 -19
  160. package/dist/astro/index.esm.js +0 -27
  161. package/dist/astro/jsx-dev-runtime.d.ts +0 -1
  162. package/dist/astro/jsx-runtime.d.ts +0 -4
  163. package/dist/astro/jsx.d.ts +0 -2
  164. package/dist/astro/pragma/fn.d.ts +0 -7
  165. package/dist/astro/pragma/index.d.ts +0 -7
  166. package/dist/astro/pragma/is.d.ts +0 -10
  167. package/dist/astro/server.d.ts +0 -12
  168. package/dist/astro/server.esm.js +0 -25
  169. package/dist/astro/switchable.d.ts +0 -7
  170. package/dist/collection.d.ts +0 -12
  171. package/dist/component.d.ts +0 -22
  172. package/dist/cycle/dom/BodyDOMSource.d.ts +0 -10
  173. package/dist/cycle/dom/DOMSource.d.ts +0 -11
  174. package/dist/cycle/dom/DocumentDOMSource.d.ts +0 -12
  175. package/dist/cycle/dom/ElementFinder.d.ts +0 -8
  176. package/dist/cycle/dom/EventDelegator.d.ts +0 -34
  177. package/dist/cycle/dom/IsolateModule.d.ts +0 -23
  178. package/dist/cycle/dom/MainDOMSource.d.ts +0 -32
  179. package/dist/cycle/dom/PriorityQueue.d.ts +0 -7
  180. package/dist/cycle/dom/ScopeChecker.d.ts +0 -9
  181. package/dist/cycle/dom/SymbolTree.d.ts +0 -9
  182. package/dist/cycle/dom/VNodeWrapper.d.ts +0 -8
  183. package/dist/cycle/dom/enrichEventStream.d.ts +0 -24
  184. package/dist/cycle/dom/fromEvent.d.ts +0 -6
  185. package/dist/cycle/dom/index.d.ts +0 -8
  186. package/dist/cycle/dom/isolate.d.ts +0 -9
  187. package/dist/cycle/dom/makeDOMDriver.d.ts +0 -11
  188. package/dist/cycle/dom/mockDOMSource.d.ts +0 -17
  189. package/dist/cycle/dom/modules.d.ts +0 -5
  190. package/dist/cycle/dom/snabbdom.d.ts +0 -22
  191. package/dist/cycle/dom/styleModule.d.ts +0 -13
  192. package/dist/cycle/dom/thunk.d.ts +0 -11
  193. package/dist/cycle/dom/utils.d.ts +0 -8
  194. package/dist/cycle/isolate/index.d.ts +0 -42
  195. package/dist/cycle/run/adapt.d.ts +0 -6
  196. package/dist/cycle/run/index.d.ts +0 -36
  197. package/dist/cycle/run/internals.d.ts +0 -8
  198. package/dist/cycle/run/types.d.ts +0 -49
  199. package/dist/cycle/state/Collection.d.ts +0 -29
  200. package/dist/cycle/state/StateSource.d.ts +0 -20
  201. package/dist/cycle/state/index.d.ts +0 -5
  202. package/dist/cycle/state/pickCombine.d.ts +0 -3
  203. package/dist/cycle/state/pickMerge.d.ts +0 -3
  204. package/dist/cycle/state/types.d.ts +0 -18
  205. package/dist/cycle/state/withState.d.ts +0 -17
  206. package/dist/extra/classes.d.ts +0 -7
  207. package/dist/extra/devtools.d.ts +0 -78
  208. package/dist/extra/dragDriver.d.ts +0 -20
  209. package/dist/extra/driverFactories.d.ts +0 -12
  210. package/dist/extra/eventDriver.d.ts +0 -9
  211. package/dist/extra/exactState.d.ts +0 -1
  212. package/dist/extra/hmr.d.ts +0 -17
  213. package/dist/extra/logDriver.d.ts +0 -2
  214. package/dist/extra/processDrag.d.ts +0 -19
  215. package/dist/extra/processForm.d.ts +0 -15
  216. package/dist/extra/run.d.ts +0 -13
  217. package/dist/extra/xstreamCompat.d.ts +0 -5
  218. package/dist/jsx-dev-runtime.d.ts +0 -1
  219. package/dist/jsx-dev-runtime.js +0 -224
  220. package/dist/jsx-runtime.d.ts +0 -4
  221. package/dist/jsx-runtime.js +0 -224
  222. package/dist/jsx.d.ts +0 -2
  223. package/dist/pragma/fn.d.ts +0 -7
  224. package/dist/pragma/index.d.ts +0 -7
  225. package/dist/pragma/is.d.ts +0 -10
  226. package/dist/switchable.d.ts +0 -7
package/src/component.ts CHANGED
@@ -6,17 +6,12 @@ import {init as snabbdomInit} from './cycle/dom/snabbdom';
6
6
  import defaultModules from './cycle/dom/modules';
7
7
  import {makeCommandSource} from './extra/command';
8
8
  import type {Command} from './extra/command';
9
+ // [diagnostics hook] shared diagnostics core — hooks are no-ops when diagnostics are off
10
+ import * as diag from './extra/diagnostics/index';
11
+ import {warn, error as logError, fail} from './extra/diagnostics/legacy';
9
12
 
10
- import xs, {Stream, resolveInteropDefault} from './extra/xstreamCompat';
11
- import * as delayModule from 'xstream/extra/delay.js';
12
- import * as concatModule from 'xstream/extra/concat.js';
13
- import * as debounceModule from 'xstream/extra/debounce.js';
14
- import * as dropRepeatsModule from 'xstream/extra/dropRepeats.js';
15
-
16
- const delay = resolveInteropDefault(delayModule);
17
- const concat = resolveInteropDefault(concatModule);
18
- const debounce = resolveInteropDefault(debounceModule);
19
- const dropRepeats = resolveInteropDefault(dropRepeatsModule);
13
+ import xs, {Stream} from './extra/xstreamCompat';
14
+ import {delay, concat, debounce, dropRepeats} from './extra/xstreamExtras';
20
15
 
21
16
  declare var process: { env: Record<string, any> };
22
17
 
@@ -31,8 +26,14 @@ const PARENT_SINK_NAME = 'PARENT';
31
26
  const CHILD_SOURCE_NAME = 'CHILD';
32
27
  const READY_SINK_NAME = 'READY';
33
28
  const EFFECT_SINK_NAME = 'EFFECT';
29
+ // fix hint for coded messages that carry the caught error (printed after the text, or diagnostic.data)
30
+ const ERR_FIX = 'See the attached error'
31
+ // B-003: the state an action's non-STATE sinks see (stamped by initModel$)
32
+ const STATE_SNAPSHOT = Symbol('sygnal.stateSnapshot');
34
33
 
35
34
  let COMPONENT_COUNT = 0;
35
+ // 1H-1: STATE reducers emitted but not applied yet (in any component)
36
+ let pendingReducers = 0;
36
37
 
37
38
  function wrapDOMSource(domSource: any): any {
38
39
  return new Proxy(domSource, {
@@ -68,9 +69,7 @@ function normalizeCalculatedEntry(field: string, entry: any): {fn: (...args: any
68
69
  && Array.isArray(entry[0]) && typeof entry[1] === 'function') {
69
70
  return { fn: entry[1], deps: entry[0] }
70
71
  }
71
- throw new Error(
72
- `Invalid calculated field '${field}': expected a function or [depsArray, function]`
73
- )
72
+ fail('SYG206', undefined, `Invalid calculated field '${field}'`, 'Use fn or [deps, fn]')
74
73
  }
75
74
 
76
75
  export interface ComponentOptions {
@@ -100,7 +99,7 @@ export default function component(opts: ComponentOptions): any {
100
99
  const { name, sources, isolateOpts, stateSourceName='STATE' } = opts
101
100
 
102
101
  if (sources && !isObj(sources)) {
103
- throw new Error(`[${name}] Sources must be a Cycle.js sources object`)
102
+ fail('SYG601', name, 'Invalid sources', 'Pass sources from run()')
104
103
  }
105
104
 
106
105
  let fixedIsolateOpts
@@ -197,15 +196,17 @@ class Component {
197
196
  _calculatedOrder: Array<[string, {fn: (...args: any[]) => any; deps: string[] | null}]> | null;
198
197
  _calculatedFieldCache: Record<string, {lastDepValues: any; lastResult: any}> | null;
199
198
  _subscriptions: any[];
199
+ _processedChildren$: any;
200
200
  _disposeListener: any;
201
201
  _dispose$: any;
202
+ _disposed?: boolean;
202
203
  _activeSubComponents: Map<string, any>;
203
204
  _childReadyState: Record<string, boolean>;
204
205
  _readyChanged$: any;
205
206
  _readyChangedListener: any;
206
207
 
207
208
  constructor({name = 'NO NAME', sources, intent, model, hmrActions, context, response, view, peers = {}, components = {}, initialState, calculated, storeCalculatedInState = true, DOMSourceName = 'DOM', stateSourceName = 'STATE', requestSourceName = 'HTTP', isolatedState = false, onError, debug = false}: ComponentOptions) {
208
- if (!sources || !isObj(sources)) throw new Error(`[${name}] Missing or invalid sources`)
209
+ if (!sources || !isObj(sources)) fail('SYG601', name, 'Missing or invalid sources', 'Pass sources from run()')
209
210
 
210
211
  this._componentNumber = COMPONENT_COUNT++
211
212
 
@@ -235,10 +236,7 @@ class Component {
235
236
  && isObj(this.calculated) && isObj(this.initialState)) {
236
237
  for (const key of Object.keys(this.calculated)) {
237
238
  if (key in this.initialState) {
238
- console.warn(
239
- `[${name}] Calculated field '${key}' shadows a key in initialState. ` +
240
- `The initialState value will be overwritten on every state update.`
241
- )
239
+ warn('SYG207', name, `Calculated field '${key}' overwrites the initialState key of the same name`, 'Rename one of them')
242
240
  }
243
241
  }
244
242
  }
@@ -261,10 +259,7 @@ class Component {
261
259
  for (const dep of deps) {
262
260
  if (!this._calculatedFieldNames.has(dep)
263
261
  && this.initialState && !(dep in this.initialState)) {
264
- console.warn(
265
- `[${name}] Calculated field '${field}' declares dependency '${dep}' ` +
266
- `which is not in initialState or calculated fields`
267
- )
262
+ warn('SYG208', name, `Calculated field '${field}' depends on unknown key '${dep}'`, 'Add it to initialState')
268
263
  }
269
264
  }
270
265
  }
@@ -328,7 +323,7 @@ class Component {
328
323
  traceCycle(inCycle[0])
329
324
  const start = path[path.length - 1]
330
325
  const cycle = path.slice(path.indexOf(start))
331
- throw new Error(`Circular calculated dependency: ${cycle.join(' \u2192 ')}`)
326
+ fail('SYG209', name, `Circular calculated dependency: ${cycle.join(' \u2192 ')}`, 'Break the cycle')
332
327
  }
333
328
 
334
329
  this._calculatedOrder = sorted.map(f => [f, this._calculatedNormalized![f]])
@@ -378,17 +373,20 @@ class Component {
378
373
 
379
374
  const children$ = sources.children$
380
375
  if (children$) {
381
- this.sources.children$ = children$.map((val: any) => {
376
+ // Process children and extract slots once, share across subscribers
377
+ this._processedChildren$ = children$.map((val: any) => {
382
378
  if (Array.isArray(val)) {
383
379
  const { slots, defaultChildren } = extractSlots(val)
384
380
  this.currentSlots = slots
385
381
  this.currentChildren = defaultChildren
382
+ return { children: defaultChildren, slots }
386
383
  } else {
387
384
  this.currentSlots = {}
388
385
  this.currentChildren = val
386
+ return { children: val, slots: {} }
389
387
  }
390
- return val
391
388
  })
389
+ this.sources.children$ = this._processedChildren$.map((p: any) => p.children)
392
390
  }
393
391
 
394
392
  if (this.sources[DOMSourceName]) {
@@ -453,6 +451,10 @@ class Component {
453
451
  }
454
452
 
455
453
  dispose(): void {
454
+ if (this._disposed) return
455
+ this._disposed = true
456
+ // [diagnostics hook]
457
+ diag.onDispose(this)
456
458
  if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
457
459
  window.__SYGNAL_DEVTOOLS__.onComponentDisposed(this._componentNumber, this.name)
458
460
  }
@@ -471,7 +473,11 @@ class Component {
471
473
  } catch (_) {}
472
474
  this._disposeListener = null
473
475
  }
474
- // Tear down streams on next microtask to allow DISPOSE/cleanup actions to process
476
+ // Dispose the sub-components now (R3), so the whole subtree's DISPOSE actions and
477
+ // onDispose hooks run within this call (e.g. before renderComponent restores diagnostics)
478
+ this._activeSubComponents.forEach((entry) => entry?.sink$?.__dispose?.())
479
+ this._activeSubComponents.clear()
480
+ // Tear down streams on next macrotask to allow DISPOSE/cleanup actions to process
475
481
  setTimeout(() => {
476
482
  // Complete the action$ stream to stop the entire component cycle
477
483
  if (this.action$ && typeof this.action$.shamefullySendComplete === 'function') {
@@ -488,11 +494,6 @@ class Component {
488
494
  }
489
495
  }
490
496
  this._subscriptions = []
491
- // Dispose any active sub-components
492
- this._activeSubComponents.forEach((entry) => {
493
- if (entry?.sink$?.__dispose) entry.sink$.__dispose()
494
- })
495
- this._activeSubComponents.clear()
496
497
  }, 0)
497
498
  }
498
499
 
@@ -502,17 +503,22 @@ class Component {
502
503
 
503
504
  initIntent$(): void {
504
505
  if (!this.intent) {
506
+ // [diagnostics hook]
507
+ diag.onIntent(this, [], undefined)
505
508
  return
506
509
  }
507
510
  if (typeof this.intent != 'function') {
508
- throw new Error(`[${this.name}] Intent must be a function`)
511
+ fail('SYG602', this, 'intent must be a function', 'Use intent = sources => ({ ACTION: stream$ })')
509
512
  }
510
513
 
511
514
  this.intent$ = this.intent(this.sources)
512
515
 
513
516
  if (!(this.intent$ instanceof Stream) && (!isObj(this.intent$))) {
514
- throw new Error(`[${this.name}] Intent must return either an action$ stream or map of event streams`)
517
+ fail('SYG603', this, 'intent must return a stream or an object of streams', 'Return { ACTION: stream$ }')
515
518
  }
519
+
520
+ // [diagnostics hook] TODO(1A): selectorsUsed via MainDOMSource.select instrumentation
521
+ diag.onIntent(this, this.intent$ instanceof Stream ? [] : Object.keys(this.intent$), undefined)
516
522
  }
517
523
 
518
524
  initHmrActions(): void {
@@ -523,11 +529,8 @@ class Component {
523
529
  if (typeof this.hmrActions === 'string') {
524
530
  this.hmrActions = [this.hmrActions]
525
531
  }
526
- if (!Array.isArray(this.hmrActions)) {
527
- throw new Error(`[${this.name}] hmrActions must be the name of an action or an array of names of actions to run when a component is hot-reloaded`)
528
- }
529
- if (this.hmrActions.some(action => typeof action !== 'string')) {
530
- throw new Error(`[${this.name}] hmrActions must be the name of an action or an array of names of actions to run when a component is hot-reloaded`)
532
+ if (!Array.isArray(this.hmrActions) || this.hmrActions.some(action => typeof action !== 'string')) {
533
+ fail('SYG604', this, 'hmrActions must be an action name or an array of action names', "Use hmrActions = ['ACTION']")
531
534
  }
532
535
  this.hmrAction$ = xs.fromArray(this.hmrActions.map(action => ({ type: action })))
533
536
  }
@@ -547,12 +550,12 @@ class Component {
547
550
  // Validate that no intent action names contain '|' (reserved for model shorthand)
548
551
  for (const key of Object.keys(this.intent$)) {
549
552
  if (key.includes('|')) {
550
- throw new Error(`[${this.name}] Intent action name '${key}' contains '|', which is reserved for the model shorthand syntax (e.g., 'ACTION | DRIVER'). Rename this action.`)
553
+ fail('SYG605', this, `Intent action '${key}' contains '|', which is reserved for model shorthand`, 'Rename the action')
551
554
  }
552
555
  }
553
556
  const mapped = Object.entries(this.intent$)
554
557
  .map(([type, data$]: [string, any]) => data$.map((data: any) => ({type, data})))
555
- runner = xs.merge(xs.never(), ...mapped)
558
+ runner = mapped.length > 0 ? xs.merge(...mapped) : xs.never()
556
559
  }
557
560
 
558
561
  const action$ = ((runner instanceof Stream) ? runner : (runner.apply && runner(this.sources) || xs.never()))
@@ -593,7 +596,7 @@ class Component {
593
596
  } else if (isObj(this.model[INITIALIZE_ACTION])) {
594
597
  Object.keys(this.model[INITIALIZE_ACTION]).forEach(name => {
595
598
  if (name !== this.stateSourceName) {
596
- console.warn(`${INITIALIZE_ACTION} can only be used with the ${this.stateSourceName} source... disregarding ${name}`)
599
+ warn('SYG210', this, `${INITIALIZE_ACTION} only supports the ${this.stateSourceName} sink; ignoring '${name}'`, 'Use another action')
597
600
  delete this.model[INITIALIZE_ACTION][name]
598
601
  }
599
602
  })
@@ -610,7 +613,7 @@ class Component {
610
613
  const state$ = this.sources[this.stateSourceName]?.stream.startWith({}).compose(dropRepeats(objIsEqual)) || xs.never()
611
614
  const parentContext$ = this.sources.__parentContext$?.startWith({}).compose(dropRepeats(objIsEqual)) || xs.of({})
612
615
  if (this.context && !isObj(this.context)) {
613
- console.error(`[${this.name}] Context must be an object mapping names to values of functions: ignoring provided ${ typeof this.context }`)
616
+ logError('SYG402', this, `context must be an object, got ${typeof this.context}; ignoring it`, 'Use context = { name: state => value }')
614
617
  }
615
618
  this.context$ = xs.combine(state$, parentContext$)
616
619
  .map(([_, parent]: [any, any]) => {
@@ -628,7 +631,7 @@ class Component {
628
631
  } else if (valueType === 'function') {
629
632
  _value = value(state)
630
633
  } else {
631
- console.error(`[${this.name}] Invalid context entry '${name}': must be the name of a state property or a function returning a value to use`)
634
+ logError('SYG403', this, `Invalid context entry '${name}'; skipping it`, 'Use a state key or state => value')
632
635
  return acc
633
636
  }
634
637
  acc[name] = _value
@@ -643,7 +646,7 @@ class Component {
643
646
  })
644
647
  .compose(dropRepeats(objIsEqual))
645
648
  .startWith({})
646
- this._subscriptions.push(this.context$.subscribe({ next: (_: any) => _, error: (err: any) => console.error(`[${this.name}] Error in context stream:`, err) }))
649
+ this._subscriptions.push(this.context$.subscribe({ next: (_: any) => _, error: (err: any) => logError('SYG404', this, 'Context stream errored; context stops updating', 'Check the context functions', err) }))
647
650
  }
648
651
 
649
652
  initModel$(): void {
@@ -652,6 +655,8 @@ class Component {
652
655
  a[s] = xs.never()
653
656
  return a
654
657
  }, {} as Record<string, any>)
658
+ // [diagnostics hook]
659
+ diag.onModel(this, {})
655
660
  return
656
661
  }
657
662
 
@@ -659,19 +664,39 @@ class Component {
659
664
  const effectiveInitialState = (typeof hmrState !== 'undefined') ? hmrState : this.initialState
660
665
  const initial = { type: INITIALIZE_ACTION, data: effectiveInitialState }
661
666
  if (this.isSubComponent && this.initialState && !this.isolatedState) {
662
- console.warn(`[${this.name}] Initial state provided to sub-component. This will overwrite any state provided by the parent component.`)
667
+ warn('SYG405', this, 'Sub-component initialState replaces the state its parent passes in', 'Remove initialState, or set isolatedState = true')
663
668
  }
664
669
  const hasInitialState = (typeof effectiveInitialState !== 'undefined')
665
670
  const shouldInjectInitialState = hasInitialState && (ENVIRONMENT?.__SYGNAL_HMR_UPDATING !== true || typeof hmrState !== 'undefined')
666
- const shimmed$ = shouldInjectInitialState ? concat(xs.of(initial), this.action$).compose(delay(0)) : this.action$
667
- const onState = () => this.makeOnAction(shimmed$, true, this.action$)
668
- const onNormal = () => this.makeOnAction(this.action$, false, this.action$)
671
+ // Only INITIALIZE is delayed (user actions start >= 1ms later), so the other actions, and
672
+ // their non-STATE sinks, stay synchronous with the event that caused them (1H-1).
673
+ const shimmed$ = shouldInjectInitialState ? xs.merge(xs.of(initial).compose(delay(0)), this.action$) : this.action$
674
+ // B-003: STATE reducers are applied later (withState queues each one in a microtask), so
675
+ // while one is pending a non-STATE sink must not read this.currentState. Then, before the
676
+ // action reaches any reducer, queue a microtask (ahead of this action's own STATE reducer,
677
+ // behind every earlier one) that snapshots the state and runs the non-STATE sinks.
678
+ // Otherwise run them now, so EFFECT can still preventDefault() the live event (1H-1).
679
+ let snapListener: any = null
680
+ const sequenced$ = shimmed$.map((action: any) => {
681
+ const l = snapListener
682
+ const run = () => l.next({ ...action, [STATE_SNAPSHOT]: this.currentState })
683
+ if (l) pendingReducers ? queueMicrotask(run) : run()
684
+ return action
685
+ })
686
+ let snapSub: any
687
+ const snapshotted$ = xs.create({
688
+ start: (l: any) => { snapListener = l; snapSub = sequenced$.subscribe({}) },
689
+ stop: () => { snapListener = null; snapSub?.unsubscribe() },
690
+ })
691
+ const onState = () => this.makeOnAction(sequenced$, true, this.action$)
692
+ const onNormal = () => this.makeOnAction(snapshotted$, false, this.action$)
669
693
 
670
694
 
671
695
  const modelEntries = Object.entries(this.model)
672
696
 
673
697
  const reducers: Record<string, any[]> = {}
674
698
  const seenActionSinks = new Set<string>()
699
+ const modelMap: Record<string, string[]> = {} // [diagnostics hook]
675
700
 
676
701
  modelEntries.forEach((entry) => {
677
702
  let [action, sinks]: [string, any] = entry
@@ -680,7 +705,7 @@ class Component {
680
705
  if (action.includes('|')) {
681
706
  const parts = action.split('|').map((s: string) => s.trim())
682
707
  if (parts.length !== 2 || !parts[0] || !parts[1]) {
683
- throw new Error(`[${this.name}] Invalid shorthand model entry '${action}'. Expected 'ACTION | DRIVER' format.`)
708
+ fail('SYG211', this, `Invalid shorthand model entry '${action}'`, "Use 'ACTION | SINK'")
684
709
  }
685
710
  action = parts[0]
686
711
  sinks = { [parts[1]]: sinks }
@@ -691,7 +716,7 @@ class Component {
691
716
  }
692
717
 
693
718
  if (!isObj(sinks)) {
694
- throw new Error(`[${this.name}] Entry for each action must be an object: ${action}`)
719
+ fail('SYG212', this, `Model entry '${action}' must be a function or an object`, 'Use { STATE: reducer }')
695
720
  }
696
721
 
697
722
  const sinkEntries = Object.entries(sinks)
@@ -701,13 +726,14 @@ class Component {
701
726
 
702
727
  const actionSinkKey = `${action}::${sink}`
703
728
  if (seenActionSinks.has(actionSinkKey)) {
704
- console.warn(`[${this.name}] Duplicate model entry for action '${action}' on sink '${sink}'. Only the last definition will take effect.`)
729
+ warn('SYG213', this, `Duplicate model entry for action '${action}' on sink '${sink}'; both run`, 'Remove the duplicate')
705
730
  }
706
731
  seenActionSinks.add(actionSinkKey)
732
+ ;(modelMap[action] ||= []).push(sink) // [diagnostics hook]
707
733
 
708
734
  // EFFECT sink: run the reducer for side effects only, no state change or sink output
709
735
  if (sink === EFFECT_SINK_NAME) {
710
- const effect$ = this.makeEffectHandler(this.action$, action, reducer)
736
+ const effect$ = this.makeEffectHandler(snapshotted$, action, reducer, this.action$)
711
737
  if (Array.isArray(reducers[sink])) {
712
738
  reducers[sink].push(effect$)
713
739
  } else {
@@ -744,11 +770,14 @@ class Component {
744
770
 
745
771
  const model$ = Object.entries(reducers).reduce((acc: Record<string, any>, entry: [string, any]) => {
746
772
  const [sink, streams] = entry
747
- acc[sink] = xs.merge(xs.never(), ...streams)
773
+ acc[sink] = streams.length === 1 ? streams[0] : xs.merge(...streams)
748
774
  return acc
749
775
  }, {} as Record<string, any>)
750
776
 
751
777
  this.model$ = model$
778
+
779
+ // [diagnostics hook]
780
+ diag.onModel(this, modelMap)
752
781
  }
753
782
 
754
783
  initPeers$(): void {
@@ -812,7 +841,7 @@ class Component {
812
841
 
813
842
  }
814
843
  })
815
- this._subscriptions.push(subComponentSink$.subscribe({ next: (_: any) => _, error: (err: any) => console.error(`[${this.name}] Error in sub-component sink stream:`, err) }))
844
+ this._subscriptions.push(subComponentSink$.subscribe({ next: (_: any) => _, error: (err: any) => logError('SYG901', this, 'Sub-component sink stream errored', ERR_FIX, err) }))
816
845
  this.subComponentSink$ = subComponentSink$.filter((sinks: any) => Object.keys(sinks).length > 0)
817
846
  }
818
847
 
@@ -844,23 +873,23 @@ class Component {
844
873
  return this.view({ ...sanitizedProps, state, children, slots: slots || {}, context, peers }, state, context, peers)
845
874
  } catch (err) {
846
875
  const error = err instanceof Error ? err : new Error(String(err))
847
- console.error(`[${this.name}] Error in view:`, error)
876
+ // B-022: an error handled by .onError is a warning, without the "add .onError" hint
848
877
  if (typeof this.onError === 'function') {
849
878
  try {
850
- return this.onError(error, { componentName: this.name })
879
+ const fallback = this.onError(error, { componentName: this.name })
880
+ warn('SYG406', this, 'View threw; rendered the onError fallback', undefined, error)
881
+ return fallback
851
882
  } catch (fallbackErr) {
852
- console.error(`[${this.name}] Error in onError handler:`, fallbackErr)
883
+ logError('SYG406', this, 'View threw; rendering the error fallback', undefined, error)
884
+ logError('SYG407', this, 'onError threw; rendering an empty error <div>', 'Make onError return a vnode', fallbackErr)
853
885
  }
854
- }
886
+ } else logError('SYG406', this, 'View threw; rendering the error fallback', 'Add .onError for a custom fallback', error)
855
887
  return { sel: 'div', data: { attrs: { 'data-sygnal-error': this.name } }, children: [] }
856
888
  }
857
889
  })
858
890
  .compose(this.log('View rendered'))
859
891
  .map((vDom: any) => vDom || { sel: 'div', data: {}, children: [] })
860
- .map((vdom: any) => processLazy(vdom, this))
861
- .map(processPortals)
862
- .map(processTransitions)
863
- .map(processClientOnly)
892
+ .map((vdom: any) => preprocessVdom(vdom, this))
864
893
  .compose(this.instantiateSubComponents.bind(this))
865
894
  .filter((val: any) => val !== undefined)
866
895
  .compose(this.renderVdom.bind(this))
@@ -868,6 +897,15 @@ class Component {
868
897
  }
869
898
 
870
899
  initSinks(): void {
900
+ // Stamp this component's own EVENTS emissions with its info for devtools, before they are
901
+ // merged with the sub-components' (B-023: stamping the merged sink made every ancestor
902
+ // re-stamp, so the emitter was always the root). Non-enumerable (G-020), so sink values
903
+ // still toEqual what the model returned.
904
+ const ev$ = this.model$.EVENTS
905
+ if (ev$) this.model$.EVENTS = ev$.map((ev: any) => Object.defineProperties({...ev}, {
906
+ __emitterId: { value: this._componentNumber, configurable: true },
907
+ __emitterName: { value: this.name, configurable: true },
908
+ }))
871
909
  this.sinks = this.sourceNames.reduce((acc: Record<string, any>, name) => {
872
910
  if (name == this.DOMSourceName) return acc
873
911
  const subComponentSink$ = (this.subComponentSink$ && name !== PARENT_SINK_NAME) ? this.subComponentSink$.map((sinks: any) => sinks[name]).filter((sink: any) => !!sink).flatten() : xs.never()
@@ -876,12 +914,6 @@ class Component {
876
914
  } else {
877
915
  acc[name] = xs.merge((this.model$[name] || xs.never()), subComponentSink$, ...(this.peers$[name] || []))
878
916
  }
879
- // Stamp EVENTS sink emissions with emitter component info for devtools
880
- if (name === 'EVENTS' && acc[name]) {
881
- const _componentNumber = this._componentNumber
882
- const _name = this.name
883
- acc[name] = acc[name].map((ev: any) => ({...ev, __emitterId: _componentNumber, __emitterName: _name}))
884
- }
885
917
  return acc
886
918
  }, {} as Record<string, any>)
887
919
 
@@ -892,7 +924,7 @@ class Component {
892
924
  if (this.model$[EFFECT_SINK_NAME]) {
893
925
  const effectSub = this.model$[EFFECT_SINK_NAME].subscribe({
894
926
  next: () => {},
895
- error: (err: any) => console.error(`[${this.name}] Uncaught error in EFFECT stream:`, err),
927
+ error: (err: any) => logError('SYG902', this, 'EFFECT stream errored; effects stop running', ERR_FIX, err),
896
928
  })
897
929
  this._subscriptions.push(effectSub)
898
930
  delete this.sinks[EFFECT_SINK_NAME]
@@ -922,7 +954,7 @@ class Component {
922
954
  if (typeof reducer === 'function') {
923
955
  returnStream$ = filtered$.map((action: any) => {
924
956
  const next = (type: any, data: any, delay=10) => {
925
- if (typeof delay !== 'number') throw new Error(`[${this.name}] Invalid delay value provided to next() function in model action '${name}'. Must be a number in ms.`)
957
+ if (typeof delay !== 'number') fail('SYG215', this, `next() delay in '${name}' must be a number`, "Use next('ACTION', data, ms)")
926
958
  // put the "next" action request at the end of the event loop so the "current" action completes first
927
959
  setTimeout(() => {
928
960
  // push the "next" action request into the action$ stream
@@ -935,33 +967,50 @@ class Component {
935
967
 
936
968
  let data = action.data
937
969
  if (isStateSink) {
970
+ // withState applies reducers in microtasks, so none is legitimately pending at the
971
+ // next macrotask: reset then, in case one was never applied (e.g. app disposed)
972
+ if (!pendingReducers++) setTimeout(() => { pendingReducers = 0 })
973
+ let applied = false
938
974
  return (state: any) => {
939
- const _state = this.isSubComponent ? this.currentState : state
975
+ if (!applied && pendingReducers) { applied = true; pendingReducers-- }
976
+ // Reduce from the fresh argument, not currentState (B-013, 1H-3): below a
977
+ // Collection, currentState lags behind instantiateCollection's debounce, so a
978
+ // second same-tick action would start from the pre-update state. The parents'
979
+ // lenses add their calculated fields, so it matches what the view gets.
980
+ const fresh = typeof state !== 'undefined'
981
+ const _state = this.isSubComponent && !fresh ? this.currentState : state
940
982
  try {
941
983
  const enhancedState = this.addCalculated(_state)
942
984
  props.state = enhancedState
943
985
  const newState = reducer(enhancedState, data, next, props)
944
986
  if (isAbort(newState)) return _state
945
- return this.cleanupCalculated(newState)
987
+ // [diagnostics hook]
988
+ diag.onReducer(this, name, _state, newState, this.stateSourceName)
989
+ const result = this.cleanupCalculated(newState)
990
+ // B-013: later same-tick actions' non-STATE sinks snapshot currentState (B-003)
991
+ if (fresh) this.currentState = result
992
+ return result
946
993
  } catch (err) {
947
- console.error(`[${this.name}] Error in model reducer '${name}':`, err)
994
+ logError('SYG216', this, `Reducer for '${name}' threw; state unchanged`, ERR_FIX, err)
948
995
  return _state
949
996
  }
950
997
  }
951
998
  } else {
952
999
  try {
953
- const enhancedState = this.addCalculated(this.currentState)
1000
+ const enhancedState = this.addCalculated(STATE_SNAPSHOT in action ? action[STATE_SNAPSHOT] : this.currentState)
954
1001
  props.state = enhancedState
955
1002
  const reduced = reducer(enhancedState, data, next, props)
1003
+ // B-029: ABORT = send nothing (filtered below), before the type checks
1004
+ if (isAbort(reduced)) return reduced
956
1005
  const type = typeof reduced
957
1006
  if (isObj(reduced) || ['string', 'number', 'boolean', 'function'].includes(type)) return reduced
958
1007
  if (type === 'undefined') {
959
- console.warn(`[${this.name}] 'undefined' value sent to ${name}`)
1008
+ warn('SYG217', this, `Reducer for '${name}' sent undefined to the driver`, 'Return a value, or ABORT to send nothing')
960
1009
  return reduced
961
1010
  }
962
- throw new Error(`[${this.name}] Invalid reducer type for action '${name}': ${type}`)
1011
+ fail('SYG218', this, `Reducer for '${name}' returned a ${type}`, 'Return a value, or ABORT to send nothing')
963
1012
  } catch (err) {
964
- console.error(`[${this.name}] Error in model reducer '${name}':`, err)
1013
+ logError('SYG216', this, `Reducer for '${name}' threw; nothing sent`, ERR_FIX, err)
965
1014
  return ABORT
966
1015
  }
967
1016
  }
@@ -977,28 +1026,28 @@ class Component {
977
1026
  }
978
1027
  }
979
1028
 
980
- makeEffectHandler(action$: any, name: string, reducer: any): any {
1029
+ makeEffectHandler(action$: any, name: string, reducer: any, rootAction$: any = action$): any {
981
1030
  const filtered$ = action$.filter(({type}: any) => type == name)
982
1031
 
983
1032
  return filtered$.map((action: any) => {
984
1033
  if (typeof reducer === 'function') {
985
1034
  const next = (type: any, data: any, delay=10) => {
986
- if (typeof delay !== 'number') throw new Error(`[${this.name}] Invalid delay value provided to next() function in EFFECT handler '${name}'. Must be a number in ms.`)
1035
+ if (typeof delay !== 'number') fail('SYG215', this, `next() delay in '${name}' must be a number`, "Use next('ACTION', data, ms)")
987
1036
  setTimeout(() => {
988
- action$.shamefullySendNext({ type, data })
1037
+ rootAction$.shamefullySendNext({ type, data })
989
1038
  }, delay)
990
1039
  this.log(`<${name}> EFFECT triggered a next() action: <${type}> ${delay}ms delay`, true)
991
1040
  }
992
1041
 
993
1042
  try {
994
- const enhancedState = this.addCalculated(this.currentState)
1043
+ const enhancedState = this.addCalculated(STATE_SNAPSHOT in action ? action[STATE_SNAPSHOT] : this.currentState)
995
1044
  const props = { ...this.currentProps, children: this.currentChildren, slots: this.currentSlots || {}, context: this.currentContext, state: enhancedState }
996
1045
  const result = reducer(enhancedState, action.data, next, props)
997
- if (result !== undefined) {
998
- console.warn(`[${this.name}] EFFECT handler '${name}' returned a value. EFFECT handlers are for side effects only — return values are ignored.`)
1046
+ if (result !== undefined && !isAbort(result)) {
1047
+ warn('SYG219', this, `EFFECT handler '${name}' returned a value, which is ignored`, 'Use a STATE or driver sink')
999
1048
  }
1000
1049
  } catch (err) {
1001
- console.error(`[${this.name}] Error in EFFECT handler '${name}':`, err)
1050
+ logError('SYG214', this, `EFFECT handler '${name}' threw`, ERR_FIX, err)
1002
1051
  }
1003
1052
  }
1004
1053
  return null
@@ -1014,7 +1063,7 @@ class Component {
1014
1063
  if (state === lastState) {
1015
1064
  return lastResult
1016
1065
  }
1017
- if (!isObj(this.calculated)) throw new Error(`[${this.name}] 'calculated' parameter must be an object mapping calculated state field names to functions`)
1066
+ if (!isObj(this.calculated)) fail('SYG606', this, 'calculated must be an object', 'Use calculated = { field: state => value }')
1018
1067
 
1019
1068
  const calculated = this.getCalculatedValues(state)
1020
1069
  if (!calculated) {
@@ -1067,7 +1116,7 @@ class Component {
1067
1116
  computedSoFar[field] = result
1068
1117
  mergedState[field] = result
1069
1118
  } catch (e: unknown) {
1070
- console.warn(`Calculated field '${field}' threw an error during calculation: ${e instanceof Error ? e.message : e}`)
1119
+ warn('SYG220', this, `Calculated field '${field}' threw (${e instanceof Error ? e.message : e}); skipped this update`, 'Guard against missing data')
1071
1120
  }
1072
1121
  } else {
1073
1122
  // No deps declared — always recompute
@@ -1076,7 +1125,7 @@ class Component {
1076
1125
  computedSoFar[field] = result
1077
1126
  mergedState[field] = result
1078
1127
  } catch (e: unknown) {
1079
- console.warn(`Calculated field '${field}' threw an error during calculation: ${e instanceof Error ? e.message : e}`)
1128
+ warn('SYG220', this, `Calculated field '${field}' threw (${e instanceof Error ? e.message : e}); skipped this update`, 'Guard against missing data')
1080
1129
  }
1081
1130
  }
1082
1131
  }
@@ -1114,14 +1163,9 @@ class Component {
1114
1163
  renderParams.props = this.sources.props$.compose(dropRepeats(propsIsEqual))
1115
1164
  }
1116
1165
 
1117
- if (this.sources.children$) {
1118
- const processedChildren$ = this.sources.children$.map((val: any) => {
1119
- if (!Array.isArray(val)) return { children: val, slots: {} }
1120
- const { slots, defaultChildren } = extractSlots(val)
1121
- return { children: defaultChildren, slots }
1122
- })
1123
- renderParams.children = processedChildren$.map((p: any) => p.children).compose(dropRepeats(objIsEqual))
1124
- renderParams.slots = processedChildren$.map((p: any) => p.slots).compose(dropRepeats(objIsEqual))
1166
+ if (this._processedChildren$) {
1167
+ renderParams.children = this._processedChildren$.map((p: any) => p.children).compose(dropRepeats(objIsEqual))
1168
+ renderParams.slots = this._processedChildren$.map((p: any) => p.slots).compose(dropRepeats(objIsEqual))
1125
1169
  }
1126
1170
 
1127
1171
  if (this.context$) {
@@ -1155,9 +1199,11 @@ class Component {
1155
1199
  }
1156
1200
 
1157
1201
  instantiateSubComponents(vDom$: any): any {
1202
+ // Build the component name Set once outside the fold — avoids rebuilding per render
1203
+ const componentNameSet = new Set(['collection', 'switchable', 'sygnal-factory', ...Object.keys(this.components)])
1204
+
1158
1205
  return vDom$.fold((previousComponents: any, vDom: any) => {
1159
- const componentNames = Object.keys(this.components)
1160
- const foundComponents = getComponents(vDom, componentNames)
1206
+ const foundComponents = getComponents(vDom, componentNameSet)
1161
1207
  const entries = Object.entries(foundComponents)
1162
1208
 
1163
1209
  const rootEntry: Record<string, any> = { '::ROOT::': vDom }
@@ -1178,7 +1224,8 @@ class Component {
1178
1224
  const newComponents = entries.reduce((acc, [id, el]) => {
1179
1225
  const data = el.data
1180
1226
  const props = data.props || {}
1181
- const children = el.children || []
1227
+ // a string-tag component with a single text child is a text-only vnode (B-011)
1228
+ const children = el.children || (el.text != null ? [{ text: el.text }] : [])
1182
1229
 
1183
1230
  const isCollection = data.isCollection || false
1184
1231
  const isSwitchable = data.isSwitchable || false
@@ -1224,13 +1271,13 @@ class Component {
1224
1271
  sink$ = instantiator(el, props$, children$)
1225
1272
  } catch (err) {
1226
1273
  const error = err instanceof Error ? err : new Error(String(err))
1227
- console.error(`[${this.name}] Error instantiating sub-component:`, error)
1274
+ logError('SYG408', this, 'Sub-component threw; rendering the error fallback', ERR_FIX, error)
1228
1275
  let fallbackVNode = { sel: 'div', data: { attrs: { 'data-sygnal-error': this.name } }, children: [] }
1229
1276
  if (typeof this.onError === 'function') {
1230
1277
  try {
1231
1278
  fallbackVNode = this.onError(error, { componentName: this.name }) || fallbackVNode
1232
1279
  } catch (fallbackErr) {
1233
- console.error(`[${this.name}] Error in onError handler:`, fallbackErr)
1280
+ logError('SYG407', this, 'onError threw; rendering an empty error <div>', 'Make onError return a vnode', fallbackErr)
1234
1281
  }
1235
1282
  }
1236
1283
  sink$ = { [this.DOMSourceName]: xs.of(fallbackVNode) }
@@ -1273,16 +1320,52 @@ class Component {
1273
1320
  }
1274
1321
 
1275
1322
  makeCoordinatedSubComponentDomSink(domSink$: any): any {
1276
- const remembered$ = domSink$.remember()
1277
-
1278
- const coordinated = this.sources[this.stateSourceName].stream
1279
- .compose(dropRepeats(objIsEqual))
1280
- .map((state: any) => remembered$)
1281
- .flatten()
1323
+ return domSink$
1282
1324
  .debug((_: any) => this.triggerSubComponentsRendered())
1283
1325
  .remember()
1326
+ }
1284
1327
 
1285
- return coordinated
1328
+ // A child's reducers get their state through its lens from this component's raw reducer
1329
+ // state; adding the calculated fields here gives them what the child's view gets (1H-3).
1330
+ withCalculated(lense: any): any {
1331
+ return this.calculated ? { get: (state: any) => lense.get(isObj(state) ? this.addCalculated(state) : state), set: lense.set } : lense
1332
+ }
1333
+
1334
+ createSubComponentLense(stateField: any, componentType: string, defaultState?: any): any {
1335
+ const baseLense = {
1336
+ get: (state: any) => state,
1337
+ set: (_oldState: any, newState: any) => newState
1338
+ }
1339
+
1340
+ if (typeof stateField === 'undefined') return baseLense
1341
+
1342
+ if (typeof stateField === 'string') {
1343
+ return {
1344
+ get: (state: any) => {
1345
+ const slice = state[stateField]
1346
+ if (typeof slice === 'undefined' && defaultState) return defaultState
1347
+ return slice
1348
+ },
1349
+ set: (oldState: any, newState: any) => {
1350
+ if (this.calculated && stateField in this.calculated) {
1351
+ warn('SYG409', this, `${componentType} tried to update calculated field '${stateField}'; ignored`, 'Bind it to a non-calculated field')
1352
+ return oldState
1353
+ }
1354
+ return { ...oldState, [stateField]: newState }
1355
+ }
1356
+ }
1357
+ }
1358
+
1359
+ if (isObj(stateField)) {
1360
+ if (typeof stateField.get !== 'function') {
1361
+ logError('SYG410', this, `${componentType} 'state' prop has no get(); it gets the parent's whole state`, 'Use a state key string or { get, set }')
1362
+ return baseLense
1363
+ }
1364
+ return { get: stateField.get, set: stateField.set }
1365
+ }
1366
+
1367
+ logError('SYG410', this, `${componentType} 'state' prop is a ${typeof stateField}; it gets the parent's whole state`, 'Use a state key string or { get, set }')
1368
+ return baseLense
1286
1369
  }
1287
1370
 
1288
1371
  instantiateCollection(el: any, props$: any, children$: any): any {
@@ -1307,8 +1390,8 @@ class Component {
1307
1390
  if (props.sort !== arrayOperators.sort) {
1308
1391
  arrayOperators.sort = sortFunctionFromProp(props.sort)
1309
1392
  }
1310
-
1311
- return isObj(state) ? this.addCalculated(state) : state
1393
+ // calculated fields are added by the lens (B-013)
1394
+ return state
1312
1395
  })
1313
1396
 
1314
1397
  const stateSource = new StateSource(state$, this.stateSourceName)
@@ -1332,7 +1415,7 @@ class Component {
1332
1415
  } else if (this.components[collectionOf]) {
1333
1416
  factory = this.components[collectionOf]
1334
1417
  } else {
1335
- throw new Error(`[${this.name}] Invalid 'of' property in collection: ${collectionOf}`)
1418
+ fail('SYG411', this, `Collection 'of' is not a component: ${collectionOf}`, 'Use of={ItemComponent}')
1336
1419
  }
1337
1420
 
1338
1421
  const fieldLense = {
@@ -1349,7 +1432,7 @@ class Component {
1349
1432
  },
1350
1433
  set: (oldState: any, newState: any) => {
1351
1434
  if (this.calculated && stateField in this.calculated) {
1352
- console.warn(`Collection sub-component of ${this.name} attempted to update state on a calculated field '${stateField}': Update ignored`)
1435
+ warn('SYG409', this, `Collection tried to update calculated field '${stateField}'; ignored`, 'Bind it to a non-calculated field')
1353
1436
  return oldState
1354
1437
  }
1355
1438
  const updated = []
@@ -1378,17 +1461,18 @@ class Component {
1378
1461
  } else if (typeof stateField === 'string') {
1379
1462
  if (isObj(this.currentState)) {
1380
1463
  if(!(this.currentState && stateField in this.currentState) && !(this.calculated && stateField in this.calculated)) {
1381
- console.error(`Collection component in ${this.name} is attempting to use non-existent state property '${stateField}': To fix this error, specify a valid array property on the state. Attempting to use parent component state.`)
1464
+ const arrayFields = Object.keys(this.currentState).filter(k => Array.isArray(this.currentState[k]))
1465
+ logError('SYG401', this, `Collection from="${stateField}" is not in state${arrayFields.length ? ` (array fields: '${arrayFields.join("', '")}')` : ''}; it renders nothing`, 'Set it to an array in initialState')
1382
1466
  lense = undefined
1383
1467
  } else if (!Array.isArray(this.currentState[stateField])) {
1384
- console.warn(`[${this.name}] State property '${stateField}' in collection component is not an array: No components will be instantiated in the collection.`)
1468
+ warn('SYG401', this, `Collection 'from' field '${stateField}' is not an array; it renders nothing`, 'Set it to an array in initialState')
1385
1469
  lense = fieldLense
1386
1470
  } else {
1387
1471
  lense = fieldLense
1388
1472
  }
1389
1473
  } else {
1390
1474
  if (!Array.isArray(this.currentState[stateField])) {
1391
- console.warn(`[${this.name}] State property '${stateField}' in collection component is not an array: No components will be instantiated in the collection.`)
1475
+ warn('SYG401', this, `Collection 'from' field '${stateField}' is not an array; it renders nothing`, 'Set it to an array in initialState')
1392
1476
  lense = fieldLense
1393
1477
  } else {
1394
1478
  lense = fieldLense
@@ -1396,14 +1480,14 @@ class Component {
1396
1480
  }
1397
1481
  } else if (isObj(stateField)) {
1398
1482
  if (typeof stateField.get !== 'function') {
1399
- console.error(`Collection component in ${this.name} has an invalid 'from' field: Expecting 'undefined', a string indicating an array property in the state, or an object with 'get' and 'set' functions for retrieving and setting child state from the current state. Attempting to use parent component state.`)
1483
+ logError('SYG412', this, "Collection 'from' prop is invalid; it renders nothing", 'Use a state key string or { get, set }', stateField)
1400
1484
  lense = undefined
1401
1485
  } else {
1402
1486
  lense = {
1403
1487
  get: (state: any) => {
1404
1488
  const newState = stateField.get(state)
1405
1489
  if (!Array.isArray(newState)) {
1406
- console.warn(`State getter function in collection component of ${this.name} did not return an array: No components will be instantiated in the collection. Returned value:`, newState)
1490
+ warn('SYG401', this, "Collection 'from' getter returned a non-array; it renders nothing", 'Return an array from get()', newState)
1407
1491
  return []
1408
1492
  }
1409
1493
  return newState
@@ -1412,10 +1496,15 @@ class Component {
1412
1496
  }
1413
1497
  }
1414
1498
  } else {
1415
- console.error(`Collection component in ${this.name} has an invalid 'from' field: Expecting 'undefined', a string indicating an array property in the state, or an object with 'get' and 'set' functions for retrieving and setting child state from the current state. Attempting to use parent component state.`)
1499
+ logError('SYG412', this, "Collection 'from' prop is invalid; it renders nothing", 'Use a state key string or { get, set }', stateField)
1416
1500
  lense = undefined
1417
1501
  }
1418
1502
 
1503
+ // B-013: add this component's calculated fields inside the lens, not on state$, so the
1504
+ // items' reducers (which read through the lens from the raw state) see the same item
1505
+ // array as their views, e.g. for from={calculatedField} or a custom get().
1506
+ if (lense) lense = this.withCalculated(lense)
1507
+
1419
1508
  // Strip collection-specific props and forward only user-defined extra props to each item
1420
1509
  const collectionKeys = ['of', 'from', 'filter', 'sort', 'idfield', 'className']
1421
1510
  const itemProps$ = props$.map((p: any) => {
@@ -1430,7 +1519,7 @@ class Component {
1430
1519
  const sources = { ...this.sources, [this.stateSourceName]: stateSource, props$: itemProps$, children$, __parentContext$: this.context$, PARENT: null, __parentComponentNumber: this._componentNumber }
1431
1520
  const sink$ = collection(factory, lense as any, { container: null as any })(sources)
1432
1521
  if (!isObj(sink$)) {
1433
- throw new Error(`[${this.name}] Invalid sinks returned from component factory of collection element`)
1522
+ fail('SYG903', this, 'Collection factory returned invalid sinks', 'Return a sinks object')
1434
1523
  }
1435
1524
 
1436
1525
  // Notify devtools of collection mount
@@ -1458,40 +1547,7 @@ class Component {
1458
1547
 
1459
1548
  const stateSource = new StateSource(state$, this.stateSourceName)
1460
1549
  const stateField = props.state
1461
- let lense
1462
-
1463
- const fieldLense = {
1464
- get: (state: any) => state[stateField],
1465
- set: (oldState: any, newState: any) => {
1466
- if (this.calculated && stateField in this.calculated) {
1467
- console.warn(`Switchable sub-component of ${this.name} attempted to update state on a calculated field '${stateField}': Update ignored`)
1468
- return oldState
1469
- }
1470
- if (!isObj(newState) || Array.isArray(newState)) return { ...oldState, [stateField]: newState }
1471
- return { ...oldState, [stateField]: newState }
1472
- }
1473
- }
1474
-
1475
- const baseLense = {
1476
- get: (state: any) => state,
1477
- set: (oldState: any, newState: any) => newState
1478
- }
1479
-
1480
- if (typeof stateField === 'undefined') {
1481
- lense = baseLense
1482
- } else if (typeof stateField === 'string') {
1483
- lense = fieldLense
1484
- } else if (isObj(stateField)) {
1485
- if (typeof stateField.get !== 'function') {
1486
- console.error(`Switchable component in ${this.name} has an invalid 'state' field: Expecting 'undefined', a string indicating an array property in the state, or an object with 'get' and 'set' functions for retrieving and setting sub-component state from the current state. Attempting to use parent component state.`)
1487
- lense = baseLense
1488
- } else {
1489
- lense = { get: stateField.get, set: stateField.set }
1490
- }
1491
- } else {
1492
- console.error(`Invalid state provided to switchable sub-component of ${this.name}: Expecting string, object, or undefined, but found ${typeof stateField}. Attempting to use parent component state.`)
1493
- lense = baseLense
1494
- }
1550
+ const lense = this.withCalculated(this.createSubComponentLense(stateField, 'Switchable sub-component'))
1495
1551
 
1496
1552
  const switchableComponents = props.of
1497
1553
  const keys = Object.keys(switchableComponents)
@@ -1510,7 +1566,7 @@ class Component {
1510
1566
  const sink$ = isolate(switchable(switchableComponents, props$.map((props: any) => props.current), ''), { [this.stateSourceName]: lense })(sources)
1511
1567
 
1512
1568
  if (!isObj(sink$)) {
1513
- throw new Error(`[${this.name}] Invalid sinks returned from component factory of switchable element`)
1569
+ fail('SYG903', this, 'Switchable factory returned invalid sinks', 'Return a sinks object')
1514
1570
  }
1515
1571
 
1516
1572
  return sink$
@@ -1526,7 +1582,7 @@ class Component {
1526
1582
  return isObj(state) ? this.addCalculated(state) : state
1527
1583
  })
1528
1584
 
1529
- const stateSource = new StateSource(state$, this.stateSourceName)
1585
+ let stateSource = new StateSource(state$, this.stateSourceName)
1530
1586
  const stateField = props.state
1531
1587
 
1532
1588
  if (typeof props.sygnalFactory !== 'function' && isObj(props.sygnalOptions)) {
@@ -1535,8 +1591,8 @@ class Component {
1535
1591
 
1536
1592
  const factory = componentName === 'sygnal-factory' ? props.sygnalFactory : (this.components[componentName] || props.sygnalFactory)
1537
1593
  if (!factory) {
1538
- if (componentName === 'sygnal-factory') throw new Error(`Component not found on element with Capitalized selector and nameless function: JSX transpilation replaces selectors starting with upper case letters with functions in-scope with the same name, Sygnal cannot see the name of the resulting component.`)
1539
- throw new Error(`Component not found: ${componentName}`)
1594
+ if (componentName === 'sygnal-factory') fail('SYG413', this, 'Unnamed component has no factory', 'Name the function, or set Comp.componentName')
1595
+ fail('SYG414', this, `Component '${componentName}' not found`, 'Import it, or add it to .components')
1540
1596
  }
1541
1597
 
1542
1598
  // Guard against sub-components accidentally overwriting parent state with .initialState
@@ -1544,52 +1600,24 @@ class Component {
1544
1600
  const subIsolatedState = props.sygnalOptions?.isolatedState
1545
1601
  if (subInitialState && !subIsolatedState) {
1546
1602
  const subName = props.sygnalOptions?.name || componentName
1547
- throw new Error(
1548
- `[${subName}] Sub-component has .initialState but no .isolatedState = true. ` +
1549
- `This will overwrite parent state. If this is intentional, add .isolatedState = true to the component.`
1550
- )
1603
+ fail('SYG405', subName, 'Sub-component initialState replaces the state its parent passes in', 'Remove initialState, or set isolatedState = true')
1551
1604
  }
1552
1605
 
1553
- let lense
1554
-
1555
1606
  const subInitState = subIsolatedState ? subInitialState : undefined
1556
- const fieldLense = {
1557
- get: (state: any) => {
1558
- const slice = state[stateField]
1559
- if (typeof slice === 'undefined' && subInitState) return subInitState
1560
- return slice
1561
- },
1562
- set: (oldState: any, newState: any) => {
1563
- if (this.calculated && stateField in this.calculated) {
1564
- console.warn(`Sub-component of ${this.name} attempted to update state on a calculated field '${stateField}': Update ignored`)
1565
- return oldState
1566
- }
1567
- return { ...oldState, [stateField]: newState }
1568
- }
1569
- }
1570
-
1571
- const baseLense = {
1572
- get: (state: any) => state,
1573
- set: (oldState: any, newState: any) => newState
1574
- }
1575
-
1576
- if (typeof stateField === 'undefined') {
1577
- lense = baseLense
1578
- } else if (typeof stateField === 'string') {
1579
- lense = fieldLense
1580
- } else if (isObj(stateField)) {
1581
- if (typeof stateField.get !== 'function') {
1582
- console.error(`Sub-component in ${this.name} has an invalid 'state' field: Expecting 'undefined', a string indicating an array property in the state, or an object with 'get' and 'set' functions for retrieving and setting sub-component state from the current state. Attempting to use parent component state.`)
1583
- lense = baseLense
1584
- } else {
1585
- lense = { get: stateField.get, set: stateField.set }
1586
- }
1587
- } else {
1588
- console.error(`Invalid state provided to sub-component of ${this.name}: Expecting string, object, or undefined, but found ${typeof stateField}. Attempting to use parent component state.`)
1589
- lense = baseLense
1607
+ let lense = this.createSubComponentLense(stateField, 'Sub-component', subInitState)
1608
+ // B-008: isolatedState without a `state` prop = state local to this instance; the
1609
+ // parent's state is never replaced. It's kept off the parent's state stream (1H-9): the
1610
+ // child's reducers are applied below and feed its own state source, so a child write
1611
+ // doesn't produce a new parent state. Until the child first writes (INITIALIZE; never,
1612
+ // without a model) it reads the parent's state, as before.
1613
+ let local: any, local$: any
1614
+ if (subIsolatedState && typeof stateField === 'undefined') {
1615
+ local$ = xs.create()
1616
+ stateSource = new StateSource(xs.merge(state$.filter(() => local === undefined), local$), this.stateSourceName)
1590
1617
  }
1591
1618
 
1592
1619
  const sources: Record<string, any> = { ...this.sources, [this.stateSourceName]: stateSource, props$, children$, __parentContext$: this.context$, __parentComponentNumber: this._componentNumber }
1620
+ lense = local$ ? null : this.withCalculated(lense)
1593
1621
 
1594
1622
  // Detect Command objects in props and expose as commands$ source
1595
1623
  for (const key of Object.keys(props)) {
@@ -1605,18 +1633,24 @@ class Component {
1605
1633
 
1606
1634
  if (!isObj(sink$)) {
1607
1635
  const name = componentName === 'sygnal-factory' ? 'custom element' : componentName
1608
- throw new Error(`Invalid sinks returned from component factory: ${name}`)
1636
+ fail('SYG903', this, `Factory for ${name} returned invalid sinks`, 'Return a sinks object')
1637
+ }
1638
+ if (local$ && sink$[this.stateSourceName]) {
1639
+ sink$[this.stateSourceName] = sink$[this.stateSourceName]
1640
+ .map((reducer: any) => local$.shamefullySendNext(local = reducer(local === undefined ? this.addCalculated(this.currentState) : local)))
1641
+ .filter(() => false)
1609
1642
  }
1610
1643
 
1611
1644
  return sink$
1612
1645
  }
1613
1646
 
1614
1647
  renderVdom(componentInstances$: any): any {
1648
+ // Build the component name Set once — avoids rebuilding per render cycle
1649
+ const componentNameSet = new Set(['collection', 'switchable', 'sygnal-factory', ...Object.keys(this.components)])
1650
+
1615
1651
  return xs.combine(this.subComponentsRendered$, componentInstances$, this._readyChanged$.startWith(null))
1616
1652
  .compose(debounce(1))
1617
1653
  .map(([_, components]: [any, any]) => {
1618
- const componentNames = Object.keys(this.components)
1619
-
1620
1654
  const root = components['::ROOT::']
1621
1655
  const entries = Object.entries(components).filter(([id]) => id !== '::ROOT::')
1622
1656
 
@@ -1665,19 +1699,20 @@ class Component {
1665
1699
  }
1666
1700
 
1667
1701
  return xs.combine(...vdom$)
1668
- .compose(debounce(1))
1702
+ .filter((vdoms: any) => vdoms.every((v: any) => v !== undefined))
1669
1703
  .map((vdoms: any) => {
1670
1704
  const withIds = vdoms.reduce((acc: Record<string, any>, vdom: any, index: any) => {
1671
1705
  acc[ids[index]] = vdom
1672
1706
  return acc
1673
1707
  }, {} as Record<string, any>)
1674
- const rootCopy = deepCopyVdom(root)
1675
- const injected = injectComponents(rootCopy, withIds, componentNames, 'r', undefined, this._childReadyState)
1708
+ const injected = injectComponents(root, withIds, componentNameSet, 'r', undefined, this._childReadyState)
1676
1709
  return processSuspensePost(injected)
1677
1710
  })
1678
1711
  })
1679
1712
  .flatten()
1680
1713
  .filter((val: any) => !!val)
1714
+ // [diagnostics hook]
1715
+ .map((vdom: any) => { diag.onRender(this, vdom); return vdom })
1681
1716
  .remember()
1682
1717
  }
1683
1718
 
@@ -1731,93 +1766,92 @@ class Component {
1731
1766
 
1732
1767
 
1733
1768
 
1734
- function getComponents(currentElement: any, componentNames: string[], path: string = 'r', parentId?: string): Record<string, any> {
1735
- if (!currentElement) return {}
1769
+ function getComponents(currentElement: any, componentNameSet: Set<string>, path: string = 'r', parentId?: string, found?: Record<string, any>): Record<string, any> {
1770
+ if (!currentElement) return found || {}
1736
1771
 
1737
- if (currentElement.data?.componentsProcessed) return {}
1772
+ if (currentElement.data?.componentsProcessed) return found || {}
1738
1773
  if (path === 'r') currentElement.data.componentsProcessed = true
1739
1774
 
1775
+ // Allocate the result object only at the root call
1776
+ if (!found) found = {}
1777
+
1740
1778
  const sel = currentElement.sel
1741
1779
  const isCollection = sel && sel.toLowerCase() === 'collection'
1742
1780
  const isSwitchable = sel && sel.toLowerCase() === 'switchable'
1743
- const isComponent = sel && (['collection', 'switchable', 'sygnal-factory', ...componentNames].includes(sel)) || typeof currentElement.data?.props?.sygnalFactory === 'function' || isObj(currentElement.data?.props?.sygnalOptions)
1781
+ const isComponent = (sel && componentNameSet.has(sel)) || typeof currentElement.data?.props?.sygnalFactory === 'function' || isObj(currentElement.data?.props?.sygnalOptions)
1744
1782
  const props = (currentElement.data && currentElement.data.props) || {}
1745
- const attrs = (currentElement.data && currentElement.data.attrs) || {}
1746
1783
  const children = currentElement.children || []
1747
1784
 
1748
- let found: Record<string, any> = {}
1749
-
1750
1785
  let id = parentId
1751
1786
  if (isComponent) {
1752
1787
  id = getComponentIdFromElement(currentElement, path, parentId)
1753
1788
  if (isCollection) {
1754
- if (!props.of) throw new Error(`Collection element missing required 'component' property`)
1755
- if (typeof props.of !== 'string' && typeof props.of !== 'function') throw new Error(`Invalid 'component' property of collection element: found ${ typeof props.of } requires string or component factory function`)
1756
- if (typeof props.of !== 'function' && !componentNames.includes(props.of)) throw new Error(`Specified component for collection not found: ${props.of}`)
1757
- if (typeof props.from !== 'undefined' && !(typeof props.from === 'string' || Array.isArray(props.from) || typeof props.from.get === 'function')) console.warn(`No valid array found for collection ${ typeof props.of === 'string' ? props.of : 'function component' }: no collection components will be created`, props.from)
1789
+ if (!props.of) fail('SYG411', undefined, "Collection is missing 'of'", 'Use of={ItemComponent}')
1790
+ if (typeof props.of !== 'string' && typeof props.of !== 'function') fail('SYG411', undefined, `Collection 'of' is a ${typeof props.of}`, 'Use of={ItemComponent}')
1791
+ if (typeof props.of !== 'function' && !componentNameSet.has(props.of)) fail('SYG411', undefined, `Collection 'of' component not found: ${props.of}`, 'Use of={ItemComponent}')
1792
+ // an invalid 'from' is reported once, with the component name, by instantiateCollection (G-026)
1758
1793
  currentElement.data.isCollection = true
1759
1794
  currentElement.data.props ||= {}
1760
1795
  } else if (isSwitchable) {
1761
- if (!props.of) throw new Error(`Switchable element missing required 'of' property`)
1762
- if (!isObj(props.of)) throw new Error(`Invalid 'of' property of switchable element: found ${ typeof props.of } requires object mapping names to component factories`)
1796
+ if (!props.of) fail('SYG415', undefined, "Switchable is missing 'of'", 'Use of={{ name: Component }}')
1797
+ if (!isObj(props.of)) fail('SYG415', undefined, `Switchable 'of' is a ${typeof props.of}`, 'Use of={{ name: Component }}')
1763
1798
  const switchableComponents = Object.values(props.of)
1764
- if (!switchableComponents.every(comp => typeof comp === 'function')) throw new Error(`One or more components provided to switchable element is not a valid component factory`)
1765
- if (!props.current || (typeof props.current !== 'string' && typeof props.current !== 'function')) throw new Error(`Missing or invalid 'current' property for switchable element: found '${ typeof props.current }' requires string or function`)
1799
+ if (!switchableComponents.every(comp => typeof comp === 'function')) fail('SYG415', undefined, "Switchable 'of' has a value that is not a component", 'Use of={{ name: Component }}')
1800
+ if (!props.current || (typeof props.current !== 'string' && typeof props.current !== 'function')) fail('SYG416', undefined, `Switchable 'current' is missing or a ${typeof props.current}`, "Set current to a key of 'of'")
1766
1801
  const switchableComponentNames = Object.keys(props.of)
1767
- if (!switchableComponentNames.includes(props.current)) throw new Error(`Component '${props.current}' not found in switchable element`)
1802
+ if (!switchableComponentNames.includes(props.current)) fail('SYG416', undefined, `Switchable 'current' '${props.current}' is not a key of 'of'`, "Set current to a key of 'of'")
1768
1803
  currentElement.data.isSwitchable = true
1769
- } else {
1770
-
1771
1804
  }
1772
- if (typeof props.key === 'undefined') currentElement.data.props.key = id
1805
+ if (typeof props.key === 'undefined') (currentElement.data.props ||= {}).key = id
1773
1806
  found[id] = currentElement
1774
1807
  }
1775
1808
 
1776
- if (children.length > 0) {
1777
- children.map((child: any, i: any) => getComponents(child, componentNames, `${path}.${i}`, id))
1778
- .forEach((child: any) => {
1779
- Object.entries(child).forEach(([id, el]: [string, any]) => found[id] = el)
1780
- })
1809
+ for (let i = 0; i < children.length; i++) {
1810
+ getComponents(children[i], componentNameSet, `${path}.${i}`, id, found)
1781
1811
  }
1782
1812
 
1783
1813
  return found
1784
1814
  }
1785
1815
 
1786
- function injectComponents(currentElement: any, components: Record<string, any>, componentNames: string[], path: string = 'r', parentId?: string, readyMap?: Record<string, boolean>): any {
1787
- if (!currentElement) return
1788
- if (currentElement.data?.componentsInjected) return currentElement
1789
- if (path === 'r' && currentElement.data) currentElement.data.componentsInjected = true
1790
-
1816
+ function injectComponents(currentElement: any, components: Record<string, any>, componentNameSet: Set<string>, path: string = 'r', parentId?: string, readyMap?: Record<string, boolean>): any {
1817
+ if (!currentElement) return undefined
1791
1818
 
1792
1819
  const sel = currentElement.sel || 'NO SELECTOR'
1793
- const isComponent = ['collection', 'switchable', 'sygnal-factory', ...componentNames].includes(sel) || typeof currentElement.data?.props?.sygnalFactory === 'function' || isObj(currentElement.data?.props?.sygnalOptions)
1820
+ const isComponent = componentNameSet.has(sel) || typeof currentElement.data?.props?.sygnalFactory === 'function' || isObj(currentElement.data?.props?.sygnalOptions)
1794
1821
  const isCollection = currentElement?.data?.isCollection
1795
- const isSwitchable = currentElement?.data?.isSwitchable
1796
- const props = (currentElement.data && currentElement.data.props) || {}
1797
1822
  const children = currentElement.children || []
1798
1823
 
1799
1824
  let id = parentId
1800
1825
  if (isComponent) {
1801
1826
  id = getComponentIdFromElement(currentElement, path, parentId)
1802
1827
  let component = components[id]
1803
- // Annotate the injected VNode with its READY state
1804
- if (readyMap && id && component && typeof component === 'object' && component.sel) {
1805
- component.data = component.data || {}
1806
- component.data.attrs = component.data.attrs || {}
1807
- component.data.attrs['data-sygnal-ready'] = readyMap[id] !== false ? 'true' : 'false'
1828
+ // Mark an injected VNode that is NOT ready (non-mutating) so Suspense can find it.
1829
+ // Ready children get no attribute (G-018): moving markup into a child component
1830
+ // must not change the DOM.
1831
+ if (readyMap && id && readyMap[id] === false && component && typeof component === 'object' && component.sel) {
1832
+ component = {
1833
+ ...component,
1834
+ data: {
1835
+ ...(component.data || {}),
1836
+ attrs: {
1837
+ ...(component.data?.attrs || {}),
1838
+ 'data-sygnal-ready': 'false'
1839
+ }
1840
+ }
1841
+ }
1808
1842
  }
1809
1843
  if (isCollection) {
1810
- currentElement.sel = 'div'
1811
- currentElement.children = Array.isArray(component) ? component : [component]
1812
- return currentElement
1813
- } else if (isSwitchable) {
1814
- return component
1844
+ return {
1845
+ ...currentElement,
1846
+ sel: 'div',
1847
+ children: Array.isArray(component) ? component : [component]
1848
+ }
1815
1849
  } else {
1816
1850
  return component
1817
1851
  }
1818
1852
  } else if (children.length > 0) {
1819
- currentElement.children = children.map((child: any, i: any) => injectComponents(child, components, componentNames, `${path}.${i}`, id, readyMap)).flat()
1820
- return currentElement
1853
+ const newChildren = children.map((child: any, i: any) => injectComponents(child, components, componentNameSet, `${path}.${i}`, id, readyMap)).flat()
1854
+ return { ...currentElement, children: newChildren }
1821
1855
  } else {
1822
1856
  return currentElement
1823
1857
  }
@@ -1873,66 +1907,14 @@ function processSuspensePost(vnode: any): any {
1873
1907
  return { sel: 'div', data: { attrs: { 'data-sygnal-suspense': 'resolved' } }, children: children.map((c: any) => processSuspensePost(c)), text: undefined, elm: undefined, key: undefined }
1874
1908
  }
1875
1909
  if (vnode.children && vnode.children.length > 0) {
1876
- vnode.children = vnode.children.map((c: any) => processSuspensePost(c))
1910
+ const newChildren = vnode.children.map((c: any) => processSuspensePost(c))
1911
+ return { ...vnode, children: newChildren }
1877
1912
  }
1878
1913
  return vnode
1879
1914
  }
1880
1915
 
1881
1916
  const portalPatch = snabbdomInit(defaultModules);
1882
1917
 
1883
- function processPortals(vnode: any): any {
1884
- if (!vnode || !vnode.sel) return vnode
1885
- if (vnode.sel === 'portal') {
1886
- const target = vnode.data?.props?.target
1887
- const children = vnode.children || []
1888
- return createPortalPlaceholder(target, children)
1889
- }
1890
- if (vnode.children && vnode.children.length > 0) {
1891
- vnode.children = vnode.children.map(processPortals)
1892
- }
1893
- return vnode
1894
- }
1895
-
1896
- function processTransitions(vnode: any): any {
1897
- if (!vnode || !vnode.sel) return vnode
1898
- if (vnode.sel === 'transition') {
1899
- const props = vnode.data?.props || {}
1900
- const name = props.name || 'v'
1901
- const duration = props.duration
1902
- const children = vnode.children || []
1903
- const child = children[0]
1904
- if (!child || !child.sel) return child || vnode
1905
- return applyTransitionHooks(processTransitions(child), name, duration)
1906
- }
1907
- if (vnode.children && vnode.children.length > 0) {
1908
- vnode.children = vnode.children.map(processTransitions)
1909
- }
1910
- return vnode
1911
- }
1912
-
1913
- function processClientOnly(vnode: any): any {
1914
- if (!vnode || !vnode.sel) return vnode
1915
- if (vnode.sel === 'clientonly') {
1916
- // On the client, unwrap to children (render them normally)
1917
- const children = vnode.children || []
1918
- if (children.length === 0) return { sel: 'div', data: {}, children: [] }
1919
- if (children.length === 1) return processClientOnly(children[0])
1920
- // Multiple children: wrap in a div
1921
- return {
1922
- sel: 'div',
1923
- data: {},
1924
- children: children.map(processClientOnly),
1925
- text: undefined,
1926
- elm: undefined,
1927
- key: undefined,
1928
- }
1929
- }
1930
- if (vnode.children && vnode.children.length > 0) {
1931
- vnode.children = vnode.children.map(processClientOnly)
1932
- }
1933
- return vnode
1934
- }
1935
-
1936
1918
  function applyTransitionHooks(vnode: any, name: string, duration?: number): any {
1937
1919
  const existingInsert = vnode.data?.hook?.insert
1938
1920
  const existingRemove = vnode.data?.hook?.remove
@@ -1976,14 +1958,17 @@ function applyTransitionHooks(vnode: any, name: string, duration?: number): any
1976
1958
  return vnode
1977
1959
  }
1978
1960
 
1979
- function processLazy(vnode: any, componentInstance: any): any {
1961
+ /**
1962
+ * Single-pass VNode preprocessor that handles lazy, portal, transition, and clientonly
1963
+ * nodes in one recursive walk instead of four separate passes.
1964
+ */
1965
+ function preprocessVdom(vnode: any, componentInstance: any): any {
1980
1966
  if (!vnode || !vnode.sel) return vnode
1981
1967
 
1982
- // Check if this VNode wraps a lazy component via sygnalOptions
1968
+ // Handle lazy components
1983
1969
  const view = vnode.data?.props?.sygnalOptions?.view
1984
1970
  if (view && view.__sygnalLazy) {
1985
1971
  if (view.__sygnalLazyLoaded()) {
1986
- // Lazy component is loaded — rebuild VNode with loaded component's properties
1987
1972
  const loaded = view.__sygnalLazyLoadedComponent
1988
1973
  if (loaded) {
1989
1974
  const props = vnode.data?.props || {}
@@ -2000,14 +1985,12 @@ function processLazy(vnode: any, componentInstance: any): any {
2000
1985
  }
2001
1986
  }
2002
1987
  } else {
2003
- // Schedule re-render when lazy promise resolves
2004
1988
  if (!view.__sygnalLazyReRenderScheduled && view.__sygnalLazyPromise && componentInstance) {
2005
1989
  view.__sygnalLazyReRenderScheduled = true
2006
1990
  view.__sygnalLazyPromise.then(() => {
2007
1991
  setTimeout(() => {
2008
1992
  const stateSource = componentInstance.sources?.[componentInstance.stateSourceName]
2009
1993
  if (stateSource && stateSource.stream) {
2010
- // Add a unique token to bypass dropRepeats deep comparison
2011
1994
  const stateCopy = { ...componentInstance.currentState, __sygnalLazyTick: Date.now() }
2012
1995
  stateSource.stream.shamefullySendNext(stateCopy)
2013
1996
  }
@@ -2017,8 +2000,42 @@ function processLazy(vnode: any, componentInstance: any): any {
2017
2000
  }
2018
2001
  }
2019
2002
 
2003
+ // Handle portals
2004
+ if (vnode.sel === 'portal') {
2005
+ const target = vnode.data?.props?.target
2006
+ const children = vnode.children || []
2007
+ return createPortalPlaceholder(target, children)
2008
+ }
2009
+
2010
+ // Handle transitions — recursively preprocess the child before applying hooks
2011
+ if (vnode.sel === 'transition') {
2012
+ const props = vnode.data?.props || {}
2013
+ const name = props.name || 'v'
2014
+ const duration = props.duration
2015
+ const children = vnode.children || []
2016
+ const child = children[0]
2017
+ if (!child || !child.sel) return child || vnode
2018
+ return applyTransitionHooks(preprocessVdom(child, componentInstance), name, duration)
2019
+ }
2020
+
2021
+ // Handle clientonly — unwrap to children on client
2022
+ if (vnode.sel === 'clientonly') {
2023
+ const children = vnode.children || []
2024
+ if (children.length === 0) return { sel: 'div', data: {}, children: [] }
2025
+ if (children.length === 1) return preprocessVdom(children[0], componentInstance)
2026
+ return {
2027
+ sel: 'div',
2028
+ data: {},
2029
+ children: children.map((c: any) => preprocessVdom(c, componentInstance)),
2030
+ text: undefined,
2031
+ elm: undefined,
2032
+ key: undefined,
2033
+ }
2034
+ }
2035
+
2036
+ // Recurse into children
2020
2037
  if (vnode.children && vnode.children.length > 0) {
2021
- vnode.children = vnode.children.map((child: any) => processLazy(child, componentInstance))
2038
+ vnode.children = vnode.children.map((child: any) => preprocessVdom(child, componentInstance))
2022
2039
  }
2023
2040
  return vnode
2024
2041
  }
@@ -2037,6 +2054,21 @@ function onTransitionEnd(el: any, duration: number | undefined, cb: () => void):
2037
2054
  }
2038
2055
 
2039
2056
 
2057
+ function portalMount(vnode: any, target: string, children: any[]): void {
2058
+ const container = document.querySelector(target)
2059
+ if (!container) {
2060
+ warn('SYG417', 'Portal', `Target '${target}' not found; content not rendered`, 'Render the target first')
2061
+ return
2062
+ }
2063
+ const anchor = document.createElement('div')
2064
+ container.appendChild(anchor)
2065
+ vnode.data._portalVnode = portalPatch(anchor, {
2066
+ sel: 'div', data: {}, children,
2067
+ text: undefined, elm: undefined, key: undefined,
2068
+ })
2069
+ vnode.data._portalContainer = container
2070
+ }
2071
+
2040
2072
  function createPortalPlaceholder(target: string, children: any[]): any {
2041
2073
  const portalChildren = children || []
2042
2074
 
@@ -2048,24 +2080,41 @@ function createPortalPlaceholder(target: string, children: any[]): any {
2048
2080
  portalChildren,
2049
2081
  hook: {
2050
2082
  insert: (vnode: any) => {
2083
+ // Try synchronously first (target outside component tree or
2084
+ // plain element in the same patch cycle).
2051
2085
  const container = document.querySelector(target)
2052
- if (!container) {
2053
- console.warn(`[Portal] Target "${target}" not found in DOM`)
2086
+ if (container) {
2087
+ portalMount(vnode, target, portalChildren)
2054
2088
  return
2055
2089
  }
2056
- const anchor = document.createElement('div')
2057
- container.appendChild(anchor)
2058
- vnode.data._portalVnode = portalPatch(anchor, {
2059
- sel: 'div', data: {}, children: portalChildren,
2060
- text: undefined, elm: undefined, key: undefined,
2061
- })
2062
- vnode.data._portalContainer = container
2090
+ // Target may be rendered by a sub-component whose streams
2091
+ // haven't settled yet (debounced). Retry a few times to
2092
+ // allow sub-component rendering to complete.
2093
+ let attempts = 0
2094
+ const tryMount = () => {
2095
+ if (vnode.data._portalVnode) return // already mounted
2096
+ if (document.querySelector(target)) {
2097
+ portalMount(vnode, target, portalChildren)
2098
+ } else if (++attempts < 10) {
2099
+ setTimeout(tryMount, 5)
2100
+ } else {
2101
+ warn('SYG417', 'Portal', `Target '${target}' not found; content not rendered`, 'Render the target first')
2102
+ }
2103
+ }
2104
+ setTimeout(tryMount, 5)
2063
2105
  },
2064
2106
  postpatch: (oldVnode: any, newVnode: any) => {
2065
2107
  const prevPortalVnode = oldVnode.data?._portalVnode
2066
2108
  const container = oldVnode.data?._portalContainer
2067
- if (!prevPortalVnode || !container) return
2068
2109
  const newChildren = newVnode.data?.portalChildren || []
2110
+ if (!prevPortalVnode || !container) {
2111
+ // insert was deferred and hasn't fired yet, or target
2112
+ // wasn't found. Try mounting now with updated children.
2113
+ if (!newVnode.data._portalVnode) {
2114
+ portalMount(newVnode, target, newChildren)
2115
+ }
2116
+ return
2117
+ }
2069
2118
  newVnode.data._portalVnode = portalPatch(prevPortalVnode, {
2070
2119
  sel: 'div', data: {}, children: newChildren,
2071
2120
  text: undefined, elm: undefined, key: undefined,
@@ -2088,11 +2137,6 @@ function createPortalPlaceholder(target: string, children: any[]): any {
2088
2137
  }
2089
2138
 
2090
2139
 
2091
- function deepCopyVdom(obj: any): any {
2092
- if (typeof obj === 'undefined') return obj
2093
- return { ...obj, children: Array.isArray(obj.children) ? obj.children.map(deepCopyVdom) : undefined, data: obj.data && { ...obj.data, componentsInjected: false } }
2094
- }
2095
-
2096
2140
  function propsIsEqual(obj1: any, obj2: any): boolean {
2097
2141
  return objIsEqual(sanitizeObject(obj1), sanitizeObject(obj2))
2098
2142
  }
@@ -2128,16 +2172,15 @@ function objIsEqual(obj1: any, obj2?: any, maxDepth: number = 5, depth: number =
2128
2172
 
2129
2173
  // Get keys of both objects
2130
2174
  const keys1 = Object.keys(obj1);
2131
- const keys2 = Object.keys(obj2);
2132
2175
 
2133
2176
  // Check if the number of properties is different
2134
- if (keys1.length !== keys2.length) {
2177
+ if (keys1.length !== Object.keys(obj2).length) {
2135
2178
  return false;
2136
2179
  }
2137
2180
 
2138
2181
  // Recursively check each property
2139
2182
  for (const key of keys1) {
2140
- if (!keys2.includes(key)) {
2183
+ if (!(key in obj2)) {
2141
2184
  return false;
2142
2185
  }
2143
2186
  if (!objIsEqual(obj1[key], obj2[key], maxDepth, depth + 1)) {
@@ -2158,6 +2201,8 @@ function isObj(obj: any): obj is Record<string, any> {
2158
2201
  return typeof obj === 'object' && obj !== null && !Array.isArray(obj)
2159
2202
  }
2160
2203
 
2204
+ const SORT_FIX = "Use a field name, { field: 'asc'|'desc'|1|-1 }, or a function"
2205
+
2161
2206
  function __baseSort(a: any, b: any, ascending: boolean = true): number {
2162
2207
  const direction = ascending ? 1 : -1
2163
2208
  switch(true) {
@@ -2170,26 +2215,26 @@ function __baseSort(a: any, b: any, ascending: boolean = true): number {
2170
2215
  function __sortFunctionFromObj(item: Record<string, any>): ((a: any, b: any) => number) | undefined {
2171
2216
  const entries = Object.entries(item)
2172
2217
  if (entries.length > 1) {
2173
- console.error('Sort objects can only have one key:', item)
2218
+ logError('SYG418', 'Collection', 'sort object must have one key; ignored', SORT_FIX, item)
2174
2219
  return undefined
2175
2220
  }
2176
2221
  const entry = entries[0]
2177
2222
  const [field, directionRaw] = entry
2178
2223
  if (!['string', 'number'].includes(typeof directionRaw)) {
2179
- console.error('Sort object properties must be a string or number:', item)
2224
+ logError('SYG418', 'Collection', 'sort direction must be a string or number; ignored', SORT_FIX, item)
2180
2225
  return undefined
2181
2226
  }
2182
2227
  let ascending = true
2183
2228
  if (typeof directionRaw === 'string') {
2184
2229
  if (!['asc', 'desc'].includes(directionRaw.toLowerCase())) {
2185
- console.error('Sort object string values must be asc or desc:', item)
2230
+ logError('SYG418', 'Collection', "sort direction must be 'asc' or 'desc'; ignored", SORT_FIX, item)
2186
2231
  return undefined
2187
2232
  }
2188
2233
  ascending = directionRaw.toLowerCase() !== 'desc'
2189
2234
  }
2190
2235
  if (typeof directionRaw === 'number') {
2191
2236
  if (directionRaw !== 1 && directionRaw !== -1) {
2192
- console.error('Sort object number values must be 1 or -1:', item)
2237
+ logError('SYG418', 'Collection', 'sort direction must be 1 or -1; ignored', SORT_FIX, item)
2193
2238
  return undefined
2194
2239
  }
2195
2240
  ascending = directionRaw === 1
@@ -2227,7 +2272,7 @@ function sortFunctionFromProp(sortProp: any): ((a: any, b: any) => number) | und
2227
2272
  } else if (isObj(sortProp)) {
2228
2273
  return __sortFunctionFromObj(sortProp)
2229
2274
  } else {
2230
- console.error('Invalid sort option (ignoring):', sortProp)
2275
+ logError('SYG418', 'Collection', 'Invalid sort prop; ignored', SORT_FIX, sortProp)
2231
2276
  return undefined
2232
2277
  }
2233
2278
  }