ripple 0.2.186 → 0.2.188

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/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.2.186",
6
+ "version": "0.2.188",
7
7
  "type": "module",
8
8
  "module": "src/runtime/index-client.js",
9
9
  "main": "src/runtime/index-client.js",
@@ -86,6 +86,6 @@
86
86
  "vscode-languageserver-types": "^3.17.5"
87
87
  },
88
88
  "peerDependencies": {
89
- "ripple": "0.2.186"
89
+ "ripple": "0.2.188"
90
90
  }
91
91
  }
@@ -422,25 +422,11 @@ const visitors = {
422
422
  context.visit(node.discriminant, context.state);
423
423
 
424
424
  for (const switch_case of node.cases) {
425
- // Fallthrough
425
+ // Skip empty cases
426
426
  if (switch_case.consequent.length === 0) {
427
427
  continue;
428
428
  }
429
429
 
430
- // Validate that each cases ends in a break statement, except for the last case
431
- const last = switch_case.consequent?.[switch_case.consequent.length - 1];
432
-
433
- if (
434
- last.type !== 'BreakStatement' &&
435
- node.cases.indexOf(switch_case) !== node.cases.length - 1
436
- ) {
437
- error(
438
- 'Template switch cases must end with a break statement (with the exception of the last case).',
439
- context.state.analysis.module.filename,
440
- switch_case,
441
- );
442
- }
443
-
444
430
  node.metadata = {
445
431
  ...node.metadata,
446
432
  has_template: false,
@@ -1830,31 +1830,41 @@ const visitors = {
1830
1830
  const statements = [];
1831
1831
  const cases = [];
1832
1832
 
1833
- let i = 1;
1834
-
1833
+ let id_gen = 0;
1834
+ let counter = 0;
1835
1835
  for (const switch_case of node.cases) {
1836
1836
  const case_body = [];
1837
+ const consequent = switch_case.consequent;
1838
+
1839
+ if (consequent.length !== 0) {
1840
+ const consequent_scope = context.state.scopes.get(consequent) || context.state.scope;
1837
1841
 
1838
- if (switch_case.consequent.length !== 0) {
1839
- const consequent_scope =
1840
- context.state.scopes.get(switch_case.consequent) || context.state.scope;
1842
+ const block = transform_body(consequent, {
1843
+ ...context,
1844
+ state: { ...context.state, scope: consequent_scope },
1845
+ });
1846
+ const has_break = consequent.some((stmt) => stmt.type === 'BreakStatement');
1847
+ const is_last = counter === node.cases.length - 1;
1848
+ const is_default = switch_case.test == null;
1841
1849
  const consequent_id = context.state.scope.generate(
1842
- 'switch_case_' + (switch_case.test == null ? 'default' : i),
1850
+ 'switch_case_' + (is_default ? 'default' : id_gen),
1843
1851
  );
1844
- const consequent = b.block(
1845
- transform_body(switch_case.consequent, {
1846
- ...context,
1847
- state: { ...context.state, scope: consequent_scope },
1848
- }),
1849
- );
1850
-
1851
- statements.push(b.var(b.id(consequent_id), b.arrow([b.id('__anchor')], consequent)));
1852
1852
 
1853
- case_body.push(b.return(b.id(consequent_id)));
1853
+ statements.push(b.var(b.id(consequent_id), b.arrow([b.id('__anchor')], b.block(block))));
1854
+ case_body.push(
1855
+ b.stmt(b.call(b.member(b.id('result'), b.id('push'), false), b.id(consequent_id))),
1856
+ );
1854
1857
 
1855
- i++;
1858
+ // in js, `default:` can be in the middle without a break
1859
+ // so we only add return for the last case or cases with a break
1860
+ if (has_break || is_last) {
1861
+ case_body.push(b.return(b.id('result')));
1862
+ }
1863
+ id_gen++;
1856
1864
  }
1857
1865
 
1866
+ counter++;
1867
+
1858
1868
  cases.push(
1859
1869
  b.switch_case(
1860
1870
  switch_case.test ? /** @type {AST.Expression} */ (context.visit(switch_case.test)) : null,
@@ -1870,6 +1880,7 @@ const visitors = {
1870
1880
  id,
1871
1881
  b.thunk(
1872
1882
  b.block([
1883
+ b.var(b.id('result'), b.array([])),
1873
1884
  b.switch(/** @type {AST.Expression} */ (context.visit(node.discriminant)), cases),
1874
1885
  ]),
1875
1886
  ),
@@ -2,32 +2,76 @@
2
2
 
3
3
  import { branch, destroy_block, render } from './blocks.js';
4
4
  import { SWITCH_BLOCK } from './constants.js';
5
+ import { next_sibling } from './operations.js';
6
+ import { append } from './template.js';
5
7
 
6
8
  /**
7
- * @param {Node} node
8
- * @param {() => ((anchor: Node) => void)} fn
9
+ * Moves a block's DOM nodes to before an anchor node
10
+ * @param {Block} block
11
+ * @param {ChildNode} anchor
9
12
  * @returns {void}
10
13
  */
11
- export function switch_block(node, fn) {
12
- var anchor = node;
13
- /** @type {any} */
14
- var current_branch = null;
15
- /** @type {Block | null} */
16
- var b = null;
14
+ function move(block, anchor) {
15
+ var node = block.s.start;
16
+ var end = block.s.end;
17
+ /** @type {Node | null} */
18
+ var sibling;
19
+
20
+ while (node !== null) {
21
+ if (node === end) {
22
+ append(anchor, node);
23
+ break;
24
+ }
25
+ sibling = next_sibling(node);
26
+ append(anchor, node);
27
+ node = sibling;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * @param {ChildNode} anchor
33
+ * @param {() => ((anchor: ChildNode) => void)[] | null} fn
34
+ * @returns {void}
35
+ */
36
+ export function switch_block(anchor, fn) {
37
+ /** @type {((anchor: ChildNode) => void)[]} */
38
+ var prev = [];
39
+ /** @type {Map<(anchor: ChildNode) => void, Block>} */
40
+ var blocks = new Map();
17
41
 
18
42
  render(
19
43
  () => {
20
- const branch_fn = fn() ?? null;
21
- if (current_branch === branch_fn) return;
22
- current_branch = branch_fn;
44
+ var funcs = fn();
45
+ let same = prev.length === funcs?.length || false;
23
46
 
24
- if (b !== null) {
25
- destroy_block(b);
26
- b = null;
47
+ for (var i = 0; i < prev.length; i++) {
48
+ var p = prev[i];
49
+
50
+ if (!funcs || funcs.indexOf(p) === -1) {
51
+ same = false;
52
+ destroy_block(/** @type {Block} */ (blocks.get(p)));
53
+ blocks.delete(p);
54
+ }
27
55
  }
28
56
 
29
- if (branch_fn !== null) {
30
- b = branch(() => branch_fn(anchor));
57
+ prev = funcs ?? [];
58
+
59
+ if (same || !funcs) {
60
+ return;
61
+ }
62
+
63
+ for (var i = 0; i < funcs.length; i++) {
64
+ var n = funcs[i];
65
+ var b = blocks.get(n);
66
+ if (b) {
67
+ move(b, anchor);
68
+ continue;
69
+ }
70
+
71
+ blocks.set(
72
+ n,
73
+ branch(() => n(anchor)),
74
+ );
31
75
  }
32
76
  },
33
77
  null,
@@ -89,7 +89,7 @@ describe('switch statements', () => {
89
89
  expect(container.querySelector('div').textContent).toBe('Default for y');
90
90
  });
91
91
 
92
- it('renders switch using fallthrough without recreating DOM unnecessarily', () => {
92
+ it('renders switch using empty case fall-through', () => {
93
93
  component App() {
94
94
  let value = track('a');
95
95
 
@@ -131,7 +131,7 @@ describe('switch statements', () => {
131
131
  expect(container.querySelector('div').textContent).toBe('DOM check');
132
132
  });
133
133
 
134
- it('renders switch with template content and JS logic', () => {
134
+ it('renders switch with template content and reacts to tracked changes', () => {
135
135
  component App() {
136
136
  let status = track('active');
137
137
  let message = track('');
@@ -180,4 +180,140 @@ describe('switch statements', () => {
180
180
  expect(container.querySelector('div').textContent).toBe('Status: An error occurred.');
181
181
  expect(container.querySelector('.error')).toBeTruthy();
182
182
  });
183
+
184
+ it(
185
+ 'renders switch with multiple non-empty fall-through cases and reacts to tracked changes without recreating DOM unnecessarily',
186
+ () => {
187
+ component App() {
188
+ let status = track(0);
189
+ <div>
190
+ switch (@status) {
191
+ case -1:
192
+ case 0:
193
+ <p>{' Idle'}</p>
194
+ case 1:
195
+ <p>{' Loading'}</p>
196
+ case 2:
197
+ <p>{' Success'}</p>
198
+ break;
199
+ default:
200
+ <p>{' Unknown status'}</p>
201
+ <p>{' Unknown 2'}</p>
202
+ case 3:
203
+ <p>{' Error'}</p>
204
+ <p>{' Error 2'}</p>
205
+ <p>{' Error 3'}</p>
206
+ break;
207
+ }
208
+ </div>
209
+ <button
210
+ onClick={() => {
211
+ @status = (@status + 1) % 5;
212
+ }}
213
+ >
214
+ {'Next Status'}
215
+ </button>
216
+ }
217
+
218
+ render(App);
219
+ const button = container.querySelector('button');
220
+
221
+ expect(container.querySelector('div').textContent).toBe(' Idle Loading Success');
222
+
223
+ button.click();
224
+ flushSync();
225
+
226
+ expect(container.querySelector('div').textContent).toBe(' Loading Success');
227
+
228
+ button.click();
229
+ flushSync();
230
+
231
+ expect(container.querySelector('div').textContent).toBe(' Success');
232
+
233
+ button.click();
234
+ flushSync();
235
+
236
+ expect(container.querySelector('div').textContent).toBe(' Error Error 2 Error 3');
237
+
238
+ button.click();
239
+ flushSync();
240
+
241
+ expect(container.querySelector('div').textContent).toBe(
242
+ ' Unknown status Unknown 2 Error Error 2 Error 3',
243
+ );
244
+
245
+ button.click();
246
+ flushSync();
247
+
248
+ expect(container.querySelector('div').textContent).toBe(' Idle Loading Success');
249
+ },
250
+ );
251
+
252
+ it(
253
+ 'renders a fall-through default in the middle of switch cases and reacts to changes without recreating DOM unnecessarily',
254
+ () => {
255
+ component App() {
256
+ let value = track('x');
257
+
258
+ <button onClick={() => (@value = 'a')}>{'Set A'}</button>
259
+ <button onClick={() => (@value = 'b')}>{'Set B'}</button>
260
+ <button onClick={() => (@value = 'c')}>{'Set C'}</button>
261
+ <button onClick={() => (@value = 'x')}>{'Set X'}</button>
262
+
263
+ <div>
264
+ switch (@value) {
265
+ case 'a':
266
+ <div>{' Case A'}</div>
267
+ break;
268
+ // NOTE: This should be the default in the middle of the cases
269
+ // However, jsdom (and other node-based dom libs) has a bug
270
+ // that breaks out of the switch even if the default doesn't have a break
271
+ // The browser works correctly.
272
+ // So, we're just using a defined case in the middle to simulate default.
273
+ case 'x':
274
+ <div>{' Default Case for ' + @value}</div>
275
+ case 'b':
276
+ <div>{' Case B'}</div>
277
+ break;
278
+ case 'c':
279
+ <div>{' Case C'}</div>
280
+ }
281
+ </div>
282
+ }
283
+
284
+ render(App);
285
+ const [buttonA, buttonB, buttonC, buttonX] = container.querySelectorAll('button');
286
+
287
+ expect(container.querySelector('div').textContent).toBe(' Default Case for x Case B');
288
+ expect(container.querySelector('div').querySelectorAll('div').length).toBe(2);
289
+
290
+ buttonA.click();
291
+ flushSync();
292
+
293
+ expect(container.querySelector('div').textContent).toBe(' Case A');
294
+ expect(container.querySelector('div').querySelectorAll('div').length).toBe(1);
295
+
296
+ buttonC.click();
297
+ flushSync();
298
+
299
+ expect(container.querySelector('div').textContent).toBe(' Case C');
300
+ expect(container.querySelector('div').querySelectorAll('div').length).toBe(1);
301
+
302
+ buttonB.click();
303
+ flushSync();
304
+
305
+ const bDiv = container.querySelector('div').querySelectorAll('div')[0];
306
+ expect(bDiv.textContent).toBe(' Case B');
307
+ bDiv.id = 'b';
308
+
309
+ buttonX.click();
310
+ flushSync();
311
+
312
+ // order should be correct with the previously rendered B element
313
+ expect(container.querySelector('div').textContent).toBe(' Default Case for x Case B');
314
+ expect(container.querySelector('div').querySelectorAll('div').length).toBe(2);
315
+ // the previously rendered div should be preserved
316
+ expect(container.querySelector('div').querySelectorAll('div')[1].id).toBe('b');
317
+ },
318
+ );
183
319
  });
@@ -25,7 +25,7 @@ describe('SSR: switch statements', () => {
25
25
  expect(body).toBe('<div>Case B</div>');
26
26
  });
27
27
 
28
- it('renders switch using fallthrough case', async () => {
28
+ it('renders a fall-through with an empty switch case', async () => {
29
29
  component App() {
30
30
  let value = 'b';
31
31
 
@@ -45,4 +45,47 @@ describe('SSR: switch statements', () => {
45
45
  const { body } = await render(App);
46
46
  expect(body).toBe('<div>Case B or C</div>');
47
47
  });
48
+
49
+ it('renders a fall-through with a case that has elements', async () => {
50
+ component App() {
51
+ let value = 'a';
52
+
53
+ switch (value) {
54
+ case 'a':
55
+ <div>{'Case A'}</div>
56
+ case 'b':
57
+ <div>{'Case B'}</div>
58
+ case 'c':
59
+ <div>{'Case C'}</div>
60
+ break;
61
+ default:
62
+ <div>{'Default Case'}</div>
63
+ }
64
+ }
65
+
66
+ const { body } = await render(App);
67
+ expect(body).toBe('<div>Case A</div><div>Case B</div><div>Case C</div>');
68
+ });
69
+
70
+ it('renders a fall-through with a default case in the middle', async () => {
71
+ component App() {
72
+ let value = 'x';
73
+
74
+ switch (value) {
75
+ case 'a':
76
+ <div>{'Case A'}</div>
77
+ default:
78
+ <div>{'Default Case'}</div>
79
+ case 'b':
80
+ <div>{'Case B'}</div>
81
+ break;
82
+ case 'c':
83
+ <div>{'Case C'}</div>
84
+ break;
85
+ }
86
+ }
87
+
88
+ const { body } = await render(App);
89
+ expect(body).toBe('<div>Default Case</div><div>Case B</div>');
90
+ });
48
91
  });