ripple 0.3.36 → 0.3.38

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,36 @@
1
1
  # ripple
2
2
 
3
+ ## 0.3.38
4
+
5
+ ### Patch Changes
6
+
7
+ - [#1007](https://github.com/Ripple-TS/ripple/pull/1007)
8
+ [`088299c`](https://github.com/Ripple-TS/ripple/commit/088299ce94a6022c017ce2e56c7e1b59bd5973f7)
9
+ Thanks [@trueadm](https://github.com/trueadm)! - Keep double-quoted JavaScript
10
+ strings inside TSRX expression containers using normal JavaScript string
11
+ semantics while preserving direct double-quoted text child parsing.
12
+
13
+ - Updated dependencies
14
+ [[`088299c`](https://github.com/Ripple-TS/ripple/commit/088299ce94a6022c017ce2e56c7e1b59bd5973f7)]:
15
+ - @tsrx/ripple@0.0.20
16
+ - ripple@0.3.38
17
+
18
+ ## 0.3.37
19
+
20
+ ### Patch Changes
21
+
22
+ - [#1002](https://github.com/Ripple-TS/ripple/pull/1002)
23
+ [`c631ab0`](https://github.com/Ripple-TS/ripple/commit/c631ab0076b7e2cb30f4998101b54c3a86e78c61)
24
+ Thanks [@trueadm](https://github.com/trueadm)! - Align direct double-quoted TSRX
25
+ text children with quoted JSX attribute text by decoding character references
26
+ and treating backslashes as literal text. Preserve the direct quoted form in the
27
+ Prettier plugin and highlight it as JSX text in the TextMate grammar.
28
+
29
+ - Updated dependencies
30
+ [[`c631ab0`](https://github.com/Ripple-TS/ripple/commit/c631ab0076b7e2cb30f4998101b54c3a86e78c61)]:
31
+ - @tsrx/ripple@0.0.19
32
+ - ripple@0.3.37
33
+
3
34
  ## 0.3.36
4
35
 
5
36
  ### 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.36",
6
+ "version": "0.3.38",
7
7
  "type": "module",
8
8
  "module": "src/runtime/index-client.js",
9
9
  "main": "src/runtime/index-client.js",
@@ -76,7 +76,7 @@
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.18"
79
+ "@tsrx/ripple": "0.0.20"
80
80
  },
81
81
  "devDependencies": {
82
82
  "@types/node": "^24.3.0",
@@ -84,9 +84,9 @@
84
84
  "typescript": "^5.9.3",
85
85
  "@volar/language-core": "~2.4.28",
86
86
  "vscode-languageserver-types": "^3.17.5",
87
- "@tsrx/core": "0.0.16"
87
+ "@tsrx/core": "0.0.18"
88
88
  },
89
89
  "peerDependencies": {
90
- "ripple": "0.3.36"
90
+ "ripple": "0.3.38"
91
91
  }
92
92
  }
@@ -14,6 +14,7 @@ import {
14
14
  TRY_BLOCK,
15
15
  HEAD_BLOCK,
16
16
  DIRECT_CHILD_BLOCK,
17
+ UNINITIALIZED,
17
18
  } from './constants.js';
18
19
  import { next_sibling } from './operations.js';
19
20
  import { apply_element_spread } from './render.js';
@@ -26,7 +27,9 @@ import {
26
27
  run_block,
27
28
  run_teardown,
28
29
  schedule_update,
30
+ untrack,
29
31
  } from './runtime.js';
32
+ import { is_ripple_object } from './utils.js';
30
33
 
31
34
  /**
32
35
  * @param {Function} fn
@@ -96,29 +99,56 @@ export function branch(fn, flags = 0, state = null) {
96
99
  }
97
100
 
98
101
  /**
102
+ * Wire up a `{ref expr}` attribute. `expr` may be:
103
+ * - a callback function — invoked with the element on mount; if it returns
104
+ * a function, that function runs as the cleanup on unmount.
105
+ * - a `Tracked` (e.g. from `track()`) — `tracked.value` is set to the
106
+ * element on mount and reset to `null` on unmount.
107
+ * - a plain mutable var (`let foo;`) — the element is assigned to the
108
+ * variable. No teardown is run, released with the component.
109
+ *
110
+ * `get_fn` is invoked through `untrack` so the surrounding render block
111
+ * doesn't subscribe to whatever the thunk happens to read. The supported
112
+ * shape is to pass the ref slot itself (`{ref tracker}`); a foot-gun like
113
+ * `{ref tracker.value}` would otherwise read the cell reactively and cause
114
+ * spurious re-runs.
115
+ *
99
116
  * @param {Element} element
100
- * @param {() => (element: Element) => (void | (() => void))} get_fn
117
+ * @param {() => any} get_fn
118
+ * @param {(value: any) => void} [set_fn]
101
119
  * @returns {Block}
102
120
  */
103
- export function ref(element, get_fn) {
104
- /** @type {(element: Element) => (void | (() => void) | undefined)} */
105
- var ref_fn;
121
+ export function ref(element, get_fn, set_fn) {
122
+ // make sure the first run always enters the dispatch branch,
123
+ /** @type {any} */
124
+ var ref_value = UNINITIALIZED;
106
125
  /** @type {Block | null} */
107
126
  var e;
108
127
 
109
128
  return block(RENDER_BLOCK, () => {
110
- if (ref_fn !== (ref_fn = get_fn())) {
129
+ // avoid any reactive reads
130
+ var next = untrack(get_fn);
131
+ if (ref_value !== (ref_value = next)) {
111
132
  if (e) {
112
133
  destroy_block(e);
113
134
  e = null;
114
135
  }
115
136
 
116
- if (ref_fn) {
137
+ if (typeof ref_value === 'function') {
138
+ e = branch(() => {
139
+ effect(() => ref_value(element));
140
+ });
141
+ } else if (is_ripple_object(ref_value)) {
117
142
  e = branch(() => {
118
143
  effect(() => {
119
- return ref_fn(element);
144
+ ref_value.value = element;
145
+ return () => {
146
+ ref_value.value = null;
147
+ };
120
148
  });
121
149
  });
150
+ } else if (set_fn !== undefined) {
151
+ set_fn(element);
122
152
  }
123
153
  }
124
154
  });
@@ -40,6 +40,21 @@ describe('basic client > rendering & text', () => {
40
40
  expect(div.querySelector('span')).toBeNull();
41
41
  });
42
42
 
43
+ it('renders direct double-quoted text children as text', () => {
44
+ component Basic() {
45
+ <div class="entities">"Rock &amp; &quot;Roll&quot;"</div>
46
+ <div class="backslash">"line\nbreak"</div>
47
+ <pre class="multiline">"first
48
+ second"</pre>
49
+ }
50
+
51
+ render(Basic);
52
+
53
+ expect(container.querySelector('.entities').textContent).toBe('Rock & "Roll"');
54
+ expect(container.querySelector('.backslash').textContent).toBe('line\\nbreak');
55
+ expect(container.querySelector('.multiline').textContent).toBe('first\nsecond');
56
+ });
57
+
43
58
  it('renders dynamic text', () => {
44
59
  component Basic() {
45
60
  let &[message] = track('Hello World');
@@ -1,6 +1,7 @@
1
1
  import type { PropsWithExtras } from 'ripple';
2
2
  import { describe, it, expect } from 'vitest';
3
- import { RippleArray, createRefKey, flushSync, track } from 'ripple';
3
+ import { RippleArray, createRefKey, effect, flushSync, track } from 'ripple';
4
+ import type { Tracked } from 'ripple';
4
5
 
5
6
  describe('refs', () => {
6
7
  it('capture a <div>', () => {
@@ -77,6 +78,161 @@ describe('refs', () => {
77
78
  expect(logs).toEqual(['ref called', 'ref called']);
78
79
  });
79
80
 
81
+ it('captures a host element into a Tracked via {ref tracker}', () => {
82
+ let captured: Tracked<HTMLDivElement | null> | undefined;
83
+
84
+ component Component() {
85
+ const tracker = track<HTMLDivElement | null>(null);
86
+ captured = tracker;
87
+
88
+ <div {ref tracker}>{'Hello World'}</div>
89
+ }
90
+
91
+ render(Component);
92
+ flushSync();
93
+ expect(captured!.value).toBeInstanceOf(HTMLDivElement);
94
+ expect(captured!.value!.textContent).toBe('Hello World');
95
+ });
96
+
97
+ it('forwards a Tracked through a composite component via prop destructuring + spread', () => {
98
+ let captured: Tracked<HTMLInputElement | null> | undefined;
99
+
100
+ component Child({ id, ...rest }: PropsWithExtras<{ id: string }>) {
101
+ // Symbol-keyed `ref` prop survives `...rest` destructuring and
102
+ // arrives on the DOM element via spread.
103
+ <input type="text" {id} {...rest} />
104
+ }
105
+
106
+ component App() {
107
+ const tracker = track<HTMLInputElement | null>(null);
108
+ captured = tracker;
109
+
110
+ <Child id="example" {ref tracker} />
111
+ }
112
+
113
+ render(App);
114
+ flushSync();
115
+ expect(captured!.value).toBeInstanceOf(HTMLInputElement);
116
+ expect(captured!.value!.id).toBe('example');
117
+ });
118
+
119
+ it('assigns a host element to a plain let variable via {ref var}', () => {
120
+ let captured: HTMLDivElement | null = null;
121
+
122
+ component App() {
123
+ let div: HTMLDivElement | undefined;
124
+
125
+ <div {ref div}>{'Hello World'}</div>
126
+
127
+ // Read the captured element through an effect so the assertion
128
+ // observes the post-mount value (component setup runs before the
129
+ // element is created).
130
+ effect(() => {
131
+ captured = div ?? null;
132
+ });
133
+ }
134
+
135
+ render(App);
136
+ flushSync();
137
+ expect(captured).toBeInstanceOf(HTMLDivElement);
138
+ expect(captured!.textContent).toBe('Hello World');
139
+ });
140
+
141
+ it(
142
+ 'uses the function path even when the variable is an Identifier (function wins over setter)',
143
+ () => {
144
+ let logs: string[] = [];
145
+
146
+ component App() {
147
+ let cb = (node: HTMLDivElement) => {
148
+ logs.push(`mount:${node.textContent}`);
149
+ };
150
+
151
+ <div {ref cb}>{'Hello'}</div>
152
+ }
153
+
154
+ render(App);
155
+ flushSync();
156
+ expect(logs).toEqual(['mount:Hello']);
157
+ },
158
+ );
159
+
160
+ it(
161
+ 'uses the Tracked path even when the variable is an Identifier (Tracked wins over setter)',
162
+ () => {
163
+ let captured: Tracked<HTMLDivElement | null> | undefined;
164
+
165
+ component App() {
166
+ const tracker = track<HTMLDivElement | null>(null);
167
+ let slot = tracker;
168
+ captured = tracker;
169
+
170
+ <div {ref slot}>{'Hello'}</div>
171
+ }
172
+
173
+ render(App);
174
+ flushSync();
175
+ expect(captured!.value).toBeInstanceOf(HTMLDivElement);
176
+ },
177
+ );
178
+
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.
187
+ let captured: HTMLInputElement | null = null;
188
+
189
+ component Child({ id, ...rest }: PropsWithExtras<{ id: string }>) {
190
+ <input type="text" {id} {...rest} />
191
+ }
192
+
193
+ component App() {
194
+ let input: HTMLInputElement | undefined;
195
+
196
+ <Child id="example" {ref input} />
197
+
198
+ effect(() => {
199
+ captured = input ?? null;
200
+ });
201
+ }
202
+
203
+ render(App);
204
+ 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
+ expect(container.querySelector('input')).toBeInstanceOf(HTMLInputElement);
209
+ expect(captured).toBeNull();
210
+ });
211
+
212
+ it('clears the Tracked when the host element unmounts', () => {
213
+ let captured: Tracked<HTMLDivElement | null> | undefined;
214
+ let toggle: Tracked<boolean> | undefined;
215
+
216
+ component Component() {
217
+ const tracker = track<HTMLDivElement | null>(null);
218
+ const show = track(true);
219
+ captured = tracker;
220
+ toggle = show;
221
+
222
+ if (show.value) {
223
+ <div {ref tracker}>{'Hello World'}</div>
224
+ }
225
+ }
226
+
227
+ render(Component);
228
+ flushSync();
229
+ expect(captured!.value).toBeInstanceOf(HTMLDivElement);
230
+
231
+ toggle!.value = false;
232
+ flushSync();
233
+ expect(captured!.value).toBeNull();
234
+ });
235
+
80
236
  it('should handle spreading props with a static ref', () => {
81
237
  let logs: string[] = [];
82
238
 
@@ -25,6 +25,21 @@ describe('basic client', () => {
25
25
  expect(body).toBeHtml('<div>&lt;span>Not HTML&lt;/span></div>');
26
26
  });
27
27
 
28
+ it('renders direct double-quoted text children as text', async () => {
29
+ component Basic() {
30
+ <div>"Rock &amp; &quot;Roll&quot;"</div>
31
+ <div>"line\nbreak"</div>
32
+ <pre>"first
33
+ second"</pre>
34
+ }
35
+
36
+ const { body } = await render(Basic);
37
+
38
+ expect(body).toBeHtml(
39
+ '<div>Rock &amp; "Roll"</div><div>line\\nbreak</div><pre>first\nsecond</pre>',
40
+ );
41
+ });
42
+
28
43
  it('renders tracked state updates', async () => {
29
44
  component Counter() {
30
45
  let &[count] = track(0);
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { compile } from '@tsrx/ripple';
3
+
4
+ describe('double-quoted text children', () => {
5
+ it('decodes JSX-style entities before server text escaping', () => {
6
+ const result = compile(
7
+ `component App() {
8
+ <div>"Rock &amp; &quot;Roll&quot;"</div>
9
+ }`,
10
+ '/src/App.tsrx',
11
+ { mode: 'server' },
12
+ );
13
+
14
+ expect(result.js.code).toContain(`_$_.output_push('Rock &amp; "Roll"')`);
15
+ });
16
+ });