sygnal 4.2.1 → 4.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.
@@ -111,7 +111,8 @@ function eventBusDriver(out$) {
111
111
  const events = new EventTarget();
112
112
 
113
113
  out$.subscribe({
114
- next: event => events.dispatchEvent(new CustomEvent('data', { detail: event }))
114
+ next: event => events.dispatchEvent(new CustomEvent('data', { detail: event })),
115
+ error: err => console.error('[EVENTS driver] Error in sink stream:', err)
115
116
  });
116
117
 
117
118
  return {
@@ -139,11 +140,19 @@ function logDriver(out$) {
139
140
  out$.addListener({
140
141
  next: (val) => {
141
142
  console.log(val);
143
+ },
144
+ error: (err) => {
145
+ console.error('[LOG driver] Error in sink stream:', err);
142
146
  }
143
147
  });
144
148
  }
145
149
 
150
+ let COLLECTION_COUNT = 0;
151
+
146
152
  function collection(component, stateLense, opts={}) {
153
+ if (typeof component !== 'function') {
154
+ throw new Error('collection: first argument (component) must be a function')
155
+ }
147
156
  const {
148
157
  combineList = ['DOM'],
149
158
  globalList = ['EVENTS'],
@@ -154,7 +163,7 @@ function collection(component, stateLense, opts={}) {
154
163
  } = opts;
155
164
 
156
165
  return (sources) => {
157
- const key = Date.now();
166
+ const key = `sygnal-collection-${COLLECTION_COUNT++}`;
158
167
  const collectionOpts = {
159
168
  item: component,
160
169
  itemKey: (state, ind) => typeof state.id !== 'undefined' ? state.id : ind,
@@ -2925,7 +2934,7 @@ function switchable(factories, name$, initial, opts={}) {
2925
2934
  const mapFunction = (nameType === 'function' && name$) || (state => state[name$]);
2926
2935
  return sources => {
2927
2936
  const state$ = sources && ((typeof stateSourceName === 'string' && sources[stateSourceName]) || sources.STATE || sources.state).stream;
2928
- if (!state$ instanceof Stream$1) throw new Error(`Could not find the state source: ${ stateSourceName }`)
2937
+ if (!(state$ instanceof Stream$1)) throw new Error(`Could not find the state source: ${stateSourceName}`)
2929
2938
  const _name$ = state$
2930
2939
  .map(mapFunction)
2931
2940
  .filter(name => typeof name === 'string')
@@ -3332,13 +3341,27 @@ function wrapDOMSource(domSource) {
3332
3341
  }
3333
3342
 
3334
3343
 
3335
- const ABORT = '~#~#~ABORT~#~#~';
3344
+ const ABORT = Symbol('ABORT');
3345
+
3346
+
3347
+ function normalizeCalculatedEntry(field, entry) {
3348
+ if (typeof entry === 'function') {
3349
+ return { fn: entry, deps: null }
3350
+ }
3351
+ if (Array.isArray(entry) && entry.length === 2
3352
+ && Array.isArray(entry[0]) && typeof entry[1] === 'function') {
3353
+ return { fn: entry[1], deps: entry[0] }
3354
+ }
3355
+ throw new Error(
3356
+ `Invalid calculated field '${field}': expected a function or [depsArray, function]`
3357
+ )
3358
+ }
3336
3359
 
3337
3360
  function component (opts) {
3338
3361
  const { name, sources, isolateOpts, stateSourceName='STATE' } = opts;
3339
3362
 
3340
3363
  if (sources && !isObj(sources)) {
3341
- throw new Error('Sources must be a Cycle.js sources object:', name)
3364
+ throw new Error(`[${name}] Sources must be a Cycle.js sources object`)
3342
3365
  }
3343
3366
 
3344
3367
  let fixedIsolateOpts;
@@ -3418,7 +3441,9 @@ class Component {
3418
3441
  // sinks
3419
3442
 
3420
3443
  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 }) {
3421
- if (!sources || !isObj(sources)) throw new Error('Missing or invalid sources')
3444
+ if (!sources || !isObj(sources)) throw new Error(`[${name}] Missing or invalid sources`)
3445
+
3446
+ this._componentNumber = COMPONENT_COUNT++;
3422
3447
 
3423
3448
  this.name = name;
3424
3449
  this.sources = sources;
@@ -3439,6 +3464,123 @@ class Component {
3439
3464
  this.sourceNames = Object.keys(sources);
3440
3465
  this._debug = debug;
3441
3466
 
3467
+ // Warn if calculated fields shadow base state keys
3468
+ if (this.calculated && this.initialState
3469
+ && isObj(this.calculated) && isObj(this.initialState)) {
3470
+ for (const key of Object.keys(this.calculated)) {
3471
+ if (key in this.initialState) {
3472
+ console.warn(
3473
+ `[${name}] Calculated field '${key}' shadows a key in initialState. ` +
3474
+ `The initialState value will be overwritten on every state update.`
3475
+ );
3476
+ }
3477
+ }
3478
+ }
3479
+
3480
+ // Normalize calculated entries, build dependency graph, topological sort
3481
+ if (this.calculated && isObj(this.calculated)) {
3482
+ const calcEntries = Object.entries(this.calculated);
3483
+
3484
+ // Normalize all entries to { fn, deps } shape
3485
+ this._calculatedNormalized = {};
3486
+ for (const [field, entry] of calcEntries) {
3487
+ this._calculatedNormalized[field] = normalizeCalculatedEntry(field, entry);
3488
+ }
3489
+
3490
+ this._calculatedFieldNames = new Set(Object.keys(this._calculatedNormalized));
3491
+
3492
+ // Warn on deps referencing nonexistent keys
3493
+ for (const [field, { deps }] of Object.entries(this._calculatedNormalized)) {
3494
+ if (deps !== null) {
3495
+ for (const dep of deps) {
3496
+ if (!this._calculatedFieldNames.has(dep)
3497
+ && this.initialState && !(dep in this.initialState)) {
3498
+ console.warn(
3499
+ `[${name}] Calculated field '${field}' declares dependency '${dep}' ` +
3500
+ `which is not in initialState or calculated fields`
3501
+ );
3502
+ }
3503
+ }
3504
+ }
3505
+ }
3506
+
3507
+ // Build adjacency: for each field, which other calculated fields must run first?
3508
+ const calcDeps = {};
3509
+ for (const [field, { deps }] of Object.entries(this._calculatedNormalized)) {
3510
+ if (deps === null) {
3511
+ calcDeps[field] = [];
3512
+ } else {
3513
+ calcDeps[field] = deps.filter(d => this._calculatedFieldNames.has(d));
3514
+ }
3515
+ }
3516
+
3517
+ // Kahn's algorithm for topological sort
3518
+ const inDegree = {};
3519
+ const reverseGraph = {};
3520
+ for (const field of this._calculatedFieldNames) {
3521
+ inDegree[field] = 0;
3522
+ reverseGraph[field] = [];
3523
+ }
3524
+ for (const [field, depList] of Object.entries(calcDeps)) {
3525
+ inDegree[field] = depList.length;
3526
+ for (const dep of depList) {
3527
+ reverseGraph[dep].push(field);
3528
+ }
3529
+ }
3530
+
3531
+ const queue = [];
3532
+ for (const [field, degree] of Object.entries(inDegree)) {
3533
+ if (degree === 0) queue.push(field);
3534
+ }
3535
+
3536
+ const sorted = [];
3537
+ while (queue.length > 0) {
3538
+ const current = queue.shift();
3539
+ sorted.push(current);
3540
+ for (const dependent of reverseGraph[current]) {
3541
+ inDegree[dependent]--;
3542
+ if (inDegree[dependent] === 0) queue.push(dependent);
3543
+ }
3544
+ }
3545
+
3546
+ if (sorted.length !== this._calculatedFieldNames.size) {
3547
+ // Cycle detected — build error message with cycle path
3548
+ const inCycle = [...this._calculatedFieldNames].filter(f => !sorted.includes(f));
3549
+ const visited = new Set();
3550
+ const path = [];
3551
+ const traceCycle = (node) => {
3552
+ if (visited.has(node)) { path.push(node); return true }
3553
+ visited.add(node);
3554
+ path.push(node);
3555
+ for (const dep of calcDeps[node]) {
3556
+ if (inCycle.includes(dep) && traceCycle(dep)) return true
3557
+ }
3558
+ path.pop();
3559
+ visited.delete(node);
3560
+ return false
3561
+ };
3562
+ traceCycle(inCycle[0]);
3563
+ const start = path[path.length - 1];
3564
+ const cycle = path.slice(path.indexOf(start));
3565
+ throw new Error(`Circular calculated dependency: ${cycle.join(' \u2192 ')}`)
3566
+ }
3567
+
3568
+ this._calculatedOrder = sorted.map(f => [f, this._calculatedNormalized[f]]);
3569
+
3570
+ // Initialize per-field memoization caches for fields with declared deps
3571
+ this._calculatedFieldCache = {};
3572
+ for (const [field, { deps }] of this._calculatedOrder) {
3573
+ if (deps !== null) {
3574
+ this._calculatedFieldCache[field] = { lastDepValues: undefined, lastResult: undefined };
3575
+ }
3576
+ }
3577
+ } else {
3578
+ this._calculatedOrder = null;
3579
+ this._calculatedNormalized = null;
3580
+ this._calculatedFieldNames = null;
3581
+ this._calculatedFieldCache = null;
3582
+ }
3583
+
3442
3584
  this.isSubComponent = this.sourceNames.includes('props$');
3443
3585
 
3444
3586
  const state$ = sources[stateSourceName] && sources[stateSourceName].stream;
@@ -3447,6 +3589,9 @@ class Component {
3447
3589
  this.currentState = initialState || {};
3448
3590
  this.sources[stateSourceName] = new state.StateSource(state$.map(val => {
3449
3591
  this.currentState = val;
3592
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
3593
+ window.__SYGNAL_DEVTOOLS__.onStateChanged(this._componentNumber, this.name, val);
3594
+ }
3450
3595
  return val
3451
3596
  }));
3452
3597
  }
@@ -3482,10 +3627,8 @@ class Component {
3482
3627
  };
3483
3628
  }
3484
3629
 
3485
- const componentNumber = COMPONENT_COUNT++;
3486
-
3487
3630
  this.addCalculated = this.createMemoizedAddCalculated();
3488
- this.log = makeLog(`${componentNumber} | ${name}`);
3631
+ this.log = makeLog(`${this._componentNumber} | ${name}`);
3489
3632
 
3490
3633
  this.initChildSources$();
3491
3634
  this.initIntent$();
@@ -3500,9 +3643,20 @@ class Component {
3500
3643
  this.initVdom$();
3501
3644
  this.initSinks();
3502
3645
 
3503
- this.sinks.__index = componentNumber;
3646
+ this.sinks.__index = this._componentNumber;
3504
3647
 
3505
3648
  this.log(`Instantiated`, true);
3649
+
3650
+ // Hook 1: Register with DevTools
3651
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__) {
3652
+ window.__SYGNAL_DEVTOOLS__.onComponentCreated(this._componentNumber, name, this);
3653
+
3654
+ // Hook 1b: Register parent-child relationship
3655
+ const parentNum = sources?.__parentComponentNumber;
3656
+ if (typeof parentNum === 'number') {
3657
+ window.__SYGNAL_DEVTOOLS__.onSubComponentRegistered(parentNum, this._componentNumber);
3658
+ }
3659
+ }
3506
3660
  }
3507
3661
 
3508
3662
  get debug() {
@@ -3514,13 +3668,13 @@ class Component {
3514
3668
  return
3515
3669
  }
3516
3670
  if (typeof this.intent != 'function') {
3517
- throw new Error('Intent must be a function')
3671
+ throw new Error(`[${this.name}] Intent must be a function`)
3518
3672
  }
3519
3673
 
3520
3674
  this.intent$ = this.intent(this.sources);
3521
3675
 
3522
3676
  if (!(this.intent$ instanceof Stream$1) && (!isObj(this.intent$))) {
3523
- throw new Error('Intent must return either an action$ stream or map of event streams')
3677
+ throw new Error(`[${this.name}] Intent must return either an action$ stream or map of event streams`)
3524
3678
  }
3525
3679
  }
3526
3680
 
@@ -3533,10 +3687,10 @@ class Component {
3533
3687
  this.hmrActions = [this.hmrActions];
3534
3688
  }
3535
3689
  if (!Array.isArray(this.hmrActions)) {
3536
- 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`)
3690
+ 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`)
3537
3691
  }
3538
3692
  if (this.hmrActions.some(action => typeof action !== 'string')) {
3539
- 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`)
3693
+ 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`)
3540
3694
  }
3541
3695
  this.hmrAction$ = xs$1.fromArray(this.hmrActions.map(action => ({ type: action })));
3542
3696
  }
@@ -3575,7 +3729,15 @@ class Component {
3575
3729
  const hydrate$ = initialApiData.map(data => ({ type: HYDRATE_ACTION, data }));
3576
3730
 
3577
3731
  this.action$ = xs$1.merge(wrapped$, hydrate$)
3578
- .compose(this.log(({ type }) => `<${ type }> Action triggered`));
3732
+ .compose(this.log(({ type }) => `<${type}> Action triggered`))
3733
+ .map(action => {
3734
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
3735
+ window.__SYGNAL_DEVTOOLS__.onActionDispatched(
3736
+ this._componentNumber, this.name, action.type, action.data
3737
+ );
3738
+ }
3739
+ return action
3740
+ });
3579
3741
  }
3580
3742
 
3581
3743
  initState() {
@@ -3587,7 +3749,7 @@ class Component {
3587
3749
  } else if (isObj(this.model[INITIALIZE_ACTION])) {
3588
3750
  Object.keys(this.model[INITIALIZE_ACTION]).forEach(name => {
3589
3751
  if (name !== this.stateSourceName) {
3590
- console.warn(`${ INITIALIZE_ACTION } can only be used with the ${ this.stateSourceName } source... disregarding ${ name }`);
3752
+ console.warn(`${INITIALIZE_ACTION} can only be used with the ${this.stateSourceName} source... disregarding ${name}`);
3591
3753
  delete this.model[INITIALIZE_ACTION][name];
3592
3754
  }
3593
3755
  });
@@ -3622,7 +3784,7 @@ class Component {
3622
3784
  } else if (valueType === 'function') {
3623
3785
  _value = value(state);
3624
3786
  } else {
3625
- console.error(`[${ this.name }] Invalid context entry '${ name }': must be the name of a state property or a function returning a value to use`);
3787
+ console.error(`[${this.name}] Invalid context entry '${name}': must be the name of a state property or a function returning a value to use`);
3626
3788
  return acc
3627
3789
  }
3628
3790
  acc[name] = _value;
@@ -3630,11 +3792,14 @@ class Component {
3630
3792
  }, {});
3631
3793
  const newContext = { ..._parent, ...values };
3632
3794
  this.currentContext = newContext;
3795
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
3796
+ window.__SYGNAL_DEVTOOLS__.onContextChanged(this._componentNumber, this.name, newContext);
3797
+ }
3633
3798
  return newContext
3634
3799
  })
3635
3800
  .compose(dropRepeats(objIsEqual))
3636
3801
  .startWith({});
3637
- this.context$.subscribe({ next: _ => _ });
3802
+ this.context$.subscribe({ next: _ => _, error: err => console.error(`[${this.name}] Error in context stream:`, err) });
3638
3803
  }
3639
3804
 
3640
3805
  initModel$() {
@@ -3650,7 +3815,7 @@ class Component {
3650
3815
  const effectiveInitialState = (typeof hmrState !== 'undefined') ? hmrState : this.initialState;
3651
3816
  const initial = { type: INITIALIZE_ACTION, data: effectiveInitialState };
3652
3817
  if (this.isSubComponent && this.initialState) {
3653
- console.warn(`[${ this.name }] Initial state provided to sub-component. This will overwrite any state provided by the parent component.`);
3818
+ console.warn(`[${this.name}] Initial state provided to sub-component. This will overwrite any state provided by the parent component.`);
3654
3819
  }
3655
3820
  const hasInitialState = (typeof effectiveInitialState !== 'undefined');
3656
3821
  const shouldInjectInitialState = hasInitialState && (ENVIRONMENT?.__SYGNAL_HMR_UPDATING !== true || typeof hmrState !== 'undefined');
@@ -3671,7 +3836,7 @@ class Component {
3671
3836
  }
3672
3837
 
3673
3838
  if (!isObj(sinks)) {
3674
- throw new Error(`Entry for each action must be an object: ${ this.name } ${ action }`)
3839
+ throw new Error(`[${this.name}] Entry for each action must be an object: ${action}`)
3675
3840
  }
3676
3841
 
3677
3842
  const sinkEntries = Object.entries(sinks);
@@ -3683,17 +3848,17 @@ class Component {
3683
3848
  const isParentSink = (sink === PARENT_SINK_NAME);
3684
3849
 
3685
3850
  const on = isStateSink ? onState() : onNormal();
3686
- const on$ = isParentSink ? on(action, reducer).map(value => ({ name: this.name, value })) : on(action, reducer);
3851
+ const on$ = isParentSink ? on(action, reducer).map(value => ({ name: this.name, component: this.view, value })) : on(action, reducer);
3687
3852
 
3688
3853
  const wrapped$ = on$
3689
3854
  .compose(this.log(data => {
3690
3855
  if (isStateSink) {
3691
- return `<${ action }> State reducer added`
3856
+ return `<${action}> State reducer added`
3692
3857
  } else if (isParentSink) {
3693
- return `<${ action }> Data sent to parent component: ${ JSON.stringify(data.value).replaceAll('"', '') }`
3858
+ return `<${action}> Data sent to parent component: ${JSON.stringify(data.value).replaceAll('"', '')}`
3694
3859
  } else {
3695
3860
  const extra = data && (data.type || data.command || data.name || data.key || (Array.isArray(data) && 'Array') || data);
3696
- return `<${ action }> Data sent to [${ sink }]: ${ JSON.stringify(extra).replaceAll('"', '') }`
3861
+ return `<${action}> Data sent to [${sink}]: ${JSON.stringify(extra).replaceAll('"', '')}`
3697
3862
  }
3698
3863
  }));
3699
3864
 
@@ -3749,9 +3914,13 @@ class Component {
3749
3914
  }).map(sources => xs$1.merge(...sources)).flatten();
3750
3915
 
3751
3916
  this.sources[CHILD_SOURCE_NAME] = {
3752
- select: (name) => {
3917
+ select: (nameOrComponent) => {
3753
3918
  const all$ = childSources$;
3754
- const filtered$ = name ? all$.filter(entry => entry.name === name) : all$;
3919
+ const filtered$ = typeof nameOrComponent === 'function'
3920
+ ? all$.filter(entry => entry.component === nameOrComponent)
3921
+ : nameOrComponent
3922
+ ? all$.filter(entry => entry.name === nameOrComponent)
3923
+ : all$;
3755
3924
  const unwrapped$ = filtered$.map(entry => entry.value);
3756
3925
  return unwrapped$
3757
3926
  }
@@ -3771,7 +3940,7 @@ class Component {
3771
3940
 
3772
3941
  }
3773
3942
  });
3774
- subComponentSink$.subscribe({ next: _ => _ });
3943
+ subComponentSink$.subscribe({ next: _ => _, error: err => console.error(`[${this.name}] Error in sub-component sink stream:`, err) });
3775
3944
  this.subComponentSink$ = subComponentSink$.filter(sinks => Object.keys(sinks).length > 0);
3776
3945
  }
3777
3946
 
@@ -3834,13 +4003,13 @@ class Component {
3834
4003
  if (typeof reducer === 'function') {
3835
4004
  returnStream$ = filtered$.map(action => {
3836
4005
  const next = (type, data, delay=10) => {
3837
- 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.`)
4006
+ 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.`)
3838
4007
  // put the "next" action request at the end of the event loop so the "current" action completes first
3839
4008
  setTimeout(() => {
3840
4009
  // push the "next" action request into the action$ stream
3841
4010
  rootAction$.shamefullySendNext({ type, data });
3842
4011
  }, delay);
3843
- this.log(`<${ name }> Triggered a next() action: <${ type }> ${ delay }ms delay`, true);
4012
+ this.log(`<${name}> Triggered a next() action: <${type}> ${delay}ms delay`, true);
3844
4013
  };
3845
4014
 
3846
4015
  const props = { ...this.currentProps, children: this.currentChildren, context: this.currentContext };
@@ -3852,7 +4021,7 @@ class Component {
3852
4021
  const enhancedState = this.addCalculated(_state);
3853
4022
  props.state = enhancedState;
3854
4023
  const newState = reducer(enhancedState, data, next, props);
3855
- if (newState == ABORT) return _state
4024
+ if (newState === ABORT) return _state
3856
4025
  return this.cleanupCalculated(newState)
3857
4026
  }
3858
4027
  } else {
@@ -3861,13 +4030,13 @@ class Component {
3861
4030
  const reduced = reducer(enhancedState, data, next, props);
3862
4031
  const type = typeof reduced;
3863
4032
  if (isObj(reduced) || ['string', 'number', 'boolean', 'function'].includes(type)) return reduced
3864
- if (type == 'undefined') {
3865
- console.warn(`'undefined' value sent to ${ name }`);
4033
+ if (type === 'undefined') {
4034
+ console.warn(`[${this.name}] 'undefined' value sent to ${name}`);
3866
4035
  return reduced
3867
4036
  }
3868
- throw new Error(`Invalid reducer type for ${ name } ${ type }`)
4037
+ throw new Error(`[${this.name}] Invalid reducer type for action '${name}': ${type}`)
3869
4038
  }
3870
- }).filter(result => result != ABORT);
4039
+ }).filter(result => result !== ABORT);
3871
4040
  } else if (reducer === undefined || reducer === true) {
3872
4041
  returnStream$ = filtered$.map(({data}) => data);
3873
4042
  } else {
@@ -3888,7 +4057,7 @@ class Component {
3888
4057
  if (state === lastState) {
3889
4058
  return lastResult
3890
4059
  }
3891
- if (!isObj(this.calculated)) throw new Error(`'calculated' parameter must be an object mapping calculated state field named to functions`)
4060
+ if (!isObj(this.calculated)) throw new Error(`[${this.name}] 'calculated' parameter must be an object mapping calculated state field names to functions`)
3892
4061
 
3893
4062
  const calculated = this.getCalculatedValues(state);
3894
4063
  if (!calculated) {
@@ -3907,19 +4076,55 @@ class Component {
3907
4076
  }
3908
4077
 
3909
4078
  getCalculatedValues(state) {
3910
- const entries = Object.entries(this.calculated || {});
3911
- if (entries.length === 0) {
4079
+ if (!this._calculatedOrder || this._calculatedOrder.length === 0) {
3912
4080
  return
3913
4081
  }
3914
- return entries.reduce((acc, [field, fn]) => {
3915
- if (typeof fn !== 'function') throw new Error(`Missing or invalid calculator function for calculated field '${ field }`)
3916
- try {
3917
- acc[field] = fn(state);
3918
- } catch(e) {
3919
- console.warn(`Calculated field '${ field }' threw an error during calculation: ${ e.message }`);
4082
+
4083
+ const mergedState = { ...state };
4084
+ const computedSoFar = {};
4085
+
4086
+ for (const [field, { fn, deps }] of this._calculatedOrder) {
4087
+ if (deps !== null && this._calculatedFieldCache) {
4088
+ const cache = this._calculatedFieldCache[field];
4089
+ const currentDepValues = deps.map(d => mergedState[d]);
4090
+
4091
+ if (cache.lastDepValues !== undefined) {
4092
+ let unchanged = true;
4093
+ for (let i = 0; i < currentDepValues.length; i++) {
4094
+ if (currentDepValues[i] !== cache.lastDepValues[i]) {
4095
+ unchanged = false;
4096
+ break
4097
+ }
4098
+ }
4099
+ if (unchanged) {
4100
+ computedSoFar[field] = cache.lastResult;
4101
+ mergedState[field] = cache.lastResult;
4102
+ continue
4103
+ }
4104
+ }
4105
+
4106
+ try {
4107
+ const result = fn(mergedState);
4108
+ cache.lastDepValues = currentDepValues;
4109
+ cache.lastResult = result;
4110
+ computedSoFar[field] = result;
4111
+ mergedState[field] = result;
4112
+ } catch (e) {
4113
+ console.warn(`Calculated field '${field}' threw an error during calculation: ${e.message}`);
4114
+ }
4115
+ } else {
4116
+ // No deps declared — always recompute
4117
+ try {
4118
+ const result = fn(mergedState);
4119
+ computedSoFar[field] = result;
4120
+ mergedState[field] = result;
4121
+ } catch (e) {
4122
+ console.warn(`Calculated field '${field}' threw an error during calculation: ${e.message}`);
4123
+ }
3920
4124
  }
3921
- return acc
3922
- }, {})
4125
+ }
4126
+
4127
+ return computedSoFar
3923
4128
  }
3924
4129
 
3925
4130
  cleanupCalculated(incomingState) {
@@ -4067,7 +4272,7 @@ class Component {
4067
4272
  this.newChildSources(childSources);
4068
4273
 
4069
4274
 
4070
- if (newInstanceCount > 0) this.log(`New sub components instantiated: ${ newInstanceCount }`, true);
4275
+ if (newInstanceCount > 0) this.log(`New sub components instantiated: ${newInstanceCount}`, true);
4071
4276
 
4072
4277
  return newComponents
4073
4278
  }, {})
@@ -4133,7 +4338,7 @@ class Component {
4133
4338
  } else if (this.components[collectionOf]) {
4134
4339
  factory = this.components[collectionOf];
4135
4340
  } else {
4136
- throw new Error(`[${this.name}] Invalid 'of' propery in collection: ${ collectionOf }`)
4341
+ throw new Error(`[${this.name}] Invalid 'of' property in collection: ${collectionOf}`)
4137
4342
  }
4138
4343
 
4139
4344
  const fieldLense = {
@@ -4141,7 +4346,7 @@ class Component {
4141
4346
  if (!Array.isArray(state[stateField])) return []
4142
4347
  const items = state[stateField];
4143
4348
  const filtered = typeof arrayOperators.filter === 'function' ? items.filter(arrayOperators.filter) : items;
4144
- const sorted = typeof arrayOperators.sort ? filtered.sort(arrayOperators.sort) : filtered;
4349
+ const sorted = typeof arrayOperators.sort === 'function' ? filtered.sort(arrayOperators.sort) : filtered;
4145
4350
  const mapped = sorted.map((item, index) => {
4146
4351
  return (isObj(item)) ? { ...item, [idField]: item[idField] || index } : { value: item, [idField]: index }
4147
4352
  });
@@ -4150,7 +4355,7 @@ class Component {
4150
4355
  },
4151
4356
  set: (oldState, newState) => {
4152
4357
  if (this.calculated && stateField in this.calculated) {
4153
- console.warn(`Collection sub-component of ${ this.name } attempted to update state on a calculated field '${ stateField }': Update ignored`);
4358
+ console.warn(`Collection sub-component of ${this.name} attempted to update state on a calculated field '${stateField}': Update ignored`);
4154
4359
  return oldState
4155
4360
  }
4156
4361
  const updated = [];
@@ -4179,17 +4384,17 @@ class Component {
4179
4384
  } else if (typeof stateField === 'string') {
4180
4385
  if (isObj(this.currentState)) {
4181
4386
  if(!(this.currentState && stateField in this.currentState) && !(this.calculated && stateField in this.calculated)) {
4182
- 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.`);
4387
+ 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.`);
4183
4388
  lense = undefined;
4184
4389
  } else if (!Array.isArray(this.currentState[stateField])) {
4185
- console.warn(`State property '${ stateField }' in collection comopnent of ${ this.name } is not an array: No components will be instantiated in the collection.`);
4390
+ console.warn(`[${this.name}] State property '${stateField}' in collection component is not an array: No components will be instantiated in the collection.`);
4186
4391
  lense = fieldLense;
4187
4392
  } else {
4188
4393
  lense = fieldLense;
4189
4394
  }
4190
4395
  } else {
4191
4396
  if (!Array.isArray(this.currentState[stateField])) {
4192
- console.warn(`State property '${ stateField }' in collection component of ${ this.name } is not an array: No components will be instantiated in the collection.`);
4397
+ console.warn(`[${this.name}] State property '${stateField}' in collection component is not an array: No components will be instantiated in the collection.`);
4193
4398
  lense = fieldLense;
4194
4399
  } else {
4195
4400
  lense = fieldLense;
@@ -4197,14 +4402,14 @@ class Component {
4197
4402
  }
4198
4403
  } else if (isObj(stateField)) {
4199
4404
  if (typeof stateField.get !== 'function') {
4200
- 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.`);
4405
+ 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.`);
4201
4406
  lense = undefined;
4202
4407
  } else {
4203
4408
  lense = {
4204
4409
  get: (state) => {
4205
4410
  const newState = stateField.get(state);
4206
4411
  if (!Array.isArray(newState)) {
4207
- 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);
4412
+ 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);
4208
4413
  return []
4209
4414
  }
4210
4415
  return newState
@@ -4213,14 +4418,14 @@ class Component {
4213
4418
  };
4214
4419
  }
4215
4420
  } else {
4216
- 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.`);
4421
+ 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.`);
4217
4422
  lense = undefined;
4218
4423
  }
4219
4424
 
4220
- const sources = { ...this.sources, [this.stateSourceName]: stateSource, props$, children$, __parentContext$: this.context$, PARENT: null };
4425
+ const sources = { ...this.sources, [this.stateSourceName]: stateSource, props$, children$, __parentContext$: this.context$, PARENT: null, __parentComponentNumber: this._componentNumber };
4221
4426
  const sink$ = collection(factory, lense, { container: null })(sources);
4222
4427
  if (!isObj(sink$)) {
4223
- throw new Error('Invalid sinks returned from component factory of collection element')
4428
+ throw new Error(`[${this.name}] Invalid sinks returned from component factory of collection element`)
4224
4429
  }
4225
4430
  return sink$
4226
4431
  }
@@ -4242,7 +4447,7 @@ class Component {
4242
4447
  get: state => state[stateField],
4243
4448
  set: (oldState, newState) => {
4244
4449
  if (this.calculated && stateField in this.calculated) {
4245
- console.warn(`Switchable sub-component of ${ this.name } attempted to update state on a calculated field '${ stateField }': Update ignored`);
4450
+ console.warn(`Switchable sub-component of ${this.name} attempted to update state on a calculated field '${stateField}': Update ignored`);
4246
4451
  return oldState
4247
4452
  }
4248
4453
  if (!isObj(newState) || Array.isArray(newState)) return { ...oldState, [stateField]: newState }
@@ -4261,13 +4466,13 @@ class Component {
4261
4466
  lense = fieldLense;
4262
4467
  } else if (isObj(stateField)) {
4263
4468
  if (typeof stateField.get !== 'function') {
4264
- 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.`);
4469
+ 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.`);
4265
4470
  lense = baseLense;
4266
4471
  } else {
4267
4472
  lense = { get: stateField.get, set: stateField.set };
4268
4473
  }
4269
4474
  } else {
4270
- 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.`);
4475
+ 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.`);
4271
4476
  lense = baseLense;
4272
4477
  }
4273
4478
 
@@ -4283,12 +4488,12 @@ class Component {
4283
4488
  switchableComponents[key] = component(options);
4284
4489
  }
4285
4490
  });
4286
- const sources = { ...this.sources, [this.stateSourceName]: stateSource, props$, children$, __parentContext$: this.context$ };
4491
+ const sources = { ...this.sources, [this.stateSourceName]: stateSource, props$, children$, __parentContext$: this.context$, __parentComponentNumber: this._componentNumber };
4287
4492
 
4288
4493
  const sink$ = isolate(switchable(switchableComponents, props$.map(props => props.current)), { [this.stateSourceName]: lense })(sources);
4289
4494
 
4290
4495
  if (!isObj(sink$)) {
4291
- throw new Error('Invalid sinks returned from component factory of switchable element')
4496
+ throw new Error(`[${this.name}] Invalid sinks returned from component factory of switchable element`)
4292
4497
  }
4293
4498
 
4294
4499
  return sink$
@@ -4314,7 +4519,7 @@ class Component {
4314
4519
  const factory = componentName === 'sygnal-factory' ? props.sygnalFactory : (this.components[componentName] || props.sygnalFactory);
4315
4520
  if (!factory) {
4316
4521
  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.`)
4317
- throw new Error(`Component not found: ${ componentName }`)
4522
+ throw new Error(`Component not found: ${componentName}`)
4318
4523
  }
4319
4524
 
4320
4525
  let lense;
@@ -4323,7 +4528,7 @@ class Component {
4323
4528
  get: state => state[stateField],
4324
4529
  set: (oldState, newState) => {
4325
4530
  if (this.calculated && stateField in this.calculated) {
4326
- console.warn(`Sub-component of ${ this.name } attempted to update state on a calculated field '${ stateField }': Update ignored`);
4531
+ console.warn(`Sub-component of ${this.name} attempted to update state on a calculated field '${stateField}': Update ignored`);
4327
4532
  return oldState
4328
4533
  }
4329
4534
  return { ...oldState, [stateField]: newState }
@@ -4341,17 +4546,17 @@ class Component {
4341
4546
  lense = fieldLense;
4342
4547
  } else if (isObj(stateField)) {
4343
4548
  if (typeof stateField.get !== 'function') {
4344
- 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.`);
4549
+ 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.`);
4345
4550
  lense = baseLense;
4346
4551
  } else {
4347
4552
  lense = { get: stateField.get, set: stateField.set };
4348
4553
  }
4349
4554
  } else {
4350
- 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.`);
4555
+ 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.`);
4351
4556
  lense = baseLense;
4352
4557
  }
4353
4558
 
4354
- const sources = { ...this.sources, [this.stateSourceName]: stateSource, props$, children$, __parentContext$: this.context$ };
4559
+ const sources = { ...this.sources, [this.stateSourceName]: stateSource, props$, children$, __parentContext$: this.context$, __parentComponentNumber: this._componentNumber };
4355
4560
  const sink$ = isolate(factory, { [this.stateSourceName]: lense })(sources);
4356
4561
 
4357
4562
  if (!isObj(sink$)) {
@@ -4426,14 +4631,22 @@ class Component {
4426
4631
  const fixedMsg = (typeof msg === 'function') ? msg : _ => msg;
4427
4632
  if (immediate) {
4428
4633
  if (this.debug) {
4429
- console.log(`[${context}] ${fixedMsg(msg)}`);
4634
+ const text = `[${context}] ${fixedMsg(msg)}`;
4635
+ console.log(text);
4636
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
4637
+ window.__SYGNAL_DEVTOOLS__.onDebugLog(this._componentNumber, text);
4638
+ }
4430
4639
  }
4431
4640
  return
4432
4641
  } else {
4433
4642
  return stream => {
4434
4643
  return stream.debug(msg => {
4435
4644
  if (this.debug) {
4436
- console.log(`[${context}] ${fixedMsg(msg)}`);
4645
+ const text = `[${context}] ${fixedMsg(msg)}`;
4646
+ console.log(text);
4647
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
4648
+ window.__SYGNAL_DEVTOOLS__.onDebugLog(this._componentNumber, text);
4649
+ }
4437
4650
  }
4438
4651
  })
4439
4652
  }
@@ -4443,11 +4656,11 @@ class Component {
4443
4656
 
4444
4657
 
4445
4658
 
4446
- function getComponents(currentElement, componentNames, depth=0, index=0, parentId) {
4659
+ function getComponents(currentElement, componentNames, path='r', parentId) {
4447
4660
  if (!currentElement) return {}
4448
4661
 
4449
4662
  if (currentElement.data?.componentsProcessed) return {}
4450
- if (depth === 0) currentElement.data.componentsProcessed = true;
4663
+ if (path === 'r') currentElement.data.componentsProcessed = true;
4451
4664
 
4452
4665
  const sel = currentElement.sel;
4453
4666
  const isCollection = sel && sel.toLowerCase() === 'collection';
@@ -4461,11 +4674,11 @@ function getComponents(currentElement, componentNames, depth=0, index=0, parentI
4461
4674
 
4462
4675
  let id = parentId;
4463
4676
  if (isComponent) {
4464
- id = getComponentIdFromElement(currentElement, depth, index, parentId);
4677
+ id = getComponentIdFromElement(currentElement, path, parentId);
4465
4678
  if (isCollection) {
4466
4679
  if (!props.of) throw new Error(`Collection element missing required 'component' property`)
4467
4680
  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`)
4468
- if (typeof props.of !== 'function' && !componentNames.includes(props.of)) throw new Error(`Specified component for collection not found: ${ props.of }`)
4681
+ if (typeof props.of !== 'function' && !componentNames.includes(props.of)) throw new Error(`Specified component for collection not found: ${props.of}`)
4469
4682
  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);
4470
4683
  currentElement.data.isCollection = true;
4471
4684
  currentElement.data.props ||= {};
@@ -4476,7 +4689,7 @@ function getComponents(currentElement, componentNames, depth=0, index=0, parentI
4476
4689
  if (!switchableComponents.every(comp => typeof comp === 'function')) throw new Error(`One or more components provided to switchable element is not a valid component factory`)
4477
4690
  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`)
4478
4691
  const switchableComponentNames = Object.keys(props.of);
4479
- if (!switchableComponentNames.includes(props.current)) throw new Error(`Component '${ props.current }' not found in switchable element`)
4692
+ if (!switchableComponentNames.includes(props.current)) throw new Error(`Component '${props.current}' not found in switchable element`)
4480
4693
  currentElement.data.isSwitchable = true;
4481
4694
  } else ;
4482
4695
  if (typeof props.key === 'undefined') currentElement.data.props.key = id;
@@ -4484,7 +4697,7 @@ function getComponents(currentElement, componentNames, depth=0, index=0, parentI
4484
4697
  }
4485
4698
 
4486
4699
  if (children.length > 0) {
4487
- children.map((child, i) => getComponents(child, componentNames, depth + 1, index + i, id))
4700
+ children.map((child, i) => getComponents(child, componentNames, `${path}.${i}`, id))
4488
4701
  .forEach((child) => {
4489
4702
  Object.entries(child).forEach(([id, el]) => found[id] = el);
4490
4703
  });
@@ -4493,10 +4706,10 @@ function getComponents(currentElement, componentNames, depth=0, index=0, parentI
4493
4706
  return found
4494
4707
  }
4495
4708
 
4496
- function injectComponents(currentElement, components, componentNames, depth=0, index=0, parentId) {
4709
+ function injectComponents(currentElement, components, componentNames, path='r', parentId) {
4497
4710
  if (!currentElement) return
4498
4711
  if (currentElement.data?.componentsInjected) return currentElement
4499
- if (depth === 0 && currentElement.data) currentElement.data.componentsInjected = true;
4712
+ if (path === 'r' && currentElement.data) currentElement.data.componentsInjected = true;
4500
4713
 
4501
4714
 
4502
4715
  const sel = currentElement.sel || 'NO SELECTOR';
@@ -4508,7 +4721,7 @@ function injectComponents(currentElement, components, componentNames, depth=0, i
4508
4721
 
4509
4722
  let id = parentId;
4510
4723
  if (isComponent) {
4511
- id = getComponentIdFromElement(currentElement, depth, index, parentId);
4724
+ id = getComponentIdFromElement(currentElement, path, parentId);
4512
4725
  const component = components[id];
4513
4726
  if (isCollection) {
4514
4727
  currentElement.sel = 'div';
@@ -4520,21 +4733,20 @@ function injectComponents(currentElement, components, componentNames, depth=0, i
4520
4733
  return component
4521
4734
  }
4522
4735
  } else if (children.length > 0) {
4523
- currentElement.children = children.map((child, i) => injectComponents(child, components, componentNames, depth + 1, index + i, id)).flat();
4736
+ currentElement.children = children.map((child, i) => injectComponents(child, components, componentNames, `${path}.${i}`, id)).flat();
4524
4737
  return currentElement
4525
4738
  } else {
4526
4739
  return currentElement
4527
4740
  }
4528
4741
  }
4529
4742
 
4530
- function getComponentIdFromElement(el, depth, index, parentId) {
4743
+ function getComponentIdFromElement(el, path, parentId) {
4531
4744
  const sel = el.sel;
4532
4745
  const name = typeof sel === 'string' ? sel : 'functionComponent';
4533
- const uid = `${depth}:${index}`;
4534
4746
  const props = el.data?.props || {};
4535
- const id = (props.id && JSON.stringify(props.id).replaceAll('"', '')) || uid;
4536
- const parentString = parentId ? `${ parentId }|` : '';
4537
- const fullId = `${ parentString }${ name }::${ id }`;
4747
+ const id = (props.id && JSON.stringify(props.id).replaceAll('"', '')) || path;
4748
+ const parentString = parentId ? `${parentId}|` : '';
4749
+ const fullId = `${parentString}${name}::${id}`;
4538
4750
  return fullId
4539
4751
  }
4540
4752
 
@@ -4678,12 +4890,264 @@ function sortFunctionFromProp(sortProp) {
4678
4890
  } else if (isObj(sortProp)) {
4679
4891
  return __sortFunctionFromObj(sortProp)
4680
4892
  } else {
4681
- console.error('Invalid sort option (ignoring):', item);
4893
+ console.error('Invalid sort option (ignoring):', sortProp);
4682
4894
  return undefined
4683
4895
  }
4684
4896
  }
4685
4897
 
4898
+ const DEVTOOLS_SOURCE = '__SYGNAL_DEVTOOLS_PAGE__';
4899
+ const EXTENSION_SOURCE = '__SYGNAL_DEVTOOLS_EXTENSION__';
4900
+ const DEFAULT_MAX_HISTORY = 200;
4901
+
4902
+ class SygnalDevTools {
4903
+ constructor() {
4904
+ this._connected = false;
4905
+ this._components = new Map();
4906
+ this._stateHistory = [];
4907
+ this._maxHistory = DEFAULT_MAX_HISTORY;
4908
+ }
4909
+
4910
+ get connected() {
4911
+ return this._connected && typeof window !== 'undefined'
4912
+ }
4913
+
4914
+ // ─── Initialization ─────────────────────────────────────────────────────────
4915
+
4916
+ init() {
4917
+ if (typeof window === 'undefined') return
4918
+
4919
+ window.__SYGNAL_DEVTOOLS__ = this;
4920
+
4921
+ window.addEventListener('message', (event) => {
4922
+ if (event.source !== window) return
4923
+ if (event.data?.source === EXTENSION_SOURCE) {
4924
+ this._handleExtensionMessage(event.data);
4925
+ }
4926
+ });
4927
+ }
4928
+
4929
+ _handleExtensionMessage(msg) {
4930
+ switch (msg.type) {
4931
+ case 'CONNECT':
4932
+ this._connected = true;
4933
+ if (msg.payload?.maxHistory) this._maxHistory = msg.payload.maxHistory;
4934
+ this._sendFullTree();
4935
+ break
4936
+ case 'DISCONNECT':
4937
+ this._connected = false;
4938
+ break
4939
+ case 'SET_DEBUG':
4940
+ this._setDebug(msg.payload);
4941
+ break
4942
+ case 'TIME_TRAVEL':
4943
+ this._timeTravel(msg.payload);
4944
+ break
4945
+ case 'GET_STATE':
4946
+ this._sendComponentState(msg.payload.componentId);
4947
+ break
4948
+ }
4949
+ }
4950
+
4951
+ // ─── Hooks (called from component.js) ────────────────────────────────────────
4952
+
4953
+ onComponentCreated(componentNumber, name, instance) {
4954
+ const meta = {
4955
+ id: componentNumber,
4956
+ name: name,
4957
+ isSubComponent: instance.isSubComponent,
4958
+ hasModel: !!instance.model,
4959
+ hasIntent: !!instance.intent,
4960
+ hasContext: !!instance.context,
4961
+ hasCalculated: !!instance.calculated,
4962
+ components: Object.keys(instance.components || {}),
4963
+ parentId: null,
4964
+ children: [],
4965
+ debug: instance._debug,
4966
+ createdAt: Date.now(),
4967
+ _instanceRef: new WeakRef(instance),
4968
+ };
4969
+ this._components.set(componentNumber, meta);
4970
+
4971
+ if (!this.connected) return
4972
+ this._post('COMPONENT_CREATED', this._serializeMeta(meta));
4973
+ }
4974
+
4975
+ onStateChanged(componentNumber, name, state) {
4976
+ if (!this.connected) return
4977
+
4978
+ const entry = {
4979
+ componentId: componentNumber,
4980
+ componentName: name,
4981
+ timestamp: Date.now(),
4982
+ state: this._safeClone(state),
4983
+ };
4984
+
4985
+ this._stateHistory.push(entry);
4986
+ if (this._stateHistory.length > this._maxHistory) {
4987
+ this._stateHistory.shift();
4988
+ }
4989
+
4990
+ this._post('STATE_CHANGED', {
4991
+ componentId: componentNumber,
4992
+ componentName: name,
4993
+ state: entry.state,
4994
+ historyIndex: this._stateHistory.length - 1,
4995
+ });
4996
+ }
4997
+
4998
+ onActionDispatched(componentNumber, name, actionType, data) {
4999
+ if (!this.connected) return
5000
+ this._post('ACTION_DISPATCHED', {
5001
+ componentId: componentNumber,
5002
+ componentName: name,
5003
+ actionType: actionType,
5004
+ data: this._safeClone(data),
5005
+ timestamp: Date.now(),
5006
+ });
5007
+ }
5008
+
5009
+ onSubComponentRegistered(parentNumber, childNumber) {
5010
+ const parent = this._components.get(parentNumber);
5011
+ const child = this._components.get(childNumber);
5012
+ if (parent && child) {
5013
+ child.parentId = parentNumber;
5014
+ if (!parent.children.includes(childNumber)) {
5015
+ parent.children.push(childNumber);
5016
+ }
5017
+ }
5018
+
5019
+ if (!this.connected) return
5020
+ this._post('TREE_UPDATED', {
5021
+ parentId: parentNumber,
5022
+ childId: childNumber,
5023
+ });
5024
+ }
5025
+
5026
+ onContextChanged(componentNumber, name, context) {
5027
+ if (!this.connected) return
5028
+ this._post('CONTEXT_CHANGED', {
5029
+ componentId: componentNumber,
5030
+ componentName: name,
5031
+ context: this._safeClone(context),
5032
+ });
5033
+ }
5034
+
5035
+ onDebugLog(componentNumber, message) {
5036
+ if (!this.connected) return
5037
+ this._post('DEBUG_LOG', {
5038
+ componentId: componentNumber,
5039
+ message: message,
5040
+ timestamp: Date.now(),
5041
+ });
5042
+ }
5043
+
5044
+ // ─── Commands (from extension to page) ───────────────────────────────────────
5045
+
5046
+ _setDebug({ componentId, enabled }) {
5047
+ if (typeof componentId === 'undefined' || componentId === null) {
5048
+ if (typeof window !== 'undefined') window.SYGNAL_DEBUG = enabled ? 'true' : false;
5049
+ this._post('DEBUG_TOGGLED', { global: true, enabled });
5050
+ return
5051
+ }
5052
+
5053
+ const meta = this._components.get(componentId);
5054
+ if (meta && meta._instanceRef) {
5055
+ const instance = meta._instanceRef.deref();
5056
+ if (instance) {
5057
+ instance._debug = enabled;
5058
+ meta.debug = enabled;
5059
+ this._post('DEBUG_TOGGLED', { componentId, enabled });
5060
+ }
5061
+ }
5062
+ }
5063
+
5064
+ _timeTravel({ historyIndex }) {
5065
+ const entry = this._stateHistory[historyIndex];
5066
+ if (!entry) return
5067
+
5068
+ const app = typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS_APP__;
5069
+ if (app?.sinks?.STATE?.shamefullySendNext) {
5070
+ app.sinks.STATE.shamefullySendNext(() => ({ ...entry.state }));
5071
+ this._post('TIME_TRAVEL_APPLIED', {
5072
+ historyIndex,
5073
+ state: entry.state,
5074
+ });
5075
+ }
5076
+ }
5077
+
5078
+ _sendComponentState(componentId) {
5079
+ const meta = this._components.get(componentId);
5080
+ if (meta && meta._instanceRef) {
5081
+ const instance = meta._instanceRef.deref();
5082
+ if (instance) {
5083
+ this._post('COMPONENT_STATE', {
5084
+ componentId,
5085
+ state: this._safeClone(instance.currentState),
5086
+ context: this._safeClone(instance.currentContext),
5087
+ props: this._safeClone(instance.currentProps),
5088
+ });
5089
+ }
5090
+ }
5091
+ }
5092
+
5093
+ _sendFullTree() {
5094
+ const tree = [];
5095
+ for (const [id, meta] of this._components) {
5096
+ const instance = meta._instanceRef?.deref();
5097
+ tree.push({
5098
+ ...this._serializeMeta(meta),
5099
+ state: instance ? this._safeClone(instance.currentState) : null,
5100
+ context: instance ? this._safeClone(instance.currentContext) : null,
5101
+ });
5102
+ }
5103
+ this._post('FULL_TREE', {
5104
+ components: tree,
5105
+ history: this._stateHistory,
5106
+ });
5107
+ }
5108
+
5109
+ // ─── Transport ───────────────────────────────────────────────────────────────
5110
+
5111
+ _post(type, payload) {
5112
+ if (typeof window === 'undefined') return
5113
+ window.postMessage({
5114
+ source: DEVTOOLS_SOURCE,
5115
+ type,
5116
+ payload,
5117
+ }, '*');
5118
+ }
5119
+
5120
+ _safeClone(obj) {
5121
+ if (obj === undefined || obj === null) return obj
5122
+ try {
5123
+ return JSON.parse(JSON.stringify(obj))
5124
+ } catch (e) {
5125
+ return '[unserializable]'
5126
+ }
5127
+ }
5128
+
5129
+ _serializeMeta(meta) {
5130
+ const { _instanceRef, ...rest } = meta;
5131
+ return rest
5132
+ }
5133
+ }
5134
+
5135
+ // ─── Singleton ────────────────────────────────────────────────────────────────
5136
+
5137
+ let instance = null;
5138
+
5139
+ function getDevTools() {
5140
+ if (!instance) instance = new SygnalDevTools();
5141
+ return instance
5142
+ }
5143
+
4686
5144
  function run(app, drivers={}, options={}) {
5145
+ // Initialize DevTools instrumentation bridge early (before component creation)
5146
+ if (typeof window !== 'undefined') {
5147
+ const dt = getDevTools();
5148
+ dt.init();
5149
+ }
5150
+
4687
5151
  const { mountPoint='#root', fragments=true, useDefaultDrivers=true } = options;
4688
5152
  if (!app.isSygnalComponent) {
4689
5153
  const name = app.name || app.componentName || app.label || "FUNCTIONAL_COMPONENT";
@@ -4733,6 +5197,11 @@ function run(app, drivers={}, options={}) {
4733
5197
 
4734
5198
  const exposed = { sources, sinks, dispose };
4735
5199
 
5200
+ // Store app reference for time-travel
5201
+ if (typeof window !== 'undefined') {
5202
+ window.__SYGNAL_DEVTOOLS_APP__ = exposed;
5203
+ }
5204
+
4736
5205
  const swapToComponent = (newComponent, state) => {
4737
5206
  const persistedState = (typeof window !== 'undefined') ? window.__SYGNAL_HMR_PERSISTED_STATE : undefined;
4738
5207
  const fallbackState = typeof persistedState !== 'undefined' ? persistedState : app.initialState;