pulse-js-framework 1.4.5 → 1.4.6
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 +74 -0
- package/loader/vite-plugin.js +22 -6
- package/package.json +6 -2
- package/runtime/dom.js +49 -8
- package/runtime/hmr.js +169 -0
- package/runtime/pulse.js +143 -42
- package/types/index.d.ts +5 -1
- package/types/pulse.d.ts +33 -0
package/README.md
CHANGED
|
@@ -13,6 +13,7 @@ A declarative DOM framework with CSS selector-based structure and reactive pulsa
|
|
|
13
13
|
- **No Build Required** - Works directly in the browser
|
|
14
14
|
- **Lightweight** - Minimal footprint, maximum performance
|
|
15
15
|
- **Router & Store** - Built-in SPA routing and state management
|
|
16
|
+
- **Hot Module Replacement** - Full HMR with state preservation
|
|
16
17
|
- **Mobile Apps** - Build native Android & iOS apps (zero dependencies)
|
|
17
18
|
- **TypeScript Support** - Full type definitions for IDE autocomplete
|
|
18
19
|
|
|
@@ -280,6 +281,57 @@ const store = createStore({
|
|
|
280
281
|
store.user.set({ name: 'John' });
|
|
281
282
|
```
|
|
282
283
|
|
|
284
|
+
### HMR (Hot Module Replacement)
|
|
285
|
+
|
|
286
|
+
Pulse supports full HMR with state preservation during development:
|
|
287
|
+
|
|
288
|
+
```javascript
|
|
289
|
+
import { createHMRContext } from 'pulse-js-framework/runtime/hmr';
|
|
290
|
+
|
|
291
|
+
const hmr = createHMRContext(import.meta.url);
|
|
292
|
+
|
|
293
|
+
// State preserved across HMR updates
|
|
294
|
+
const count = hmr.preservePulse('count', 0);
|
|
295
|
+
const items = hmr.preservePulse('items', []);
|
|
296
|
+
|
|
297
|
+
// Effects tracked for automatic cleanup
|
|
298
|
+
hmr.setup(() => {
|
|
299
|
+
effect(() => {
|
|
300
|
+
document.title = `Count: ${count.get()}`;
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Accept HMR updates
|
|
305
|
+
hmr.accept();
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
**HMR Features:**
|
|
309
|
+
- `preservePulse(key, value)` - Create pulses that survive module replacement
|
|
310
|
+
- `setup(callback)` - Execute with effect tracking for cleanup
|
|
311
|
+
- Automatic event listener cleanup (no accumulation)
|
|
312
|
+
- Works with Vite dev server
|
|
313
|
+
|
|
314
|
+
### Logger
|
|
315
|
+
|
|
316
|
+
```javascript
|
|
317
|
+
import { createLogger, setLogLevel, LogLevel } from 'pulse-js-framework/runtime/logger';
|
|
318
|
+
|
|
319
|
+
// Create a namespaced logger
|
|
320
|
+
const log = createLogger('MyComponent');
|
|
321
|
+
|
|
322
|
+
log.info('Component initialized'); // [MyComponent] Component initialized
|
|
323
|
+
log.warn('Deprecated method used');
|
|
324
|
+
log.error('Failed to load', error);
|
|
325
|
+
log.debug('Detailed info'); // Only shown at DEBUG level
|
|
326
|
+
|
|
327
|
+
// Set global log level
|
|
328
|
+
setLogLevel(LogLevel.DEBUG); // SILENT, ERROR, WARN, INFO, DEBUG
|
|
329
|
+
|
|
330
|
+
// Child loggers for sub-namespaces
|
|
331
|
+
const childLog = log.child('Validation');
|
|
332
|
+
childLog.info('Validating'); // [MyComponent:Validation] Validating
|
|
333
|
+
```
|
|
334
|
+
|
|
283
335
|
## CLI Commands
|
|
284
336
|
|
|
285
337
|
```bash
|
|
@@ -399,6 +451,28 @@ onNativeReady(({ platform }) => {
|
|
|
399
451
|
|
|
400
452
|
**Available APIs:** Storage, Device Info, Network Status, Toast, Vibration, Clipboard, App Lifecycle
|
|
401
453
|
|
|
454
|
+
## VSCode Extension
|
|
455
|
+
|
|
456
|
+
Pulse includes a VSCode extension for `.pulse` files with syntax highlighting and snippets.
|
|
457
|
+
|
|
458
|
+
### Installation
|
|
459
|
+
|
|
460
|
+
```bash
|
|
461
|
+
# Windows (PowerShell)
|
|
462
|
+
cd vscode-extension
|
|
463
|
+
powershell -ExecutionPolicy Bypass -File install.ps1
|
|
464
|
+
|
|
465
|
+
# macOS/Linux
|
|
466
|
+
cd vscode-extension
|
|
467
|
+
bash install.sh
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
Then restart VSCode. You'll get:
|
|
471
|
+
- Syntax highlighting for `.pulse` files
|
|
472
|
+
- Code snippets (`page`, `state`, `view`, `@click`, etc.)
|
|
473
|
+
- Bracket matching and auto-closing
|
|
474
|
+
- Comment toggling (Ctrl+/)
|
|
475
|
+
|
|
402
476
|
## TypeScript Support
|
|
403
477
|
|
|
404
478
|
Pulse includes full TypeScript definitions for IDE autocomplete and type checking:
|
package/loader/vite-plugin.js
CHANGED
|
@@ -69,22 +69,30 @@ export default function pulsePlugin(options = {}) {
|
|
|
69
69
|
/**
|
|
70
70
|
* Handle hot module replacement
|
|
71
71
|
*/
|
|
72
|
-
handleHotUpdate({ file, server }) {
|
|
72
|
+
handleHotUpdate({ file, server, modules }) {
|
|
73
73
|
if (file.endsWith('.pulse')) {
|
|
74
74
|
console.log(`[Pulse] HMR update: ${file}`);
|
|
75
75
|
|
|
76
|
-
// Invalidate the module
|
|
76
|
+
// Invalidate the module in Vite's module graph
|
|
77
77
|
const module = server.moduleGraph.getModuleById(file);
|
|
78
78
|
if (module) {
|
|
79
79
|
server.moduleGraph.invalidateModule(module);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
// Send full reload
|
|
83
|
-
//
|
|
82
|
+
// Send HMR update instead of full reload
|
|
83
|
+
// The module will handle its own state preservation via hmrRuntime
|
|
84
84
|
server.ws.send({
|
|
85
|
-
type: '
|
|
86
|
-
|
|
85
|
+
type: 'update',
|
|
86
|
+
updates: [{
|
|
87
|
+
type: 'js-update',
|
|
88
|
+
path: file,
|
|
89
|
+
acceptedPath: file,
|
|
90
|
+
timestamp: Date.now()
|
|
91
|
+
}]
|
|
87
92
|
});
|
|
93
|
+
|
|
94
|
+
// Return empty array to prevent Vite's default HMR handling
|
|
95
|
+
return [];
|
|
88
96
|
}
|
|
89
97
|
},
|
|
90
98
|
|
|
@@ -117,6 +125,14 @@ export default function pulsePlugin(options = {}) {
|
|
|
117
125
|
*/
|
|
118
126
|
export const hmrRuntime = `
|
|
119
127
|
if (import.meta.hot) {
|
|
128
|
+
// Cleanup effects before module replacement
|
|
129
|
+
import.meta.hot.dispose(() => {
|
|
130
|
+
import('pulse-js-framework/runtime/pulse').then(m => {
|
|
131
|
+
m.disposeModule(import.meta.url);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Accept HMR updates
|
|
120
136
|
import.meta.hot.accept((newModule) => {
|
|
121
137
|
if (newModule) {
|
|
122
138
|
// Re-render with new module
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pulse-js-framework",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.6",
|
|
4
4
|
"description": "A declarative DOM framework with CSS selector-based structure and reactive pulsations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -41,6 +41,9 @@
|
|
|
41
41
|
"types": "./types/logger.d.ts",
|
|
42
42
|
"default": "./runtime/logger.js"
|
|
43
43
|
},
|
|
44
|
+
"./runtime/hmr": {
|
|
45
|
+
"default": "./runtime/hmr.js"
|
|
46
|
+
},
|
|
44
47
|
"./compiler": {
|
|
45
48
|
"types": "./types/index.d.ts",
|
|
46
49
|
"default": "./compiler/index.js"
|
|
@@ -67,12 +70,13 @@
|
|
|
67
70
|
"LICENSE"
|
|
68
71
|
],
|
|
69
72
|
"scripts": {
|
|
70
|
-
"test": "npm run test:compiler && npm run test:pulse && npm run test:dom && npm run test:router && npm run test:store && npm run test:lint && npm run test:format && npm run test:analyze",
|
|
73
|
+
"test": "npm run test:compiler && npm run test:pulse && npm run test:dom && npm run test:router && npm run test:store && npm run test:hmr && npm run test:lint && npm run test:format && npm run test:analyze",
|
|
71
74
|
"test:compiler": "node test/compiler.test.js",
|
|
72
75
|
"test:pulse": "node test/pulse.test.js",
|
|
73
76
|
"test:dom": "node test/dom.test.js",
|
|
74
77
|
"test:router": "node test/router.test.js",
|
|
75
78
|
"test:store": "node test/store.test.js",
|
|
79
|
+
"test:hmr": "node test/hmr.test.js",
|
|
76
80
|
"test:lint": "node test/lint.test.js",
|
|
77
81
|
"test:format": "node test/format.test.js",
|
|
78
82
|
"test:analyze": "node test/analyze.test.js",
|
package/runtime/dom.js
CHANGED
|
@@ -10,6 +10,10 @@ import { loggers } from './logger.js';
|
|
|
10
10
|
|
|
11
11
|
const log = loggers.dom;
|
|
12
12
|
|
|
13
|
+
// Selector cache for parseSelector
|
|
14
|
+
const selectorCache = new Map();
|
|
15
|
+
const SELECTOR_CACHE_MAX = 500;
|
|
16
|
+
|
|
13
17
|
// Lifecycle tracking
|
|
14
18
|
let mountCallbacks = [];
|
|
15
19
|
let unmountCallbacks = [];
|
|
@@ -41,6 +45,7 @@ export function onUnmount(fn) {
|
|
|
41
45
|
/**
|
|
42
46
|
* Parse a CSS selector-like string into element configuration
|
|
43
47
|
* Supports: tag, #id, .class, [attr=value]
|
|
48
|
+
* Results are cached for performance.
|
|
44
49
|
*
|
|
45
50
|
* Examples:
|
|
46
51
|
* "div" -> { tag: "div" }
|
|
@@ -50,6 +55,22 @@ export function onUnmount(fn) {
|
|
|
50
55
|
* "input[type=text][placeholder=Name]" -> { tag: "input", attrs: { type: "text", placeholder: "Name" } }
|
|
51
56
|
*/
|
|
52
57
|
export function parseSelector(selector) {
|
|
58
|
+
if (!selector || selector === '') {
|
|
59
|
+
return { tag: 'div', id: null, classes: [], attrs: {} };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Check cache first
|
|
63
|
+
const cached = selectorCache.get(selector);
|
|
64
|
+
if (cached) {
|
|
65
|
+
// Return a shallow copy to prevent mutation
|
|
66
|
+
return {
|
|
67
|
+
tag: cached.tag,
|
|
68
|
+
id: cached.id,
|
|
69
|
+
classes: [...cached.classes],
|
|
70
|
+
attrs: { ...cached.attrs }
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
53
74
|
const config = {
|
|
54
75
|
tag: 'div',
|
|
55
76
|
id: null,
|
|
@@ -57,30 +78,30 @@ export function parseSelector(selector) {
|
|
|
57
78
|
attrs: {}
|
|
58
79
|
};
|
|
59
80
|
|
|
60
|
-
|
|
81
|
+
let remaining = selector;
|
|
61
82
|
|
|
62
83
|
// Match tag name at the start
|
|
63
|
-
const tagMatch =
|
|
84
|
+
const tagMatch = remaining.match(/^([a-zA-Z][a-zA-Z0-9-]*)/);
|
|
64
85
|
if (tagMatch) {
|
|
65
86
|
config.tag = tagMatch[1];
|
|
66
|
-
|
|
87
|
+
remaining = remaining.slice(tagMatch[0].length);
|
|
67
88
|
}
|
|
68
89
|
|
|
69
90
|
// Match ID
|
|
70
|
-
const idMatch =
|
|
91
|
+
const idMatch = remaining.match(/#([a-zA-Z][a-zA-Z0-9-_]*)/);
|
|
71
92
|
if (idMatch) {
|
|
72
93
|
config.id = idMatch[1];
|
|
73
|
-
|
|
94
|
+
remaining = remaining.replace(idMatch[0], '');
|
|
74
95
|
}
|
|
75
96
|
|
|
76
97
|
// Match classes
|
|
77
|
-
const classMatches =
|
|
98
|
+
const classMatches = remaining.matchAll(/\.([a-zA-Z][a-zA-Z0-9-_]*)/g);
|
|
78
99
|
for (const match of classMatches) {
|
|
79
100
|
config.classes.push(match[1]);
|
|
80
101
|
}
|
|
81
102
|
|
|
82
103
|
// Match attributes
|
|
83
|
-
const attrMatches =
|
|
104
|
+
const attrMatches = remaining.matchAll(/\[([a-zA-Z][a-zA-Z0-9-_]*)(?:=([^\]]+))?\]/g);
|
|
84
105
|
for (const match of attrMatches) {
|
|
85
106
|
const key = match[1];
|
|
86
107
|
let value = match[2] || '';
|
|
@@ -92,7 +113,21 @@ export function parseSelector(selector) {
|
|
|
92
113
|
config.attrs[key] = value;
|
|
93
114
|
}
|
|
94
115
|
|
|
95
|
-
|
|
116
|
+
// Cache the result (with size limit to prevent memory leaks)
|
|
117
|
+
if (selectorCache.size >= SELECTOR_CACHE_MAX) {
|
|
118
|
+
// Remove oldest entry (first key)
|
|
119
|
+
const firstKey = selectorCache.keys().next().value;
|
|
120
|
+
selectorCache.delete(firstKey);
|
|
121
|
+
}
|
|
122
|
+
selectorCache.set(selector, config);
|
|
123
|
+
|
|
124
|
+
// Return a copy
|
|
125
|
+
return {
|
|
126
|
+
tag: config.tag,
|
|
127
|
+
id: config.id,
|
|
128
|
+
classes: [...config.classes],
|
|
129
|
+
attrs: { ...config.attrs }
|
|
130
|
+
};
|
|
96
131
|
}
|
|
97
132
|
|
|
98
133
|
/**
|
|
@@ -265,6 +300,12 @@ export function style(element, prop, getValue) {
|
|
|
265
300
|
*/
|
|
266
301
|
export function on(element, event, handler, options) {
|
|
267
302
|
element.addEventListener(event, handler, options);
|
|
303
|
+
|
|
304
|
+
// Auto-cleanup: remove listener when effect is disposed (HMR support)
|
|
305
|
+
onCleanup(() => {
|
|
306
|
+
element.removeEventListener(event, handler, options);
|
|
307
|
+
});
|
|
308
|
+
|
|
268
309
|
return element;
|
|
269
310
|
}
|
|
270
311
|
|
package/runtime/hmr.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HMR (Hot Module Replacement) utilities for Pulse framework
|
|
3
|
+
* @module pulse-js-framework/runtime/hmr
|
|
4
|
+
*
|
|
5
|
+
* Provides state preservation and effect cleanup during hot module replacement.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* import { createHMRContext } from 'pulse-js-framework/runtime/hmr';
|
|
9
|
+
*
|
|
10
|
+
* const hmr = createHMRContext(import.meta.url);
|
|
11
|
+
*
|
|
12
|
+
* // State preserved across HMR updates
|
|
13
|
+
* const count = hmr.preservePulse('count', 0);
|
|
14
|
+
*
|
|
15
|
+
* // Effects tracked for cleanup
|
|
16
|
+
* hmr.setup(() => {
|
|
17
|
+
* effect(() => console.log(count.get()));
|
|
18
|
+
* });
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { pulse } from './pulse.js';
|
|
22
|
+
import { setCurrentModule, clearCurrentModule, disposeModule } from './pulse.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {Object} HMRContext
|
|
26
|
+
* @property {Object} data - Persistent data storage across HMR updates
|
|
27
|
+
* @property {function(string, *, Object=): Pulse} preservePulse - Create a pulse with preserved state
|
|
28
|
+
* @property {function(function): *} setup - Execute code with module tracking
|
|
29
|
+
* @property {function(function): void} accept - Register HMR accept callback
|
|
30
|
+
* @property {function(function): void} dispose - Register HMR dispose callback
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Create an HMR context for a module.
|
|
35
|
+
* Provides utilities for state preservation and effect cleanup during HMR.
|
|
36
|
+
*
|
|
37
|
+
* @param {string} moduleId - The module identifier (typically import.meta.url)
|
|
38
|
+
* @returns {HMRContext} HMR context with preservation utilities
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* const hmr = createHMRContext(import.meta.url);
|
|
42
|
+
*
|
|
43
|
+
* // Preserve state across HMR
|
|
44
|
+
* const todos = hmr.preservePulse('todos', []);
|
|
45
|
+
* const filter = hmr.preservePulse('filter', 'all');
|
|
46
|
+
*
|
|
47
|
+
* // Setup effects with automatic cleanup
|
|
48
|
+
* hmr.setup(() => {
|
|
49
|
+
* effect(() => {
|
|
50
|
+
* document.title = `${todos.get().length} todos`;
|
|
51
|
+
* });
|
|
52
|
+
* });
|
|
53
|
+
*
|
|
54
|
+
* // Accept HMR updates
|
|
55
|
+
* hmr.accept();
|
|
56
|
+
*/
|
|
57
|
+
export function createHMRContext(moduleId) {
|
|
58
|
+
// Check if HMR is available (Vite dev server)
|
|
59
|
+
if (typeof import.meta === 'undefined' || !import.meta.hot) {
|
|
60
|
+
return createNoopContext();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const hot = import.meta.hot;
|
|
64
|
+
|
|
65
|
+
// Initialize data storage if not present
|
|
66
|
+
if (!hot.data) {
|
|
67
|
+
hot.data = {};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
/**
|
|
72
|
+
* Persistent data storage across HMR updates.
|
|
73
|
+
* Values stored here survive module reloads.
|
|
74
|
+
*/
|
|
75
|
+
data: hot.data,
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Create a pulse with state preservation across HMR updates.
|
|
79
|
+
* If a value exists from a previous module load, it's restored.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} key - Unique key for this pulse within the module
|
|
82
|
+
* @param {*} initialValue - Initial value (used on first load only)
|
|
83
|
+
* @param {Object} [options] - Pulse options (equals function, etc.)
|
|
84
|
+
* @returns {Pulse} A pulse instance with preserved state
|
|
85
|
+
*/
|
|
86
|
+
preservePulse(key, initialValue, options) {
|
|
87
|
+
const fullKey = `__pulse_${key}`;
|
|
88
|
+
|
|
89
|
+
// Check if we have a preserved value from previous load
|
|
90
|
+
if (fullKey in hot.data) {
|
|
91
|
+
const p = pulse(hot.data[fullKey], options);
|
|
92
|
+
// Register to save state on next HMR update
|
|
93
|
+
hot.dispose(() => {
|
|
94
|
+
hot.data[fullKey] = p.peek();
|
|
95
|
+
});
|
|
96
|
+
return p;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// First load - create new pulse with initial value
|
|
100
|
+
const p = pulse(initialValue, options);
|
|
101
|
+
// Register to save state on HMR update
|
|
102
|
+
hot.dispose(() => {
|
|
103
|
+
hot.data[fullKey] = p.peek();
|
|
104
|
+
});
|
|
105
|
+
return p;
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Execute code with module tracking enabled.
|
|
110
|
+
* Effects created within this callback will be registered
|
|
111
|
+
* for automatic cleanup during HMR.
|
|
112
|
+
*
|
|
113
|
+
* @param {function} callback - Code to execute with tracking
|
|
114
|
+
* @returns {*} The return value of the callback
|
|
115
|
+
*/
|
|
116
|
+
setup(callback) {
|
|
117
|
+
setCurrentModule(moduleId);
|
|
118
|
+
try {
|
|
119
|
+
return callback();
|
|
120
|
+
} finally {
|
|
121
|
+
clearCurrentModule();
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Register a callback to run when the module accepts an HMR update.
|
|
127
|
+
*
|
|
128
|
+
* @param {function} [callback] - Optional callback for custom handling
|
|
129
|
+
*/
|
|
130
|
+
accept(callback) {
|
|
131
|
+
if (callback) {
|
|
132
|
+
hot.accept(callback);
|
|
133
|
+
} else {
|
|
134
|
+
hot.accept();
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Register a callback to run before the module is replaced.
|
|
140
|
+
* Use this for custom cleanup logic.
|
|
141
|
+
*
|
|
142
|
+
* @param {function} callback - Cleanup callback
|
|
143
|
+
*/
|
|
144
|
+
dispose(callback) {
|
|
145
|
+
hot.dispose(callback);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Create a no-op HMR context for production or non-HMR environments.
|
|
152
|
+
* All methods work normally but without HMR-specific behavior.
|
|
153
|
+
*
|
|
154
|
+
* @returns {HMRContext} A no-op HMR context
|
|
155
|
+
* @private
|
|
156
|
+
*/
|
|
157
|
+
function createNoopContext() {
|
|
158
|
+
return {
|
|
159
|
+
data: {},
|
|
160
|
+
preservePulse: (key, initialValue, options) => pulse(initialValue, options),
|
|
161
|
+
setup: (callback) => callback(),
|
|
162
|
+
accept: () => {},
|
|
163
|
+
dispose: () => {}
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export default {
|
|
168
|
+
createHMRContext
|
|
169
|
+
};
|
package/runtime/pulse.js
CHANGED
|
@@ -22,17 +22,96 @@ import { loggers } from './logger.js';
|
|
|
22
22
|
|
|
23
23
|
const log = loggers.pulse;
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {Object} ReactiveContext
|
|
27
|
+
* @property {EffectFn|null} currentEffect - Currently executing effect for dependency tracking
|
|
28
|
+
* @property {number} batchDepth - Nesting depth of batch() calls
|
|
29
|
+
* @property {Set<EffectFn>} pendingEffects - Effects queued during batch
|
|
30
|
+
* @property {boolean} isRunningEffects - Flag to prevent recursive effect flushing
|
|
31
|
+
* @property {string|null} currentModuleId - Current module ID for HMR effect tracking
|
|
32
|
+
* @property {Map<string, Set<EffectFn>>} effectRegistry - Module ID to effects mapping for HMR
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Global reactive context - holds all tracking state.
|
|
37
|
+
* Exported for testing purposes (use resetContext() to reset).
|
|
38
|
+
* @type {ReactiveContext}
|
|
39
|
+
*/
|
|
40
|
+
export const context = {
|
|
41
|
+
currentEffect: null,
|
|
42
|
+
batchDepth: 0,
|
|
43
|
+
pendingEffects: new Set(),
|
|
44
|
+
isRunningEffects: false,
|
|
45
|
+
// HMR support
|
|
46
|
+
currentModuleId: null,
|
|
47
|
+
effectRegistry: new Map()
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Reset the reactive context to initial state.
|
|
52
|
+
* Use this in tests to ensure isolation between test cases.
|
|
53
|
+
* @returns {void}
|
|
54
|
+
* @example
|
|
55
|
+
* // In test setup/teardown
|
|
56
|
+
* import { resetContext } from 'pulse-js-framework/runtime/pulse';
|
|
57
|
+
* beforeEach(() => resetContext());
|
|
58
|
+
*/
|
|
59
|
+
export function resetContext() {
|
|
60
|
+
context.currentEffect = null;
|
|
61
|
+
context.batchDepth = 0;
|
|
62
|
+
context.pendingEffects.clear();
|
|
63
|
+
context.isRunningEffects = false;
|
|
64
|
+
context.currentModuleId = null;
|
|
65
|
+
context.effectRegistry.clear();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Set the current module ID for HMR effect tracking.
|
|
70
|
+
* Effects created while a module ID is set will be registered for cleanup.
|
|
71
|
+
* @param {string} moduleId - The module identifier (typically import.meta.url)
|
|
72
|
+
* @returns {void}
|
|
73
|
+
*/
|
|
74
|
+
export function setCurrentModule(moduleId) {
|
|
75
|
+
context.currentModuleId = moduleId;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Clear the current module ID after module initialization.
|
|
80
|
+
* @returns {void}
|
|
81
|
+
*/
|
|
82
|
+
export function clearCurrentModule() {
|
|
83
|
+
context.currentModuleId = null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Dispose all effects associated with a module.
|
|
88
|
+
* Called during HMR to clean up before re-executing the module.
|
|
89
|
+
* @param {string} moduleId - The module identifier to dispose
|
|
90
|
+
* @returns {void}
|
|
91
|
+
*/
|
|
92
|
+
export function disposeModule(moduleId) {
|
|
93
|
+
const effects = context.effectRegistry.get(moduleId);
|
|
94
|
+
if (effects) {
|
|
95
|
+
for (const effectFn of effects) {
|
|
96
|
+
// Run cleanup functions
|
|
97
|
+
for (const cleanup of effectFn.cleanups) {
|
|
98
|
+
try {
|
|
99
|
+
cleanup();
|
|
100
|
+
} catch (e) {
|
|
101
|
+
log.error('HMR cleanup error:', e);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
effectFn.cleanups = [];
|
|
105
|
+
|
|
106
|
+
// Unsubscribe from all dependencies
|
|
107
|
+
for (const dep of effectFn.dependencies) {
|
|
108
|
+
dep._unsubscribe(effectFn);
|
|
109
|
+
}
|
|
110
|
+
effectFn.dependencies.clear();
|
|
111
|
+
}
|
|
112
|
+
context.effectRegistry.delete(moduleId);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
36
115
|
|
|
37
116
|
/**
|
|
38
117
|
* @typedef {Object} EffectFn
|
|
@@ -88,8 +167,8 @@ let cleanupQueue = [];
|
|
|
88
167
|
* });
|
|
89
168
|
*/
|
|
90
169
|
export function onCleanup(fn) {
|
|
91
|
-
if (currentEffect) {
|
|
92
|
-
currentEffect.cleanups.push(fn);
|
|
170
|
+
if (context.currentEffect) {
|
|
171
|
+
context.currentEffect.cleanups.push(fn);
|
|
93
172
|
}
|
|
94
173
|
}
|
|
95
174
|
|
|
@@ -126,9 +205,9 @@ export class Pulse {
|
|
|
126
205
|
* });
|
|
127
206
|
*/
|
|
128
207
|
get() {
|
|
129
|
-
if (currentEffect) {
|
|
130
|
-
this.#subscribers.add(currentEffect);
|
|
131
|
-
currentEffect.dependencies.add(this);
|
|
208
|
+
if (context.currentEffect) {
|
|
209
|
+
this.#subscribers.add(context.currentEffect);
|
|
210
|
+
context.currentEffect.dependencies.add(this);
|
|
132
211
|
}
|
|
133
212
|
return this.#value;
|
|
134
213
|
}
|
|
@@ -199,8 +278,8 @@ export class Pulse {
|
|
|
199
278
|
const subs = [...this.#subscribers];
|
|
200
279
|
|
|
201
280
|
for (const subscriber of subs) {
|
|
202
|
-
if (batchDepth > 0 || isRunningEffects) {
|
|
203
|
-
pendingEffects.add(subscriber);
|
|
281
|
+
if (context.batchDepth > 0 || context.isRunningEffects) {
|
|
282
|
+
context.pendingEffects.add(subscriber);
|
|
204
283
|
} else {
|
|
205
284
|
runEffect(subscriber);
|
|
206
285
|
}
|
|
@@ -292,17 +371,17 @@ function runEffect(effectFn) {
|
|
|
292
371
|
* @returns {void}
|
|
293
372
|
*/
|
|
294
373
|
function flushEffects() {
|
|
295
|
-
if (isRunningEffects) return;
|
|
374
|
+
if (context.isRunningEffects) return;
|
|
296
375
|
|
|
297
|
-
isRunningEffects = true;
|
|
376
|
+
context.isRunningEffects = true;
|
|
298
377
|
let iterations = 0;
|
|
299
378
|
const maxIterations = 100; // Prevent infinite loops
|
|
300
379
|
|
|
301
380
|
try {
|
|
302
|
-
while (pendingEffects.size > 0 && iterations < maxIterations) {
|
|
381
|
+
while (context.pendingEffects.size > 0 && iterations < maxIterations) {
|
|
303
382
|
iterations++;
|
|
304
|
-
const effects = [...pendingEffects];
|
|
305
|
-
pendingEffects.clear();
|
|
383
|
+
const effects = [...context.pendingEffects];
|
|
384
|
+
context.pendingEffects.clear();
|
|
306
385
|
|
|
307
386
|
for (const effect of effects) {
|
|
308
387
|
runEffect(effect);
|
|
@@ -311,10 +390,10 @@ function flushEffects() {
|
|
|
311
390
|
|
|
312
391
|
if (iterations >= maxIterations) {
|
|
313
392
|
log.warn('Maximum effect iterations reached. Possible infinite loop.');
|
|
314
|
-
pendingEffects.clear();
|
|
393
|
+
context.pendingEffects.clear();
|
|
315
394
|
}
|
|
316
395
|
} finally {
|
|
317
|
-
isRunningEffects = false;
|
|
396
|
+
context.isRunningEffects = false;
|
|
318
397
|
}
|
|
319
398
|
}
|
|
320
399
|
|
|
@@ -371,13 +450,13 @@ export function computed(fn, options = {}) {
|
|
|
371
450
|
p.get = function() {
|
|
372
451
|
if (dirty) {
|
|
373
452
|
// Run computation
|
|
374
|
-
const prevEffect = currentEffect;
|
|
453
|
+
const prevEffect = context.currentEffect;
|
|
375
454
|
const tempEffect = {
|
|
376
455
|
run: () => {},
|
|
377
456
|
dependencies: new Set(),
|
|
378
457
|
cleanups: []
|
|
379
458
|
};
|
|
380
|
-
currentEffect = tempEffect;
|
|
459
|
+
context.currentEffect = tempEffect;
|
|
381
460
|
|
|
382
461
|
try {
|
|
383
462
|
cachedValue = fn();
|
|
@@ -400,14 +479,14 @@ export function computed(fn, options = {}) {
|
|
|
400
479
|
|
|
401
480
|
p._init(cachedValue);
|
|
402
481
|
} finally {
|
|
403
|
-
currentEffect = prevEffect;
|
|
482
|
+
context.currentEffect = prevEffect;
|
|
404
483
|
}
|
|
405
484
|
}
|
|
406
485
|
|
|
407
486
|
// Track dependency on this computed
|
|
408
|
-
if (currentEffect) {
|
|
409
|
-
p._addSubscriber(currentEffect);
|
|
410
|
-
currentEffect.dependencies.add(p);
|
|
487
|
+
if (context.currentEffect) {
|
|
488
|
+
p._addSubscriber(context.currentEffect);
|
|
489
|
+
context.currentEffect.dependencies.add(p);
|
|
411
490
|
}
|
|
412
491
|
|
|
413
492
|
return cachedValue;
|
|
@@ -467,6 +546,9 @@ export function computed(fn, options = {}) {
|
|
|
467
546
|
* });
|
|
468
547
|
*/
|
|
469
548
|
export function effect(fn) {
|
|
549
|
+
// Capture module ID at creation time for HMR tracking
|
|
550
|
+
const moduleId = context.currentModuleId;
|
|
551
|
+
|
|
470
552
|
const effectFn = {
|
|
471
553
|
run: () => {
|
|
472
554
|
// Run cleanup functions from previous run
|
|
@@ -486,21 +568,29 @@ export function effect(fn) {
|
|
|
486
568
|
effectFn.dependencies.clear();
|
|
487
569
|
|
|
488
570
|
// Set as current effect for dependency tracking
|
|
489
|
-
const prevEffect = currentEffect;
|
|
490
|
-
currentEffect = effectFn;
|
|
571
|
+
const prevEffect = context.currentEffect;
|
|
572
|
+
context.currentEffect = effectFn;
|
|
491
573
|
|
|
492
574
|
try {
|
|
493
575
|
fn();
|
|
494
576
|
} catch (error) {
|
|
495
577
|
log.error('Effect execution error:', error);
|
|
496
578
|
} finally {
|
|
497
|
-
currentEffect = prevEffect;
|
|
579
|
+
context.currentEffect = prevEffect;
|
|
498
580
|
}
|
|
499
581
|
},
|
|
500
582
|
dependencies: new Set(),
|
|
501
583
|
cleanups: []
|
|
502
584
|
};
|
|
503
585
|
|
|
586
|
+
// HMR: Register effect with current module
|
|
587
|
+
if (moduleId) {
|
|
588
|
+
if (!context.effectRegistry.has(moduleId)) {
|
|
589
|
+
context.effectRegistry.set(moduleId, new Set());
|
|
590
|
+
}
|
|
591
|
+
context.effectRegistry.get(moduleId).add(effectFn);
|
|
592
|
+
}
|
|
593
|
+
|
|
504
594
|
// Run immediately to collect dependencies
|
|
505
595
|
effectFn.run();
|
|
506
596
|
|
|
@@ -511,7 +601,7 @@ export function effect(fn) {
|
|
|
511
601
|
try {
|
|
512
602
|
cleanup();
|
|
513
603
|
} catch (e) {
|
|
514
|
-
|
|
604
|
+
log.error('Cleanup error:', e);
|
|
515
605
|
}
|
|
516
606
|
}
|
|
517
607
|
effectFn.cleanups = [];
|
|
@@ -520,6 +610,11 @@ export function effect(fn) {
|
|
|
520
610
|
dep._unsubscribe(effectFn);
|
|
521
611
|
}
|
|
522
612
|
effectFn.dependencies.clear();
|
|
613
|
+
|
|
614
|
+
// HMR: Remove from registry
|
|
615
|
+
if (moduleId && context.effectRegistry.has(moduleId)) {
|
|
616
|
+
context.effectRegistry.get(moduleId).delete(effectFn);
|
|
617
|
+
}
|
|
523
618
|
};
|
|
524
619
|
}
|
|
525
620
|
|
|
@@ -545,12 +640,12 @@ export function effect(fn) {
|
|
|
545
640
|
* });
|
|
546
641
|
*/
|
|
547
642
|
export function batch(fn) {
|
|
548
|
-
batchDepth++;
|
|
643
|
+
context.batchDepth++;
|
|
549
644
|
try {
|
|
550
645
|
return fn();
|
|
551
646
|
} finally {
|
|
552
|
-
batchDepth--;
|
|
553
|
-
if (batchDepth === 0) {
|
|
647
|
+
context.batchDepth--;
|
|
648
|
+
if (context.batchDepth === 0) {
|
|
554
649
|
flushEffects();
|
|
555
650
|
}
|
|
556
651
|
}
|
|
@@ -831,12 +926,12 @@ export function fromPromise(promise, initialValue = undefined) {
|
|
|
831
926
|
* // Effect only re-runs when aSignal changes, not bSignal
|
|
832
927
|
*/
|
|
833
928
|
export function untrack(fn) {
|
|
834
|
-
const prevEffect = currentEffect;
|
|
835
|
-
currentEffect = null;
|
|
929
|
+
const prevEffect = context.currentEffect;
|
|
930
|
+
context.currentEffect = null;
|
|
836
931
|
try {
|
|
837
932
|
return fn();
|
|
838
933
|
} finally {
|
|
839
|
-
currentEffect = prevEffect;
|
|
934
|
+
context.currentEffect = prevEffect;
|
|
840
935
|
}
|
|
841
936
|
}
|
|
842
937
|
|
|
@@ -852,5 +947,11 @@ export default {
|
|
|
852
947
|
untrack,
|
|
853
948
|
onCleanup,
|
|
854
949
|
memo,
|
|
855
|
-
memoComputed
|
|
950
|
+
memoComputed,
|
|
951
|
+
context,
|
|
952
|
+
resetContext,
|
|
953
|
+
// HMR support
|
|
954
|
+
setCurrentModule,
|
|
955
|
+
clearCurrentModule,
|
|
956
|
+
disposeModule
|
|
856
957
|
};
|
package/types/index.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export {
|
|
|
13
13
|
EqualsFn,
|
|
14
14
|
ReactiveState,
|
|
15
15
|
PromiseState,
|
|
16
|
+
EffectFn,
|
|
17
|
+
ReactiveContext,
|
|
16
18
|
pulse,
|
|
17
19
|
computed,
|
|
18
20
|
effect,
|
|
@@ -23,7 +25,9 @@ export {
|
|
|
23
25
|
memoComputed,
|
|
24
26
|
fromPromise,
|
|
25
27
|
untrack,
|
|
26
|
-
onCleanup
|
|
28
|
+
onCleanup,
|
|
29
|
+
context,
|
|
30
|
+
resetContext
|
|
27
31
|
} from './pulse';
|
|
28
32
|
|
|
29
33
|
// DOM Helpers
|
package/types/pulse.d.ts
CHANGED
|
@@ -147,3 +147,36 @@ export declare function untrack<T>(fn: () => T): T;
|
|
|
147
147
|
* Register cleanup function for current effect
|
|
148
148
|
*/
|
|
149
149
|
export declare function onCleanup(fn: () => void): void;
|
|
150
|
+
|
|
151
|
+
/** Effect function with dependency tracking */
|
|
152
|
+
export interface EffectFn {
|
|
153
|
+
run: () => void;
|
|
154
|
+
dependencies: Set<Pulse>;
|
|
155
|
+
cleanups: (() => void)[];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Reactive context - holds global tracking state.
|
|
160
|
+
* Exposed for testing and advanced use cases.
|
|
161
|
+
*/
|
|
162
|
+
export interface ReactiveContext {
|
|
163
|
+
/** Currently executing effect for dependency tracking */
|
|
164
|
+
currentEffect: EffectFn | null;
|
|
165
|
+
/** Nesting depth of batch() calls */
|
|
166
|
+
batchDepth: number;
|
|
167
|
+
/** Effects queued during batch */
|
|
168
|
+
pendingEffects: Set<EffectFn>;
|
|
169
|
+
/** Flag to prevent recursive effect flushing */
|
|
170
|
+
isRunningEffects: boolean;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Global reactive context
|
|
175
|
+
*/
|
|
176
|
+
export declare const context: ReactiveContext;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Reset the reactive context to initial state.
|
|
180
|
+
* Use this in tests to ensure isolation between test cases.
|
|
181
|
+
*/
|
|
182
|
+
export declare function resetContext(): void;
|