pulse-js-framework 1.4.5 → 1.4.7

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
@@ -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:
@@ -422,6 +496,7 @@ Types are automatically detected by IDEs (VS Code, WebStorm) without additional
422
496
 
423
497
  ## Examples
424
498
 
499
+ - [HMR Demo](examples/hmr) - Hot Module Replacement with `.pulse` components and state preservation
425
500
  - [Blog](examples/blog) - Full blog app with CRUD, categories, search, dark mode
426
501
  - [Todo App](examples/todo) - Task management with filters and persistence
427
502
  - [Chat App](examples/chat) - Real-time messaging interface
@@ -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 for now
83
- // In a more advanced implementation, we could do partial updates
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: 'full-reload',
86
- path: '*'
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.5",
3
+ "version": "1.4.7",
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
- if (!selector || selector === '') return config;
81
+ let remaining = selector;
61
82
 
62
83
  // Match tag name at the start
63
- const tagMatch = selector.match(/^([a-zA-Z][a-zA-Z0-9-]*)/);
84
+ const tagMatch = remaining.match(/^([a-zA-Z][a-zA-Z0-9-]*)/);
64
85
  if (tagMatch) {
65
86
  config.tag = tagMatch[1];
66
- selector = selector.slice(tagMatch[0].length);
87
+ remaining = remaining.slice(tagMatch[0].length);
67
88
  }
68
89
 
69
90
  // Match ID
70
- const idMatch = selector.match(/#([a-zA-Z][a-zA-Z0-9-_]*)/);
91
+ const idMatch = remaining.match(/#([a-zA-Z][a-zA-Z0-9-_]*)/);
71
92
  if (idMatch) {
72
93
  config.id = idMatch[1];
73
- selector = selector.replace(idMatch[0], '');
94
+ remaining = remaining.replace(idMatch[0], '');
74
95
  }
75
96
 
76
97
  // Match classes
77
- const classMatches = selector.matchAll(/\.([a-zA-Z][a-zA-Z0-9-_]*)/g);
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 = selector.matchAll(/\[([a-zA-Z][a-zA-Z0-9-_]*)(?:=([^\]]+))?\]/g);
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
- return config;
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
- // Current tracking context for automatic dependency collection
26
- /** @type {EffectFn|null} */
27
- let currentEffect = null;
28
- /** @type {number} */
29
- let batchDepth = 0;
30
- /** @type {Set<EffectFn>} */
31
- let pendingEffects = new Set();
32
- /** @type {boolean} */
33
- let isRunningEffects = false;
34
- /** @type {Array<Function>} */
35
- let cleanupQueue = [];
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
- console.error('Cleanup error:', e);
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;