sygnal 5.2.1 → 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.
package/README.md CHANGED
@@ -26,7 +26,7 @@ cd my-app
26
26
  npm run dev
27
27
  ```
28
28
 
29
- Choose from Vite (SPA), Vike (SSR), or Astro templates in JavaScript or TypeScript.
29
+ Choose from Vite (SPA), Vite + PWA, Vike (SSR), or Astro templates in JavaScript or TypeScript.
30
30
 
31
31
  **Or add to an existing project:**
32
32
 
@@ -325,6 +325,25 @@ MyComponent.model = {
325
325
 
326
326
  For advanced cases needing stream composition, the `dispose$` source is also available in intent.
327
327
 
328
+ ### PWA Helpers
329
+
330
+ Built-in service worker driver, online/offline detection, and install prompt handling:
331
+
332
+ ```jsx
333
+ import { run, makeServiceWorkerDriver, onlineStatus$, createInstallPrompt } from 'sygnal'
334
+
335
+ const installPrompt = createInstallPrompt()
336
+
337
+ run(App, { SW: makeServiceWorkerDriver('/sw.js') })
338
+
339
+ App.intent = ({ DOM, SW }) => ({
340
+ ONLINE_CHANGED: onlineStatus$(),
341
+ UPDATE_READY: SW.select('waiting'),
342
+ APPLY_UPDATE: DOM.click('.update-btn'),
343
+ INSTALL: DOM.click('.install-btn'),
344
+ })
345
+ ```
346
+
328
347
  ### Testing
329
348
 
330
349
  Test components in isolation with `renderComponent`:
@@ -5029,7 +5029,12 @@ function makeDOMDriver(container, options = {}) {
5029
5029
  function eventBusDriver(out$) {
5030
5030
  const events = new EventTarget();
5031
5031
  out$.subscribe({
5032
- next: (event) => events.dispatchEvent(new CustomEvent('data', { detail: event })),
5032
+ next: (event) => {
5033
+ events.dispatchEvent(new CustomEvent('data', { detail: event }));
5034
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
5035
+ window.__SYGNAL_DEVTOOLS__.onBusEvent(event);
5036
+ }
5037
+ },
5033
5038
  error: (err) => console.error('[EVENTS driver] Error in sink stream:', err),
5034
5039
  });
5035
5040
  return {
@@ -5683,6 +5688,9 @@ class Component {
5683
5688
  this.sources.props$ = props$.map((val) => {
5684
5689
  const { sygnalFactory, sygnalOptions, ...sanitizedProps } = val;
5685
5690
  this.currentProps = sanitizedProps;
5691
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
5692
+ window.__SYGNAL_DEVTOOLS__.onPropsChanged(this._componentNumber, this.name, sanitizedProps);
5693
+ }
5686
5694
  return val;
5687
5695
  });
5688
5696
  }
@@ -5754,6 +5762,9 @@ class Component {
5754
5762
  }
5755
5763
  }
5756
5764
  dispose() {
5765
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
5766
+ window.__SYGNAL_DEVTOOLS__.onComponentDisposed(this._componentNumber, this.name);
5767
+ }
5757
5768
  // Fire the DISPOSE built-in action so model handlers can run cleanup logic
5758
5769
  const hasDispose = this.model && (this.model[DISPOSE_ACTION] || Object.keys(this.model).some(k => k.includes('|') && k.split('|')[0].trim() === DISPOSE_ACTION));
5759
5770
  if (hasDispose && this.action$ && typeof this.action$.shamefullySendNext === 'function') {
@@ -6151,6 +6162,12 @@ class Component {
6151
6162
  else {
6152
6163
  acc[name] = xs.merge((this.model$[name] || xs.never()), subComponentSink$, ...(this.peers$[name] || []));
6153
6164
  }
6165
+ // Stamp EVENTS sink emissions with emitter component info for devtools
6166
+ if (name === 'EVENTS' && acc[name]) {
6167
+ const _componentNumber = this._componentNumber;
6168
+ const _name = this.name;
6169
+ acc[name] = acc[name].map((ev) => ({ ...ev, __emitterId: _componentNumber, __emitterName: _name }));
6170
+ }
6154
6171
  return acc;
6155
6172
  }, {});
6156
6173
  this.sinks[this.DOMSourceName] = this.vdom$;
@@ -6673,6 +6690,13 @@ class Component {
6673
6690
  if (!isObj(sink$)) {
6674
6691
  throw new Error(`[${this.name}] Invalid sinks returned from component factory of collection element`);
6675
6692
  }
6693
+ // Notify devtools of collection mount
6694
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
6695
+ const itemName = typeof collectionOf === 'function'
6696
+ ? (collectionOf.componentName || collectionOf.label || collectionOf.name || 'anonymous')
6697
+ : String(collectionOf);
6698
+ window.__SYGNAL_DEVTOOLS__.onCollectionMounted(this._componentNumber, this.name, itemName, typeof stateField === 'string' ? stateField : null);
6699
+ }
6676
6700
  return sink$;
6677
6701
  }
6678
6702
  instantiateSwitchable(el, props$, children$) {
@@ -6811,6 +6835,7 @@ class Component {
6811
6835
  for (const key of Object.keys(props)) {
6812
6836
  const val = props[key];
6813
6837
  if (val && val.__sygnalCommand) {
6838
+ val._targetComponentName = componentName;
6814
6839
  sources.commands$ = makeCommandSource(val);
6815
6840
  break;
6816
6841
  }
@@ -6853,10 +6878,15 @@ class Component {
6853
6878
  const wasReady = this._childReadyState[id];
6854
6879
  this._childReadyState[id] = !!ready;
6855
6880
  // When READY state changes, trigger a re-render
6856
- if (wasReady !== !!ready && this._readyChangedListener) {
6857
- setTimeout(() => {
6858
- this._readyChangedListener?.next(null);
6859
- }, 0);
6881
+ if (wasReady !== !!ready) {
6882
+ if (this._readyChangedListener) {
6883
+ setTimeout(() => {
6884
+ this._readyChangedListener?.next(null);
6885
+ }, 0);
6886
+ }
6887
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
6888
+ window.__SYGNAL_DEVTOOLS__.onReadyChanged(this._componentNumber, this.name, id, !!ready);
6889
+ }
6860
6890
  }
6861
6891
  },
6862
6892
  error: () => { },
@@ -7492,6 +7522,12 @@ class SygnalDevTools {
7492
7522
  case 'TIME_TRAVEL':
7493
7523
  this._timeTravel(msg.payload);
7494
7524
  break;
7525
+ case 'SNAPSHOT':
7526
+ this._takeSnapshot();
7527
+ break;
7528
+ case 'RESTORE_SNAPSHOT':
7529
+ this._restoreSnapshot(msg.payload);
7530
+ break;
7495
7531
  case 'GET_STATE':
7496
7532
  this._sendComponentState(msg.payload.componentId);
7497
7533
  break;
@@ -7511,6 +7547,7 @@ class SygnalDevTools {
7511
7547
  children: [],
7512
7548
  debug: instance._debug,
7513
7549
  createdAt: Date.now(),
7550
+ mviGraph: this._extractMviGraph(instance),
7514
7551
  _instanceRef: new WeakRef(instance),
7515
7552
  };
7516
7553
  this._components.set(componentNumber, meta);
@@ -7535,7 +7572,6 @@ class SygnalDevTools {
7535
7572
  componentId: componentNumber,
7536
7573
  componentName: name,
7537
7574
  state: entry.state,
7538
- historyIndex: this._stateHistory.length - 1,
7539
7575
  });
7540
7576
  }
7541
7577
  onActionDispatched(componentNumber, name, actionType, data) {
@@ -7572,6 +7608,76 @@ class SygnalDevTools {
7572
7608
  componentId: componentNumber,
7573
7609
  componentName: name,
7574
7610
  context: this._safeClone(context),
7611
+ contextTrace: this._buildContextTrace(componentNumber, context),
7612
+ });
7613
+ }
7614
+ onPropsChanged(componentNumber, name, props) {
7615
+ if (!this.connected)
7616
+ return;
7617
+ this._post('PROPS_CHANGED', {
7618
+ componentId: componentNumber,
7619
+ componentName: name,
7620
+ props: this._safeClone(props),
7621
+ });
7622
+ }
7623
+ onBusEvent(event) {
7624
+ if (!this.connected)
7625
+ return;
7626
+ this._post('BUS_EVENT', {
7627
+ type: event.type,
7628
+ data: this._safeClone(event.data),
7629
+ componentId: event.__emitterId ?? null,
7630
+ componentName: event.__emitterName ?? null,
7631
+ timestamp: Date.now(),
7632
+ });
7633
+ }
7634
+ onCommandSent(type, data, targetComponentId, targetComponentName) {
7635
+ if (!this.connected)
7636
+ return;
7637
+ this._post('COMMAND_SENT', {
7638
+ type,
7639
+ data: this._safeClone(data),
7640
+ targetComponentName: targetComponentName ?? null,
7641
+ timestamp: Date.now(),
7642
+ });
7643
+ }
7644
+ onReadyChanged(parentId, parentName, childKey, ready) {
7645
+ if (!this.connected)
7646
+ return;
7647
+ this._post('READY_CHANGED', {
7648
+ parentId,
7649
+ parentName,
7650
+ childKey,
7651
+ ready,
7652
+ timestamp: Date.now(),
7653
+ });
7654
+ }
7655
+ onCollectionMounted(parentId, parentName, itemComponentName, stateField) {
7656
+ const meta = this._components.get(parentId);
7657
+ if (meta) {
7658
+ meta.collection = { itemComponent: itemComponentName, stateField };
7659
+ }
7660
+ if (!this.connected)
7661
+ return;
7662
+ this._post('COLLECTION_MOUNTED', {
7663
+ parentId,
7664
+ parentName,
7665
+ itemComponent: itemComponentName,
7666
+ stateField,
7667
+ });
7668
+ }
7669
+ onComponentDisposed(componentNumber, name) {
7670
+ const meta = this._components.get(componentNumber);
7671
+ if (meta) {
7672
+ meta.disposed = true;
7673
+ meta.disposedAt = Date.now();
7674
+ }
7675
+ if (!this.connected)
7676
+ return;
7677
+ this._post('COMPONENT_DISPOSED', {
7678
+ componentId: componentNumber,
7679
+ componentName: name,
7680
+ timestamp: Date.now(),
7575
7681
  });
7576
7682
  }
7577
7683
  onDebugLog(componentNumber, message) {
@@ -7600,20 +7706,79 @@ class SygnalDevTools {
7600
7706
  }
7601
7707
  }
7602
7708
  }
7603
- _timeTravel({ historyIndex }) {
7604
- const entry = this._stateHistory[historyIndex];
7605
- if (!entry)
7709
+ _timeTravel({ componentId, componentName, state }) {
7710
+ if (componentId == null || !state) {
7711
+ console.warn('[Sygnal DevTools] _timeTravel: missing componentId or state', { componentId, hasState: !!state });
7606
7712
  return;
7713
+ }
7607
7714
  if (typeof window === 'undefined')
7608
7715
  return;
7716
+ const newState = this._safeClone(state);
7717
+ // Try per-component time-travel via the component's STATE sink (reducer stream)
7718
+ const meta = this._components.get(componentId);
7719
+ if (meta) {
7720
+ const instance = meta._instanceRef?.deref();
7721
+ if (!instance) {
7722
+ console.warn(`[Sygnal DevTools] _timeTravel: WeakRef for component #${componentId} (${componentName}) has been GC'd`);
7723
+ }
7724
+ else {
7725
+ // sinks[stateSourceName] is the reducer stream — push a reducer that replaces state
7726
+ const stateSinkName = instance.stateSourceName || 'STATE';
7727
+ const stateSink = instance.sinks?.[stateSinkName];
7728
+ if (stateSink?.shamefullySendNext) {
7729
+ stateSink.shamefullySendNext(() => ({ ...newState }));
7730
+ this._post('TIME_TRAVEL_APPLIED', {
7731
+ componentId,
7732
+ componentName,
7733
+ state: newState,
7734
+ });
7735
+ return;
7736
+ }
7737
+ 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'}`);
7738
+ }
7739
+ }
7740
+ else {
7741
+ console.warn(`[Sygnal DevTools] _timeTravel: no meta for componentId ${componentId}`);
7742
+ }
7743
+ // Fall back to root STATE sink for root-level components
7609
7744
  const app = window.__SYGNAL_DEVTOOLS_APP__;
7610
7745
  if (app?.sinks?.STATE?.shamefullySendNext) {
7611
- app.sinks.STATE.shamefullySendNext(() => ({ ...entry.state }));
7746
+ app.sinks.STATE.shamefullySendNext(() => ({ ...newState }));
7612
7747
  this._post('TIME_TRAVEL_APPLIED', {
7613
- historyIndex,
7614
- state: entry.state,
7748
+ componentId,
7749
+ componentName,
7750
+ state: newState,
7615
7751
  });
7616
7752
  }
7753
+ else {
7754
+ console.warn(`[Sygnal DevTools] _timeTravel: no fallback root STATE sink available`);
7755
+ }
7756
+ }
7757
+ _takeSnapshot() {
7758
+ const entries = [];
7759
+ for (const [id, meta] of this._components) {
7760
+ if (meta.disposed)
7761
+ continue;
7762
+ const instance = meta._instanceRef?.deref();
7763
+ if (instance?.currentState != null) {
7764
+ entries.push({
7765
+ componentId: id,
7766
+ componentName: meta.name,
7767
+ state: this._safeClone(instance.currentState),
7768
+ });
7769
+ }
7770
+ }
7771
+ this._post('SNAPSHOT_TAKEN', {
7772
+ entries,
7773
+ timestamp: Date.now(),
7774
+ });
7775
+ }
7776
+ _restoreSnapshot(snapshot) {
7777
+ if (!snapshot?.entries)
7778
+ return;
7779
+ for (const entry of snapshot.entries) {
7780
+ this._timeTravel(entry);
7781
+ }
7617
7782
  }
7618
7783
  _sendComponentState(componentId) {
7619
7784
  const meta = this._components.get(componentId);
@@ -7624,11 +7789,38 @@ class SygnalDevTools {
7624
7789
  componentId,
7625
7790
  state: this._safeClone(instance.currentState),
7626
7791
  context: this._safeClone(instance.currentContext),
7792
+ contextTrace: this._buildContextTrace(componentId, instance.currentContext),
7627
7793
  props: this._safeClone(instance.currentProps),
7628
7794
  });
7629
7795
  }
7630
7796
  }
7631
7797
  }
7798
+ _buildContextTrace(componentId, context) {
7799
+ if (!context || typeof context !== 'object')
7800
+ return [];
7801
+ const trace = [];
7802
+ const fields = Object.keys(context);
7803
+ for (const field of fields) {
7804
+ // Walk up parent chain to find which component provides this field
7805
+ let currentId = componentId;
7806
+ let found = false;
7807
+ while (currentId != null) {
7808
+ const meta = this._components.get(currentId);
7809
+ if (!meta)
7810
+ break;
7811
+ if (meta.mviGraph?.contextProvides?.includes(field)) {
7812
+ trace.push({ field, providerId: meta.id, providerName: meta.name });
7813
+ found = true;
7814
+ break;
7815
+ }
7816
+ currentId = meta.parentId;
7817
+ }
7818
+ if (!found) {
7819
+ trace.push({ field, providerId: -1, providerName: 'unknown' });
7820
+ }
7821
+ }
7822
+ return trace;
7823
+ }
7632
7824
  _sendFullTree() {
7633
7825
  const tree = [];
7634
7826
  for (const [, meta] of this._components) {
@@ -7663,6 +7855,44 @@ class SygnalDevTools {
7663
7855
  return '[unserializable]';
7664
7856
  }
7665
7857
  }
7858
+ _extractMviGraph(instance) {
7859
+ if (!instance.model)
7860
+ return null;
7861
+ try {
7862
+ const sources = instance.sourceNames ? [...instance.sourceNames] : [];
7863
+ const actions = [];
7864
+ const model = instance.model;
7865
+ for (const key of Object.keys(model)) {
7866
+ let actionName = key;
7867
+ let entry = model[key];
7868
+ // Handle shorthand 'ACTION | SINK'
7869
+ if (key.includes('|')) {
7870
+ const parts = key.split('|').map((s) => s.trim());
7871
+ if (parts.length === 2 && parts[0] && parts[1]) {
7872
+ actionName = parts[0];
7873
+ actions.push({ name: actionName, sinks: [parts[1]] });
7874
+ continue;
7875
+ }
7876
+ }
7877
+ // Function → targets STATE
7878
+ if (typeof entry === 'function') {
7879
+ actions.push({ name: actionName, sinks: [instance.stateSourceName || 'STATE'] });
7880
+ continue;
7881
+ }
7882
+ // Object → keys are sink names
7883
+ if (entry && typeof entry === 'object') {
7884
+ actions.push({ name: actionName, sinks: Object.keys(entry) });
7885
+ continue;
7886
+ }
7887
+ }
7888
+ const contextProvides = instance.context && typeof instance.context === 'object'
7889
+ ? Object.keys(instance.context) : [];
7890
+ return { sources, actions, contextProvides };
7891
+ }
7892
+ catch (e) {
7893
+ return null;
7894
+ }
7895
+ }
7666
7896
  _serializeMeta(meta) {
7667
7897
  const { _instanceRef, ...rest } = meta;
7668
7898
  return rest;