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 +41 -0
- package/package.json +1 -1
- package/src/runtime/internal/client/events.js +47 -9
- package/src/runtime/internal/client/for.js +4 -4
- package/src/runtime/internal/client/portal.js +8 -3
- package/tests/client/events.test.tsrx +33 -1
- package/tests/client/for.test.tsrx +72 -0
- package/tests/client/portal.test.tsrx +107 -0
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.
|
|
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
|
-
/**
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|