ripple 0.3.85 → 0.3.86

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/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # ripple
2
2
 
3
+ ## 0.3.86
4
+
5
+ ### Patch Changes
6
+
7
+ - [#1310](https://github.com/Ripple-TS/ripple/pull/1310)
8
+ [`4ebd58d`](https://github.com/Ripple-TS/ripple/commit/4ebd58dfe853c1ed945072822eaba8a7a9e19a6c)
9
+ Thanks [@leonidaz](https://github.com/leonidaz)! - Fix
10
+ `Cannot use 'in' operator to search for 'parent' in null` thrown from `append()`
11
+ when reordering or inserting into a keyed `@for` whose item body is a single
12
+ control-flow / component root (e.g. `@for (...; key ...) { <Card {item} /> }`
13
+ where `Card`'s own body is a single `@if`/`@for`/component). The 0.3.85
14
+ wrapper-anchor optimization leaves such an item block's `s.start` null because
15
+ its DOM is rendered through a descendant block, and keyed reconciliation read
16
+ `s.start` directly as the insertion anchor. Reconciliation now resolves the real
17
+ first/last DOM node by descending child blocks, so no `<!>` comment marker is
18
+ reintroduced and the optimization's reduced DOM-mutation cost is preserved. The
19
+ same resolution is applied to `@switch` case reordering.
20
+ - Updated dependencies
21
+ [[`e4e6d7b`](https://github.com/Ripple-TS/ripple/commit/e4e6d7b854786ad19a2c86276ea7e0ffb062e61a)]:
22
+ - @tsrx/ripple@0.1.34
23
+
3
24
  ## 0.3.85
4
25
 
5
26
  ### Patch Changes
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "Ripple is an elegant TypeScript UI framework",
4
4
  "license": "MIT",
5
5
  "author": "Dominic Gannaway",
6
- "version": "0.3.85",
6
+ "version": "0.3.86",
7
7
  "type": "module",
8
8
  "module": "src/runtime/index-client.js",
9
9
  "main": "src/runtime/index-client.js",
@@ -74,7 +74,7 @@
74
74
  "devalue": "^5.8.1",
75
75
  "esm-env": "^1.2.2",
76
76
  "@tsrx/core": "0.1.33",
77
- "@tsrx/ripple": "0.1.33"
77
+ "@tsrx/ripple": "0.1.34"
78
78
  },
79
79
  "devDependencies": {
80
80
  "@types/estree": "^1.0.8",
@@ -429,6 +429,59 @@ export function move_block(block, target) {
429
429
  return moved;
430
430
  }
431
431
 
432
+ /**
433
+ * Resolve the first DOM node owned by a block. A block normally records its
434
+ * range in `s.start`/`s.end`, but an optimized single control-flow / component
435
+ * root scope renders its content through a descendant block instead of a
436
+ * synthesized `<!>` wrapper, so its own `s.start` is null. In that case we
437
+ * descend into child blocks to find the real first node. Returns null when the
438
+ * block currently renders no DOM.
439
+ * @param {Block} block
440
+ * @returns {Node | null}
441
+ */
442
+ export function get_first_node(block) {
443
+ var f = block.f;
444
+ if ((f & BRANCH_BLOCK) !== 0 && (f & TRY_BLOCK) === 0) {
445
+ var s = block.s;
446
+ if (s !== null && s.start !== null) {
447
+ return s.start;
448
+ }
449
+ }
450
+ var child = block.first;
451
+ while (child !== null) {
452
+ var node = get_first_node(child);
453
+ if (node !== null) {
454
+ return node;
455
+ }
456
+ child = child.next;
457
+ }
458
+ return null;
459
+ }
460
+
461
+ /**
462
+ * Mirror of {@link get_first_node} for the last DOM node owned by a block.
463
+ * @param {Block} block
464
+ * @returns {Node | null}
465
+ */
466
+ export function get_last_node(block) {
467
+ var f = block.f;
468
+ if ((f & BRANCH_BLOCK) !== 0 && (f & TRY_BLOCK) === 0) {
469
+ var s = block.s;
470
+ if (s !== null && s.start !== null) {
471
+ return s.end;
472
+ }
473
+ }
474
+ var child = block.last;
475
+ while (child !== null) {
476
+ var node = get_last_node(child);
477
+ if (node !== null) {
478
+ return node;
479
+ }
480
+ child = child.prev;
481
+ }
482
+ return null;
483
+ }
484
+
432
485
  /**
433
486
  * @param {Block} block
434
487
  * @param {boolean} [remove_dom]
@@ -1,7 +1,14 @@
1
1
  /** @import { Block, Tracked } from '#client' */
2
2
 
3
3
  import { IS_CONTROLLED, IS_INDEXED, ROOT_CONTROLLED } from '../../../constants.js';
4
- import { branch, destroy_block, destroy_block_children, render } from './blocks.js';
4
+ import {
5
+ branch,
6
+ destroy_block,
7
+ destroy_block_children,
8
+ get_first_node,
9
+ get_last_node,
10
+ render,
11
+ } from './blocks.js';
5
12
  import { FOR_BLOCK, TRACKED_ARRAY } from './constants.js';
6
13
  import { hydrate_next, hydrate_node, hydrating, set_hydrate_node } from './hydration.js';
7
14
  import { create_text, get_first_child, get_last_child, next_sibling } from './operations.js';
@@ -71,12 +78,26 @@ function create_empty(anchor, render_empty) {
71
78
 
72
79
  /**
73
80
  * @param {Block} block
74
- * @param {Element} anchor
81
+ * @param {ChildNode} anchor
75
82
  * @returns {void}
76
83
  */
77
84
  function move(block, anchor) {
78
- var node = block.s.start;
79
- var end = block.s.end;
85
+ // Fast path: a normal item records its own range. Only an optimized single
86
+ // control-flow / component root item (DOM rendered through a descendant
87
+ // block, `s.start` null) needs the descent via `get_first_node`/`get_last_node`.
88
+ var s = block.s;
89
+ var node = s.start;
90
+ var end;
91
+
92
+ if (node === null) {
93
+ node = get_first_node(block);
94
+ if (node === null) {
95
+ return;
96
+ }
97
+ end = get_last_node(block);
98
+ } else {
99
+ end = s.end;
100
+ }
80
101
 
81
102
  if (node === end) {
82
103
  anchor.before(node);
@@ -87,12 +108,43 @@ function move(block, anchor) {
87
108
  anchor.before(node);
88
109
  node = next_node;
89
110
  if (node === end) {
90
- anchor.before(end);
111
+ anchor.before(/** @type {Node} */ (end));
91
112
  break;
92
113
  }
93
114
  }
94
115
  }
95
116
 
117
+ /**
118
+ * Resolve the insertion anchor for a block at `index`: the first real DOM node
119
+ * at or after `index`, or `fallback` when every remaining block renders nothing.
120
+ * Scanning forward keeps insertions correct even when an optimized item renders
121
+ * no DOM (e.g. a single `@if` that is currently false), which previously relied
122
+ * on a synthesized `<!>` wrapper as a stable position marker.
123
+ * @param {Block[]} blocks
124
+ * @param {number} index
125
+ * @param {number} length
126
+ * @param {Element | Text} fallback
127
+ * @returns {ChildNode}
128
+ */
129
+ function block_start(blocks, index, length, fallback) {
130
+ if (index >= length) {
131
+ return fallback;
132
+ }
133
+ // Fast path: a normal item records its own boundary, so this is the same
134
+ // single property read the pre-#1307 code did — no descent, no scan.
135
+ var first = blocks[index].s.start;
136
+ if (first !== null) {
137
+ return first;
138
+ }
139
+ for (var k = index; k < length; k++) {
140
+ var node = get_first_node(blocks[k]);
141
+ if (node !== null) {
142
+ return /** @type {ChildNode} */ (node);
143
+ }
144
+ }
145
+ return fallback;
146
+ }
147
+
96
148
  /**
97
149
  * @template V
98
150
  * @param {V[] | Iterable<V>} collection
@@ -429,7 +481,7 @@ function reconcile_by_key(
429
481
  if (j <= b_end) {
430
482
  while (j <= b_end) {
431
483
  b_val = b[j];
432
- var target = j >= a_length ? anchor : a_blocks[j].s.start;
484
+ var target = block_start(a_blocks, j, a_length, anchor);
433
485
  b_blocks[j] = create_item(target, b_val, j, render_fn, is_indexed, true);
434
486
  j++;
435
487
  }
@@ -548,14 +600,14 @@ function reconcile_by_key(
548
600
  b_val = b[pos];
549
601
  next_pos = pos + 1;
550
602
 
551
- var target = next_pos < b_length ? b_blocks[next_pos].s.start : anchor;
603
+ var target = block_start(b_blocks, next_pos, b_length, anchor);
552
604
  b_blocks[pos] = create_item(target, b_val, pos, render_fn, is_indexed, true);
553
605
  } else if (j < 0 || i !== seq[j]) {
554
606
  pos = i + b_start;
555
607
  b_val = b[pos];
556
608
  next_pos = pos + 1;
557
609
 
558
- var target = next_pos < b_length ? b_blocks[next_pos].s.start : anchor;
610
+ var target = block_start(b_blocks, next_pos, b_length, anchor);
559
611
  move(b_blocks[pos], target);
560
612
  } else {
561
613
  j--;
@@ -568,7 +620,7 @@ function reconcile_by_key(
568
620
  b_val = b[pos];
569
621
  next_pos = pos + 1;
570
622
 
571
- var target = next_pos < b_length ? b_blocks[next_pos].s.start : anchor;
623
+ var target = block_start(b_blocks, next_pos, b_length, anchor);
572
624
  b_blocks[pos] = create_item(target, b_val, pos, render_fn, is_indexed, true);
573
625
  }
574
626
  }
@@ -716,7 +768,7 @@ function reconcile_by_ref(anchor, block, b, render_fn, is_controlled, is_indexed
716
768
  if (j <= b_end) {
717
769
  while (j <= b_end) {
718
770
  b_val = b[j];
719
- var target = j >= a_length ? anchor : a_blocks[j].s.start;
771
+ var target = block_start(a_blocks, j, a_length, anchor);
720
772
  b_blocks[j] = create_item(target, b_val, j, render_fn, is_indexed, false);
721
773
  j++;
722
774
  }
@@ -829,14 +881,14 @@ function reconcile_by_ref(anchor, block, b, render_fn, is_controlled, is_indexed
829
881
  b_val = b[pos];
830
882
  next_pos = pos + 1;
831
883
 
832
- var target = next_pos < b_length ? b_blocks[next_pos].s.start : anchor;
884
+ var target = block_start(b_blocks, next_pos, b_length, anchor);
833
885
  b_blocks[pos] = create_item(target, b_val, pos, render_fn, is_indexed, false);
834
886
  } else if (j < 0 || i !== seq[j]) {
835
887
  pos = i + b_start;
836
888
  b_val = b[pos];
837
889
  next_pos = pos + 1;
838
890
 
839
- var target = next_pos < b_length ? b_blocks[next_pos].s.start : anchor;
891
+ var target = block_start(b_blocks, next_pos, b_length, anchor);
840
892
  move(b_blocks[pos], target);
841
893
  } else {
842
894
  j--;
@@ -849,7 +901,7 @@ function reconcile_by_ref(anchor, block, b, render_fn, is_controlled, is_indexed
849
901
  b_val = b[pos];
850
902
  next_pos = pos + 1;
851
903
 
852
- var target = next_pos < b_length ? b_blocks[next_pos].s.start : anchor;
904
+ var target = block_start(b_blocks, next_pos, b_length, anchor);
853
905
  b_blocks[pos] = create_item(target, b_val, pos, render_fn, is_indexed, false);
854
906
  }
855
907
  }
@@ -1,6 +1,6 @@
1
1
  /** @import { Block } from '#client' */
2
2
 
3
- import { branch, destroy_block, render } from './blocks.js';
3
+ import { branch, destroy_block, get_first_node, get_last_node, render } from './blocks.js';
4
4
  import { SWITCH_BLOCK } from './constants.js';
5
5
  import { hydrate_next, hydrate_node, hydrating } from './hydration.js';
6
6
  import { next_sibling } from './operations.js';
@@ -13,8 +13,22 @@ import { append } from './template.js';
13
13
  * @returns {void}
14
14
  */
15
15
  function move(block, anchor) {
16
- var node = block.s.start;
17
- var end = block.s.end;
16
+ // Fast path: a normal case records its own range. Only an optimized single
17
+ // control-flow / component root case (DOM rendered through a descendant
18
+ // block, `s.start` null) needs the descent via `get_first_node`/`get_last_node`.
19
+ var s = block.s;
20
+ var node = s !== null ? s.start : null;
21
+ var end;
22
+
23
+ if (node === null) {
24
+ node = get_first_node(block);
25
+ if (node === null) {
26
+ return;
27
+ }
28
+ end = get_last_node(block);
29
+ } else {
30
+ end = s.end;
31
+ }
18
32
  /** @type {Node | null} */
19
33
  var sibling;
20
34
 
@@ -0,0 +1,130 @@
1
+ import { RippleArray, flushSync, track } from 'ripple';
2
+
3
+ // Regression tests for the 0.3.85 optimization (PR #1307) that elides the
4
+ // `<!>` wrapper for single control-flow / component root scopes. When such a
5
+ // scope renders its DOM through a descendant block, the owning block's
6
+ // `s.start` is null, so keyed-for reconciliation must resolve the real first
7
+ // node by descending child blocks instead of reading `s.start` directly.
8
+ // Previously this threw `Cannot use 'in' operator to search for 'parent' in
9
+ // null` from `append()` on a reactive insert.
10
+
11
+ describe('keyed for with single-root item bodies (#1307 regression)', () => {
12
+ it(
13
+ 'prepends into a keyed @for whose body is a single component (single control-flow root)',
14
+ () => {
15
+ function MuzeCard(props) @{
16
+ @if (true) {
17
+ <p>{props.muze.muzeId}</p>
18
+ }
19
+ }
20
+ function App() @{
21
+ const muzes = new RippleArray({ muzeId: 'b' }, { muzeId: 'c' });
22
+ <>
23
+ <button onClick={() => muzes.unshift({ muzeId: 'a' })}>{'add'}</button>
24
+ @for (const muze of muzes; key muze.muzeId) {
25
+ <MuzeCard {muze} />
26
+ }
27
+ </>
28
+ }
29
+ render(App);
30
+ expect(container.querySelectorAll('p').length).toBe(2);
31
+
32
+ container.querySelector('button').click();
33
+ flushSync();
34
+
35
+ expect(Array.from(container.querySelectorAll('p')).map((p) => p.textContent)).toEqual([
36
+ 'a',
37
+ 'b',
38
+ 'c',
39
+ ]);
40
+ },
41
+ );
42
+
43
+ it('reorders a keyed @for of single-control-flow-root components', () => {
44
+ function MuzeCard(props) @{
45
+ @if (true) {
46
+ <p>{props.muze.muzeId}</p>
47
+ }
48
+ }
49
+ function App() @{
50
+ const muzes = new RippleArray({ muzeId: 'a' }, { muzeId: 'b' }, { muzeId: 'c' });
51
+ <>
52
+ <button onClick={() => muzes.reverse()}>{'reverse'}</button>
53
+ @for (const muze of muzes; key muze.muzeId) {
54
+ <MuzeCard {muze} />
55
+ }
56
+ </>
57
+ }
58
+ render(App);
59
+
60
+ container.querySelector('button').click();
61
+ flushSync();
62
+
63
+ expect(Array.from(container.querySelectorAll('p')).map((p) => p.textContent)).toEqual([
64
+ 'c',
65
+ 'b',
66
+ 'a',
67
+ ]);
68
+ });
69
+
70
+ it('inserts in the middle of a keyed @for whose item body is a single @for', () => {
71
+ function App() @{
72
+ const groups = new RippleArray({ id: 'g1', items: ['1'] }, { id: 'g3', items: ['3'] });
73
+ <>
74
+ <button onClick={() => groups.splice(1, 0, { id: 'g2', items: ['2'] })}>{'insert'}</button>
75
+ @for (const group of groups; key group.id) {
76
+ @for (const item of group.items; key item) {
77
+ <p>{item}</p>
78
+ }
79
+ }
80
+ </>
81
+ }
82
+ render(App);
83
+
84
+ container.querySelector('button').click();
85
+ flushSync();
86
+
87
+ expect(Array.from(container.querySelectorAll('p')).map((p) => p.textContent)).toEqual([
88
+ '1',
89
+ '2',
90
+ '3',
91
+ ]);
92
+ });
93
+
94
+ it('realistic shape: keyed @for of components inside an @if > fragment', () => {
95
+ function MuzeCard(props) @{
96
+ @if (true) {
97
+ <p class="muze">{props.muze.muzeId}</p>
98
+ }
99
+ }
100
+ function App() @{
101
+ let &[loaded, loadedT] = track(true);
102
+ const muzes = new RippleArray({ muzeId: 'b' }, { muzeId: 'c' });
103
+ <>
104
+ <button onClick={() => muzes.unshift({ muzeId: 'a' })}>{'add'}</button>
105
+ @if (loaded) {
106
+ <>
107
+ @for (const muze of muzes; key muze.muzeId) {
108
+ <MuzeCard {muze} />
109
+ }
110
+ @if (muzes.length > 0) {
111
+ <span class="count">{String(muzes.length)}</span>
112
+ }
113
+ </>
114
+ }
115
+ </>
116
+ }
117
+ render(App);
118
+ expect(container.querySelectorAll('.muze').length).toBe(2);
119
+
120
+ container.querySelector('button').click();
121
+ flushSync();
122
+
123
+ expect(Array.from(container.querySelectorAll('.muze')).map((p) => p.textContent)).toEqual([
124
+ 'a',
125
+ 'b',
126
+ 'c',
127
+ ]);
128
+ expect(container.querySelector('.count').textContent).toBe('3');
129
+ });
130
+ });