sygnal 5.2.0 → 5.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.
@@ -5027,7 +5027,12 @@ function makeDOMDriver(container, options = {}) {
5027
5027
  function eventBusDriver(out$) {
5028
5028
  const events = new EventTarget();
5029
5029
  out$.subscribe({
5030
- next: (event) => events.dispatchEvent(new CustomEvent('data', { detail: event })),
5030
+ next: (event) => {
5031
+ events.dispatchEvent(new CustomEvent('data', { detail: event }));
5032
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
5033
+ window.__SYGNAL_DEVTOOLS__.onBusEvent(event);
5034
+ }
5035
+ },
5031
5036
  error: (err) => console.error('[EVENTS driver] Error in sink stream:', err),
5032
5037
  });
5033
5038
  return {
@@ -5530,7 +5535,7 @@ function component(opts) {
5530
5535
  return returnFunction;
5531
5536
  }
5532
5537
  class Component {
5533
- constructor({ name = 'NO NAME', sources, intent, model, hmrActions, context, response, view, peers = {}, components = {}, initialState, calculated, storeCalculatedInState = true, DOMSourceName = 'DOM', stateSourceName = 'STATE', requestSourceName = 'HTTP', onError, debug = false }) {
5538
+ constructor({ name = 'NO NAME', sources, intent, model, hmrActions, context, response, view, peers = {}, components = {}, initialState, calculated, storeCalculatedInState = true, DOMSourceName = 'DOM', stateSourceName = 'STATE', requestSourceName = 'HTTP', isolatedState = false, onError, debug = false }) {
5534
5539
  if (!sources || !isObj(sources))
5535
5540
  throw new Error(`[${name}] Missing or invalid sources`);
5536
5541
  this._componentNumber = COMPONENT_COUNT++;
@@ -5552,6 +5557,7 @@ class Component {
5552
5557
  this.requestSourceName = requestSourceName;
5553
5558
  this.sourceNames = Object.keys(sources);
5554
5559
  this.onError = onError;
5560
+ this.isolatedState = isolatedState;
5555
5561
  this._debug = debug;
5556
5562
  // Warn if calculated fields shadow base state keys
5557
5563
  if (this.calculated && this.initialState
@@ -5680,6 +5686,9 @@ class Component {
5680
5686
  this.sources.props$ = props$.map((val) => {
5681
5687
  const { sygnalFactory, sygnalOptions, ...sanitizedProps } = val;
5682
5688
  this.currentProps = sanitizedProps;
5689
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
5690
+ window.__SYGNAL_DEVTOOLS__.onPropsChanged(this._componentNumber, this.name, sanitizedProps);
5691
+ }
5683
5692
  return val;
5684
5693
  });
5685
5694
  }
@@ -5751,6 +5760,9 @@ class Component {
5751
5760
  }
5752
5761
  }
5753
5762
  dispose() {
5763
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
5764
+ window.__SYGNAL_DEVTOOLS__.onComponentDisposed(this._componentNumber, this.name);
5765
+ }
5754
5766
  // Fire the DISPOSE built-in action so model handlers can run cleanup logic
5755
5767
  const hasDispose = this.model && (this.model[DISPOSE_ACTION] || Object.keys(this.model).some(k => k.includes('|') && k.split('|')[0].trim() === DISPOSE_ACTION));
5756
5768
  if (hasDispose && this.action$ && typeof this.action$.shamefullySendNext === 'function') {
@@ -5951,7 +5963,7 @@ class Component {
5951
5963
  const hmrState = ENVIRONMENT?.__SYGNAL_HMR_STATE;
5952
5964
  const effectiveInitialState = (typeof hmrState !== 'undefined') ? hmrState : this.initialState;
5953
5965
  const initial = { type: INITIALIZE_ACTION, data: effectiveInitialState };
5954
- if (this.isSubComponent && this.initialState) {
5966
+ if (this.isSubComponent && this.initialState && !this.isolatedState) {
5955
5967
  console.warn(`[${this.name}] Initial state provided to sub-component. This will overwrite any state provided by the parent component.`);
5956
5968
  }
5957
5969
  const hasInitialState = (typeof effectiveInitialState !== 'undefined');
@@ -6132,6 +6144,7 @@ class Component {
6132
6144
  .map((vdom) => processLazy(vdom, this))
6133
6145
  .map(processPortals)
6134
6146
  .map(processTransitions)
6147
+ .map(processClientOnly)
6135
6148
  .compose(this.instantiateSubComponents.bind(this))
6136
6149
  .filter((val) => val !== undefined)
6137
6150
  .compose(this.renderVdom.bind(this));
@@ -6147,6 +6160,12 @@ class Component {
6147
6160
  else {
6148
6161
  acc[name] = xs.merge((this.model$[name] || xs.never()), subComponentSink$, ...(this.peers$[name] || []));
6149
6162
  }
6163
+ // Stamp EVENTS sink emissions with emitter component info for devtools
6164
+ if (name === 'EVENTS' && acc[name]) {
6165
+ const _componentNumber = this._componentNumber;
6166
+ const _name = this.name;
6167
+ acc[name] = acc[name].map((ev) => ({ ...ev, __emitterId: _componentNumber, __emitterName: _name }));
6168
+ }
6150
6169
  return acc;
6151
6170
  }, {});
6152
6171
  this.sinks[this.DOMSourceName] = this.vdom$;
@@ -6669,6 +6688,13 @@ class Component {
6669
6688
  if (!isObj(sink$)) {
6670
6689
  throw new Error(`[${this.name}] Invalid sinks returned from component factory of collection element`);
6671
6690
  }
6691
+ // Notify devtools of collection mount
6692
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
6693
+ const itemName = typeof collectionOf === 'function'
6694
+ ? (collectionOf.componentName || collectionOf.label || collectionOf.name || 'anonymous')
6695
+ : String(collectionOf);
6696
+ window.__SYGNAL_DEVTOOLS__.onCollectionMounted(this._componentNumber, this.name, itemName, typeof stateField === 'string' ? stateField : null);
6697
+ }
6672
6698
  return sink$;
6673
6699
  }
6674
6700
  instantiateSwitchable(el, props$, children$) {
@@ -6807,6 +6833,7 @@ class Component {
6807
6833
  for (const key of Object.keys(props)) {
6808
6834
  const val = props[key];
6809
6835
  if (val && val.__sygnalCommand) {
6836
+ val._targetComponentName = componentName;
6810
6837
  sources.commands$ = makeCommandSource(val);
6811
6838
  break;
6812
6839
  }
@@ -6849,10 +6876,15 @@ class Component {
6849
6876
  const wasReady = this._childReadyState[id];
6850
6877
  this._childReadyState[id] = !!ready;
6851
6878
  // When READY state changes, trigger a re-render
6852
- if (wasReady !== !!ready && this._readyChangedListener) {
6853
- setTimeout(() => {
6854
- this._readyChangedListener?.next(null);
6855
- }, 0);
6879
+ if (wasReady !== !!ready) {
6880
+ if (this._readyChangedListener) {
6881
+ setTimeout(() => {
6882
+ this._readyChangedListener?.next(null);
6883
+ }, 0);
6884
+ }
6885
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
6886
+ window.__SYGNAL_DEVTOOLS__.onReadyChanged(this._componentNumber, this.name, id, !!ready);
6887
+ }
6856
6888
  }
6857
6889
  },
6858
6890
  error: () => { },
@@ -7109,6 +7141,31 @@ function processTransitions(vnode) {
7109
7141
  }
7110
7142
  return vnode;
7111
7143
  }
7144
+ function processClientOnly(vnode) {
7145
+ if (!vnode || !vnode.sel)
7146
+ return vnode;
7147
+ if (vnode.sel === 'clientonly') {
7148
+ // On the client, unwrap to children (render them normally)
7149
+ const children = vnode.children || [];
7150
+ if (children.length === 0)
7151
+ return { sel: 'div', data: {}, children: [] };
7152
+ if (children.length === 1)
7153
+ return processClientOnly(children[0]);
7154
+ // Multiple children: wrap in a div
7155
+ return {
7156
+ sel: 'div',
7157
+ data: {},
7158
+ children: children.map(processClientOnly),
7159
+ text: undefined,
7160
+ elm: undefined,
7161
+ key: undefined,
7162
+ };
7163
+ }
7164
+ if (vnode.children && vnode.children.length > 0) {
7165
+ vnode.children = vnode.children.map(processClientOnly);
7166
+ }
7167
+ return vnode;
7168
+ }
7112
7169
  function applyTransitionHooks(vnode, name, duration) {
7113
7170
  const existingInsert = vnode.data?.hook?.insert;
7114
7171
  const existingRemove = vnode.data?.hook?.remove;
@@ -7463,6 +7520,12 @@ class SygnalDevTools {
7463
7520
  case 'TIME_TRAVEL':
7464
7521
  this._timeTravel(msg.payload);
7465
7522
  break;
7523
+ case 'SNAPSHOT':
7524
+ this._takeSnapshot();
7525
+ break;
7526
+ case 'RESTORE_SNAPSHOT':
7527
+ this._restoreSnapshot(msg.payload);
7528
+ break;
7466
7529
  case 'GET_STATE':
7467
7530
  this._sendComponentState(msg.payload.componentId);
7468
7531
  break;
@@ -7482,6 +7545,7 @@ class SygnalDevTools {
7482
7545
  children: [],
7483
7546
  debug: instance._debug,
7484
7547
  createdAt: Date.now(),
7548
+ mviGraph: this._extractMviGraph(instance),
7485
7549
  _instanceRef: new WeakRef(instance),
7486
7550
  };
7487
7551
  this._components.set(componentNumber, meta);
@@ -7506,7 +7570,6 @@ class SygnalDevTools {
7506
7570
  componentId: componentNumber,
7507
7571
  componentName: name,
7508
7572
  state: entry.state,
7509
- historyIndex: this._stateHistory.length - 1,
7510
7573
  });
7511
7574
  }
7512
7575
  onActionDispatched(componentNumber, name, actionType, data) {
@@ -7543,6 +7606,76 @@ class SygnalDevTools {
7543
7606
  componentId: componentNumber,
7544
7607
  componentName: name,
7545
7608
  context: this._safeClone(context),
7609
+ contextTrace: this._buildContextTrace(componentNumber, context),
7610
+ });
7611
+ }
7612
+ onPropsChanged(componentNumber, name, props) {
7613
+ if (!this.connected)
7614
+ return;
7615
+ this._post('PROPS_CHANGED', {
7616
+ componentId: componentNumber,
7617
+ componentName: name,
7618
+ props: this._safeClone(props),
7619
+ });
7620
+ }
7621
+ onBusEvent(event) {
7622
+ if (!this.connected)
7623
+ return;
7624
+ this._post('BUS_EVENT', {
7625
+ type: event.type,
7626
+ data: this._safeClone(event.data),
7627
+ componentId: event.__emitterId ?? null,
7628
+ componentName: event.__emitterName ?? null,
7629
+ timestamp: Date.now(),
7630
+ });
7631
+ }
7632
+ onCommandSent(type, data, targetComponentId, targetComponentName) {
7633
+ if (!this.connected)
7634
+ return;
7635
+ this._post('COMMAND_SENT', {
7636
+ type,
7637
+ data: this._safeClone(data),
7638
+ targetComponentName: targetComponentName ?? null,
7639
+ timestamp: Date.now(),
7640
+ });
7641
+ }
7642
+ onReadyChanged(parentId, parentName, childKey, ready) {
7643
+ if (!this.connected)
7644
+ return;
7645
+ this._post('READY_CHANGED', {
7646
+ parentId,
7647
+ parentName,
7648
+ childKey,
7649
+ ready,
7650
+ timestamp: Date.now(),
7651
+ });
7652
+ }
7653
+ onCollectionMounted(parentId, parentName, itemComponentName, stateField) {
7654
+ const meta = this._components.get(parentId);
7655
+ if (meta) {
7656
+ meta.collection = { itemComponent: itemComponentName, stateField };
7657
+ }
7658
+ if (!this.connected)
7659
+ return;
7660
+ this._post('COLLECTION_MOUNTED', {
7661
+ parentId,
7662
+ parentName,
7663
+ itemComponent: itemComponentName,
7664
+ stateField,
7665
+ });
7666
+ }
7667
+ onComponentDisposed(componentNumber, name) {
7668
+ const meta = this._components.get(componentNumber);
7669
+ if (meta) {
7670
+ meta.disposed = true;
7671
+ meta.disposedAt = Date.now();
7672
+ }
7673
+ if (!this.connected)
7674
+ return;
7675
+ this._post('COMPONENT_DISPOSED', {
7676
+ componentId: componentNumber,
7677
+ componentName: name,
7678
+ timestamp: Date.now(),
7546
7679
  });
7547
7680
  }
7548
7681
  onDebugLog(componentNumber, message) {
@@ -7571,20 +7704,79 @@ class SygnalDevTools {
7571
7704
  }
7572
7705
  }
7573
7706
  }
7574
- _timeTravel({ historyIndex }) {
7575
- const entry = this._stateHistory[historyIndex];
7576
- if (!entry)
7707
+ _timeTravel({ componentId, componentName, state }) {
7708
+ if (componentId == null || !state) {
7709
+ console.warn('[Sygnal DevTools] _timeTravel: missing componentId or state', { componentId, hasState: !!state });
7577
7710
  return;
7711
+ }
7578
7712
  if (typeof window === 'undefined')
7579
7713
  return;
7714
+ const newState = this._safeClone(state);
7715
+ // Try per-component time-travel via the component's STATE sink (reducer stream)
7716
+ const meta = this._components.get(componentId);
7717
+ if (meta) {
7718
+ const instance = meta._instanceRef?.deref();
7719
+ if (!instance) {
7720
+ console.warn(`[Sygnal DevTools] _timeTravel: WeakRef for component #${componentId} (${componentName}) has been GC'd`);
7721
+ }
7722
+ else {
7723
+ // sinks[stateSourceName] is the reducer stream — push a reducer that replaces state
7724
+ const stateSinkName = instance.stateSourceName || 'STATE';
7725
+ const stateSink = instance.sinks?.[stateSinkName];
7726
+ if (stateSink?.shamefullySendNext) {
7727
+ stateSink.shamefullySendNext(() => ({ ...newState }));
7728
+ this._post('TIME_TRAVEL_APPLIED', {
7729
+ componentId,
7730
+ componentName,
7731
+ state: newState,
7732
+ });
7733
+ return;
7734
+ }
7735
+ console.warn(`[Sygnal DevTools] _timeTravel: component #${componentId} (${componentName}) has no STATE sink with shamefullySendNext. sinkName=${stateSinkName}, hasSinks=${!!instance.sinks}, sinkKeys=${instance.sinks ? Object.keys(instance.sinks).join(',') : 'none'}`);
7736
+ }
7737
+ }
7738
+ else {
7739
+ console.warn(`[Sygnal DevTools] _timeTravel: no meta for componentId ${componentId}`);
7740
+ }
7741
+ // Fall back to root STATE sink for root-level components
7580
7742
  const app = window.__SYGNAL_DEVTOOLS_APP__;
7581
7743
  if (app?.sinks?.STATE?.shamefullySendNext) {
7582
- app.sinks.STATE.shamefullySendNext(() => ({ ...entry.state }));
7744
+ app.sinks.STATE.shamefullySendNext(() => ({ ...newState }));
7583
7745
  this._post('TIME_TRAVEL_APPLIED', {
7584
- historyIndex,
7585
- state: entry.state,
7746
+ componentId,
7747
+ componentName,
7748
+ state: newState,
7586
7749
  });
7587
7750
  }
7751
+ else {
7752
+ console.warn(`[Sygnal DevTools] _timeTravel: no fallback root STATE sink available`);
7753
+ }
7754
+ }
7755
+ _takeSnapshot() {
7756
+ const entries = [];
7757
+ for (const [id, meta] of this._components) {
7758
+ if (meta.disposed)
7759
+ continue;
7760
+ const instance = meta._instanceRef?.deref();
7761
+ if (instance?.currentState != null) {
7762
+ entries.push({
7763
+ componentId: id,
7764
+ componentName: meta.name,
7765
+ state: this._safeClone(instance.currentState),
7766
+ });
7767
+ }
7768
+ }
7769
+ this._post('SNAPSHOT_TAKEN', {
7770
+ entries,
7771
+ timestamp: Date.now(),
7772
+ });
7773
+ }
7774
+ _restoreSnapshot(snapshot) {
7775
+ if (!snapshot?.entries)
7776
+ return;
7777
+ for (const entry of snapshot.entries) {
7778
+ this._timeTravel(entry);
7779
+ }
7588
7780
  }
7589
7781
  _sendComponentState(componentId) {
7590
7782
  const meta = this._components.get(componentId);
@@ -7595,11 +7787,38 @@ class SygnalDevTools {
7595
7787
  componentId,
7596
7788
  state: this._safeClone(instance.currentState),
7597
7789
  context: this._safeClone(instance.currentContext),
7790
+ contextTrace: this._buildContextTrace(componentId, instance.currentContext),
7598
7791
  props: this._safeClone(instance.currentProps),
7599
7792
  });
7600
7793
  }
7601
7794
  }
7602
7795
  }
7796
+ _buildContextTrace(componentId, context) {
7797
+ if (!context || typeof context !== 'object')
7798
+ return [];
7799
+ const trace = [];
7800
+ const fields = Object.keys(context);
7801
+ for (const field of fields) {
7802
+ // Walk up parent chain to find which component provides this field
7803
+ let currentId = componentId;
7804
+ let found = false;
7805
+ while (currentId != null) {
7806
+ const meta = this._components.get(currentId);
7807
+ if (!meta)
7808
+ break;
7809
+ if (meta.mviGraph?.contextProvides?.includes(field)) {
7810
+ trace.push({ field, providerId: meta.id, providerName: meta.name });
7811
+ found = true;
7812
+ break;
7813
+ }
7814
+ currentId = meta.parentId;
7815
+ }
7816
+ if (!found) {
7817
+ trace.push({ field, providerId: -1, providerName: 'unknown' });
7818
+ }
7819
+ }
7820
+ return trace;
7821
+ }
7603
7822
  _sendFullTree() {
7604
7823
  const tree = [];
7605
7824
  for (const [, meta] of this._components) {
@@ -7634,6 +7853,44 @@ class SygnalDevTools {
7634
7853
  return '[unserializable]';
7635
7854
  }
7636
7855
  }
7856
+ _extractMviGraph(instance) {
7857
+ if (!instance.model)
7858
+ return null;
7859
+ try {
7860
+ const sources = instance.sourceNames ? [...instance.sourceNames] : [];
7861
+ const actions = [];
7862
+ const model = instance.model;
7863
+ for (const key of Object.keys(model)) {
7864
+ let actionName = key;
7865
+ let entry = model[key];
7866
+ // Handle shorthand 'ACTION | SINK'
7867
+ if (key.includes('|')) {
7868
+ const parts = key.split('|').map((s) => s.trim());
7869
+ if (parts.length === 2 && parts[0] && parts[1]) {
7870
+ actionName = parts[0];
7871
+ actions.push({ name: actionName, sinks: [parts[1]] });
7872
+ continue;
7873
+ }
7874
+ }
7875
+ // Function → targets STATE
7876
+ if (typeof entry === 'function') {
7877
+ actions.push({ name: actionName, sinks: [instance.stateSourceName || 'STATE'] });
7878
+ continue;
7879
+ }
7880
+ // Object → keys are sink names
7881
+ if (entry && typeof entry === 'object') {
7882
+ actions.push({ name: actionName, sinks: Object.keys(entry) });
7883
+ continue;
7884
+ }
7885
+ }
7886
+ const contextProvides = instance.context && typeof instance.context === 'object'
7887
+ ? Object.keys(instance.context) : [];
7888
+ return { sources, actions, contextProvides };
7889
+ }
7890
+ catch (e) {
7891
+ return null;
7892
+ }
7893
+ }
7637
7894
  _serializeMeta(meta) {
7638
7895
  const { _instanceRef, ...rest } = meta;
7639
7896
  return rest;
@@ -172,6 +172,24 @@ function processSSRTree(vnode, context, parentState) {
172
172
  key: undefined,
173
173
  };
174
174
  }
175
+ // ClientOnly: render fallback during SSR, skip children (they need a browser)
176
+ if (sel === 'clientonly') {
177
+ const props = vnode.data?.props || {};
178
+ const fallback = props.fallback;
179
+ if (fallback) {
180
+ // fallback can be a VNode or a string
181
+ return processSSRTree(fallback, context, parentState);
182
+ }
183
+ // No fallback — render an empty placeholder div
184
+ return {
185
+ sel: 'div',
186
+ data: { attrs: { 'data-sygnal-clientonly': '' } },
187
+ children: [],
188
+ text: undefined,
189
+ elm: undefined,
190
+ key: undefined,
191
+ };
192
+ }
175
193
  // Slot: unwrap to children
176
194
  if (sel === 'slot') {
177
195
  const children = vnode.children || [];
@@ -168,6 +168,24 @@ function processSSRTree(vnode, context, parentState) {
168
168
  key: undefined,
169
169
  };
170
170
  }
171
+ // ClientOnly: render fallback during SSR, skip children (they need a browser)
172
+ if (sel === 'clientonly') {
173
+ const props = vnode.data?.props || {};
174
+ const fallback = props.fallback;
175
+ if (fallback) {
176
+ // fallback can be a VNode or a string
177
+ return processSSRTree(fallback, context, parentState);
178
+ }
179
+ // No fallback — render an empty placeholder div
180
+ return {
181
+ sel: 'div',
182
+ data: { attrs: { 'data-sygnal-clientonly': '' } },
183
+ children: [],
184
+ text: undefined,
185
+ elm: undefined,
186
+ key: undefined,
187
+ };
188
+ }
171
189
  // Slot: unwrap to children
172
190
  if (sel === 'slot') {
173
191
  const children = vnode.children || [];