ripple 0.2.109 → 0.2.110

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.109",
6
+ "version": "0.2.110",
7
7
  "type": "module",
8
8
  "module": "src/runtime/index-client.js",
9
9
  "main": "src/runtime/index-client.js",
@@ -165,18 +165,19 @@ function RipplePlugin(config) {
165
165
 
166
166
  // Check if this is #server
167
167
  if (this.input.slice(this.pos, this.pos + 7) === '#server') {
168
- // Check that next char after 'server' is whitespace or {
168
+ // Check that next char after 'server' is whitespace, {, . (dot), or EOF
169
169
  const charAfter =
170
170
  this.pos + 7 < this.input.length ? this.input.charCodeAt(this.pos + 7) : -1;
171
171
  if (
172
- charAfter === 123 ||
173
- charAfter === 32 ||
174
- charAfter === 9 ||
175
- charAfter === 10 ||
176
- charAfter === 13 ||
177
- charAfter === -1
172
+ charAfter === 123 || // {
173
+ charAfter === 46 || // . (dot)
174
+ charAfter === 32 || // space
175
+ charAfter === 9 || // tab
176
+ charAfter === 10 || // newline
177
+ charAfter === 13 || // carriage return
178
+ charAfter === -1 // EOF
178
179
  ) {
179
- // { or whitespace or EOF
180
+ // { or . or whitespace or EOF
180
181
  this.pos += 7; // consume '#server'
181
182
  return this.finishToken(tt.name, '#server');
182
183
  }
@@ -379,6 +380,13 @@ function RipplePlugin(config) {
379
380
  return this.parseTrackedExpression();
380
381
  }
381
382
 
383
+ // Check if this is #server identifier for server function calls
384
+ if (this.type === tt.name && this.value === '#server') {
385
+ const node = this.startNode();
386
+ this.next();
387
+ return this.finishNode(node, 'ServerIdentifier');
388
+ }
389
+
382
390
  // Check if this is a tuple literal starting with #[
383
391
  if (this.type === tt.bracketL && this.value === '#[') {
384
392
  return this.parseTrackedArrayExpression();
@@ -388,7 +396,6 @@ function RipplePlugin(config) {
388
396
 
389
397
  return super.parseExprAtom(refDestructuringErrors, forNew, forInit);
390
398
  }
391
-
392
399
  /**
393
400
  * Parse `@(expression)` syntax for unboxing tracked values
394
401
  * Creates a TrackedExpression node with the argument property
@@ -419,7 +426,21 @@ function RipplePlugin(config) {
419
426
  parseServerBlock() {
420
427
  const node = this.startNode();
421
428
  this.next();
422
- node.body = this.parseBlock();
429
+
430
+ const body = this.startNode();
431
+ node.body = body;
432
+ body.body = [];
433
+
434
+ this.expect(tt.braceL);
435
+ this.enterScope(0);
436
+ while (this.type !== tt.braceR) {
437
+ var stmt = this.parseStatement(null, true);
438
+ body.body.push(stmt);
439
+ }
440
+ this.next();
441
+ this.exitScope();
442
+ this.finishNode(body, 'BlockStatement');
443
+
423
444
  this.awaitPos = 0;
424
445
  return this.finishNode(node, 'ServerBlock');
425
446
  }
@@ -96,11 +96,46 @@ const visitors = {
96
96
  return context.next({ ...context.state, function_depth: 0, expression: null });
97
97
  },
98
98
 
99
+ ServerBlock(node, context) {
100
+ node.metadata = {
101
+ ...node.metadata,
102
+ exports: [],
103
+ };
104
+ context.visit(node.body, { ...context.state, inside_server_block: true });
105
+ },
106
+
99
107
  Identifier(node, context) {
100
108
  const binding = context.state.scope.get(node.name);
101
109
  const parent = context.path.at(-1);
102
110
 
103
- if (binding?.kind === 'prop' || binding?.kind === 'prop_fallback') {
111
+ if (
112
+ is_reference(node, /** @type {Node} */ (parent)) &&
113
+ binding &&
114
+ context.state.inside_server_block
115
+ ) {
116
+ let current_scope = binding.scope;
117
+
118
+ while (current_scope !== null) {
119
+ if (current_scope.server_block) {
120
+ break;
121
+ }
122
+ const parent_scope = current_scope.parent;
123
+ if (parent_scope === null) {
124
+ error(
125
+ `Cannot reference client-side variable "${node.name}" from a server block`,
126
+ context.state.analysis.module.filename,
127
+ node,
128
+ );
129
+ }
130
+ current_scope = parent_scope;
131
+ }
132
+ }
133
+
134
+ if (
135
+ binding?.kind === 'prop' ||
136
+ binding?.kind === 'prop_fallback' ||
137
+ binding?.kind === 'for_pattern'
138
+ ) {
104
139
  mark_as_tracked(context.path);
105
140
  if (context.state.metadata?.tracking === false) {
106
141
  context.state.metadata.tracking = true;
@@ -312,6 +347,7 @@ const visitors = {
312
347
  }
313
348
 
314
349
  node.metadata = {
350
+ ...node.metadata,
315
351
  has_template: false,
316
352
  };
317
353
 
@@ -347,7 +383,38 @@ const visitors = {
347
383
  }
348
384
  }
349
385
 
386
+ if (node.key) {
387
+ const state = context.state;
388
+ const pattern = node.left.declarations[0].id;
389
+ const paths = extract_paths(pattern);
390
+ const scope = state.scopes.get(node);
391
+ const pattern_id = b.id(scope.generate('pattern'));
392
+
393
+ node.left.declarations[0].id = pattern_id;
394
+
395
+ for (const path of paths) {
396
+ const name = path.node.name;
397
+ const binding = context.state.scope.get(name);
398
+
399
+ binding.kind = 'for_pattern';
400
+ if (!binding.metadata) {
401
+ binding.metadata = {
402
+ pattern: pattern_id,
403
+ };
404
+ }
405
+
406
+ if (binding !== null) {
407
+ binding.transform = {
408
+ read: () => {
409
+ return path.expression(b.call('_$_.get', pattern_id));
410
+ },
411
+ };
412
+ }
413
+ }
414
+ }
415
+
350
416
  node.metadata = {
417
+ ...node.metadata,
351
418
  has_template: false,
352
419
  };
353
420
  context.next();
@@ -361,6 +428,23 @@ const visitors = {
361
428
  }
362
429
  },
363
430
 
431
+ ExportNamedDeclaration(node, context) {
432
+ if (!context.state.inside_server_block) {
433
+ return context.next();
434
+ }
435
+ const server_block = context.path.find((n) => n.type === 'ServerBlock');
436
+ const declaration = node.declaration;
437
+
438
+ if (declaration && declaration.type === 'FunctionDeclaration') {
439
+ server_block.metadata.exports.push(declaration.id.name);
440
+ } else {
441
+ // TODO
442
+ throw new Error('Not implemented');
443
+ }
444
+
445
+ return context.next();
446
+ },
447
+
364
448
  TSTypeReference(node, context) {
365
449
  // bug in our acorn pasrer: it uses typeParameters instead of typeArguments
366
450
  if (node.typeParameters) {
@@ -376,6 +460,7 @@ const visitors = {
376
460
  }
377
461
 
378
462
  node.metadata = {
463
+ ...node.metadata,
379
464
  has_template: false,
380
465
  };
381
466
 
@@ -391,6 +476,7 @@ const visitors = {
391
476
 
392
477
  if (node.alternate) {
393
478
  node.metadata = {
479
+ ...node.metadata,
394
480
  has_template: false,
395
481
  };
396
482
  context.visit(node.alternate, context.state);
@@ -417,6 +503,7 @@ const visitors = {
417
503
  }
418
504
 
419
505
  node.metadata = {
506
+ ...node.metadata,
420
507
  has_template: false,
421
508
  };
422
509
 
@@ -431,6 +518,7 @@ const visitors = {
431
518
  }
432
519
 
433
520
  node.metadata = {
521
+ ...node.metadata,
434
522
  has_template: false,
435
523
  };
436
524
 
@@ -672,6 +760,7 @@ export function analyze(ast, filename) {
672
760
  scopes,
673
761
  analysis,
674
762
  inside_head: false,
763
+ inside_server_block: false,
675
764
  },
676
765
  visitors,
677
766
  );
@@ -1,4 +1,4 @@
1
- /** @import {Expression, FunctionExpression, Pattern, Node, Program} from 'estree' */
1
+ /** @import {Expression, FunctionExpression, Node, Program} from 'estree' */
2
2
 
3
3
  import { walk } from 'zimmerframe';
4
4
  import path from 'node:path';
@@ -37,6 +37,7 @@ import is_reference from 'is-reference';
37
37
  import { object } from '../../../../utils/ast.js';
38
38
  import { render_stylesheets } from '../stylesheet.js';
39
39
  import { is_event_attribute, is_passive_event } from '../../../../utils/events.js';
40
+ import { createHash } from 'node:crypto';
40
41
 
41
42
  function add_ripple_internal_import(context) {
42
43
  if (!context.state.to_ts) {
@@ -54,6 +55,7 @@ function visit_function(node, context) {
54
55
  const state = context.state;
55
56
 
56
57
  delete node.returnType;
58
+ delete node.typeParameters;
57
59
 
58
60
  for (const param of node.params) {
59
61
  delete param.typeAnnotation;
@@ -168,7 +170,8 @@ const visitors = {
168
170
  (node.tracked ||
169
171
  binding?.kind === 'prop' ||
170
172
  binding?.kind === 'index' ||
171
- binding?.kind === 'prop_fallback') &&
173
+ binding?.kind === 'prop_fallback' ||
174
+ binding?.kind === 'for_pattern') &&
172
175
  binding?.node !== node
173
176
  ) {
174
177
  if (context.state.metadata?.tracking === false) {
@@ -185,6 +188,10 @@ const visitors = {
185
188
  }
186
189
  },
187
190
 
191
+ ServerIdentifier(node, context) {
192
+ return b.id('_$_server_$_');
193
+ },
194
+
188
195
  ImportDeclaration(node, context) {
189
196
  if (!context.state.to_ts && node.importKind === 'type') {
190
197
  return b.empty;
@@ -1199,6 +1206,14 @@ const visitors = {
1199
1206
  return context.next();
1200
1207
  },
1201
1208
 
1209
+ TSInstantiationExpression(node, context) {
1210
+ if (!context.state.to_ts) {
1211
+ // In JavaScript, just return the expression wrapped in parentheses
1212
+ return b.sequence([context.visit(node.expression)]);
1213
+ }
1214
+ return context.next();
1215
+ },
1216
+
1202
1217
  ExportNamedDeclaration(node, context) {
1203
1218
  if (!context.state.to_ts && node.exportKind === 'type') {
1204
1219
  return b.empty;
@@ -1281,8 +1296,34 @@ const visitors = {
1281
1296
  return b.block(statements);
1282
1297
  },
1283
1298
 
1284
- ServerBlock() {
1285
- return b.empty;
1299
+ ServerBlock(node, context) {
1300
+ const exports = node.metadata.exports;
1301
+
1302
+ if (exports.length === 0) {
1303
+ return b.empty;
1304
+ }
1305
+ const file_path = context.state.filename;
1306
+
1307
+ return b.const(
1308
+ '_$_server_$_',
1309
+ b.object(
1310
+ exports.map((name) => {
1311
+ const func_path = file_path + '#' + name;
1312
+ // needs to be a sha256 hash of func_path, to avoid leaking file structure
1313
+ const hash = createHash('sha256').update(func_path).digest('hex').slice(0, 8);
1314
+
1315
+ return b.prop(
1316
+ 'init',
1317
+ b.id(name),
1318
+ b.function(
1319
+ null,
1320
+ [b.rest(b.id('args'))],
1321
+ b.block([b.return(b.call('_$_.rpc', b.literal(hash), b.id('args')))]),
1322
+ ),
1323
+ );
1324
+ }),
1325
+ ),
1326
+ );
1286
1327
  },
1287
1328
 
1288
1329
  Program(node, context) {
@@ -1791,6 +1832,7 @@ export function transform_client(filename, source, analysis, to_ts) {
1791
1832
  scopes: analysis.scopes,
1792
1833
  stylesheets: [],
1793
1834
  to_ts,
1835
+ filename,
1794
1836
  };
1795
1837
 
1796
1838
  const program = /** @type {Program} */ (
@@ -17,6 +17,7 @@ import is_reference from 'is-reference';
17
17
  import { escape } from '../../../../utils/escaping.js';
18
18
  import { is_event_attribute } from '../../../../utils/events.js';
19
19
  import { render_stylesheets } from '../stylesheet.js';
20
+ import { createHash } from 'node:crypto';
20
21
 
21
22
  function add_ripple_internal_import(context) {
22
23
  if (!context.state.to_ts) {
@@ -31,6 +32,10 @@ function transform_children(children, context) {
31
32
  const normalized = normalize_children(children, context);
32
33
 
33
34
  for (const node of normalized) {
35
+ if (node.type === 'BreakStatement') {
36
+ state.init.push(b.break);
37
+ continue;
38
+ }
34
39
  if (
35
40
  node.type === 'VariableDeclaration' ||
36
41
  node.type === 'ExpressionStatement' ||
@@ -132,6 +137,39 @@ const visitors = {
132
137
  return context.next();
133
138
  },
134
139
 
140
+ FunctionDeclaration(node, context) {
141
+ if (!context.state.to_ts) {
142
+ delete node.returnType;
143
+ delete node.typeParameters;
144
+ for (const param of node.params) {
145
+ delete param.typeAnnotation;
146
+ }
147
+ }
148
+ return context.next();
149
+ },
150
+
151
+ FunctionExpression(node, context) {
152
+ if (!context.state.to_ts) {
153
+ delete node.returnType;
154
+ delete node.typeParameters;
155
+ for (const param of node.params) {
156
+ delete param.typeAnnotation;
157
+ }
158
+ }
159
+ return context.next();
160
+ },
161
+
162
+ ArrowFunctionExpression(node, context) {
163
+ if (!context.state.to_ts) {
164
+ delete node.returnType;
165
+ delete node.typeParameters;
166
+ for (const param of node.params) {
167
+ delete param.typeAnnotation;
168
+ }
169
+ }
170
+ return context.next();
171
+ },
172
+
135
173
  TSAsExpression(node, context) {
136
174
  if (!context.state.to_ts) {
137
175
  return context.visit(node.expression);
@@ -139,6 +177,14 @@ const visitors = {
139
177
  return context.next();
140
178
  },
141
179
 
180
+ TSInstantiationExpression(node, context) {
181
+ if (!context.state.to_ts) {
182
+ // In JavaScript, just return the expression wrapped in parentheses
183
+ return b.sequence([context.visit(node.expression)]);
184
+ }
185
+ return context.next();
186
+ },
187
+
142
188
  TSTypeAliasDeclaration(_, context) {
143
189
  if (!context.state.to_ts) {
144
190
  return b.empty;
@@ -157,8 +203,23 @@ const visitors = {
157
203
  if (!context.state.to_ts && node.exportKind === 'type') {
158
204
  return b.empty;
159
205
  }
160
-
161
- return context.next();
206
+ if (!context.state.inside_server_block) {
207
+ return context.next();
208
+ }
209
+ const declaration = node.declaration;
210
+
211
+ if (declaration && declaration.type === 'FunctionDeclaration') {
212
+ return b.stmt(
213
+ b.assignment(
214
+ '=',
215
+ b.member(b.id('_$_server_$_'), b.id(declaration.id.name)),
216
+ context.visit(declaration),
217
+ ),
218
+ );
219
+ } else {
220
+ // TODO
221
+ throw new Error('Not implemented');
222
+ }
162
223
  },
163
224
 
164
225
  VariableDeclaration(node, context) {
@@ -380,15 +441,43 @@ const visitors = {
380
441
  }
381
442
  } else {
382
443
  // Component is imported or dynamic - check .async property at runtime
383
- const conditional_await = b.conditional(
384
- b.member(visit(node.id, state), b.id('async')),
385
- b.await(component_call),
386
- component_call,
444
+ // Use if-statement instead of ternary to avoid parser issues with await in conditionals
445
+ state.init.push(
446
+ b.if(
447
+ b.member(visit(node.id, state), b.id('async')),
448
+ b.block([b.stmt(b.await(component_call))]),
449
+ b.block([b.stmt(component_call)]),
450
+ ),
387
451
  );
388
- state.init.push(b.stmt(conditional_await));
452
+
453
+ // Mark parent component as async since we're using await
454
+ if (state.metadata?.await === false) {
455
+ state.metadata.await = true;
456
+ }
389
457
  }
390
458
  }
391
459
  },
460
+ SwitchStatement(node, context) {
461
+ if (!is_inside_component(context)) {
462
+ return context.next();
463
+ }
464
+ const cases = [];
465
+ for (const switch_case of node.cases) {
466
+ const consequent_scope =
467
+ context.state.scopes.get(switch_case.consequent) || context.state.scope;
468
+ const consequent = b.block(
469
+ transform_body(switch_case.consequent, {
470
+ ...context,
471
+ state: { ...context.state, scope: consequent_scope },
472
+ }),
473
+ );
474
+ cases.push(
475
+ b.switch_case(switch_case.test ? context.visit(switch_case.test) : null, consequent.body),
476
+ );
477
+ }
478
+ context.state.init.push(b.switch(context.visit(node.discriminant), cases));
479
+ },
480
+
392
481
  ForOfStatement(node, context) {
393
482
  if (!is_inside_component(context)) {
394
483
  context.next();
@@ -440,6 +529,10 @@ const visitors = {
440
529
  }
441
530
  },
442
531
 
532
+ ServerIdentifier(node, context) {
533
+ return b.id('_$_server_$_');
534
+ },
535
+
443
536
  ImportDeclaration(node, context) {
444
537
  if (!context.state.to_ts && node.importKind === 'type') {
445
538
  return b.empty;
@@ -599,7 +692,38 @@ const visitors = {
599
692
  },
600
693
 
601
694
  ServerBlock(node, context) {
602
- return context.visit(node.body);
695
+ const exports = node.metadata.exports;
696
+
697
+ if (exports.length === 0) {
698
+ return context.visit(node.body);
699
+ }
700
+ const file_path = context.state.filename;
701
+ const block = context.visit(node.body, { ...context.state, inside_server_block: true });
702
+ const rpc_modules = globalThis.rpc_modules;
703
+
704
+ if (rpc_modules) {
705
+ for (const name of exports) {
706
+ const func_path = file_path + '#' + name;
707
+ // needs to be a sha256 hash of func_path, to avoid leaking file structure
708
+ const hash = createHash('sha256').update(func_path).digest('hex').slice(0, 8);
709
+ rpc_modules.set(hash, [file_path, name]);
710
+ }
711
+ }
712
+
713
+ return b.export(
714
+ b.const(
715
+ '_$_server_$_',
716
+ b.call(
717
+ b.thunk(
718
+ b.block([
719
+ b.var('_$_server_$_', b.object([])),
720
+ ...block.body,
721
+ b.return(b.id('_$_server_$_')),
722
+ ]),
723
+ ),
724
+ ),
725
+ ),
726
+ );
603
727
  },
604
728
  };
605
729
 
@@ -614,6 +738,8 @@ export function transform_server(filename, source, analysis) {
614
738
  scopes: analysis.scopes,
615
739
  stylesheets: [],
616
740
  component_metadata,
741
+ inside_server_block: false,
742
+ filename,
617
743
  };
618
744
 
619
745
  const program = /** @type {ESTree.Program} */ (
@@ -169,6 +169,14 @@ export function create_scopes(ast, root, parent) {
169
169
  next({ scope });
170
170
  },
171
171
 
172
+ ServerBlock(node, { state, next }) {
173
+ const scope = state.scope.child();
174
+ scope.server_block = true;
175
+ scopes.set(node, scope);
176
+
177
+ next({ scope });
178
+ },
179
+
172
180
  FunctionExpression(node, { state, next }) {
173
181
  const scope = state.scope.child();
174
182
  scopes.set(node, scope);
@@ -327,6 +335,12 @@ export class Scope {
327
335
  */
328
336
  tracing = null;
329
337
 
338
+ /**
339
+ * Is this scope a top-level server block scope
340
+ * @type {boolean}
341
+ */
342
+ server_block = false;
343
+
330
344
  /**
331
345
  *
332
346
  * @param {ScopeRoot} root
@@ -353,7 +367,7 @@ export class Scope {
353
367
  return this.parent.declare(node, kind, declaration_kind);
354
368
  }
355
369
 
356
- if (declaration_kind === 'import') {
370
+ if (declaration_kind === 'import' && !this.parent.server_block) {
357
371
  return this.parent.declare(node, kind, declaration_kind, initial);
358
372
  }
359
373
  }
@@ -151,7 +151,7 @@ export type DeclarationKind =
151
151
  /**
152
152
  * Binding kinds
153
153
  */
154
- export type BindingKind = 'normal' | 'each' | 'rest_prop' | 'prop' | 'prop_fallback';
154
+ export type BindingKind = 'normal' | 'for_pattern' | 'rest_prop' | 'prop' | 'prop_fallback';
155
155
 
156
156
  /**
157
157
  * A variable binding in a scope
@@ -299,4 +299,4 @@ export interface DelegatedEventResult {
299
299
  hoisted: boolean;
300
300
  /** The hoisted function */
301
301
  function?: FunctionExpression | FunctionDeclaration | ArrowFunctionExpression;
302
- }
302
+ }
@@ -316,6 +316,8 @@ function get_hoisted_params(node, context) {
316
316
  if (binding !== null && !scope.declarations.has(reference) && binding.initial !== node) {
317
317
  if (binding.kind === 'prop') {
318
318
  push_unique(b.id('__props'));
319
+ } else if (binding.kind === 'for_pattern') {
320
+ push_unique(binding.metadata.pattern);
319
321
  } else if (binding.kind === 'prop_fallback') {
320
322
  push_unique(b.id(binding.node.name));
321
323
  } else if (
@@ -1,4 +1,4 @@
1
- /** @import { Block } from '#client' */
1
+ /** @import { Block, Tracked } from '#client' */
2
2
 
3
3
  import { IS_CONTROLLED, IS_INDEXED } from '../../../constants.js';
4
4
  import { branch, destroy_block, destroy_block_children, render } from './blocks.js';
@@ -12,31 +12,45 @@ import { array_from, is_array } from './utils.js';
12
12
  * @param {Node} anchor
13
13
  * @param {V} value
14
14
  * @param {number} index
15
- * @param {(anchor: Node, value: V, index?: any) => Block} render_fn
15
+ * @param {(anchor: Node, value: V | Tracked, index?: any) => Block} render_fn
16
16
  * @param {boolean} is_indexed
17
+ * @param {boolean} is_keyed
17
18
  * @returns {Block}
18
19
  */
19
- function create_item(anchor, value, index, render_fn, is_indexed) {
20
+ function create_item(anchor, value, index, render_fn, is_indexed, is_keyed) {
20
21
  var b = branch(() => {
21
22
  var tracked_index;
23
+ /** @type {V | Tracked} */
24
+ var tracked_value = value;
22
25
 
23
- if (is_indexed) {
26
+ if (is_indexed || is_keyed) {
24
27
  var block = /** @type {Block} */ (active_block);
25
28
 
26
29
  if (block.s === null) {
27
- tracked_index = tracked(index, block);
30
+ if (is_indexed) {
31
+ tracked_index = tracked(index, block);
32
+ }
33
+ if (is_keyed) {
34
+ tracked_value = tracked(value, block);
35
+ }
28
36
 
29
37
  block.s = {
30
38
  start: null,
31
39
  end: null,
32
40
  i: tracked_index,
41
+ v: tracked_value,
33
42
  };
34
43
  } else {
35
- tracked_index = block.s.i;
44
+ if (is_indexed) {
45
+ tracked_index = block.s.i;
46
+ }
47
+ if (is_keyed) {
48
+ tracked_index = block.s.v;
49
+ }
36
50
  }
37
- render_fn(anchor, value, tracked_index);
51
+ render_fn(anchor, tracked_value, tracked_index);
38
52
  } else {
39
- render_fn(anchor, value);
53
+ render_fn(anchor, tracked_value);
40
54
  }
41
55
  });
42
56
  return b;
@@ -87,7 +101,7 @@ function collection_to_array(collection) {
87
101
  * @template V
88
102
  * @param {Element} node
89
103
  * @param {() => V[] | Iterable<V>} get_collection
90
- * @param {(anchor: Node, value: V, index?: any) => Block} render_fn
104
+ * @param {(anchor: Node, value: V | Tracked, index?: any) => Block} render_fn
91
105
  * @param {number} flags
92
106
  * @returns {void}
93
107
  */
@@ -116,7 +130,7 @@ export function for_block(node, get_collection, render_fn, flags) {
116
130
  * @template K
117
131
  * @param {Element} node
118
132
  * @param {() => V[] | Iterable<V>} get_collection
119
- * @param {(anchor: Node, value: V, index?: any) => Block} render_fn
133
+ * @param {(anchor: Node, value: V | Tracked, index?: any) => Block} render_fn
120
134
  * @param {number} flags
121
135
  * @param {(item: V) => K} [get_key]
122
136
  * @returns {void}
@@ -175,13 +189,22 @@ function update_index(block, index) {
175
189
  set(block.s.i, index, block);
176
190
  }
177
191
 
192
+ /**
193
+ * @param {Block} block
194
+ * @param {any} value
195
+ * @returns {void}
196
+ */
197
+ function update_value(block, value) {
198
+ set(block.s.v, value, block);
199
+ }
200
+
178
201
  /**
179
202
  * @template V
180
203
  * @template K
181
204
  * @param {Element | Text} anchor
182
205
  * @param {Block} block
183
206
  * @param {V[]} b
184
- * @param {(anchor: Node, value: V, index?: any) => Block} render_fn
207
+ * @param {(anchor: Node, value: V | Tracked, index?: any) => Block} render_fn
185
208
  * @param {boolean} is_controlled
186
209
  * @param {boolean} is_indexed
187
210
  * @param {(item: V) => K} get_key
@@ -236,7 +259,7 @@ function reconcile_by_key(anchor, block, b, render_fn, is_controlled, is_indexed
236
259
  // Fast-path for create
237
260
  if (a_length === 0) {
238
261
  for (; j < b_length; j++) {
239
- b_blocks[j] = create_item(anchor, b[j], j, render_fn, is_indexed);
262
+ b_blocks[j] = create_item(anchor, b[j], j, render_fn, is_indexed, true);
240
263
  }
241
264
  state.array = b;
242
265
  state.blocks = b_blocks;
@@ -261,6 +284,7 @@ function reconcile_by_key(anchor, block, b, render_fn, is_controlled, is_indexed
261
284
  if (is_indexed) {
262
285
  update_index(b_block, j);
263
286
  }
287
+ update_value(b_block, b_val);
264
288
  ++j;
265
289
  if (j > a_end || j > b_end) {
266
290
  break outer;
@@ -282,6 +306,7 @@ function reconcile_by_key(anchor, block, b, render_fn, is_controlled, is_indexed
282
306
  if (is_indexed) {
283
307
  update_index(b_block, b_end);
284
308
  }
309
+ update_value(b_block, b_val);
285
310
  a_end--;
286
311
  b_end--;
287
312
  if (j > a_end || j > b_end) {
@@ -301,7 +326,7 @@ function reconcile_by_key(anchor, block, b, render_fn, is_controlled, is_indexed
301
326
  while (j <= b_end) {
302
327
  b_val = b[j];
303
328
  var target = j >= a_length ? anchor : a_blocks[j].s.start;
304
- b_blocks[j] = create_item(target, b_val, j, render_fn, is_indexed);
329
+ b_blocks[j] = create_item(target, b_val, j, render_fn, is_indexed, true);
305
330
  j++;
306
331
  }
307
332
  }
@@ -348,6 +373,7 @@ function reconcile_by_key(anchor, block, b, render_fn, is_controlled, is_indexed
348
373
  if (is_indexed) {
349
374
  update_index(b_block, j);
350
375
  }
376
+ update_value(b_block, b_val);
351
377
  ++patched;
352
378
  break;
353
379
  }
@@ -387,9 +413,11 @@ function reconcile_by_key(anchor, block, b, render_fn, is_controlled, is_indexed
387
413
  pos = j;
388
414
  }
389
415
  block = b_blocks[j] = a_blocks[i];
416
+ b_val = b[j];
390
417
  if (is_indexed) {
391
418
  update_index(block, j);
392
419
  }
420
+ update_value(b_block, b_val);
393
421
  ++patched;
394
422
  } else if (!fast_path_removal) {
395
423
  destroy_block(a_blocks[i]);
@@ -417,7 +445,7 @@ function reconcile_by_key(anchor, block, b, render_fn, is_controlled, is_indexed
417
445
  next_pos = pos + 1;
418
446
 
419
447
  var target = next_pos < b_length ? b_blocks[next_pos].s.start : anchor;
420
- b_blocks[pos] = create_item(target, b_val, pos, render_fn, is_indexed);
448
+ b_blocks[pos] = create_item(target, b_val, pos, render_fn, is_indexed, true);
421
449
  } else if (j < 0 || i !== seq[j]) {
422
450
  pos = i + b_start;
423
451
  b_val = b[pos];
@@ -437,7 +465,7 @@ function reconcile_by_key(anchor, block, b, render_fn, is_controlled, is_indexed
437
465
  next_pos = pos + 1;
438
466
 
439
467
  var target = next_pos < b_length ? b_blocks[next_pos].s.start : anchor;
440
- b_blocks[pos] = create_item(target, b_val, pos, render_fn, is_indexed);
468
+ b_blocks[pos] = create_item(target, b_val, pos, render_fn, is_indexed, true);
441
469
  }
442
470
  }
443
471
  }
@@ -452,7 +480,7 @@ function reconcile_by_key(anchor, block, b, render_fn, is_controlled, is_indexed
452
480
  * @param {Element | Text} anchor
453
481
  * @param {Block} block
454
482
  * @param {V[]} b
455
- * @param {(anchor: Node, value: V, index?: any) => Block} render_fn
483
+ * @param {(anchor: Node, value: V | Tracked, index?: any) => Block} render_fn
456
484
  * @param {boolean} is_controlled
457
485
  * @param {boolean} is_indexed
458
486
  * @returns {void}
@@ -505,7 +533,7 @@ function reconcile_by_ref(anchor, block, b, render_fn, is_controlled, is_indexed
505
533
  // Fast-path for create
506
534
  if (a_length === 0) {
507
535
  for (; j < b_length; j++) {
508
- b_blocks[j] = create_item(anchor, b[j], j, render_fn, is_indexed);
536
+ b_blocks[j] = create_item(anchor, b[j], j, render_fn, is_indexed, false);
509
537
  }
510
538
  state.array = b;
511
539
  state.blocks = b_blocks;
@@ -560,7 +588,7 @@ function reconcile_by_ref(anchor, block, b, render_fn, is_controlled, is_indexed
560
588
  while (j <= b_end) {
561
589
  b_val = b[j];
562
590
  var target = j >= a_length ? anchor : a_blocks[j].s.start;
563
- b_blocks[j] = create_item(target, b_val, j, render_fn, is_indexed);
591
+ b_blocks[j] = create_item(target, b_val, j, render_fn, is_indexed, false);
564
592
  j++;
565
593
  }
566
594
  }
@@ -673,7 +701,7 @@ function reconcile_by_ref(anchor, block, b, render_fn, is_controlled, is_indexed
673
701
  next_pos = pos + 1;
674
702
 
675
703
  var target = next_pos < b_length ? b_blocks[next_pos].s.start : anchor;
676
- b_blocks[pos] = create_item(target, b_val, pos, render_fn, is_indexed);
704
+ b_blocks[pos] = create_item(target, b_val, pos, render_fn, is_indexed, false);
677
705
  } else if (j < 0 || i !== seq[j]) {
678
706
  pos = i + b_start;
679
707
  b_val = b[pos];
@@ -693,7 +721,7 @@ function reconcile_by_ref(anchor, block, b, render_fn, is_controlled, is_indexed
693
721
  next_pos = pos + 1;
694
722
 
695
723
  var target = next_pos < b_length ? b_blocks[next_pos].s.start : anchor;
696
- b_blocks[pos] = create_item(target, b_val, pos, render_fn, is_indexed);
724
+ b_blocks[pos] = create_item(target, b_val, pos, render_fn, is_indexed, false);
697
725
  }
698
726
  }
699
727
  }
@@ -70,3 +70,5 @@ export { head } from './head.js';
70
70
  export { script } from './script.js';
71
71
 
72
72
  export { html } from './html.js';
73
+
74
+ export { rpc } from './rpc.js';
@@ -0,0 +1,14 @@
1
+
2
+ /**
3
+ * @param {string} hash
4
+ * @param {any[]} args
5
+ */
6
+ export function rpc(hash, args) {
7
+ return fetch('/_$_ripple_rpc_$_/' + hash, {
8
+ method: 'POST',
9
+ headers: {
10
+ 'Content-Type': 'application/json'
11
+ },
12
+ body: JSON.stringify(args)
13
+ }).then(res => res.json());
14
+ }
@@ -220,6 +220,17 @@ export function export_default(declaration) {
220
220
  return { type: 'ExportDefaultDeclaration', declaration };
221
221
  }
222
222
 
223
+ /**
224
+ * @param {ESTree.Declaration | null} declaration
225
+ * @param {ESTree.ExportSpecifier[]} [specifiers]
226
+ * @param {ESTree.ImportAttribute[]} [attributes]
227
+ * @param {ESTree.Literal | null} [source]
228
+ * @returns {ESTree.ExportNamedDeclaration}
229
+ */
230
+ export function export_builder(declaration, specifiers = [], attributes = [], source = null) {
231
+ return { type: 'ExportNamedDeclaration', declaration, specifiers, attributes, source };
232
+ }
233
+
223
234
  /**
224
235
  * @param {ESTree.Identifier} id
225
236
  * @param {ESTree.Pattern[]} params
@@ -581,16 +592,17 @@ export function method(kind, key, params, body, computed = false, is_static = fa
581
592
  * @param {ESTree.Identifier | null} id
582
593
  * @param {ESTree.Pattern[]} params
583
594
  * @param {ESTree.BlockStatement} body
595
+ * @param {boolean} async
584
596
  * @returns {ESTree.FunctionExpression}
585
597
  */
586
- function function_builder(id, params, body) {
598
+ function function_builder(id, params, body, async = false) {
587
599
  return {
588
600
  type: 'FunctionExpression',
589
601
  id,
590
602
  params,
591
603
  body,
592
604
  generator: false,
593
- async: false,
605
+ async,
594
606
  metadata: /** @type {any} */ (null), // should not be used by codegen
595
607
  };
596
608
  }
@@ -812,6 +824,7 @@ export {
812
824
  let_builder as let,
813
825
  const_builder as const,
814
826
  var_builder as var,
827
+ export_builder as export,
815
828
  true_instance as true,
816
829
  false_instance as false,
817
830
  break_statement as break,
@@ -1,5 +1,18 @@
1
1
  // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
2
 
3
+ exports[`compiler success tests > compiles TSInstantiationExpression 1`] = `
4
+ "import * as _$_ from 'ripple/internal/client';
5
+
6
+ function makeBox(value) {
7
+ return { value };
8
+ }
9
+
10
+ const makeStringBox = (makeBox);
11
+ const stringBox = makeStringBox('abc');
12
+ const ErrorMap = (Map);
13
+ const errorMap = new ErrorMap();"
14
+ `;
15
+
3
16
  exports[`compiler success tests > compiles tracked values in effect with assignment expression 1`] = `"state.count = _$_.get(count);"`;
4
17
 
5
18
  exports[`compiler success tests > compiles tracked values in effect with update expressions 1`] = `
@@ -198,7 +198,7 @@ exports[`for statements > correctly handles the index in a for...of loop 3`] = `
198
198
  </div>
199
199
  `;
200
200
 
201
- exports[`for statements > handles reversing an array manually 1`] = `
201
+ exports[`for statements > handles updating with new objects with same key 1`] = `
202
202
  <div>
203
203
  <!---->
204
204
  <div>
@@ -218,17 +218,17 @@ exports[`for statements > handles reversing an array manually 1`] = `
218
218
  </div>
219
219
  `;
220
220
 
221
- exports[`for statements > handles reversing an array manually 2`] = `
221
+ exports[`for statements > handles updating with new objects with same key 2`] = `
222
222
  <div>
223
223
  <!---->
224
224
  <div>
225
- 0:Item 3
225
+ 0:Item 1!
226
226
  </div>
227
227
  <div>
228
- 1:Item 2
228
+ 1:Item 2!
229
229
  </div>
230
230
  <div>
231
- 2:Item 1
231
+ 2:Item 3!
232
232
  </div>
233
233
  <!---->
234
234
  <button>
@@ -495,4 +495,19 @@ effect(() => {
495
495
  const effectMatch = result.js.code.match(/effect\(\(\) => \{([\s\S]+?)\n\t\}\)\)/);
496
496
  expect(effectMatch[1].trim()).toMatchSnapshot();
497
497
  });
498
+
499
+ it('compiles TSInstantiationExpression', () => {
500
+ const source =
501
+ `function makeBox<T>(value: T) {
502
+ return { value };
503
+ }
504
+ const makeStringBox = makeBox<string>;
505
+ const stringBox = makeStringBox('abc');
506
+ const ErrorMap = Map<string, Error>;
507
+ const errorMap = new ErrorMap();`;
508
+
509
+ const result = compile(source, 'test.ripple', { mode: 'client' });
510
+
511
+ expect(result.js.code).toMatchSnapshot();
512
+ });
498
513
  });
@@ -659,4 +659,35 @@ describe('composite components', () => {
659
659
 
660
660
  expect(container.querySelector('#container').textContent).toBe('I am child 1');
661
661
  });
662
+
663
+ it('mutating a tracked value prop should work as intended', () => {
664
+ const logs = [];
665
+
666
+ component Counter({count}) {
667
+ effect(() => {
668
+ logs.push(@count);
669
+ })
670
+
671
+ <button onClick={() => @count = @count + 1}>{'+'}</button>
672
+ }
673
+
674
+ component App() {
675
+ const count = track(0);
676
+
677
+ <div>
678
+ <Counter count={count} />
679
+ </div>
680
+ }
681
+
682
+ render(App);
683
+ flushSync();
684
+
685
+ expect(logs).toEqual([0]);
686
+
687
+ const button = container.querySelector('button');
688
+ button.click();
689
+ flushSync();
690
+
691
+ expect(logs).toEqual([0, 1]);
692
+ })
662
693
  });
@@ -145,12 +145,12 @@ describe('for statements', () => {
145
145
  expect(container).toMatchSnapshot();
146
146
  });
147
147
 
148
- it('handles reversing an array manually', () => {
148
+ it('handles updating with new objects with same key', () => {
149
149
  component App() {
150
150
  let items = track([
151
- #{ id: 1, text: 'Item 1' },
152
- #{ id: 2, text: 'Item 2' },
153
- #{ id: 3, text: 'Item 3' },
151
+ { id: 1, text: 'Item 1' },
152
+ { id: 2, text: 'Item 2' },
153
+ { id: 3, text: 'Item 3' },
154
154
  ]);
155
155
 
156
156
  for (let item of @items; index i; key item.id) {
@@ -163,9 +163,9 @@ describe('for statements', () => {
163
163
  @items[2].id = 1;
164
164
 
165
165
  @items = [
166
- @items[0],
167
- @items[1],
168
- @items[2],
166
+ {...@items[0], text: 'Item 1!'},
167
+ {...@items[1], text: 'Item 2!'},
168
+ {...@items[2], text: 'Item 3!'},
169
169
  ];
170
170
  }}>{"Reverse"}</button>
171
171
  }
@@ -0,0 +1,152 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mount, flushSync, track } from 'ripple';
3
+
4
+ describe('switch statements', () => {
5
+ let container;
6
+
7
+ function render(component) {
8
+ mount(component, {
9
+ target: container
10
+ });
11
+ }
12
+
13
+ beforeEach(() => {
14
+ container = document.createElement('div');
15
+ document.body.appendChild(container);
16
+ });
17
+
18
+ afterEach(() => {
19
+ document.body.removeChild(container);
20
+ container = null;
21
+ });
22
+
23
+ it('renders simple switch with literal cases', () => {
24
+ component App() {
25
+ let value = track('b');
26
+
27
+ <button onClick={() => @value = 'c'}>{'Change to C'}</button>
28
+ <button onClick={() => @value = 'a'}>{'Change to A'}</button>
29
+
30
+ switch (@value) {
31
+ case 'a':
32
+ <div>{'Case A'}</div>
33
+ break;
34
+ case 'b':
35
+ <div>{'Case B'}</div>
36
+ break;
37
+ case 'c':
38
+ <div>{'Case C'}</div>
39
+ break;
40
+ default:
41
+ <div>{'Default Case'}</div>
42
+ }
43
+ }
44
+
45
+ render(App);
46
+ expect(container.textContent).toBe('Change to CChange to ACase B');
47
+
48
+ container.querySelectorAll('button')[0].click(); // Change to C
49
+ flushSync();
50
+ expect(container.textContent).toBe('Change to CChange to ACase C');
51
+
52
+ container.querySelectorAll('button')[1].click(); // Change to A
53
+ flushSync();
54
+ expect(container.textContent).toBe('Change to CChange to ACase A');
55
+ });
56
+
57
+ it('renders switch with reactive discriminant', () => {
58
+ component App() {
59
+ let count = track(1);
60
+
61
+ <button onClick={() => @count++}>{'Increment'}</button>
62
+
63
+ switch (@count) {
64
+ case 1:
65
+ <div>{'Count is 1'}</div>
66
+ break;
67
+ case 2:
68
+ <div>{'Count is 2'}</div>
69
+ break;
70
+ default:
71
+ <div>{'Count is other'}</div>
72
+ }
73
+ }
74
+
75
+ render(App);
76
+ expect(container.textContent).toBe('IncrementCount is 1');
77
+
78
+ container.querySelector('button').click();
79
+ flushSync();
80
+ expect(container.textContent).toBe('IncrementCount is 2');
81
+
82
+ container.querySelector('button').click();
83
+ flushSync();
84
+ expect(container.textContent).toBe('IncrementCount is other');
85
+ });
86
+
87
+ it('renders switch with default clause only', () => {
88
+ component App() {
89
+ let value = track('x');
90
+
91
+ <button onClick={() => @value = 'y'}>{'Change Value'}</button>
92
+
93
+ switch (@value) {
94
+ default:
95
+ <div>{'Default for ' + @value}</div>
96
+ }
97
+ }
98
+
99
+ render(App);
100
+ expect(container.textContent).toBe('Change ValueDefault for x');
101
+
102
+ container.querySelector('button').click();
103
+ flushSync();
104
+ expect(container.textContent).toBe('Change ValueDefault for y');
105
+ });
106
+
107
+ it('renders switch with template content and JS logic', () => {
108
+ component App() {
109
+ let status = track('active');
110
+ let message = track('');
111
+
112
+ <button onClick={() => @status = 'pending'}>{'Pending'}</button>
113
+ <button onClick={() => @status = 'completed'}>{'Completed'}</button>
114
+ <button onClick={() => @status = 'error'}>{'Error'}</button>
115
+
116
+ switch (@status) {
117
+ case 'active':
118
+ message = 'Currently active.';
119
+ <div>{'Status: ' + @message}</div>
120
+ break;
121
+ case 'pending':
122
+ message = 'Waiting for completion.';
123
+ <div>{'Status: ' + @message}</div>
124
+ break;
125
+ case 'completed':
126
+ message = 'Task finished!';
127
+ <div class="success">{'Status: ' + @message}</div>
128
+ break;
129
+ default:
130
+ message = 'An error occurred.';
131
+ <div class="error">{'Status: ' + @message}</div>
132
+ }
133
+ }
134
+
135
+ render(App);
136
+ expect(container.textContent).toBe('PendingCompletedErrorStatus: Currently active.');
137
+
138
+ container.querySelectorAll('button')[0].click(); // Pending
139
+ flushSync();
140
+ expect(container.textContent).toBe('PendingCompletedErrorStatus: Waiting for completion.');
141
+
142
+ container.querySelectorAll('button')[1].click(); // Completed
143
+ flushSync();
144
+ expect(container.textContent).toBe('PendingCompletedErrorStatus: Task finished!');
145
+ expect(container.querySelector('.success')).toBeTruthy();
146
+
147
+ container.querySelectorAll('button')[2].click(); // Error
148
+ flushSync();
149
+ expect(container.textContent).toBe('PendingCompletedErrorStatus: An error occurred.');
150
+ expect(container.querySelector('.error')).toBeTruthy();
151
+ });
152
+ });
@@ -0,0 +1,37 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`compiler success tests > compiles TSInstantiationExpression 1`] = `
4
+ "import * as _$_ from 'ripple/internal/client';
5
+
6
+ function makeBox(value) {
7
+ return { value };
8
+ }
9
+
10
+ const makeStringBox = (makeBox);
11
+ const stringBox = makeStringBox('abc');
12
+ const ErrorMap = (Map);
13
+ const errorMap = new ErrorMap();"
14
+ `;
15
+
16
+ exports[`compiler success tests > compiles imported component with conditional async in SSR 1`] = `
17
+ "import * as _$_ from 'ripple/internal/server';
18
+
19
+ import { ChildComponent } from './Child.ripple';
20
+
21
+ export async function App(__output) {
22
+ return _$_.async(async () => {
23
+ _$_.push_component();
24
+ __output.push('<div');
25
+ __output.push('>');
26
+
27
+ if (ChildComponent.async) {
28
+ await ChildComponent(__output, { message: "hello" });
29
+ } else {
30
+ ChildComponent(__output, { message: "hello" });
31
+ }
32
+
33
+ __output.push('</div>');
34
+ _$_.pop_component();
35
+ });
36
+ }"
37
+ `;
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { compile } from 'ripple/compiler'
3
+
4
+ describe('compiler success tests', () => {
5
+
6
+
7
+ it('compiles TSInstantiationExpression', () => {
8
+ const source =
9
+ `function makeBox<T>(value: T) {
10
+ return { value };
11
+ }
12
+ const makeStringBox = makeBox<string>;
13
+ const stringBox = makeStringBox('abc');
14
+ const ErrorMap = Map<string, Error>;
15
+ const errorMap = new ErrorMap();`;
16
+
17
+ const result = compile(source, 'test.ripple', { mode: 'client' });
18
+
19
+ expect(result.js.code).toMatchSnapshot();
20
+ });
21
+
22
+ it('compiles imported component with conditional async in SSR', () => {
23
+ const source =
24
+ `import { ChildComponent } from './Child.ripple';
25
+
26
+ export component App() {
27
+ <div>
28
+ <ChildComponent message="hello" />
29
+ </div>
30
+ }`;
31
+
32
+ const result = compile(source, 'test.ripple', { mode: 'server' });
33
+
34
+ // Should use if-statement instead of ternary to avoid parser issues
35
+ expect(result.js.code).toContain('if (ChildComponent.async)');
36
+ expect(result.js.code).toContain('await ChildComponent');
37
+ expect(result.js.code).toMatchSnapshot();
38
+ });
39
+ });
@@ -0,0 +1,27 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render } from 'ripple/server';
3
+
4
+ describe('SSR: switch statements', () => {
5
+ it('renders simple switch with literal cases', async () => {
6
+ component App() {
7
+ let value = 'b';
8
+
9
+ switch (value) {
10
+ case 'a':
11
+ <div>{'Case A'}</div>
12
+ break;
13
+ case 'b':
14
+ <div>{'Case B'}</div>
15
+ break;
16
+ case 'c':
17
+ <div>{'Case C'}</div>
18
+ break;
19
+ default:
20
+ <div>{'Default Case'}</div>
21
+ }
22
+ }
23
+
24
+ const { body } = await render(App);
25
+ expect(body).toBe('<div>Case B</div>');
26
+ });
27
+ });