ripple 0.2.211 → 0.2.212

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,16 @@
1
1
  # ripple
2
2
 
3
+ ## 0.2.212
4
+
5
+ ### Patch Changes
6
+
7
+ - Fix hydration error when component is last sibling - added `hydrate_advance()`
8
+ to safely advance hydration position at end of component content without
9
+ throwing when no next sibling exists
10
+
11
+ - Updated dependencies []:
12
+ - ripple@0.2.212
13
+
3
14
  ## 0.2.211
4
15
 
5
16
  ### Patch Changes
package/README.md CHANGED
@@ -5,5 +5,8 @@
5
5
 
6
6
  # What is Ripple?
7
7
 
8
+ [![npm version](https://img.shields.io/npm/v/ripple?logo=npm)](https://www.npmjs.com/package/ripple)
9
+ [![npm downloads](https://img.shields.io/npm/dm/ripple?logo=npm&label=downloads)](https://www.npmjs.com/package/ripple)
10
+
8
11
  Ripple is an elegant TypeScript UI framework. To find out more, view
9
12
  [Ripple's Github README](https://github.com/Ripple-TS/ripple).
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.211",
6
+ "version": "0.2.212",
7
7
  "type": "module",
8
8
  "module": "src/runtime/index-client.js",
9
9
  "main": "src/runtime/index-client.js",
@@ -41,6 +41,9 @@
41
41
  "./compiler/internal/import": {
42
42
  "types": "./src/compiler/types/import.d.ts"
43
43
  },
44
+ "./compiler/internal/rpc": {
45
+ "types": "./src/compiler/types/rpc.d.ts"
46
+ },
44
47
  "./compiler/internal/identifier/utils": {
45
48
  "default": "./src/compiler/identifier-utils.js"
46
49
  },
@@ -93,6 +96,6 @@
93
96
  "vscode-languageserver-types": "^3.17.5"
94
97
  },
95
98
  "peerDependencies": {
96
- "ripple": "0.2.211"
99
+ "ripple": "0.2.212"
97
100
  }
98
101
  }
@@ -2308,13 +2308,17 @@ const visitors = {
2308
2308
  },
2309
2309
 
2310
2310
  TryStatement(node, context) {
2311
- if (context.state.to_ts) {
2312
- return transform_ts_child(node, context);
2313
- }
2314
-
2315
2311
  if (!is_inside_component(context)) {
2312
+ if (context.state.to_ts) {
2313
+ return transform_ts_child(node, SetContextForOutsideComponent(context));
2314
+ }
2315
+
2316
2316
  return context.next();
2317
2317
  }
2318
+
2319
+ if (context.state.to_ts) {
2320
+ return transform_ts_child(node, context);
2321
+ }
2318
2322
  context.state.template?.push('<!>');
2319
2323
 
2320
2324
  const id = context.state.flush_node?.();
@@ -2999,6 +3003,72 @@ function collect_returns_from_children(children) {
2999
3003
  return returns;
3000
3004
  }
3001
3005
 
3006
+ /**
3007
+ * Check if an Element has any dynamic content that would trigger flush_node().
3008
+ * An Element has dynamic content if it has:
3009
+ * - Dynamic attributes (tracked expressions in attribute values)
3010
+ * - Control flow children (IfStatement, ForOfStatement, etc.)
3011
+ * - Dynamic text children (non-Literal Text nodes)
3012
+ * - Non-DOM element children (components)
3013
+ * - Html children
3014
+ * - Dynamic descendants (recursive)
3015
+ * @param {AST.Element} element
3016
+ * @returns {boolean}
3017
+ */
3018
+ function element_has_dynamic_content(element) {
3019
+ // Check for dynamic attributes
3020
+ for (const attr of element.attributes) {
3021
+ if (attr.type === 'Attribute') {
3022
+ // Dynamic value expression (not null, not Literal)
3023
+ if (attr.value !== null && attr.value.type !== 'Literal') {
3024
+ return true;
3025
+ }
3026
+ // Tracked attribute name
3027
+ if (attr.name.tracked) {
3028
+ return true;
3029
+ }
3030
+ } else if (attr.type === 'SpreadAttribute' || attr.type === 'RefAttribute') {
3031
+ return true;
3032
+ }
3033
+ }
3034
+
3035
+ // Check children for dynamic content
3036
+ for (const child of element.children) {
3037
+ if (
3038
+ child.type === 'IfStatement' ||
3039
+ child.type === 'TryStatement' ||
3040
+ child.type === 'ForOfStatement' ||
3041
+ child.type === 'SwitchStatement' ||
3042
+ child.type === 'TsxCompat' ||
3043
+ child.type === 'Html'
3044
+ ) {
3045
+ return true;
3046
+ }
3047
+ if (child.type === 'Text' && child.expression.type !== 'Literal') {
3048
+ return true;
3049
+ }
3050
+ // Non-DOM element (component)
3051
+ if (
3052
+ child.type === 'Element' &&
3053
+ (child.id.type !== 'Identifier' || !is_element_dom_element(child))
3054
+ ) {
3055
+ return true;
3056
+ }
3057
+ // Recursively check DOM element children
3058
+ if (
3059
+ child.type === 'Element' &&
3060
+ child.id.type === 'Identifier' &&
3061
+ is_element_dom_element(child)
3062
+ ) {
3063
+ if (element_has_dynamic_content(child)) {
3064
+ return true;
3065
+ }
3066
+ }
3067
+ }
3068
+
3069
+ return false;
3070
+ }
3071
+
3002
3072
  /**
3003
3073
  *
3004
3074
  * @param {AST.Node[]} children
@@ -3286,6 +3356,77 @@ function transform_children(children, context) {
3286
3356
  flush_node: /** @type {TransformClientState['flush_node']} */ (flush_node),
3287
3357
  namespace: state.namespace,
3288
3358
  });
3359
+
3360
+ // After processing an element's children via child()/sibling() navigation,
3361
+ // hydrate_node is left deep inside the element. If there's a next sibling,
3362
+ // we need to restore hydrate_node so sibling() navigation works correctly.
3363
+ //
3364
+ // We only need pop() when we actually DESCEND into the element, which happens when:
3365
+ // - There are Element children (including DOM elements like <button>)
3366
+ // - There are non-literal Text children (we navigate to set text content)
3367
+ // - There are control flow / Html / component children
3368
+ //
3369
+ // The Element visitor already adds pop() for non-literal text, control flow,
3370
+ // Html, and component (non-DOM element) children. We need to ALSO add pop()
3371
+ // when there are DOM element children, which the Element visitor doesn't cover.
3372
+ const next_node = normalized[node_idx + 1];
3373
+ if (next_node && is_element_dom_element(node) && node.children.length > 0) {
3374
+ // Check if any child is a DOM element - this causes navigation but
3375
+ // the Element visitor doesn't add pop() for it
3376
+ const has_dom_element_children = node.children.some(
3377
+ (child) =>
3378
+ child.type === 'Element' &&
3379
+ child.id.type === 'Identifier' &&
3380
+ is_element_dom_element(child),
3381
+ );
3382
+
3383
+ // Check if the Element visitor already added pop()
3384
+ const element_visitor_adds_pop = node.children.some(
3385
+ (child) =>
3386
+ child.type === 'IfStatement' ||
3387
+ child.type === 'TryStatement' ||
3388
+ child.type === 'ForOfStatement' ||
3389
+ child.type === 'SwitchStatement' ||
3390
+ child.type === 'TsxCompat' ||
3391
+ child.type === 'Html' ||
3392
+ (child.type === 'Element' &&
3393
+ (child.id.type !== 'Identifier' || !is_element_dom_element(child))) ||
3394
+ (child.type === 'Text' && child.expression.type !== 'Literal'),
3395
+ );
3396
+
3397
+ // Add pop() if we have DOM element children AND the Element visitor didn't already add pop()
3398
+ if (has_dom_element_children && !element_visitor_adds_pop) {
3399
+ // Only add pop() if next_node will actually generate a sibling() call.
3400
+ // Static Text nodes (Literals) and static Elements don't call flush_node().
3401
+ let needs_sibling_call = false;
3402
+ if (next_node.type === 'Element') {
3403
+ // Static DOM elements with no dynamic content don't generate sibling()
3404
+ if (is_element_dom_element(next_node)) {
3405
+ needs_sibling_call = element_has_dynamic_content(next_node);
3406
+ } else {
3407
+ // Components always generate sibling()
3408
+ needs_sibling_call = true;
3409
+ }
3410
+ } else if (next_node.type === 'Text') {
3411
+ // Only dynamic text generates sibling()
3412
+ needs_sibling_call = next_node.expression.type !== 'Literal';
3413
+ } else if (
3414
+ next_node.type === 'Html' ||
3415
+ next_node.type === 'IfStatement' ||
3416
+ next_node.type === 'TryStatement' ||
3417
+ next_node.type === 'ForOfStatement' ||
3418
+ next_node.type === 'SwitchStatement' ||
3419
+ next_node.type === 'TsxCompat'
3420
+ ) {
3421
+ needs_sibling_call = true;
3422
+ }
3423
+
3424
+ if (needs_sibling_call) {
3425
+ const id = flush_node();
3426
+ state.init?.push(b.stmt(b.call('_$_.pop', id)));
3427
+ }
3428
+ }
3429
+ }
3289
3430
  } else if (node.type === 'TsxCompat') {
3290
3431
  skipped = 0;
3291
3432
 
@@ -8,12 +8,6 @@ import type { RippleCompileError, CompileOptions } from 'ripple/compiler';
8
8
  import type { Position } from 'acorn';
9
9
  import type { RequireAllOrNone } from '#helpers';
10
10
 
11
- export type RpcModules = Map<string, [string, string]>;
12
-
13
- declare global {
14
- var rpc_modules: RpcModules | undefined;
15
- }
16
-
17
11
  export type NameSpace = keyof typeof NAMESPACE_URI;
18
12
  interface BaseNodeMetaData {
19
13
  scoped?: boolean;
@@ -140,6 +134,7 @@ declare module 'estree' {
140
134
  ServerIdentifier: ServerIdentifier;
141
135
  Text: TextNode;
142
136
  JSXEmptyExpression: ESTreeJSX.JSXEmptyExpression;
137
+ ParenthesizedExpression: ParenthesizedExpression;
143
138
  }
144
139
 
145
140
  // Missing estree type
@@ -0,0 +1,5 @@
1
+ export type RpcModules = Map<string, [string, string]>;
2
+
3
+ declare global {
4
+ var rpc_modules: RpcModules | undefined;
5
+ }
@@ -28,6 +28,10 @@ export function hydrate_next() {
28
28
  return set_hydrate_node(get_next_sibling(/** @type {Node} */ (hydrate_node)));
29
29
  }
30
30
 
31
+ export function hydrate_advance() {
32
+ hydrate_node = get_next_sibling(/** @type {Node} */ (hydrate_node));
33
+ }
34
+
31
35
  export function next(n = 1) {
32
36
  if (hydrating) {
33
37
  var node = hydrate_node;
@@ -6,21 +6,49 @@ import * as devalue from 'devalue';
6
6
  */
7
7
  export async function rpc(hash, args) {
8
8
  const body = devalue.stringify(args);
9
- let data;
9
+ /** @type {Response} */
10
+ let response;
10
11
 
11
12
  try {
12
- const response = await fetch('/_$_ripple_rpc_$_/' + hash, {
13
+ response = await fetch('/_$_ripple_rpc_$_/' + hash, {
13
14
  method: 'POST',
14
15
  headers: {
15
16
  'Content-Type': 'application/json',
16
17
  },
17
18
  body,
18
19
  });
19
- data = await response.text();
20
20
  } catch (err) {
21
21
  throw new Error('An error occurred while trying to call the server function.');
22
22
  }
23
23
 
24
+ if (!response.ok) {
25
+ let message = `Server function call failed with status ${response.status}`;
26
+ let error_body;
27
+ try {
28
+ error_body = await response.text();
29
+ } catch {
30
+ // ignore parse errors, use default message
31
+ }
32
+
33
+ if (error_body) {
34
+ try {
35
+ const parsed = JSON.parse(error_body);
36
+
37
+ if (parsed && typeof parsed.error === 'string' && parsed.error.length > 0) {
38
+ message = parsed.error;
39
+ } else {
40
+ message = error_body;
41
+ }
42
+ } catch {
43
+ message = error_body;
44
+ }
45
+ }
46
+
47
+ throw new Error(message);
48
+ }
49
+
50
+ const data = await response.text();
51
+
24
52
  if (data === '') {
25
53
  throw new Error(
26
54
  'The server function end-point did not return a response. Are you running a Ripple server?',
@@ -8,7 +8,7 @@ import {
8
8
  HYDRATION_START,
9
9
  HYDRATION_END,
10
10
  } from '../../../constants.js';
11
- import { hydrate_next, hydrate_node, hydrating, pop } from './hydration.js';
11
+ import { hydrate_advance, hydrate_next, hydrate_node, hydrating, pop } from './hydration.js';
12
12
  import { create_text, get_first_child, get_next_sibling, is_firefox } from './operations.js';
13
13
  import { active_block, active_namespace } from './runtime.js';
14
14
 
@@ -217,7 +217,9 @@ export function append(anchor, dom, skip_advance) {
217
217
  }
218
218
  }
219
219
 
220
- hydrate_next();
220
+ // Only advance if there's a next sibling. At the end of a component's
221
+ // content, there might not be more siblings, and that's fine.
222
+ hydrate_advance();
221
223
  return;
222
224
  }
223
225
  anchor.before(/** @type {Node} */ (dom));
@@ -0,0 +1,159 @@
1
+ import { compile_to_volar_mappings } from 'ripple/compiler';
2
+
3
+ function count_occurrences(string: string, sub_string: string): number {
4
+ let count = 0;
5
+ let pos = string.indexOf(sub_string);
6
+
7
+ while (pos !== -1) {
8
+ count++;
9
+ pos = string.indexOf(sub_string, pos + sub_string.length);
10
+ }
11
+
12
+ return count;
13
+ }
14
+
15
+ // TryStatement in the Volar transform should behave like IfStatement and
16
+ // SwitchStatement: try blocks inside functions within a component must not
17
+ // be duplicated into the component body.
18
+
19
+ describe('compiler > Volar transform does not duplicate try blocks from functions', () => {
20
+ it('try inside an async function', () => {
21
+ const source = `export component App() {
22
+ async function doWork() {
23
+ try {
24
+ await fetch('/api');
25
+ } catch (e) {
26
+ console.error(e);
27
+ }
28
+ }
29
+ <div onclick={doWork}>{"click"}</div>
30
+ }`;
31
+ const result = compile_to_volar_mappings(source, 'test.ripple').code;
32
+
33
+ expect(count_occurrences(result, 'try {')).toBe(1);
34
+ expect(count_occurrences(result, 'catch')).toBe(1);
35
+ expect(count_occurrences(result, 'await fetch')).toBe(1);
36
+ });
37
+
38
+ it('try inside an arrow function', () => {
39
+ const source = `export component App() {
40
+ const doWork = async () => {
41
+ try {
42
+ await fetch('/api');
43
+ } catch (e) {}
44
+ };
45
+ <div onclick={doWork}>{"click"}</div>
46
+ }`;
47
+ const result = compile_to_volar_mappings(source, 'test.ripple').code;
48
+
49
+ expect(count_occurrences(result, 'try {')).toBe(1);
50
+ expect(count_occurrences(result, 'await fetch')).toBe(1);
51
+ });
52
+
53
+ it('try-catch-finally inside a function', () => {
54
+ const source = `export component App() {
55
+ async function save() {
56
+ try {
57
+ await fetch('/save');
58
+ } catch (e) {
59
+ console.error(e);
60
+ } finally {
61
+ console.log('done');
62
+ }
63
+ }
64
+ <div onclick={save}>{"save"}</div>
65
+ }`;
66
+ const result = compile_to_volar_mappings(source, 'test.ripple').code;
67
+
68
+ expect(count_occurrences(result, 'try {')).toBe(1);
69
+ expect(count_occurrences(result, 'catch')).toBe(1);
70
+ expect(count_occurrences(result, 'finally')).toBe(1);
71
+ });
72
+
73
+ it('try at component top level is preserved', () => {
74
+ const source = `export component App() {
75
+ try {
76
+ await fetch('/api');
77
+ } catch (e) {}
78
+ <div>{"hi"}</div>
79
+ }`;
80
+ const result = compile_to_volar_mappings(source, 'test.ripple').code;
81
+
82
+ expect(count_occurrences(result, 'try {')).toBe(1);
83
+ expect(count_occurrences(result, 'await fetch')).toBe(1);
84
+ });
85
+
86
+ it('component-level try and function-level try coexist without duplication', () => {
87
+ const source = `export component App() {
88
+ try {
89
+ await fetch('/init');
90
+ } catch (e) {}
91
+
92
+ async function refresh() {
93
+ try {
94
+ await fetch('/refresh');
95
+ } catch (e) {}
96
+ }
97
+ <div onclick={refresh}>{"click"}</div>
98
+ }`;
99
+ const result = compile_to_volar_mappings(source, 'test.ripple').code;
100
+
101
+ expect(count_occurrences(result, 'try {')).toBe(2);
102
+ expect(count_occurrences(result, 'await fetch(\'/init\')')).toBe(1);
103
+ expect(count_occurrences(result, 'await fetch(\'/refresh\')')).toBe(1);
104
+ });
105
+
106
+ it('try in nested functions', () => {
107
+ const source = `export component App() {
108
+ function outer() {
109
+ async function inner() {
110
+ try {
111
+ await fetch('/api');
112
+ } catch (e) {}
113
+ }
114
+ }
115
+ <div>{"hi"}</div>
116
+ }`;
117
+ const result = compile_to_volar_mappings(source, 'test.ripple').code;
118
+
119
+ expect(count_occurrences(result, 'try {')).toBe(1);
120
+ expect(count_occurrences(result, 'await fetch')).toBe(1);
121
+ });
122
+
123
+ it('multiple functions with try blocks each appear once', () => {
124
+ const source = `export component App() {
125
+ async function load() {
126
+ try {
127
+ await fetch('/load');
128
+ } catch (e) {}
129
+ }
130
+ async function save() {
131
+ try {
132
+ await fetch('/save');
133
+ } catch (e) {}
134
+ }
135
+ <div>{"hi"}</div>
136
+ }`;
137
+ const result = compile_to_volar_mappings(source, 'test.ripple').code;
138
+
139
+ expect(count_occurrences(result, 'try {')).toBe(2);
140
+ expect(count_occurrences(result, 'await fetch')).toBe(2);
141
+ });
142
+
143
+ it('try in object method', () => {
144
+ const source = `export component App() {
145
+ const handlers = {
146
+ async onClick() {
147
+ try {
148
+ await fetch('/api');
149
+ } catch (e) {}
150
+ }
151
+ };
152
+ <div>{"hi"}</div>
153
+ }`;
154
+ const result = compile_to_volar_mappings(source, 'test.ripple').code;
155
+
156
+ expect(count_occurrences(result, 'try {')).toBe(1);
157
+ expect(count_occurrences(result, 'await fetch')).toBe(1);
158
+ });
159
+ });
@@ -81,4 +81,27 @@ describe('hydration > basic', () => {
81
81
  expect(container.querySelector('.playground-link')?.textContent).toBe('Playground');
82
82
  expect(container.querySelector('.content')).toBeTruthy();
83
83
  });
84
+
85
+ // Test for hydrate_advance() in append() - component as last sibling with no following siblings
86
+ it('hydrates component as last sibling (no following siblings)', async () => {
87
+ await hydrateComponent(
88
+ ServerComponents.ComponentAsLastSibling,
89
+ ClientComponents.ComponentAsLastSibling,
90
+ );
91
+ expect(container.querySelector('.wrapper')).toBeTruthy();
92
+ expect(container.querySelector('h1')?.textContent).toBe('Header');
93
+ expect(container.querySelector('p')?.textContent).toBe('Some content');
94
+ expect(container.querySelector('.last-child')?.textContent).toBe('I am the last child');
95
+ });
96
+
97
+ it('hydrates nested component with inner component as last sibling', async () => {
98
+ await hydrateComponent(
99
+ ServerComponents.NestedComponentAsLastSibling,
100
+ ClientComponents.NestedComponentAsLastSibling,
101
+ );
102
+ expect(container.querySelector('.outer')).toBeTruthy();
103
+ expect(container.querySelector('h2')?.textContent).toBe('Section title');
104
+ expect(container.querySelector('.inner span')?.textContent).toBe('Inner text');
105
+ expect(container.querySelector('.inner .last-child')?.textContent).toBe('I am the last child');
106
+ });
84
107
  });
@@ -21,6 +21,10 @@ var root_17 = _$_.template(`<main><div class="container"><!></div></main>`, 0);
21
21
  var root_18 = _$_.template(`<div class="content"><p>Some content here</p></div>`, 0);
22
22
  var root_20 = _$_.template(`<!><!><!><!>`, 1);
23
23
  var root_19 = _$_.template(`<!>`, 1);
24
+ var root_21 = _$_.template(`<footer class="last-child">I am the last child</footer>`, 0);
25
+ var root_22 = _$_.template(`<div class="wrapper"><h1>Header</h1><p>Some content</p><!></div>`, 0);
26
+ var root_23 = _$_.template(`<div class="inner"><span>Inner text</span><!></div>`, 0);
27
+ var root_24 = _$_.template(`<section class="outer"><h2>Section title</h2><!></section>`, 0);
24
28
 
25
29
  export function StaticText(__anchor, _, __block) {
26
30
  _$_.push_component();
@@ -328,4 +332,65 @@ export function WebsiteIndex(__anchor, _, __block) {
328
332
 
329
333
  _$_.append(__anchor, fragment_8);
330
334
  _$_.pop_component();
335
+ }
336
+
337
+ function LastChild(__anchor, _, __block) {
338
+ _$_.push_component();
339
+
340
+ var footer_1 = root_21();
341
+
342
+ _$_.append(__anchor, footer_1);
343
+ _$_.pop_component();
344
+ }
345
+
346
+ export function ComponentAsLastSibling(__anchor, _, __block) {
347
+ _$_.push_component();
348
+
349
+ var div_11 = root_22();
350
+
351
+ {
352
+ var h1_1 = _$_.child(div_11);
353
+ var p_1 = _$_.sibling(h1_1);
354
+ var node_12 = _$_.sibling(p_1);
355
+
356
+ LastChild(node_12, {}, _$_.active_block);
357
+ _$_.pop(div_11);
358
+ }
359
+
360
+ _$_.append(__anchor, div_11);
361
+ _$_.pop_component();
362
+ }
363
+
364
+ function InnerContent(__anchor, _, __block) {
365
+ _$_.push_component();
366
+
367
+ var div_12 = root_23();
368
+
369
+ {
370
+ var span_5 = _$_.child(div_12);
371
+ var node_13 = _$_.sibling(span_5);
372
+
373
+ LastChild(node_13, {}, _$_.active_block);
374
+ _$_.pop(div_12);
375
+ }
376
+
377
+ _$_.append(__anchor, div_12);
378
+ _$_.pop_component();
379
+ }
380
+
381
+ export function NestedComponentAsLastSibling(__anchor, _, __block) {
382
+ _$_.push_component();
383
+
384
+ var section_1 = root_24();
385
+
386
+ {
387
+ var h2_1 = _$_.child(section_1);
388
+ var node_14 = _$_.sibling(h2_1);
389
+
390
+ InnerContent(node_14, {}, _$_.active_block);
391
+ _$_.pop(section_1);
392
+ }
393
+
394
+ _$_.append(__anchor, section_1);
395
+ _$_.pop_component();
331
396
  }