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.
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`:
@@ -404,7 +423,7 @@ import vikeSygnal from 'sygnal/config'
404
423
  export default { extends: [vikeSygnal] }
405
424
  ```
406
425
 
407
- Pages are standard Sygnal components in `pages/*/+Page.jsx`. Supports layouts, data fetching, and SPA mode.
426
+ Pages are standard Sygnal components in `pages/*/+Page.jsx`. Supports layouts, data fetching, SPA mode, and `ClientOnly` for browser-only components.
408
427
 
409
428
  ### TypeScript
410
429
 
@@ -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 {
@@ -5532,7 +5537,7 @@ function component(opts) {
5532
5537
  return returnFunction;
5533
5538
  }
5534
5539
  class Component {
5535
- 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 }) {
5540
+ 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 }) {
5536
5541
  if (!sources || !isObj(sources))
5537
5542
  throw new Error(`[${name}] Missing or invalid sources`);
5538
5543
  this._componentNumber = COMPONENT_COUNT++;
@@ -5554,6 +5559,7 @@ class Component {
5554
5559
  this.requestSourceName = requestSourceName;
5555
5560
  this.sourceNames = Object.keys(sources);
5556
5561
  this.onError = onError;
5562
+ this.isolatedState = isolatedState;
5557
5563
  this._debug = debug;
5558
5564
  // Warn if calculated fields shadow base state keys
5559
5565
  if (this.calculated && this.initialState
@@ -5682,6 +5688,9 @@ class Component {
5682
5688
  this.sources.props$ = props$.map((val) => {
5683
5689
  const { sygnalFactory, sygnalOptions, ...sanitizedProps } = val;
5684
5690
  this.currentProps = sanitizedProps;
5691
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
5692
+ window.__SYGNAL_DEVTOOLS__.onPropsChanged(this._componentNumber, this.name, sanitizedProps);
5693
+ }
5685
5694
  return val;
5686
5695
  });
5687
5696
  }
@@ -5753,6 +5762,9 @@ class Component {
5753
5762
  }
5754
5763
  }
5755
5764
  dispose() {
5765
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
5766
+ window.__SYGNAL_DEVTOOLS__.onComponentDisposed(this._componentNumber, this.name);
5767
+ }
5756
5768
  // Fire the DISPOSE built-in action so model handlers can run cleanup logic
5757
5769
  const hasDispose = this.model && (this.model[DISPOSE_ACTION] || Object.keys(this.model).some(k => k.includes('|') && k.split('|')[0].trim() === DISPOSE_ACTION));
5758
5770
  if (hasDispose && this.action$ && typeof this.action$.shamefullySendNext === 'function') {
@@ -5953,7 +5965,7 @@ class Component {
5953
5965
  const hmrState = ENVIRONMENT?.__SYGNAL_HMR_STATE;
5954
5966
  const effectiveInitialState = (typeof hmrState !== 'undefined') ? hmrState : this.initialState;
5955
5967
  const initial = { type: INITIALIZE_ACTION, data: effectiveInitialState };
5956
- if (this.isSubComponent && this.initialState) {
5968
+ if (this.isSubComponent && this.initialState && !this.isolatedState) {
5957
5969
  console.warn(`[${this.name}] Initial state provided to sub-component. This will overwrite any state provided by the parent component.`);
5958
5970
  }
5959
5971
  const hasInitialState = (typeof effectiveInitialState !== 'undefined');
@@ -6134,6 +6146,7 @@ class Component {
6134
6146
  .map((vdom) => processLazy(vdom, this))
6135
6147
  .map(processPortals)
6136
6148
  .map(processTransitions)
6149
+ .map(processClientOnly)
6137
6150
  .compose(this.instantiateSubComponents.bind(this))
6138
6151
  .filter((val) => val !== undefined)
6139
6152
  .compose(this.renderVdom.bind(this));
@@ -6149,6 +6162,12 @@ class Component {
6149
6162
  else {
6150
6163
  acc[name] = xs.merge((this.model$[name] || xs.never()), subComponentSink$, ...(this.peers$[name] || []));
6151
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
+ }
6152
6171
  return acc;
6153
6172
  }, {});
6154
6173
  this.sinks[this.DOMSourceName] = this.vdom$;
@@ -6671,6 +6690,13 @@ class Component {
6671
6690
  if (!isObj(sink$)) {
6672
6691
  throw new Error(`[${this.name}] Invalid sinks returned from component factory of collection element`);
6673
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
+ }
6674
6700
  return sink$;
6675
6701
  }
6676
6702
  instantiateSwitchable(el, props$, children$) {
@@ -6809,6 +6835,7 @@ class Component {
6809
6835
  for (const key of Object.keys(props)) {
6810
6836
  const val = props[key];
6811
6837
  if (val && val.__sygnalCommand) {
6838
+ val._targetComponentName = componentName;
6812
6839
  sources.commands$ = makeCommandSource(val);
6813
6840
  break;
6814
6841
  }
@@ -6851,10 +6878,15 @@ class Component {
6851
6878
  const wasReady = this._childReadyState[id];
6852
6879
  this._childReadyState[id] = !!ready;
6853
6880
  // When READY state changes, trigger a re-render
6854
- if (wasReady !== !!ready && this._readyChangedListener) {
6855
- setTimeout(() => {
6856
- this._readyChangedListener?.next(null);
6857
- }, 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
+ }
6858
6890
  }
6859
6891
  },
6860
6892
  error: () => { },
@@ -7111,6 +7143,31 @@ function processTransitions(vnode) {
7111
7143
  }
7112
7144
  return vnode;
7113
7145
  }
7146
+ function processClientOnly(vnode) {
7147
+ if (!vnode || !vnode.sel)
7148
+ return vnode;
7149
+ if (vnode.sel === 'clientonly') {
7150
+ // On the client, unwrap to children (render them normally)
7151
+ const children = vnode.children || [];
7152
+ if (children.length === 0)
7153
+ return { sel: 'div', data: {}, children: [] };
7154
+ if (children.length === 1)
7155
+ return processClientOnly(children[0]);
7156
+ // Multiple children: wrap in a div
7157
+ return {
7158
+ sel: 'div',
7159
+ data: {},
7160
+ children: children.map(processClientOnly),
7161
+ text: undefined,
7162
+ elm: undefined,
7163
+ key: undefined,
7164
+ };
7165
+ }
7166
+ if (vnode.children && vnode.children.length > 0) {
7167
+ vnode.children = vnode.children.map(processClientOnly);
7168
+ }
7169
+ return vnode;
7170
+ }
7114
7171
  function applyTransitionHooks(vnode, name, duration) {
7115
7172
  const existingInsert = vnode.data?.hook?.insert;
7116
7173
  const existingRemove = vnode.data?.hook?.remove;
@@ -7465,6 +7522,12 @@ class SygnalDevTools {
7465
7522
  case 'TIME_TRAVEL':
7466
7523
  this._timeTravel(msg.payload);
7467
7524
  break;
7525
+ case 'SNAPSHOT':
7526
+ this._takeSnapshot();
7527
+ break;
7528
+ case 'RESTORE_SNAPSHOT':
7529
+ this._restoreSnapshot(msg.payload);
7530
+ break;
7468
7531
  case 'GET_STATE':
7469
7532
  this._sendComponentState(msg.payload.componentId);
7470
7533
  break;
@@ -7484,6 +7547,7 @@ class SygnalDevTools {
7484
7547
  children: [],
7485
7548
  debug: instance._debug,
7486
7549
  createdAt: Date.now(),
7550
+ mviGraph: this._extractMviGraph(instance),
7487
7551
  _instanceRef: new WeakRef(instance),
7488
7552
  };
7489
7553
  this._components.set(componentNumber, meta);
@@ -7508,7 +7572,6 @@ class SygnalDevTools {
7508
7572
  componentId: componentNumber,
7509
7573
  componentName: name,
7510
7574
  state: entry.state,
7511
- historyIndex: this._stateHistory.length - 1,
7512
7575
  });
7513
7576
  }
7514
7577
  onActionDispatched(componentNumber, name, actionType, data) {
@@ -7545,6 +7608,76 @@ class SygnalDevTools {
7545
7608
  componentId: componentNumber,
7546
7609
  componentName: name,
7547
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(),
7548
7681
  });
7549
7682
  }
7550
7683
  onDebugLog(componentNumber, message) {
@@ -7573,20 +7706,79 @@ class SygnalDevTools {
7573
7706
  }
7574
7707
  }
7575
7708
  }
7576
- _timeTravel({ historyIndex }) {
7577
- const entry = this._stateHistory[historyIndex];
7578
- 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 });
7579
7712
  return;
7713
+ }
7580
7714
  if (typeof window === 'undefined')
7581
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
7582
7744
  const app = window.__SYGNAL_DEVTOOLS_APP__;
7583
7745
  if (app?.sinks?.STATE?.shamefullySendNext) {
7584
- app.sinks.STATE.shamefullySendNext(() => ({ ...entry.state }));
7746
+ app.sinks.STATE.shamefullySendNext(() => ({ ...newState }));
7585
7747
  this._post('TIME_TRAVEL_APPLIED', {
7586
- historyIndex,
7587
- state: entry.state,
7748
+ componentId,
7749
+ componentName,
7750
+ state: newState,
7588
7751
  });
7589
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
+ }
7590
7782
  }
7591
7783
  _sendComponentState(componentId) {
7592
7784
  const meta = this._components.get(componentId);
@@ -7597,11 +7789,38 @@ class SygnalDevTools {
7597
7789
  componentId,
7598
7790
  state: this._safeClone(instance.currentState),
7599
7791
  context: this._safeClone(instance.currentContext),
7792
+ contextTrace: this._buildContextTrace(componentId, instance.currentContext),
7600
7793
  props: this._safeClone(instance.currentProps),
7601
7794
  });
7602
7795
  }
7603
7796
  }
7604
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
+ }
7605
7824
  _sendFullTree() {
7606
7825
  const tree = [];
7607
7826
  for (const [, meta] of this._components) {
@@ -7636,6 +7855,44 @@ class SygnalDevTools {
7636
7855
  return '[unserializable]';
7637
7856
  }
7638
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
+ }
7639
7896
  _serializeMeta(meta) {
7640
7897
  const { _instanceRef, ...rest } = meta;
7641
7898
  return rest;