@igojs/signal 6.0.0-beta.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 +203 -0
- package/index.js +24 -0
- package/package.json +30 -0
- package/src/client/DerivedCache.js +63 -0
- package/src/client/EventBinder.js +135 -0
- package/src/client/FormHandler.js +94 -0
- package/src/client/README.md +273 -0
- package/src/client/SignalComponent.js +409 -0
- package/src/client/StateProxy.js +74 -0
- package/src/client/dust/Templates.js +26 -0
- package/src/client/dust/Utils.js +124 -0
- package/src/client/dust/i18n.js +22 -0
- package/src/client/index.js +72 -0
- package/src/server/SerializeUtils.js +70 -0
- package/src/server/SignalController.js +94 -0
- package/src/server/index.js +17 -0
- package/src/shared/serialize.js +35 -0
- package/test/client/DerivedCacheTest.js +44 -0
- package/test/client/SignalComponentTest.js +59 -0
- package/test/client/StateProxyTest.js +59 -0
- package/test/server/SerializeUtilsTest.js +35 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StateProxy - Deep reactive state with Proxy (like Vue 3)
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Deep reactivity (nested objects/arrays)
|
|
6
|
+
* - Array mutation detection (push, pop, splice, etc.)
|
|
7
|
+
* - WeakMap caching to avoid double-wrapping
|
|
8
|
+
*/
|
|
9
|
+
class StateProxy {
|
|
10
|
+
constructor(component, namespace) {
|
|
11
|
+
this.component = component;
|
|
12
|
+
this.namespace = namespace;
|
|
13
|
+
this.cache = new WeakMap();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
create(target, path = []) {
|
|
17
|
+
if (this.cache.has(target)) return this.cache.get(target);
|
|
18
|
+
|
|
19
|
+
const proxy = new Proxy(target, {
|
|
20
|
+
get: (target, property) => {
|
|
21
|
+
// Track dependency
|
|
22
|
+
if (this.component._isTracking) {
|
|
23
|
+
this.component._trackedDeps.push([this.namespace, ...path, property]);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const value = target[property];
|
|
27
|
+
|
|
28
|
+
// Don't wrap primitives, functions, Date, RegExp
|
|
29
|
+
if (!value || typeof value !== 'object' || value instanceof Date || value instanceof RegExp) {
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Recursively wrap objects/arrays
|
|
34
|
+
return this.create(value, [...path, property]);
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
set: (target, property, value) => {
|
|
38
|
+
const oldValue = target[property];
|
|
39
|
+
target[property] = value;
|
|
40
|
+
|
|
41
|
+
// Trigger render if changed
|
|
42
|
+
if (this.component._isInitialized && !Object.is(oldValue, value)) {
|
|
43
|
+
this.component._triggerRender();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Wrap array methods
|
|
51
|
+
if (Array.isArray(target)) {
|
|
52
|
+
['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(method => {
|
|
53
|
+
const original = Array.prototype[method];
|
|
54
|
+
Object.defineProperty(target, method, {
|
|
55
|
+
value: (...args) => {
|
|
56
|
+
const result = original.apply(target, args);
|
|
57
|
+
if (this.component._isInitialized) this.component._triggerRender();
|
|
58
|
+
return result;
|
|
59
|
+
},
|
|
60
|
+
enumerable: false,
|
|
61
|
+
writable: true,
|
|
62
|
+
configurable: true
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
this.cache.set(target, proxy);
|
|
68
|
+
return proxy;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = StateProxy;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
|
|
2
|
+
const _CACHE = {};
|
|
3
|
+
|
|
4
|
+
const AsyncFunction = Object.getPrototypeOf(async function() { }).constructor;
|
|
5
|
+
|
|
6
|
+
const getTemplate = (file) => {
|
|
7
|
+
// loadTemplate(file);
|
|
8
|
+
return _CACHE[file];
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const loadTemplate = async (file) => {
|
|
12
|
+
if (!_CACHE[file]) {
|
|
13
|
+
const response = await fetch(`/__signal/templates?file=${file}`);
|
|
14
|
+
const json = await response.json();
|
|
15
|
+
// console.dir(json);
|
|
16
|
+
const fn = new AsyncFunction('l', 'u', 'c', 's', json.source);
|
|
17
|
+
_CACHE[file] = fn;
|
|
18
|
+
}
|
|
19
|
+
return _CACHE[file];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
module.exports = {
|
|
23
|
+
getTemplate,
|
|
24
|
+
loadTemplate,
|
|
25
|
+
_CACHE
|
|
26
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const Templates = require('./Templates');
|
|
2
|
+
const { uneval } = require('devalue'); // Bundler handles ES modules correctly
|
|
3
|
+
const igoDustHelpers = require('@igojs/dust/src/render/Helpers');
|
|
4
|
+
const { createSerializeHelper, htmlencode } = require('../../shared/serialize');
|
|
5
|
+
|
|
6
|
+
// Special characters
|
|
7
|
+
const BS = /\\/g,
|
|
8
|
+
LS = /\u2028/g,
|
|
9
|
+
PS = /\u2029/g,
|
|
10
|
+
LT = /</g,
|
|
11
|
+
FS = /\//g,
|
|
12
|
+
CR = /\r/g,
|
|
13
|
+
NL = /\n/g,
|
|
14
|
+
LF = /\f/g,
|
|
15
|
+
SQ = /'/g,
|
|
16
|
+
DQ = /"/g,
|
|
17
|
+
TB = /\t/g;
|
|
18
|
+
|
|
19
|
+
const escapeJs = (s) => {
|
|
20
|
+
if (typeof s === 'string') {
|
|
21
|
+
return s
|
|
22
|
+
.replace(BS, '\\\\')
|
|
23
|
+
.replace(FS, '\\/')
|
|
24
|
+
.replace(DQ, '\\"')
|
|
25
|
+
.replace(SQ, '\\\'')
|
|
26
|
+
.replace(CR, '\\r')
|
|
27
|
+
.replace(LS, '\\u2028')
|
|
28
|
+
.replace(PS, '\\u2029')
|
|
29
|
+
.replace(NL, '\\n')
|
|
30
|
+
.replace(LF, '\\f')
|
|
31
|
+
.replace(TB, '\\t');
|
|
32
|
+
}
|
|
33
|
+
return s;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const stringifyJson = (o) => {
|
|
37
|
+
return o && JSON.stringify(o)
|
|
38
|
+
.replace(LS, '\\u2028')
|
|
39
|
+
.replace(PS, '\\u2029')
|
|
40
|
+
.replace(LT, '\\u003c');
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Filters
|
|
44
|
+
const f = {
|
|
45
|
+
h: htmlencode,
|
|
46
|
+
j: escapeJs,
|
|
47
|
+
u: encodeURI,
|
|
48
|
+
uc: encodeURIComponent,
|
|
49
|
+
js: stringifyJson,
|
|
50
|
+
jp: JSON.parse,
|
|
51
|
+
uppercase: s => s.toUpperCase(),
|
|
52
|
+
lowercase: s => s.toLowerCase(),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
// return value to be displayed
|
|
57
|
+
const d = (s, t, l) => {
|
|
58
|
+
if (typeof s === 'function') {
|
|
59
|
+
return s.call(t, l);
|
|
60
|
+
}
|
|
61
|
+
if (s === null || s === undefined) {
|
|
62
|
+
return '';
|
|
63
|
+
}
|
|
64
|
+
return s;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// return value (if it's a function, invoke it with locals)
|
|
68
|
+
const v = (s, t, l) => {
|
|
69
|
+
if (typeof s === 'function') {
|
|
70
|
+
return s.call(t, l);
|
|
71
|
+
}
|
|
72
|
+
return s;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// return boolean
|
|
76
|
+
const b = (v) => {
|
|
77
|
+
if (!v) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
if (v.length === 0) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
return true;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// return array
|
|
87
|
+
const a = (v) => {
|
|
88
|
+
if (Array.isArray(v)) {
|
|
89
|
+
if (v.length === 0) {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
return v;
|
|
93
|
+
}
|
|
94
|
+
if (v) {
|
|
95
|
+
return [v];
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// helpers
|
|
101
|
+
const h = (t, p, l) => {
|
|
102
|
+
if (!h.helpers || !h.helpers[t]) {
|
|
103
|
+
throw new Error(`Error: helper @${t} not found!`);
|
|
104
|
+
}
|
|
105
|
+
return h.helpers[t](p, l);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// Initialize with igo-dust base helpers
|
|
109
|
+
h.helpers = {
|
|
110
|
+
...igoDustHelpers,
|
|
111
|
+
serialize: createSerializeHelper(uneval)
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// Register application helpers (called by signal.start())
|
|
115
|
+
const setHelpers = (appHelpers) => {
|
|
116
|
+
Object.assign(h.helpers, appHelpers);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// include file
|
|
120
|
+
const i = async (file) => {
|
|
121
|
+
return await Templates.loadTemplate(file);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
module.exports = { a, b, v, d, h, f, i, setHelpers };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const i18next = require('i18next');
|
|
2
|
+
|
|
3
|
+
// Get language from <html lang="..."> attribute (set by server)
|
|
4
|
+
const lang = document.documentElement.lang || 'en';
|
|
5
|
+
|
|
6
|
+
// Initialize i18next with translations injected from the server
|
|
7
|
+
i18next.init({
|
|
8
|
+
lng: lang,
|
|
9
|
+
fallbackLng: 'en',
|
|
10
|
+
resources: {
|
|
11
|
+
[lang]: {
|
|
12
|
+
translation: window.__signal_translations || {}
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
interpolation: {
|
|
16
|
+
escapeValue: false
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Expose globally for use in components
|
|
21
|
+
window.i18next = i18next;
|
|
22
|
+
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal - Zero-boilerplate reactive framework (browser entry point)
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* require('@igojs/signal/src/client').start({
|
|
6
|
+
* components: { 'Counter': Counter },
|
|
7
|
+
* helpers: require('./helpers')
|
|
8
|
+
* });
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
require('./dust/i18n');
|
|
12
|
+
|
|
13
|
+
const SignalComponent = require('./SignalComponent');
|
|
14
|
+
const Utils = require('./dust/Utils');
|
|
15
|
+
|
|
16
|
+
window.__signal = {
|
|
17
|
+
IgoDustUtils: Utils
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
let registry = {};
|
|
21
|
+
|
|
22
|
+
function mountElement(el) {
|
|
23
|
+
if (el.__igoInstance) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const Comp = registry[el.dataset.component];
|
|
27
|
+
if (Comp) {
|
|
28
|
+
new Comp(el);
|
|
29
|
+
} else {
|
|
30
|
+
console.error(`[signal] Component "${el.dataset.component}" not found`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function mountAll(root = document) {
|
|
35
|
+
root.querySelectorAll('[data-component]').forEach(mountElement);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function start(config = {}) {
|
|
39
|
+
const { components, helpers } = config;
|
|
40
|
+
|
|
41
|
+
// Register application helpers
|
|
42
|
+
if (helpers) {
|
|
43
|
+
Utils.setHelpers(helpers);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Build component registry
|
|
47
|
+
if (components) {
|
|
48
|
+
// Support webpack require.context
|
|
49
|
+
if (typeof components.keys === 'function') {
|
|
50
|
+
components.keys().forEach(key => {
|
|
51
|
+
const mod = components(key);
|
|
52
|
+
if (mod?.prototype instanceof SignalComponent) {
|
|
53
|
+
registry[key.replace(/^\.\//, '').replace(/\.js$/, '')] = mod;
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
} else {
|
|
57
|
+
// Support plain object { 'path': ComponentClass }
|
|
58
|
+
Object.assign(registry, components);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Mount components
|
|
63
|
+
if (document.readyState === 'loading') {
|
|
64
|
+
document.addEventListener('DOMContentLoaded', () => mountAll());
|
|
65
|
+
} else {
|
|
66
|
+
mountAll();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
window.__signal.mountElement = mountElement;
|
|
71
|
+
|
|
72
|
+
module.exports = { SignalComponent, start, mountAll, mountElement };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
|
|
2
|
+
const _ = require('lodash');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Serialize data for client-side hydration
|
|
6
|
+
*
|
|
7
|
+
* This function prepares data for devalue by converting Model instances
|
|
8
|
+
* to plain objects using their serialize() method.
|
|
9
|
+
*
|
|
10
|
+
* IMPORTANT: For deduplication to work, pass the same object references
|
|
11
|
+
* multiple times. Do NOT create separate serialize() calls before passing
|
|
12
|
+
* to this function - let it handle the serialization.
|
|
13
|
+
*
|
|
14
|
+
* @param {any} data - Data to serialize
|
|
15
|
+
* @param {WeakMap} [seen] - Internal map for tracking already-serialized objects
|
|
16
|
+
* @returns {any} - Serialized data ready for devalue
|
|
17
|
+
*/
|
|
18
|
+
module.exports.serialize = (data, seen = new WeakMap()) => {
|
|
19
|
+
if (data === null || data === undefined) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
// Skip functions
|
|
23
|
+
if (_.isFunction(data)) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
// Keep Date objects as-is (devalue handles them natively)
|
|
27
|
+
if (_.isDate(data)) {
|
|
28
|
+
return data;
|
|
29
|
+
}
|
|
30
|
+
if (_.isArray(data)) {
|
|
31
|
+
return _.map(data, item => module.exports.serialize(item, seen));
|
|
32
|
+
}
|
|
33
|
+
// Model instances with serialize method
|
|
34
|
+
if (_.isFunction(data?.serialize)) {
|
|
35
|
+
// Check if already serialized (deduplication)
|
|
36
|
+
if (seen.has(data)) {
|
|
37
|
+
return seen.get(data);
|
|
38
|
+
}
|
|
39
|
+
// Create placeholder to handle circular refs
|
|
40
|
+
const serialized = {};
|
|
41
|
+
seen.set(data, serialized);
|
|
42
|
+
// Serialize and merge into placeholder
|
|
43
|
+
const result = data.serialize();
|
|
44
|
+
Object.assign(serialized, module.exports.serialize(result, seen));
|
|
45
|
+
return serialized;
|
|
46
|
+
}
|
|
47
|
+
// Form instances with getValues method (Igo Form)
|
|
48
|
+
if (_.isFunction(data?.getValues) && data.constructor?.schema?.attributes) {
|
|
49
|
+
return module.exports.serialize(data.getValues(), seen);
|
|
50
|
+
}
|
|
51
|
+
// Plain objects only - recursively serialize values
|
|
52
|
+
if (_.isPlainObject(data)) {
|
|
53
|
+
// Check if already processed (for circular plain objects)
|
|
54
|
+
if (seen.has(data)) {
|
|
55
|
+
return seen.get(data);
|
|
56
|
+
}
|
|
57
|
+
const serialized = {};
|
|
58
|
+
seen.set(data, serialized);
|
|
59
|
+
for (const [key, value] of Object.entries(data)) {
|
|
60
|
+
serialized[key] = module.exports.serialize(value, seen);
|
|
61
|
+
}
|
|
62
|
+
return serialized;
|
|
63
|
+
}
|
|
64
|
+
// Primitives (string, number, boolean)
|
|
65
|
+
if (!_.isObject(data)) {
|
|
66
|
+
return data;
|
|
67
|
+
}
|
|
68
|
+
// Skip non-POJO class instances that we don't know how to serialize
|
|
69
|
+
return undefined;
|
|
70
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
|
|
2
|
+
const { dust: IgoDust } = require('@igojs/server');
|
|
3
|
+
|
|
4
|
+
const SerializeUtils = require('./SerializeUtils');
|
|
5
|
+
const { createSerializeHelper } = require('../shared/serialize');
|
|
6
|
+
|
|
7
|
+
// Load devalue dynamically (ES module)
|
|
8
|
+
let uneval;
|
|
9
|
+
let helperRegistered = false;
|
|
10
|
+
const devaluePromise = import('devalue').then(mod => {
|
|
11
|
+
uneval = mod.uneval;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// Translations are loaded from user's project (configured via signal.configure())
|
|
15
|
+
let translations = {};
|
|
16
|
+
|
|
17
|
+
// Register @serialize helper (called once after devalue is loaded)
|
|
18
|
+
const registerHelper = () => {
|
|
19
|
+
if (helperRegistered) return;
|
|
20
|
+
helperRegistered = true;
|
|
21
|
+
IgoDust.helpers.serialize = createSerializeHelper(uneval);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Signal middleware - Handles SSR for SignalComponents
|
|
27
|
+
*
|
|
28
|
+
* Usage in controllers:
|
|
29
|
+
* res.locals.signal_props = { session, products, form, ... };
|
|
30
|
+
* res.locals.signal_components = [NewRegistrationForm]; // optional: for SSR derived values
|
|
31
|
+
* res.render('view');
|
|
32
|
+
*
|
|
33
|
+
* The middleware will:
|
|
34
|
+
* 1. Serialize signal_props with deduplication (Model.serialize() called once per instance)
|
|
35
|
+
* 2. Compute SSR derived values from registered signal_components
|
|
36
|
+
* 3. Merge everything into res.locals for template rendering
|
|
37
|
+
*/
|
|
38
|
+
module.exports.middleware = async (req, res, next) => {
|
|
39
|
+
|
|
40
|
+
// Wait for devalue to be loaded, then register helper
|
|
41
|
+
await devaluePromise;
|
|
42
|
+
registerHelper();
|
|
43
|
+
|
|
44
|
+
// Inject translations for frontend i18next
|
|
45
|
+
res.locals.__signal_translations = uneval(translations);
|
|
46
|
+
|
|
47
|
+
const originalRender = res.render.bind(res);
|
|
48
|
+
|
|
49
|
+
res.render = (view, locals, callback) => {
|
|
50
|
+
const props = locals?.signal_props || res.locals.signal_props || {};
|
|
51
|
+
const components = res.locals.signal_components || [];
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
// Serialize props with deduplication
|
|
55
|
+
const serializedProps = SerializeUtils.serialize(props);
|
|
56
|
+
|
|
57
|
+
// Store serialized props for client hydration (devalue for XSS safety)
|
|
58
|
+
res.locals.__signal_props = uneval(serializedProps);
|
|
59
|
+
|
|
60
|
+
// Merge raw props into res.locals for template access
|
|
61
|
+
Object.assign(res.locals, props);
|
|
62
|
+
|
|
63
|
+
// Compute and merge SSR derived values from each component
|
|
64
|
+
for (const ComponentClass of components) {
|
|
65
|
+
if (ComponentClass?.ssr) {
|
|
66
|
+
const derived = ComponentClass.ssr(props);
|
|
67
|
+
Object.assign(res.locals, derived);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
} catch (error) {
|
|
72
|
+
console.error('[Signal] Serialization error:', error);
|
|
73
|
+
res.locals.__signal_props = '{}';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return originalRender(view, locals, callback);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
next();
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
//
|
|
83
|
+
module.exports.templates = async (req, res) => {
|
|
84
|
+
const file = req.query.file;
|
|
85
|
+
const source = await IgoDust.getSource(`${file}.dust`);
|
|
86
|
+
res.json({ file, source });
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Configure Signal (called from user's app)
|
|
90
|
+
module.exports.configure = (options) => {
|
|
91
|
+
if (options.translations) {
|
|
92
|
+
translations = options.translations;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signal - Server-side SSR support for SignalComponents
|
|
3
|
+
*
|
|
4
|
+
* This module provides:
|
|
5
|
+
* - SignalController: Express middleware for SSR and template serving
|
|
6
|
+
* - SerializeUtils: Model serialization with deduplication
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const SignalController = require('./SignalController');
|
|
10
|
+
const SerializeUtils = require('./SerializeUtils');
|
|
11
|
+
|
|
12
|
+
module.exports = {
|
|
13
|
+
middleware: SignalController.middleware,
|
|
14
|
+
templates: SignalController.templates,
|
|
15
|
+
configure: SignalController.configure,
|
|
16
|
+
serialize: SerializeUtils.serialize
|
|
17
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
|
|
2
|
+
// Shared serialize helper for @igojs/signal
|
|
3
|
+
// Used both server-side (SignalController) and client-side (Utils)
|
|
4
|
+
|
|
5
|
+
const HCHARS = /[&<>"']/;
|
|
6
|
+
|
|
7
|
+
const htmlencode = (s) => {
|
|
8
|
+
if (!s || typeof s !== 'string' || !HCHARS.test(s)) {
|
|
9
|
+
return s;
|
|
10
|
+
}
|
|
11
|
+
return s
|
|
12
|
+
.replace(/&/g, '&')
|
|
13
|
+
.replace(/</g, '<')
|
|
14
|
+
.replace(/>/g, '>')
|
|
15
|
+
.replace(/"/g, '"')
|
|
16
|
+
.replace(/'/g, ''');
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Create serialize helper function with the provided uneval function
|
|
21
|
+
* @param {Function} uneval - The uneval function from devalue
|
|
22
|
+
* @returns {Function} The serialize helper
|
|
23
|
+
*/
|
|
24
|
+
const createSerializeHelper = (uneval) => {
|
|
25
|
+
return (params, locals) => {
|
|
26
|
+
const data = {};
|
|
27
|
+
const keys = (params.props || '').split(',').map(p => p.trim()).filter(Boolean);
|
|
28
|
+
keys.forEach(key => {
|
|
29
|
+
data[key] = locals[key];
|
|
30
|
+
});
|
|
31
|
+
return htmlencode(uneval(data));
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
module.exports = { createSerializeHelper, htmlencode };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const assert = require('assert');
|
|
2
|
+
const DerivedCache = require('../../src/client/DerivedCache');
|
|
3
|
+
|
|
4
|
+
describe('DerivedCache', () => {
|
|
5
|
+
|
|
6
|
+
it('should cache computed values', () => {
|
|
7
|
+
const cache = new DerivedCache();
|
|
8
|
+
const context = { props: { value: 1 }, state: {} };
|
|
9
|
+
let callCount = 0;
|
|
10
|
+
|
|
11
|
+
const fn = () => ++callCount;
|
|
12
|
+
|
|
13
|
+
cache.memoize('test', fn, [], context, fn());
|
|
14
|
+
cache.memoize('test', fn, [], context);
|
|
15
|
+
|
|
16
|
+
assert.strictEqual(callCount, 1);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should recompute when deps change', () => {
|
|
20
|
+
const cache = new DerivedCache();
|
|
21
|
+
const context = { props: { count: 1 }, state: {} };
|
|
22
|
+
|
|
23
|
+
const fn = () => context.props.count * 2;
|
|
24
|
+
|
|
25
|
+
cache.memoize('double', fn, [['props', 'count']], context, fn());
|
|
26
|
+
context.props.count = 5;
|
|
27
|
+
const result = cache.memoize('double', fn, [['props', 'count']], context);
|
|
28
|
+
|
|
29
|
+
assert.strictEqual(result, 10);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should clear all cached values', () => {
|
|
33
|
+
const cache = new DerivedCache();
|
|
34
|
+
let count = 0;
|
|
35
|
+
const fn = () => ++count;
|
|
36
|
+
|
|
37
|
+
cache.memoize('a', fn, [], {}, fn());
|
|
38
|
+
cache.clear();
|
|
39
|
+
cache.memoize('a', fn, [], {});
|
|
40
|
+
|
|
41
|
+
assert.strictEqual(count, 2);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const assert = require('assert');
|
|
2
|
+
const SignalComponent = require('../../src/client/SignalComponent');
|
|
3
|
+
|
|
4
|
+
describe('SignalComponent', () => {
|
|
5
|
+
|
|
6
|
+
it('should initialize with props in SSR mode', () => {
|
|
7
|
+
const props = { products: [{ id: 1 }] };
|
|
8
|
+
const component = new SignalComponent(null, 'test', props);
|
|
9
|
+
|
|
10
|
+
assert.deepStrictEqual(component.props, props);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should initialize form state from props.form', () => {
|
|
14
|
+
const props = { form: { search: 'test' } };
|
|
15
|
+
const component = new SignalComponent(null, 'test', props);
|
|
16
|
+
|
|
17
|
+
assert.deepStrictEqual(component._state.form, props.form);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should compute derived values via ssr()', () => {
|
|
21
|
+
class TestComponent extends SignalComponent {
|
|
22
|
+
get doubled() {
|
|
23
|
+
return (this.props.value || 0) * 2;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const derived = TestComponent.ssr({ value: 5 });
|
|
28
|
+
|
|
29
|
+
assert.strictEqual(derived.doubled, 10);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should skip private and reserved getters in ssr()', () => {
|
|
33
|
+
class TestComponent extends SignalComponent {
|
|
34
|
+
get _private() { return 'hidden'; }
|
|
35
|
+
get public() { return 'visible'; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const derived = TestComponent.ssr({});
|
|
39
|
+
|
|
40
|
+
assert.strictEqual(derived._private, undefined);
|
|
41
|
+
assert.strictEqual(derived.public, 'visible');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should handle getter errors gracefully in ssr()', () => {
|
|
45
|
+
class TestComponent extends SignalComponent {
|
|
46
|
+
get failing() { throw new Error('DOM error'); }
|
|
47
|
+
get working() { return 'ok'; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const originalError = console.error;
|
|
51
|
+
console.error = () => {};
|
|
52
|
+
const derived = TestComponent.ssr({});
|
|
53
|
+
console.error = originalError;
|
|
54
|
+
|
|
55
|
+
assert.strictEqual(derived.failing, undefined);
|
|
56
|
+
assert.strictEqual(derived.working, 'ok');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const assert = require('assert');
|
|
2
|
+
const StateProxy = require('../../src/client/StateProxy');
|
|
3
|
+
|
|
4
|
+
describe('StateProxy', () => {
|
|
5
|
+
|
|
6
|
+
function createMockComponent() {
|
|
7
|
+
return {
|
|
8
|
+
_isTracking: false,
|
|
9
|
+
_trackedDeps: [],
|
|
10
|
+
_isInitialized: true,
|
|
11
|
+
_renderCount: 0,
|
|
12
|
+
_triggerRender() { this._renderCount++; }
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
it('should trigger render on property change', () => {
|
|
17
|
+
const component = createMockComponent();
|
|
18
|
+
const state = new StateProxy(component, 'state').create({ count: 0 });
|
|
19
|
+
|
|
20
|
+
state.count = 5;
|
|
21
|
+
|
|
22
|
+
assert.strictEqual(state.count, 5);
|
|
23
|
+
assert.strictEqual(component._renderCount, 1);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should handle nested objects', () => {
|
|
27
|
+
const component = createMockComponent();
|
|
28
|
+
const state = new StateProxy(component, 'state').create({
|
|
29
|
+
user: { name: 'John' }
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
state.user.name = 'Jane';
|
|
33
|
+
|
|
34
|
+
assert.strictEqual(state.user.name, 'Jane');
|
|
35
|
+
assert.strictEqual(component._renderCount, 1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should trigger render on array methods', () => {
|
|
39
|
+
const component = createMockComponent();
|
|
40
|
+
const state = new StateProxy(component, 'state').create({ items: [] });
|
|
41
|
+
|
|
42
|
+
state.items.push({ id: 1 });
|
|
43
|
+
|
|
44
|
+
assert.strictEqual(state.items.length, 1);
|
|
45
|
+
assert.strictEqual(component._renderCount, 1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should track dependencies when tracking is enabled', () => {
|
|
49
|
+
const component = createMockComponent();
|
|
50
|
+
const state = new StateProxy(component, 'state').create({ count: 0 });
|
|
51
|
+
|
|
52
|
+
component._isTracking = true;
|
|
53
|
+
const _ = state.count;
|
|
54
|
+
component._isTracking = false;
|
|
55
|
+
|
|
56
|
+
assert.deepStrictEqual(component._trackedDeps[0], ['state', 'count']);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
});
|