sygnal 4.2.1 → 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.
@@ -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);
@@ -3688,12 +3853,12 @@ class Component {
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
 
@@ -3771,7 +3936,7 @@ class Component {
3771
3936
 
3772
3937
  }
3773
3938
  });
3774
- subComponentSink$.subscribe({ next: _ => _ });
3939
+ subComponentSink$.subscribe({ next: _ => _, error: err => console.error(`[${this.name}] Error in sub-component sink stream:`, err) });
3775
3940
  this.subComponentSink$ = subComponentSink$.filter(sinks => Object.keys(sinks).length > 0);
3776
3941
  }
3777
3942
 
@@ -3834,13 +3999,13 @@ class Component {
3834
3999
  if (typeof reducer === 'function') {
3835
4000
  returnStream$ = filtered$.map(action => {
3836
4001
  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.`)
4002
+ 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
4003
  // put the "next" action request at the end of the event loop so the "current" action completes first
3839
4004
  setTimeout(() => {
3840
4005
  // push the "next" action request into the action$ stream
3841
4006
  rootAction$.shamefullySendNext({ type, data });
3842
4007
  }, delay);
3843
- this.log(`<${ name }> Triggered a next() action: <${ type }> ${ delay }ms delay`, true);
4008
+ this.log(`<${name}> Triggered a next() action: <${type}> ${delay}ms delay`, true);
3844
4009
  };
3845
4010
 
3846
4011
  const props = { ...this.currentProps, children: this.currentChildren, context: this.currentContext };
@@ -3852,7 +4017,7 @@ class Component {
3852
4017
  const enhancedState = this.addCalculated(_state);
3853
4018
  props.state = enhancedState;
3854
4019
  const newState = reducer(enhancedState, data, next, props);
3855
- if (newState == ABORT) return _state
4020
+ if (newState === ABORT) return _state
3856
4021
  return this.cleanupCalculated(newState)
3857
4022
  }
3858
4023
  } else {
@@ -3861,13 +4026,13 @@ class Component {
3861
4026
  const reduced = reducer(enhancedState, data, next, props);
3862
4027
  const type = typeof reduced;
3863
4028
  if (isObj(reduced) || ['string', 'number', 'boolean', 'function'].includes(type)) return reduced
3864
- if (type == 'undefined') {
3865
- console.warn(`'undefined' value sent to ${ name }`);
4029
+ if (type === 'undefined') {
4030
+ console.warn(`[${this.name}] 'undefined' value sent to ${name}`);
3866
4031
  return reduced
3867
4032
  }
3868
- throw new Error(`Invalid reducer type for ${ name } ${ type }`)
4033
+ throw new Error(`[${this.name}] Invalid reducer type for action '${name}': ${type}`)
3869
4034
  }
3870
- }).filter(result => result != ABORT);
4035
+ }).filter(result => result !== ABORT);
3871
4036
  } else if (reducer === undefined || reducer === true) {
3872
4037
  returnStream$ = filtered$.map(({data}) => data);
3873
4038
  } else {
@@ -3888,7 +4053,7 @@ class Component {
3888
4053
  if (state === lastState) {
3889
4054
  return lastResult
3890
4055
  }
3891
- if (!isObj(this.calculated)) throw new Error(`'calculated' parameter must be an object mapping calculated state field named to functions`)
4056
+ if (!isObj(this.calculated)) throw new Error(`[${this.name}] 'calculated' parameter must be an object mapping calculated state field names to functions`)
3892
4057
 
3893
4058
  const calculated = this.getCalculatedValues(state);
3894
4059
  if (!calculated) {
@@ -3907,19 +4072,55 @@ class Component {
3907
4072
  }
3908
4073
 
3909
4074
  getCalculatedValues(state) {
3910
- const entries = Object.entries(this.calculated || {});
3911
- if (entries.length === 0) {
4075
+ if (!this._calculatedOrder || this._calculatedOrder.length === 0) {
3912
4076
  return
3913
4077
  }
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 }`);
4078
+
4079
+ const mergedState = { ...state };
4080
+ const computedSoFar = {};
4081
+
4082
+ for (const [field, { fn, deps }] of this._calculatedOrder) {
4083
+ if (deps !== null && this._calculatedFieldCache) {
4084
+ const cache = this._calculatedFieldCache[field];
4085
+ const currentDepValues = deps.map(d => mergedState[d]);
4086
+
4087
+ if (cache.lastDepValues !== undefined) {
4088
+ let unchanged = true;
4089
+ for (let i = 0; i < currentDepValues.length; i++) {
4090
+ if (currentDepValues[i] !== cache.lastDepValues[i]) {
4091
+ unchanged = false;
4092
+ break
4093
+ }
4094
+ }
4095
+ if (unchanged) {
4096
+ computedSoFar[field] = cache.lastResult;
4097
+ mergedState[field] = cache.lastResult;
4098
+ continue
4099
+ }
4100
+ }
4101
+
4102
+ try {
4103
+ const result = fn(mergedState);
4104
+ cache.lastDepValues = currentDepValues;
4105
+ cache.lastResult = result;
4106
+ computedSoFar[field] = result;
4107
+ mergedState[field] = result;
4108
+ } catch (e) {
4109
+ console.warn(`Calculated field '${field}' threw an error during calculation: ${e.message}`);
4110
+ }
4111
+ } else {
4112
+ // No deps declared — always recompute
4113
+ try {
4114
+ const result = fn(mergedState);
4115
+ computedSoFar[field] = result;
4116
+ mergedState[field] = result;
4117
+ } catch (e) {
4118
+ console.warn(`Calculated field '${field}' threw an error during calculation: ${e.message}`);
4119
+ }
3920
4120
  }
3921
- return acc
3922
- }, {})
4121
+ }
4122
+
4123
+ return computedSoFar
3923
4124
  }
3924
4125
 
3925
4126
  cleanupCalculated(incomingState) {
@@ -4067,7 +4268,7 @@ class Component {
4067
4268
  this.newChildSources(childSources);
4068
4269
 
4069
4270
 
4070
- if (newInstanceCount > 0) this.log(`New sub components instantiated: ${ newInstanceCount }`, true);
4271
+ if (newInstanceCount > 0) this.log(`New sub components instantiated: ${newInstanceCount}`, true);
4071
4272
 
4072
4273
  return newComponents
4073
4274
  }, {})
@@ -4133,7 +4334,7 @@ class Component {
4133
4334
  } else if (this.components[collectionOf]) {
4134
4335
  factory = this.components[collectionOf];
4135
4336
  } else {
4136
- throw new Error(`[${this.name}] Invalid 'of' propery in collection: ${ collectionOf }`)
4337
+ throw new Error(`[${this.name}] Invalid 'of' property in collection: ${collectionOf}`)
4137
4338
  }
4138
4339
 
4139
4340
  const fieldLense = {
@@ -4141,7 +4342,7 @@ class Component {
4141
4342
  if (!Array.isArray(state[stateField])) return []
4142
4343
  const items = state[stateField];
4143
4344
  const filtered = typeof arrayOperators.filter === 'function' ? items.filter(arrayOperators.filter) : items;
4144
- const sorted = typeof arrayOperators.sort ? filtered.sort(arrayOperators.sort) : filtered;
4345
+ const sorted = typeof arrayOperators.sort === 'function' ? filtered.sort(arrayOperators.sort) : filtered;
4145
4346
  const mapped = sorted.map((item, index) => {
4146
4347
  return (isObj(item)) ? { ...item, [idField]: item[idField] || index } : { value: item, [idField]: index }
4147
4348
  });
@@ -4150,7 +4351,7 @@ class Component {
4150
4351
  },
4151
4352
  set: (oldState, newState) => {
4152
4353
  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`);
4354
+ console.warn(`Collection sub-component of ${this.name} attempted to update state on a calculated field '${stateField}': Update ignored`);
4154
4355
  return oldState
4155
4356
  }
4156
4357
  const updated = [];
@@ -4179,17 +4380,17 @@ class Component {
4179
4380
  } else if (typeof stateField === 'string') {
4180
4381
  if (isObj(this.currentState)) {
4181
4382
  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.`);
4383
+ 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
4384
  lense = undefined;
4184
4385
  } 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.`);
4386
+ console.warn(`[${this.name}] State property '${stateField}' in collection component is not an array: No components will be instantiated in the collection.`);
4186
4387
  lense = fieldLense;
4187
4388
  } else {
4188
4389
  lense = fieldLense;
4189
4390
  }
4190
4391
  } else {
4191
4392
  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.`);
4393
+ console.warn(`[${this.name}] State property '${stateField}' in collection component is not an array: No components will be instantiated in the collection.`);
4193
4394
  lense = fieldLense;
4194
4395
  } else {
4195
4396
  lense = fieldLense;
@@ -4197,14 +4398,14 @@ class Component {
4197
4398
  }
4198
4399
  } else if (isObj(stateField)) {
4199
4400
  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.`);
4401
+ 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
4402
  lense = undefined;
4202
4403
  } else {
4203
4404
  lense = {
4204
4405
  get: (state) => {
4205
4406
  const newState = stateField.get(state);
4206
4407
  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);
4408
+ 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
4409
  return []
4209
4410
  }
4210
4411
  return newState
@@ -4213,14 +4414,14 @@ class Component {
4213
4414
  };
4214
4415
  }
4215
4416
  } 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.`);
4417
+ 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
4418
  lense = undefined;
4218
4419
  }
4219
4420
 
4220
- const sources = { ...this.sources, [this.stateSourceName]: stateSource, props$, children$, __parentContext$: this.context$, PARENT: null };
4421
+ const sources = { ...this.sources, [this.stateSourceName]: stateSource, props$, children$, __parentContext$: this.context$, PARENT: null, __parentComponentNumber: this._componentNumber };
4221
4422
  const sink$ = collection(factory, lense, { container: null })(sources);
4222
4423
  if (!isObj(sink$)) {
4223
- throw new Error('Invalid sinks returned from component factory of collection element')
4424
+ throw new Error(`[${this.name}] Invalid sinks returned from component factory of collection element`)
4224
4425
  }
4225
4426
  return sink$
4226
4427
  }
@@ -4242,7 +4443,7 @@ class Component {
4242
4443
  get: state => state[stateField],
4243
4444
  set: (oldState, newState) => {
4244
4445
  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`);
4446
+ console.warn(`Switchable sub-component of ${this.name} attempted to update state on a calculated field '${stateField}': Update ignored`);
4246
4447
  return oldState
4247
4448
  }
4248
4449
  if (!isObj(newState) || Array.isArray(newState)) return { ...oldState, [stateField]: newState }
@@ -4261,13 +4462,13 @@ class Component {
4261
4462
  lense = fieldLense;
4262
4463
  } else if (isObj(stateField)) {
4263
4464
  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.`);
4465
+ 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
4466
  lense = baseLense;
4266
4467
  } else {
4267
4468
  lense = { get: stateField.get, set: stateField.set };
4268
4469
  }
4269
4470
  } 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.`);
4471
+ 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
4472
  lense = baseLense;
4272
4473
  }
4273
4474
 
@@ -4283,12 +4484,12 @@ class Component {
4283
4484
  switchableComponents[key] = component(options);
4284
4485
  }
4285
4486
  });
4286
- const sources = { ...this.sources, [this.stateSourceName]: stateSource, props$, children$, __parentContext$: this.context$ };
4487
+ const sources = { ...this.sources, [this.stateSourceName]: stateSource, props$, children$, __parentContext$: this.context$, __parentComponentNumber: this._componentNumber };
4287
4488
 
4288
4489
  const sink$ = isolate(switchable(switchableComponents, props$.map(props => props.current)), { [this.stateSourceName]: lense })(sources);
4289
4490
 
4290
4491
  if (!isObj(sink$)) {
4291
- throw new Error('Invalid sinks returned from component factory of switchable element')
4492
+ throw new Error(`[${this.name}] Invalid sinks returned from component factory of switchable element`)
4292
4493
  }
4293
4494
 
4294
4495
  return sink$
@@ -4314,7 +4515,7 @@ class Component {
4314
4515
  const factory = componentName === 'sygnal-factory' ? props.sygnalFactory : (this.components[componentName] || props.sygnalFactory);
4315
4516
  if (!factory) {
4316
4517
  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 }`)
4518
+ throw new Error(`Component not found: ${componentName}`)
4318
4519
  }
4319
4520
 
4320
4521
  let lense;
@@ -4323,7 +4524,7 @@ class Component {
4323
4524
  get: state => state[stateField],
4324
4525
  set: (oldState, newState) => {
4325
4526
  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`);
4527
+ console.warn(`Sub-component of ${this.name} attempted to update state on a calculated field '${stateField}': Update ignored`);
4327
4528
  return oldState
4328
4529
  }
4329
4530
  return { ...oldState, [stateField]: newState }
@@ -4341,17 +4542,17 @@ class Component {
4341
4542
  lense = fieldLense;
4342
4543
  } else if (isObj(stateField)) {
4343
4544
  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.`);
4545
+ 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
4546
  lense = baseLense;
4346
4547
  } else {
4347
4548
  lense = { get: stateField.get, set: stateField.set };
4348
4549
  }
4349
4550
  } 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.`);
4551
+ 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
4552
  lense = baseLense;
4352
4553
  }
4353
4554
 
4354
- const sources = { ...this.sources, [this.stateSourceName]: stateSource, props$, children$, __parentContext$: this.context$ };
4555
+ const sources = { ...this.sources, [this.stateSourceName]: stateSource, props$, children$, __parentContext$: this.context$, __parentComponentNumber: this._componentNumber };
4355
4556
  const sink$ = isolate(factory, { [this.stateSourceName]: lense })(sources);
4356
4557
 
4357
4558
  if (!isObj(sink$)) {
@@ -4426,14 +4627,22 @@ class Component {
4426
4627
  const fixedMsg = (typeof msg === 'function') ? msg : _ => msg;
4427
4628
  if (immediate) {
4428
4629
  if (this.debug) {
4429
- console.log(`[${context}] ${fixedMsg(msg)}`);
4630
+ const text = `[${context}] ${fixedMsg(msg)}`;
4631
+ console.log(text);
4632
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
4633
+ window.__SYGNAL_DEVTOOLS__.onDebugLog(this._componentNumber, text);
4634
+ }
4430
4635
  }
4431
4636
  return
4432
4637
  } else {
4433
4638
  return stream => {
4434
4639
  return stream.debug(msg => {
4435
4640
  if (this.debug) {
4436
- console.log(`[${context}] ${fixedMsg(msg)}`);
4641
+ const text = `[${context}] ${fixedMsg(msg)}`;
4642
+ console.log(text);
4643
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
4644
+ window.__SYGNAL_DEVTOOLS__.onDebugLog(this._componentNumber, text);
4645
+ }
4437
4646
  }
4438
4647
  })
4439
4648
  }
@@ -4443,11 +4652,11 @@ class Component {
4443
4652
 
4444
4653
 
4445
4654
 
4446
- function getComponents(currentElement, componentNames, depth=0, index=0, parentId) {
4655
+ function getComponents(currentElement, componentNames, path='r', parentId) {
4447
4656
  if (!currentElement) return {}
4448
4657
 
4449
4658
  if (currentElement.data?.componentsProcessed) return {}
4450
- if (depth === 0) currentElement.data.componentsProcessed = true;
4659
+ if (path === 'r') currentElement.data.componentsProcessed = true;
4451
4660
 
4452
4661
  const sel = currentElement.sel;
4453
4662
  const isCollection = sel && sel.toLowerCase() === 'collection';
@@ -4461,11 +4670,11 @@ function getComponents(currentElement, componentNames, depth=0, index=0, parentI
4461
4670
 
4462
4671
  let id = parentId;
4463
4672
  if (isComponent) {
4464
- id = getComponentIdFromElement(currentElement, depth, index, parentId);
4673
+ id = getComponentIdFromElement(currentElement, path, parentId);
4465
4674
  if (isCollection) {
4466
4675
  if (!props.of) throw new Error(`Collection element missing required 'component' property`)
4467
4676
  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 }`)
4677
+ if (typeof props.of !== 'function' && !componentNames.includes(props.of)) throw new Error(`Specified component for collection not found: ${props.of}`)
4469
4678
  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
4679
  currentElement.data.isCollection = true;
4471
4680
  currentElement.data.props ||= {};
@@ -4476,7 +4685,7 @@ function getComponents(currentElement, componentNames, depth=0, index=0, parentI
4476
4685
  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
4686
  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
4687
  const switchableComponentNames = Object.keys(props.of);
4479
- if (!switchableComponentNames.includes(props.current)) throw new Error(`Component '${ props.current }' not found in switchable element`)
4688
+ if (!switchableComponentNames.includes(props.current)) throw new Error(`Component '${props.current}' not found in switchable element`)
4480
4689
  currentElement.data.isSwitchable = true;
4481
4690
  } else ;
4482
4691
  if (typeof props.key === 'undefined') currentElement.data.props.key = id;
@@ -4484,7 +4693,7 @@ function getComponents(currentElement, componentNames, depth=0, index=0, parentI
4484
4693
  }
4485
4694
 
4486
4695
  if (children.length > 0) {
4487
- children.map((child, i) => getComponents(child, componentNames, depth + 1, index + i, id))
4696
+ children.map((child, i) => getComponents(child, componentNames, `${path}.${i}`, id))
4488
4697
  .forEach((child) => {
4489
4698
  Object.entries(child).forEach(([id, el]) => found[id] = el);
4490
4699
  });
@@ -4493,10 +4702,10 @@ function getComponents(currentElement, componentNames, depth=0, index=0, parentI
4493
4702
  return found
4494
4703
  }
4495
4704
 
4496
- function injectComponents(currentElement, components, componentNames, depth=0, index=0, parentId) {
4705
+ function injectComponents(currentElement, components, componentNames, path='r', parentId) {
4497
4706
  if (!currentElement) return
4498
4707
  if (currentElement.data?.componentsInjected) return currentElement
4499
- if (depth === 0 && currentElement.data) currentElement.data.componentsInjected = true;
4708
+ if (path === 'r' && currentElement.data) currentElement.data.componentsInjected = true;
4500
4709
 
4501
4710
 
4502
4711
  const sel = currentElement.sel || 'NO SELECTOR';
@@ -4508,7 +4717,7 @@ function injectComponents(currentElement, components, componentNames, depth=0, i
4508
4717
 
4509
4718
  let id = parentId;
4510
4719
  if (isComponent) {
4511
- id = getComponentIdFromElement(currentElement, depth, index, parentId);
4720
+ id = getComponentIdFromElement(currentElement, path, parentId);
4512
4721
  const component = components[id];
4513
4722
  if (isCollection) {
4514
4723
  currentElement.sel = 'div';
@@ -4520,21 +4729,20 @@ function injectComponents(currentElement, components, componentNames, depth=0, i
4520
4729
  return component
4521
4730
  }
4522
4731
  } else if (children.length > 0) {
4523
- currentElement.children = children.map((child, i) => injectComponents(child, components, componentNames, depth + 1, index + i, id)).flat();
4732
+ currentElement.children = children.map((child, i) => injectComponents(child, components, componentNames, `${path}.${i}`, id)).flat();
4524
4733
  return currentElement
4525
4734
  } else {
4526
4735
  return currentElement
4527
4736
  }
4528
4737
  }
4529
4738
 
4530
- function getComponentIdFromElement(el, depth, index, parentId) {
4739
+ function getComponentIdFromElement(el, path, parentId) {
4531
4740
  const sel = el.sel;
4532
4741
  const name = typeof sel === 'string' ? sel : 'functionComponent';
4533
- const uid = `${depth}:${index}`;
4534
4742
  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 }`;
4743
+ const id = (props.id && JSON.stringify(props.id).replaceAll('"', '')) || path;
4744
+ const parentString = parentId ? `${parentId}|` : '';
4745
+ const fullId = `${parentString}${name}::${id}`;
4538
4746
  return fullId
4539
4747
  }
4540
4748
 
@@ -4678,12 +4886,264 @@ function sortFunctionFromProp(sortProp) {
4678
4886
  } else if (isObj(sortProp)) {
4679
4887
  return __sortFunctionFromObj(sortProp)
4680
4888
  } else {
4681
- console.error('Invalid sort option (ignoring):', item);
4889
+ console.error('Invalid sort option (ignoring):', sortProp);
4682
4890
  return undefined
4683
4891
  }
4684
4892
  }
4685
4893
 
4894
+ const DEVTOOLS_SOURCE = '__SYGNAL_DEVTOOLS_PAGE__';
4895
+ const EXTENSION_SOURCE = '__SYGNAL_DEVTOOLS_EXTENSION__';
4896
+ const DEFAULT_MAX_HISTORY = 200;
4897
+
4898
+ class SygnalDevTools {
4899
+ constructor() {
4900
+ this._connected = false;
4901
+ this._components = new Map();
4902
+ this._stateHistory = [];
4903
+ this._maxHistory = DEFAULT_MAX_HISTORY;
4904
+ }
4905
+
4906
+ get connected() {
4907
+ return this._connected && typeof window !== 'undefined'
4908
+ }
4909
+
4910
+ // ─── Initialization ─────────────────────────────────────────────────────────
4911
+
4912
+ init() {
4913
+ if (typeof window === 'undefined') return
4914
+
4915
+ window.__SYGNAL_DEVTOOLS__ = this;
4916
+
4917
+ window.addEventListener('message', (event) => {
4918
+ if (event.source !== window) return
4919
+ if (event.data?.source === EXTENSION_SOURCE) {
4920
+ this._handleExtensionMessage(event.data);
4921
+ }
4922
+ });
4923
+ }
4924
+
4925
+ _handleExtensionMessage(msg) {
4926
+ switch (msg.type) {
4927
+ case 'CONNECT':
4928
+ this._connected = true;
4929
+ if (msg.payload?.maxHistory) this._maxHistory = msg.payload.maxHistory;
4930
+ this._sendFullTree();
4931
+ break
4932
+ case 'DISCONNECT':
4933
+ this._connected = false;
4934
+ break
4935
+ case 'SET_DEBUG':
4936
+ this._setDebug(msg.payload);
4937
+ break
4938
+ case 'TIME_TRAVEL':
4939
+ this._timeTravel(msg.payload);
4940
+ break
4941
+ case 'GET_STATE':
4942
+ this._sendComponentState(msg.payload.componentId);
4943
+ break
4944
+ }
4945
+ }
4946
+
4947
+ // ─── Hooks (called from component.js) ────────────────────────────────────────
4948
+
4949
+ onComponentCreated(componentNumber, name, instance) {
4950
+ const meta = {
4951
+ id: componentNumber,
4952
+ name: name,
4953
+ isSubComponent: instance.isSubComponent,
4954
+ hasModel: !!instance.model,
4955
+ hasIntent: !!instance.intent,
4956
+ hasContext: !!instance.context,
4957
+ hasCalculated: !!instance.calculated,
4958
+ components: Object.keys(instance.components || {}),
4959
+ parentId: null,
4960
+ children: [],
4961
+ debug: instance._debug,
4962
+ createdAt: Date.now(),
4963
+ _instanceRef: new WeakRef(instance),
4964
+ };
4965
+ this._components.set(componentNumber, meta);
4966
+
4967
+ if (!this.connected) return
4968
+ this._post('COMPONENT_CREATED', this._serializeMeta(meta));
4969
+ }
4970
+
4971
+ onStateChanged(componentNumber, name, state) {
4972
+ if (!this.connected) return
4973
+
4974
+ const entry = {
4975
+ componentId: componentNumber,
4976
+ componentName: name,
4977
+ timestamp: Date.now(),
4978
+ state: this._safeClone(state),
4979
+ };
4980
+
4981
+ this._stateHistory.push(entry);
4982
+ if (this._stateHistory.length > this._maxHistory) {
4983
+ this._stateHistory.shift();
4984
+ }
4985
+
4986
+ this._post('STATE_CHANGED', {
4987
+ componentId: componentNumber,
4988
+ componentName: name,
4989
+ state: entry.state,
4990
+ historyIndex: this._stateHistory.length - 1,
4991
+ });
4992
+ }
4993
+
4994
+ onActionDispatched(componentNumber, name, actionType, data) {
4995
+ if (!this.connected) return
4996
+ this._post('ACTION_DISPATCHED', {
4997
+ componentId: componentNumber,
4998
+ componentName: name,
4999
+ actionType: actionType,
5000
+ data: this._safeClone(data),
5001
+ timestamp: Date.now(),
5002
+ });
5003
+ }
5004
+
5005
+ onSubComponentRegistered(parentNumber, childNumber) {
5006
+ const parent = this._components.get(parentNumber);
5007
+ const child = this._components.get(childNumber);
5008
+ if (parent && child) {
5009
+ child.parentId = parentNumber;
5010
+ if (!parent.children.includes(childNumber)) {
5011
+ parent.children.push(childNumber);
5012
+ }
5013
+ }
5014
+
5015
+ if (!this.connected) return
5016
+ this._post('TREE_UPDATED', {
5017
+ parentId: parentNumber,
5018
+ childId: childNumber,
5019
+ });
5020
+ }
5021
+
5022
+ onContextChanged(componentNumber, name, context) {
5023
+ if (!this.connected) return
5024
+ this._post('CONTEXT_CHANGED', {
5025
+ componentId: componentNumber,
5026
+ componentName: name,
5027
+ context: this._safeClone(context),
5028
+ });
5029
+ }
5030
+
5031
+ onDebugLog(componentNumber, message) {
5032
+ if (!this.connected) return
5033
+ this._post('DEBUG_LOG', {
5034
+ componentId: componentNumber,
5035
+ message: message,
5036
+ timestamp: Date.now(),
5037
+ });
5038
+ }
5039
+
5040
+ // ─── Commands (from extension to page) ───────────────────────────────────────
5041
+
5042
+ _setDebug({ componentId, enabled }) {
5043
+ if (typeof componentId === 'undefined' || componentId === null) {
5044
+ if (typeof window !== 'undefined') window.SYGNAL_DEBUG = enabled ? 'true' : false;
5045
+ this._post('DEBUG_TOGGLED', { global: true, enabled });
5046
+ return
5047
+ }
5048
+
5049
+ const meta = this._components.get(componentId);
5050
+ if (meta && meta._instanceRef) {
5051
+ const instance = meta._instanceRef.deref();
5052
+ if (instance) {
5053
+ instance._debug = enabled;
5054
+ meta.debug = enabled;
5055
+ this._post('DEBUG_TOGGLED', { componentId, enabled });
5056
+ }
5057
+ }
5058
+ }
5059
+
5060
+ _timeTravel({ historyIndex }) {
5061
+ const entry = this._stateHistory[historyIndex];
5062
+ if (!entry) return
5063
+
5064
+ const app = typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS_APP__;
5065
+ if (app?.sinks?.STATE?.shamefullySendNext) {
5066
+ app.sinks.STATE.shamefullySendNext(() => ({ ...entry.state }));
5067
+ this._post('TIME_TRAVEL_APPLIED', {
5068
+ historyIndex,
5069
+ state: entry.state,
5070
+ });
5071
+ }
5072
+ }
5073
+
5074
+ _sendComponentState(componentId) {
5075
+ const meta = this._components.get(componentId);
5076
+ if (meta && meta._instanceRef) {
5077
+ const instance = meta._instanceRef.deref();
5078
+ if (instance) {
5079
+ this._post('COMPONENT_STATE', {
5080
+ componentId,
5081
+ state: this._safeClone(instance.currentState),
5082
+ context: this._safeClone(instance.currentContext),
5083
+ props: this._safeClone(instance.currentProps),
5084
+ });
5085
+ }
5086
+ }
5087
+ }
5088
+
5089
+ _sendFullTree() {
5090
+ const tree = [];
5091
+ for (const [id, meta] of this._components) {
5092
+ const instance = meta._instanceRef?.deref();
5093
+ tree.push({
5094
+ ...this._serializeMeta(meta),
5095
+ state: instance ? this._safeClone(instance.currentState) : null,
5096
+ context: instance ? this._safeClone(instance.currentContext) : null,
5097
+ });
5098
+ }
5099
+ this._post('FULL_TREE', {
5100
+ components: tree,
5101
+ history: this._stateHistory,
5102
+ });
5103
+ }
5104
+
5105
+ // ─── Transport ───────────────────────────────────────────────────────────────
5106
+
5107
+ _post(type, payload) {
5108
+ if (typeof window === 'undefined') return
5109
+ window.postMessage({
5110
+ source: DEVTOOLS_SOURCE,
5111
+ type,
5112
+ payload,
5113
+ }, '*');
5114
+ }
5115
+
5116
+ _safeClone(obj) {
5117
+ if (obj === undefined || obj === null) return obj
5118
+ try {
5119
+ return JSON.parse(JSON.stringify(obj))
5120
+ } catch (e) {
5121
+ return '[unserializable]'
5122
+ }
5123
+ }
5124
+
5125
+ _serializeMeta(meta) {
5126
+ const { _instanceRef, ...rest } = meta;
5127
+ return rest
5128
+ }
5129
+ }
5130
+
5131
+ // ─── Singleton ────────────────────────────────────────────────────────────────
5132
+
5133
+ let instance = null;
5134
+
5135
+ function getDevTools() {
5136
+ if (!instance) instance = new SygnalDevTools();
5137
+ return instance
5138
+ }
5139
+
4686
5140
  function run(app, drivers={}, options={}) {
5141
+ // Initialize DevTools instrumentation bridge early (before component creation)
5142
+ if (typeof window !== 'undefined') {
5143
+ const dt = getDevTools();
5144
+ dt.init();
5145
+ }
5146
+
4687
5147
  const { mountPoint='#root', fragments=true, useDefaultDrivers=true } = options;
4688
5148
  if (!app.isSygnalComponent) {
4689
5149
  const name = app.name || app.componentName || app.label || "FUNCTIONAL_COMPONENT";
@@ -4733,6 +5193,11 @@ function run(app, drivers={}, options={}) {
4733
5193
 
4734
5194
  const exposed = { sources, sinks, dispose };
4735
5195
 
5196
+ // Store app reference for time-travel
5197
+ if (typeof window !== 'undefined') {
5198
+ window.__SYGNAL_DEVTOOLS_APP__ = exposed;
5199
+ }
5200
+
4736
5201
  const swapToComponent = (newComponent, state) => {
4737
5202
  const persistedState = (typeof window !== 'undefined') ? window.__SYGNAL_HMR_PERSISTED_STATE : undefined;
4738
5203
  const fallbackState = typeof persistedState !== 'undefined' ? persistedState : app.initialState;