ripple 0.4.0 → 0.4.2

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/package.json +3 -2
  3. package/src/constants.js +4 -0
  4. package/src/jsx-runtime.d.ts +12 -2
  5. package/src/runtime/internal/client/constants.js +6 -0
  6. package/src/runtime/internal/client/for.js +224 -50
  7. package/src/runtime/internal/client/hydration.js +0 -16
  8. package/src/runtime/internal/client/index.js +2 -2
  9. package/src/runtime/internal/client/operations.js +1 -2
  10. package/src/runtime/internal/client/render.js +42 -4
  11. package/src/runtime/internal/client/runtime.js +45 -2
  12. package/src/runtime/internal/server/index.js +11 -6
  13. package/tests/client/basic/basic.attributes.test.tsrx +37 -0
  14. package/tests/client/compiler/compiler.basic.test.tsrx +2 -2
  15. package/tests/client/for-item.test.tsrx +404 -0
  16. package/tests/hydration/compiled/client/basic.js +29 -55
  17. package/tests/hydration/compiled/client/events.js +80 -67
  18. package/tests/hydration/compiled/client/for.js +95 -80
  19. package/tests/hydration/compiled/client/fragment-cursor.js +412 -344
  20. package/tests/hydration/compiled/client/head.js +4 -7
  21. package/tests/hydration/compiled/client/html.js +40 -64
  22. package/tests/hydration/compiled/client/if-children.js +30 -34
  23. package/tests/hydration/compiled/client/if-fragment-controlflow.js +7 -7
  24. package/tests/hydration/compiled/client/if.js +4 -4
  25. package/tests/hydration/compiled/client/mixed-control-flow.js +20 -50
  26. package/tests/hydration/compiled/client/nested-control-flow.js +97 -185
  27. package/tests/hydration/compiled/client/portal.js +2 -2
  28. package/tests/hydration/compiled/client/reactivity.js +17 -31
  29. package/tests/hydration/compiled/client/streaming.js +4 -8
  30. package/tests/hydration/compiled/client/typed-text.js +4 -15
  31. package/tests/hydration/compiled/server/events.js +5 -53
  32. package/tests/hydration/compiled/server/for.js +1 -9
  33. package/tests/hydration/compiled/server/fragment-cursor.js +36 -324
  34. package/tests/server/basic.attributes.test.tsrx +28 -0
  35. package/tests/server/basic.test.tsrx +23 -0
  36. package/tests/utils/jsx-runtime-types.test.js +67 -2
  37. package/tests/utils/tracked-types.test.js +117 -0
  38. package/types/index.d.ts +7 -8
  39. package/tests/client/compiler/__snapshots__/compiler.assignments.test.rsrx.snap +0 -12
  40. package/tests/client/compiler/__snapshots__/compiler.typescript.test.rsrx.snap +0 -46
@@ -36,6 +36,7 @@ import {
36
36
  SCHEDULED,
37
37
  SELECTOR,
38
38
  IF_BLOCK,
39
+ ITEM_BLOCK,
39
40
  RELEASED,
40
41
  RENDER_ENTRY,
41
42
  CREATES_DERIVEDS,
@@ -389,7 +390,9 @@ function handle_run_error(error, block) {
389
390
  * @returns {Derived[] | null}
390
391
  */
391
392
  function prepare_rerun(block) {
392
- if ((block.f & (FOR_BLOCK | IF_BLOCK)) === 0) {
393
+ // A list, an if, and a list item re-run only their own logic; their child
394
+ // blocks (items, the branch's blocks, an item body's nested blocks) stay.
395
+ if ((block.f & (FOR_BLOCK | IF_BLOCK | ITEM_BLOCK)) === 0) {
393
396
  destroy_non_branch_children(block);
394
397
  }
395
398
  run_teardown(block);
@@ -411,6 +414,45 @@ function register_teardown(block, teardown) {
411
414
  }
412
415
  }
413
416
 
417
+ /**
418
+ * Runs the body of a branch block that was just created, in place of a first
419
+ * `run_block`: a branch renders untracked and never re-runs, so the only
420
+ * bookkeeping its first run needs is the globals the body reads and the
421
+ * dependencies a nested `item` records on it. Errors are handled as
422
+ * `run_block` handles them.
423
+ * @param {Block} block
424
+ * @param {(anchor: any, value: any) => void} fn
425
+ * @param {any} anchor
426
+ * @param {any} value
427
+ */
428
+ export function run_branch(block, fn, anchor, value) {
429
+ var previous_block = active_block;
430
+ var previous_reaction = active_reaction;
431
+ var previous_tracking = tracking;
432
+ var previous_dependency = active_dependency;
433
+ var previous_component = active_component;
434
+
435
+ try {
436
+ active_block = block;
437
+ active_reaction = block;
438
+ active_component = block.co;
439
+ tracking = false;
440
+ active_dependency = null;
441
+ fn(anchor, value);
442
+ if (active_dependency !== null) {
443
+ block.d = active_dependency;
444
+ }
445
+ } catch (error) {
446
+ handle_run_error(error, block);
447
+ } finally {
448
+ active_block = previous_block;
449
+ active_reaction = previous_reaction;
450
+ tracking = previous_tracking;
451
+ active_dependency = previous_dependency;
452
+ active_component = previous_component;
453
+ }
454
+ }
455
+
414
456
  /**
415
457
  * @param {Block} block
416
458
  * @param {boolean} [first_run] true when the block has no children, teardown,
@@ -1258,7 +1300,8 @@ function is_tracking_dirty(tracking) {
1258
1300
  export function is_block_dirty(block) {
1259
1301
  var flags = block.f;
1260
1302
 
1261
- if ((flags & (ROOT_BLOCK | BRANCH_BLOCK)) !== 0) {
1303
+ // A branch never re-runs, except a list item that carries its render block.
1304
+ if ((flags & ITEM_BLOCK) === 0 && (flags & (ROOT_BLOCK | BRANCH_BLOCK)) !== 0) {
1262
1305
  return false;
1263
1306
  }
1264
1307
  if ((flags & BLOCK_HAS_RUN) === 0) {
@@ -1258,7 +1258,6 @@ export async function render(component, options = {}) {
1258
1258
  }
1259
1259
  }
1260
1260
 
1261
- var CONTENT_SPECIAL = /[&<]/;
1262
1261
  var ATTR_SPECIAL = /[&"<]/;
1263
1262
  var SCRIPT_SPECIAL = /[<>]/;
1264
1263
 
@@ -1275,15 +1274,19 @@ function escape_inline_script(str) {
1275
1274
 
1276
1275
  /**
1277
1276
  * Escapes text or attribute content. Strings without a character to escape
1278
- * (the common case) return as they are after a single regex test; anything
1279
- * else goes through the general escaper.
1277
+ * (the common case) return as they are; anything else goes through the general
1278
+ * escaper. Text content is checked with two single-character searches: on the
1279
+ * short strings a page is made of they beat one character-class regex test.
1280
1280
  * @param {unknown} value
1281
1281
  * @param {boolean} [is_attr]
1282
1282
  * @returns {string}
1283
1283
  */
1284
1284
  export function escape(value, is_attr) {
1285
1285
  var str = typeof value === 'string' ? value : value == null ? '' : String(value);
1286
- return (is_attr ? ATTR_SPECIAL : CONTENT_SPECIAL).test(str) ? escape_html(str, is_attr) : str;
1286
+ if (is_attr) {
1287
+ return ATTR_SPECIAL.test(str) ? escape_html(str, true) : str;
1288
+ }
1289
+ return str.includes('&') || str.includes('<') ? escape_html(str, false) : str;
1287
1290
  }
1288
1291
 
1289
1292
  /**
@@ -1610,14 +1613,16 @@ export function attr(name, value, is_boolean = false) {
1610
1613
  }
1611
1614
 
1612
1615
  /**
1613
- * @param {Record<string, string | number>} styles
1616
+ * @param {Record<string, string | number | null | undefined>} styles
1614
1617
  * @returns {string}
1615
1618
  */
1616
1619
  function get_styles(styles) {
1617
1620
  var result = '';
1618
1621
  for (const key in styles) {
1622
+ const raw_value = styles[key];
1623
+ if (raw_value == null) continue;
1619
1624
  const css_prop = normalize_css_property_name(key);
1620
- const value = String(styles[key]).trim();
1625
+ const value = String(raw_value).trim();
1621
1626
  result += `${css_prop}: ${value}; `;
1622
1627
  }
1623
1628
  return result.trim();
@@ -244,6 +244,43 @@ describe('basic client > attribute rendering', () => {
244
244
  expect(div.style.fontWeight).toBe('bold');
245
245
  });
246
246
 
247
+ it.each([undefined, null])('omits %s styles and removes and restores conditional values', (
248
+ empty,
249
+ ) => {
250
+ function Basic() @{
251
+ const active = track(false);
252
+ <>
253
+ <button onClick={() => (active.value = !active.value)}>Toggle</button>
254
+ <div
255
+ style={{
256
+ width: active.value ? '400px' : empty,
257
+ '--scale': active.value ? 2 : empty,
258
+ margin: 0,
259
+ opacity: 0,
260
+ '--literal': 'undefined',
261
+ '--literal-null': 'null',
262
+ }}
263
+ />
264
+ </>
265
+ }
266
+
267
+ render(Basic);
268
+
269
+ const button = container.querySelector('button');
270
+ const div = container.querySelector('div');
271
+
272
+ for (const active of [false, true, false, true]) {
273
+ expect(div.style.width).toBe(active ? '400px' : '');
274
+ expect(div.style.getPropertyValue('--scale')).toBe(active ? '2' : '');
275
+ expect(div.style.margin).toBe('0px');
276
+ expect(div.style.opacity).toBe('0');
277
+ expect(div.style.getPropertyValue('--literal')).toBe('undefined');
278
+ expect(div.style.getPropertyValue('--literal-null')).toBe('null');
279
+ button.click();
280
+ flushSync();
281
+ }
282
+ });
283
+
247
284
  it('render tracked variable as style attribute', () => {
248
285
  function Basic() @{
249
286
  const style = track({ color: 'red', fontWeight: 'bold' });
@@ -110,8 +110,8 @@ describe('compiler > basics', () => {
110
110
 
111
111
  const { code } = compile(source, 'stringish-text.tsrx', { mode: 'client' });
112
112
 
113
- expect(code).toContain('_$_.set_text');
114
- expect(code).toContain('nodeValue = title');
113
+ expect(code).toContain('_$_.set_text_content');
114
+ expect(code).toContain('textContent = title');
115
115
  expect(count_occurrences(code, '_$_.expression')).toBe(2);
116
116
  });
117
117
 
@@ -0,0 +1,404 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { flushSync, track, type Tracked } from 'ripple';
3
+
4
+ interface Cell {
5
+ id: string;
6
+ text: string;
7
+ }
8
+
9
+ interface Row {
10
+ id: number;
11
+ label: string;
12
+ active: boolean;
13
+ cells: Cell[];
14
+ }
15
+
16
+ const cells = (prefix: string) => [
17
+ { id: 'a', text: `${prefix}a` },
18
+ { id: 'b', text: `${prefix}b` },
19
+ ];
20
+
21
+ // An item body whose updates live in one render block carries that block on
22
+ // the item block (`_$_.item`): the item re-runs its update function alone,
23
+ // and the nested blocks of its body (a keyed list of cells here) stay.
24
+ describe('list items carrying their render block', () => {
25
+ it('re-renders an item replaced under the same key and keeps its nested list', () => {
26
+ let set: (rows: Row[]) => void = () => {};
27
+
28
+ function App() @{
29
+ const rows = track<Row[]>([
30
+ { id: 1, label: 'one', active: false, cells: cells('1') },
31
+ { id: 2, label: 'two', active: false, cells: cells('2') },
32
+ ]);
33
+ set = (next) => {
34
+ rows.value = next;
35
+ };
36
+ <table>
37
+ <tbody>
38
+ @for (const row of rows.value; key row.id) {
39
+ <tr class={row.active ? 'active' : 'inactive'}>
40
+ <th>{row.label}</th>
41
+ @for (const cell of row.cells; key cell.id) {
42
+ <td>{cell.text}</td>
43
+ }
44
+ </tr>
45
+ }
46
+ </tbody>
47
+ </table>
48
+ }
49
+
50
+ render(App);
51
+ flushSync();
52
+
53
+ const rows = () => Array.from(container.querySelectorAll('tr'));
54
+ const text = () => rows().map((tr) => `${tr.className}:${tr.textContent}`);
55
+ expect(text()).toEqual(['inactive:one1a1b', 'inactive:two2a2b']);
56
+
57
+ const [first, second] = rows();
58
+ const first_cells = Array.from(first.querySelectorAll('td'));
59
+
60
+ // The first row object is replaced under its key; its cell objects are
61
+ // kept, so the nested list has nothing to do.
62
+ const original = [
63
+ { id: 1, label: 'one', active: false, cells: cells('1') },
64
+ { id: 2, label: 'two', active: false, cells: cells('2') },
65
+ ];
66
+ set(original);
67
+ flushSync();
68
+ set([{ ...original[0], label: 'uno', active: true }, original[1]]);
69
+ flushSync();
70
+
71
+ expect(text()).toEqual(['active:uno1a1b', 'inactive:two2a2b']);
72
+ expect(rows()[0]).toBe(first);
73
+ expect(rows()[1]).toBe(second);
74
+ expect(Array.from(first.querySelectorAll('td'))).toEqual(first_cells);
75
+
76
+ // A new cell array replaces the cells; the row's own DOM is kept.
77
+ set([
78
+ {
79
+ ...original[0],
80
+ cells: [
81
+ { id: 'a', text: 'A' },
82
+ { id: 'c', text: 'C' },
83
+ ],
84
+ },
85
+ original[1],
86
+ ]);
87
+ flushSync();
88
+ expect(text()).toEqual(['inactive:oneAC', 'inactive:two2a2b']);
89
+ expect(rows()[0]).toBe(first);
90
+ });
91
+
92
+ it('updates a plain (unkeyed) item body through the item block', () => {
93
+ let set: (items: { text: string }[]) => void = () => {};
94
+
95
+ function App() @{
96
+ const items = track([{ text: 'a' }, { text: 'b' }]);
97
+ set = (next) => {
98
+ items.value = next;
99
+ };
100
+ <div>
101
+ @for (const item of items.value) {
102
+ <span>{item.text}</span>
103
+ }
104
+ </div>
105
+ }
106
+
107
+ render(App);
108
+ flushSync();
109
+ expect(container.querySelector('div')?.textContent).toBe('ab');
110
+
111
+ set([{ text: 'a' }, { text: 'c' }, { text: 'd' }]);
112
+ flushSync();
113
+ expect(container.querySelector('div')?.textContent).toBe('acd');
114
+ });
115
+
116
+ it('destroys an item that re-ran its update function', () => {
117
+ let set: (items: { id: number; text: string }[]) => void = () => {};
118
+
119
+ function App() @{
120
+ const items = track([
121
+ { id: 1, text: 'a' },
122
+ { id: 2, text: 'b' },
123
+ ]);
124
+ set = (next) => {
125
+ items.value = next;
126
+ };
127
+ <div>
128
+ @for (const item of items.value; key item.id) {
129
+ <span>{item.text}</span>
130
+ }
131
+ </div>
132
+ }
133
+
134
+ render(App);
135
+ flushSync();
136
+
137
+ set([
138
+ { id: 1, text: 'A' },
139
+ { id: 2, text: 'B' },
140
+ ]);
141
+ flushSync();
142
+ expect(container.querySelector('div')?.textContent).toBe('AB');
143
+
144
+ set([
145
+ { id: 2, text: 'B' },
146
+ ]);
147
+ flushSync();
148
+ expect(container.querySelector('div')?.textContent).toBe('B');
149
+ expect(container.querySelectorAll('span').length).toBe(1);
150
+ });
151
+ });
152
+
153
+ // A keyed list that re-lays most of its range walks it front to back,
154
+ // inserting each item before the first node not yet in place.
155
+ describe('keyed list re-lay', () => {
156
+ const ids = (n: number) => Array.from({ length: n }, (_, i) => i);
157
+
158
+ it('reverses the middle after dropping both ends (kivi worst case)', () => {
159
+ let set: (items: number[]) => void = () => {};
160
+
161
+ function App() @{
162
+ const items = track(ids(40));
163
+ set = (next) => {
164
+ items.value = next;
165
+ };
166
+ <div>
167
+ @for (const item of items.value; key item) {
168
+ <span data-id={item}>{item}</span>
169
+ }
170
+ </div>
171
+ }
172
+
173
+ render(App);
174
+ flushSync();
175
+
176
+ const before = Array.from(container.querySelectorAll('span'));
177
+ const next = ids(40).slice(1, -1).reverse();
178
+ set(next);
179
+ flushSync();
180
+
181
+ const after = Array.from(container.querySelectorAll('span'));
182
+ expect(after.map((el) => el.textContent)).toEqual(next.map(String));
183
+ for (const el of after) {
184
+ expect(el).toBe(before[Number(el.dataset.id)]);
185
+ }
186
+ });
187
+
188
+ it('creates new items inside a re-laid range in order', () => {
189
+ let set: (items: number[]) => void = () => {};
190
+
191
+ function App() @{
192
+ const items = track(ids(40));
193
+ set = (next) => {
194
+ items.value = next;
195
+ };
196
+ <div>
197
+ <b>{'head'}</b>
198
+ @for (const item of items.value; key item) {
199
+ <span data-id={item}>{item}</span>
200
+ }
201
+ <i>{'tail'}</i>
202
+ </div>
203
+ }
204
+
205
+ render(App);
206
+ flushSync();
207
+
208
+ const before = Array.from(container.querySelectorAll('span'));
209
+ const reversed = ids(40).reverse();
210
+ const next = [100, ...reversed.slice(0, 20), 101, ...reversed.slice(20), 102];
211
+ set(next);
212
+ flushSync();
213
+
214
+ const children = Array.from(container.querySelector('div')!.children);
215
+ expect(children[0].localName).toBe('b');
216
+ expect(children[children.length - 1].localName).toBe('i');
217
+ expect(children.slice(1, -1).map((el) => el.textContent)).toEqual(next.map(String));
218
+ for (const el of children.slice(1, -1)) {
219
+ const id = Number((el as HTMLElement).dataset.id);
220
+ if (id < 40) expect(el).toBe(before[id]);
221
+ }
222
+
223
+ set(ids(40));
224
+ flushSync();
225
+ expect(Array.from(container.querySelectorAll('span')).map((el) => el.textContent)).toEqual(
226
+ ids(40).map(String),
227
+ );
228
+ });
229
+ });
230
+
231
+ // A trailing `@if` or `@for` behind template siblings appends into its parent
232
+ // (no `<!>` placeholder); the if materializes an anchor when it swaps.
233
+ describe('trailing control flow appends into the parent', () => {
234
+ it('keeps a trailing @if in place across branch swaps', () => {
235
+ let set: (value: number) => void = () => {};
236
+
237
+ function App() @{
238
+ const n = track(0);
239
+ set = (value) => {
240
+ n.value = value;
241
+ };
242
+ <div>
243
+ <b>{'head'}</b>
244
+ @if (n.value === 0) {
245
+ <span>{'zero'}</span>
246
+ } @else if (n.value === 1) {
247
+ <em>{'one'}</em>
248
+ }
249
+ </div>
250
+ }
251
+
252
+ render(App);
253
+ flushSync();
254
+ const div = container.querySelector('div')!;
255
+ expect(div.innerHTML).toBe('<b>head</b><span>zero</span>');
256
+
257
+ set(1);
258
+ flushSync();
259
+ expect(div.textContent).toBe('headone');
260
+ expect(div.querySelector('em')).not.toBeNull();
261
+
262
+ set(2);
263
+ flushSync();
264
+ expect(div.textContent).toBe('head');
265
+
266
+ set(0);
267
+ flushSync();
268
+ expect(div.textContent).toBe('headzero');
269
+ expect(div.firstElementChild?.localName).toBe('b');
270
+ expect(div.lastElementChild?.localName).toBe('span');
271
+ });
272
+
273
+ it('keeps a trailing @for with @empty in place', () => {
274
+ let set: (items: string[]) => void = () => {};
275
+
276
+ function App() @{
277
+ const items = track(['a', 'b']);
278
+ set = (next) => {
279
+ items.value = next;
280
+ };
281
+ <div>
282
+ <b>{'head'}</b>
283
+ @for (const item of items.value; key item) {
284
+ <span>{item}</span>
285
+ } @empty {
286
+ <i>{'none'}</i>
287
+ }
288
+ </div>
289
+ }
290
+
291
+ render(App);
292
+ flushSync();
293
+ const div = container.querySelector('div')!;
294
+ expect(div.innerHTML).toBe('<b>head</b><span>a</span><span>b</span>');
295
+
296
+ set([]);
297
+ flushSync();
298
+ expect(div.innerHTML).toBe('<b>head</b><i>none</i>');
299
+
300
+ set(['c', 'a']);
301
+ flushSync();
302
+ expect(div.innerHTML).toBe('<b>head</b><span>c</span><span>a</span>');
303
+
304
+ set(['a', 'c', 'd']);
305
+ flushSync();
306
+ expect(div.innerHTML).toBe('<b>head</b><span>a</span><span>c</span><span>d</span>');
307
+ });
308
+ });
309
+
310
+ // A keyed item read only by its update function is held as it is (no
311
+ // tracked): a replaced item re-runs its block directly, and tracked values
312
+ // inside the item still subscribe the block.
313
+ describe('local keyed items', () => {
314
+ it('re-renders a replaced item and follows a tracked value it holds', () => {
315
+ let set: (items: { id: number; label: Tracked<string> }[]) => void = () => {};
316
+ let a: Tracked<string>;
317
+ let b: Tracked<string>;
318
+ let c: Tracked<string>;
319
+
320
+ function App() @{
321
+ a = track('a');
322
+ b = track('b');
323
+ c = track('one');
324
+ const items = track([
325
+ { id: 1, label: a },
326
+ { id: 2, label: b },
327
+ ]);
328
+ set = (next) => {
329
+ items.value = next;
330
+ };
331
+ <div>
332
+ @for (const item of items.value; key item.id) {
333
+ <span>{item.id + ':' + item.label.value}</span>
334
+ }
335
+ </div>
336
+ }
337
+
338
+ render(App);
339
+ flushSync();
340
+ const div = container.querySelector('div')!;
341
+ expect(div.textContent).toBe('1:a2:b');
342
+ const spans = Array.from(div.querySelectorAll('span'));
343
+
344
+ a.value = 'A';
345
+ flushSync();
346
+ expect(div.textContent).toBe('1:A2:b');
347
+
348
+ set([
349
+ { id: 2, label: b },
350
+ { id: 1, label: c },
351
+ ]);
352
+ flushSync();
353
+ expect(div.textContent).toBe('2:b1:one');
354
+ expect(Array.from(div.querySelectorAll('span'))).toEqual([spans[1], spans[0]]);
355
+
356
+ a.value = 'AA';
357
+ flushSync();
358
+ expect(div.textContent).toBe('2:b1:one');
359
+ });
360
+
361
+ it('clears and restores leaf text', () => {
362
+ let set: (items: { id: number; text: string }[]) => void = () => {};
363
+
364
+ function App() @{
365
+ const items = track<{ id: number; text: string }[]>([
366
+ { id: 1, text: 'x' },
367
+ ]);
368
+ set = (next) => {
369
+ items.value = next;
370
+ };
371
+ <div>
372
+ @for (const item of items.value; key item.id) {
373
+ <span>{item.text}</span>
374
+ }
375
+ </div>
376
+ }
377
+
378
+ render(App);
379
+ flushSync();
380
+ const span = container.querySelector('span')!;
381
+ expect(span.textContent).toBe('x');
382
+ expect(span.childNodes.length).toBe(1);
383
+
384
+ set([
385
+ { id: 1, text: '' },
386
+ ]);
387
+ flushSync();
388
+ expect(span.childNodes.length).toBe(0);
389
+
390
+ set([
391
+ { id: 1, text: 'y' },
392
+ ]);
393
+ flushSync();
394
+ expect(span.textContent).toBe('y');
395
+ expect(span.childNodes.length).toBe(1);
396
+
397
+ set([
398
+ { id: 1, text: 'z' },
399
+ ]);
400
+ flushSync();
401
+ expect(span.textContent).toBe('z');
402
+ expect(span.childNodes.length).toBe(1);
403
+ });
404
+ });