pulse-js-framework 1.5.1 → 1.5.2

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/cli/index.js CHANGED
@@ -87,6 +87,7 @@ Release Options:
87
87
  --no-push Create commit and tag but don't push
88
88
  --title <text> Release title for changelog
89
89
  --skip-prompt Use empty changelog (for automation)
90
+ --from-commits Auto-extract changelog from git commits since last tag
90
91
 
91
92
  Examples:
92
93
  pulse create my-app
@@ -107,6 +108,7 @@ Examples:
107
108
  pulse release patch
108
109
  pulse release minor --title "New Features"
109
110
  pulse release major --dry-run
111
+ pulse release patch --from-commits
110
112
 
111
113
  Documentation: https://github.com/vincenthirtz/pulse-js-framework
112
114
  `);
package/cli/release.js CHANGED
@@ -113,6 +113,85 @@ function getCurrentMonthYear() {
113
113
  return `${months[now.getMonth()]} ${now.getFullYear()}`;
114
114
  }
115
115
 
116
+ /**
117
+ * Get the last git tag
118
+ */
119
+ function getLastTag() {
120
+ try {
121
+ return execSync('git describe --tags --abbrev=0', { cwd: root, encoding: 'utf-8' }).trim();
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Get commits since the last tag (or all commits if no tag exists)
129
+ */
130
+ function getCommitsSinceLastTag() {
131
+ const lastTag = getLastTag();
132
+ const range = lastTag ? `${lastTag}..HEAD` : 'HEAD';
133
+
134
+ try {
135
+ const output = execSync(`git log ${range} --pretty=format:"%s"`, {
136
+ cwd: root,
137
+ encoding: 'utf-8'
138
+ });
139
+ return output.split('\n').filter(line => line.trim());
140
+ } catch {
141
+ return [];
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Parse commit messages into changelog categories
147
+ * Supports conventional commits: feat:, fix:, docs:, chore:, refactor:, perf:, test:, style:
148
+ */
149
+ function parseCommitMessages(commits) {
150
+ const changes = { added: [], changed: [], fixed: [], removed: [] };
151
+
152
+ for (const commit of commits) {
153
+ const lowerCommit = commit.toLowerCase();
154
+
155
+ // Skip version commits (e.g., "v1.5.0")
156
+ if (/^v?\d+\.\d+\.\d+/.test(commit)) continue;
157
+
158
+ // Skip merge commits
159
+ if (lowerCommit.startsWith('merge ')) continue;
160
+
161
+ // Parse conventional commits
162
+ if (lowerCommit.startsWith('feat:') || lowerCommit.startsWith('feat(')) {
163
+ changes.added.push(cleanCommitMessage(commit, 'feat'));
164
+ } else if (lowerCommit.startsWith('fix:') || lowerCommit.startsWith('fix(')) {
165
+ changes.fixed.push(cleanCommitMessage(commit, 'fix'));
166
+ } else if (lowerCommit.startsWith('remove') || lowerCommit.startsWith('deprecate')) {
167
+ changes.removed.push(commit);
168
+ } else if (
169
+ lowerCommit.startsWith('refactor:') ||
170
+ lowerCommit.startsWith('perf:') ||
171
+ lowerCommit.startsWith('chore:') ||
172
+ lowerCommit.startsWith('docs:') ||
173
+ lowerCommit.startsWith('style:') ||
174
+ lowerCommit.startsWith('test:')
175
+ ) {
176
+ changes.changed.push(cleanCommitMessage(commit, commit.split(':')[0]));
177
+ } else {
178
+ // Default: treat as a change
179
+ changes.changed.push(commit);
180
+ }
181
+ }
182
+
183
+ return changes;
184
+ }
185
+
186
+ /**
187
+ * Clean commit message by removing the conventional commit prefix
188
+ */
189
+ function cleanCommitMessage(message, prefix) {
190
+ // Remove "prefix:" or "prefix(scope):"
191
+ const regex = new RegExp(`^${prefix}(\\([^)]+\\))?:\\s*`, 'i');
192
+ return message.replace(regex, '').trim();
193
+ }
194
+
116
195
  /**
117
196
  * Update package.json version
118
197
  */
@@ -296,30 +375,76 @@ function updateReadme(newVersion) {
296
375
  }
297
376
 
298
377
  /**
299
- * Execute git commands
378
+ * Build commit message from version, title, and changes
300
379
  */
301
- function gitCommitTagPush(newVersion, dryRun = false) {
302
- const commands = [
303
- 'git add -A',
304
- `git commit -m "$(cat <<'EOF'\nv${newVersion}\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\nEOF\n)"`,
305
- `git tag -a v${newVersion} -m "Release v${newVersion}"`,
306
- 'git push',
307
- 'git push --tags'
380
+ function buildCommitMessage(newVersion, title, changes) {
381
+ // Build header: "v1.5.2 - Title" or just "v1.5.2"
382
+ let message = `v${newVersion}`;
383
+ if (title) {
384
+ message += ` - ${title}`;
385
+ }
386
+ message += '\n\n';
387
+
388
+ // Add change items as bullet points
389
+ const allChanges = [
390
+ ...(changes.added || []),
391
+ ...(changes.changed || []),
392
+ ...(changes.fixed || []),
393
+ ...(changes.removed || [])
308
394
  ];
309
395
 
310
- for (const cmd of commands) {
311
- if (dryRun) {
312
- log.info(` [dry-run] ${cmd}`);
313
- } else {
314
- log.info(` Running: ${cmd.split('\n')[0]}...`);
315
- try {
316
- execSync(cmd, { cwd: root, stdio: 'inherit', shell: '/bin/bash' });
317
- } catch (error) {
318
- log.error(` Failed: ${error.message}`);
319
- throw error;
320
- }
396
+ if (allChanges.length > 0) {
397
+ for (const change of allChanges) {
398
+ message += `- ${change}\n`;
321
399
  }
400
+ message += '\n';
322
401
  }
402
+
403
+ // Add co-author
404
+ message += 'Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>';
405
+
406
+ return message;
407
+ }
408
+
409
+ /**
410
+ * Execute git commands
411
+ */
412
+ function gitCommitTagPush(newVersion, title, changes, dryRun = false) {
413
+ const commitMessage = buildCommitMessage(newVersion, title, changes);
414
+
415
+ if (dryRun) {
416
+ log.info(' [dry-run] git add -A');
417
+ log.info(' [dry-run] git commit with message:');
418
+ log.info(' ' + commitMessage.split('\n').join('\n '));
419
+ log.info(` [dry-run] git tag -a v${newVersion} -m "Release v${newVersion}"`);
420
+ log.info(' [dry-run] git push');
421
+ log.info(' [dry-run] git push --tags');
422
+ return;
423
+ }
424
+
425
+ // git add
426
+ log.info(' Running: git add -A...');
427
+ execSync('git add -A', { cwd: root, stdio: 'inherit' });
428
+
429
+ // git commit with heredoc for proper message formatting
430
+ log.info(' Running: git commit...');
431
+ const escapedMessage = commitMessage.replace(/'/g, "'\\''");
432
+ execSync(`git commit -m $'${escapedMessage.replace(/\n/g, '\\n')}'`, {
433
+ cwd: root,
434
+ stdio: 'inherit',
435
+ shell: '/bin/bash'
436
+ });
437
+
438
+ // git tag
439
+ log.info(` Running: git tag v${newVersion}...`);
440
+ execSync(`git tag -a v${newVersion} -m "Release v${newVersion}"`, { cwd: root, stdio: 'inherit' });
441
+
442
+ // git push
443
+ log.info(' Running: git push...');
444
+ execSync('git push', { cwd: root, stdio: 'inherit' });
445
+
446
+ log.info(' Running: git push --tags...');
447
+ execSync('git push --tags', { cwd: root, stdio: 'inherit' });
323
448
  }
324
449
 
325
450
  /**
@@ -339,11 +464,13 @@ Options:
339
464
  --no-push Create commit and tag but don't push
340
465
  --title <text> Release title (e.g., "Performance Improvements")
341
466
  --skip-prompt Use empty changelog (for automated releases)
467
+ --from-commits Auto-extract changelog from git commits since last tag
342
468
 
343
469
  Examples:
344
470
  pulse release patch
345
471
  pulse release minor --title "New Features"
346
472
  pulse release major --dry-run
473
+ pulse release patch --from-commits --title "Bug Fixes"
347
474
  `);
348
475
  }
349
476
 
@@ -362,6 +489,7 @@ export async function runRelease(args) {
362
489
  const dryRun = args.includes('--dry-run');
363
490
  const noPush = args.includes('--no-push');
364
491
  const skipPrompt = args.includes('--skip-prompt');
492
+ const fromCommits = args.includes('--from-commits');
365
493
 
366
494
  let title = '';
367
495
  const titleIndex = args.indexOf('--title');
@@ -403,7 +531,33 @@ export async function runRelease(args) {
403
531
  // Collect changelog entries
404
532
  let changes = { added: [], changed: [], fixed: [], removed: [] };
405
533
 
406
- if (!skipPrompt) {
534
+ if (fromCommits) {
535
+ // Auto-extract from git commits since last tag
536
+ const lastTag = getLastTag();
537
+ const commits = getCommitsSinceLastTag();
538
+
539
+ if (commits.length === 0) {
540
+ log.warn('No commits found since last tag');
541
+ } else {
542
+ log.info(`Found ${commits.length} commits since ${lastTag || 'beginning'}`);
543
+ changes = parseCommitMessages(commits);
544
+
545
+ // Show extracted changes
546
+ const totalChanges = Object.values(changes).flat().length;
547
+ if (totalChanges > 0) {
548
+ log.info('');
549
+ log.info('Extracted changelog entries:');
550
+ if (changes.added.length) log.info(` Added: ${changes.added.length} items`);
551
+ if (changes.changed.length) log.info(` Changed: ${changes.changed.length} items`);
552
+ if (changes.fixed.length) log.info(` Fixed: ${changes.fixed.length} items`);
553
+ if (changes.removed.length) log.info(` Removed: ${changes.removed.length} items`);
554
+ }
555
+ }
556
+
557
+ if (!title) {
558
+ title = await prompt('Release title (e.g., "Performance Improvements"): ');
559
+ }
560
+ } else if (!skipPrompt) {
407
561
  if (!title) {
408
562
  title = await prompt('Release title (e.g., "Performance Improvements"): ');
409
563
  }
@@ -466,8 +620,10 @@ export async function runRelease(args) {
466
620
  if (!dryRun) {
467
621
  if (noPush) {
468
622
  // Only commit and tag, no push
623
+ const commitMessage = buildCommitMessage(newVersion, title, changes);
469
624
  execSync('git add -A', { cwd: root, stdio: 'inherit' });
470
- execSync(`git commit -m "v${newVersion}\n\nCo-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"`, {
625
+ const escapedMessage = commitMessage.replace(/'/g, "'\\''");
626
+ execSync(`git commit -m $'${escapedMessage.replace(/\n/g, '\\n')}'`, {
471
627
  cwd: root,
472
628
  stdio: 'inherit',
473
629
  shell: '/bin/bash'
@@ -475,10 +631,10 @@ export async function runRelease(args) {
475
631
  execSync(`git tag -a v${newVersion} -m "Release v${newVersion}"`, { cwd: root, stdio: 'inherit' });
476
632
  log.info(' Created commit and tag (--no-push specified)');
477
633
  } else {
478
- gitCommitTagPush(newVersion, false);
634
+ gitCommitTagPush(newVersion, title, changes, false);
479
635
  }
480
636
  } else {
481
- gitCommitTagPush(newVersion, true);
637
+ gitCommitTagPush(newVersion, title, changes, true);
482
638
  }
483
639
 
484
640
  log.info('');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pulse-js-framework",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
4
4
  "description": "A declarative DOM framework with CSS selector-based structure and reactive pulsations",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/runtime/pulse.js CHANGED
@@ -102,6 +102,63 @@ export function resetContext() {
102
102
  context.effectRegistry.clear();
103
103
  }
104
104
 
105
+ /**
106
+ * Counter for generating unique effect IDs
107
+ * @type {number}
108
+ */
109
+ let effectIdCounter = 0;
110
+
111
+ /**
112
+ * Global effect error handler
113
+ * @type {Function|null}
114
+ */
115
+ let globalEffectErrorHandler = null;
116
+
117
+ /**
118
+ * Custom error class for effect-related errors with context information.
119
+ * Provides details about which effect failed, in what phase, and its dependencies.
120
+ */
121
+ export class EffectError extends Error {
122
+ /**
123
+ * Create an EffectError with context information
124
+ * @param {string} message - Error message
125
+ * @param {Object} options - Error context
126
+ * @param {string} [options.effectId] - Effect identifier
127
+ * @param {string} [options.phase] - Phase when error occurred ('cleanup' | 'execution')
128
+ * @param {number} [options.dependencyCount] - Number of dependencies
129
+ * @param {Error} [options.cause] - Original error that caused this
130
+ */
131
+ constructor(message, options = {}) {
132
+ super(message);
133
+ this.name = 'EffectError';
134
+ this.effectId = options.effectId || null;
135
+ this.phase = options.phase || 'unknown';
136
+ this.dependencyCount = options.dependencyCount ?? 0;
137
+ this.cause = options.cause || null;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Set a global error handler for effect errors.
143
+ * The handler receives an EffectError with full context about the failure.
144
+ * @param {Function|null} handler - Error handler (effectError) => void, or null to clear
145
+ * @returns {Function|null} Previous handler (for restoration)
146
+ * @example
147
+ * // Set up global error tracking
148
+ * const prevHandler = onEffectError((err) => {
149
+ * console.error(`Effect ${err.effectId} failed during ${err.phase}:`, err.cause);
150
+ * reportToErrorService(err);
151
+ * });
152
+ *
153
+ * // Later, restore previous handler
154
+ * onEffectError(prevHandler);
155
+ */
156
+ export function onEffectError(handler) {
157
+ const prev = globalEffectErrorHandler;
158
+ globalEffectErrorHandler = handler;
159
+ return prev;
160
+ }
161
+
105
162
  /**
106
163
  * Set the current module ID for HMR effect tracking.
107
164
  * Effects created while a module ID is set will be registered for cleanup.
@@ -386,6 +443,52 @@ export class Pulse {
386
443
  }
387
444
  }
388
445
 
446
+ /**
447
+ * Handle an effect error with full context information.
448
+ * Tries effect-specific handler, then global handler, then logs.
449
+ * @private
450
+ * @param {Error} error - The original error
451
+ * @param {EffectFn} effectFn - The effect that errored
452
+ * @param {string} phase - Phase when error occurred ('cleanup' | 'execution')
453
+ */
454
+ function handleEffectError(error, effectFn, phase) {
455
+ const effectError = new EffectError(
456
+ `Effect [${effectFn.id}] error during ${phase}: ${error.message}`,
457
+ {
458
+ effectId: effectFn.id,
459
+ phase,
460
+ dependencyCount: effectFn.dependencies?.size ?? 0,
461
+ cause: error
462
+ }
463
+ );
464
+
465
+ // Try effect-specific handler first
466
+ if (effectFn.onError) {
467
+ try {
468
+ effectFn.onError(effectError);
469
+ return;
470
+ } catch (handlerError) {
471
+ log.error('Effect onError handler threw:', handlerError);
472
+ }
473
+ }
474
+
475
+ // Try global handler
476
+ if (globalEffectErrorHandler) {
477
+ try {
478
+ globalEffectErrorHandler(effectError);
479
+ return;
480
+ } catch (handlerError) {
481
+ log.error('Global effect error handler threw:', handlerError);
482
+ }
483
+ }
484
+
485
+ // Default: log with context
486
+ log.error(`[${effectError.effectId}] ${effectError.message}`, {
487
+ phase: effectError.phase,
488
+ dependencies: effectError.dependencyCount
489
+ });
490
+ }
491
+
389
492
  /**
390
493
  * Run a single effect safely
391
494
  * @private
@@ -398,7 +501,7 @@ function runEffect(effectFn) {
398
501
  try {
399
502
  effectFn.run();
400
503
  } catch (error) {
401
- log.error('Effect error:', error);
504
+ handleEffectError(error, effectFn, 'execution');
402
505
  }
403
506
  }
404
507
 
@@ -568,9 +671,16 @@ export function computed(fn, options = {}) {
568
671
  return p;
569
672
  }
570
673
 
674
+ /**
675
+ * @typedef {Object} EffectOptions
676
+ * @property {string} [id] - Custom effect identifier for debugging
677
+ * @property {function(EffectError): void} [onError] - Error handler for this effect
678
+ */
679
+
571
680
  /**
572
681
  * Create an effect that runs when its dependencies change
573
682
  * @param {function(): void|function(): void} fn - Effect function, may return a cleanup function
683
+ * @param {EffectOptions} [options={}] - Effect configuration options
574
684
  * @returns {function(): void} Dispose function to stop the effect
575
685
  * @example
576
686
  * const count = pulse(0);
@@ -588,19 +698,32 @@ export function computed(fn, options = {}) {
588
698
  * const timer = setInterval(() => tick(), 1000);
589
699
  * return () => clearInterval(timer); // Cleanup on re-run or dispose
590
700
  * });
701
+ *
702
+ * // With custom ID and error handler
703
+ * effect(() => {
704
+ * // Effect logic that might fail
705
+ * }, {
706
+ * id: 'data-sync',
707
+ * onError: (err) => console.error('Data sync failed:', err.cause)
708
+ * });
591
709
  */
592
- export function effect(fn) {
710
+ export function effect(fn, options = {}) {
711
+ const { id: customId, onError } = options;
712
+ const effectId = customId || `effect_${++effectIdCounter}`;
713
+
593
714
  // Capture module ID at creation time for HMR tracking
594
715
  const moduleId = context.currentModuleId;
595
716
 
596
717
  const effectFn = {
718
+ id: effectId,
719
+ onError,
597
720
  run: () => {
598
721
  // Run cleanup functions from previous run
599
722
  for (const cleanup of effectFn.cleanups) {
600
723
  try {
601
724
  cleanup();
602
725
  } catch (e) {
603
- log.error('Cleanup error:', e);
726
+ handleEffectError(e, effectFn, 'cleanup');
604
727
  }
605
728
  }
606
729
  effectFn.cleanups = [];
@@ -618,7 +741,7 @@ export function effect(fn) {
618
741
  try {
619
742
  fn();
620
743
  } catch (error) {
621
- log.error('Effect execution error:', error);
744
+ handleEffectError(error, effectFn, 'execution');
622
745
  } finally {
623
746
  context.currentEffect = prevEffect;
624
747
  }
@@ -645,7 +768,7 @@ export function effect(fn) {
645
768
  try {
646
769
  cleanup();
647
770
  } catch (e) {
648
- log.error('Cleanup error:', e);
771
+ handleEffectError(e, effectFn, 'cleanup');
649
772
  }
650
773
  }
651
774
  effectFn.cleanups = [];
@@ -994,6 +1117,9 @@ export default {
994
1117
  memoComputed,
995
1118
  context,
996
1119
  resetContext,
1120
+ // Error handling
1121
+ EffectError,
1122
+ onEffectError,
997
1123
  // HMR support
998
1124
  setCurrentModule,
999
1125
  clearCurrentModule,
package/runtime/store.js CHANGED
@@ -20,6 +20,12 @@ import { loggers, createLogger } from './logger.js';
20
20
 
21
21
  const log = loggers.store;
22
22
 
23
+ /**
24
+ * Maximum nesting depth for nested objects to prevent abuse
25
+ * @type {number}
26
+ */
27
+ const MAX_NESTING_DEPTH = 10;
28
+
23
29
  /**
24
30
  * @typedef {Object} StoreOptions
25
31
  * @property {boolean} [persist=false] - Persist state to localStorage
@@ -46,6 +52,52 @@ const log = loggers.store;
46
52
  * @typedef {function(Store): Store} StorePlugin
47
53
  */
48
54
 
55
+ /**
56
+ * Dangerous property names that could cause prototype pollution
57
+ * @type {Set<string>}
58
+ */
59
+ const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
60
+
61
+ /**
62
+ * Safely deserialize persisted state, preventing prototype pollution
63
+ * and property injection attacks.
64
+ * @private
65
+ * @param {Object} savedState - The parsed JSON state
66
+ * @param {Object} schema - The initial state defining allowed keys
67
+ * @returns {Object} Sanitized state object
68
+ */
69
+ function safeDeserialize(savedState, schema) {
70
+ if (typeof savedState !== 'object' || savedState === null || Array.isArray(savedState)) {
71
+ return {};
72
+ }
73
+
74
+ const result = {};
75
+ for (const [key, value] of Object.entries(savedState)) {
76
+ // Block dangerous keys that could pollute prototypes
77
+ if (DANGEROUS_KEYS.has(key)) {
78
+ log.warn(`Blocked potentially dangerous key in persisted state: "${key}"`);
79
+ continue;
80
+ }
81
+
82
+ // Only allow keys that exist in the schema (initial state)
83
+ if (!(key in schema)) {
84
+ continue;
85
+ }
86
+
87
+ // Recursively validate nested objects
88
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
89
+ if (typeof schema[key] === 'object' && schema[key] !== null && !Array.isArray(schema[key])) {
90
+ result[key] = safeDeserialize(value, schema[key]);
91
+ }
92
+ // If schema expects primitive but got object, skip it
93
+ } else {
94
+ result[key] = value;
95
+ }
96
+ }
97
+
98
+ return result;
99
+ }
100
+
49
101
  /**
50
102
  * Create a global store with reactive state properties.
51
103
  * @template T
@@ -76,7 +128,9 @@ export function createStore(initialState = {}, options = {}) {
76
128
  try {
77
129
  const saved = localStorage.getItem(storageKey);
78
130
  if (saved) {
79
- state = { ...initialState, ...JSON.parse(saved) };
131
+ const parsed = JSON.parse(saved);
132
+ const sanitized = safeDeserialize(parsed, initialState);
133
+ state = { ...initialState, ...sanitized };
80
134
  }
81
135
  } catch (e) {
82
136
  log.warn('Failed to load persisted state:', e);
@@ -93,9 +147,18 @@ export function createStore(initialState = {}, options = {}) {
93
147
  * @private
94
148
  * @param {string} key - State key
95
149
  * @param {*} value - Initial value
150
+ * @param {number} [depth=0] - Current nesting depth
96
151
  * @returns {Pulse|Object} Pulse or nested object of pulses
97
152
  */
98
- function createPulse(key, value) {
153
+ function createPulse(key, value, depth = 0) {
154
+ // Prevent excessive nesting depth
155
+ if (depth > MAX_NESTING_DEPTH) {
156
+ log.warn(`Max nesting depth (${MAX_NESTING_DEPTH}) exceeded for key: "${key}". Flattening to single pulse.`);
157
+ const p = pulse(value);
158
+ pulses[key] = p;
159
+ return p;
160
+ }
161
+
99
162
  if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
100
163
  // Create a pulse for the nested object itself (for $setState support)
101
164
  const objectPulse = pulse(value);
@@ -104,7 +167,7 @@ export function createStore(initialState = {}, options = {}) {
104
167
  // Also create nested pulses for individual properties
105
168
  const nested = {};
106
169
  for (const [k, v] of Object.entries(value)) {
107
- nested[k] = createPulse(`${key}.${k}`, v);
170
+ nested[k] = createPulse(`${key}.${k}`, v, depth + 1);
108
171
  }
109
172
  return nested;
110
173
  }
@@ -119,6 +182,15 @@ export function createStore(initialState = {}, options = {}) {
119
182
  store[key] = createPulse(key, value);
120
183
  }
121
184
 
185
+ // Sync nested pulses for persisted state to ensure consistency
186
+ if (persist && typeof localStorage !== 'undefined') {
187
+ for (const [key, value] of Object.entries(state)) {
188
+ if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
189
+ updateNestedPulses(key, value);
190
+ }
191
+ }
192
+ }
193
+
122
194
  // Persist state changes
123
195
  if (persist) {
124
196
  effect(() => {