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 +7 -7
- package/dist/astro/client.cjs.js +47 -5
- package/dist/astro/client.mjs +47 -5
- package/dist/astro/server.cjs.js +18 -0
- package/dist/astro/server.mjs +18 -0
- package/dist/index.cjs.js +132 -5
- package/dist/index.d.ts +35 -0
- package/dist/index.esm.js +130 -6
- package/dist/sygnal.min.js +1 -1
- package/dist/vike/+config.cjs.js +5 -1
- package/dist/vike/+config.js +5 -1
- package/dist/vike/ClientOnly.cjs.js +34 -0
- package/dist/vike/ClientOnly.mjs +32 -0
- package/dist/vike/onRenderClient.cjs.js +292 -35
- package/dist/vike/onRenderClient.mjs +292 -35
- package/dist/vike/onRenderHtml.cjs.js +71 -34
- package/dist/vike/onRenderHtml.mjs +71 -34
- package/dist/vite/plugin.cjs.js +6 -4
- package/dist/vite/plugin.mjs +6 -4
- package/package.json +5 -1
- package/src/component.ts +40 -6
- package/src/extra/reducers.ts +64 -0
- package/src/extra/run.ts +4 -0
- package/src/extra/ssr.ts +19 -0
- package/src/extra/testing.ts +4 -0
- package/src/index.d.ts +35 -0
- package/src/index.ts +1 -0
- package/src/vike/+config.ts +5 -1
- package/src/vike/ClientOnly.ts +10 -0
- package/src/vike/onRenderClient.ts +319 -36
- package/src/vike/onRenderHtml.ts +77 -33
- package/src/vike/types.ts +2 -0
- package/src/vite/plugin.ts +6 -4
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
|
-
|
|
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
|
|
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
|
|
package/dist/astro/client.cjs.js
CHANGED
|
@@ -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
|
-
//
|
|
5756
|
-
|
|
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
|
|
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 };
|
package/dist/astro/client.mjs
CHANGED
|
@@ -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
|
-
//
|
|
5754
|
-
|
|
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
|
|
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 };
|
package/dist/astro/server.cjs.js
CHANGED
|
@@ -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 || [];
|
package/dist/astro/server.mjs
CHANGED
|
@@ -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
|
-
//
|
|
2494
|
-
|
|
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
|
|
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().
|