sygnal 4.2.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -90,7 +90,8 @@ function eventBusDriver(out$) {
90
90
  const events = new EventTarget();
91
91
 
92
92
  out$.subscribe({
93
- next: event => events.dispatchEvent(new CustomEvent('data', { detail: event }))
93
+ next: event => events.dispatchEvent(new CustomEvent('data', { detail: event })),
94
+ error: err => console.error('[EVENTS driver] Error in sink stream:', err)
94
95
  });
95
96
 
96
97
  return {
@@ -118,11 +119,19 @@ function logDriver(out$) {
118
119
  out$.addListener({
119
120
  next: (val) => {
120
121
  console.log(val);
122
+ },
123
+ error: (err) => {
124
+ console.error('[LOG driver] Error in sink stream:', err);
121
125
  }
122
126
  });
123
127
  }
124
128
 
129
+ let COLLECTION_COUNT = 0;
130
+
125
131
  function collection(component, stateLense, opts={}) {
132
+ if (typeof component !== 'function') {
133
+ throw new Error('collection: first argument (component) must be a function')
134
+ }
126
135
  const {
127
136
  combineList = ['DOM'],
128
137
  globalList = ['EVENTS'],
@@ -133,7 +142,7 @@ function collection(component, stateLense, opts={}) {
133
142
  } = opts;
134
143
 
135
144
  return (sources) => {
136
- const key = Date.now();
145
+ const key = `sygnal-collection-${COLLECTION_COUNT++}`;
137
146
  const collectionOpts = {
138
147
  item: component,
139
148
  itemKey: (state, ind) => typeof state.id !== 'undefined' ? state.id : ind,
@@ -2904,7 +2913,7 @@ function switchable(factories, name$, initial, opts={}) {
2904
2913
  const mapFunction = (nameType === 'function' && name$) || (state => state[name$]);
2905
2914
  return sources => {
2906
2915
  const state$ = sources && ((typeof stateSourceName === 'string' && sources[stateSourceName]) || sources.STATE || sources.state).stream;
2907
- if (!state$ instanceof Stream$1) throw new Error(`Could not find the state source: ${ stateSourceName }`)
2916
+ if (!(state$ instanceof Stream$1)) throw new Error(`Could not find the state source: ${stateSourceName}`)
2908
2917
  const _name$ = state$
2909
2918
  .map(mapFunction)
2910
2919
  .filter(name => typeof name === 'string')
@@ -3311,13 +3320,27 @@ function wrapDOMSource(domSource) {
3311
3320
  }
3312
3321
 
3313
3322
 
3314
- const ABORT = '~#~#~ABORT~#~#~';
3323
+ const ABORT = Symbol('ABORT');
3324
+
3325
+
3326
+ function normalizeCalculatedEntry(field, entry) {
3327
+ if (typeof entry === 'function') {
3328
+ return { fn: entry, deps: null }
3329
+ }
3330
+ if (Array.isArray(entry) && entry.length === 2
3331
+ && Array.isArray(entry[0]) && typeof entry[1] === 'function') {
3332
+ return { fn: entry[1], deps: entry[0] }
3333
+ }
3334
+ throw new Error(
3335
+ `Invalid calculated field '${field}': expected a function or [depsArray, function]`
3336
+ )
3337
+ }
3315
3338
 
3316
3339
  function component (opts) {
3317
3340
  const { name, sources, isolateOpts, stateSourceName='STATE' } = opts;
3318
3341
 
3319
3342
  if (sources && !isObj(sources)) {
3320
- throw new Error('Sources must be a Cycle.js sources object:', name)
3343
+ throw new Error(`[${name}] Sources must be a Cycle.js sources object`)
3321
3344
  }
3322
3345
 
3323
3346
  let fixedIsolateOpts;
@@ -3397,7 +3420,9 @@ class Component {
3397
3420
  // sinks
3398
3421
 
3399
3422
  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 }) {
3400
- if (!sources || !isObj(sources)) throw new Error('Missing or invalid sources')
3423
+ if (!sources || !isObj(sources)) throw new Error(`[${name}] Missing or invalid sources`)
3424
+
3425
+ this._componentNumber = COMPONENT_COUNT++;
3401
3426
 
3402
3427
  this.name = name;
3403
3428
  this.sources = sources;
@@ -3418,6 +3443,123 @@ class Component {
3418
3443
  this.sourceNames = Object.keys(sources);
3419
3444
  this._debug = debug;
3420
3445
 
3446
+ // Warn if calculated fields shadow base state keys
3447
+ if (this.calculated && this.initialState
3448
+ && isObj(this.calculated) && isObj(this.initialState)) {
3449
+ for (const key of Object.keys(this.calculated)) {
3450
+ if (key in this.initialState) {
3451
+ console.warn(
3452
+ `[${name}] Calculated field '${key}' shadows a key in initialState. ` +
3453
+ `The initialState value will be overwritten on every state update.`
3454
+ );
3455
+ }
3456
+ }
3457
+ }
3458
+
3459
+ // Normalize calculated entries, build dependency graph, topological sort
3460
+ if (this.calculated && isObj(this.calculated)) {
3461
+ const calcEntries = Object.entries(this.calculated);
3462
+
3463
+ // Normalize all entries to { fn, deps } shape
3464
+ this._calculatedNormalized = {};
3465
+ for (const [field, entry] of calcEntries) {
3466
+ this._calculatedNormalized[field] = normalizeCalculatedEntry(field, entry);
3467
+ }
3468
+
3469
+ this._calculatedFieldNames = new Set(Object.keys(this._calculatedNormalized));
3470
+
3471
+ // Warn on deps referencing nonexistent keys
3472
+ for (const [field, { deps }] of Object.entries(this._calculatedNormalized)) {
3473
+ if (deps !== null) {
3474
+ for (const dep of deps) {
3475
+ if (!this._calculatedFieldNames.has(dep)
3476
+ && this.initialState && !(dep in this.initialState)) {
3477
+ console.warn(
3478
+ `[${name}] Calculated field '${field}' declares dependency '${dep}' ` +
3479
+ `which is not in initialState or calculated fields`
3480
+ );
3481
+ }
3482
+ }
3483
+ }
3484
+ }
3485
+
3486
+ // Build adjacency: for each field, which other calculated fields must run first?
3487
+ const calcDeps = {};
3488
+ for (const [field, { deps }] of Object.entries(this._calculatedNormalized)) {
3489
+ if (deps === null) {
3490
+ calcDeps[field] = [];
3491
+ } else {
3492
+ calcDeps[field] = deps.filter(d => this._calculatedFieldNames.has(d));
3493
+ }
3494
+ }
3495
+
3496
+ // Kahn's algorithm for topological sort
3497
+ const inDegree = {};
3498
+ const reverseGraph = {};
3499
+ for (const field of this._calculatedFieldNames) {
3500
+ inDegree[field] = 0;
3501
+ reverseGraph[field] = [];
3502
+ }
3503
+ for (const [field, depList] of Object.entries(calcDeps)) {
3504
+ inDegree[field] = depList.length;
3505
+ for (const dep of depList) {
3506
+ reverseGraph[dep].push(field);
3507
+ }
3508
+ }
3509
+
3510
+ const queue = [];
3511
+ for (const [field, degree] of Object.entries(inDegree)) {
3512
+ if (degree === 0) queue.push(field);
3513
+ }
3514
+
3515
+ const sorted = [];
3516
+ while (queue.length > 0) {
3517
+ const current = queue.shift();
3518
+ sorted.push(current);
3519
+ for (const dependent of reverseGraph[current]) {
3520
+ inDegree[dependent]--;
3521
+ if (inDegree[dependent] === 0) queue.push(dependent);
3522
+ }
3523
+ }
3524
+
3525
+ if (sorted.length !== this._calculatedFieldNames.size) {
3526
+ // Cycle detected — build error message with cycle path
3527
+ const inCycle = [...this._calculatedFieldNames].filter(f => !sorted.includes(f));
3528
+ const visited = new Set();
3529
+ const path = [];
3530
+ const traceCycle = (node) => {
3531
+ if (visited.has(node)) { path.push(node); return true }
3532
+ visited.add(node);
3533
+ path.push(node);
3534
+ for (const dep of calcDeps[node]) {
3535
+ if (inCycle.includes(dep) && traceCycle(dep)) return true
3536
+ }
3537
+ path.pop();
3538
+ visited.delete(node);
3539
+ return false
3540
+ };
3541
+ traceCycle(inCycle[0]);
3542
+ const start = path[path.length - 1];
3543
+ const cycle = path.slice(path.indexOf(start));
3544
+ throw new Error(`Circular calculated dependency: ${cycle.join(' \u2192 ')}`)
3545
+ }
3546
+
3547
+ this._calculatedOrder = sorted.map(f => [f, this._calculatedNormalized[f]]);
3548
+
3549
+ // Initialize per-field memoization caches for fields with declared deps
3550
+ this._calculatedFieldCache = {};
3551
+ for (const [field, { deps }] of this._calculatedOrder) {
3552
+ if (deps !== null) {
3553
+ this._calculatedFieldCache[field] = { lastDepValues: undefined, lastResult: undefined };
3554
+ }
3555
+ }
3556
+ } else {
3557
+ this._calculatedOrder = null;
3558
+ this._calculatedNormalized = null;
3559
+ this._calculatedFieldNames = null;
3560
+ this._calculatedFieldCache = null;
3561
+ }
3562
+
3421
3563
  this.isSubComponent = this.sourceNames.includes('props$');
3422
3564
 
3423
3565
  const state$ = sources[stateSourceName] && sources[stateSourceName].stream;
@@ -3426,6 +3568,9 @@ class Component {
3426
3568
  this.currentState = initialState || {};
3427
3569
  this.sources[stateSourceName] = new StateSource(state$.map(val => {
3428
3570
  this.currentState = val;
3571
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
3572
+ window.__SYGNAL_DEVTOOLS__.onStateChanged(this._componentNumber, this.name, val);
3573
+ }
3429
3574
  return val
3430
3575
  }));
3431
3576
  }
@@ -3461,10 +3606,8 @@ class Component {
3461
3606
  };
3462
3607
  }
3463
3608
 
3464
- const componentNumber = COMPONENT_COUNT++;
3465
-
3466
3609
  this.addCalculated = this.createMemoizedAddCalculated();
3467
- this.log = makeLog(`${componentNumber} | ${name}`);
3610
+ this.log = makeLog(`${this._componentNumber} | ${name}`);
3468
3611
 
3469
3612
  this.initChildSources$();
3470
3613
  this.initIntent$();
@@ -3479,9 +3622,20 @@ class Component {
3479
3622
  this.initVdom$();
3480
3623
  this.initSinks();
3481
3624
 
3482
- this.sinks.__index = componentNumber;
3625
+ this.sinks.__index = this._componentNumber;
3483
3626
 
3484
3627
  this.log(`Instantiated`, true);
3628
+
3629
+ // Hook 1: Register with DevTools
3630
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__) {
3631
+ window.__SYGNAL_DEVTOOLS__.onComponentCreated(this._componentNumber, name, this);
3632
+
3633
+ // Hook 1b: Register parent-child relationship
3634
+ const parentNum = sources?.__parentComponentNumber;
3635
+ if (typeof parentNum === 'number') {
3636
+ window.__SYGNAL_DEVTOOLS__.onSubComponentRegistered(parentNum, this._componentNumber);
3637
+ }
3638
+ }
3485
3639
  }
3486
3640
 
3487
3641
  get debug() {
@@ -3493,13 +3647,13 @@ class Component {
3493
3647
  return
3494
3648
  }
3495
3649
  if (typeof this.intent != 'function') {
3496
- throw new Error('Intent must be a function')
3650
+ throw new Error(`[${this.name}] Intent must be a function`)
3497
3651
  }
3498
3652
 
3499
3653
  this.intent$ = this.intent(this.sources);
3500
3654
 
3501
3655
  if (!(this.intent$ instanceof Stream$1) && (!isObj(this.intent$))) {
3502
- throw new Error('Intent must return either an action$ stream or map of event streams')
3656
+ throw new Error(`[${this.name}] Intent must return either an action$ stream or map of event streams`)
3503
3657
  }
3504
3658
  }
3505
3659
 
@@ -3512,10 +3666,10 @@ class Component {
3512
3666
  this.hmrActions = [this.hmrActions];
3513
3667
  }
3514
3668
  if (!Array.isArray(this.hmrActions)) {
3515
- 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`)
3669
+ 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`)
3516
3670
  }
3517
3671
  if (this.hmrActions.some(action => typeof action !== 'string')) {
3518
- 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`)
3672
+ 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`)
3519
3673
  }
3520
3674
  this.hmrAction$ = xs$1.fromArray(this.hmrActions.map(action => ({ type: action })));
3521
3675
  }
@@ -3554,7 +3708,15 @@ class Component {
3554
3708
  const hydrate$ = initialApiData.map(data => ({ type: HYDRATE_ACTION, data }));
3555
3709
 
3556
3710
  this.action$ = xs$1.merge(wrapped$, hydrate$)
3557
- .compose(this.log(({ type }) => `<${ type }> Action triggered`));
3711
+ .compose(this.log(({ type }) => `<${type}> Action triggered`))
3712
+ .map(action => {
3713
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
3714
+ window.__SYGNAL_DEVTOOLS__.onActionDispatched(
3715
+ this._componentNumber, this.name, action.type, action.data
3716
+ );
3717
+ }
3718
+ return action
3719
+ });
3558
3720
  }
3559
3721
 
3560
3722
  initState() {
@@ -3566,7 +3728,7 @@ class Component {
3566
3728
  } else if (isObj(this.model[INITIALIZE_ACTION])) {
3567
3729
  Object.keys(this.model[INITIALIZE_ACTION]).forEach(name => {
3568
3730
  if (name !== this.stateSourceName) {
3569
- console.warn(`${ INITIALIZE_ACTION } can only be used with the ${ this.stateSourceName } source... disregarding ${ name }`);
3731
+ console.warn(`${INITIALIZE_ACTION} can only be used with the ${this.stateSourceName} source... disregarding ${name}`);
3570
3732
  delete this.model[INITIALIZE_ACTION][name];
3571
3733
  }
3572
3734
  });
@@ -3601,7 +3763,7 @@ class Component {
3601
3763
  } else if (valueType === 'function') {
3602
3764
  _value = value(state);
3603
3765
  } else {
3604
- console.error(`[${ this.name }] Invalid context entry '${ name }': must be the name of a state property or a function returning a value to use`);
3766
+ console.error(`[${this.name}] Invalid context entry '${name}': must be the name of a state property or a function returning a value to use`);
3605
3767
  return acc
3606
3768
  }
3607
3769
  acc[name] = _value;
@@ -3609,11 +3771,14 @@ class Component {
3609
3771
  }, {});
3610
3772
  const newContext = { ..._parent, ...values };
3611
3773
  this.currentContext = newContext;
3774
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
3775
+ window.__SYGNAL_DEVTOOLS__.onContextChanged(this._componentNumber, this.name, newContext);
3776
+ }
3612
3777
  return newContext
3613
3778
  })
3614
3779
  .compose(dropRepeats(objIsEqual))
3615
3780
  .startWith({});
3616
- this.context$.subscribe({ next: _ => _ });
3781
+ this.context$.subscribe({ next: _ => _, error: err => console.error(`[${this.name}] Error in context stream:`, err) });
3617
3782
  }
3618
3783
 
3619
3784
  initModel$() {
@@ -3629,7 +3794,7 @@ class Component {
3629
3794
  const effectiveInitialState = (typeof hmrState !== 'undefined') ? hmrState : this.initialState;
3630
3795
  const initial = { type: INITIALIZE_ACTION, data: effectiveInitialState };
3631
3796
  if (this.isSubComponent && this.initialState) {
3632
- console.warn(`[${ this.name }] Initial state provided to sub-component. This will overwrite any state provided by the parent component.`);
3797
+ console.warn(`[${this.name}] Initial state provided to sub-component. This will overwrite any state provided by the parent component.`);
3633
3798
  }
3634
3799
  const hasInitialState = (typeof effectiveInitialState !== 'undefined');
3635
3800
  const shouldInjectInitialState = hasInitialState && (ENVIRONMENT?.__SYGNAL_HMR_UPDATING !== true || typeof hmrState !== 'undefined');
@@ -3650,7 +3815,7 @@ class Component {
3650
3815
  }
3651
3816
 
3652
3817
  if (!isObj(sinks)) {
3653
- throw new Error(`Entry for each action must be an object: ${ this.name } ${ action }`)
3818
+ throw new Error(`[${this.name}] Entry for each action must be an object: ${action}`)
3654
3819
  }
3655
3820
 
3656
3821
  const sinkEntries = Object.entries(sinks);
@@ -3667,12 +3832,12 @@ class Component {
3667
3832
  const wrapped$ = on$
3668
3833
  .compose(this.log(data => {
3669
3834
  if (isStateSink) {
3670
- return `<${ action }> State reducer added`
3835
+ return `<${action}> State reducer added`
3671
3836
  } else if (isParentSink) {
3672
- return `<${ action }> Data sent to parent component: ${ JSON.stringify(data.value).replaceAll('"', '') }`
3837
+ return `<${action}> Data sent to parent component: ${JSON.stringify(data.value).replaceAll('"', '')}`
3673
3838
  } else {
3674
3839
  const extra = data && (data.type || data.command || data.name || data.key || (Array.isArray(data) && 'Array') || data);
3675
- return `<${ action }> Data sent to [${ sink }]: ${ JSON.stringify(extra).replaceAll('"', '') }`
3840
+ return `<${action}> Data sent to [${sink}]: ${JSON.stringify(extra).replaceAll('"', '')}`
3676
3841
  }
3677
3842
  }));
3678
3843
 
@@ -3750,7 +3915,7 @@ class Component {
3750
3915
 
3751
3916
  }
3752
3917
  });
3753
- subComponentSink$.subscribe({ next: _ => _ });
3918
+ subComponentSink$.subscribe({ next: _ => _, error: err => console.error(`[${this.name}] Error in sub-component sink stream:`, err) });
3754
3919
  this.subComponentSink$ = subComponentSink$.filter(sinks => Object.keys(sinks).length > 0);
3755
3920
  }
3756
3921
 
@@ -3813,13 +3978,13 @@ class Component {
3813
3978
  if (typeof reducer === 'function') {
3814
3979
  returnStream$ = filtered$.map(action => {
3815
3980
  const next = (type, data, delay=10) => {
3816
- 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.`)
3981
+ 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.`)
3817
3982
  // put the "next" action request at the end of the event loop so the "current" action completes first
3818
3983
  setTimeout(() => {
3819
3984
  // push the "next" action request into the action$ stream
3820
3985
  rootAction$.shamefullySendNext({ type, data });
3821
3986
  }, delay);
3822
- this.log(`<${ name }> Triggered a next() action: <${ type }> ${ delay }ms delay`, true);
3987
+ this.log(`<${name}> Triggered a next() action: <${type}> ${delay}ms delay`, true);
3823
3988
  };
3824
3989
 
3825
3990
  const props = { ...this.currentProps, children: this.currentChildren, context: this.currentContext };
@@ -3831,7 +3996,7 @@ class Component {
3831
3996
  const enhancedState = this.addCalculated(_state);
3832
3997
  props.state = enhancedState;
3833
3998
  const newState = reducer(enhancedState, data, next, props);
3834
- if (newState == ABORT) return _state
3999
+ if (newState === ABORT) return _state
3835
4000
  return this.cleanupCalculated(newState)
3836
4001
  }
3837
4002
  } else {
@@ -3840,13 +4005,13 @@ class Component {
3840
4005
  const reduced = reducer(enhancedState, data, next, props);
3841
4006
  const type = typeof reduced;
3842
4007
  if (isObj(reduced) || ['string', 'number', 'boolean', 'function'].includes(type)) return reduced
3843
- if (type == 'undefined') {
3844
- console.warn(`'undefined' value sent to ${ name }`);
4008
+ if (type === 'undefined') {
4009
+ console.warn(`[${this.name}] 'undefined' value sent to ${name}`);
3845
4010
  return reduced
3846
4011
  }
3847
- throw new Error(`Invalid reducer type for ${ name } ${ type }`)
4012
+ throw new Error(`[${this.name}] Invalid reducer type for action '${name}': ${type}`)
3848
4013
  }
3849
- }).filter(result => result != ABORT);
4014
+ }).filter(result => result !== ABORT);
3850
4015
  } else if (reducer === undefined || reducer === true) {
3851
4016
  returnStream$ = filtered$.map(({data}) => data);
3852
4017
  } else {
@@ -3867,7 +4032,7 @@ class Component {
3867
4032
  if (state === lastState) {
3868
4033
  return lastResult
3869
4034
  }
3870
- if (!isObj(this.calculated)) throw new Error(`'calculated' parameter must be an object mapping calculated state field named to functions`)
4035
+ if (!isObj(this.calculated)) throw new Error(`[${this.name}] 'calculated' parameter must be an object mapping calculated state field names to functions`)
3871
4036
 
3872
4037
  const calculated = this.getCalculatedValues(state);
3873
4038
  if (!calculated) {
@@ -3886,19 +4051,55 @@ class Component {
3886
4051
  }
3887
4052
 
3888
4053
  getCalculatedValues(state) {
3889
- const entries = Object.entries(this.calculated || {});
3890
- if (entries.length === 0) {
4054
+ if (!this._calculatedOrder || this._calculatedOrder.length === 0) {
3891
4055
  return
3892
4056
  }
3893
- return entries.reduce((acc, [field, fn]) => {
3894
- if (typeof fn !== 'function') throw new Error(`Missing or invalid calculator function for calculated field '${ field }`)
3895
- try {
3896
- acc[field] = fn(state);
3897
- } catch(e) {
3898
- console.warn(`Calculated field '${ field }' threw an error during calculation: ${ e.message }`);
4057
+
4058
+ const mergedState = { ...state };
4059
+ const computedSoFar = {};
4060
+
4061
+ for (const [field, { fn, deps }] of this._calculatedOrder) {
4062
+ if (deps !== null && this._calculatedFieldCache) {
4063
+ const cache = this._calculatedFieldCache[field];
4064
+ const currentDepValues = deps.map(d => mergedState[d]);
4065
+
4066
+ if (cache.lastDepValues !== undefined) {
4067
+ let unchanged = true;
4068
+ for (let i = 0; i < currentDepValues.length; i++) {
4069
+ if (currentDepValues[i] !== cache.lastDepValues[i]) {
4070
+ unchanged = false;
4071
+ break
4072
+ }
4073
+ }
4074
+ if (unchanged) {
4075
+ computedSoFar[field] = cache.lastResult;
4076
+ mergedState[field] = cache.lastResult;
4077
+ continue
4078
+ }
4079
+ }
4080
+
4081
+ try {
4082
+ const result = fn(mergedState);
4083
+ cache.lastDepValues = currentDepValues;
4084
+ cache.lastResult = result;
4085
+ computedSoFar[field] = result;
4086
+ mergedState[field] = result;
4087
+ } catch (e) {
4088
+ console.warn(`Calculated field '${field}' threw an error during calculation: ${e.message}`);
4089
+ }
4090
+ } else {
4091
+ // No deps declared — always recompute
4092
+ try {
4093
+ const result = fn(mergedState);
4094
+ computedSoFar[field] = result;
4095
+ mergedState[field] = result;
4096
+ } catch (e) {
4097
+ console.warn(`Calculated field '${field}' threw an error during calculation: ${e.message}`);
4098
+ }
3899
4099
  }
3900
- return acc
3901
- }, {})
4100
+ }
4101
+
4102
+ return computedSoFar
3902
4103
  }
3903
4104
 
3904
4105
  cleanupCalculated(incomingState) {
@@ -4046,7 +4247,7 @@ class Component {
4046
4247
  this.newChildSources(childSources);
4047
4248
 
4048
4249
 
4049
- if (newInstanceCount > 0) this.log(`New sub components instantiated: ${ newInstanceCount }`, true);
4250
+ if (newInstanceCount > 0) this.log(`New sub components instantiated: ${newInstanceCount}`, true);
4050
4251
 
4051
4252
  return newComponents
4052
4253
  }, {})
@@ -4112,7 +4313,7 @@ class Component {
4112
4313
  } else if (this.components[collectionOf]) {
4113
4314
  factory = this.components[collectionOf];
4114
4315
  } else {
4115
- throw new Error(`[${this.name}] Invalid 'of' propery in collection: ${ collectionOf }`)
4316
+ throw new Error(`[${this.name}] Invalid 'of' property in collection: ${collectionOf}`)
4116
4317
  }
4117
4318
 
4118
4319
  const fieldLense = {
@@ -4120,7 +4321,7 @@ class Component {
4120
4321
  if (!Array.isArray(state[stateField])) return []
4121
4322
  const items = state[stateField];
4122
4323
  const filtered = typeof arrayOperators.filter === 'function' ? items.filter(arrayOperators.filter) : items;
4123
- const sorted = typeof arrayOperators.sort ? filtered.sort(arrayOperators.sort) : filtered;
4324
+ const sorted = typeof arrayOperators.sort === 'function' ? filtered.sort(arrayOperators.sort) : filtered;
4124
4325
  const mapped = sorted.map((item, index) => {
4125
4326
  return (isObj(item)) ? { ...item, [idField]: item[idField] || index } : { value: item, [idField]: index }
4126
4327
  });
@@ -4129,7 +4330,7 @@ class Component {
4129
4330
  },
4130
4331
  set: (oldState, newState) => {
4131
4332
  if (this.calculated && stateField in this.calculated) {
4132
- console.warn(`Collection sub-component of ${ this.name } attempted to update state on a calculated field '${ stateField }': Update ignored`);
4333
+ console.warn(`Collection sub-component of ${this.name} attempted to update state on a calculated field '${stateField}': Update ignored`);
4133
4334
  return oldState
4134
4335
  }
4135
4336
  const updated = [];
@@ -4158,17 +4359,17 @@ class Component {
4158
4359
  } else if (typeof stateField === 'string') {
4159
4360
  if (isObj(this.currentState)) {
4160
4361
  if(!(this.currentState && stateField in this.currentState) && !(this.calculated && stateField in this.calculated)) {
4161
- 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.`);
4362
+ 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.`);
4162
4363
  lense = undefined;
4163
4364
  } else if (!Array.isArray(this.currentState[stateField])) {
4164
- console.warn(`State property '${ stateField }' in collection comopnent of ${ this.name } is not an array: No components will be instantiated in the collection.`);
4365
+ console.warn(`[${this.name}] State property '${stateField}' in collection component is not an array: No components will be instantiated in the collection.`);
4165
4366
  lense = fieldLense;
4166
4367
  } else {
4167
4368
  lense = fieldLense;
4168
4369
  }
4169
4370
  } else {
4170
4371
  if (!Array.isArray(this.currentState[stateField])) {
4171
- console.warn(`State property '${ stateField }' in collection component of ${ this.name } is not an array: No components will be instantiated in the collection.`);
4372
+ console.warn(`[${this.name}] State property '${stateField}' in collection component is not an array: No components will be instantiated in the collection.`);
4172
4373
  lense = fieldLense;
4173
4374
  } else {
4174
4375
  lense = fieldLense;
@@ -4176,14 +4377,14 @@ class Component {
4176
4377
  }
4177
4378
  } else if (isObj(stateField)) {
4178
4379
  if (typeof stateField.get !== 'function') {
4179
- 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.`);
4380
+ 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.`);
4180
4381
  lense = undefined;
4181
4382
  } else {
4182
4383
  lense = {
4183
4384
  get: (state) => {
4184
4385
  const newState = stateField.get(state);
4185
4386
  if (!Array.isArray(newState)) {
4186
- 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);
4387
+ 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);
4187
4388
  return []
4188
4389
  }
4189
4390
  return newState
@@ -4192,14 +4393,14 @@ class Component {
4192
4393
  };
4193
4394
  }
4194
4395
  } else {
4195
- 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.`);
4396
+ 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.`);
4196
4397
  lense = undefined;
4197
4398
  }
4198
4399
 
4199
- const sources = { ...this.sources, [this.stateSourceName]: stateSource, props$, children$, __parentContext$: this.context$, PARENT: null };
4400
+ const sources = { ...this.sources, [this.stateSourceName]: stateSource, props$, children$, __parentContext$: this.context$, PARENT: null, __parentComponentNumber: this._componentNumber };
4200
4401
  const sink$ = collection(factory, lense, { container: null })(sources);
4201
4402
  if (!isObj(sink$)) {
4202
- throw new Error('Invalid sinks returned from component factory of collection element')
4403
+ throw new Error(`[${this.name}] Invalid sinks returned from component factory of collection element`)
4203
4404
  }
4204
4405
  return sink$
4205
4406
  }
@@ -4221,7 +4422,7 @@ class Component {
4221
4422
  get: state => state[stateField],
4222
4423
  set: (oldState, newState) => {
4223
4424
  if (this.calculated && stateField in this.calculated) {
4224
- console.warn(`Switchable sub-component of ${ this.name } attempted to update state on a calculated field '${ stateField }': Update ignored`);
4425
+ console.warn(`Switchable sub-component of ${this.name} attempted to update state on a calculated field '${stateField}': Update ignored`);
4225
4426
  return oldState
4226
4427
  }
4227
4428
  if (!isObj(newState) || Array.isArray(newState)) return { ...oldState, [stateField]: newState }
@@ -4240,13 +4441,13 @@ class Component {
4240
4441
  lense = fieldLense;
4241
4442
  } else if (isObj(stateField)) {
4242
4443
  if (typeof stateField.get !== 'function') {
4243
- 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.`);
4444
+ 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.`);
4244
4445
  lense = baseLense;
4245
4446
  } else {
4246
4447
  lense = { get: stateField.get, set: stateField.set };
4247
4448
  }
4248
4449
  } else {
4249
- 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.`);
4450
+ 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.`);
4250
4451
  lense = baseLense;
4251
4452
  }
4252
4453
 
@@ -4262,12 +4463,12 @@ class Component {
4262
4463
  switchableComponents[key] = component(options);
4263
4464
  }
4264
4465
  });
4265
- const sources = { ...this.sources, [this.stateSourceName]: stateSource, props$, children$, __parentContext$: this.context$ };
4466
+ const sources = { ...this.sources, [this.stateSourceName]: stateSource, props$, children$, __parentContext$: this.context$, __parentComponentNumber: this._componentNumber };
4266
4467
 
4267
4468
  const sink$ = isolate(switchable(switchableComponents, props$.map(props => props.current)), { [this.stateSourceName]: lense })(sources);
4268
4469
 
4269
4470
  if (!isObj(sink$)) {
4270
- throw new Error('Invalid sinks returned from component factory of switchable element')
4471
+ throw new Error(`[${this.name}] Invalid sinks returned from component factory of switchable element`)
4271
4472
  }
4272
4473
 
4273
4474
  return sink$
@@ -4293,7 +4494,7 @@ class Component {
4293
4494
  const factory = componentName === 'sygnal-factory' ? props.sygnalFactory : (this.components[componentName] || props.sygnalFactory);
4294
4495
  if (!factory) {
4295
4496
  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.`)
4296
- throw new Error(`Component not found: ${ componentName }`)
4497
+ throw new Error(`Component not found: ${componentName}`)
4297
4498
  }
4298
4499
 
4299
4500
  let lense;
@@ -4302,7 +4503,7 @@ class Component {
4302
4503
  get: state => state[stateField],
4303
4504
  set: (oldState, newState) => {
4304
4505
  if (this.calculated && stateField in this.calculated) {
4305
- console.warn(`Sub-component of ${ this.name } attempted to update state on a calculated field '${ stateField }': Update ignored`);
4506
+ console.warn(`Sub-component of ${this.name} attempted to update state on a calculated field '${stateField}': Update ignored`);
4306
4507
  return oldState
4307
4508
  }
4308
4509
  return { ...oldState, [stateField]: newState }
@@ -4320,17 +4521,17 @@ class Component {
4320
4521
  lense = fieldLense;
4321
4522
  } else if (isObj(stateField)) {
4322
4523
  if (typeof stateField.get !== 'function') {
4323
- 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.`);
4524
+ 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.`);
4324
4525
  lense = baseLense;
4325
4526
  } else {
4326
4527
  lense = { get: stateField.get, set: stateField.set };
4327
4528
  }
4328
4529
  } else {
4329
- 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.`);
4530
+ 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.`);
4330
4531
  lense = baseLense;
4331
4532
  }
4332
4533
 
4333
- const sources = { ...this.sources, [this.stateSourceName]: stateSource, props$, children$, __parentContext$: this.context$ };
4534
+ const sources = { ...this.sources, [this.stateSourceName]: stateSource, props$, children$, __parentContext$: this.context$, __parentComponentNumber: this._componentNumber };
4334
4535
  const sink$ = isolate(factory, { [this.stateSourceName]: lense })(sources);
4335
4536
 
4336
4537
  if (!isObj(sink$)) {
@@ -4405,14 +4606,22 @@ class Component {
4405
4606
  const fixedMsg = (typeof msg === 'function') ? msg : _ => msg;
4406
4607
  if (immediate) {
4407
4608
  if (this.debug) {
4408
- console.log(`[${context}] ${fixedMsg(msg)}`);
4609
+ const text = `[${context}] ${fixedMsg(msg)}`;
4610
+ console.log(text);
4611
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
4612
+ window.__SYGNAL_DEVTOOLS__.onDebugLog(this._componentNumber, text);
4613
+ }
4409
4614
  }
4410
4615
  return
4411
4616
  } else {
4412
4617
  return stream => {
4413
4618
  return stream.debug(msg => {
4414
4619
  if (this.debug) {
4415
- console.log(`[${context}] ${fixedMsg(msg)}`);
4620
+ const text = `[${context}] ${fixedMsg(msg)}`;
4621
+ console.log(text);
4622
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
4623
+ window.__SYGNAL_DEVTOOLS__.onDebugLog(this._componentNumber, text);
4624
+ }
4416
4625
  }
4417
4626
  })
4418
4627
  }
@@ -4422,11 +4631,11 @@ class Component {
4422
4631
 
4423
4632
 
4424
4633
 
4425
- function getComponents(currentElement, componentNames, depth=0, index=0, parentId) {
4634
+ function getComponents(currentElement, componentNames, path='r', parentId) {
4426
4635
  if (!currentElement) return {}
4427
4636
 
4428
4637
  if (currentElement.data?.componentsProcessed) return {}
4429
- if (depth === 0) currentElement.data.componentsProcessed = true;
4638
+ if (path === 'r') currentElement.data.componentsProcessed = true;
4430
4639
 
4431
4640
  const sel = currentElement.sel;
4432
4641
  const isCollection = sel && sel.toLowerCase() === 'collection';
@@ -4440,11 +4649,11 @@ function getComponents(currentElement, componentNames, depth=0, index=0, parentI
4440
4649
 
4441
4650
  let id = parentId;
4442
4651
  if (isComponent) {
4443
- id = getComponentIdFromElement(currentElement, depth, index, parentId);
4652
+ id = getComponentIdFromElement(currentElement, path, parentId);
4444
4653
  if (isCollection) {
4445
4654
  if (!props.of) throw new Error(`Collection element missing required 'component' property`)
4446
4655
  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`)
4447
- if (typeof props.of !== 'function' && !componentNames.includes(props.of)) throw new Error(`Specified component for collection not found: ${ props.of }`)
4656
+ if (typeof props.of !== 'function' && !componentNames.includes(props.of)) throw new Error(`Specified component for collection not found: ${props.of}`)
4448
4657
  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);
4449
4658
  currentElement.data.isCollection = true;
4450
4659
  currentElement.data.props ||= {};
@@ -4455,7 +4664,7 @@ function getComponents(currentElement, componentNames, depth=0, index=0, parentI
4455
4664
  if (!switchableComponents.every(comp => typeof comp === 'function')) throw new Error(`One or more components provided to switchable element is not a valid component factory`)
4456
4665
  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`)
4457
4666
  const switchableComponentNames = Object.keys(props.of);
4458
- if (!switchableComponentNames.includes(props.current)) throw new Error(`Component '${ props.current }' not found in switchable element`)
4667
+ if (!switchableComponentNames.includes(props.current)) throw new Error(`Component '${props.current}' not found in switchable element`)
4459
4668
  currentElement.data.isSwitchable = true;
4460
4669
  } else ;
4461
4670
  if (typeof props.key === 'undefined') currentElement.data.props.key = id;
@@ -4463,7 +4672,7 @@ function getComponents(currentElement, componentNames, depth=0, index=0, parentI
4463
4672
  }
4464
4673
 
4465
4674
  if (children.length > 0) {
4466
- children.map((child, i) => getComponents(child, componentNames, depth + 1, index + i, id))
4675
+ children.map((child, i) => getComponents(child, componentNames, `${path}.${i}`, id))
4467
4676
  .forEach((child) => {
4468
4677
  Object.entries(child).forEach(([id, el]) => found[id] = el);
4469
4678
  });
@@ -4472,10 +4681,10 @@ function getComponents(currentElement, componentNames, depth=0, index=0, parentI
4472
4681
  return found
4473
4682
  }
4474
4683
 
4475
- function injectComponents(currentElement, components, componentNames, depth=0, index=0, parentId) {
4684
+ function injectComponents(currentElement, components, componentNames, path='r', parentId) {
4476
4685
  if (!currentElement) return
4477
4686
  if (currentElement.data?.componentsInjected) return currentElement
4478
- if (depth === 0 && currentElement.data) currentElement.data.componentsInjected = true;
4687
+ if (path === 'r' && currentElement.data) currentElement.data.componentsInjected = true;
4479
4688
 
4480
4689
 
4481
4690
  const sel = currentElement.sel || 'NO SELECTOR';
@@ -4487,7 +4696,7 @@ function injectComponents(currentElement, components, componentNames, depth=0, i
4487
4696
 
4488
4697
  let id = parentId;
4489
4698
  if (isComponent) {
4490
- id = getComponentIdFromElement(currentElement, depth, index, parentId);
4699
+ id = getComponentIdFromElement(currentElement, path, parentId);
4491
4700
  const component = components[id];
4492
4701
  if (isCollection) {
4493
4702
  currentElement.sel = 'div';
@@ -4499,21 +4708,20 @@ function injectComponents(currentElement, components, componentNames, depth=0, i
4499
4708
  return component
4500
4709
  }
4501
4710
  } else if (children.length > 0) {
4502
- currentElement.children = children.map((child, i) => injectComponents(child, components, componentNames, depth + 1, index + i, id)).flat();
4711
+ currentElement.children = children.map((child, i) => injectComponents(child, components, componentNames, `${path}.${i}`, id)).flat();
4503
4712
  return currentElement
4504
4713
  } else {
4505
4714
  return currentElement
4506
4715
  }
4507
4716
  }
4508
4717
 
4509
- function getComponentIdFromElement(el, depth, index, parentId) {
4718
+ function getComponentIdFromElement(el, path, parentId) {
4510
4719
  const sel = el.sel;
4511
4720
  const name = typeof sel === 'string' ? sel : 'functionComponent';
4512
- const uid = `${depth}:${index}`;
4513
4721
  const props = el.data?.props || {};
4514
- const id = (props.id && JSON.stringify(props.id).replaceAll('"', '')) || uid;
4515
- const parentString = parentId ? `${ parentId }|` : '';
4516
- const fullId = `${ parentString }${ name }::${ id }`;
4722
+ const id = (props.id && JSON.stringify(props.id).replaceAll('"', '')) || path;
4723
+ const parentString = parentId ? `${parentId}|` : '';
4724
+ const fullId = `${parentString}${name}::${id}`;
4517
4725
  return fullId
4518
4726
  }
4519
4727
 
@@ -4657,12 +4865,264 @@ function sortFunctionFromProp(sortProp) {
4657
4865
  } else if (isObj(sortProp)) {
4658
4866
  return __sortFunctionFromObj(sortProp)
4659
4867
  } else {
4660
- console.error('Invalid sort option (ignoring):', item);
4868
+ console.error('Invalid sort option (ignoring):', sortProp);
4661
4869
  return undefined
4662
4870
  }
4663
4871
  }
4664
4872
 
4873
+ const DEVTOOLS_SOURCE = '__SYGNAL_DEVTOOLS_PAGE__';
4874
+ const EXTENSION_SOURCE = '__SYGNAL_DEVTOOLS_EXTENSION__';
4875
+ const DEFAULT_MAX_HISTORY = 200;
4876
+
4877
+ class SygnalDevTools {
4878
+ constructor() {
4879
+ this._connected = false;
4880
+ this._components = new Map();
4881
+ this._stateHistory = [];
4882
+ this._maxHistory = DEFAULT_MAX_HISTORY;
4883
+ }
4884
+
4885
+ get connected() {
4886
+ return this._connected && typeof window !== 'undefined'
4887
+ }
4888
+
4889
+ // ─── Initialization ─────────────────────────────────────────────────────────
4890
+
4891
+ init() {
4892
+ if (typeof window === 'undefined') return
4893
+
4894
+ window.__SYGNAL_DEVTOOLS__ = this;
4895
+
4896
+ window.addEventListener('message', (event) => {
4897
+ if (event.source !== window) return
4898
+ if (event.data?.source === EXTENSION_SOURCE) {
4899
+ this._handleExtensionMessage(event.data);
4900
+ }
4901
+ });
4902
+ }
4903
+
4904
+ _handleExtensionMessage(msg) {
4905
+ switch (msg.type) {
4906
+ case 'CONNECT':
4907
+ this._connected = true;
4908
+ if (msg.payload?.maxHistory) this._maxHistory = msg.payload.maxHistory;
4909
+ this._sendFullTree();
4910
+ break
4911
+ case 'DISCONNECT':
4912
+ this._connected = false;
4913
+ break
4914
+ case 'SET_DEBUG':
4915
+ this._setDebug(msg.payload);
4916
+ break
4917
+ case 'TIME_TRAVEL':
4918
+ this._timeTravel(msg.payload);
4919
+ break
4920
+ case 'GET_STATE':
4921
+ this._sendComponentState(msg.payload.componentId);
4922
+ break
4923
+ }
4924
+ }
4925
+
4926
+ // ─── Hooks (called from component.js) ────────────────────────────────────────
4927
+
4928
+ onComponentCreated(componentNumber, name, instance) {
4929
+ const meta = {
4930
+ id: componentNumber,
4931
+ name: name,
4932
+ isSubComponent: instance.isSubComponent,
4933
+ hasModel: !!instance.model,
4934
+ hasIntent: !!instance.intent,
4935
+ hasContext: !!instance.context,
4936
+ hasCalculated: !!instance.calculated,
4937
+ components: Object.keys(instance.components || {}),
4938
+ parentId: null,
4939
+ children: [],
4940
+ debug: instance._debug,
4941
+ createdAt: Date.now(),
4942
+ _instanceRef: new WeakRef(instance),
4943
+ };
4944
+ this._components.set(componentNumber, meta);
4945
+
4946
+ if (!this.connected) return
4947
+ this._post('COMPONENT_CREATED', this._serializeMeta(meta));
4948
+ }
4949
+
4950
+ onStateChanged(componentNumber, name, state) {
4951
+ if (!this.connected) return
4952
+
4953
+ const entry = {
4954
+ componentId: componentNumber,
4955
+ componentName: name,
4956
+ timestamp: Date.now(),
4957
+ state: this._safeClone(state),
4958
+ };
4959
+
4960
+ this._stateHistory.push(entry);
4961
+ if (this._stateHistory.length > this._maxHistory) {
4962
+ this._stateHistory.shift();
4963
+ }
4964
+
4965
+ this._post('STATE_CHANGED', {
4966
+ componentId: componentNumber,
4967
+ componentName: name,
4968
+ state: entry.state,
4969
+ historyIndex: this._stateHistory.length - 1,
4970
+ });
4971
+ }
4972
+
4973
+ onActionDispatched(componentNumber, name, actionType, data) {
4974
+ if (!this.connected) return
4975
+ this._post('ACTION_DISPATCHED', {
4976
+ componentId: componentNumber,
4977
+ componentName: name,
4978
+ actionType: actionType,
4979
+ data: this._safeClone(data),
4980
+ timestamp: Date.now(),
4981
+ });
4982
+ }
4983
+
4984
+ onSubComponentRegistered(parentNumber, childNumber) {
4985
+ const parent = this._components.get(parentNumber);
4986
+ const child = this._components.get(childNumber);
4987
+ if (parent && child) {
4988
+ child.parentId = parentNumber;
4989
+ if (!parent.children.includes(childNumber)) {
4990
+ parent.children.push(childNumber);
4991
+ }
4992
+ }
4993
+
4994
+ if (!this.connected) return
4995
+ this._post('TREE_UPDATED', {
4996
+ parentId: parentNumber,
4997
+ childId: childNumber,
4998
+ });
4999
+ }
5000
+
5001
+ onContextChanged(componentNumber, name, context) {
5002
+ if (!this.connected) return
5003
+ this._post('CONTEXT_CHANGED', {
5004
+ componentId: componentNumber,
5005
+ componentName: name,
5006
+ context: this._safeClone(context),
5007
+ });
5008
+ }
5009
+
5010
+ onDebugLog(componentNumber, message) {
5011
+ if (!this.connected) return
5012
+ this._post('DEBUG_LOG', {
5013
+ componentId: componentNumber,
5014
+ message: message,
5015
+ timestamp: Date.now(),
5016
+ });
5017
+ }
5018
+
5019
+ // ─── Commands (from extension to page) ───────────────────────────────────────
5020
+
5021
+ _setDebug({ componentId, enabled }) {
5022
+ if (typeof componentId === 'undefined' || componentId === null) {
5023
+ if (typeof window !== 'undefined') window.SYGNAL_DEBUG = enabled ? 'true' : false;
5024
+ this._post('DEBUG_TOGGLED', { global: true, enabled });
5025
+ return
5026
+ }
5027
+
5028
+ const meta = this._components.get(componentId);
5029
+ if (meta && meta._instanceRef) {
5030
+ const instance = meta._instanceRef.deref();
5031
+ if (instance) {
5032
+ instance._debug = enabled;
5033
+ meta.debug = enabled;
5034
+ this._post('DEBUG_TOGGLED', { componentId, enabled });
5035
+ }
5036
+ }
5037
+ }
5038
+
5039
+ _timeTravel({ historyIndex }) {
5040
+ const entry = this._stateHistory[historyIndex];
5041
+ if (!entry) return
5042
+
5043
+ const app = typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS_APP__;
5044
+ if (app?.sinks?.STATE?.shamefullySendNext) {
5045
+ app.sinks.STATE.shamefullySendNext(() => ({ ...entry.state }));
5046
+ this._post('TIME_TRAVEL_APPLIED', {
5047
+ historyIndex,
5048
+ state: entry.state,
5049
+ });
5050
+ }
5051
+ }
5052
+
5053
+ _sendComponentState(componentId) {
5054
+ const meta = this._components.get(componentId);
5055
+ if (meta && meta._instanceRef) {
5056
+ const instance = meta._instanceRef.deref();
5057
+ if (instance) {
5058
+ this._post('COMPONENT_STATE', {
5059
+ componentId,
5060
+ state: this._safeClone(instance.currentState),
5061
+ context: this._safeClone(instance.currentContext),
5062
+ props: this._safeClone(instance.currentProps),
5063
+ });
5064
+ }
5065
+ }
5066
+ }
5067
+
5068
+ _sendFullTree() {
5069
+ const tree = [];
5070
+ for (const [id, meta] of this._components) {
5071
+ const instance = meta._instanceRef?.deref();
5072
+ tree.push({
5073
+ ...this._serializeMeta(meta),
5074
+ state: instance ? this._safeClone(instance.currentState) : null,
5075
+ context: instance ? this._safeClone(instance.currentContext) : null,
5076
+ });
5077
+ }
5078
+ this._post('FULL_TREE', {
5079
+ components: tree,
5080
+ history: this._stateHistory,
5081
+ });
5082
+ }
5083
+
5084
+ // ─── Transport ───────────────────────────────────────────────────────────────
5085
+
5086
+ _post(type, payload) {
5087
+ if (typeof window === 'undefined') return
5088
+ window.postMessage({
5089
+ source: DEVTOOLS_SOURCE,
5090
+ type,
5091
+ payload,
5092
+ }, '*');
5093
+ }
5094
+
5095
+ _safeClone(obj) {
5096
+ if (obj === undefined || obj === null) return obj
5097
+ try {
5098
+ return JSON.parse(JSON.stringify(obj))
5099
+ } catch (e) {
5100
+ return '[unserializable]'
5101
+ }
5102
+ }
5103
+
5104
+ _serializeMeta(meta) {
5105
+ const { _instanceRef, ...rest } = meta;
5106
+ return rest
5107
+ }
5108
+ }
5109
+
5110
+ // ─── Singleton ────────────────────────────────────────────────────────────────
5111
+
5112
+ let instance = null;
5113
+
5114
+ function getDevTools() {
5115
+ if (!instance) instance = new SygnalDevTools();
5116
+ return instance
5117
+ }
5118
+
4665
5119
  function run(app, drivers={}, options={}) {
5120
+ // Initialize DevTools instrumentation bridge early (before component creation)
5121
+ if (typeof window !== 'undefined') {
5122
+ const dt = getDevTools();
5123
+ dt.init();
5124
+ }
5125
+
4666
5126
  const { mountPoint='#root', fragments=true, useDefaultDrivers=true } = options;
4667
5127
  if (!app.isSygnalComponent) {
4668
5128
  const name = app.name || app.componentName || app.label || "FUNCTIONAL_COMPONENT";
@@ -4712,6 +5172,11 @@ function run(app, drivers={}, options={}) {
4712
5172
 
4713
5173
  const exposed = { sources, sinks, dispose };
4714
5174
 
5175
+ // Store app reference for time-travel
5176
+ if (typeof window !== 'undefined') {
5177
+ window.__SYGNAL_DEVTOOLS_APP__ = exposed;
5178
+ }
5179
+
4715
5180
  const swapToComponent = (newComponent, state) => {
4716
5181
  const persistedState = (typeof window !== 'undefined') ? window.__SYGNAL_HMR_PERSISTED_STATE : undefined;
4717
5182
  const fallbackState = typeof persistedState !== 'undefined' ? persistedState : app.initialState;