sygnal 5.1.5 → 5.2.1

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
@@ -313,18 +313,18 @@ App.model = {
313
313
 
314
314
  ### Disposal Hooks
315
315
 
316
- Cleanup on unmount:
316
+ Cleanup on unmount with the built-in `DISPOSE` action:
317
317
 
318
318
  ```jsx
319
- MyComponent.intent = ({ dispose$ }) => ({
320
- CLEANUP: dispose$,
321
- })
322
-
323
319
  MyComponent.model = {
324
- CLEANUP: { WEBSOCKET: () => ({ type: 'close' }) },
320
+ DISPOSE: {
321
+ EFFECT: (state) => clearInterval(state.timerId),
322
+ },
325
323
  }
326
324
  ```
327
325
 
326
+ For advanced cases needing stream composition, the `dispose$` source is also available in intent.
327
+
328
328
  ### Testing
329
329
 
330
330
  Test components in isolation with `renderComponent`:
@@ -404,7 +404,7 @@ import vikeSygnal from 'sygnal/config'
404
404
  export default { extends: [vikeSygnal] }
405
405
  ```
406
406
 
407
- Pages are standard Sygnal components in `pages/*/+Page.jsx`. Supports layouts, data fetching, and SPA mode.
407
+ Pages are standard Sygnal components in `pages/*/+Page.jsx`. Supports layouts, data fetching, SPA mode, and `ClientOnly` for browser-only components.
408
408
 
409
409
  ### TypeScript
410
410
 
@@ -5447,6 +5447,7 @@ const ENVIRONMENT = (typeof window != 'undefined' && window) || (typeof process
5447
5447
  const BOOTSTRAP_ACTION = 'BOOTSTRAP';
5448
5448
  const INITIALIZE_ACTION = 'INITIALIZE';
5449
5449
  const HYDRATE_ACTION = 'HYDRATE';
5450
+ const DISPOSE_ACTION = 'DISPOSE';
5450
5451
  const PARENT_SINK_NAME = 'PARENT';
5451
5452
  const CHILD_SOURCE_NAME = 'CHILD';
5452
5453
  const READY_SINK_NAME = 'READY';
@@ -5531,7 +5532,7 @@ function component(opts) {
5531
5532
  return returnFunction;
5532
5533
  }
5533
5534
  class Component {
5534
- 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 }) {
5535
+ 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 }) {
5535
5536
  if (!sources || !isObj(sources))
5536
5537
  throw new Error(`[${name}] Missing or invalid sources`);
5537
5538
  this._componentNumber = COMPONENT_COUNT++;
@@ -5553,6 +5554,7 @@ class Component {
5553
5554
  this.requestSourceName = requestSourceName;
5554
5555
  this.sourceNames = Object.keys(sources);
5555
5556
  this.onError = onError;
5557
+ this.isolatedState = isolatedState;
5556
5558
  this._debug = debug;
5557
5559
  // Warn if calculated fields shadow base state keys
5558
5560
  if (this.calculated && this.initialState
@@ -5752,8 +5754,15 @@ class Component {
5752
5754
  }
5753
5755
  }
5754
5756
  dispose() {
5755
- // Signal disposal to the component via dispose$ stream
5756
- // This fires FIRST so CLEANUP actions in the model can process
5757
+ // Fire the DISPOSE built-in action so model handlers can run cleanup logic
5758
+ const hasDispose = this.model && (this.model[DISPOSE_ACTION] || Object.keys(this.model).some(k => k.includes('|') && k.split('|')[0].trim() === DISPOSE_ACTION));
5759
+ if (hasDispose && this.action$ && typeof this.action$.shamefullySendNext === 'function') {
5760
+ try {
5761
+ this.action$.shamefullySendNext({ type: DISPOSE_ACTION });
5762
+ }
5763
+ catch (_) { }
5764
+ }
5765
+ // Signal disposal to the component via dispose$ stream (for advanced use cases)
5757
5766
  if (this._disposeListener) {
5758
5767
  try {
5759
5768
  this._disposeListener.next(true);
@@ -5762,7 +5771,7 @@ class Component {
5762
5771
  catch (_) { }
5763
5772
  this._disposeListener = null;
5764
5773
  }
5765
- // Tear down streams on next microtask to allow CLEANUP actions to process
5774
+ // Tear down streams on next microtask to allow DISPOSE/cleanup actions to process
5766
5775
  setTimeout(() => {
5767
5776
  // Complete the action$ stream to stop the entire component cycle
5768
5777
  if (this.action$ && typeof this.action$.shamefullySendComplete === 'function') {
@@ -5945,7 +5954,7 @@ class Component {
5945
5954
  const hmrState = ENVIRONMENT?.__SYGNAL_HMR_STATE;
5946
5955
  const effectiveInitialState = (typeof hmrState !== 'undefined') ? hmrState : this.initialState;
5947
5956
  const initial = { type: INITIALIZE_ACTION, data: effectiveInitialState };
5948
- if (this.isSubComponent && this.initialState) {
5957
+ if (this.isSubComponent && this.initialState && !this.isolatedState) {
5949
5958
  console.warn(`[${this.name}] Initial state provided to sub-component. This will overwrite any state provided by the parent component.`);
5950
5959
  }
5951
5960
  const hasInitialState = (typeof effectiveInitialState !== 'undefined');
@@ -6126,6 +6135,7 @@ class Component {
6126
6135
  .map((vdom) => processLazy(vdom, this))
6127
6136
  .map(processPortals)
6128
6137
  .map(processTransitions)
6138
+ .map(processClientOnly)
6129
6139
  .compose(this.instantiateSubComponents.bind(this))
6130
6140
  .filter((val) => val !== undefined)
6131
6141
  .compose(this.renderVdom.bind(this));
@@ -7103,6 +7113,31 @@ function processTransitions(vnode) {
7103
7113
  }
7104
7114
  return vnode;
7105
7115
  }
7116
+ function processClientOnly(vnode) {
7117
+ if (!vnode || !vnode.sel)
7118
+ return vnode;
7119
+ if (vnode.sel === 'clientonly') {
7120
+ // On the client, unwrap to children (render them normally)
7121
+ const children = vnode.children || [];
7122
+ if (children.length === 0)
7123
+ return { sel: 'div', data: {}, children: [] };
7124
+ if (children.length === 1)
7125
+ return processClientOnly(children[0]);
7126
+ // Multiple children: wrap in a div
7127
+ return {
7128
+ sel: 'div',
7129
+ data: {},
7130
+ children: children.map(processClientOnly),
7131
+ text: undefined,
7132
+ elm: undefined,
7133
+ key: undefined,
7134
+ };
7135
+ }
7136
+ if (vnode.children && vnode.children.length > 0) {
7137
+ vnode.children = vnode.children.map(processClientOnly);
7138
+ }
7139
+ return vnode;
7140
+ }
7106
7141
  function applyTransitionHooks(vnode, name, duration) {
7107
7142
  const existingInsert = vnode.data?.hook?.insert;
7108
7143
  const existingRemove = vnode.data?.hook?.remove;
@@ -7706,6 +7741,13 @@ function run(app, drivers = {}, options = {}) {
7706
7741
  sources.STATE.stream.removeListener(persistListener);
7707
7742
  persistListener = null;
7708
7743
  }
7744
+ // Trigger the component's dispose() which fires the DISPOSE action and dispose$ stream
7745
+ if (typeof sinks.__dispose === 'function') {
7746
+ try {
7747
+ sinks.__dispose();
7748
+ }
7749
+ catch (_) { }
7750
+ }
7709
7751
  rawDispose();
7710
7752
  };
7711
7753
  const exposed = { sources, sinks, dispose };
@@ -5445,6 +5445,7 @@ const ENVIRONMENT = (typeof window != 'undefined' && window) || (typeof process
5445
5445
  const BOOTSTRAP_ACTION = 'BOOTSTRAP';
5446
5446
  const INITIALIZE_ACTION = 'INITIALIZE';
5447
5447
  const HYDRATE_ACTION = 'HYDRATE';
5448
+ const DISPOSE_ACTION = 'DISPOSE';
5448
5449
  const PARENT_SINK_NAME = 'PARENT';
5449
5450
  const CHILD_SOURCE_NAME = 'CHILD';
5450
5451
  const READY_SINK_NAME = 'READY';
@@ -5529,7 +5530,7 @@ function component(opts) {
5529
5530
  return returnFunction;
5530
5531
  }
5531
5532
  class Component {
5532
- 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 }) {
5533
+ 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 }) {
5533
5534
  if (!sources || !isObj(sources))
5534
5535
  throw new Error(`[${name}] Missing or invalid sources`);
5535
5536
  this._componentNumber = COMPONENT_COUNT++;
@@ -5551,6 +5552,7 @@ class Component {
5551
5552
  this.requestSourceName = requestSourceName;
5552
5553
  this.sourceNames = Object.keys(sources);
5553
5554
  this.onError = onError;
5555
+ this.isolatedState = isolatedState;
5554
5556
  this._debug = debug;
5555
5557
  // Warn if calculated fields shadow base state keys
5556
5558
  if (this.calculated && this.initialState
@@ -5750,8 +5752,15 @@ class Component {
5750
5752
  }
5751
5753
  }
5752
5754
  dispose() {
5753
- // Signal disposal to the component via dispose$ stream
5754
- // This fires FIRST so CLEANUP actions in the model can process
5755
+ // Fire the DISPOSE built-in action so model handlers can run cleanup logic
5756
+ const hasDispose = this.model && (this.model[DISPOSE_ACTION] || Object.keys(this.model).some(k => k.includes('|') && k.split('|')[0].trim() === DISPOSE_ACTION));
5757
+ if (hasDispose && this.action$ && typeof this.action$.shamefullySendNext === 'function') {
5758
+ try {
5759
+ this.action$.shamefullySendNext({ type: DISPOSE_ACTION });
5760
+ }
5761
+ catch (_) { }
5762
+ }
5763
+ // Signal disposal to the component via dispose$ stream (for advanced use cases)
5755
5764
  if (this._disposeListener) {
5756
5765
  try {
5757
5766
  this._disposeListener.next(true);
@@ -5760,7 +5769,7 @@ class Component {
5760
5769
  catch (_) { }
5761
5770
  this._disposeListener = null;
5762
5771
  }
5763
- // Tear down streams on next microtask to allow CLEANUP actions to process
5772
+ // Tear down streams on next microtask to allow DISPOSE/cleanup actions to process
5764
5773
  setTimeout(() => {
5765
5774
  // Complete the action$ stream to stop the entire component cycle
5766
5775
  if (this.action$ && typeof this.action$.shamefullySendComplete === 'function') {
@@ -5943,7 +5952,7 @@ class Component {
5943
5952
  const hmrState = ENVIRONMENT?.__SYGNAL_HMR_STATE;
5944
5953
  const effectiveInitialState = (typeof hmrState !== 'undefined') ? hmrState : this.initialState;
5945
5954
  const initial = { type: INITIALIZE_ACTION, data: effectiveInitialState };
5946
- if (this.isSubComponent && this.initialState) {
5955
+ if (this.isSubComponent && this.initialState && !this.isolatedState) {
5947
5956
  console.warn(`[${this.name}] Initial state provided to sub-component. This will overwrite any state provided by the parent component.`);
5948
5957
  }
5949
5958
  const hasInitialState = (typeof effectiveInitialState !== 'undefined');
@@ -6124,6 +6133,7 @@ class Component {
6124
6133
  .map((vdom) => processLazy(vdom, this))
6125
6134
  .map(processPortals)
6126
6135
  .map(processTransitions)
6136
+ .map(processClientOnly)
6127
6137
  .compose(this.instantiateSubComponents.bind(this))
6128
6138
  .filter((val) => val !== undefined)
6129
6139
  .compose(this.renderVdom.bind(this));
@@ -7101,6 +7111,31 @@ function processTransitions(vnode) {
7101
7111
  }
7102
7112
  return vnode;
7103
7113
  }
7114
+ function processClientOnly(vnode) {
7115
+ if (!vnode || !vnode.sel)
7116
+ return vnode;
7117
+ if (vnode.sel === 'clientonly') {
7118
+ // On the client, unwrap to children (render them normally)
7119
+ const children = vnode.children || [];
7120
+ if (children.length === 0)
7121
+ return { sel: 'div', data: {}, children: [] };
7122
+ if (children.length === 1)
7123
+ return processClientOnly(children[0]);
7124
+ // Multiple children: wrap in a div
7125
+ return {
7126
+ sel: 'div',
7127
+ data: {},
7128
+ children: children.map(processClientOnly),
7129
+ text: undefined,
7130
+ elm: undefined,
7131
+ key: undefined,
7132
+ };
7133
+ }
7134
+ if (vnode.children && vnode.children.length > 0) {
7135
+ vnode.children = vnode.children.map(processClientOnly);
7136
+ }
7137
+ return vnode;
7138
+ }
7104
7139
  function applyTransitionHooks(vnode, name, duration) {
7105
7140
  const existingInsert = vnode.data?.hook?.insert;
7106
7141
  const existingRemove = vnode.data?.hook?.remove;
@@ -7704,6 +7739,13 @@ function run(app, drivers = {}, options = {}) {
7704
7739
  sources.STATE.stream.removeListener(persistListener);
7705
7740
  persistListener = null;
7706
7741
  }
7742
+ // Trigger the component's dispose() which fires the DISPOSE action and dispose$ stream
7743
+ if (typeof sinks.__dispose === 'function') {
7744
+ try {
7745
+ sinks.__dispose();
7746
+ }
7747
+ catch (_) { }
7748
+ }
7707
7749
  rawDispose();
7708
7750
  };
7709
7751
  const exposed = { sources, sinks, dispose };
@@ -172,6 +172,24 @@ function processSSRTree(vnode, context, parentState) {
172
172
  key: undefined,
173
173
  };
174
174
  }
175
+ // ClientOnly: render fallback during SSR, skip children (they need a browser)
176
+ if (sel === 'clientonly') {
177
+ const props = vnode.data?.props || {};
178
+ const fallback = props.fallback;
179
+ if (fallback) {
180
+ // fallback can be a VNode or a string
181
+ return processSSRTree(fallback, context, parentState);
182
+ }
183
+ // No fallback — render an empty placeholder div
184
+ return {
185
+ sel: 'div',
186
+ data: { attrs: { 'data-sygnal-clientonly': '' } },
187
+ children: [],
188
+ text: undefined,
189
+ elm: undefined,
190
+ key: undefined,
191
+ };
192
+ }
175
193
  // Slot: unwrap to children
176
194
  if (sel === 'slot') {
177
195
  const children = vnode.children || [];
@@ -168,6 +168,24 @@ function processSSRTree(vnode, context, parentState) {
168
168
  key: undefined,
169
169
  };
170
170
  }
171
+ // ClientOnly: render fallback during SSR, skip children (they need a browser)
172
+ if (sel === 'clientonly') {
173
+ const props = vnode.data?.props || {};
174
+ const fallback = props.fallback;
175
+ if (fallback) {
176
+ // fallback can be a VNode or a string
177
+ return processSSRTree(fallback, context, parentState);
178
+ }
179
+ // No fallback — render an empty placeholder div
180
+ return {
181
+ sel: 'div',
182
+ data: { attrs: { 'data-sygnal-clientonly': '' } },
183
+ children: [],
184
+ text: undefined,
185
+ elm: undefined,
186
+ key: undefined,
187
+ };
188
+ }
171
189
  // Slot: unwrap to children
172
190
  if (sel === 'slot') {
173
191
  const children = vnode.children || [];
package/dist/index.cjs.js CHANGED
@@ -2185,6 +2185,7 @@ const ENVIRONMENT = (typeof window != 'undefined' && window) || (typeof process
2185
2185
  const BOOTSTRAP_ACTION = 'BOOTSTRAP';
2186
2186
  const INITIALIZE_ACTION = 'INITIALIZE';
2187
2187
  const HYDRATE_ACTION = 'HYDRATE';
2188
+ const DISPOSE_ACTION = 'DISPOSE';
2188
2189
  const PARENT_SINK_NAME = 'PARENT';
2189
2190
  const CHILD_SOURCE_NAME = 'CHILD';
2190
2191
  const READY_SINK_NAME = 'READY';
@@ -2269,7 +2270,7 @@ function component(opts) {
2269
2270
  return returnFunction;
2270
2271
  }
2271
2272
  class Component {
2272
- 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 }) {
2273
+ 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 }) {
2273
2274
  if (!sources || !isObj(sources))
2274
2275
  throw new Error(`[${name}] Missing or invalid sources`);
2275
2276
  this._componentNumber = COMPONENT_COUNT++;
@@ -2291,6 +2292,7 @@ class Component {
2291
2292
  this.requestSourceName = requestSourceName;
2292
2293
  this.sourceNames = Object.keys(sources);
2293
2294
  this.onError = onError;
2295
+ this.isolatedState = isolatedState;
2294
2296
  this._debug = debug;
2295
2297
  // Warn if calculated fields shadow base state keys
2296
2298
  if (this.calculated && this.initialState
@@ -2490,8 +2492,15 @@ class Component {
2490
2492
  }
2491
2493
  }
2492
2494
  dispose() {
2493
- // Signal disposal to the component via dispose$ stream
2494
- // This fires FIRST so CLEANUP actions in the model can process
2495
+ // Fire the DISPOSE built-in action so model handlers can run cleanup logic
2496
+ const hasDispose = this.model && (this.model[DISPOSE_ACTION] || Object.keys(this.model).some(k => k.includes('|') && k.split('|')[0].trim() === DISPOSE_ACTION));
2497
+ if (hasDispose && this.action$ && typeof this.action$.shamefullySendNext === 'function') {
2498
+ try {
2499
+ this.action$.shamefullySendNext({ type: DISPOSE_ACTION });
2500
+ }
2501
+ catch (_) { }
2502
+ }
2503
+ // Signal disposal to the component via dispose$ stream (for advanced use cases)
2495
2504
  if (this._disposeListener) {
2496
2505
  try {
2497
2506
  this._disposeListener.next(true);
@@ -2500,7 +2509,7 @@ class Component {
2500
2509
  catch (_) { }
2501
2510
  this._disposeListener = null;
2502
2511
  }
2503
- // Tear down streams on next microtask to allow CLEANUP actions to process
2512
+ // Tear down streams on next microtask to allow DISPOSE/cleanup actions to process
2504
2513
  setTimeout(() => {
2505
2514
  // Complete the action$ stream to stop the entire component cycle
2506
2515
  if (this.action$ && typeof this.action$.shamefullySendComplete === 'function') {
@@ -2683,7 +2692,7 @@ class Component {
2683
2692
  const hmrState = ENVIRONMENT?.__SYGNAL_HMR_STATE;
2684
2693
  const effectiveInitialState = (typeof hmrState !== 'undefined') ? hmrState : this.initialState;
2685
2694
  const initial = { type: INITIALIZE_ACTION, data: effectiveInitialState };
2686
- if (this.isSubComponent && this.initialState) {
2695
+ if (this.isSubComponent && this.initialState && !this.isolatedState) {
2687
2696
  console.warn(`[${this.name}] Initial state provided to sub-component. This will overwrite any state provided by the parent component.`);
2688
2697
  }
2689
2698
  const hasInitialState = (typeof effectiveInitialState !== 'undefined');
@@ -2864,6 +2873,7 @@ class Component {
2864
2873
  .map((vdom) => processLazy(vdom, this))
2865
2874
  .map(processPortals)
2866
2875
  .map(processTransitions)
2876
+ .map(processClientOnly)
2867
2877
  .compose(this.instantiateSubComponents.bind(this))
2868
2878
  .filter((val) => val !== undefined)
2869
2879
  .compose(this.renderVdom.bind(this));
@@ -3841,6 +3851,31 @@ function processTransitions(vnode) {
3841
3851
  }
3842
3852
  return vnode;
3843
3853
  }
3854
+ function processClientOnly(vnode) {
3855
+ if (!vnode || !vnode.sel)
3856
+ return vnode;
3857
+ if (vnode.sel === 'clientonly') {
3858
+ // On the client, unwrap to children (render them normally)
3859
+ const children = vnode.children || [];
3860
+ if (children.length === 0)
3861
+ return { sel: 'div', data: {}, children: [] };
3862
+ if (children.length === 1)
3863
+ return processClientOnly(children[0]);
3864
+ // Multiple children: wrap in a div
3865
+ return {
3866
+ sel: 'div',
3867
+ data: {},
3868
+ children: children.map(processClientOnly),
3869
+ text: undefined,
3870
+ elm: undefined,
3871
+ key: undefined,
3872
+ };
3873
+ }
3874
+ if (vnode.children && vnode.children.length > 0) {
3875
+ vnode.children = vnode.children.map(processClientOnly);
3876
+ }
3877
+ return vnode;
3878
+ }
3844
3879
  function applyTransitionHooks(vnode, name, duration) {
3845
3880
  const existingInsert = vnode.data?.hook?.insert;
3846
3881
  const existingRemove = vnode.data?.hook?.remove;
@@ -5009,6 +5044,13 @@ function run(app, drivers = {}, options = {}) {
5009
5044
  sources.STATE.stream.removeListener(persistListener);
5010
5045
  persistListener = null;
5011
5046
  }
5047
+ // Trigger the component's dispose() which fires the DISPOSE action and dispose$ stream
5048
+ if (typeof sinks.__dispose === 'function') {
5049
+ try {
5050
+ sinks.__dispose();
5051
+ }
5052
+ catch (_) { }
5053
+ }
5012
5054
  rawDispose();
5013
5055
  };
5014
5056
  const exposed = { sources, sinks, dispose };
@@ -5805,6 +5847,13 @@ function renderComponent(componentDef, options = {}) {
5805
5847
  catch (_) { }
5806
5848
  stateListener = null;
5807
5849
  }
5850
+ // Trigger the component's dispose() which fires the DISPOSE action and dispose$ stream
5851
+ if (typeof sinks.__dispose === 'function') {
5852
+ try {
5853
+ sinks.__dispose();
5854
+ }
5855
+ catch (_) { }
5856
+ }
5808
5857
  rawDispose();
5809
5858
  };
5810
5859
  return {
@@ -5820,6 +5869,63 @@ function renderComponent(componentDef, options = {}) {
5820
5869
  };
5821
5870
  }
5822
5871
 
5872
+ /**
5873
+ * Reducer helpers for common state update patterns.
5874
+ *
5875
+ * These reduce boilerplate in model definitions by providing
5876
+ * shorthand factories for the most frequent reducer shapes.
5877
+ */
5878
+ // ── set() ──────────────────────────────────────────────────────────
5879
+ /**
5880
+ * Create a reducer that merges a partial update into state.
5881
+ *
5882
+ * Static form — merge a fixed object:
5883
+ * set({ isEditing: true })
5884
+ *
5885
+ * Dynamic form — function receives (state, data, next, props) and
5886
+ * returns the partial update to merge:
5887
+ * set((state, title) => ({ title }))
5888
+ */
5889
+ function set(partial) {
5890
+ if (typeof partial === 'function') {
5891
+ return (state, data, next, props) => ({
5892
+ ...state,
5893
+ ...partial(state, data, next, props),
5894
+ });
5895
+ }
5896
+ return (state) => ({ ...state, ...partial });
5897
+ }
5898
+ // ── toggle() ───────────────────────────────────────────────────────
5899
+ /**
5900
+ * Create a reducer that toggles a boolean field on state.
5901
+ *
5902
+ * toggle('showModal')
5903
+ * // equivalent to: (state) => ({ ...state, showModal: !state.showModal })
5904
+ */
5905
+ function toggle(field) {
5906
+ return (state) => ({ ...state, [field]: !state[field] });
5907
+ }
5908
+ // ── emit() ─────────────────────────────────────────────────────────
5909
+ /**
5910
+ * Create a model entry that emits an EVENTS bus event.
5911
+ *
5912
+ * With static data:
5913
+ * emit('DELETE_LANE', { laneId: 42 })
5914
+ *
5915
+ * With dynamic data derived from state:
5916
+ * emit('DELETE_LANE', (state) => ({ laneId: state.id }))
5917
+ *
5918
+ * Fire-and-forget (no data):
5919
+ * emit('REFRESH')
5920
+ */
5921
+ function emit(type, data) {
5922
+ return {
5923
+ EVENTS: typeof data === 'function'
5924
+ ? (state, actionData, next, props) => ({ type, data: data(state, actionData, next, props) })
5925
+ : () => ({ type, data }),
5926
+ };
5927
+ }
5928
+
5823
5929
  /**
5824
5930
  * Server-Side Rendering utilities for Sygnal components.
5825
5931
  *
@@ -5990,6 +6096,24 @@ function processSSRTree(vnode, context, parentState) {
5990
6096
  key: undefined,
5991
6097
  };
5992
6098
  }
6099
+ // ClientOnly: render fallback during SSR, skip children (they need a browser)
6100
+ if (sel === 'clientonly') {
6101
+ const props = vnode.data?.props || {};
6102
+ const fallback = props.fallback;
6103
+ if (fallback) {
6104
+ // fallback can be a VNode or a string
6105
+ return processSSRTree(fallback, context, parentState);
6106
+ }
6107
+ // No fallback — render an empty placeholder div
6108
+ return {
6109
+ sel: 'div',
6110
+ data: { attrs: { 'data-sygnal-clientonly': '' } },
6111
+ children: [],
6112
+ text: undefined,
6113
+ elm: undefined,
6114
+ key: undefined,
6115
+ };
6116
+ }
5993
6117
  // Slot: unwrap to children
5994
6118
  if (sel === 'slot') {
5995
6119
  const children = vnode.children || [];
@@ -6495,6 +6619,7 @@ exports.createElement = createElement;
6495
6619
  exports.createRef = createRef;
6496
6620
  exports.createRef$ = createRef$;
6497
6621
  exports.driverFromAsync = driverFromAsync;
6622
+ exports.emit = emit;
6498
6623
  exports.enableHMR = enableHMR;
6499
6624
  exports.exactState = exactState;
6500
6625
  exports.getDevTools = getDevTools;
@@ -6508,6 +6633,8 @@ exports.processForm = processForm;
6508
6633
  exports.renderComponent = renderComponent;
6509
6634
  exports.renderToString = renderToString;
6510
6635
  exports.run = run;
6636
+ exports.set = set;
6511
6637
  exports.switchable = switchable;
6512
6638
  exports.thunk = thunk;
6639
+ exports.toggle = toggle;
6513
6640
  exports.xs = xs;
package/dist/index.d.ts CHANGED
@@ -117,6 +117,7 @@ type WithDefaultActions<STATE, ACTIONS> = ACTIONS & {
117
117
  BOOTSTRAP?: never;
118
118
  INITIALIZE?: STATE;
119
119
  HYDRATE?: any;
120
+ DISPOSE?: never;
120
121
  }
121
122
 
122
123
  type ComponentModel<STATE, PROPS, DRIVERS, ACTIONS, CALCULATED, SINK_RETURNS extends NonStateSinkReturns = {}> = keyof ACTIONS extends never
@@ -377,6 +378,40 @@ export function enableHMR<STATE = any, DRIVERS = {}>(
377
378
  export function classes(...classes: ClassesType): string
378
379
  export function exactState<STATE>(): <ACTUAL extends STATE>(state: ExactShape<STATE, ACTUAL>) => STATE
379
380
 
381
+ // ── Reducer helpers ────────────────────────────────────────────────
382
+
383
+ /**
384
+ * Create a reducer that merges a partial update into state.
385
+ *
386
+ * Static form — merge a fixed object:
387
+ * `set({ isEditing: true })`
388
+ *
389
+ * Dynamic form — function receives (state, data, next, props) and
390
+ * returns the partial update to merge:
391
+ * `set((state, title) => ({ title }))`
392
+ */
393
+ export function set<S = any>(
394
+ partial: Partial<S> | ((state: S, data: any, next: Function, props: any) => Partial<S>)
395
+ ): (state: S, data: any, next: Function, props: any) => S
396
+
397
+ /**
398
+ * Create a reducer that toggles a boolean field on state.
399
+ *
400
+ * `toggle('showModal')`
401
+ */
402
+ export function toggle<S = any>(field: keyof S & string): (state: S) => S
403
+
404
+ /**
405
+ * Create a model entry that emits an EVENTS bus event.
406
+ *
407
+ * `emit('DELETE_LANE', (state) => ({ laneId: state.id }))`
408
+ * `emit('REFRESH')`
409
+ */
410
+ export function emit(
411
+ type: string,
412
+ data?: any | ((state: any, actionData: any, next: Function, props: any) => any)
413
+ ): { EVENTS: (state: any, actionData: any, next: Function, props: any) => { type: string; data: any } }
414
+
380
415
  /**
381
416
  * Any object with an events() method (e.g., DOM.select('form')).
382
417
  * Uses permissive signature to be compatible with MainDOMSource's overloaded events().