ripple 0.3.122 → 0.3.124

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,46 @@
1
1
  # ripple
2
2
 
3
+ ## 0.3.124
4
+
5
+ ### Patch Changes
6
+
7
+ - [#1434](https://github.com/Ripple-TS/ripple/pull/1434)
8
+ [`2f4d02d`](https://github.com/Ripple-TS/ripple/commit/2f4d02d6a6ab83fd1af7e16b86967223990f1aa3)
9
+ Thanks [@chenzylab](https://github.com/chenzylab)! - Fix event delegation
10
+ breaking when multiple roots (Portals, mounts) share or mix targets.
11
+
12
+ - `handle_root_events(target)` had no notion of multiple callers sharing the
13
+ same `target` element. Each Portal (or the app root) calls it once on mount;
14
+ its cleanup unconditionally removed the delegated event listeners from
15
+ `target`. When two Portals both mount to `document.body` (e.g. a Modal and a
16
+ SideSheet), closing the first tore down the delegated listeners for
17
+ `document.body` entirely, silently breaking every click inside the second — no
18
+ error, no warning. `handle_root_events` now ref-counts callers per target, and
19
+ the delegated listeners are only torn down once every caller for that target
20
+ has released it.
21
+ - The single `root_target` global is gone. `on()` now checks the element against
22
+ every active root target, so attaching a listener directly to one root's
23
+ target while another root (e.g. a Portal to a sibling layer) was acquired
24
+ later no longer silently takes the broken delegated path.
25
+ - `Portal` acquires root event delegation in its own render block keyed on
26
+ `target`, so a children-only update no longer releases and re-adds every
27
+ delegated listener on the target.
28
+
29
+ ## 0.3.123
30
+
31
+ ### Patch Changes
32
+
33
+ - [#1432](https://github.com/Ripple-TS/ripple/pull/1432)
34
+ [`8b5abd7`](https://github.com/Ripple-TS/ripple/commit/8b5abd7021a23b7651608fb26ff7e59ed5b4a18c)
35
+ Thanks [@chenzylab](https://github.com/chenzylab)! - Fix insertion order when a
36
+ keyed or ref-based `@for` inserts multiple new items in the middle of the list.
37
+ The pure-insert reconciliation path resolved the DOM anchor per new item by
38
+ indexing the old blocks with a new-list index, so the anchor drifted into the
39
+ matched suffix and later items landed after it (e.g. `[A, C, D]` →
40
+ `[A, B1, B2, C, D]` rendered as `A, B1, C, B2, D`). The anchor is now resolved
41
+ once, at the start of the matched suffix, and reused for the whole run of
42
+ inserts.
43
+
3
44
  ## 0.3.122
4
45
 
5
46
  ### 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.122",
6
+ "version": "0.3.124",
7
7
  "type": "module",
8
8
  "module": "src/runtime/index-client.js",
9
9
  "main": "src/runtime/index-client.js",
@@ -25,8 +25,31 @@ var all_registered_events = new Set();
25
25
  /** @type {Set<(events: Array<string>) => void>} */
26
26
  var root_event_handles = new Set();
27
27
 
28
- /** @type {Element | null} */
29
- var root_target = null;
28
+ /**
29
+ * Active root delegation targets, ref-counted per target. Multiple callers of
30
+ * `handle_root_events` can share one target element — sibling Portals both
31
+ * targeting `document.body`, a Portal targeting the app's mount target, or
32
+ * nested mounts. The shared delegated listeners for a target may only be torn
33
+ * down once every caller for that target has released it, otherwise
34
+ * unmounting one Portal silently kills event delegation for its siblings.
35
+ * @type {Map<Element, { count: number, registered_events: Set<string> }>}
36
+ */
37
+ var root_target_refs = new Map();
38
+
39
+ /**
40
+ * Delegated handling only works for elements strictly below a root delegation
41
+ * target: the root listener's propagation walk never visits the target itself
42
+ * or anything above it, so a delegated handler stored there would never fire.
43
+ * @param {EventTarget} element
44
+ */
45
+ function is_root_target_or_above(element) {
46
+ for (var target of root_target_refs.keys()) {
47
+ if (element === target || /** @type {Element} */ (element).contains?.(target)) {
48
+ return true;
49
+ }
50
+ }
51
+ return false;
52
+ }
30
53
 
31
54
  /**
32
55
  * @param {AddEventOptions} options
@@ -63,9 +86,8 @@ export function on(element, type, handler, options = {}) {
63
86
  element === window ||
64
87
  element === document ||
65
88
  element === document.body ||
66
- element === root_target ||
67
89
  element instanceof MediaQueryList ||
68
- /** @type {Element} */ (element).contains(root_target)
90
+ is_root_target_or_above(element)
69
91
  ) {
70
92
  opts.delegated = false;
71
93
  }
@@ -381,9 +403,13 @@ export function delegate(events) {
381
403
 
382
404
  /** @param {Element} target */
383
405
  export function handle_root_events(target) {
384
- /** @type {Set<string>} */
385
- var registered_events = new Set();
386
- root_target = target;
406
+ var ref = root_target_refs.get(target) ?? {
407
+ count: 0,
408
+ registered_events: /** @type {Set<string>} */ (new Set()),
409
+ };
410
+ ref.count += 1;
411
+ root_target_refs.set(target, ref);
412
+ var registered_events = ref.registered_events;
387
413
 
388
414
  /**
389
415
  * @typedef {Object} EventHandleOptions
@@ -417,14 +443,26 @@ export function handle_root_events(target) {
417
443
  event_handle(array_from(all_registered_events));
418
444
  root_event_handles.add(event_handle);
419
445
 
446
+ var released = false;
447
+
420
448
  return () => {
449
+ if (released) return;
450
+ released = true;
451
+
452
+ root_event_handles.delete(event_handle);
453
+
454
+ // The map entry for `target` is always this `ref`: it is only deleted
455
+ // when the count hits 0, which requires this cleanup to have run.
456
+ ref.count -= 1;
457
+ if (ref.count > 0) return;
458
+
459
+ // Last caller for this target: actually tear down the shared listeners.
460
+ root_target_refs.delete(target);
421
461
  for (var event_name of registered_events) {
422
462
  target.removeEventListener(
423
463
  event_name,
424
464
  /** @type {EventListener} */ (handle_event_propagation),
425
465
  );
426
466
  }
427
- root_event_handles.delete(event_handle);
428
- root_target = null;
429
467
  };
430
468
  }
@@ -479,10 +479,10 @@ function reconcile_by_key(
479
479
 
480
480
  if (j > a_end) {
481
481
  if (j <= b_end) {
482
+ var insert_target = block_start(a_blocks, a_end + 1, a_length, anchor);
482
483
  while (j <= b_end) {
483
484
  b_val = b[j];
484
- var target = block_start(a_blocks, j, a_length, anchor);
485
- b_blocks[j] = create_item(target, b_val, j, render_fn, is_indexed, true);
485
+ b_blocks[j] = create_item(insert_target, b_val, j, render_fn, is_indexed, true);
486
486
  j++;
487
487
  }
488
488
  }
@@ -766,10 +766,10 @@ function reconcile_by_ref(anchor, block, b, render_fn, is_controlled, is_indexed
766
766
 
767
767
  if (j > a_end) {
768
768
  if (j <= b_end) {
769
+ var insert_target = block_start(a_blocks, a_end + 1, a_length, anchor);
769
770
  while (j <= b_end) {
770
771
  b_val = b[j];
771
- var target = block_start(a_blocks, j, a_length, anchor);
772
- b_blocks[j] = create_item(target, b_val, j, render_fn, is_indexed, false);
772
+ b_blocks[j] = create_item(insert_target, b_val, j, render_fn, is_indexed, false);
773
773
  j++;
774
774
  }
775
775
  }
@@ -38,6 +38,14 @@ export function Portal(props) {
38
38
  }
39
39
 
40
40
  try {
41
+ // Root event delegation lives in its own render block so it only
42
+ // re-runs when `props.target` changes — a children-only update must
43
+ // not release and re-acquire the target's delegated listeners.
44
+ render(() => {
45
+ const cleanup_events = handle_root_events(/** @type {Element} */ (props.target));
46
+ return cleanup_events;
47
+ });
48
+
41
49
  render(() => {
42
50
  const next_target = props.target;
43
51
  const next_children = props.children;
@@ -60,8 +68,6 @@ export function Portal(props) {
60
68
  anchor = create_text();
61
69
  /** @type {Element} */ (target).append(anchor);
62
70
 
63
- const cleanup_events = handle_root_events(/** @type {Element} */ (target));
64
-
65
71
  var block = /** @type {Block} */ (active_block);
66
72
 
67
73
  b = branch(() => {
@@ -74,7 +80,6 @@ export function Portal(props) {
74
80
  dom_end = b?.s?.end;
75
81
 
76
82
  return () => {
77
- cleanup_events();
78
83
  /** @type {Text} */ (anchor).remove();
79
84
  if (dom_start && dom_end) {
80
85
  remove_block_dom(dom_start, dom_end);
@@ -1,5 +1,5 @@
1
1
  import type { OnEventListenerRemover } from 'ripple';
2
- import { effect, flushSync, on, track } from 'ripple';
2
+ import { effect, flushSync, on, Portal, track } from 'ripple';
3
3
 
4
4
  describe('on() event handler', () => {
5
5
  it('should attach multiple handlers via onClick attribute (delegated)', () => {
@@ -33,6 +33,38 @@ describe('on() event handler', () => {
33
33
  expect(count2Div.textContent).toBe('1');
34
34
  });
35
35
 
36
+ it('attaches directly on a root delegation target even when a portal roots elsewhere', () => {
37
+ // Regression test: `on()` must not delegate handlers attached to any
38
+ // active root delegation target. With a single `root_target` global,
39
+ // a Portal targeting a sibling layer overwrote it, so `on(container)`
40
+ // took the delegated path and its handler never fired.
41
+ const layer = document.createElement('div');
42
+ document.body.appendChild(layer);
43
+
44
+ function App() @{
45
+ <>
46
+ <button class="inner">{'Inner'}</button>
47
+ <Portal target={layer}>
48
+ <div class="layer-content">{'Layer content'}</div>
49
+ </Portal>
50
+ </>
51
+ }
52
+
53
+ render(App);
54
+
55
+ let calls = 0;
56
+ const off = on(container, 'click', () => {
57
+ calls++;
58
+ });
59
+
60
+ container.querySelector('.inner').click();
61
+ flushSync();
62
+ expect(calls).toBe(1);
63
+
64
+ off();
65
+ layer.remove();
66
+ });
67
+
36
68
  it('should attach and remove a single event handler', () => {
37
69
  function Basic() @{
38
70
  let &[count] = track(0);
@@ -234,6 +234,78 @@ describe('for statements', () => {
234
234
  expect(container.querySelectorAll('.item').length).toBe(2);
235
235
  });
236
236
 
237
+ it('keyed for inserts multiple new items in the middle in correct order', () => {
238
+ function App() @{
239
+ let &[items] = track([
240
+ { id: 'a', text: 'A' },
241
+ { id: 'c', text: 'C' },
242
+ { id: 'd', text: 'D' },
243
+ ]);
244
+ <>
245
+ @for (const item of items; key item.id) {
246
+ <div class="item">{item.text}</div>
247
+ }
248
+ <button
249
+ onClick={() => {
250
+ items = [
251
+ { id: 'a', text: 'A' },
252
+ { id: 'b1', text: 'B1' },
253
+ { id: 'b2', text: 'B2' },
254
+ { id: 'c', text: 'C' },
255
+ { id: 'd', text: 'D' },
256
+ ];
257
+ }}
258
+ >{'Insert'}</button>
259
+ </>
260
+ }
261
+
262
+ render(App);
263
+
264
+ const getTexts = () => Array.from(container.querySelectorAll('.item')).map(
265
+ (el) => el.textContent,
266
+ );
267
+
268
+ expect(getTexts()).toEqual(['A', 'C', 'D']);
269
+
270
+ container.querySelector('button').click();
271
+ flushSync();
272
+
273
+ expect(getTexts()).toEqual(['A', 'B1', 'B2', 'C', 'D']);
274
+ });
275
+
276
+ it('ref-based for inserts multiple new items in the middle in correct order', () => {
277
+ const a = { text: 'A' };
278
+ const c = { text: 'C' };
279
+ const d = { text: 'D' };
280
+
281
+ function App() @{
282
+ let &[items] = track([a, c, d]);
283
+ <>
284
+ @for (const item of items) {
285
+ <div class="item">{item.text}</div>
286
+ }
287
+ <button
288
+ onClick={() => {
289
+ items = [a, { text: 'B1' }, { text: 'B2' }, c, d];
290
+ }}
291
+ >{'Insert'}</button>
292
+ </>
293
+ }
294
+
295
+ render(App);
296
+
297
+ const getTexts = () => Array.from(container.querySelectorAll('.item')).map(
298
+ (el) => el.textContent,
299
+ );
300
+
301
+ expect(getTexts()).toEqual(['A', 'C', 'D']);
302
+
303
+ container.querySelector('button').click();
304
+ flushSync();
305
+
306
+ expect(getTexts()).toEqual(['A', 'B1', 'B2', 'C', 'D']);
307
+ });
308
+
237
309
  it('keyed for with 32+ items: full reversal updates values via Map path', () => {
238
310
  function App() @{
239
311
  let &[items] = track(Array.from({ length: 40 }, (_, i) => ({ id: i, text: `Item ${i}` })));
@@ -137,6 +137,113 @@ describe('Portal', () => {
137
137
  document.body.removeChild(target2);
138
138
  });
139
139
 
140
+ it(
141
+ 'two portals sharing the same target: closing one does not break click delegation for the other',
142
+ () => {
143
+ // Regression test: handle_root_events used to have no ref-counting per
144
+ // target. When two Portals both targeted document.body (e.g. a Modal
145
+ // and a SideSheet), closing/unmounting the first Portal would tear
146
+ // down the shared delegated event listeners for *all* portals on that
147
+ // target, silently breaking clicks inside any portal still open.
148
+ function TestSharedTargetPortals() @{
149
+ let &[modalOpen] = track(true);
150
+ let &[sheetOpen] = track(true);
151
+ let &[sheetClicks] = track(0);
152
+ <>
153
+ @if (modalOpen) {
154
+ <Portal target={document.body}>
155
+ <div class="test-portal test-modal">
156
+ <button
157
+ class="close-modal"
158
+ onClick={() => (modalOpen = false)}
159
+ >{'Close modal'}</button>
160
+ </div>
161
+ </Portal>
162
+ }
163
+ @if (sheetOpen) {
164
+ <Portal target={document.body}>
165
+ <div class="test-portal test-sheet">
166
+ <span class="sheet-clicks">{String(sheetClicks)}</span>
167
+ <button
168
+ class="sheet-btn"
169
+ onClick={() => {
170
+ sheetClicks++;
171
+ }}
172
+ >{'Sheet button'}</button>
173
+ </div>
174
+ </Portal>
175
+ }
176
+ </>
177
+ }
178
+
179
+ render(TestSharedTargetPortals);
180
+
181
+ expect(document.body.querySelector('.close-modal')).toBeTruthy();
182
+ expect(document.body.querySelector('.sheet-btn')).toBeTruthy();
183
+
184
+ // Close the modal portal - its cleanup must not tear down the shared
185
+ // document.body event delegation that the still-open sheet portal relies on.
186
+ document.body.querySelector('.close-modal').click();
187
+ flushSync();
188
+
189
+ expect(document.body.querySelector('.close-modal')).toBeNull();
190
+ const sheetBtn = document.body.querySelector('.sheet-btn');
191
+ expect(sheetBtn).toBeTruthy();
192
+ expect(document.body.querySelector('.sheet-clicks').textContent).toBe('0');
193
+
194
+ sheetBtn.click();
195
+ flushSync();
196
+
197
+ // This is the actual regression check: before the fix, the shared
198
+ // document.body listener was removed when the modal portal's cleanup
199
+ // ran, so this click would never reach the handler and the count
200
+ // would stay at 0.
201
+ expect(document.body.querySelector('.sheet-clicks').textContent).toBe('1');
202
+ },
203
+ );
204
+
205
+ it('retargets portal when target changes and keeps click delegation working', () => {
206
+ const target1 = document.createElement('div');
207
+ const target2 = document.createElement('div');
208
+ document.body.appendChild(target1);
209
+ document.body.appendChild(target2);
210
+
211
+ function TestRetargetPortal() @{
212
+ let &[useSecond] = track(false);
213
+ let &[clicks] = track(0);
214
+ <>
215
+ <button class="swap" onClick={() => (useSecond = true)}>{'Swap'}</button>
216
+ <Portal target={useSecond ? target2 : target1}>
217
+ <div class="test-portal">
218
+ <span class="clicks">{String(clicks)}</span>
219
+ <button class="portal-btn" onClick={() => clicks++}>{'Click'}</button>
220
+ </div>
221
+ </Portal>
222
+ </>
223
+ }
224
+
225
+ render(TestRetargetPortal);
226
+
227
+ expect(target1.querySelector('.portal-btn')).toBeTruthy();
228
+ target1.querySelector('.portal-btn').click();
229
+ flushSync();
230
+ expect(target1.querySelector('.clicks').textContent).toBe('1');
231
+
232
+ container.querySelector('.swap').click();
233
+ flushSync();
234
+
235
+ // Content moved to the new target, and delegation must follow it there.
236
+ expect(target1.querySelector('.portal-btn')).toBeNull();
237
+ expect(target2.querySelector('.portal-btn')).toBeTruthy();
238
+
239
+ target2.querySelector('.portal-btn').click();
240
+ flushSync();
241
+ expect(target2.querySelector('.clicks').textContent).toBe('2');
242
+
243
+ document.body.removeChild(target1);
244
+ document.body.removeChild(target2);
245
+ });
246
+
140
247
  it('handles portal with reactive content', () => {
141
248
  function TestReactivePortal() @{
142
249
  let &[count] = track(0);