sygnal 5.2.0 → 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
@@ -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
 
@@ -5532,7 +5532,7 @@ function component(opts) {
5532
5532
  return returnFunction;
5533
5533
  }
5534
5534
  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 }) {
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 }) {
5536
5536
  if (!sources || !isObj(sources))
5537
5537
  throw new Error(`[${name}] Missing or invalid sources`);
5538
5538
  this._componentNumber = COMPONENT_COUNT++;
@@ -5554,6 +5554,7 @@ class Component {
5554
5554
  this.requestSourceName = requestSourceName;
5555
5555
  this.sourceNames = Object.keys(sources);
5556
5556
  this.onError = onError;
5557
+ this.isolatedState = isolatedState;
5557
5558
  this._debug = debug;
5558
5559
  // Warn if calculated fields shadow base state keys
5559
5560
  if (this.calculated && this.initialState
@@ -5953,7 +5954,7 @@ class Component {
5953
5954
  const hmrState = ENVIRONMENT?.__SYGNAL_HMR_STATE;
5954
5955
  const effectiveInitialState = (typeof hmrState !== 'undefined') ? hmrState : this.initialState;
5955
5956
  const initial = { type: INITIALIZE_ACTION, data: effectiveInitialState };
5956
- if (this.isSubComponent && this.initialState) {
5957
+ if (this.isSubComponent && this.initialState && !this.isolatedState) {
5957
5958
  console.warn(`[${this.name}] Initial state provided to sub-component. This will overwrite any state provided by the parent component.`);
5958
5959
  }
5959
5960
  const hasInitialState = (typeof effectiveInitialState !== 'undefined');
@@ -6134,6 +6135,7 @@ class Component {
6134
6135
  .map((vdom) => processLazy(vdom, this))
6135
6136
  .map(processPortals)
6136
6137
  .map(processTransitions)
6138
+ .map(processClientOnly)
6137
6139
  .compose(this.instantiateSubComponents.bind(this))
6138
6140
  .filter((val) => val !== undefined)
6139
6141
  .compose(this.renderVdom.bind(this));
@@ -7111,6 +7113,31 @@ function processTransitions(vnode) {
7111
7113
  }
7112
7114
  return vnode;
7113
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
+ }
7114
7141
  function applyTransitionHooks(vnode, name, duration) {
7115
7142
  const existingInsert = vnode.data?.hook?.insert;
7116
7143
  const existingRemove = vnode.data?.hook?.remove;
@@ -5530,7 +5530,7 @@ function component(opts) {
5530
5530
  return returnFunction;
5531
5531
  }
5532
5532
  class Component {
5533
- 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 }) {
5534
5534
  if (!sources || !isObj(sources))
5535
5535
  throw new Error(`[${name}] Missing or invalid sources`);
5536
5536
  this._componentNumber = COMPONENT_COUNT++;
@@ -5552,6 +5552,7 @@ class Component {
5552
5552
  this.requestSourceName = requestSourceName;
5553
5553
  this.sourceNames = Object.keys(sources);
5554
5554
  this.onError = onError;
5555
+ this.isolatedState = isolatedState;
5555
5556
  this._debug = debug;
5556
5557
  // Warn if calculated fields shadow base state keys
5557
5558
  if (this.calculated && this.initialState
@@ -5951,7 +5952,7 @@ class Component {
5951
5952
  const hmrState = ENVIRONMENT?.__SYGNAL_HMR_STATE;
5952
5953
  const effectiveInitialState = (typeof hmrState !== 'undefined') ? hmrState : this.initialState;
5953
5954
  const initial = { type: INITIALIZE_ACTION, data: effectiveInitialState };
5954
- if (this.isSubComponent && this.initialState) {
5955
+ if (this.isSubComponent && this.initialState && !this.isolatedState) {
5955
5956
  console.warn(`[${this.name}] Initial state provided to sub-component. This will overwrite any state provided by the parent component.`);
5956
5957
  }
5957
5958
  const hasInitialState = (typeof effectiveInitialState !== 'undefined');
@@ -6132,6 +6133,7 @@ class Component {
6132
6133
  .map((vdom) => processLazy(vdom, this))
6133
6134
  .map(processPortals)
6134
6135
  .map(processTransitions)
6136
+ .map(processClientOnly)
6135
6137
  .compose(this.instantiateSubComponents.bind(this))
6136
6138
  .filter((val) => val !== undefined)
6137
6139
  .compose(this.renderVdom.bind(this));
@@ -7109,6 +7111,31 @@ function processTransitions(vnode) {
7109
7111
  }
7110
7112
  return vnode;
7111
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
+ }
7112
7139
  function applyTransitionHooks(vnode, name, duration) {
7113
7140
  const existingInsert = vnode.data?.hook?.insert;
7114
7141
  const existingRemove = vnode.data?.hook?.remove;
@@ -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
@@ -2270,7 +2270,7 @@ function component(opts) {
2270
2270
  return returnFunction;
2271
2271
  }
2272
2272
  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 }) {
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 }) {
2274
2274
  if (!sources || !isObj(sources))
2275
2275
  throw new Error(`[${name}] Missing or invalid sources`);
2276
2276
  this._componentNumber = COMPONENT_COUNT++;
@@ -2292,6 +2292,7 @@ class Component {
2292
2292
  this.requestSourceName = requestSourceName;
2293
2293
  this.sourceNames = Object.keys(sources);
2294
2294
  this.onError = onError;
2295
+ this.isolatedState = isolatedState;
2295
2296
  this._debug = debug;
2296
2297
  // Warn if calculated fields shadow base state keys
2297
2298
  if (this.calculated && this.initialState
@@ -2691,7 +2692,7 @@ class Component {
2691
2692
  const hmrState = ENVIRONMENT?.__SYGNAL_HMR_STATE;
2692
2693
  const effectiveInitialState = (typeof hmrState !== 'undefined') ? hmrState : this.initialState;
2693
2694
  const initial = { type: INITIALIZE_ACTION, data: effectiveInitialState };
2694
- if (this.isSubComponent && this.initialState) {
2695
+ if (this.isSubComponent && this.initialState && !this.isolatedState) {
2695
2696
  console.warn(`[${this.name}] Initial state provided to sub-component. This will overwrite any state provided by the parent component.`);
2696
2697
  }
2697
2698
  const hasInitialState = (typeof effectiveInitialState !== 'undefined');
@@ -2872,6 +2873,7 @@ class Component {
2872
2873
  .map((vdom) => processLazy(vdom, this))
2873
2874
  .map(processPortals)
2874
2875
  .map(processTransitions)
2876
+ .map(processClientOnly)
2875
2877
  .compose(this.instantiateSubComponents.bind(this))
2876
2878
  .filter((val) => val !== undefined)
2877
2879
  .compose(this.renderVdom.bind(this));
@@ -3849,6 +3851,31 @@ function processTransitions(vnode) {
3849
3851
  }
3850
3852
  return vnode;
3851
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
+ }
3852
3879
  function applyTransitionHooks(vnode, name, duration) {
3853
3880
  const existingInsert = vnode.data?.hook?.insert;
3854
3881
  const existingRemove = vnode.data?.hook?.remove;
@@ -5842,6 +5869,63 @@ function renderComponent(componentDef, options = {}) {
5842
5869
  };
5843
5870
  }
5844
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
+
5845
5929
  /**
5846
5930
  * Server-Side Rendering utilities for Sygnal components.
5847
5931
  *
@@ -6012,6 +6096,24 @@ function processSSRTree(vnode, context, parentState) {
6012
6096
  key: undefined,
6013
6097
  };
6014
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
+ }
6015
6117
  // Slot: unwrap to children
6016
6118
  if (sel === 'slot') {
6017
6119
  const children = vnode.children || [];
@@ -6517,6 +6619,7 @@ exports.createElement = createElement;
6517
6619
  exports.createRef = createRef;
6518
6620
  exports.createRef$ = createRef$;
6519
6621
  exports.driverFromAsync = driverFromAsync;
6622
+ exports.emit = emit;
6520
6623
  exports.enableHMR = enableHMR;
6521
6624
  exports.exactState = exactState;
6522
6625
  exports.getDevTools = getDevTools;
@@ -6530,6 +6633,8 @@ exports.processForm = processForm;
6530
6633
  exports.renderComponent = renderComponent;
6531
6634
  exports.renderToString = renderToString;
6532
6635
  exports.run = run;
6636
+ exports.set = set;
6533
6637
  exports.switchable = switchable;
6534
6638
  exports.thunk = thunk;
6639
+ exports.toggle = toggle;
6535
6640
  exports.xs = xs;
package/dist/index.d.ts CHANGED
@@ -378,6 +378,40 @@ export function enableHMR<STATE = any, DRIVERS = {}>(
378
378
  export function classes(...classes: ClassesType): string
379
379
  export function exactState<STATE>(): <ACTUAL extends STATE>(state: ExactShape<STATE, ACTUAL>) => STATE
380
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
+
381
415
  /**
382
416
  * Any object with an events() method (e.g., DOM.select('form')).
383
417
  * Uses permissive signature to be compatible with MainDOMSource's overloaded events().
package/dist/index.esm.js CHANGED
@@ -2253,7 +2253,7 @@ function component(opts) {
2253
2253
  return returnFunction;
2254
2254
  }
2255
2255
  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 }) {
2256
+ 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
2257
  if (!sources || !isObj(sources))
2258
2258
  throw new Error(`[${name}] Missing or invalid sources`);
2259
2259
  this._componentNumber = COMPONENT_COUNT++;
@@ -2275,6 +2275,7 @@ class Component {
2275
2275
  this.requestSourceName = requestSourceName;
2276
2276
  this.sourceNames = Object.keys(sources);
2277
2277
  this.onError = onError;
2278
+ this.isolatedState = isolatedState;
2278
2279
  this._debug = debug;
2279
2280
  // Warn if calculated fields shadow base state keys
2280
2281
  if (this.calculated && this.initialState
@@ -2674,7 +2675,7 @@ class Component {
2674
2675
  const hmrState = ENVIRONMENT?.__SYGNAL_HMR_STATE;
2675
2676
  const effectiveInitialState = (typeof hmrState !== 'undefined') ? hmrState : this.initialState;
2676
2677
  const initial = { type: INITIALIZE_ACTION, data: effectiveInitialState };
2677
- if (this.isSubComponent && this.initialState) {
2678
+ if (this.isSubComponent && this.initialState && !this.isolatedState) {
2678
2679
  console.warn(`[${this.name}] Initial state provided to sub-component. This will overwrite any state provided by the parent component.`);
2679
2680
  }
2680
2681
  const hasInitialState = (typeof effectiveInitialState !== 'undefined');
@@ -2855,6 +2856,7 @@ class Component {
2855
2856
  .map((vdom) => processLazy(vdom, this))
2856
2857
  .map(processPortals)
2857
2858
  .map(processTransitions)
2859
+ .map(processClientOnly)
2858
2860
  .compose(this.instantiateSubComponents.bind(this))
2859
2861
  .filter((val) => val !== undefined)
2860
2862
  .compose(this.renderVdom.bind(this));
@@ -3832,6 +3834,31 @@ function processTransitions(vnode) {
3832
3834
  }
3833
3835
  return vnode;
3834
3836
  }
3837
+ function processClientOnly(vnode) {
3838
+ if (!vnode || !vnode.sel)
3839
+ return vnode;
3840
+ if (vnode.sel === 'clientonly') {
3841
+ // On the client, unwrap to children (render them normally)
3842
+ const children = vnode.children || [];
3843
+ if (children.length === 0)
3844
+ return { sel: 'div', data: {}, children: [] };
3845
+ if (children.length === 1)
3846
+ return processClientOnly(children[0]);
3847
+ // Multiple children: wrap in a div
3848
+ return {
3849
+ sel: 'div',
3850
+ data: {},
3851
+ children: children.map(processClientOnly),
3852
+ text: undefined,
3853
+ elm: undefined,
3854
+ key: undefined,
3855
+ };
3856
+ }
3857
+ if (vnode.children && vnode.children.length > 0) {
3858
+ vnode.children = vnode.children.map(processClientOnly);
3859
+ }
3860
+ return vnode;
3861
+ }
3835
3862
  function applyTransitionHooks(vnode, name, duration) {
3836
3863
  const existingInsert = vnode.data?.hook?.insert;
3837
3864
  const existingRemove = vnode.data?.hook?.remove;
@@ -5825,6 +5852,63 @@ function renderComponent(componentDef, options = {}) {
5825
5852
  };
5826
5853
  }
5827
5854
 
5855
+ /**
5856
+ * Reducer helpers for common state update patterns.
5857
+ *
5858
+ * These reduce boilerplate in model definitions by providing
5859
+ * shorthand factories for the most frequent reducer shapes.
5860
+ */
5861
+ // ── set() ──────────────────────────────────────────────────────────
5862
+ /**
5863
+ * Create a reducer that merges a partial update into state.
5864
+ *
5865
+ * Static form — merge a fixed object:
5866
+ * set({ isEditing: true })
5867
+ *
5868
+ * Dynamic form — function receives (state, data, next, props) and
5869
+ * returns the partial update to merge:
5870
+ * set((state, title) => ({ title }))
5871
+ */
5872
+ function set(partial) {
5873
+ if (typeof partial === 'function') {
5874
+ return (state, data, next, props) => ({
5875
+ ...state,
5876
+ ...partial(state, data, next, props),
5877
+ });
5878
+ }
5879
+ return (state) => ({ ...state, ...partial });
5880
+ }
5881
+ // ── toggle() ───────────────────────────────────────────────────────
5882
+ /**
5883
+ * Create a reducer that toggles a boolean field on state.
5884
+ *
5885
+ * toggle('showModal')
5886
+ * // equivalent to: (state) => ({ ...state, showModal: !state.showModal })
5887
+ */
5888
+ function toggle(field) {
5889
+ return (state) => ({ ...state, [field]: !state[field] });
5890
+ }
5891
+ // ── emit() ─────────────────────────────────────────────────────────
5892
+ /**
5893
+ * Create a model entry that emits an EVENTS bus event.
5894
+ *
5895
+ * With static data:
5896
+ * emit('DELETE_LANE', { laneId: 42 })
5897
+ *
5898
+ * With dynamic data derived from state:
5899
+ * emit('DELETE_LANE', (state) => ({ laneId: state.id }))
5900
+ *
5901
+ * Fire-and-forget (no data):
5902
+ * emit('REFRESH')
5903
+ */
5904
+ function emit(type, data) {
5905
+ return {
5906
+ EVENTS: typeof data === 'function'
5907
+ ? (state, actionData, next, props) => ({ type, data: data(state, actionData, next, props) })
5908
+ : () => ({ type, data }),
5909
+ };
5910
+ }
5911
+
5828
5912
  /**
5829
5913
  * Server-Side Rendering utilities for Sygnal components.
5830
5914
  *
@@ -5995,6 +6079,24 @@ function processSSRTree(vnode, context, parentState) {
5995
6079
  key: undefined,
5996
6080
  };
5997
6081
  }
6082
+ // ClientOnly: render fallback during SSR, skip children (they need a browser)
6083
+ if (sel === 'clientonly') {
6084
+ const props = vnode.data?.props || {};
6085
+ const fallback = props.fallback;
6086
+ if (fallback) {
6087
+ // fallback can be a VNode or a string
6088
+ return processSSRTree(fallback, context, parentState);
6089
+ }
6090
+ // No fallback — render an empty placeholder div
6091
+ return {
6092
+ sel: 'div',
6093
+ data: { attrs: { 'data-sygnal-clientonly': '' } },
6094
+ children: [],
6095
+ text: undefined,
6096
+ elm: undefined,
6097
+ key: undefined,
6098
+ };
6099
+ }
5998
6100
  // Slot: unwrap to children
5999
6101
  if (sel === 'slot') {
6000
6102
  const children = vnode.children || [];
@@ -6474,4 +6576,4 @@ function buildAttributes(data, selectorId, selectorClasses) {
6474
6576
  return result;
6475
6577
  }
6476
6578
 
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 };
6579
+ export { ABORT, Collection, MainDOMSource, MockedDOMSource, Portal, Slot, Suspense, Switchable, Transition, classes, collection, component, createCommand, createElement, createRef, createRef$, driverFromAsync, emit, enableHMR, exactState, getDevTools, lazy, makeDOMDriver, makeDragDriver, mockDOMSource, Portal as portal, processDrag, processForm, renderComponent, renderToString, run, set, switchable, thunk, toggle, xs };