ripple 0.3.47 → 0.3.49

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,29 @@
1
1
  # ripple
2
2
 
3
+ ## 0.3.49
4
+
5
+ ### Patch Changes
6
+
7
+ - [#1071](https://github.com/Ripple-TS/ripple/pull/1071)
8
+ [`b54a72f`](https://github.com/Ripple-TS/ripple/commit/b54a72f721adb5f08a5bf3e3d006780b7e1eb471)
9
+ Thanks [@leonidaz](https://github.com/leonidaz)! - Add named ref props with
10
+ `prop_name={ref expr}` syntax and expose `isRefProp()` for runtime detection of
11
+ named ref prop values.
12
+ - Updated dependencies
13
+ [[`b54a72f`](https://github.com/Ripple-TS/ripple/commit/b54a72f721adb5f08a5bf3e3d006780b7e1eb471),
14
+ [`b54a72f`](https://github.com/Ripple-TS/ripple/commit/b54a72f721adb5f08a5bf3e3d006780b7e1eb471),
15
+ [`b54a72f`](https://github.com/Ripple-TS/ripple/commit/b54a72f721adb5f08a5bf3e3d006780b7e1eb471)]:
16
+ - ripple@0.3.49
17
+ - @tsrx/core@0.0.28
18
+ - @tsrx/ripple@0.0.30
19
+
20
+ ## 0.3.48
21
+
22
+ ### Patch Changes
23
+
24
+ - Updated dependencies []:
25
+ - ripple@0.3.48
26
+
3
27
  ## 0.3.47
4
28
 
5
29
  ### 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.47",
6
+ "version": "0.3.49",
7
7
  "type": "module",
8
8
  "module": "src/runtime/index-client.js",
9
9
  "main": "src/runtime/index-client.js",
@@ -76,17 +76,17 @@
76
76
  "esm-env": "^1.2.2",
77
77
  "@types/estree": "^1.0.8",
78
78
  "@types/estree-jsx": "^1.0.5",
79
- "@tsrx/ripple": "0.0.29"
79
+ "@tsrx/core": "0.0.28",
80
+ "@tsrx/ripple": "0.0.30"
80
81
  },
81
82
  "devDependencies": {
82
83
  "@types/node": "^24.3.0",
83
84
  "@typescript-eslint/types": "^8.40.0",
84
85
  "typescript": "^5.9.3",
85
86
  "@volar/language-core": "~2.4.28",
86
- "vscode-languageserver-types": "^3.17.5",
87
- "@tsrx/core": "0.0.27"
87
+ "vscode-languageserver-types": "^3.17.5"
88
88
  },
89
89
  "peerDependencies": {
90
- "ripple": "0.3.47"
90
+ "ripple": "0.3.49"
91
91
  }
92
92
  }
@@ -39,6 +39,7 @@ import {
39
39
  RippleDate as _$_Date__Ripple,
40
40
  createRefKey as _$_RefKey__create,
41
41
  } from 'ripple';
42
+ import { create_ref_prop as _$_RefProp__create } from '@tsrx/core/runtime/ref';
42
43
 
43
44
  export {
44
45
  _$_Map__Ripple,
@@ -49,4 +50,5 @@ export {
49
50
  _$_URLSearchParams__Ripple,
50
51
  _$_Date__Ripple,
51
52
  _$_RefKey__create,
53
+ _$_RefProp__create,
52
54
  };
@@ -24,7 +24,7 @@ import { COMMENT_NODE, HYDRATION_START } from '../constants.js';
24
24
  export { jsx, jsxs, Fragment } from '../jsx-runtime.js';
25
25
  export {
26
26
  UNINITIALIZED,
27
- DERIVED_UPDATED,
27
+ TRACKED_UPDATED,
28
28
  SUSPENSE_PENDING,
29
29
  SUSPENSE_REJECTED,
30
30
  } from './internal/client/constants.js';
@@ -156,6 +156,8 @@ export { Portal } from './internal/client/portal.js';
156
156
 
157
157
  export { ref_prop as createRefKey, get, public_set as set } from './internal/client/runtime.js';
158
158
 
159
+ export { isRefProp } from '@tsrx/core/runtime/ref';
160
+
159
161
  export { on } from './internal/client/events.js';
160
162
 
161
163
  export {
@@ -12,10 +12,11 @@ export {
12
12
  } from './internal/server/index.js';
13
13
  export {
14
14
  UNINITIALIZED,
15
- DERIVED_UPDATED,
15
+ TRACKED_UPDATED,
16
16
  SUSPENSE_PENDING,
17
17
  SUSPENSE_REJECTED,
18
18
  } from './internal/client/constants.js';
19
+ export { isRefProp } from '@tsrx/core/runtime/ref';
19
20
 
20
21
  export const effect = noop;
21
22
  export const createRefKey = noop;
@@ -8,7 +8,8 @@
8
8
  import { effect, render } from './blocks.js';
9
9
  import { on } from './events.js';
10
10
  import { get, set } from './runtime.js';
11
- import { is_array, is_ripple_object } from './utils.js';
11
+ import { is_ripple_object } from './utils.js';
12
+ import { is_array } from '@tsrx/core/runtime/language-helpers';
12
13
 
13
14
  /**
14
15
  * @param {string} name
@@ -105,7 +105,7 @@ export function branch(fn, flags = 0, state = null) {
105
105
  * - a `Tracked` (e.g. from `track()`) — `tracked.value` is set to the
106
106
  * element on mount and reset to `null` on unmount.
107
107
  * - a plain mutable var (`let foo;`) — the element is assigned to the
108
- * variable. No teardown is run, released with the component.
108
+ * variable on mount and reset to `null` on unmount.
109
109
  *
110
110
  * `get_fn` is invoked through `untrack` so the surrounding render block
111
111
  * doesn't subscribe to whatever the thunk happens to read. The supported
@@ -148,7 +148,14 @@ export function ref(element, get_fn, set_fn) {
148
148
  });
149
149
  });
150
150
  } else if (set_fn !== undefined) {
151
- set_fn(element);
151
+ e = branch(() => {
152
+ effect(() => {
153
+ set_fn(element);
154
+ return () => {
155
+ set_fn(null);
156
+ };
157
+ });
158
+ });
152
159
  }
153
160
  }
154
161
  });
@@ -42,7 +42,7 @@ export const NAMESPACE_URI = {
42
42
  mathml: 'http://www.w3.org/1998/Math/MathML',
43
43
  };
44
44
  /** @type {unique symbol} */
45
- export const DERIVED_UPDATED = Symbol('derived_updated');
45
+ export const TRACKED_UPDATED = Symbol('TRACKED_UPDATED');
46
46
  /** @type {unique symbol} */
47
47
  export const SUSPENSE_PENDING = Symbol('suspense_pending');
48
48
  /** @type {unique symbol} */
@@ -16,7 +16,7 @@ import {
16
16
  set_tracking,
17
17
  tracking,
18
18
  } from './runtime.js';
19
- import { array_from, define_property, is_array } from './utils.js';
19
+ import { array_from, define_property, is_array } from '@tsrx/core/runtime/language-helpers';
20
20
  import { render } from './blocks.js';
21
21
 
22
22
  /** @type {Set<string>} */
@@ -6,7 +6,7 @@ import { FOR_BLOCK, TRACKED_ARRAY } from './constants.js';
6
6
  import { hydrate_next, hydrate_node, hydrating, set_hydrate_node } from './hydration.js';
7
7
  import { create_text, get_first_child, get_last_child, next_sibling } from './operations.js';
8
8
  import { active_block, set, tracked, untrack } from './runtime.js';
9
- import { array_from, is_array } from './utils.js';
9
+ import { array_from, is_array } from '@tsrx/core/runtime/language-helpers';
10
10
 
11
11
  /**
12
12
  * @template V
@@ -4,7 +4,7 @@ import { remove_block_dom, render } from './blocks.js';
4
4
  import { get_first_child, get_next_sibling } from './operations.js';
5
5
  import { active_block } from './runtime.js';
6
6
  import { assign_nodes, create_fragment_from_html } from './template.js';
7
- import { hydrate_next, hydrate_node, hydrating, set_hydrate_node } from './hydration.js';
7
+ import { hydrate_next, hydrating, set_hydrate_node } from './hydration.js';
8
8
  import { COMMENT_NODE } from '../../../constants.js';
9
9
 
10
10
  /**
@@ -32,7 +32,7 @@ export {
32
32
 
33
33
  export {
34
34
  UNINITIALIZED,
35
- DERIVED_UPDATED,
35
+ TRACKED_UPDATED,
36
36
  SUSPENSE_PENDING,
37
37
  SUSPENSE_REJECTED,
38
38
  } from './constants.js';
@@ -65,6 +65,7 @@ export {
65
65
  pop_component,
66
66
  untrack,
67
67
  ref_prop,
68
+ create_ref_prop,
68
69
  fallback,
69
70
  exclude_from_object,
70
71
  derived,
@@ -89,7 +90,7 @@ export { switch_block as switch } from './switch.js';
89
90
 
90
91
  export { template, append, text } from './template.js';
91
92
 
92
- export { array_slice } from './utils.js';
93
+ export { array_slice } from '@tsrx/core/runtime/language-helpers';
93
94
 
94
95
  export { ripple_array } from '../../array.js';
95
96
 
@@ -1,6 +1,6 @@
1
1
  import { TEXT_NODE } from '../../../constants.js';
2
2
  import { hydrate_node, hydrating, set_hydrate_node } from './hydration.js';
3
- import { get_descriptor } from './utils.js';
3
+ import { get_descriptor } from '@tsrx/core/runtime/language-helpers';
4
4
 
5
5
  /** @type {(() => Node | null)} */
6
6
  var first_child_getter;
@@ -5,13 +5,7 @@ import { UNINITIALIZED } from './constants.js';
5
5
  import { handle_root_events } from './events.js';
6
6
  import { create_text } from './operations.js';
7
7
  import { active_block } from './runtime.js';
8
- import {
9
- hydrating,
10
- hydrate_next,
11
- hydrate_node,
12
- set_hydrating,
13
- set_hydrate_node,
14
- } from './hydration.js';
8
+ import { hydrating, hydrate_node, set_hydrating, set_hydrate_node } from './hydration.js';
15
9
  import { is_tsrx_element } from '../../element.js';
16
10
 
17
11
  /**
@@ -1,13 +1,14 @@
1
1
  /** @import { Block } from '#client' */
2
2
 
3
- import { destroy_block, ref } from './blocks.js';
3
+ import { branch, destroy_block, ref } from './blocks.js';
4
4
  import { DESTROYED, REF_PROP } from './constants.js';
5
+ import { isRefProp as is_ref_prop } from '@tsrx/core/runtime/ref';
6
+ import { is_ripple_object } from './utils.js';
5
7
  import {
6
8
  get_descriptors,
7
9
  get_own_property_symbols,
8
10
  get_prototype_of,
9
- is_ripple_object,
10
- } from './utils.js';
11
+ } from '@tsrx/core/runtime/language-helpers';
11
12
  import { event } from './events.js';
12
13
  import {
13
14
  getAttributeEventName as get_attribute_event_name,
@@ -143,11 +144,14 @@ function set_attribute_helper(element, key, value, remove_listeners, prev) {
143
144
  element.classList.add(value);
144
145
  } else if (typeof key === 'string' && is_event_attribute(key)) {
145
146
  // Handle event handlers in spread props
146
- const event_name = get_attribute_event_name(key, value);
147
147
  if (remove_listeners[key]) {
148
148
  remove_listeners[key]();
149
+ remove_listeners[key] = undefined;
150
+ }
151
+ if (value != null) {
152
+ const event_name = get_attribute_event_name(key, value);
153
+ remove_listeners[key] = event(event_name, element, value);
149
154
  }
150
- remove_listeners[key] = event(event_name, element, value);
151
155
  } else {
152
156
  set_attribute(element, key, value);
153
157
  }
@@ -254,56 +258,90 @@ export function set_selected(element, selected) {
254
258
  export function apply_element_spread(element, fn) {
255
259
  /** @type {Record<string | symbol, any>} */
256
260
  var prev = {};
257
- /** @type {Record<symbol, Block | undefined>} */
261
+ /** @type {Record<string | symbol, Block | undefined>} */
258
262
  var effects = {};
259
263
  /** @type {Record<string | symbol, (() => void) | undefined>} */
260
264
  var remove_listeners = {};
261
265
 
262
266
  /** @type {Record<symbol, any>} */
263
267
  var prev_symbols = {};
268
+ /** @type {Record<string, any>} */
269
+ var prev_ref_props = {};
264
270
 
265
271
  return () => {
266
272
  var next = fn();
267
-
268
- for (const symbol of get_own_property_symbols(effects)) {
269
- if (!next[symbol] && effects[symbol]) {
270
- destroy_block(effects[symbol]);
271
- effects[symbol] = undefined;
272
- }
273
- }
274
-
275
- /** @type {Record<symbol, any>} */
276
- var current_symbols = {};
273
+ var current_symbols = /** @type {Record<symbol, any>} */ ({});
277
274
 
278
275
  for (const symbol of get_own_property_symbols(next)) {
279
- var ref_fn = next[symbol];
276
+ if (symbol.description !== REF_PROP) {
277
+ continue;
278
+ }
279
+ const ref_fn = next[symbol];
280
280
  current_symbols[symbol] = ref_fn;
281
281
 
282
282
  if (
283
- symbol.description === REF_PROP &&
284
- (!(symbol in prev_symbols) ||
285
- ref_fn !== prev_symbols[symbol] ||
286
- (effects[symbol] && (effects[symbol].f & DESTROYED) !== 0))
283
+ !(symbol in prev_symbols) ||
284
+ ref_fn !== prev_symbols[symbol] ||
285
+ (effects[symbol] && (effects[symbol].f & DESTROYED) !== 0)
287
286
  ) {
288
287
  if (effects[symbol] && (effects[symbol].f & DESTROYED) === 0) {
289
288
  destroy_block(effects[symbol]);
290
289
  }
291
- effects[symbol] = ref(element, () => ref_fn);
290
+ effects[symbol] = create_spread_ref_effect(element, ref_fn);
291
+ }
292
+ }
293
+
294
+ for (const symbol of get_own_property_symbols(prev_symbols)) {
295
+ if (!(symbol in current_symbols) && effects[symbol]) {
296
+ destroy_block(/** @type {Block} */ (effects[symbol]));
297
+ effects[symbol] = undefined;
292
298
  }
293
299
  }
294
300
 
295
301
  prev_symbols = current_symbols;
296
302
 
303
+ /** @type {Record<string, any>} */
304
+ var current_ref_props = {};
305
+
306
+ for (const key in next) {
307
+ const ref_fn = next[key];
308
+ if (!is_ref_prop(ref_fn)) {
309
+ continue;
310
+ }
311
+
312
+ current_ref_props[key] = ref_fn;
313
+
314
+ if (
315
+ !(key in prev_ref_props) ||
316
+ ref_fn !== prev_ref_props[key] ||
317
+ (effects[key] && (effects[key].f & DESTROYED) !== 0)
318
+ ) {
319
+ if (effects[key] && (effects[key].f & DESTROYED) === 0) {
320
+ destroy_block(effects[key]);
321
+ }
322
+ effects[key] = create_spread_ref_effect(element, ref_fn);
323
+ }
324
+ }
325
+
326
+ for (const key in prev_ref_props) {
327
+ if (!(key in current_ref_props) && effects[key]) {
328
+ destroy_block(/** @type {Block} */ (effects[key]));
329
+ effects[key] = undefined;
330
+ }
331
+ }
332
+
333
+ prev_ref_props = current_ref_props;
334
+
297
335
  for (let key in remove_listeners) {
298
336
  // Remove event listeners that are no longer present
299
- if (!(key in next) && remove_listeners[key]) {
337
+ if ((!(key in next) || is_ref_prop(next[key])) && remove_listeners[key]) {
300
338
  remove_listeners[key]();
301
339
  remove_listeners[key] = undefined;
302
340
  }
303
341
  }
304
342
 
305
343
  for (const key in prev) {
306
- if (!(key in next)) {
344
+ if (!(key in next) || is_ref_prop(next[key])) {
307
345
  if (key === '#class') {
308
346
  continue;
309
347
  }
@@ -317,6 +355,9 @@ export function apply_element_spread(element, fn) {
317
355
  if (key === 'children') continue;
318
356
 
319
357
  let value = next[key];
358
+ if (is_ref_prop(value)) {
359
+ continue;
360
+ }
320
361
  if (is_ripple_object(value)) {
321
362
  value = get(value);
322
363
  }
@@ -331,3 +372,18 @@ export function apply_element_spread(element, fn) {
331
372
  prev = current;
332
373
  };
333
374
  }
375
+
376
+ /**
377
+ * Keep spread refs in a branch block so ordinary spread updates do not destroy
378
+ * and recreate the ref block before `apply_element_spread` can compare the
379
+ * previous and current ref values.
380
+ *
381
+ * @param {Element} element
382
+ * @param {any} ref_fn
383
+ * @returns {Block}
384
+ */
385
+ function create_spread_ref_effect(element, ref_fn) {
386
+ return branch(() => {
387
+ ref(element, () => ref_fn);
388
+ });
389
+ }
@@ -29,7 +29,7 @@ import {
29
29
  REF_PROP,
30
30
  TRACKED_OBJECT,
31
31
  DEFAULT_NAMESPACE,
32
- DERIVED_UPDATED,
32
+ TRACKED_UPDATED,
33
33
  SUSPENSE_PENDING,
34
34
  SUSPENSE_REJECTED,
35
35
  TRY_BLOCK,
@@ -44,18 +44,20 @@ import {
44
44
  register_boundary_paused_block,
45
45
  replace_boundary_request,
46
46
  } from './try.js';
47
+ import { is_ripple_object } from './utils.js';
48
+
47
49
  import {
48
50
  define_property,
49
51
  get_descriptor,
50
52
  get_own_property_symbols,
51
53
  is_array,
52
- is_ripple_object,
53
54
  object_keys,
54
- } from './utils.js';
55
+ } from '@tsrx/core/runtime/language-helpers';
55
56
  import { get_async_track_result } from '../../../utils/async.js';
56
57
  import { get_track_async_script_id } from '../../../utils/track-async-serialization.js';
57
58
  import * as devalue from 'devalue';
58
59
  import { hydrating, track_hash_reference } from './hydration.js';
60
+ import { create_ref_prop as create_core_ref_prop } from '@tsrx/core/runtime/ref';
59
61
 
60
62
  const FLUSH_MICROTASK = 0;
61
63
  const FLUSH_SYNC = 1;
@@ -668,7 +670,7 @@ export function track_async(fn, b, hash) {
668
670
 
669
671
  // Abort previous in-flight request
670
672
  if (abort_controller !== null && abort_controller.signal.aborted === false) {
671
- abort_controller.abort(DERIVED_UPDATED);
673
+ abort_controller.abort(TRACKED_UPDATED);
672
674
  }
673
675
  abort_controller = null;
674
676
 
@@ -766,7 +768,7 @@ export function track_async(fn, b, hash) {
766
768
  if (current_version !== version) return; // stale
767
769
 
768
770
  var is_internal_abort =
769
- error === DERIVED_UPDATED || current_abort_controller?.signal?.reason === DERIVED_UPDATED;
771
+ error === TRACKED_UPDATED || current_abort_controller?.signal?.reason === TRACKED_UPDATED;
770
772
  if (is_internal_abort) {
771
773
  // Internal abort (superseded by a new request) — don't set rejected
772
774
  if (request_id > 0 && boundary !== null) {
@@ -799,7 +801,7 @@ export function track_async(fn, b, hash) {
799
801
  return () => {
800
802
  // Teardown: abort in-flight request when block is destroyed
801
803
  if (current_abort_controller !== null && current_abort_controller.signal.aborted === false) {
802
- current_abort_controller.abort(DERIVED_UPDATED);
804
+ current_abort_controller.abort(TRACKED_UPDATED);
803
805
  }
804
806
  };
805
807
  });
@@ -1653,6 +1655,15 @@ export function ref_prop() {
1653
1655
  return Symbol(REF_PROP);
1654
1656
  }
1655
1657
 
1658
+ /**
1659
+ * @param {() => any} get_ref_value
1660
+ * @param {(value: any) => void} [set_ref_value]
1661
+ * @returns {(node: any) => void | (() => void)}
1662
+ */
1663
+ export function create_ref_prop(get_ref_value, set_ref_value) {
1664
+ return create_core_ref_prop(() => untrack(get_ref_value), set_ref_value);
1665
+ }
1666
+
1656
1667
  /**
1657
1668
  * @template T
1658
1669
  * @param {T | undefined} value
@@ -9,15 +9,7 @@ import {
9
9
  resume_block,
10
10
  } from './blocks.js';
11
11
  import { TRY_BLOCK } from './constants.js';
12
- import {
13
- hydrate_next,
14
- hydrate_node,
15
- hydrating,
16
- set_hydrate_node,
17
- set_hydrating,
18
- skip_to_hydration_end,
19
- } from './hydration.js';
20
- import { get_next_sibling } from './operations.js';
12
+ import { hydrate_next, hydrating } from './hydration.js';
21
13
  import {
22
14
  active_block,
23
15
  queue_microtask,
@@ -1,44 +1,5 @@
1
1
  /** @import { NAMESPACE_URI } from './constants.js' */
2
2
 
3
- /** @type {typeof Object.getOwnPropertyDescriptor} */
4
- export var get_descriptor = Object.getOwnPropertyDescriptor;
5
- /** @type {typeof Object.getOwnPropertyDescriptors} */
6
- export var get_descriptors = Object.getOwnPropertyDescriptors;
7
- /** @type {typeof Array.from} */
8
- export var array_from = Array.from;
9
- /** @type {typeof Array.isArray} */
10
- export var is_array = Array.isArray;
11
- /** @type {typeof Object.defineProperty} */
12
- export var define_property = Object.defineProperty;
13
- /** @type {typeof Object.getPrototypeOf} */
14
- export var get_prototype_of = Object.getPrototypeOf;
15
- /** @type {typeof Object.values} */
16
- export var object_values = Object.values;
17
- /** @type {typeof Object.entries} */
18
- export var object_entries = Object.entries;
19
- /** @type {typeof Object.keys} */
20
- export var object_keys = Object.keys;
21
- /** @type {typeof Object.getOwnPropertySymbols} */
22
- export var get_own_property_symbols = Object.getOwnPropertySymbols;
23
- /** @type {typeof structuredClone} */
24
- export var structured_clone = structuredClone;
25
- /** @type {typeof Object.prototype} */
26
- export var object_prototype = Object.prototype;
27
- /** @type {typeof Array.prototype} */
28
- export var array_prototype = Array.prototype;
29
-
30
- /**
31
- * Slice helper for arrays and array-like values.
32
- * @param {ArrayLike<any>} array_like
33
- * @param {...number} args
34
- * @returns {any[]}
35
- */
36
- export function array_slice(array_like, ...args) {
37
- return is_array(array_like)
38
- ? array_like.slice(...args)
39
- : array_prototype.slice.call(array_like, ...args);
40
- }
41
-
42
3
  /**
43
4
  * Creates a text node that serves as an anchor point in the DOM.
44
5
  * @returns {Text}
@@ -24,14 +24,16 @@ import {
24
24
  SUSPENSE_PENDING,
25
25
  SUSPENSE_REJECTED,
26
26
  ASYNC_DERIVED_READ_THROWN,
27
- DERIVED_UPDATED,
27
+ TRACKED_UPDATED,
28
28
  } from '../client/constants.js';
29
29
  import { DEV } from 'esm-env';
30
- import { is_ripple_object, array_slice } from '../client/utils.js';
30
+ import { is_ripple_object } from '../client/utils.js';
31
+ import { array_slice } from '@tsrx/core/runtime/language-helpers';
31
32
  import { escape, escapeScript as escape_script } from '@tsrx/core';
32
33
  import { isBooleanAttribute as is_boolean_attribute } from '@tsrx/core';
33
34
  import { clsx } from 'clsx';
34
35
  import { normalizeCssPropertyName as normalize_css_property_name } from '@tsrx/core';
36
+ import { create_ref_prop } from '@tsrx/core/runtime/ref';
35
37
  import { BLOCK_CLOSE, BLOCK_OPEN } from '../../../constants.js';
36
38
  import { is_tsrx_element, normalize_children, tsrx_element } from '../../element.js';
37
39
  import {
@@ -56,6 +58,7 @@ export { context } from './context.js';
56
58
  export { try_block, component_block, regular_block } from './blocks.js';
57
59
  export { array_slice };
58
60
  export { tsrx_element, normalize_children };
61
+ export { create_ref_prop };
59
62
 
60
63
  /** @extends Error */
61
64
  export class TrackAsyncRunError extends Error {
@@ -1566,7 +1569,7 @@ function register_block_rerun(block) {
1566
1569
  cancel: () => {
1567
1570
  cancelled = true;
1568
1571
  if (t && t.aa) {
1569
- t.aa.abort(DERIVED_UPDATED);
1572
+ t.aa.abort(TRACKED_UPDATED);
1570
1573
  t.aa = null;
1571
1574
  t.ap = null;
1572
1575
  }
@@ -8,7 +8,7 @@ import {
8
8
  get_prototype_of,
9
9
  is_array,
10
10
  object_prototype,
11
- } from './internal/client/utils.js';
11
+ } from '@tsrx/core/runtime/language-helpers';
12
12
  import {
13
13
  MAX_ARRAY_LENGTH,
14
14
  TRACKED_ARRAY,
@@ -1,5 +1,5 @@
1
1
  import type { Tracked } from 'ripple';
2
- import { DERIVED_UPDATED, effect, flushSync, track, trackAsync } from 'ripple';
2
+ import { TRACKED_UPDATED, effect, flushSync, track, trackAsync } from 'ripple';
3
3
 
4
4
  describe('async suspense', () => {
5
5
  it('hides child content during re-suspension when tracked dependency changes', async () => {
@@ -115,7 +115,7 @@ describe('async suspense', () => {
115
115
  expect(container.innerHTML).not.toContain('late value');
116
116
  });
117
117
 
118
- it('aborts superseded requests with DERIVED_UPDATED without rendering catch', async () => {
118
+ it('aborts superseded requests with TRACKED_UPDATED without rendering catch', async () => {
119
119
  const requests = new Map<
120
120
  string,
121
121
  { resolve: (value: string) => void; abortController: AbortController }
@@ -181,7 +181,7 @@ describe('async suspense', () => {
181
181
 
182
182
  expect(
183
183
  (requests.get('b') as { abortController: AbortController }).abortController.signal.reason,
184
- ).toBe(DERIVED_UPDATED);
184
+ ).toBe(TRACKED_UPDATED);
185
185
  expect(container.innerHTML).not.toContain('class="error"');
186
186
  expect(container.innerHTML).toContain('value-a');
187
187
 
@@ -123,6 +123,36 @@ describe('basic client > events', () => {
123
123
  expect(countSpan.textContent).toBe('1');
124
124
  });
125
125
 
126
+ it('removes event listeners from spread props when they are replaced by another prop', () => {
127
+ component Basic() {
128
+ let &[enabled] = track(true);
129
+ let &[count] = track(0);
130
+
131
+ <button class="target" {...(enabled ? { onClick: () => count++ } : { title: 'disabled' })}>
132
+ {'target'}
133
+ </button>
134
+ <button class="toggle" onClick={() => (enabled = false)}>{'toggle'}</button>
135
+ <span class="count">{count}</span>
136
+ }
137
+
138
+ render(Basic);
139
+
140
+ const target = container.querySelector('.target');
141
+ const toggle = container.querySelector('.toggle');
142
+ const count = container.querySelector('.count');
143
+
144
+ target.click();
145
+ flushSync();
146
+ expect(count.textContent).toBe('1');
147
+
148
+ toggle.click();
149
+ flushSync();
150
+ expect(target.getAttribute('title')).toBe('disabled');
151
+ target.click();
152
+ flushSync();
153
+ expect(count.textContent).toBe('1');
154
+ });
155
+
126
156
  it('handles both delegated and non-delegated events in spread props', () => {
127
157
  component Basic() {
128
158
  let &[clickCount] = track(0);
@@ -1,5 +1,5 @@
1
1
  import type { PropsWithExtras } from 'ripple';
2
- import { createRefKey, flushSync, track } from 'ripple';
2
+ import { createRefKey, effect, flushSync, track } from 'ripple';
3
3
 
4
4
  describe('dynamic DOM elements', () => {
5
5
  it('renders static dynamic element', () => {
@@ -225,6 +225,84 @@ describe('dynamic DOM elements', () => {
225
225
  expect(capturedElement!.textContent).toBe('Element with ref');
226
226
  });
227
227
 
228
+ it('handles ref={...}, {ref ...}, and named ref props on dynamic DOM elements', () => {
229
+ let refAttrElement: HTMLInputElement | null = null;
230
+ let anonymousRefElement: HTMLInputElement | null = null;
231
+ let namedRefElement: HTMLInputElement | null = null;
232
+
233
+ component App() {
234
+ let tag = track('input');
235
+ let input: HTMLInputElement | undefined;
236
+ const state: { anonymous?: HTMLInputElement } = {};
237
+
238
+ <@tag
239
+ id="dynamic-ref-combo"
240
+ type="text"
241
+ ref={input}
242
+ {ref state.anonymous}
243
+ input_ref={ref (node: HTMLInputElement | null) => {
244
+ namedRefElement = node;
245
+ }}
246
+ />
247
+
248
+ effect(() => {
249
+ refAttrElement = input ?? null;
250
+ anonymousRefElement = state.anonymous ?? null;
251
+ });
252
+ }
253
+
254
+ render(App);
255
+ flushSync();
256
+
257
+ const element = container.querySelector('#dynamic-ref-combo');
258
+ expect(element).toBeInstanceOf(HTMLInputElement);
259
+ expect(refAttrElement).toBe(element);
260
+ expect(anonymousRefElement).toBe(element);
261
+ expect(namedRefElement).toBe(element);
262
+ expect(element!.hasAttribute('ref')).toBe(false);
263
+ expect(element!.hasAttribute('input_ref')).toBe(false);
264
+ });
265
+
266
+ it('forwards dynamic ref forms through a dynamic component that spreads props', () => {
267
+ let refAttrElement: HTMLInputElement | null = null;
268
+ let anonymousRefElement: HTMLInputElement | null = null;
269
+ let namedRefElement: HTMLInputElement | null = null;
270
+
271
+ component Child(props: PropsWithExtras<{}>) {
272
+ <input id="dynamic-component-ref-combo" type="text" {...props} />
273
+ }
274
+
275
+ component App() {
276
+ let dynamic = track(() => Child);
277
+ let input: HTMLInputElement | undefined;
278
+ const state: { anonymous?: HTMLInputElement } = {};
279
+
280
+ <@dynamic
281
+ ref={input}
282
+ {ref state.anonymous}
283
+ input_ref={ref (node: HTMLInputElement | null) => {
284
+ namedRefElement = node;
285
+ }}
286
+ />
287
+
288
+ effect(() => {
289
+ refAttrElement = input ?? null;
290
+ anonymousRefElement = state.anonymous ?? null;
291
+ });
292
+ }
293
+
294
+ render(App);
295
+ flushSync();
296
+
297
+ const element = container.querySelector('#dynamic-component-ref-combo');
298
+ expect(element).toBeInstanceOf(HTMLInputElement);
299
+ expect(refAttrElement).toBe(element);
300
+ expect(anonymousRefElement).toBe(element);
301
+ expect(namedRefElement).toBe(element);
302
+ expect(element!.hasAttribute('ref')).toBe(false);
303
+ expect(element!.hasAttribute('input_ref')).toBe(false);
304
+ });
305
+
228
306
  it('handles dynamic element with createRefKey in spread', () => {
229
307
  component App() {
230
308
  let tag = track('header');
@@ -1,9 +1,70 @@
1
1
  import type { PropsWithExtras } from 'ripple';
2
2
  import { describe, it, expect } from 'vitest';
3
- import { RippleArray, createRefKey, effect, flushSync, track } from 'ripple';
3
+ import { RippleArray, createRefKey, effect, flushSync, isRefProp, track } from 'ripple';
4
4
  import type { Tracked } from 'ripple';
5
5
 
6
6
  describe('refs', () => {
7
+ it('reports ordinary functions and ref objects as non named-ref props', () => {
8
+ expect(isRefProp(() => {})).toBe(false);
9
+ expect(isRefProp({ current: null })).toBe(false);
10
+ expect(isRefProp({ value: null })).toBe(false);
11
+ });
12
+
13
+ it('captures a host element with a named ref prop', () => {
14
+ let captured: HTMLInputElement | null = null;
15
+
16
+ component App() {
17
+ let input: HTMLInputElement | undefined;
18
+
19
+ <input type="text" input_ref={ref input} />
20
+
21
+ effect(() => {
22
+ captured = input ?? null;
23
+ });
24
+ }
25
+
26
+ render(App);
27
+ flushSync();
28
+
29
+ expect(captured).toBeInstanceOf(HTMLInputElement);
30
+ });
31
+
32
+ it('forwards a named ref prop explicitly through a component', () => {
33
+ let captured: HTMLInputElement | null = null;
34
+
35
+ component Child(props: PropsWithExtras<{}>) {
36
+ expect(isRefProp(props.input_ref)).toBe(true);
37
+ <input type="text" ref={props.input_ref} />
38
+ }
39
+
40
+ component App() {
41
+ <Child input_ref={ref (node: HTMLInputElement | null) => (captured = node)} />
42
+ }
43
+
44
+ render(App);
45
+ flushSync();
46
+
47
+ expect(captured).toBeInstanceOf(HTMLInputElement);
48
+ });
49
+
50
+ it('applies named ref props from host spreads', () => {
51
+ let captured: HTMLInputElement | null = null;
52
+
53
+ component Child(props: PropsWithExtras<{}>) {
54
+ expect(isRefProp(props.input_ref)).toBe(true);
55
+ <input type="text" {...props} />
56
+ }
57
+
58
+ component App() {
59
+ <Child input_ref={ref (node: HTMLInputElement | null) => (captured = node)} />
60
+ }
61
+
62
+ render(App);
63
+ flushSync();
64
+
65
+ expect(captured).toBeInstanceOf(HTMLInputElement);
66
+ });
67
+
7
68
  it('capture a <div>', () => {
8
69
  let div: HTMLDivElement | undefined;
9
70
 
@@ -138,6 +199,95 @@ describe('refs', () => {
138
199
  expect(captured!.textContent).toBe('Hello World');
139
200
  });
140
201
 
202
+ it('assigns a host element to a plain let variable via ref={var}', () => {
203
+ let captured: HTMLDivElement | null = null;
204
+
205
+ component App() {
206
+ let div: HTMLDivElement | undefined;
207
+
208
+ <div ref={div}>{'Hello ref attr'}</div>
209
+
210
+ effect(() => {
211
+ captured = div ?? null;
212
+ });
213
+ }
214
+
215
+ render(App);
216
+ flushSync();
217
+ expect(captured).toBeInstanceOf(HTMLDivElement);
218
+ expect(captured!.textContent).toBe('Hello ref attr');
219
+ });
220
+
221
+ it('clears a plain let variable via ref={var} when the host element unmounts', () => {
222
+ let div: HTMLDivElement | null | undefined;
223
+
224
+ component App() {
225
+ let &[show] = track(true);
226
+
227
+ if (show) {
228
+ <div ref={div}>{'Hello cleanup'}</div>
229
+ }
230
+
231
+ <button class="toggle" onClick={() => (show = false)}>{'hide'}</button>
232
+ }
233
+
234
+ render(App);
235
+ flushSync();
236
+ expect(div).toBeInstanceOf(HTMLDivElement);
237
+
238
+ (container.querySelector('.toggle') as HTMLButtonElement).click();
239
+ flushSync();
240
+ expect(div).toBeNull();
241
+ });
242
+
243
+ it('assigns a host element to a member expression via ref={state.var}', () => {
244
+ let captured: HTMLInputElement | null = null;
245
+
246
+ component App() {
247
+ const state: { input?: HTMLInputElement } = {};
248
+
249
+ <input type="text" ref={state.input} />
250
+
251
+ effect(() => {
252
+ captured = state.input ?? null;
253
+ });
254
+ }
255
+
256
+ render(App);
257
+ flushSync();
258
+ expect(captured).toBeInstanceOf(HTMLInputElement);
259
+ });
260
+
261
+ it('clears a plain let variable via component ref={var} when the host element unmounts', () => {
262
+ let input: HTMLInputElement | null | undefined;
263
+ let previous: HTMLInputElement | undefined;
264
+
265
+ component Child(props: PropsWithExtras<{}>) {
266
+ <input type="text" value="keep" {...props} />
267
+ }
268
+
269
+ component App() {
270
+ let &[show] = track(true);
271
+
272
+ if (show) {
273
+ <Child ref={input} />
274
+ }
275
+
276
+ <button class="toggle" onClick={() => (show = false)}>{'hide'}</button>
277
+ }
278
+
279
+ render(App);
280
+ flushSync();
281
+ expect(input).toBeInstanceOf(HTMLInputElement);
282
+ previous = input!;
283
+ expect(previous.value).toBe('keep');
284
+
285
+ (container.querySelector('.toggle') as HTMLButtonElement).click();
286
+ flushSync();
287
+ expect(input).toBeNull();
288
+ expect(previous.value).toBe('keep');
289
+ });
290
+
141
291
  it(
142
292
  'uses the function path even when the variable is an Identifier (function wins over setter)',
143
293
  () => {
@@ -176,14 +326,7 @@ describe('refs', () => {
176
326
  },
177
327
  );
178
328
 
179
- it('does NOT propagate a plain let variable through a composite component via {...rest}', () => {
180
- // Assignment-sugar (`let foo; <el {ref foo} />`) only works on
181
- // host elements, where the setter closure has direct lexical
182
- // access to the parent's slot. Passing a plain `let` variable
183
- // through a composite forwards only its current value into the
184
- // child's local prop bag — there is no slot identity across the
185
- // component boundary. Use a `Tracked` (object identity) when you
186
- // need the captured node to be visible in the parent.
329
+ it('propagates a plain let variable through a composite component via {...rest}', () => {
187
330
  let captured: HTMLInputElement | null = null;
188
331
 
189
332
  component Child({ id, ...rest }: PropsWithExtras<{ id: string }>) {
@@ -202,11 +345,108 @@ describe('refs', () => {
202
345
 
203
346
  render(App);
204
347
  flushSync();
205
- // The DOM input was created and exists — but the parent's `input`
206
- // slot stays unset because there is no setter to forward through
207
- // the composite boundary.
208
348
  expect(container.querySelector('input')).toBeInstanceOf(HTMLInputElement);
209
- expect(captured).toBeNull();
349
+ expect(captured).toBeInstanceOf(HTMLInputElement);
350
+ expect(captured!.id).toBe('example');
351
+ });
352
+
353
+ it(
354
+ 'clears a plain let variable forwarded through a named ref prop when the host element unmounts',
355
+ () => {
356
+ let input: HTMLInputElement | null | undefined;
357
+ let previous: HTMLInputElement | undefined;
358
+
359
+ component Child(props: PropsWithExtras<{}>) {
360
+ <input type="text" value="keep" {...props} />
361
+ }
362
+
363
+ component App() {
364
+ let &[show] = track(true);
365
+
366
+ if (show) {
367
+ <Child input_ref={ref input} />
368
+ }
369
+
370
+ <button class="toggle" onClick={() => (show = false)}>{'hide'}</button>
371
+ }
372
+
373
+ render(App);
374
+ flushSync();
375
+ expect(input).toBeInstanceOf(HTMLInputElement);
376
+ previous = input!;
377
+ expect(previous.value).toBe('keep');
378
+
379
+ (container.querySelector('.toggle') as HTMLButtonElement).click();
380
+ flushSync();
381
+ expect(input).toBeNull();
382
+ expect(previous.value).toBe('keep');
383
+ },
384
+ );
385
+
386
+ it('clears a named ref prop when a host spread changes it to a regular prop', () => {
387
+ let input: HTMLInputElement | null | undefined;
388
+ let previous: HTMLInputElement | undefined;
389
+
390
+ component Child(props: PropsWithExtras<{}>) {
391
+ let &[as_ref] = track(true);
392
+
393
+ <input
394
+ type="text"
395
+ value="keep"
396
+ {...(as_ref ? { input_ref: props.input_ref } : { input_ref: 'regular prop' })}
397
+ />
398
+ <button class="toggle" onClick={() => (as_ref = false)}>{'toggle'}</button>
399
+ }
400
+
401
+ component App() {
402
+ <Child input_ref={ref input} />
403
+ }
404
+
405
+ render(App);
406
+ flushSync();
407
+ expect(input).toBeInstanceOf(HTMLInputElement);
408
+ previous = input!;
409
+ expect(previous.value).toBe('keep');
410
+ expect(previous.getAttribute('input_ref')).toBeNull();
411
+
412
+ (container.querySelector('.toggle') as HTMLButtonElement).click();
413
+ flushSync();
414
+ expect(input).toBeNull();
415
+ expect(previous.value).toBe('keep');
416
+ expect(previous.getAttribute('input_ref')).toBe('regular prop');
417
+ });
418
+
419
+ it('removes a regular spread attribute when the key changes to a named ref prop', () => {
420
+ let input: HTMLInputElement | null | undefined;
421
+ let previous: HTMLInputElement | undefined;
422
+
423
+ component Child(props: PropsWithExtras<{}>) {
424
+ let &[as_ref] = track(false);
425
+
426
+ <input
427
+ type="text"
428
+ value="keep"
429
+ {...(as_ref ? { input_ref: props.input_ref } : { input_ref: 'regular prop' })}
430
+ />
431
+ <button class="toggle" onClick={() => (as_ref = true)}>{'toggle'}</button>
432
+ }
433
+
434
+ component App() {
435
+ <Child input_ref={ref input} />
436
+ }
437
+
438
+ render(App);
439
+ flushSync();
440
+ expect(input).toBeUndefined();
441
+ previous = container.querySelector('input') as HTMLInputElement;
442
+ expect(previous.value).toBe('keep');
443
+ expect(previous.getAttribute('input_ref')).toBe('regular prop');
444
+
445
+ (container.querySelector('.toggle') as HTMLButtonElement).click();
446
+ flushSync();
447
+ expect(input).toBe(previous);
448
+ expect(previous.value).toBe('keep');
449
+ expect(previous.getAttribute('input_ref')).toBeNull();
210
450
  });
211
451
 
212
452
  it('clears the Tracked when the host element unmounts', () => {
package/types/index.d.ts CHANGED
@@ -146,8 +146,10 @@ declare global {
146
146
 
147
147
  export function createRefKey(): symbol;
148
148
 
149
+ export function isRefProp(value: unknown): boolean;
150
+
149
151
  export const UNINITIALIZED: unique symbol;
150
- export const DERIVED_UPDATED: unique symbol;
152
+ export const TRACKED_UPDATED: unique symbol;
151
153
  export const SUSPENSE_PENDING: unique symbol;
152
154
  export const SUSPENSE_REJECTED: unique symbol;
153
155