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.cjs.js CHANGED
@@ -2163,11 +2163,17 @@ function createCommand() {
2163
2163
  start(l) { listener.next = (val) => l.next(val); },
2164
2164
  stop() { listener.next = () => { }; },
2165
2165
  });
2166
- return {
2167
- send: (type, data) => listener.next({ type, data }),
2166
+ const cmd = {
2167
+ send: (type, data) => {
2168
+ listener.next({ type, data });
2169
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
2170
+ window.__SYGNAL_DEVTOOLS__.onCommandSent(type, data, cmd._targetComponentId, cmd._targetComponentName);
2171
+ }
2172
+ },
2168
2173
  _stream,
2169
2174
  __sygnalCommand: true,
2170
2175
  };
2176
+ return cmd;
2171
2177
  }
2172
2178
  function makeCommandSource(cmd) {
2173
2179
  return {
@@ -2270,7 +2276,7 @@ function component(opts) {
2270
2276
  return returnFunction;
2271
2277
  }
2272
2278
  class Component {
2273
- 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 }) {
2279
+ 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 }) {
2274
2280
  if (!sources || !isObj(sources))
2275
2281
  throw new Error(`[${name}] Missing or invalid sources`);
2276
2282
  this._componentNumber = COMPONENT_COUNT++;
@@ -2292,6 +2298,7 @@ class Component {
2292
2298
  this.requestSourceName = requestSourceName;
2293
2299
  this.sourceNames = Object.keys(sources);
2294
2300
  this.onError = onError;
2301
+ this.isolatedState = isolatedState;
2295
2302
  this._debug = debug;
2296
2303
  // Warn if calculated fields shadow base state keys
2297
2304
  if (this.calculated && this.initialState
@@ -2420,6 +2427,9 @@ class Component {
2420
2427
  this.sources.props$ = props$.map((val) => {
2421
2428
  const { sygnalFactory, sygnalOptions, ...sanitizedProps } = val;
2422
2429
  this.currentProps = sanitizedProps;
2430
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
2431
+ window.__SYGNAL_DEVTOOLS__.onPropsChanged(this._componentNumber, this.name, sanitizedProps);
2432
+ }
2423
2433
  return val;
2424
2434
  });
2425
2435
  }
@@ -2491,6 +2501,9 @@ class Component {
2491
2501
  }
2492
2502
  }
2493
2503
  dispose() {
2504
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
2505
+ window.__SYGNAL_DEVTOOLS__.onComponentDisposed(this._componentNumber, this.name);
2506
+ }
2494
2507
  // Fire the DISPOSE built-in action so model handlers can run cleanup logic
2495
2508
  const hasDispose = this.model && (this.model[DISPOSE_ACTION] || Object.keys(this.model).some(k => k.includes('|') && k.split('|')[0].trim() === DISPOSE_ACTION));
2496
2509
  if (hasDispose && this.action$ && typeof this.action$.shamefullySendNext === 'function') {
@@ -2691,7 +2704,7 @@ class Component {
2691
2704
  const hmrState = ENVIRONMENT?.__SYGNAL_HMR_STATE;
2692
2705
  const effectiveInitialState = (typeof hmrState !== 'undefined') ? hmrState : this.initialState;
2693
2706
  const initial = { type: INITIALIZE_ACTION, data: effectiveInitialState };
2694
- if (this.isSubComponent && this.initialState) {
2707
+ if (this.isSubComponent && this.initialState && !this.isolatedState) {
2695
2708
  console.warn(`[${this.name}] Initial state provided to sub-component. This will overwrite any state provided by the parent component.`);
2696
2709
  }
2697
2710
  const hasInitialState = (typeof effectiveInitialState !== 'undefined');
@@ -2872,6 +2885,7 @@ class Component {
2872
2885
  .map((vdom) => processLazy(vdom, this))
2873
2886
  .map(processPortals)
2874
2887
  .map(processTransitions)
2888
+ .map(processClientOnly)
2875
2889
  .compose(this.instantiateSubComponents.bind(this))
2876
2890
  .filter((val) => val !== undefined)
2877
2891
  .compose(this.renderVdom.bind(this));
@@ -2887,6 +2901,12 @@ class Component {
2887
2901
  else {
2888
2902
  acc[name] = xs.merge((this.model$[name] || xs.never()), subComponentSink$, ...(this.peers$[name] || []));
2889
2903
  }
2904
+ // Stamp EVENTS sink emissions with emitter component info for devtools
2905
+ if (name === 'EVENTS' && acc[name]) {
2906
+ const _componentNumber = this._componentNumber;
2907
+ const _name = this.name;
2908
+ acc[name] = acc[name].map((ev) => ({ ...ev, __emitterId: _componentNumber, __emitterName: _name }));
2909
+ }
2890
2910
  return acc;
2891
2911
  }, {});
2892
2912
  this.sinks[this.DOMSourceName] = this.vdom$;
@@ -3409,6 +3429,13 @@ class Component {
3409
3429
  if (!isObj(sink$)) {
3410
3430
  throw new Error(`[${this.name}] Invalid sinks returned from component factory of collection element`);
3411
3431
  }
3432
+ // Notify devtools of collection mount
3433
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
3434
+ const itemName = typeof collectionOf === 'function'
3435
+ ? (collectionOf.componentName || collectionOf.label || collectionOf.name || 'anonymous')
3436
+ : String(collectionOf);
3437
+ window.__SYGNAL_DEVTOOLS__.onCollectionMounted(this._componentNumber, this.name, itemName, typeof stateField === 'string' ? stateField : null);
3438
+ }
3412
3439
  return sink$;
3413
3440
  }
3414
3441
  instantiateSwitchable(el, props$, children$) {
@@ -3547,6 +3574,7 @@ class Component {
3547
3574
  for (const key of Object.keys(props)) {
3548
3575
  const val = props[key];
3549
3576
  if (val && val.__sygnalCommand) {
3577
+ val._targetComponentName = componentName;
3550
3578
  sources.commands$ = makeCommandSource(val);
3551
3579
  break;
3552
3580
  }
@@ -3589,10 +3617,15 @@ class Component {
3589
3617
  const wasReady = this._childReadyState[id];
3590
3618
  this._childReadyState[id] = !!ready;
3591
3619
  // When READY state changes, trigger a re-render
3592
- if (wasReady !== !!ready && this._readyChangedListener) {
3593
- setTimeout(() => {
3594
- this._readyChangedListener?.next(null);
3595
- }, 0);
3620
+ if (wasReady !== !!ready) {
3621
+ if (this._readyChangedListener) {
3622
+ setTimeout(() => {
3623
+ this._readyChangedListener?.next(null);
3624
+ }, 0);
3625
+ }
3626
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
3627
+ window.__SYGNAL_DEVTOOLS__.onReadyChanged(this._componentNumber, this.name, id, !!ready);
3628
+ }
3596
3629
  }
3597
3630
  },
3598
3631
  error: () => { },
@@ -3849,6 +3882,31 @@ function processTransitions(vnode) {
3849
3882
  }
3850
3883
  return vnode;
3851
3884
  }
3885
+ function processClientOnly(vnode) {
3886
+ if (!vnode || !vnode.sel)
3887
+ return vnode;
3888
+ if (vnode.sel === 'clientonly') {
3889
+ // On the client, unwrap to children (render them normally)
3890
+ const children = vnode.children || [];
3891
+ if (children.length === 0)
3892
+ return { sel: 'div', data: {}, children: [] };
3893
+ if (children.length === 1)
3894
+ return processClientOnly(children[0]);
3895
+ // Multiple children: wrap in a div
3896
+ return {
3897
+ sel: 'div',
3898
+ data: {},
3899
+ children: children.map(processClientOnly),
3900
+ text: undefined,
3901
+ elm: undefined,
3902
+ key: undefined,
3903
+ };
3904
+ }
3905
+ if (vnode.children && vnode.children.length > 0) {
3906
+ vnode.children = vnode.children.map(processClientOnly);
3907
+ }
3908
+ return vnode;
3909
+ }
3852
3910
  function applyTransitionHooks(vnode, name, duration) {
3853
3911
  const existingInsert = vnode.data?.hook?.insert;
3854
3912
  const existingRemove = vnode.data?.hook?.remove;
@@ -4408,6 +4466,40 @@ function processDrag({ draggable, dropZone } = {}, options = {}) {
4408
4466
  return { dragStart$, dragEnd$, dragOver$, drop$ };
4409
4467
  }
4410
4468
 
4469
+ /**
4470
+ * Adds chainable convenience methods to a DND event stream,
4471
+ * mirroring the DOM driver's `enrichEventStream` pattern.
4472
+ *
4473
+ * DND.dragstart('task').data('taskId')
4474
+ * DND.dragstart('task').data('taskId', Number)
4475
+ * DND.drop('lane').data('laneId')
4476
+ * DND.dragstart('task').element()
4477
+ */
4478
+ function enrichDragStream(stream$) {
4479
+ // .data(name, fn?) — extract dataset[name] from dragstart payload,
4480
+ // or dropZone.dataset[name] from drop payload
4481
+ stream$.data = function data(name, fn) {
4482
+ const mapped = stream$.map((e) => {
4483
+ // dragstart payload: { element, dataset }
4484
+ // drop payload: { dropZone, insertBefore }
4485
+ const val = e?.dataset?.[name]
4486
+ ?? e?.dropZone?.dataset?.[name]
4487
+ ?? e?.element?.dataset?.[name];
4488
+ return fn ? fn(val) : val;
4489
+ });
4490
+ return enrichDragStream(mapped);
4491
+ };
4492
+ // .element(fn?) — extract the primary element from the payload
4493
+ stream$.element = function element(fn) {
4494
+ const mapped = stream$.map((e) => {
4495
+ const el = e?.element ?? e?.dropZone ?? null;
4496
+ return fn ? fn(el) : el;
4497
+ });
4498
+ return enrichDragStream(mapped);
4499
+ };
4500
+ return stream$;
4501
+ }
4502
+ // ─── Driver Factory ──────────────────────────────────────────────────────────
4411
4503
  function makeDragDriver() {
4412
4504
  return function dragDriver(sink$) {
4413
4505
  const categories = new Map();
@@ -4499,7 +4591,7 @@ function makeDragDriver() {
4499
4591
  events(eventType) {
4500
4592
  const busEventName = `${category}:${eventType}`;
4501
4593
  let handler;
4502
- return xs$1.create({
4594
+ const stream$ = xs$1.create({
4503
4595
  start(listener) {
4504
4596
  handler = ({ detail }) => listener.next(detail);
4505
4597
  bus.addEventListener(busEventName, handler);
@@ -4509,6 +4601,7 @@ function makeDragDriver() {
4509
4601
  bus.removeEventListener(busEventName, handler);
4510
4602
  },
4511
4603
  });
4604
+ return enrichDragStream(stream$);
4512
4605
  },
4513
4606
  };
4514
4607
  },
@@ -4688,7 +4781,12 @@ function setupReusable(drivers) {
4688
4781
  function eventBusDriver(out$) {
4689
4782
  const events = new EventTarget();
4690
4783
  out$.subscribe({
4691
- next: (event) => events.dispatchEvent(new CustomEvent('data', { detail: event })),
4784
+ next: (event) => {
4785
+ events.dispatchEvent(new CustomEvent('data', { detail: event }));
4786
+ if (typeof window !== 'undefined' && window.__SYGNAL_DEVTOOLS__?.connected) {
4787
+ window.__SYGNAL_DEVTOOLS__.onBusEvent(event);
4788
+ }
4789
+ },
4692
4790
  error: (err) => console.error('[EVENTS driver] Error in sink stream:', err),
4693
4791
  });
4694
4792
  return {
@@ -4768,6 +4866,12 @@ class SygnalDevTools {
4768
4866
  case 'TIME_TRAVEL':
4769
4867
  this._timeTravel(msg.payload);
4770
4868
  break;
4869
+ case 'SNAPSHOT':
4870
+ this._takeSnapshot();
4871
+ break;
4872
+ case 'RESTORE_SNAPSHOT':
4873
+ this._restoreSnapshot(msg.payload);
4874
+ break;
4771
4875
  case 'GET_STATE':
4772
4876
  this._sendComponentState(msg.payload.componentId);
4773
4877
  break;
@@ -4787,6 +4891,7 @@ class SygnalDevTools {
4787
4891
  children: [],
4788
4892
  debug: instance._debug,
4789
4893
  createdAt: Date.now(),
4894
+ mviGraph: this._extractMviGraph(instance),
4790
4895
  _instanceRef: new WeakRef(instance),
4791
4896
  };
4792
4897
  this._components.set(componentNumber, meta);
@@ -4811,7 +4916,6 @@ class SygnalDevTools {
4811
4916
  componentId: componentNumber,
4812
4917
  componentName: name,
4813
4918
  state: entry.state,
4814
- historyIndex: this._stateHistory.length - 1,
4815
4919
  });
4816
4920
  }
4817
4921
  onActionDispatched(componentNumber, name, actionType, data) {
@@ -4848,6 +4952,76 @@ class SygnalDevTools {
4848
4952
  componentId: componentNumber,
4849
4953
  componentName: name,
4850
4954
  context: this._safeClone(context),
4955
+ contextTrace: this._buildContextTrace(componentNumber, context),
4956
+ });
4957
+ }
4958
+ onPropsChanged(componentNumber, name, props) {
4959
+ if (!this.connected)
4960
+ return;
4961
+ this._post('PROPS_CHANGED', {
4962
+ componentId: componentNumber,
4963
+ componentName: name,
4964
+ props: this._safeClone(props),
4965
+ });
4966
+ }
4967
+ onBusEvent(event) {
4968
+ if (!this.connected)
4969
+ return;
4970
+ this._post('BUS_EVENT', {
4971
+ type: event.type,
4972
+ data: this._safeClone(event.data),
4973
+ componentId: event.__emitterId ?? null,
4974
+ componentName: event.__emitterName ?? null,
4975
+ timestamp: Date.now(),
4976
+ });
4977
+ }
4978
+ onCommandSent(type, data, targetComponentId, targetComponentName) {
4979
+ if (!this.connected)
4980
+ return;
4981
+ this._post('COMMAND_SENT', {
4982
+ type,
4983
+ data: this._safeClone(data),
4984
+ targetComponentName: targetComponentName ?? null,
4985
+ timestamp: Date.now(),
4986
+ });
4987
+ }
4988
+ onReadyChanged(parentId, parentName, childKey, ready) {
4989
+ if (!this.connected)
4990
+ return;
4991
+ this._post('READY_CHANGED', {
4992
+ parentId,
4993
+ parentName,
4994
+ childKey,
4995
+ ready,
4996
+ timestamp: Date.now(),
4997
+ });
4998
+ }
4999
+ onCollectionMounted(parentId, parentName, itemComponentName, stateField) {
5000
+ const meta = this._components.get(parentId);
5001
+ if (meta) {
5002
+ meta.collection = { itemComponent: itemComponentName, stateField };
5003
+ }
5004
+ if (!this.connected)
5005
+ return;
5006
+ this._post('COLLECTION_MOUNTED', {
5007
+ parentId,
5008
+ parentName,
5009
+ itemComponent: itemComponentName,
5010
+ stateField,
5011
+ });
5012
+ }
5013
+ onComponentDisposed(componentNumber, name) {
5014
+ const meta = this._components.get(componentNumber);
5015
+ if (meta) {
5016
+ meta.disposed = true;
5017
+ meta.disposedAt = Date.now();
5018
+ }
5019
+ if (!this.connected)
5020
+ return;
5021
+ this._post('COMPONENT_DISPOSED', {
5022
+ componentId: componentNumber,
5023
+ componentName: name,
5024
+ timestamp: Date.now(),
4851
5025
  });
4852
5026
  }
4853
5027
  onDebugLog(componentNumber, message) {
@@ -4876,20 +5050,79 @@ class SygnalDevTools {
4876
5050
  }
4877
5051
  }
4878
5052
  }
4879
- _timeTravel({ historyIndex }) {
4880
- const entry = this._stateHistory[historyIndex];
4881
- if (!entry)
5053
+ _timeTravel({ componentId, componentName, state }) {
5054
+ if (componentId == null || !state) {
5055
+ console.warn('[Sygnal DevTools] _timeTravel: missing componentId or state', { componentId, hasState: !!state });
4882
5056
  return;
5057
+ }
4883
5058
  if (typeof window === 'undefined')
4884
5059
  return;
5060
+ const newState = this._safeClone(state);
5061
+ // Try per-component time-travel via the component's STATE sink (reducer stream)
5062
+ const meta = this._components.get(componentId);
5063
+ if (meta) {
5064
+ const instance = meta._instanceRef?.deref();
5065
+ if (!instance) {
5066
+ console.warn(`[Sygnal DevTools] _timeTravel: WeakRef for component #${componentId} (${componentName}) has been GC'd`);
5067
+ }
5068
+ else {
5069
+ // sinks[stateSourceName] is the reducer stream — push a reducer that replaces state
5070
+ const stateSinkName = instance.stateSourceName || 'STATE';
5071
+ const stateSink = instance.sinks?.[stateSinkName];
5072
+ if (stateSink?.shamefullySendNext) {
5073
+ stateSink.shamefullySendNext(() => ({ ...newState }));
5074
+ this._post('TIME_TRAVEL_APPLIED', {
5075
+ componentId,
5076
+ componentName,
5077
+ state: newState,
5078
+ });
5079
+ return;
5080
+ }
5081
+ 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'}`);
5082
+ }
5083
+ }
5084
+ else {
5085
+ console.warn(`[Sygnal DevTools] _timeTravel: no meta for componentId ${componentId}`);
5086
+ }
5087
+ // Fall back to root STATE sink for root-level components
4885
5088
  const app = window.__SYGNAL_DEVTOOLS_APP__;
4886
5089
  if (app?.sinks?.STATE?.shamefullySendNext) {
4887
- app.sinks.STATE.shamefullySendNext(() => ({ ...entry.state }));
5090
+ app.sinks.STATE.shamefullySendNext(() => ({ ...newState }));
4888
5091
  this._post('TIME_TRAVEL_APPLIED', {
4889
- historyIndex,
4890
- state: entry.state,
5092
+ componentId,
5093
+ componentName,
5094
+ state: newState,
4891
5095
  });
4892
5096
  }
5097
+ else {
5098
+ console.warn(`[Sygnal DevTools] _timeTravel: no fallback root STATE sink available`);
5099
+ }
5100
+ }
5101
+ _takeSnapshot() {
5102
+ const entries = [];
5103
+ for (const [id, meta] of this._components) {
5104
+ if (meta.disposed)
5105
+ continue;
5106
+ const instance = meta._instanceRef?.deref();
5107
+ if (instance?.currentState != null) {
5108
+ entries.push({
5109
+ componentId: id,
5110
+ componentName: meta.name,
5111
+ state: this._safeClone(instance.currentState),
5112
+ });
5113
+ }
5114
+ }
5115
+ this._post('SNAPSHOT_TAKEN', {
5116
+ entries,
5117
+ timestamp: Date.now(),
5118
+ });
5119
+ }
5120
+ _restoreSnapshot(snapshot) {
5121
+ if (!snapshot?.entries)
5122
+ return;
5123
+ for (const entry of snapshot.entries) {
5124
+ this._timeTravel(entry);
5125
+ }
4893
5126
  }
4894
5127
  _sendComponentState(componentId) {
4895
5128
  const meta = this._components.get(componentId);
@@ -4900,11 +5133,38 @@ class SygnalDevTools {
4900
5133
  componentId,
4901
5134
  state: this._safeClone(instance.currentState),
4902
5135
  context: this._safeClone(instance.currentContext),
5136
+ contextTrace: this._buildContextTrace(componentId, instance.currentContext),
4903
5137
  props: this._safeClone(instance.currentProps),
4904
5138
  });
4905
5139
  }
4906
5140
  }
4907
5141
  }
5142
+ _buildContextTrace(componentId, context) {
5143
+ if (!context || typeof context !== 'object')
5144
+ return [];
5145
+ const trace = [];
5146
+ const fields = Object.keys(context);
5147
+ for (const field of fields) {
5148
+ // Walk up parent chain to find which component provides this field
5149
+ let currentId = componentId;
5150
+ let found = false;
5151
+ while (currentId != null) {
5152
+ const meta = this._components.get(currentId);
5153
+ if (!meta)
5154
+ break;
5155
+ if (meta.mviGraph?.contextProvides?.includes(field)) {
5156
+ trace.push({ field, providerId: meta.id, providerName: meta.name });
5157
+ found = true;
5158
+ break;
5159
+ }
5160
+ currentId = meta.parentId;
5161
+ }
5162
+ if (!found) {
5163
+ trace.push({ field, providerId: -1, providerName: 'unknown' });
5164
+ }
5165
+ }
5166
+ return trace;
5167
+ }
4908
5168
  _sendFullTree() {
4909
5169
  const tree = [];
4910
5170
  for (const [, meta] of this._components) {
@@ -4939,6 +5199,44 @@ class SygnalDevTools {
4939
5199
  return '[unserializable]';
4940
5200
  }
4941
5201
  }
5202
+ _extractMviGraph(instance) {
5203
+ if (!instance.model)
5204
+ return null;
5205
+ try {
5206
+ const sources = instance.sourceNames ? [...instance.sourceNames] : [];
5207
+ const actions = [];
5208
+ const model = instance.model;
5209
+ for (const key of Object.keys(model)) {
5210
+ let actionName = key;
5211
+ let entry = model[key];
5212
+ // Handle shorthand 'ACTION | SINK'
5213
+ if (key.includes('|')) {
5214
+ const parts = key.split('|').map((s) => s.trim());
5215
+ if (parts.length === 2 && parts[0] && parts[1]) {
5216
+ actionName = parts[0];
5217
+ actions.push({ name: actionName, sinks: [parts[1]] });
5218
+ continue;
5219
+ }
5220
+ }
5221
+ // Function → targets STATE
5222
+ if (typeof entry === 'function') {
5223
+ actions.push({ name: actionName, sinks: [instance.stateSourceName || 'STATE'] });
5224
+ continue;
5225
+ }
5226
+ // Object → keys are sink names
5227
+ if (entry && typeof entry === 'object') {
5228
+ actions.push({ name: actionName, sinks: Object.keys(entry) });
5229
+ continue;
5230
+ }
5231
+ }
5232
+ const contextProvides = instance.context && typeof instance.context === 'object'
5233
+ ? Object.keys(instance.context) : [];
5234
+ return { sources, actions, contextProvides };
5235
+ }
5236
+ catch (e) {
5237
+ return null;
5238
+ }
5239
+ }
4942
5240
  _serializeMeta(meta) {
4943
5241
  const { _instanceRef, ...rest } = meta;
4944
5242
  return rest;
@@ -5842,6 +6140,210 @@ function renderComponent(componentDef, options = {}) {
5842
6140
  };
5843
6141
  }
5844
6142
 
6143
+ /**
6144
+ * Reducer helpers for common state update patterns.
6145
+ *
6146
+ * These reduce boilerplate in model definitions by providing
6147
+ * shorthand factories for the most frequent reducer shapes.
6148
+ */
6149
+ // ── set() ──────────────────────────────────────────────────────────
6150
+ /**
6151
+ * Create a reducer that merges a partial update into state.
6152
+ *
6153
+ * Static form — merge a fixed object:
6154
+ * set({ isEditing: true })
6155
+ *
6156
+ * Dynamic form — function receives (state, data, next, props) and
6157
+ * returns the partial update to merge:
6158
+ * set((state, title) => ({ title }))
6159
+ */
6160
+ function set(partial) {
6161
+ if (typeof partial === 'function') {
6162
+ return (state, data, next, props) => ({
6163
+ ...state,
6164
+ ...partial(state, data, next, props),
6165
+ });
6166
+ }
6167
+ return (state) => ({ ...state, ...partial });
6168
+ }
6169
+ // ── toggle() ───────────────────────────────────────────────────────
6170
+ /**
6171
+ * Create a reducer that toggles a boolean field on state.
6172
+ *
6173
+ * toggle('showModal')
6174
+ * // equivalent to: (state) => ({ ...state, showModal: !state.showModal })
6175
+ */
6176
+ function toggle(field) {
6177
+ return (state) => ({ ...state, [field]: !state[field] });
6178
+ }
6179
+ // ── emit() ─────────────────────────────────────────────────────────
6180
+ /**
6181
+ * Create a model entry that emits an EVENTS bus event.
6182
+ *
6183
+ * With static data:
6184
+ * emit('DELETE_LANE', { laneId: 42 })
6185
+ *
6186
+ * With dynamic data derived from state:
6187
+ * emit('DELETE_LANE', (state) => ({ laneId: state.id }))
6188
+ *
6189
+ * Fire-and-forget (no data):
6190
+ * emit('REFRESH')
6191
+ */
6192
+ function emit(type, data) {
6193
+ return {
6194
+ EVENTS: typeof data === 'function'
6195
+ ? (state, actionData, next, props) => ({ type, data: data(state, actionData, next, props) })
6196
+ : () => ({ type, data }),
6197
+ };
6198
+ }
6199
+
6200
+ // ── makeServiceWorkerDriver ──────────────────────────────────
6201
+ function trackWorker(worker, events) {
6202
+ const emit = (type, data) => events.dispatchEvent(new CustomEvent('data', { detail: { type, data } }));
6203
+ worker.addEventListener('statechange', () => {
6204
+ if (worker.state === 'installed')
6205
+ emit('installed', true);
6206
+ if (worker.state === 'activated')
6207
+ emit('activated', true);
6208
+ });
6209
+ if (worker.state === 'installed')
6210
+ emit('waiting', worker);
6211
+ if (worker.state === 'activated')
6212
+ emit('activated', true);
6213
+ }
6214
+ function makeServiceWorkerDriver(scriptUrl, options = {}) {
6215
+ return function serviceWorkerDriver(sink$) {
6216
+ const events = new EventTarget();
6217
+ if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) {
6218
+ navigator.serviceWorker
6219
+ .register(scriptUrl, { scope: options.scope })
6220
+ .then((reg) => {
6221
+ const emit = (type, data) => events.dispatchEvent(new CustomEvent('data', { detail: { type, data } }));
6222
+ if (reg.installing)
6223
+ trackWorker(reg.installing, events);
6224
+ if (reg.waiting)
6225
+ emit('waiting', reg.waiting);
6226
+ if (reg.active)
6227
+ emit('activated', true);
6228
+ reg.addEventListener('updatefound', () => {
6229
+ if (reg.installing)
6230
+ trackWorker(reg.installing, events);
6231
+ });
6232
+ navigator.serviceWorker.addEventListener('controllerchange', () => {
6233
+ emit('controlling', true);
6234
+ });
6235
+ navigator.serviceWorker.addEventListener('message', (e) => {
6236
+ emit('message', e.data);
6237
+ });
6238
+ })
6239
+ .catch((err) => {
6240
+ events.dispatchEvent(new CustomEvent('data', { detail: { type: 'error', data: err } }));
6241
+ });
6242
+ sink$.addListener({
6243
+ next: (cmd) => {
6244
+ if (cmd.action === 'skipWaiting') {
6245
+ navigator.serviceWorker.ready.then((r) => {
6246
+ if (r.waiting)
6247
+ r.waiting.postMessage({ type: 'SKIP_WAITING' });
6248
+ });
6249
+ }
6250
+ else if (cmd.action === 'postMessage') {
6251
+ navigator.serviceWorker.ready.then((r) => {
6252
+ if (r.active)
6253
+ r.active.postMessage(cmd.data);
6254
+ });
6255
+ }
6256
+ else if (cmd.action === 'unregister') {
6257
+ navigator.serviceWorker.ready.then((r) => r.unregister());
6258
+ }
6259
+ },
6260
+ error: (err) => console.error('[SW driver] Error in sink stream:', err),
6261
+ });
6262
+ }
6263
+ return {
6264
+ select(type) {
6265
+ let cb;
6266
+ const in$ = xs$1.create({
6267
+ start: (listener) => {
6268
+ cb = ({ detail }) => {
6269
+ if (!type || detail.type === type)
6270
+ listener.next(detail.data);
6271
+ };
6272
+ events.addEventListener('data', cb);
6273
+ },
6274
+ stop: () => {
6275
+ if (cb)
6276
+ events.removeEventListener('data', cb);
6277
+ },
6278
+ });
6279
+ return adapt(in$);
6280
+ },
6281
+ };
6282
+ };
6283
+ }
6284
+ // ── onlineStatus$ ────────────────────────────────────────────
6285
+ function onlineStatus$() {
6286
+ if (typeof window === 'undefined') {
6287
+ return xs$1.of(true);
6288
+ }
6289
+ let cleanup;
6290
+ return xs$1.create({
6291
+ start(listener) {
6292
+ listener.next(navigator.onLine);
6293
+ const on = () => listener.next(true);
6294
+ const off = () => listener.next(false);
6295
+ window.addEventListener('online', on);
6296
+ window.addEventListener('offline', off);
6297
+ cleanup = () => {
6298
+ window.removeEventListener('online', on);
6299
+ window.removeEventListener('offline', off);
6300
+ };
6301
+ },
6302
+ stop() {
6303
+ cleanup?.();
6304
+ cleanup = undefined;
6305
+ },
6306
+ });
6307
+ }
6308
+ // ── createInstallPrompt ──────────────────────────────────────
6309
+ function createInstallPrompt() {
6310
+ let deferredPrompt = null;
6311
+ const events = new EventTarget();
6312
+ if (typeof window !== 'undefined') {
6313
+ window.addEventListener('beforeinstallprompt', (e) => {
6314
+ e.preventDefault();
6315
+ deferredPrompt = e;
6316
+ events.dispatchEvent(new CustomEvent('data', { detail: { type: 'beforeinstallprompt', data: true } }));
6317
+ });
6318
+ window.addEventListener('appinstalled', () => {
6319
+ deferredPrompt = null;
6320
+ events.dispatchEvent(new CustomEvent('data', { detail: { type: 'appinstalled', data: true } }));
6321
+ });
6322
+ }
6323
+ return {
6324
+ select(type) {
6325
+ let cb;
6326
+ const in$ = xs$1.create({
6327
+ start: (listener) => {
6328
+ cb = ({ detail }) => {
6329
+ if (detail.type === type)
6330
+ listener.next(detail.data);
6331
+ };
6332
+ events.addEventListener('data', cb);
6333
+ },
6334
+ stop: () => {
6335
+ if (cb)
6336
+ events.removeEventListener('data', cb);
6337
+ },
6338
+ });
6339
+ return adapt(in$);
6340
+ },
6341
+ prompt() {
6342
+ return deferredPrompt?.prompt();
6343
+ },
6344
+ };
6345
+ }
6346
+
5845
6347
  /**
5846
6348
  * Server-Side Rendering utilities for Sygnal components.
5847
6349
  *
@@ -6012,6 +6514,24 @@ function processSSRTree(vnode, context, parentState) {
6012
6514
  key: undefined,
6013
6515
  };
6014
6516
  }
6517
+ // ClientOnly: render fallback during SSR, skip children (they need a browser)
6518
+ if (sel === 'clientonly') {
6519
+ const props = vnode.data?.props || {};
6520
+ const fallback = props.fallback;
6521
+ if (fallback) {
6522
+ // fallback can be a VNode or a string
6523
+ return processSSRTree(fallback, context, parentState);
6524
+ }
6525
+ // No fallback — render an empty placeholder div
6526
+ return {
6527
+ sel: 'div',
6528
+ data: { attrs: { 'data-sygnal-clientonly': '' } },
6529
+ children: [],
6530
+ text: undefined,
6531
+ elm: undefined,
6532
+ key: undefined,
6533
+ };
6534
+ }
6015
6535
  // Slot: unwrap to children
6016
6536
  if (sel === 'slot') {
6017
6537
  const children = vnode.children || [];
@@ -6514,22 +7034,28 @@ exports.collection = collection;
6514
7034
  exports.component = component;
6515
7035
  exports.createCommand = createCommand;
6516
7036
  exports.createElement = createElement;
7037
+ exports.createInstallPrompt = createInstallPrompt;
6517
7038
  exports.createRef = createRef;
6518
7039
  exports.createRef$ = createRef$;
6519
7040
  exports.driverFromAsync = driverFromAsync;
7041
+ exports.emit = emit;
6520
7042
  exports.enableHMR = enableHMR;
6521
7043
  exports.exactState = exactState;
6522
7044
  exports.getDevTools = getDevTools;
6523
7045
  exports.lazy = lazy;
6524
7046
  exports.makeDOMDriver = makeDOMDriver;
6525
7047
  exports.makeDragDriver = makeDragDriver;
7048
+ exports.makeServiceWorkerDriver = makeServiceWorkerDriver;
6526
7049
  exports.mockDOMSource = mockDOMSource;
7050
+ exports.onlineStatus$ = onlineStatus$;
6527
7051
  exports.portal = Portal;
6528
7052
  exports.processDrag = processDrag;
6529
7053
  exports.processForm = processForm;
6530
7054
  exports.renderComponent = renderComponent;
6531
7055
  exports.renderToString = renderToString;
6532
7056
  exports.run = run;
7057
+ exports.set = set;
6533
7058
  exports.switchable = switchable;
6534
7059
  exports.thunk = thunk;
7060
+ exports.toggle = toggle;
6535
7061
  exports.xs = xs;