pulse-js-framework 1.0.0 → 1.2.0

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/runtime/dom.js CHANGED
@@ -5,7 +5,35 @@
5
5
  * and provides reactive bindings
6
6
  */
7
7
 
8
- import { effect, pulse, batch } from './pulse.js';
8
+ import { effect, pulse, batch, onCleanup } from './pulse.js';
9
+
10
+ // Lifecycle tracking
11
+ let mountCallbacks = [];
12
+ let unmountCallbacks = [];
13
+ let currentMountContext = null;
14
+
15
+ /**
16
+ * Register a callback to run when component mounts
17
+ */
18
+ export function onMount(fn) {
19
+ if (currentMountContext) {
20
+ currentMountContext.mountCallbacks.push(fn);
21
+ } else {
22
+ // Defer to next microtask if no context
23
+ queueMicrotask(fn);
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Register a callback to run when component unmounts
29
+ */
30
+ export function onUnmount(fn) {
31
+ if (currentMountContext) {
32
+ currentMountContext.unmountCallbacks.push(fn);
33
+ }
34
+ // Also register with effect cleanup if in an effect
35
+ onCleanup(fn);
36
+ }
9
37
 
10
38
  /**
11
39
  * Parse a CSS selector-like string into element configuration
@@ -18,7 +46,7 @@ import { effect, pulse, batch } from './pulse.js';
18
46
  * "button.primary.large" -> { tag: "button", classes: ["primary", "large"] }
19
47
  * "input[type=text][placeholder=Name]" -> { tag: "input", attrs: { type: "text", placeholder: "Name" } }
20
48
  */
21
- function parseSelector(selector) {
49
+ export function parseSelector(selector) {
22
50
  const config = {
23
51
  tag: 'div',
24
52
  id: null,
@@ -238,7 +266,7 @@ export function on(element, event, handler, options) {
238
266
  }
239
267
 
240
268
  /**
241
- * Create a reactive list
269
+ * Create a reactive list with efficient keyed diffing
242
270
  */
243
271
  export function list(getItems, template, keyFn = (item, i) => i) {
244
272
  const container = document.createDocumentFragment();
@@ -248,41 +276,36 @@ export function list(getItems, template, keyFn = (item, i) => i) {
248
276
  container.appendChild(startMarker);
249
277
  container.appendChild(endMarker);
250
278
 
251
- let itemNodes = new Map(); // key -> { nodes: Node[], cleanup: Function }
279
+ // Map: key -> { nodes: Node[], cleanup: Function, item: any }
280
+ let itemNodes = new Map();
281
+ let keyOrder = []; // Track order of keys for diffing
252
282
 
253
283
  effect(() => {
254
284
  const items = typeof getItems === 'function' ? getItems() : getItems.get();
255
- const newKeys = new Set();
285
+ const itemsArray = Array.isArray(items) ? items : Array.from(items);
256
286
 
257
- // Build new list
258
- const fragment = document.createDocumentFragment();
287
+ const newKeys = [];
259
288
  const newItemNodes = new Map();
260
289
 
261
- items.forEach((item, index) => {
290
+ // Build map of new items by key
291
+ itemsArray.forEach((item, index) => {
262
292
  const key = keyFn(item, index);
263
- newKeys.add(key);
293
+ newKeys.push(key);
264
294
 
265
295
  if (itemNodes.has(key)) {
266
- // Reuse existing nodes
267
- const existing = itemNodes.get(key);
268
- for (const node of existing.nodes) {
269
- fragment.appendChild(node);
270
- }
271
- newItemNodes.set(key, existing);
296
+ // Reuse existing entry
297
+ newItemNodes.set(key, itemNodes.get(key));
272
298
  } else {
273
299
  // Create new nodes
274
300
  const result = template(item, index);
275
301
  const nodes = Array.isArray(result) ? result : [result];
276
- for (const node of nodes) {
277
- fragment.appendChild(node);
278
- }
279
- newItemNodes.set(key, { nodes, cleanup: null });
302
+ newItemNodes.set(key, { nodes, cleanup: null, item });
280
303
  }
281
304
  });
282
305
 
283
- // Remove old items
306
+ // Remove items that are no longer present
284
307
  for (const [key, entry] of itemNodes) {
285
- if (!newKeys.has(key)) {
308
+ if (!newItemNodes.has(key)) {
286
309
  for (const node of entry.nodes) {
287
310
  node.remove();
288
311
  }
@@ -290,17 +313,30 @@ export function list(getItems, template, keyFn = (item, i) => i) {
290
313
  }
291
314
  }
292
315
 
293
- // Clear between markers
294
- let current = startMarker.nextSibling;
295
- while (current && current !== endMarker) {
296
- const next = current.nextSibling;
297
- current.remove();
298
- current = next;
316
+ // Efficient reordering using minimal DOM operations
317
+ // Use a simple diff algorithm: iterate through new order and move/insert as needed
318
+ let prevNode = startMarker;
319
+
320
+ for (let i = 0; i < newKeys.length; i++) {
321
+ const key = newKeys[i];
322
+ const entry = newItemNodes.get(key);
323
+ const firstNode = entry.nodes[0];
324
+
325
+ // Check if node is already in correct position
326
+ if (prevNode.nextSibling !== firstNode) {
327
+ // Need to move/insert
328
+ for (const node of entry.nodes) {
329
+ prevNode.parentNode?.insertBefore(node, prevNode.nextSibling);
330
+ prevNode = node;
331
+ }
332
+ } else {
333
+ // Already in position, just advance prevNode
334
+ prevNode = entry.nodes[entry.nodes.length - 1];
335
+ }
299
336
  }
300
337
 
301
- // Insert new fragment
302
- endMarker.parentNode?.insertBefore(fragment, endMarker);
303
338
  itemNodes = newItemNodes;
339
+ keyOrder = newKeys;
304
340
  });
305
341
 
306
342
  return container;
@@ -441,13 +477,22 @@ export function mount(target, element) {
441
477
  }
442
478
 
443
479
  /**
444
- * Create a component factory
480
+ * Create a component factory with lifecycle support
445
481
  */
446
482
  export function component(setup) {
447
483
  return (props = {}) => {
448
484
  const state = {};
449
485
  const methods = {};
450
486
 
487
+ // Create mount context for lifecycle hooks
488
+ const mountContext = {
489
+ mountCallbacks: [],
490
+ unmountCallbacks: []
491
+ };
492
+
493
+ const prevContext = currentMountContext;
494
+ currentMountContext = mountContext;
495
+
451
496
  const ctx = {
452
497
  state,
453
498
  methods,
@@ -459,13 +504,290 @@ export function component(setup) {
459
504
  when,
460
505
  on,
461
506
  bind,
462
- model
507
+ model,
508
+ onMount,
509
+ onUnmount
463
510
  };
464
511
 
465
- return setup(ctx);
512
+ let result;
513
+ try {
514
+ result = setup(ctx);
515
+ } finally {
516
+ currentMountContext = prevContext;
517
+ }
518
+
519
+ // Schedule mount callbacks after DOM insertion
520
+ if (mountContext.mountCallbacks.length > 0) {
521
+ queueMicrotask(() => {
522
+ for (const cb of mountContext.mountCallbacks) {
523
+ try {
524
+ cb();
525
+ } catch (e) {
526
+ console.error('Mount callback error:', e);
527
+ }
528
+ }
529
+ });
530
+ }
531
+
532
+ // Store unmount callbacks on the element for later cleanup
533
+ if (result instanceof Node && mountContext.unmountCallbacks.length > 0) {
534
+ result._pulseUnmount = mountContext.unmountCallbacks;
535
+ }
536
+
537
+ return result;
466
538
  };
467
539
  }
468
540
 
541
+ /**
542
+ * Toggle element visibility without removing from DOM
543
+ * Unlike when(), this keeps the element in the DOM but hides it
544
+ */
545
+ export function show(condition, element) {
546
+ effect(() => {
547
+ const shouldShow = typeof condition === 'function' ? condition() : condition.get();
548
+ element.style.display = shouldShow ? '' : 'none';
549
+ });
550
+ return element;
551
+ }
552
+
553
+ /**
554
+ * Portal - render children into a different DOM location
555
+ */
556
+ export function portal(children, target) {
557
+ const resolvedTarget = typeof target === 'string'
558
+ ? document.querySelector(target)
559
+ : target;
560
+
561
+ if (!resolvedTarget) {
562
+ console.warn('Portal target not found:', target);
563
+ return document.createComment('portal-target-not-found');
564
+ }
565
+
566
+ const marker = document.createComment('portal');
567
+ let mountedNodes = [];
568
+
569
+ // Handle reactive children
570
+ if (typeof children === 'function') {
571
+ effect(() => {
572
+ // Cleanup previous nodes
573
+ for (const node of mountedNodes) {
574
+ node.remove();
575
+ if (node._pulseUnmount) {
576
+ for (const cb of node._pulseUnmount) cb();
577
+ }
578
+ }
579
+ mountedNodes = [];
580
+
581
+ const result = children();
582
+ if (result) {
583
+ const nodes = Array.isArray(result) ? result : [result];
584
+ for (const node of nodes) {
585
+ if (node instanceof Node) {
586
+ resolvedTarget.appendChild(node);
587
+ mountedNodes.push(node);
588
+ }
589
+ }
590
+ }
591
+ });
592
+ } else {
593
+ // Static children
594
+ const nodes = Array.isArray(children) ? children : [children];
595
+ for (const node of nodes) {
596
+ if (node instanceof Node) {
597
+ resolvedTarget.appendChild(node);
598
+ mountedNodes.push(node);
599
+ }
600
+ }
601
+ }
602
+
603
+ // Return marker for position tracking, attach cleanup
604
+ marker._pulseUnmount = [() => {
605
+ for (const node of mountedNodes) {
606
+ node.remove();
607
+ if (node._pulseUnmount) {
608
+ for (const cb of node._pulseUnmount) cb();
609
+ }
610
+ }
611
+ }];
612
+
613
+ return marker;
614
+ }
615
+
616
+ /**
617
+ * Error boundary - catch errors in child components
618
+ */
619
+ export function errorBoundary(children, fallback) {
620
+ const container = document.createDocumentFragment();
621
+ const marker = document.createComment('error-boundary');
622
+ container.appendChild(marker);
623
+
624
+ const error = pulse(null);
625
+ let currentNodes = [];
626
+
627
+ const renderContent = () => {
628
+ // Cleanup previous
629
+ for (const node of currentNodes) {
630
+ node.remove();
631
+ }
632
+ currentNodes = [];
633
+
634
+ const hasError = error.peek();
635
+
636
+ try {
637
+ let result;
638
+ if (hasError && fallback) {
639
+ result = typeof fallback === 'function' ? fallback(hasError) : fallback;
640
+ } else {
641
+ result = typeof children === 'function' ? children() : children;
642
+ }
643
+
644
+ if (result) {
645
+ const nodes = Array.isArray(result) ? result : [result];
646
+ const fragment = document.createDocumentFragment();
647
+ for (const node of nodes) {
648
+ if (node instanceof Node) {
649
+ fragment.appendChild(node);
650
+ currentNodes.push(node);
651
+ }
652
+ }
653
+ marker.parentNode?.insertBefore(fragment, marker.nextSibling);
654
+ }
655
+ } catch (e) {
656
+ console.error('Error in component:', e);
657
+ error.set(e);
658
+ // Re-render with error
659
+ if (!hasError) {
660
+ queueMicrotask(renderContent);
661
+ }
662
+ }
663
+ };
664
+
665
+ effect(renderContent);
666
+
667
+ // Expose reset method on marker
668
+ marker.resetError = () => error.set(null);
669
+
670
+ return container;
671
+ }
672
+
673
+ /**
674
+ * Transition helper - animate element enter/exit
675
+ */
676
+ export function transition(element, options = {}) {
677
+ const {
678
+ enter = 'fade-in',
679
+ exit = 'fade-out',
680
+ duration = 300,
681
+ onEnter,
682
+ onExit
683
+ } = options;
684
+
685
+ // Apply enter animation
686
+ const applyEnter = () => {
687
+ element.classList.add(enter);
688
+ if (onEnter) onEnter(element);
689
+ setTimeout(() => {
690
+ element.classList.remove(enter);
691
+ }, duration);
692
+ };
693
+
694
+ // Apply exit animation and return promise
695
+ const applyExit = () => {
696
+ return new Promise(resolve => {
697
+ element.classList.add(exit);
698
+ if (onExit) onExit(element);
699
+ setTimeout(() => {
700
+ element.classList.remove(exit);
701
+ resolve();
702
+ }, duration);
703
+ });
704
+ };
705
+
706
+ // Apply enter on mount
707
+ queueMicrotask(applyEnter);
708
+
709
+ // Attach exit method
710
+ element._pulseTransitionExit = applyExit;
711
+
712
+ return element;
713
+ }
714
+
715
+ /**
716
+ * Conditional rendering with transitions
717
+ */
718
+ export function whenTransition(condition, thenTemplate, elseTemplate = null, options = {}) {
719
+ const container = document.createDocumentFragment();
720
+ const marker = document.createComment('when-transition');
721
+ container.appendChild(marker);
722
+
723
+ const { duration = 300, enterClass = 'fade-in', exitClass = 'fade-out' } = options;
724
+
725
+ let currentNodes = [];
726
+ let isTransitioning = false;
727
+
728
+ effect(() => {
729
+ const show = typeof condition === 'function' ? condition() : condition.get();
730
+
731
+ if (isTransitioning) return;
732
+
733
+ const template = show ? thenTemplate : elseTemplate;
734
+
735
+ // Exit animation for current nodes
736
+ if (currentNodes.length > 0) {
737
+ isTransitioning = true;
738
+ const nodesToRemove = [...currentNodes];
739
+ currentNodes = [];
740
+
741
+ for (const node of nodesToRemove) {
742
+ node.classList.add(exitClass);
743
+ }
744
+
745
+ setTimeout(() => {
746
+ for (const node of nodesToRemove) {
747
+ node.remove();
748
+ }
749
+ isTransitioning = false;
750
+
751
+ // Render new content
752
+ if (template) {
753
+ const result = typeof template === 'function' ? template() : template;
754
+ if (result) {
755
+ const nodes = Array.isArray(result) ? result : [result];
756
+ const fragment = document.createDocumentFragment();
757
+ for (const node of nodes) {
758
+ if (node instanceof Node) {
759
+ node.classList.add(enterClass);
760
+ fragment.appendChild(node);
761
+ currentNodes.push(node);
762
+ setTimeout(() => node.classList.remove(enterClass), duration);
763
+ }
764
+ }
765
+ marker.parentNode?.insertBefore(fragment, marker.nextSibling);
766
+ }
767
+ }
768
+ }, duration);
769
+ } else if (template) {
770
+ // No previous content, just render with enter animation
771
+ const result = typeof template === 'function' ? template() : template;
772
+ if (result) {
773
+ const nodes = Array.isArray(result) ? result : [result];
774
+ const fragment = document.createDocumentFragment();
775
+ for (const node of nodes) {
776
+ if (node instanceof Node) {
777
+ node.classList.add(enterClass);
778
+ fragment.appendChild(node);
779
+ currentNodes.push(node);
780
+ setTimeout(() => node.classList.remove(enterClass), duration);
781
+ }
782
+ }
783
+ marker.parentNode?.insertBefore(fragment, marker.nextSibling);
784
+ }
785
+ }
786
+ });
787
+
788
+ return container;
789
+ }
790
+
469
791
  export default {
470
792
  el,
471
793
  text,
@@ -480,5 +802,13 @@ export default {
480
802
  model,
481
803
  mount,
482
804
  component,
483
- parseSelector
805
+ parseSelector,
806
+ // New features
807
+ onMount,
808
+ onUnmount,
809
+ show,
810
+ portal,
811
+ errorBoundary,
812
+ transition,
813
+ whenTransition
484
814
  };
package/runtime/index.js CHANGED
@@ -6,8 +6,10 @@ export * from './pulse.js';
6
6
  export * from './dom.js';
7
7
  export * from './router.js';
8
8
  export * from './store.js';
9
+ export * from './native.js';
9
10
 
10
11
  export { default as PulseCore } from './pulse.js';
11
12
  export { default as PulseDOM } from './dom.js';
12
13
  export { default as PulseRouter } from './router.js';
13
14
  export { default as PulseStore } from './store.js';
15
+ export { default as PulseNative } from './native.js';