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/dist/index.esm.js CHANGED
@@ -2146,11 +2146,17 @@ function createCommand() {
2146
2146
  start(l) { listener.next = (val) => l.next(val); },
2147
2147
  stop() { listener.next = () => { }; },
2148
2148
  });
2149
- return {
2150
- send: (type, data) => listener.next({ type, data }),
2149
+ const cmd = {
2150
+ send: (type, data) => {
2151
+ listener.next({ type, data });
2152
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
2153
+ window.__SYGNAL_DEVTOOLS__.onCommandSent(type, data, cmd._targetComponentId, cmd._targetComponentName);
2154
+ }
2155
+ },
2151
2156
  _stream,
2152
2157
  __sygnalCommand: true,
2153
2158
  };
2159
+ return cmd;
2154
2160
  }
2155
2161
  function makeCommandSource(cmd) {
2156
2162
  return {
@@ -2253,7 +2259,7 @@ function component(opts) {
2253
2259
  return returnFunction;
2254
2260
  }
2255
2261
  class Component {
2256
- 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 }) {
2262
+ 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 }) {
2257
2263
  if (!sources || !isObj(sources))
2258
2264
  throw new Error(`[${name}] Missing or invalid sources`);
2259
2265
  this._componentNumber = COMPONENT_COUNT++;
@@ -2275,6 +2281,7 @@ class Component {
2275
2281
  this.requestSourceName = requestSourceName;
2276
2282
  this.sourceNames = Object.keys(sources);
2277
2283
  this.onError = onError;
2284
+ this.isolatedState = isolatedState;
2278
2285
  this._debug = debug;
2279
2286
  // Warn if calculated fields shadow base state keys
2280
2287
  if (this.calculated && this.initialState
@@ -2403,6 +2410,9 @@ class Component {
2403
2410
  this.sources.props$ = props$.map((val) => {
2404
2411
  const { sygnalFactory, sygnalOptions, ...sanitizedProps } = val;
2405
2412
  this.currentProps = sanitizedProps;
2413
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
2414
+ window.__SYGNAL_DEVTOOLS__.onPropsChanged(this._componentNumber, this.name, sanitizedProps);
2415
+ }
2406
2416
  return val;
2407
2417
  });
2408
2418
  }
@@ -2474,6 +2484,9 @@ class Component {
2474
2484
  }
2475
2485
  }
2476
2486
  dispose() {
2487
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
2488
+ window.__SYGNAL_DEVTOOLS__.onComponentDisposed(this._componentNumber, this.name);
2489
+ }
2477
2490
  // Fire the DISPOSE built-in action so model handlers can run cleanup logic
2478
2491
  const hasDispose = this.model && (this.model[DISPOSE_ACTION] || Object.keys(this.model).some(k => k.includes('|') && k.split('|')[0].trim() === DISPOSE_ACTION));
2479
2492
  if (hasDispose && this.action$ && typeof this.action$.shamefullySendNext === 'function') {
@@ -2674,7 +2687,7 @@ class Component {
2674
2687
  const hmrState = ENVIRONMENT?.__SYGNAL_HMR_STATE;
2675
2688
  const effectiveInitialState = (typeof hmrState !== 'undefined') ? hmrState : this.initialState;
2676
2689
  const initial = { type: INITIALIZE_ACTION, data: effectiveInitialState };
2677
- if (this.isSubComponent && this.initialState) {
2690
+ if (this.isSubComponent && this.initialState && !this.isolatedState) {
2678
2691
  console.warn(`[${this.name}] Initial state provided to sub-component. This will overwrite any state provided by the parent component.`);
2679
2692
  }
2680
2693
  const hasInitialState = (typeof effectiveInitialState !== 'undefined');
@@ -2855,6 +2868,7 @@ class Component {
2855
2868
  .map((vdom) => processLazy(vdom, this))
2856
2869
  .map(processPortals)
2857
2870
  .map(processTransitions)
2871
+ .map(processClientOnly)
2858
2872
  .compose(this.instantiateSubComponents.bind(this))
2859
2873
  .filter((val) => val !== undefined)
2860
2874
  .compose(this.renderVdom.bind(this));
@@ -2870,6 +2884,12 @@ class Component {
2870
2884
  else {
2871
2885
  acc[name] = xs.merge((this.model$[name] || xs.never()), subComponentSink$, ...(this.peers$[name] || []));
2872
2886
  }
2887
+ // Stamp EVENTS sink emissions with emitter component info for devtools
2888
+ if (name === 'EVENTS' && acc[name]) {
2889
+ const _componentNumber = this._componentNumber;
2890
+ const _name = this.name;
2891
+ acc[name] = acc[name].map((ev) => ({ ...ev, __emitterId: _componentNumber, __emitterName: _name }));
2892
+ }
2873
2893
  return acc;
2874
2894
  }, {});
2875
2895
  this.sinks[this.DOMSourceName] = this.vdom$;
@@ -3392,6 +3412,13 @@ class Component {
3392
3412
  if (!isObj(sink$)) {
3393
3413
  throw new Error(`[${this.name}] Invalid sinks returned from component factory of collection element`);
3394
3414
  }
3415
+ // Notify devtools of collection mount
3416
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
3417
+ const itemName = typeof collectionOf === 'function'
3418
+ ? (collectionOf.componentName || collectionOf.label || collectionOf.name || 'anonymous')
3419
+ : String(collectionOf);
3420
+ window.__SYGNAL_DEVTOOLS__.onCollectionMounted(this._componentNumber, this.name, itemName, typeof stateField === 'string' ? stateField : null);
3421
+ }
3395
3422
  return sink$;
3396
3423
  }
3397
3424
  instantiateSwitchable(el, props$, children$) {
@@ -3530,6 +3557,7 @@ class Component {
3530
3557
  for (const key of Object.keys(props)) {
3531
3558
  const val = props[key];
3532
3559
  if (val && val.__sygnalCommand) {
3560
+ val._targetComponentName = componentName;
3533
3561
  sources.commands$ = makeCommandSource(val);
3534
3562
  break;
3535
3563
  }
@@ -3572,10 +3600,15 @@ class Component {
3572
3600
  const wasReady = this._childReadyState[id];
3573
3601
  this._childReadyState[id] = !!ready;
3574
3602
  // When READY state changes, trigger a re-render
3575
- if (wasReady !== !!ready && this._readyChangedListener) {
3576
- setTimeout(() => {
3577
- this._readyChangedListener?.next(null);
3578
- }, 0);
3603
+ if (wasReady !== !!ready) {
3604
+ if (this._readyChangedListener) {
3605
+ setTimeout(() => {
3606
+ this._readyChangedListener?.next(null);
3607
+ }, 0);
3608
+ }
3609
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
3610
+ window.__SYGNAL_DEVTOOLS__.onReadyChanged(this._componentNumber, this.name, id, !!ready);
3611
+ }
3579
3612
  }
3580
3613
  },
3581
3614
  error: () => { },
@@ -3832,6 +3865,31 @@ function processTransitions(vnode) {
3832
3865
  }
3833
3866
  return vnode;
3834
3867
  }
3868
+ function processClientOnly(vnode) {
3869
+ if (!vnode || !vnode.sel)
3870
+ return vnode;
3871
+ if (vnode.sel === 'clientonly') {
3872
+ // On the client, unwrap to children (render them normally)
3873
+ const children = vnode.children || [];
3874
+ if (children.length === 0)
3875
+ return { sel: 'div', data: {}, children: [] };
3876
+ if (children.length === 1)
3877
+ return processClientOnly(children[0]);
3878
+ // Multiple children: wrap in a div
3879
+ return {
3880
+ sel: 'div',
3881
+ data: {},
3882
+ children: children.map(processClientOnly),
3883
+ text: undefined,
3884
+ elm: undefined,
3885
+ key: undefined,
3886
+ };
3887
+ }
3888
+ if (vnode.children && vnode.children.length > 0) {
3889
+ vnode.children = vnode.children.map(processClientOnly);
3890
+ }
3891
+ return vnode;
3892
+ }
3835
3893
  function applyTransitionHooks(vnode, name, duration) {
3836
3894
  const existingInsert = vnode.data?.hook?.insert;
3837
3895
  const existingRemove = vnode.data?.hook?.remove;
@@ -4391,6 +4449,40 @@ function processDrag({ draggable, dropZone } = {}, options = {}) {
4391
4449
  return { dragStart$, dragEnd$, dragOver$, drop$ };
4392
4450
  }
4393
4451
 
4452
+ /**
4453
+ * Adds chainable convenience methods to a DND event stream,
4454
+ * mirroring the DOM driver's `enrichEventStream` pattern.
4455
+ *
4456
+ * DND.dragstart('task').data('taskId')
4457
+ * DND.dragstart('task').data('taskId', Number)
4458
+ * DND.drop('lane').data('laneId')
4459
+ * DND.dragstart('task').element()
4460
+ */
4461
+ function enrichDragStream(stream$) {
4462
+ // .data(name, fn?) — extract dataset[name] from dragstart payload,
4463
+ // or dropZone.dataset[name] from drop payload
4464
+ stream$.data = function data(name, fn) {
4465
+ const mapped = stream$.map((e) => {
4466
+ // dragstart payload: { element, dataset }
4467
+ // drop payload: { dropZone, insertBefore }
4468
+ const val = e?.dataset?.[name]
4469
+ ?? e?.dropZone?.dataset?.[name]
4470
+ ?? e?.element?.dataset?.[name];
4471
+ return fn ? fn(val) : val;
4472
+ });
4473
+ return enrichDragStream(mapped);
4474
+ };
4475
+ // .element(fn?) — extract the primary element from the payload
4476
+ stream$.element = function element(fn) {
4477
+ const mapped = stream$.map((e) => {
4478
+ const el = e?.element ?? e?.dropZone ?? null;
4479
+ return fn ? fn(el) : el;
4480
+ });
4481
+ return enrichDragStream(mapped);
4482
+ };
4483
+ return stream$;
4484
+ }
4485
+ // ─── Driver Factory ──────────────────────────────────────────────────────────
4394
4486
  function makeDragDriver() {
4395
4487
  return function dragDriver(sink$) {
4396
4488
  const categories = new Map();
@@ -4482,7 +4574,7 @@ function makeDragDriver() {
4482
4574
  events(eventType) {
4483
4575
  const busEventName = `${category}:${eventType}`;
4484
4576
  let handler;
4485
- return xs__default.create({
4577
+ const stream$ = xs__default.create({
4486
4578
  start(listener) {
4487
4579
  handler = ({ detail }) => listener.next(detail);
4488
4580
  bus.addEventListener(busEventName, handler);
@@ -4492,6 +4584,7 @@ function makeDragDriver() {
4492
4584
  bus.removeEventListener(busEventName, handler);
4493
4585
  },
4494
4586
  });
4587
+ return enrichDragStream(stream$);
4495
4588
  },
4496
4589
  };
4497
4590
  },
@@ -4671,7 +4764,12 @@ function setupReusable(drivers) {
4671
4764
  function eventBusDriver(out$) {
4672
4765
  const events = new EventTarget();
4673
4766
  out$.subscribe({
4674
- next: (event) => events.dispatchEvent(new CustomEvent('data', { detail: event })),
4767
+ next: (event) => {
4768
+ events.dispatchEvent(new CustomEvent('data', { detail: event }));
4769
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
4770
+ window.__SYGNAL_DEVTOOLS__.onBusEvent(event);
4771
+ }
4772
+ },
4675
4773
  error: (err) => console.error('[EVENTS driver] Error in sink stream:', err),
4676
4774
  });
4677
4775
  return {
@@ -4751,6 +4849,12 @@ class SygnalDevTools {
4751
4849
  case 'TIME_TRAVEL':
4752
4850
  this._timeTravel(msg.payload);
4753
4851
  break;
4852
+ case 'SNAPSHOT':
4853
+ this._takeSnapshot();
4854
+ break;
4855
+ case 'RESTORE_SNAPSHOT':
4856
+ this._restoreSnapshot(msg.payload);
4857
+ break;
4754
4858
  case 'GET_STATE':
4755
4859
  this._sendComponentState(msg.payload.componentId);
4756
4860
  break;
@@ -4770,6 +4874,7 @@ class SygnalDevTools {
4770
4874
  children: [],
4771
4875
  debug: instance._debug,
4772
4876
  createdAt: Date.now(),
4877
+ mviGraph: this._extractMviGraph(instance),
4773
4878
  _instanceRef: new WeakRef(instance),
4774
4879
  };
4775
4880
  this._components.set(componentNumber, meta);
@@ -4794,7 +4899,6 @@ class SygnalDevTools {
4794
4899
  componentId: componentNumber,
4795
4900
  componentName: name,
4796
4901
  state: entry.state,
4797
- historyIndex: this._stateHistory.length - 1,
4798
4902
  });
4799
4903
  }
4800
4904
  onActionDispatched(componentNumber, name, actionType, data) {
@@ -4831,6 +4935,76 @@ class SygnalDevTools {
4831
4935
  componentId: componentNumber,
4832
4936
  componentName: name,
4833
4937
  context: this._safeClone(context),
4938
+ contextTrace: this._buildContextTrace(componentNumber, context),
4939
+ });
4940
+ }
4941
+ onPropsChanged(componentNumber, name, props) {
4942
+ if (!this.connected)
4943
+ return;
4944
+ this._post('PROPS_CHANGED', {
4945
+ componentId: componentNumber,
4946
+ componentName: name,
4947
+ props: this._safeClone(props),
4948
+ });
4949
+ }
4950
+ onBusEvent(event) {
4951
+ if (!this.connected)
4952
+ return;
4953
+ this._post('BUS_EVENT', {
4954
+ type: event.type,
4955
+ data: this._safeClone(event.data),
4956
+ componentId: event.__emitterId ?? null,
4957
+ componentName: event.__emitterName ?? null,
4958
+ timestamp: Date.now(),
4959
+ });
4960
+ }
4961
+ onCommandSent(type, data, targetComponentId, targetComponentName) {
4962
+ if (!this.connected)
4963
+ return;
4964
+ this._post('COMMAND_SENT', {
4965
+ type,
4966
+ data: this._safeClone(data),
4967
+ targetComponentName: targetComponentName ?? null,
4968
+ timestamp: Date.now(),
4969
+ });
4970
+ }
4971
+ onReadyChanged(parentId, parentName, childKey, ready) {
4972
+ if (!this.connected)
4973
+ return;
4974
+ this._post('READY_CHANGED', {
4975
+ parentId,
4976
+ parentName,
4977
+ childKey,
4978
+ ready,
4979
+ timestamp: Date.now(),
4980
+ });
4981
+ }
4982
+ onCollectionMounted(parentId, parentName, itemComponentName, stateField) {
4983
+ const meta = this._components.get(parentId);
4984
+ if (meta) {
4985
+ meta.collection = { itemComponent: itemComponentName, stateField };
4986
+ }
4987
+ if (!this.connected)
4988
+ return;
4989
+ this._post('COLLECTION_MOUNTED', {
4990
+ parentId,
4991
+ parentName,
4992
+ itemComponent: itemComponentName,
4993
+ stateField,
4994
+ });
4995
+ }
4996
+ onComponentDisposed(componentNumber, name) {
4997
+ const meta = this._components.get(componentNumber);
4998
+ if (meta) {
4999
+ meta.disposed = true;
5000
+ meta.disposedAt = Date.now();
5001
+ }
5002
+ if (!this.connected)
5003
+ return;
5004
+ this._post('COMPONENT_DISPOSED', {
5005
+ componentId: componentNumber,
5006
+ componentName: name,
5007
+ timestamp: Date.now(),
4834
5008
  });
4835
5009
  }
4836
5010
  onDebugLog(componentNumber, message) {
@@ -4859,20 +5033,79 @@ class SygnalDevTools {
4859
5033
  }
4860
5034
  }
4861
5035
  }
4862
- _timeTravel({ historyIndex }) {
4863
- const entry = this._stateHistory[historyIndex];
4864
- if (!entry)
5036
+ _timeTravel({ componentId, componentName, state }) {
5037
+ if (componentId == null || !state) {
5038
+ console.warn('[Sygnal DevTools] _timeTravel: missing componentId or state', { componentId, hasState: !!state });
4865
5039
  return;
5040
+ }
4866
5041
  if (typeof window === 'undefined')
4867
5042
  return;
5043
+ const newState = this._safeClone(state);
5044
+ // Try per-component time-travel via the component's STATE sink (reducer stream)
5045
+ const meta = this._components.get(componentId);
5046
+ if (meta) {
5047
+ const instance = meta._instanceRef?.deref();
5048
+ if (!instance) {
5049
+ console.warn(`[Sygnal DevTools] _timeTravel: WeakRef for component #${componentId} (${componentName}) has been GC'd`);
5050
+ }
5051
+ else {
5052
+ // sinks[stateSourceName] is the reducer stream — push a reducer that replaces state
5053
+ const stateSinkName = instance.stateSourceName || 'STATE';
5054
+ const stateSink = instance.sinks?.[stateSinkName];
5055
+ if (stateSink?.shamefullySendNext) {
5056
+ stateSink.shamefullySendNext(() => ({ ...newState }));
5057
+ this._post('TIME_TRAVEL_APPLIED', {
5058
+ componentId,
5059
+ componentName,
5060
+ state: newState,
5061
+ });
5062
+ return;
5063
+ }
5064
+ 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'}`);
5065
+ }
5066
+ }
5067
+ else {
5068
+ console.warn(`[Sygnal DevTools] _timeTravel: no meta for componentId ${componentId}`);
5069
+ }
5070
+ // Fall back to root STATE sink for root-level components
4868
5071
  const app = window.__SYGNAL_DEVTOOLS_APP__;
4869
5072
  if (app?.sinks?.STATE?.shamefullySendNext) {
4870
- app.sinks.STATE.shamefullySendNext(() => ({ ...entry.state }));
5073
+ app.sinks.STATE.shamefullySendNext(() => ({ ...newState }));
4871
5074
  this._post('TIME_TRAVEL_APPLIED', {
4872
- historyIndex,
4873
- state: entry.state,
5075
+ componentId,
5076
+ componentName,
5077
+ state: newState,
4874
5078
  });
4875
5079
  }
5080
+ else {
5081
+ console.warn(`[Sygnal DevTools] _timeTravel: no fallback root STATE sink available`);
5082
+ }
5083
+ }
5084
+ _takeSnapshot() {
5085
+ const entries = [];
5086
+ for (const [id, meta] of this._components) {
5087
+ if (meta.disposed)
5088
+ continue;
5089
+ const instance = meta._instanceRef?.deref();
5090
+ if (instance?.currentState != null) {
5091
+ entries.push({
5092
+ componentId: id,
5093
+ componentName: meta.name,
5094
+ state: this._safeClone(instance.currentState),
5095
+ });
5096
+ }
5097
+ }
5098
+ this._post('SNAPSHOT_TAKEN', {
5099
+ entries,
5100
+ timestamp: Date.now(),
5101
+ });
5102
+ }
5103
+ _restoreSnapshot(snapshot) {
5104
+ if (!snapshot?.entries)
5105
+ return;
5106
+ for (const entry of snapshot.entries) {
5107
+ this._timeTravel(entry);
5108
+ }
4876
5109
  }
4877
5110
  _sendComponentState(componentId) {
4878
5111
  const meta = this._components.get(componentId);
@@ -4883,11 +5116,38 @@ class SygnalDevTools {
4883
5116
  componentId,
4884
5117
  state: this._safeClone(instance.currentState),
4885
5118
  context: this._safeClone(instance.currentContext),
5119
+ contextTrace: this._buildContextTrace(componentId, instance.currentContext),
4886
5120
  props: this._safeClone(instance.currentProps),
4887
5121
  });
4888
5122
  }
4889
5123
  }
4890
5124
  }
5125
+ _buildContextTrace(componentId, context) {
5126
+ if (!context || typeof context !== 'object')
5127
+ return [];
5128
+ const trace = [];
5129
+ const fields = Object.keys(context);
5130
+ for (const field of fields) {
5131
+ // Walk up parent chain to find which component provides this field
5132
+ let currentId = componentId;
5133
+ let found = false;
5134
+ while (currentId != null) {
5135
+ const meta = this._components.get(currentId);
5136
+ if (!meta)
5137
+ break;
5138
+ if (meta.mviGraph?.contextProvides?.includes(field)) {
5139
+ trace.push({ field, providerId: meta.id, providerName: meta.name });
5140
+ found = true;
5141
+ break;
5142
+ }
5143
+ currentId = meta.parentId;
5144
+ }
5145
+ if (!found) {
5146
+ trace.push({ field, providerId: -1, providerName: 'unknown' });
5147
+ }
5148
+ }
5149
+ return trace;
5150
+ }
4891
5151
  _sendFullTree() {
4892
5152
  const tree = [];
4893
5153
  for (const [, meta] of this._components) {
@@ -4922,6 +5182,44 @@ class SygnalDevTools {
4922
5182
  return '[unserializable]';
4923
5183
  }
4924
5184
  }
5185
+ _extractMviGraph(instance) {
5186
+ if (!instance.model)
5187
+ return null;
5188
+ try {
5189
+ const sources = instance.sourceNames ? [...instance.sourceNames] : [];
5190
+ const actions = [];
5191
+ const model = instance.model;
5192
+ for (const key of Object.keys(model)) {
5193
+ let actionName = key;
5194
+ let entry = model[key];
5195
+ // Handle shorthand 'ACTION | SINK'
5196
+ if (key.includes('|')) {
5197
+ const parts = key.split('|').map((s) => s.trim());
5198
+ if (parts.length === 2 && parts[0] && parts[1]) {
5199
+ actionName = parts[0];
5200
+ actions.push({ name: actionName, sinks: [parts[1]] });
5201
+ continue;
5202
+ }
5203
+ }
5204
+ // Function → targets STATE
5205
+ if (typeof entry === 'function') {
5206
+ actions.push({ name: actionName, sinks: [instance.stateSourceName || 'STATE'] });
5207
+ continue;
5208
+ }
5209
+ // Object → keys are sink names
5210
+ if (entry && typeof entry === 'object') {
5211
+ actions.push({ name: actionName, sinks: Object.keys(entry) });
5212
+ continue;
5213
+ }
5214
+ }
5215
+ const contextProvides = instance.context && typeof instance.context === 'object'
5216
+ ? Object.keys(instance.context) : [];
5217
+ return { sources, actions, contextProvides };
5218
+ }
5219
+ catch (e) {
5220
+ return null;
5221
+ }
5222
+ }
4925
5223
  _serializeMeta(meta) {
4926
5224
  const { _instanceRef, ...rest } = meta;
4927
5225
  return rest;
@@ -5825,6 +6123,210 @@ function renderComponent(componentDef, options = {}) {
5825
6123
  };
5826
6124
  }
5827
6125
 
6126
+ /**
6127
+ * Reducer helpers for common state update patterns.
6128
+ *
6129
+ * These reduce boilerplate in model definitions by providing
6130
+ * shorthand factories for the most frequent reducer shapes.
6131
+ */
6132
+ // ── set() ──────────────────────────────────────────────────────────
6133
+ /**
6134
+ * Create a reducer that merges a partial update into state.
6135
+ *
6136
+ * Static form — merge a fixed object:
6137
+ * set({ isEditing: true })
6138
+ *
6139
+ * Dynamic form — function receives (state, data, next, props) and
6140
+ * returns the partial update to merge:
6141
+ * set((state, title) => ({ title }))
6142
+ */
6143
+ function set(partial) {
6144
+ if (typeof partial === 'function') {
6145
+ return (state, data, next, props) => ({
6146
+ ...state,
6147
+ ...partial(state, data, next, props),
6148
+ });
6149
+ }
6150
+ return (state) => ({ ...state, ...partial });
6151
+ }
6152
+ // ── toggle() ───────────────────────────────────────────────────────
6153
+ /**
6154
+ * Create a reducer that toggles a boolean field on state.
6155
+ *
6156
+ * toggle('showModal')
6157
+ * // equivalent to: (state) => ({ ...state, showModal: !state.showModal })
6158
+ */
6159
+ function toggle(field) {
6160
+ return (state) => ({ ...state, [field]: !state[field] });
6161
+ }
6162
+ // ── emit() ─────────────────────────────────────────────────────────
6163
+ /**
6164
+ * Create a model entry that emits an EVENTS bus event.
6165
+ *
6166
+ * With static data:
6167
+ * emit('DELETE_LANE', { laneId: 42 })
6168
+ *
6169
+ * With dynamic data derived from state:
6170
+ * emit('DELETE_LANE', (state) => ({ laneId: state.id }))
6171
+ *
6172
+ * Fire-and-forget (no data):
6173
+ * emit('REFRESH')
6174
+ */
6175
+ function emit(type, data) {
6176
+ return {
6177
+ EVENTS: typeof data === 'function'
6178
+ ? (state, actionData, next, props) => ({ type, data: data(state, actionData, next, props) })
6179
+ : () => ({ type, data }),
6180
+ };
6181
+ }
6182
+
6183
+ // ── makeServiceWorkerDriver ──────────────────────────────────
6184
+ function trackWorker(worker, events) {
6185
+ const emit = (type, data) => events.dispatchEvent(new CustomEvent('data', { detail: { type, data } }));
6186
+ worker.addEventListener('statechange', () => {
6187
+ if (worker.state === 'installed')
6188
+ emit('installed', true);
6189
+ if (worker.state === 'activated')
6190
+ emit('activated', true);
6191
+ });
6192
+ if (worker.state === 'installed')
6193
+ emit('waiting', worker);
6194
+ if (worker.state === 'activated')
6195
+ emit('activated', true);
6196
+ }
6197
+ function makeServiceWorkerDriver(scriptUrl, options = {}) {
6198
+ return function serviceWorkerDriver(sink$) {
6199
+ const events = new EventTarget();
6200
+ if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
6201
+ navigator.serviceWorker
6202
+ .register(scriptUrl, { scope: options.scope })
6203
+ .then((reg) => {
6204
+ const emit = (type, data) => events.dispatchEvent(new CustomEvent('data', { detail: { type, data } }));
6205
+ if (reg.installing)
6206
+ trackWorker(reg.installing, events);
6207
+ if (reg.waiting)
6208
+ emit('waiting', reg.waiting);
6209
+ if (reg.active)
6210
+ emit('activated', true);
6211
+ reg.addEventListener('updatefound', () => {
6212
+ if (reg.installing)
6213
+ trackWorker(reg.installing, events);
6214
+ });
6215
+ navigator.serviceWorker.addEventListener('controllerchange', () => {
6216
+ emit('controlling', true);
6217
+ });
6218
+ navigator.serviceWorker.addEventListener('message', (e) => {
6219
+ emit('message', e.data);
6220
+ });
6221
+ })
6222
+ .catch((err) => {
6223
+ events.dispatchEvent(new CustomEvent('data', { detail: { type: 'error', data: err } }));
6224
+ });
6225
+ sink$.addListener({
6226
+ next: (cmd) => {
6227
+ if (cmd.action === 'skipWaiting') {
6228
+ navigator.serviceWorker.ready.then((r) => {
6229
+ if (r.waiting)
6230
+ r.waiting.postMessage({ type: 'SKIP_WAITING' });
6231
+ });
6232
+ }
6233
+ else if (cmd.action === 'postMessage') {
6234
+ navigator.serviceWorker.ready.then((r) => {
6235
+ if (r.active)
6236
+ r.active.postMessage(cmd.data);
6237
+ });
6238
+ }
6239
+ else if (cmd.action === 'unregister') {
6240
+ navigator.serviceWorker.ready.then((r) => r.unregister());
6241
+ }
6242
+ },
6243
+ error: (err) => console.error('[SW driver] Error in sink stream:', err),
6244
+ });
6245
+ }
6246
+ return {
6247
+ select(type) {
6248
+ let cb;
6249
+ const in$ = xs__default.create({
6250
+ start: (listener) => {
6251
+ cb = ({ detail }) => {
6252
+ if (!type || detail.type === type)
6253
+ listener.next(detail.data);
6254
+ };
6255
+ events.addEventListener('data', cb);
6256
+ },
6257
+ stop: () => {
6258
+ if (cb)
6259
+ events.removeEventListener('data', cb);
6260
+ },
6261
+ });
6262
+ return adapt(in$);
6263
+ },
6264
+ };
6265
+ };
6266
+ }
6267
+ // ── onlineStatus$ ────────────────────────────────────────────
6268
+ function onlineStatus$() {
6269
+ if (typeof window === 'undefined') {
6270
+ return xs__default.of(true);
6271
+ }
6272
+ let cleanup;
6273
+ return xs__default.create({
6274
+ start(listener) {
6275
+ listener.next(navigator.onLine);
6276
+ const on = () => listener.next(true);
6277
+ const off = () => listener.next(false);
6278
+ window.addEventListener('online', on);
6279
+ window.addEventListener('offline', off);
6280
+ cleanup = () => {
6281
+ window.removeEventListener('online', on);
6282
+ window.removeEventListener('offline', off);
6283
+ };
6284
+ },
6285
+ stop() {
6286
+ cleanup?.();
6287
+ cleanup = undefined;
6288
+ },
6289
+ });
6290
+ }
6291
+ // ── createInstallPrompt ──────────────────────────────────────
6292
+ function createInstallPrompt() {
6293
+ let deferredPrompt = null;
6294
+ const events = new EventTarget();
6295
+ if (typeof window !== 'undefined') {
6296
+ window.addEventListener('beforeinstallprompt', (e) => {
6297
+ e.preventDefault();
6298
+ deferredPrompt = e;
6299
+ events.dispatchEvent(new CustomEvent('data', { detail: { type: 'beforeinstallprompt', data: true } }));
6300
+ });
6301
+ window.addEventListener('appinstalled', () => {
6302
+ deferredPrompt = null;
6303
+ events.dispatchEvent(new CustomEvent('data', { detail: { type: 'appinstalled', data: true } }));
6304
+ });
6305
+ }
6306
+ return {
6307
+ select(type) {
6308
+ let cb;
6309
+ const in$ = xs__default.create({
6310
+ start: (listener) => {
6311
+ cb = ({ detail }) => {
6312
+ if (detail.type === type)
6313
+ listener.next(detail.data);
6314
+ };
6315
+ events.addEventListener('data', cb);
6316
+ },
6317
+ stop: () => {
6318
+ if (cb)
6319
+ events.removeEventListener('data', cb);
6320
+ },
6321
+ });
6322
+ return adapt(in$);
6323
+ },
6324
+ prompt() {
6325
+ return deferredPrompt?.prompt();
6326
+ },
6327
+ };
6328
+ }
6329
+
5828
6330
  /**
5829
6331
  * Server-Side Rendering utilities for Sygnal components.
5830
6332
  *
@@ -5995,6 +6497,24 @@ function processSSRTree(vnode, context, parentState) {
5995
6497
  key: undefined,
5996
6498
  };
5997
6499
  }
6500
+ // ClientOnly: render fallback during SSR, skip children (they need a browser)
6501
+ if (sel === 'clientonly') {
6502
+ const props = vnode.data?.props || {};
6503
+ const fallback = props.fallback;
6504
+ if (fallback) {
6505
+ // fallback can be a VNode or a string
6506
+ return processSSRTree(fallback, context, parentState);
6507
+ }
6508
+ // No fallback — render an empty placeholder div
6509
+ return {
6510
+ sel: 'div',
6511
+ data: { attrs: { 'data-sygnal-clientonly': '' } },
6512
+ children: [],
6513
+ text: undefined,
6514
+ elm: undefined,
6515
+ key: undefined,
6516
+ };
6517
+ }
5998
6518
  // Slot: unwrap to children
5999
6519
  if (sel === 'slot') {
6000
6520
  const children = vnode.children || [];
@@ -6474,4 +6994,4 @@ function buildAttributes(data, selectorId, selectorClasses) {
6474
6994
  return result;
6475
6995
  }
6476
6996
 
6477
- export { ABORT, Collection, MainDOMSource, MockedDOMSource, Portal, Slot, Suspense, Switchable, Transition, classes, collection, component, createCommand, createElement, createRef, createRef$, driverFromAsync, enableHMR, exactState, getDevTools, lazy, makeDOMDriver, makeDragDriver, mockDOMSource, Portal as portal, processDrag, processForm, renderComponent, renderToString, run, switchable, thunk, xs };
6997
+ export { ABORT, Collection, MainDOMSource, MockedDOMSource, Portal, Slot, Suspense, Switchable, Transition, classes, collection, component, createCommand, createElement, createInstallPrompt, createRef, createRef$, driverFromAsync, emit, enableHMR, exactState, getDevTools, lazy, makeDOMDriver, makeDragDriver, makeServiceWorkerDriver, mockDOMSource, onlineStatus$, Portal as portal, processDrag, processForm, renderComponent, renderToString, run, set, switchable, thunk, toggle, xs };