sygnal 4.2.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/component.js CHANGED
@@ -42,13 +42,27 @@ function wrapDOMSource(domSource) {
42
42
  }
43
43
 
44
44
 
45
- export const ABORT = '~#~#~ABORT~#~#~'
45
+ export const ABORT = Symbol('ABORT')
46
+
47
+
48
+ function normalizeCalculatedEntry(field, entry) {
49
+ if (typeof entry === 'function') {
50
+ return { fn: entry, deps: null }
51
+ }
52
+ if (Array.isArray(entry) && entry.length === 2
53
+ && Array.isArray(entry[0]) && typeof entry[1] === 'function') {
54
+ return { fn: entry[1], deps: entry[0] }
55
+ }
56
+ throw new Error(
57
+ `Invalid calculated field '${field}': expected a function or [depsArray, function]`
58
+ )
59
+ }
46
60
 
47
61
  export default function component (opts) {
48
62
  const { name, sources, isolateOpts, stateSourceName='STATE' } = opts
49
63
 
50
64
  if (sources && !isObj(sources)) {
51
- throw new Error('Sources must be a Cycle.js sources object:', name)
65
+ throw new Error(`[${name}] Sources must be a Cycle.js sources object`)
52
66
  }
53
67
 
54
68
  let fixedIsolateOpts
@@ -128,7 +142,9 @@ class Component {
128
142
  // sinks
129
143
 
130
144
  constructor({ name='NO NAME', sources, intent, model, hmrActions, context, response, view, peers={}, components={}, initialState, calculated, storeCalculatedInState=true, DOMSourceName='DOM', stateSourceName='STATE', requestSourceName='HTTP', debug=false }) {
131
- if (!sources || !isObj(sources)) throw new Error('Missing or invalid sources')
145
+ if (!sources || !isObj(sources)) throw new Error(`[${name}] Missing or invalid sources`)
146
+
147
+ this._componentNumber = COMPONENT_COUNT++
132
148
 
133
149
  this.name = name
134
150
  this.sources = sources
@@ -149,6 +165,123 @@ class Component {
149
165
  this.sourceNames = Object.keys(sources)
150
166
  this._debug = debug
151
167
 
168
+ // Warn if calculated fields shadow base state keys
169
+ if (this.calculated && this.initialState
170
+ && isObj(this.calculated) && isObj(this.initialState)) {
171
+ for (const key of Object.keys(this.calculated)) {
172
+ if (key in this.initialState) {
173
+ console.warn(
174
+ `[${name}] Calculated field '${key}' shadows a key in initialState. ` +
175
+ `The initialState value will be overwritten on every state update.`
176
+ )
177
+ }
178
+ }
179
+ }
180
+
181
+ // Normalize calculated entries, build dependency graph, topological sort
182
+ if (this.calculated && isObj(this.calculated)) {
183
+ const calcEntries = Object.entries(this.calculated)
184
+
185
+ // Normalize all entries to { fn, deps } shape
186
+ this._calculatedNormalized = {}
187
+ for (const [field, entry] of calcEntries) {
188
+ this._calculatedNormalized[field] = normalizeCalculatedEntry(field, entry)
189
+ }
190
+
191
+ this._calculatedFieldNames = new Set(Object.keys(this._calculatedNormalized))
192
+
193
+ // Warn on deps referencing nonexistent keys
194
+ for (const [field, { deps }] of Object.entries(this._calculatedNormalized)) {
195
+ if (deps !== null) {
196
+ for (const dep of deps) {
197
+ if (!this._calculatedFieldNames.has(dep)
198
+ && this.initialState && !(dep in this.initialState)) {
199
+ console.warn(
200
+ `[${name}] Calculated field '${field}' declares dependency '${dep}' ` +
201
+ `which is not in initialState or calculated fields`
202
+ )
203
+ }
204
+ }
205
+ }
206
+ }
207
+
208
+ // Build adjacency: for each field, which other calculated fields must run first?
209
+ const calcDeps = {}
210
+ for (const [field, { deps }] of Object.entries(this._calculatedNormalized)) {
211
+ if (deps === null) {
212
+ calcDeps[field] = []
213
+ } else {
214
+ calcDeps[field] = deps.filter(d => this._calculatedFieldNames.has(d))
215
+ }
216
+ }
217
+
218
+ // Kahn's algorithm for topological sort
219
+ const inDegree = {}
220
+ const reverseGraph = {}
221
+ for (const field of this._calculatedFieldNames) {
222
+ inDegree[field] = 0
223
+ reverseGraph[field] = []
224
+ }
225
+ for (const [field, depList] of Object.entries(calcDeps)) {
226
+ inDegree[field] = depList.length
227
+ for (const dep of depList) {
228
+ reverseGraph[dep].push(field)
229
+ }
230
+ }
231
+
232
+ const queue = []
233
+ for (const [field, degree] of Object.entries(inDegree)) {
234
+ if (degree === 0) queue.push(field)
235
+ }
236
+
237
+ const sorted = []
238
+ while (queue.length > 0) {
239
+ const current = queue.shift()
240
+ sorted.push(current)
241
+ for (const dependent of reverseGraph[current]) {
242
+ inDegree[dependent]--
243
+ if (inDegree[dependent] === 0) queue.push(dependent)
244
+ }
245
+ }
246
+
247
+ if (sorted.length !== this._calculatedFieldNames.size) {
248
+ // Cycle detected — build error message with cycle path
249
+ const inCycle = [...this._calculatedFieldNames].filter(f => !sorted.includes(f))
250
+ const visited = new Set()
251
+ const path = []
252
+ const traceCycle = (node) => {
253
+ if (visited.has(node)) { path.push(node); return true }
254
+ visited.add(node)
255
+ path.push(node)
256
+ for (const dep of calcDeps[node]) {
257
+ if (inCycle.includes(dep) && traceCycle(dep)) return true
258
+ }
259
+ path.pop()
260
+ visited.delete(node)
261
+ return false
262
+ }
263
+ traceCycle(inCycle[0])
264
+ const start = path[path.length - 1]
265
+ const cycle = path.slice(path.indexOf(start))
266
+ throw new Error(`Circular calculated dependency: ${cycle.join(' \u2192 ')}`)
267
+ }
268
+
269
+ this._calculatedOrder = sorted.map(f => [f, this._calculatedNormalized[f]])
270
+
271
+ // Initialize per-field memoization caches for fields with declared deps
272
+ this._calculatedFieldCache = {}
273
+ for (const [field, { deps }] of this._calculatedOrder) {
274
+ if (deps !== null) {
275
+ this._calculatedFieldCache[field] = { lastDepValues: undefined, lastResult: undefined }
276
+ }
277
+ }
278
+ } else {
279
+ this._calculatedOrder = null
280
+ this._calculatedNormalized = null
281
+ this._calculatedFieldNames = null
282
+ this._calculatedFieldCache = null
283
+ }
284
+
152
285
  this.isSubComponent = this.sourceNames.includes('props$')
153
286
 
154
287
  const state$ = sources[stateSourceName] && sources[stateSourceName].stream
@@ -157,6 +290,9 @@ class Component {
157
290
  this.currentState = initialState || {}
158
291
  this.sources[stateSourceName] = new StateSource(state$.map(val => {
159
292
  this.currentState = val
293
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
294
+ window.__SYGNAL_DEVTOOLS__.onStateChanged(this._componentNumber, this.name, val)
295
+ }
160
296
  return val
161
297
  }))
162
298
  }
@@ -192,10 +328,8 @@ class Component {
192
328
  }
193
329
  }
194
330
 
195
- const componentNumber = COMPONENT_COUNT++
196
-
197
331
  this.addCalculated = this.createMemoizedAddCalculated()
198
- this.log = makeLog(`${componentNumber} | ${name}`)
332
+ this.log = makeLog(`${this._componentNumber} | ${name}`)
199
333
 
200
334
  this.initChildSources$()
201
335
  this.initIntent$()
@@ -210,9 +344,20 @@ class Component {
210
344
  this.initVdom$()
211
345
  this.initSinks()
212
346
 
213
- this.sinks.__index = componentNumber
347
+ this.sinks.__index = this._componentNumber
214
348
 
215
349
  this.log(`Instantiated`, true)
350
+
351
+ // Hook 1: Register with DevTools
352
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__) {
353
+ window.__SYGNAL_DEVTOOLS__.onComponentCreated(this._componentNumber, name, this)
354
+
355
+ // Hook 1b: Register parent-child relationship
356
+ const parentNum = sources?.__parentComponentNumber
357
+ if (typeof parentNum === 'number') {
358
+ window.__SYGNAL_DEVTOOLS__.onSubComponentRegistered(parentNum, this._componentNumber)
359
+ }
360
+ }
216
361
  }
217
362
 
218
363
  get debug() {
@@ -224,13 +369,13 @@ class Component {
224
369
  return
225
370
  }
226
371
  if (typeof this.intent != 'function') {
227
- throw new Error('Intent must be a function')
372
+ throw new Error(`[${this.name}] Intent must be a function`)
228
373
  }
229
374
 
230
375
  this.intent$ = this.intent(this.sources)
231
376
 
232
377
  if (!(this.intent$ instanceof Stream) && (!isObj(this.intent$))) {
233
- throw new Error('Intent must return either an action$ stream or map of event streams')
378
+ throw new Error(`[${this.name}] Intent must return either an action$ stream or map of event streams`)
234
379
  }
235
380
  }
236
381
 
@@ -243,10 +388,10 @@ class Component {
243
388
  this.hmrActions = [this.hmrActions]
244
389
  }
245
390
  if (!Array.isArray(this.hmrActions)) {
246
- 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`)
391
+ 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`)
247
392
  }
248
393
  if (this.hmrActions.some(action => typeof action !== 'string')) {
249
- 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`)
394
+ 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`)
250
395
  }
251
396
  this.hmrAction$ = xs.fromArray(this.hmrActions.map(action => ({ type: action })))
252
397
  }
@@ -285,7 +430,15 @@ class Component {
285
430
  const hydrate$ = initialApiData.map(data => ({ type: HYDRATE_ACTION, data }))
286
431
 
287
432
  this.action$ = xs.merge(wrapped$, hydrate$)
288
- .compose(this.log(({ type }) => `<${ type }> Action triggered`))
433
+ .compose(this.log(({ type }) => `<${type}> Action triggered`))
434
+ .map(action => {
435
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
436
+ window.__SYGNAL_DEVTOOLS__.onActionDispatched(
437
+ this._componentNumber, this.name, action.type, action.data
438
+ )
439
+ }
440
+ return action
441
+ })
289
442
  }
290
443
 
291
444
  initState() {
@@ -297,7 +450,7 @@ class Component {
297
450
  } else if (isObj(this.model[INITIALIZE_ACTION])) {
298
451
  Object.keys(this.model[INITIALIZE_ACTION]).forEach(name => {
299
452
  if (name !== this.stateSourceName) {
300
- console.warn(`${ INITIALIZE_ACTION } can only be used with the ${ this.stateSourceName } source... disregarding ${ name }`)
453
+ console.warn(`${INITIALIZE_ACTION} can only be used with the ${this.stateSourceName} source... disregarding ${name}`)
301
454
  delete this.model[INITIALIZE_ACTION][name]
302
455
  }
303
456
  })
@@ -332,7 +485,7 @@ class Component {
332
485
  } else if (valueType === 'function') {
333
486
  _value = value(state)
334
487
  } else {
335
- console.error(`[${ this.name }] Invalid context entry '${ name }': must be the name of a state property or a function returning a value to use`)
488
+ console.error(`[${this.name}] Invalid context entry '${name}': must be the name of a state property or a function returning a value to use`)
336
489
  return acc
337
490
  }
338
491
  acc[name] = _value
@@ -340,11 +493,14 @@ class Component {
340
493
  }, {})
341
494
  const newContext = { ..._parent, ...values }
342
495
  this.currentContext = newContext
496
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
497
+ window.__SYGNAL_DEVTOOLS__.onContextChanged(this._componentNumber, this.name, newContext)
498
+ }
343
499
  return newContext
344
500
  })
345
501
  .compose(dropRepeats(objIsEqual))
346
502
  .startWith({})
347
- this.context$.subscribe({ next: _ => _ })
503
+ this.context$.subscribe({ next: _ => _, error: err => console.error(`[${this.name}] Error in context stream:`, err) })
348
504
  }
349
505
 
350
506
  initModel$() {
@@ -360,7 +516,7 @@ class Component {
360
516
  const effectiveInitialState = (typeof hmrState !== 'undefined') ? hmrState : this.initialState
361
517
  const initial = { type: INITIALIZE_ACTION, data: effectiveInitialState }
362
518
  if (this.isSubComponent && this.initialState) {
363
- console.warn(`[${ this.name }] Initial state provided to sub-component. This will overwrite any state provided by the parent component.`)
519
+ console.warn(`[${this.name}] Initial state provided to sub-component. This will overwrite any state provided by the parent component.`)
364
520
  }
365
521
  const hasInitialState = (typeof effectiveInitialState !== 'undefined')
366
522
  const shouldInjectInitialState = hasInitialState && (ENVIRONMENT?.__SYGNAL_HMR_UPDATING !== true || typeof hmrState !== 'undefined')
@@ -381,7 +537,7 @@ class Component {
381
537
  }
382
538
 
383
539
  if (!isObj(sinks)) {
384
- throw new Error(`Entry for each action must be an object: ${ this.name } ${ action }`)
540
+ throw new Error(`[${this.name}] Entry for each action must be an object: ${action}`)
385
541
  }
386
542
 
387
543
  const sinkEntries = Object.entries(sinks)
@@ -398,12 +554,12 @@ class Component {
398
554
  const wrapped$ = on$
399
555
  .compose(this.log(data => {
400
556
  if (isStateSink) {
401
- return `<${ action }> State reducer added`
557
+ return `<${action}> State reducer added`
402
558
  } else if (isParentSink) {
403
- return `<${ action }> Data sent to parent component: ${ JSON.stringify(data.value).replaceAll('"', '') }`
559
+ return `<${action}> Data sent to parent component: ${JSON.stringify(data.value).replaceAll('"', '')}`
404
560
  } else {
405
561
  const extra = data && (data.type || data.command || data.name || data.key || (Array.isArray(data) && 'Array') || data)
406
- return `<${ action }> Data sent to [${ sink }]: ${ JSON.stringify(extra).replaceAll('"', '') }`
562
+ return `<${action}> Data sent to [${sink}]: ${JSON.stringify(extra).replaceAll('"', '')}`
407
563
  }
408
564
  }))
409
565
 
@@ -481,7 +637,7 @@ class Component {
481
637
 
482
638
  }
483
639
  })
484
- subComponentSink$.subscribe({ next: _ => _ })
640
+ subComponentSink$.subscribe({ next: _ => _, error: err => console.error(`[${this.name}] Error in sub-component sink stream:`, err) })
485
641
  this.subComponentSink$ = subComponentSink$.filter(sinks => Object.keys(sinks).length > 0)
486
642
  }
487
643
 
@@ -544,13 +700,13 @@ class Component {
544
700
  if (typeof reducer === 'function') {
545
701
  returnStream$ = filtered$.map(action => {
546
702
  const next = (type, data, delay=10) => {
547
- 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.`)
703
+ 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.`)
548
704
  // put the "next" action request at the end of the event loop so the "current" action completes first
549
705
  setTimeout(() => {
550
706
  // push the "next" action request into the action$ stream
551
707
  rootAction$.shamefullySendNext({ type, data })
552
708
  }, delay)
553
- this.log(`<${ name }> Triggered a next() action: <${ type }> ${ delay }ms delay`, true)
709
+ this.log(`<${name}> Triggered a next() action: <${type}> ${delay}ms delay`, true)
554
710
  }
555
711
 
556
712
  const props = { ...this.currentProps, children: this.currentChildren, context: this.currentContext }
@@ -562,7 +718,7 @@ class Component {
562
718
  const enhancedState = this.addCalculated(_state)
563
719
  props.state = enhancedState
564
720
  const newState = reducer(enhancedState, data, next, props)
565
- if (newState == ABORT) return _state
721
+ if (newState === ABORT) return _state
566
722
  return this.cleanupCalculated(newState)
567
723
  }
568
724
  } else {
@@ -571,13 +727,13 @@ class Component {
571
727
  const reduced = reducer(enhancedState, data, next, props)
572
728
  const type = typeof reduced
573
729
  if (isObj(reduced) || ['string', 'number', 'boolean', 'function'].includes(type)) return reduced
574
- if (type == 'undefined') {
575
- console.warn(`'undefined' value sent to ${ name }`)
730
+ if (type === 'undefined') {
731
+ console.warn(`[${this.name}] 'undefined' value sent to ${name}`)
576
732
  return reduced
577
733
  }
578
- throw new Error(`Invalid reducer type for ${ name } ${ type }`)
734
+ throw new Error(`[${this.name}] Invalid reducer type for action '${name}': ${type}`)
579
735
  }
580
- }).filter(result => result != ABORT)
736
+ }).filter(result => result !== ABORT)
581
737
  } else if (reducer === undefined || reducer === true) {
582
738
  returnStream$ = filtered$.map(({data}) => data)
583
739
  } else {
@@ -598,7 +754,7 @@ class Component {
598
754
  if (state === lastState) {
599
755
  return lastResult
600
756
  }
601
- if (!isObj(this.calculated)) throw new Error(`'calculated' parameter must be an object mapping calculated state field named to functions`)
757
+ if (!isObj(this.calculated)) throw new Error(`[${this.name}] 'calculated' parameter must be an object mapping calculated state field names to functions`)
602
758
 
603
759
  const calculated = this.getCalculatedValues(state)
604
760
  if (!calculated) {
@@ -617,19 +773,55 @@ class Component {
617
773
  }
618
774
 
619
775
  getCalculatedValues(state) {
620
- const entries = Object.entries(this.calculated || {})
621
- if (entries.length === 0) {
776
+ if (!this._calculatedOrder || this._calculatedOrder.length === 0) {
622
777
  return
623
778
  }
624
- return entries.reduce((acc, [field, fn]) => {
625
- if (typeof fn !== 'function') throw new Error(`Missing or invalid calculator function for calculated field '${ field }`)
626
- try {
627
- acc[field] = fn(state)
628
- } catch(e) {
629
- console.warn(`Calculated field '${ field }' threw an error during calculation: ${ e.message }`)
779
+
780
+ const mergedState = { ...state }
781
+ const computedSoFar = {}
782
+
783
+ for (const [field, { fn, deps }] of this._calculatedOrder) {
784
+ if (deps !== null && this._calculatedFieldCache) {
785
+ const cache = this._calculatedFieldCache[field]
786
+ const currentDepValues = deps.map(d => mergedState[d])
787
+
788
+ if (cache.lastDepValues !== undefined) {
789
+ let unchanged = true
790
+ for (let i = 0; i < currentDepValues.length; i++) {
791
+ if (currentDepValues[i] !== cache.lastDepValues[i]) {
792
+ unchanged = false
793
+ break
794
+ }
795
+ }
796
+ if (unchanged) {
797
+ computedSoFar[field] = cache.lastResult
798
+ mergedState[field] = cache.lastResult
799
+ continue
800
+ }
801
+ }
802
+
803
+ try {
804
+ const result = fn(mergedState)
805
+ cache.lastDepValues = currentDepValues
806
+ cache.lastResult = result
807
+ computedSoFar[field] = result
808
+ mergedState[field] = result
809
+ } catch (e) {
810
+ console.warn(`Calculated field '${field}' threw an error during calculation: ${e.message}`)
811
+ }
812
+ } else {
813
+ // No deps declared — always recompute
814
+ try {
815
+ const result = fn(mergedState)
816
+ computedSoFar[field] = result
817
+ mergedState[field] = result
818
+ } catch (e) {
819
+ console.warn(`Calculated field '${field}' threw an error during calculation: ${e.message}`)
820
+ }
630
821
  }
631
- return acc
632
- }, {})
822
+ }
823
+
824
+ return computedSoFar
633
825
  }
634
826
 
635
827
  cleanupCalculated(incomingState) {
@@ -777,7 +969,7 @@ class Component {
777
969
  this.newChildSources(childSources)
778
970
 
779
971
 
780
- if (newInstanceCount > 0) this.log(`New sub components instantiated: ${ newInstanceCount }`, true)
972
+ if (newInstanceCount > 0) this.log(`New sub components instantiated: ${newInstanceCount}`, true)
781
973
 
782
974
  return newComponents
783
975
  }, {})
@@ -843,7 +1035,7 @@ class Component {
843
1035
  } else if (this.components[collectionOf]) {
844
1036
  factory = this.components[collectionOf]
845
1037
  } else {
846
- throw new Error(`[${this.name}] Invalid 'of' propery in collection: ${ collectionOf }`)
1038
+ throw new Error(`[${this.name}] Invalid 'of' property in collection: ${collectionOf}`)
847
1039
  }
848
1040
 
849
1041
  const fieldLense = {
@@ -851,7 +1043,7 @@ class Component {
851
1043
  if (!Array.isArray(state[stateField])) return []
852
1044
  const items = state[stateField]
853
1045
  const filtered = typeof arrayOperators.filter === 'function' ? items.filter(arrayOperators.filter) : items
854
- const sorted = typeof arrayOperators.sort ? filtered.sort(arrayOperators.sort) : filtered
1046
+ const sorted = typeof arrayOperators.sort === 'function' ? filtered.sort(arrayOperators.sort) : filtered
855
1047
  const mapped = sorted.map((item, index) => {
856
1048
  return (isObj(item)) ? { ...item, [idField]: item[idField] || index } : { value: item, [idField]: index }
857
1049
  })
@@ -860,7 +1052,7 @@ class Component {
860
1052
  },
861
1053
  set: (oldState, newState) => {
862
1054
  if (this.calculated && stateField in this.calculated) {
863
- console.warn(`Collection sub-component of ${ this.name } attempted to update state on a calculated field '${ stateField }': Update ignored`)
1055
+ console.warn(`Collection sub-component of ${this.name} attempted to update state on a calculated field '${stateField}': Update ignored`)
864
1056
  return oldState
865
1057
  }
866
1058
  const updated = []
@@ -889,17 +1081,17 @@ class Component {
889
1081
  } else if (typeof stateField === 'string') {
890
1082
  if (isObj(this.currentState)) {
891
1083
  if(!(this.currentState && stateField in this.currentState) && !(this.calculated && stateField in this.calculated)) {
892
- 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.`)
1084
+ 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.`)
893
1085
  lense = undefined
894
1086
  } else if (!Array.isArray(this.currentState[stateField])) {
895
- console.warn(`State property '${ stateField }' in collection comopnent of ${ this.name } is not an array: No components will be instantiated in the collection.`)
1087
+ console.warn(`[${this.name}] State property '${stateField}' in collection component is not an array: No components will be instantiated in the collection.`)
896
1088
  lense = fieldLense
897
1089
  } else {
898
1090
  lense = fieldLense
899
1091
  }
900
1092
  } else {
901
1093
  if (!Array.isArray(this.currentState[stateField])) {
902
- console.warn(`State property '${ stateField }' in collection component of ${ this.name } is not an array: No components will be instantiated in the collection.`)
1094
+ console.warn(`[${this.name}] State property '${stateField}' in collection component is not an array: No components will be instantiated in the collection.`)
903
1095
  lense = fieldLense
904
1096
  } else {
905
1097
  lense = fieldLense
@@ -907,14 +1099,14 @@ class Component {
907
1099
  }
908
1100
  } else if (isObj(stateField)) {
909
1101
  if (typeof stateField.get !== 'function') {
910
- 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.`)
1102
+ 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.`)
911
1103
  lense = undefined
912
1104
  } else {
913
1105
  lense = {
914
1106
  get: (state) => {
915
1107
  const newState = stateField.get(state)
916
1108
  if (!Array.isArray(newState)) {
917
- 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)
1109
+ 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)
918
1110
  return []
919
1111
  }
920
1112
  return newState
@@ -923,14 +1115,14 @@ class Component {
923
1115
  }
924
1116
  }
925
1117
  } else {
926
- 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.`)
1118
+ 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.`)
927
1119
  lense = undefined
928
1120
  }
929
1121
 
930
- const sources = { ...this.sources, [this.stateSourceName]: stateSource, props$, children$, __parentContext$: this.context$, PARENT: null }
1122
+ const sources = { ...this.sources, [this.stateSourceName]: stateSource, props$, children$, __parentContext$: this.context$, PARENT: null, __parentComponentNumber: this._componentNumber }
931
1123
  const sink$ = collection(factory, lense, { container: null })(sources)
932
1124
  if (!isObj(sink$)) {
933
- throw new Error('Invalid sinks returned from component factory of collection element')
1125
+ throw new Error(`[${this.name}] Invalid sinks returned from component factory of collection element`)
934
1126
  }
935
1127
  return sink$
936
1128
  }
@@ -952,7 +1144,7 @@ class Component {
952
1144
  get: state => state[stateField],
953
1145
  set: (oldState, newState) => {
954
1146
  if (this.calculated && stateField in this.calculated) {
955
- console.warn(`Switchable sub-component of ${ this.name } attempted to update state on a calculated field '${ stateField }': Update ignored`)
1147
+ console.warn(`Switchable sub-component of ${this.name} attempted to update state on a calculated field '${stateField}': Update ignored`)
956
1148
  return oldState
957
1149
  }
958
1150
  if (!isObj(newState) || Array.isArray(newState)) return { ...oldState, [stateField]: newState }
@@ -971,13 +1163,13 @@ class Component {
971
1163
  lense = fieldLense
972
1164
  } else if (isObj(stateField)) {
973
1165
  if (typeof stateField.get !== 'function') {
974
- 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.`)
1166
+ 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.`)
975
1167
  lense = baseLense
976
1168
  } else {
977
1169
  lense = { get: stateField.get, set: stateField.set }
978
1170
  }
979
1171
  } else {
980
- 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.`)
1172
+ 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.`)
981
1173
  lense = baseLense
982
1174
  }
983
1175
 
@@ -993,12 +1185,12 @@ class Component {
993
1185
  switchableComponents[key] = component(options)
994
1186
  }
995
1187
  })
996
- const sources = { ...this.sources, [this.stateSourceName]: stateSource, props$, children$, __parentContext$: this.context$ }
1188
+ const sources = { ...this.sources, [this.stateSourceName]: stateSource, props$, children$, __parentContext$: this.context$, __parentComponentNumber: this._componentNumber }
997
1189
 
998
1190
  const sink$ = isolate(switchable(switchableComponents, props$.map(props => props.current)), { [this.stateSourceName]: lense })(sources)
999
1191
 
1000
1192
  if (!isObj(sink$)) {
1001
- throw new Error('Invalid sinks returned from component factory of switchable element')
1193
+ throw new Error(`[${this.name}] Invalid sinks returned from component factory of switchable element`)
1002
1194
  }
1003
1195
 
1004
1196
  return sink$
@@ -1024,7 +1216,7 @@ class Component {
1024
1216
  const factory = componentName === 'sygnal-factory' ? props.sygnalFactory : (this.components[componentName] || props.sygnalFactory)
1025
1217
  if (!factory) {
1026
1218
  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.`)
1027
- throw new Error(`Component not found: ${ componentName }`)
1219
+ throw new Error(`Component not found: ${componentName}`)
1028
1220
  }
1029
1221
 
1030
1222
  let lense
@@ -1033,7 +1225,7 @@ class Component {
1033
1225
  get: state => state[stateField],
1034
1226
  set: (oldState, newState) => {
1035
1227
  if (this.calculated && stateField in this.calculated) {
1036
- console.warn(`Sub-component of ${ this.name } attempted to update state on a calculated field '${ stateField }': Update ignored`)
1228
+ console.warn(`Sub-component of ${this.name} attempted to update state on a calculated field '${stateField}': Update ignored`)
1037
1229
  return oldState
1038
1230
  }
1039
1231
  return { ...oldState, [stateField]: newState }
@@ -1051,17 +1243,17 @@ class Component {
1051
1243
  lense = fieldLense
1052
1244
  } else if (isObj(stateField)) {
1053
1245
  if (typeof stateField.get !== 'function') {
1054
- 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.`)
1246
+ 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.`)
1055
1247
  lense = baseLense
1056
1248
  } else {
1057
1249
  lense = { get: stateField.get, set: stateField.set }
1058
1250
  }
1059
1251
  } else {
1060
- 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.`)
1252
+ 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.`)
1061
1253
  lense = baseLense
1062
1254
  }
1063
1255
 
1064
- const sources = { ...this.sources, [this.stateSourceName]: stateSource, props$, children$, __parentContext$: this.context$ }
1256
+ const sources = { ...this.sources, [this.stateSourceName]: stateSource, props$, children$, __parentContext$: this.context$, __parentComponentNumber: this._componentNumber }
1065
1257
  const sink$ = isolate(factory, { [this.stateSourceName]: lense })(sources)
1066
1258
 
1067
1259
  if (!isObj(sink$)) {
@@ -1136,14 +1328,22 @@ class Component {
1136
1328
  const fixedMsg = (typeof msg === 'function') ? msg : _ => msg
1137
1329
  if (immediate) {
1138
1330
  if (this.debug) {
1139
- console.log(`[${context}] ${fixedMsg(msg)}`)
1331
+ const text = `[${context}] ${fixedMsg(msg)}`
1332
+ console.log(text)
1333
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
1334
+ window.__SYGNAL_DEVTOOLS__.onDebugLog(this._componentNumber, text)
1335
+ }
1140
1336
  }
1141
1337
  return
1142
1338
  } else {
1143
1339
  return stream => {
1144
1340
  return stream.debug(msg => {
1145
1341
  if (this.debug) {
1146
- console.log(`[${context}] ${fixedMsg(msg)}`)
1342
+ const text = `[${context}] ${fixedMsg(msg)}`
1343
+ console.log(text)
1344
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
1345
+ window.__SYGNAL_DEVTOOLS__.onDebugLog(this._componentNumber, text)
1346
+ }
1147
1347
  }
1148
1348
  })
1149
1349
  }
@@ -1153,11 +1353,11 @@ class Component {
1153
1353
 
1154
1354
 
1155
1355
 
1156
- function getComponents(currentElement, componentNames, depth=0, index=0, parentId) {
1356
+ function getComponents(currentElement, componentNames, path='r', parentId) {
1157
1357
  if (!currentElement) return {}
1158
1358
 
1159
1359
  if (currentElement.data?.componentsProcessed) return {}
1160
- if (depth === 0) currentElement.data.componentsProcessed = true
1360
+ if (path === 'r') currentElement.data.componentsProcessed = true
1161
1361
 
1162
1362
  const sel = currentElement.sel
1163
1363
  const isCollection = sel && sel.toLowerCase() === 'collection'
@@ -1171,11 +1371,11 @@ function getComponents(currentElement, componentNames, depth=0, index=0, parentI
1171
1371
 
1172
1372
  let id = parentId
1173
1373
  if (isComponent) {
1174
- id = getComponentIdFromElement(currentElement, depth, index, parentId)
1374
+ id = getComponentIdFromElement(currentElement, path, parentId)
1175
1375
  if (isCollection) {
1176
1376
  if (!props.of) throw new Error(`Collection element missing required 'component' property`)
1177
1377
  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`)
1178
- if (typeof props.of !== 'function' && !componentNames.includes(props.of)) throw new Error(`Specified component for collection not found: ${ props.of }`)
1378
+ if (typeof props.of !== 'function' && !componentNames.includes(props.of)) throw new Error(`Specified component for collection not found: ${props.of}`)
1179
1379
  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)
1180
1380
  currentElement.data.isCollection = true
1181
1381
  currentElement.data.props ||= {}
@@ -1186,7 +1386,7 @@ function getComponents(currentElement, componentNames, depth=0, index=0, parentI
1186
1386
  if (!switchableComponents.every(comp => typeof comp === 'function')) throw new Error(`One or more components provided to switchable element is not a valid component factory`)
1187
1387
  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`)
1188
1388
  const switchableComponentNames = Object.keys(props.of)
1189
- if (!switchableComponentNames.includes(props.current)) throw new Error(`Component '${ props.current }' not found in switchable element`)
1389
+ if (!switchableComponentNames.includes(props.current)) throw new Error(`Component '${props.current}' not found in switchable element`)
1190
1390
  currentElement.data.isSwitchable = true
1191
1391
  } else {
1192
1392
 
@@ -1196,7 +1396,7 @@ function getComponents(currentElement, componentNames, depth=0, index=0, parentI
1196
1396
  }
1197
1397
 
1198
1398
  if (children.length > 0) {
1199
- children.map((child, i) => getComponents(child, componentNames, depth + 1, index + i, id))
1399
+ children.map((child, i) => getComponents(child, componentNames, `${path}.${i}`, id))
1200
1400
  .forEach((child) => {
1201
1401
  Object.entries(child).forEach(([id, el]) => found[id] = el)
1202
1402
  })
@@ -1205,10 +1405,10 @@ function getComponents(currentElement, componentNames, depth=0, index=0, parentI
1205
1405
  return found
1206
1406
  }
1207
1407
 
1208
- function injectComponents(currentElement, components, componentNames, depth=0, index=0, parentId) {
1408
+ function injectComponents(currentElement, components, componentNames, path='r', parentId) {
1209
1409
  if (!currentElement) return
1210
1410
  if (currentElement.data?.componentsInjected) return currentElement
1211
- if (depth === 0 && currentElement.data) currentElement.data.componentsInjected = true
1411
+ if (path === 'r' && currentElement.data) currentElement.data.componentsInjected = true
1212
1412
 
1213
1413
 
1214
1414
  const sel = currentElement.sel || 'NO SELECTOR'
@@ -1220,7 +1420,7 @@ function injectComponents(currentElement, components, componentNames, depth=0, i
1220
1420
 
1221
1421
  let id = parentId
1222
1422
  if (isComponent) {
1223
- id = getComponentIdFromElement(currentElement, depth, index, parentId)
1423
+ id = getComponentIdFromElement(currentElement, path, parentId)
1224
1424
  const component = components[id]
1225
1425
  if (isCollection) {
1226
1426
  currentElement.sel = 'div'
@@ -1232,21 +1432,20 @@ function injectComponents(currentElement, components, componentNames, depth=0, i
1232
1432
  return component
1233
1433
  }
1234
1434
  } else if (children.length > 0) {
1235
- currentElement.children = children.map((child, i) => injectComponents(child, components, componentNames, depth + 1, index + i, id)).flat()
1435
+ currentElement.children = children.map((child, i) => injectComponents(child, components, componentNames, `${path}.${i}`, id)).flat()
1236
1436
  return currentElement
1237
1437
  } else {
1238
1438
  return currentElement
1239
1439
  }
1240
1440
  }
1241
1441
 
1242
- function getComponentIdFromElement(el, depth, index, parentId) {
1442
+ function getComponentIdFromElement(el, path, parentId) {
1243
1443
  const sel = el.sel
1244
1444
  const name = typeof sel === 'string' ? sel : 'functionComponent'
1245
- const uid = `${depth}:${index}`
1246
1445
  const props = el.data?.props || {}
1247
- const id = (props.id && JSON.stringify(props.id).replaceAll('"', '')) || uid
1248
- const parentString = parentId ? `${ parentId }|` : ''
1249
- const fullId = `${ parentString }${ name }::${ id }`
1446
+ const id = (props.id && JSON.stringify(props.id).replaceAll('"', '')) || path
1447
+ const parentString = parentId ? `${parentId}|` : ''
1448
+ const fullId = `${parentString}${name}::${id}`
1250
1449
  return fullId
1251
1450
  }
1252
1451
 
@@ -1390,7 +1589,7 @@ function sortFunctionFromProp(sortProp) {
1390
1589
  } else if (isObj(sortProp)) {
1391
1590
  return __sortFunctionFromObj(sortProp)
1392
1591
  } else {
1393
- console.error('Invalid sort option (ignoring):', item)
1592
+ console.error('Invalid sort option (ignoring):', sortProp)
1394
1593
  return undefined
1395
1594
  }
1396
1595
  }