@tsrx/core 0.0.23 → 0.0.24

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": "Core compiler infrastructure for TSRX syntax",
4
4
  "license": "MIT",
5
5
  "author": "Dominic Gannaway",
6
- "version": "0.0.23",
6
+ "version": "0.0.24",
7
7
  "type": "module",
8
8
  "repository": {
9
9
  "type": "git",
@@ -7,6 +7,18 @@ import { error } from '../errors.js';
7
7
 
8
8
  export const COMPONENT_RETURN_VALUE_ERROR =
9
9
  'Return statements inside components cannot have a return value.';
10
+ export const COMPONENT_LOOP_RETURN_ERROR =
11
+ 'Return statements are not allowed inside component for...of loops. Use continue instead.';
12
+ export const COMPONENT_LOOP_BREAK_ERROR =
13
+ 'Break statements are not allowed inside component for...of loops.';
14
+ export const COMPONENT_FOR_STATEMENT_ERROR =
15
+ 'For loops are not supported in components. Use for...of instead.';
16
+ export const COMPONENT_FOR_IN_STATEMENT_ERROR =
17
+ 'For...in loops are not supported in components. Use for...of instead.';
18
+ export const COMPONENT_WHILE_STATEMENT_ERROR =
19
+ 'While loops are not supported in components. Move the while loop into a function.';
20
+ export const COMPONENT_DO_WHILE_STATEMENT_ERROR =
21
+ 'Do...while loops are not supported in components. Move the do...while loop into a function.';
10
22
 
11
23
  const invalid_nestings = {
12
24
  // <p> cannot contain block-level elements
@@ -133,19 +145,29 @@ function get_element_tag(element) {
133
145
  * @returns {AST.ReturnStatement}
134
146
  */
135
147
  export function get_return_keyword_node(node) {
136
- const return_keyword_length = 'return'.length;
148
+ return get_statement_keyword_node(node, 'return');
149
+ }
150
+
151
+ /**
152
+ * @template {AST.Node} T
153
+ * @param {T} node
154
+ * @param {string} keyword
155
+ * @returns {T}
156
+ */
157
+ export function get_statement_keyword_node(node, keyword) {
158
+ const keyword_length = keyword.length;
137
159
  const start = /** @type {AST.NodeWithLocation} */ (node).start ?? 0;
138
160
  const loc = /** @type {AST.NodeWithLocation} */ (node).loc;
139
161
 
140
- return /** @type {AST.ReturnStatement} */ ({
162
+ return /** @type {T} */ ({
141
163
  ...node,
142
- end: start + return_keyword_length,
164
+ end: start + keyword_length,
143
165
  loc: loc
144
166
  ? {
145
167
  start: loc.start,
146
168
  end: {
147
169
  line: loc.start.line,
148
- column: loc.start.column + return_keyword_length,
170
+ column: loc.start.column + keyword_length,
149
171
  },
150
172
  }
151
173
  : undefined,
@@ -172,6 +194,59 @@ export function validate_component_return_statement(node, filename, errors, comm
172
194
  );
173
195
  }
174
196
 
197
+ /**
198
+ * @param {AST.ReturnStatement} node
199
+ * @param {string | null | undefined} filename
200
+ * @param {CompileError[]} [errors]
201
+ * @param {AST.CommentWithLocation[]} [comments]
202
+ */
203
+ export function validate_component_loop_return_statement(node, filename, errors, comments) {
204
+ error(
205
+ COMPONENT_LOOP_RETURN_ERROR,
206
+ filename ?? null,
207
+ get_return_keyword_node(node),
208
+ errors,
209
+ comments,
210
+ );
211
+ }
212
+
213
+ /**
214
+ * @param {AST.BreakStatement} node
215
+ * @param {string | null | undefined} filename
216
+ * @param {CompileError[]} [errors]
217
+ * @param {AST.CommentWithLocation[]} [comments]
218
+ */
219
+ export function validate_component_loop_break_statement(node, filename, errors, comments) {
220
+ error(
221
+ COMPONENT_LOOP_BREAK_ERROR,
222
+ filename ?? null,
223
+ get_statement_keyword_node(node, 'break'),
224
+ errors,
225
+ comments,
226
+ );
227
+ }
228
+
229
+ /**
230
+ * @param {AST.ForStatement | AST.ForInStatement | AST.WhileStatement | AST.DoWhileStatement} node
231
+ * @param {string | null | undefined} filename
232
+ * @param {CompileError[]} [errors]
233
+ * @param {AST.CommentWithLocation[]} [comments]
234
+ */
235
+ export function validate_component_unsupported_loop_statement(node, filename, errors, comments) {
236
+ let message;
237
+ if (node.type === 'ForStatement') {
238
+ message = COMPONENT_FOR_STATEMENT_ERROR;
239
+ } else if (node.type === 'ForInStatement') {
240
+ message = COMPONENT_FOR_IN_STATEMENT_ERROR;
241
+ } else if (node.type === 'WhileStatement') {
242
+ message = COMPONENT_WHILE_STATEMENT_ERROR;
243
+ } else {
244
+ message = COMPONENT_DO_WHILE_STATEMENT_ERROR;
245
+ }
246
+
247
+ error(message, filename ?? null, node, errors, comments);
248
+ }
249
+
175
250
  /**
176
251
  * @param {AST.Element} element
177
252
  * @param {AnalysisContext} context
package/src/index.js CHANGED
@@ -140,6 +140,7 @@ export { escape } from './utils/escaping.js';
140
140
  export {
141
141
  createJsxTransform,
142
142
  merge_duplicate_refs as mergeDuplicateRefs,
143
+ rewrite_loop_continues_to_bare_returns as rewriteLoopContinuesToBareReturns,
143
144
  to_jsx_attribute as toJsxAttribute,
144
145
  validate_at_most_one_ref_attribute as validateAtMostOneRefAttribute,
145
146
  component_to_function_declaration as componentToFunctionDeclaration,
@@ -209,8 +210,18 @@ export {
209
210
  // Analyze
210
211
  export { analyze_css as analyzeCss } from './analyze/css-analyze.js';
211
212
  export {
213
+ COMPONENT_DO_WHILE_STATEMENT_ERROR,
214
+ COMPONENT_FOR_IN_STATEMENT_ERROR,
215
+ COMPONENT_FOR_STATEMENT_ERROR,
216
+ COMPONENT_LOOP_BREAK_ERROR,
217
+ COMPONENT_LOOP_RETURN_ERROR,
212
218
  COMPONENT_RETURN_VALUE_ERROR,
219
+ COMPONENT_WHILE_STATEMENT_ERROR,
213
220
  get_return_keyword_node as getReturnKeywordNode,
221
+ get_statement_keyword_node as getStatementKeywordNode,
222
+ validate_component_loop_break_statement as validateComponentLoopBreakStatement,
223
+ validate_component_loop_return_statement as validateComponentLoopReturnStatement,
214
224
  validate_component_return_statement as validateComponentReturnStatement,
225
+ validate_component_unsupported_loop_statement as validateComponentUnsupportedLoopStatement,
215
226
  validate_nesting as validateNesting,
216
227
  } from './analyze/validation.js';
package/src/plugin.js CHANGED
@@ -269,6 +269,50 @@ export function TSRXPlugin(config) {
269
269
  return null;
270
270
  }
271
271
 
272
+ #popTsxTokenContextBeforeTemplateExpressionChild() {
273
+ let index = this.pos;
274
+ let has_newline = false;
275
+
276
+ // Text-only Tsx nodes can leave the tokenizer in JSX text mode.
277
+ // Only unwind it for ASI before a following TSRX `{expr}` child;
278
+ // fragment props like `content={<></>}` still need the JSX context.
279
+ while (index < this.input.length) {
280
+ const ch = this.input.charCodeAt(index);
281
+ if (ch === 32 || ch === 9) {
282
+ index++;
283
+ } else if (ch === 10 || ch === 13) {
284
+ has_newline = true;
285
+ index++;
286
+ } else if (ch === 47 && this.input.charCodeAt(index + 1) === 42) {
287
+ const end = this.input.indexOf('*/', index + 2);
288
+ const comment_end = end === -1 ? this.input.length : end + 2;
289
+ if (this.input.slice(index, comment_end).match(regex_newline_characters)) {
290
+ has_newline = true;
291
+ }
292
+ index = comment_end;
293
+ } else if (ch === 47 && this.input.charCodeAt(index + 1) === 47) {
294
+ has_newline = true;
295
+ index += 2;
296
+ while (index < this.input.length) {
297
+ const comment_ch = this.input.charCodeAt(index);
298
+ if (comment_ch === 10 || comment_ch === 13) break;
299
+ index++;
300
+ }
301
+ } else {
302
+ break;
303
+ }
304
+ }
305
+
306
+ if (!has_newline || this.input.charCodeAt(index) !== 123) {
307
+ return;
308
+ }
309
+
310
+ const context_index = this.context.lastIndexOf(tstc.tc_expr);
311
+ if (context_index !== -1) {
312
+ this.context.length = context_index;
313
+ }
314
+ }
315
+
272
316
  #isDoubleQuotedTextChildStart() {
273
317
  if (this.#path.findLast((n) => n.type === 'TsxCompat' || n.type === 'Tsx')) {
274
318
  return false;
@@ -2009,6 +2053,7 @@ export function TSRXPlugin(config) {
2009
2053
  if (this.type !== tstt.jsxTagEnd) {
2010
2054
  raise_error();
2011
2055
  }
2056
+ this.#popTsxTokenContextBeforeTemplateExpressionChild();
2012
2057
  this.next();
2013
2058
  }
2014
2059
  }
@@ -2194,6 +2239,7 @@ export function TSRXPlugin(config) {
2194
2239
  if (this.type !== tstt.jsxTagEnd) {
2195
2240
  raise_error();
2196
2241
  }
2242
+ this.#popTsxTokenContextBeforeTemplateExpressionChild();
2197
2243
  this.next();
2198
2244
  }
2199
2245
  } else if (element.type === 'TsxCompat') {
@@ -2225,6 +2271,7 @@ export function TSRXPlugin(config) {
2225
2271
  if (this.type !== tstt.jsxTagEnd) {
2226
2272
  raise_error();
2227
2273
  }
2274
+ this.#popTsxTokenContextBeforeTemplateExpressionChild();
2228
2275
  this.next();
2229
2276
  }
2230
2277
  } else if (this.#path[this.#path.length - 1] === element) {
@@ -40,7 +40,12 @@ import {
40
40
  } from '../lazy.js';
41
41
  import { find_first_top_level_await_in_component_body } from '../await.js';
42
42
  import { prepare_stylesheet_for_render, annotate_component_with_hash } from '../scoping.js';
43
- import { validate_component_return_statement } from '../../analyze/validation.js';
43
+ import {
44
+ validate_component_loop_break_statement,
45
+ validate_component_loop_return_statement,
46
+ validate_component_return_statement,
47
+ validate_component_unsupported_loop_statement,
48
+ } from '../../analyze/validation.js';
44
49
  import { get_component_from_path } from '../../utils/ast.js';
45
50
  import {
46
51
  is_interleaved_body as is_interleaved_body_core,
@@ -61,6 +66,63 @@ import { is_hoist_safe_jsx_node } from '../jsx-hoist.js';
61
66
  * @typedef {{ source_name: string, read: () => any }} LazyBinding
62
67
  */
63
68
 
69
+ /**
70
+ * @param {any} node
71
+ * @returns {boolean}
72
+ */
73
+ function is_function_or_class_boundary(node) {
74
+ return (
75
+ node?.type === 'FunctionDeclaration' ||
76
+ node?.type === 'FunctionExpression' ||
77
+ node?.type === 'ArrowFunctionExpression' ||
78
+ node?.type === 'ClassDeclaration' ||
79
+ node?.type === 'ClassExpression'
80
+ );
81
+ }
82
+
83
+ /**
84
+ * @param {any[]} path
85
+ * @returns {boolean}
86
+ */
87
+ function is_inside_component_for_of(path) {
88
+ for (let i = path.length - 1; i >= 0; i -= 1) {
89
+ const node = path[i];
90
+ if (is_function_or_class_boundary(node) || node?.type === 'Component') {
91
+ return false;
92
+ }
93
+ if (node?.type === 'ForOfStatement') {
94
+ return true;
95
+ }
96
+ }
97
+ return false;
98
+ }
99
+
100
+ /**
101
+ * @param {any[]} path
102
+ * @returns {boolean}
103
+ */
104
+ function break_targets_component_loop(path) {
105
+ for (let i = path.length - 1; i >= 0; i -= 1) {
106
+ const node = path[i];
107
+ if (is_function_or_class_boundary(node) || node?.type === 'Component') {
108
+ return false;
109
+ }
110
+ if (node?.type === 'SwitchStatement') {
111
+ return false;
112
+ }
113
+ if (
114
+ node?.type === 'ForOfStatement' ||
115
+ node?.type === 'ForStatement' ||
116
+ node?.type === 'ForInStatement' ||
117
+ node?.type === 'WhileStatement' ||
118
+ node?.type === 'DoWhileStatement'
119
+ ) {
120
+ return true;
121
+ }
122
+ }
123
+ return false;
124
+ }
125
+
64
126
  /**
65
127
  * Build a `transform()` function for a specific JSX platform (React, Preact,
66
128
  * Solid). Given a `JsxPlatform` descriptor, returns a transform that parses
@@ -104,6 +166,7 @@ export function createJsxTransform(platform) {
104
166
  needs_error_boundary: false,
105
167
  needs_suspense: false,
106
168
  needs_merge_refs: false,
169
+ needs_fragment: false,
107
170
  helper_state: null,
108
171
  available_bindings: new Map(),
109
172
  lazy_next_id: 0,
@@ -122,7 +185,81 @@ export function createJsxTransform(platform) {
122
185
  walk(/** @type {any} */ (ast), transform_context, {
123
186
  ReturnStatement(node, { next, path }) {
124
187
  if (get_component_from_path(path)) {
125
- validate_component_return_statement(
188
+ if (is_inside_component_for_of(path)) {
189
+ validate_component_loop_return_statement(
190
+ node,
191
+ filename,
192
+ transform_context.errors,
193
+ transform_context.comments,
194
+ );
195
+ } else {
196
+ validate_component_return_statement(
197
+ node,
198
+ filename,
199
+ transform_context.errors,
200
+ transform_context.comments,
201
+ );
202
+ }
203
+ }
204
+
205
+ return next();
206
+ },
207
+
208
+ BreakStatement(node, { next, path }) {
209
+ if (get_component_from_path(path) && break_targets_component_loop(path)) {
210
+ validate_component_loop_break_statement(
211
+ node,
212
+ filename,
213
+ transform_context.errors,
214
+ transform_context.comments,
215
+ );
216
+ }
217
+
218
+ return next();
219
+ },
220
+
221
+ ForStatement(node, { next, path }) {
222
+ if (get_component_from_path(path)) {
223
+ validate_component_unsupported_loop_statement(
224
+ node,
225
+ filename,
226
+ transform_context.errors,
227
+ transform_context.comments,
228
+ );
229
+ }
230
+
231
+ return next();
232
+ },
233
+
234
+ ForInStatement(node, { next, path }) {
235
+ if (get_component_from_path(path)) {
236
+ validate_component_unsupported_loop_statement(
237
+ node,
238
+ filename,
239
+ transform_context.errors,
240
+ transform_context.comments,
241
+ );
242
+ }
243
+
244
+ return next();
245
+ },
246
+
247
+ WhileStatement(node, { next, path }) {
248
+ if (get_component_from_path(path)) {
249
+ validate_component_unsupported_loop_statement(
250
+ node,
251
+ filename,
252
+ transform_context.errors,
253
+ transform_context.comments,
254
+ );
255
+ }
256
+
257
+ return next();
258
+ },
259
+
260
+ DoWhileStatement(node, { next, path }) {
261
+ if (get_component_from_path(path)) {
262
+ validate_component_unsupported_loop_statement(
126
263
  node,
127
264
  filename,
128
265
  transform_context.errors,
@@ -440,6 +577,7 @@ function build_component_statements(body_nodes, transform_context) {
440
577
  function build_render_statements(body_nodes, return_null_when_empty, transform_context) {
441
578
  const statements = [];
442
579
  const render_nodes = [];
580
+ let has_bare_return = false;
443
581
 
444
582
  // Create a new bindings map so inner-scope bindings from
445
583
  // collect_statement_bindings don't leak to the caller's scope.
@@ -460,6 +598,7 @@ function build_render_statements(body_nodes, return_null_when_empty, transform_c
460
598
  if (is_bare_return_statement(child)) {
461
599
  statements.push(create_component_return_statement(render_nodes, child));
462
600
  render_nodes.length = 0;
601
+ has_bare_return = true;
463
602
  continue;
464
603
  }
465
604
 
@@ -708,7 +847,7 @@ function build_render_statements(body_nodes, return_null_when_empty, transform_c
708
847
  }
709
848
 
710
849
  const return_arg = build_return_expression(render_nodes);
711
- if (return_arg || return_null_when_empty) {
850
+ if (return_arg || (return_null_when_empty && !has_bare_return)) {
712
851
  statements.push({
713
852
  type: 'ReturnStatement',
714
853
  argument: return_arg || { type: 'Literal', value: null, raw: 'null' },
@@ -1339,6 +1478,126 @@ function append_tail_invocation(body, tail_helper) {
1339
1478
  return [...body, clone_tail_invocation(tail_helper)];
1340
1479
  }
1341
1480
 
1481
+ /**
1482
+ * @param {AST.Identifier} tail_synthetic_id
1483
+ * @param {{ component_element: ESTreeJSX.JSXElement }} tail_helper
1484
+ * @returns {any}
1485
+ */
1486
+ function create_loop_tail_expression(tail_synthetic_id, tail_helper) {
1487
+ return b.logical('&&', clone_identifier(tail_synthetic_id), clone_tail_invocation(tail_helper));
1488
+ }
1489
+
1490
+ /**
1491
+ * @param {AST.Identifier} tail_synthetic_id
1492
+ * @param {{ component_element: ESTreeJSX.JSXElement }} tail_helper
1493
+ * @returns {any}
1494
+ */
1495
+ function create_loop_tail_conditional(tail_synthetic_id, tail_helper) {
1496
+ return b.conditional(
1497
+ clone_identifier(tail_synthetic_id),
1498
+ clone_tail_invocation(tail_helper),
1499
+ create_null_literal(),
1500
+ );
1501
+ }
1502
+
1503
+ /**
1504
+ * @param {any[]} statements
1505
+ * @param {AST.Identifier} tail_synthetic_id
1506
+ * @param {{ component_element: ESTreeJSX.JSXElement }} tail_helper
1507
+ * @returns {void}
1508
+ */
1509
+ function append_loop_tail_to_return_statements(statements, tail_synthetic_id, tail_helper) {
1510
+ for (const statement of statements) {
1511
+ append_loop_tail_to_return_statement(statement, tail_synthetic_id, tail_helper, false);
1512
+ }
1513
+ }
1514
+
1515
+ /**
1516
+ * @param {any} node
1517
+ * @param {AST.Identifier} tail_synthetic_id
1518
+ * @param {{ component_element: ESTreeJSX.JSXElement }} tail_helper
1519
+ * @param {boolean} inside_nested_function
1520
+ * @returns {void}
1521
+ */
1522
+ function append_loop_tail_to_return_statement(
1523
+ node,
1524
+ tail_synthetic_id,
1525
+ tail_helper,
1526
+ inside_nested_function,
1527
+ ) {
1528
+ if (!node || typeof node !== 'object') {
1529
+ return;
1530
+ }
1531
+
1532
+ if (
1533
+ node.type === 'FunctionDeclaration' ||
1534
+ node.type === 'FunctionExpression' ||
1535
+ node.type === 'ArrowFunctionExpression'
1536
+ ) {
1537
+ inside_nested_function = true;
1538
+ }
1539
+
1540
+ if (!inside_nested_function && node.type === 'ReturnStatement') {
1541
+ if (
1542
+ references_scope_bindings(
1543
+ node.argument,
1544
+ new Map([[tail_synthetic_id.name, tail_synthetic_id]]),
1545
+ )
1546
+ ) {
1547
+ return;
1548
+ }
1549
+ node.argument = append_loop_tail_to_return_argument(
1550
+ node.argument,
1551
+ tail_synthetic_id,
1552
+ tail_helper,
1553
+ );
1554
+ return;
1555
+ }
1556
+
1557
+ if (Array.isArray(node)) {
1558
+ for (const child of node) {
1559
+ append_loop_tail_to_return_statement(
1560
+ child,
1561
+ tail_synthetic_id,
1562
+ tail_helper,
1563
+ inside_nested_function,
1564
+ );
1565
+ }
1566
+ return;
1567
+ }
1568
+
1569
+ for (const key of Object.keys(node)) {
1570
+ if (key === 'loc' || key === 'start' || key === 'end' || key === 'metadata') {
1571
+ continue;
1572
+ }
1573
+ append_loop_tail_to_return_statement(
1574
+ node[key],
1575
+ tail_synthetic_id,
1576
+ tail_helper,
1577
+ inside_nested_function,
1578
+ );
1579
+ }
1580
+ }
1581
+
1582
+ /**
1583
+ * @param {any} return_argument
1584
+ * @param {AST.Identifier} tail_synthetic_id
1585
+ * @param {{ component_element: ESTreeJSX.JSXElement }} tail_helper
1586
+ * @returns {any}
1587
+ */
1588
+ function append_loop_tail_to_return_argument(return_argument, tail_synthetic_id, tail_helper) {
1589
+ if (return_argument == null || is_null_literal(return_argument)) {
1590
+ return create_loop_tail_conditional(tail_synthetic_id, tail_helper);
1591
+ }
1592
+
1593
+ return (
1594
+ build_return_expression([
1595
+ return_argument_to_render_node(return_argument),
1596
+ to_jsx_expression_container(create_loop_tail_expression(tail_synthetic_id, tail_helper)),
1597
+ ]) || create_null_literal()
1598
+ );
1599
+ }
1600
+
1342
1601
  /**
1343
1602
  * Build a `return <combined-render-fragment>;` statement, prepending any
1344
1603
  * `render_nodes` collected before the control-flow construct so they don't
@@ -1703,7 +1962,11 @@ function build_hoisted_for_of_with_hooks(node, continuation_body, transform_cont
1703
1962
  }
1704
1963
 
1705
1964
  const has_tail = continuation_body.length > 0;
1706
- const original_loop_body = node.body.type === 'BlockStatement' ? node.body.body : [node.body];
1965
+ const original_loop_body = /** @type {any[]} */ (
1966
+ rewrite_loop_continues_to_bare_returns(
1967
+ node.body.type === 'BlockStatement' ? node.body.body : [node.body],
1968
+ )
1969
+ );
1707
1970
 
1708
1971
  // When there's a tail, build TailHelper first so its component_element can
1709
1972
  // be embedded inside the loop helper's body (gated on isLast). The
@@ -1720,18 +1983,13 @@ function build_hoisted_for_of_with_hooks(node, continuation_body, transform_cont
1720
1983
  } else {
1721
1984
  tail_synthetic_id = /** @type {any} */ (null);
1722
1985
  }
1723
- const loop_body = has_tail
1724
- ? [
1725
- ...original_loop_body,
1726
- b.jsx_expression_container(
1727
- b.logical(
1728
- '&&',
1729
- clone_identifier(tail_synthetic_id),
1730
- clone_tail_invocation(/** @type {any} */ (tail_helper)),
1731
- ),
1732
- ),
1733
- ]
1734
- : original_loop_body;
1986
+ const loop_tail_expression = has_tail
1987
+ ? create_loop_tail_expression(tail_synthetic_id, /** @type {any} */ (tail_helper))
1988
+ : null;
1989
+ const loop_body =
1990
+ has_tail && loop_tail_expression
1991
+ ? [...original_loop_body, b.jsx_expression_container(loop_tail_expression)]
1992
+ : original_loop_body;
1735
1993
 
1736
1994
  const source_id = create_generated_identifier(
1737
1995
  `_tsrx_iteration_items_${transform_context.local_statement_component_index + 1}`,
@@ -1767,10 +2025,8 @@ function build_hoisted_for_of_with_hooks(node, continuation_body, transform_cont
1767
2025
  );
1768
2026
 
1769
2027
  // Synthetic `isLast` prop on the loop helper when there's a tail. It's
1770
- // passed from the .map callback as `i === source.length - 1` so the loop
1771
- // helper renders the tail helper only on the last iteration. We do not
1772
- // gate on this prop's value here — the JSXLogicalExpression appended to
1773
- // `loop_body` does the gating at render time.
2028
+ // passed from the .map callback as `i === source.length - 1` so every
2029
+ // loop-helper return can append the tail helper on the last iteration.
1774
2030
  const tail_isLast_alias = has_tail
1775
2031
  ? {
1776
2032
  id: create_generated_identifier(`_tsrx_${helper_id.name}_isLast`),
@@ -1808,6 +2064,13 @@ function build_hoisted_for_of_with_hooks(node, continuation_body, transform_cont
1808
2064
  transform_context.available_bindings.set(tail_synthetic_id.name, tail_synthetic_id);
1809
2065
  }
1810
2066
  const fn_body_statements = build_render_statements(loop_body, true, transform_context);
2067
+ if (has_tail) {
2068
+ append_loop_tail_to_return_statements(
2069
+ fn_body_statements,
2070
+ tail_synthetic_id,
2071
+ /** @type {any} */ (tail_helper),
2072
+ );
2073
+ }
1811
2074
  transform_context.available_bindings = fn_saved_bindings;
1812
2075
 
1813
2076
  const helper_fn = /** @type {any} */ (
@@ -1851,7 +2114,7 @@ function build_hoisted_for_of_with_hooks(node, continuation_body, transform_cont
1851
2114
  index_identifier = null;
1852
2115
  }
1853
2116
 
1854
- const body_key_expression = find_key_expression_in_body(loop_body);
2117
+ const body_key_expression = find_key_expression_in_body(original_loop_body);
1855
2118
  const explicit_key_expression =
1856
2119
  body_key_expression ?? (node.key ? clone_expression_node(node.key) : undefined);
1857
2120
  const key_expression =
@@ -2087,7 +2350,7 @@ function prepend_render_nodes_to_return_statement(node, render_nodes, inside_nes
2087
2350
  function combine_render_return_argument(render_nodes, return_argument) {
2088
2351
  const combined = render_nodes.map((node) => clone_expression_node_without_locations(node));
2089
2352
 
2090
- if (!is_null_literal(return_argument)) {
2353
+ if (return_argument != null && !is_null_literal(return_argument)) {
2091
2354
  combined.push(return_argument_to_render_node(return_argument));
2092
2355
  }
2093
2356
 
@@ -3049,6 +3312,71 @@ function find_key_expression_in_body(body_nodes) {
3049
3312
  return undefined;
3050
3313
  }
3051
3314
 
3315
+ /**
3316
+ * @param {any} source_node
3317
+ * @returns {any}
3318
+ */
3319
+ function continue_to_bare_return(source_node) {
3320
+ return set_loc(
3321
+ /** @type {any} */ ({
3322
+ type: 'ReturnStatement',
3323
+ argument: null,
3324
+ metadata: { path: [] },
3325
+ }),
3326
+ source_node,
3327
+ );
3328
+ }
3329
+
3330
+ /**
3331
+ * `continue` in a component `for...of` body means "skip this item". JSX targets
3332
+ * lower `for...of` to callbacks, so a raw ContinueStatement would be invalid JS;
3333
+ * a bare `return` from the callback preserves the item-skip behavior.
3334
+ *
3335
+ * @param {any[] | any} node
3336
+ * @param {boolean} [is_root]
3337
+ * @returns {any[] | any}
3338
+ */
3339
+ export function rewrite_loop_continues_to_bare_returns(node, is_root = true) {
3340
+ if (Array.isArray(node)) {
3341
+ return node.map((child) => rewrite_loop_continues_to_bare_returns(child, false));
3342
+ }
3343
+
3344
+ if (!node || typeof node !== 'object') {
3345
+ return node;
3346
+ }
3347
+
3348
+ if (node.type === 'ContinueStatement') {
3349
+ return continue_to_bare_return(node);
3350
+ }
3351
+
3352
+ if (is_function_or_class_boundary(node) || (!is_root && is_loop_statement(node))) {
3353
+ return node;
3354
+ }
3355
+
3356
+ for (const key of Object.keys(node)) {
3357
+ if (key === 'loc' || key === 'start' || key === 'end' || key === 'metadata') {
3358
+ continue;
3359
+ }
3360
+ node[key] = rewrite_loop_continues_to_bare_returns(node[key], false);
3361
+ }
3362
+
3363
+ return node;
3364
+ }
3365
+
3366
+ /**
3367
+ * @param {any} node
3368
+ * @returns {boolean}
3369
+ */
3370
+ function is_loop_statement(node) {
3371
+ return (
3372
+ node?.type === 'ForOfStatement' ||
3373
+ node?.type === 'ForStatement' ||
3374
+ node?.type === 'ForInStatement' ||
3375
+ node?.type === 'WhileStatement' ||
3376
+ node?.type === 'DoWhileStatement'
3377
+ );
3378
+ }
3379
+
3052
3380
  /**
3053
3381
  * @param {any} node
3054
3382
  * @param {TransformContext} transform_context
@@ -3066,7 +3394,11 @@ function for_of_statement_to_jsx_child(node, transform_context) {
3066
3394
  }
3067
3395
 
3068
3396
  const loop_params = get_for_of_iteration_params(node.left, node.index);
3069
- const loop_body = node.body.type === 'BlockStatement' ? node.body.body : [node.body];
3397
+ const loop_body = /** @type {any[]} */ (
3398
+ rewrite_loop_continues_to_bare_returns(
3399
+ node.body.type === 'BlockStatement' ? node.body.body : [node.body],
3400
+ )
3401
+ );
3070
3402
  const has_hooks = body_contains_top_level_hook_call(loop_body, transform_context, true);
3071
3403
  const body_key_expression = find_key_expression_in_body(loop_body);
3072
3404
  const explicit_key_expression =
@@ -3091,14 +3423,14 @@ function for_of_statement_to_jsx_child(node, transform_context) {
3091
3423
  collect_pattern_bindings(param, transform_context.available_bindings);
3092
3424
  }
3093
3425
 
3426
+ if (implicit_non_hook_key_expression && should_apply_key_to_loop_body(loop_body)) {
3427
+ apply_key_to_loop_body(loop_body, implicit_non_hook_key_expression);
3428
+ }
3429
+
3094
3430
  const body_statements = has_hooks
3095
3431
  ? hook_safe_render_statements(loop_body, key_expression, transform_context)
3096
3432
  : build_render_statements(loop_body, true, transform_context);
3097
3433
 
3098
- if (implicit_non_hook_key_expression) {
3099
- apply_key_to_render_statements(body_statements, implicit_non_hook_key_expression);
3100
- }
3101
-
3102
3434
  const platform_for_of = transform_context.platform.hooks?.renderForOf?.(
3103
3435
  node,
3104
3436
  loop_params,
@@ -3110,6 +3442,11 @@ function for_of_statement_to_jsx_child(node, transform_context) {
3110
3442
  return platform_for_of;
3111
3443
  }
3112
3444
 
3445
+ const non_hook_key_expression = key_expression ?? implicit_non_hook_key_expression;
3446
+ if (!has_hooks && non_hook_key_expression) {
3447
+ apply_key_to_render_statements(body_statements, non_hook_key_expression, transform_context);
3448
+ }
3449
+
3113
3450
  // Restore bindings
3114
3451
  transform_context.available_bindings = saved_bindings;
3115
3452
 
@@ -3147,19 +3484,33 @@ function for_of_statement_to_jsx_child(node, transform_context) {
3147
3484
  }
3148
3485
 
3149
3486
  /**
3150
- * @param {any[]} statements
3487
+ * @param {any[]} body_nodes
3151
3488
  * @param {any} key_expression
3152
3489
  * @returns {void}
3153
3490
  */
3154
- function apply_key_to_render_statements(statements, key_expression) {
3155
- for (let i = statements.length - 1; i >= 0; i -= 1) {
3156
- const statement = statements[i];
3157
- if (statement?.type !== 'ReturnStatement' || !statement.argument) {
3158
- continue;
3491
+ function apply_key_to_loop_body(body_nodes, key_expression) {
3492
+ for (const node of body_nodes) {
3493
+ if (node.type === 'Element') {
3494
+ const attributes = node.attributes || (node.attributes = []);
3495
+ const has_key = attributes.some((/** @type {any} */ attr) => {
3496
+ const attr_name = typeof attr.name === 'string' ? attr.name : attr.name?.name;
3497
+ return attr_name === 'key';
3498
+ });
3499
+
3500
+ if (!has_key) {
3501
+ attributes.push({
3502
+ type: 'Attribute',
3503
+ name: { type: 'Identifier', name: 'key', metadata: { path: [] } },
3504
+ value: clone_expression_node(key_expression),
3505
+ shorthand: false,
3506
+ metadata: { path: [] },
3507
+ });
3508
+ }
3509
+ return;
3159
3510
  }
3160
3511
 
3161
- if (statement.argument.type === 'JSXElement') {
3162
- const attributes = statement.argument.openingElement?.attributes || [];
3512
+ if (node.type === 'JSXElement') {
3513
+ const attributes = node.openingElement?.attributes || [];
3163
3514
  const has_key = attributes.some(
3164
3515
  (/** @type {any} */ attr) =>
3165
3516
  attr.type === 'JSXAttribute' &&
@@ -3180,12 +3531,92 @@ function apply_key_to_render_statements(statements, key_expression) {
3180
3531
  }),
3181
3532
  );
3182
3533
  }
3534
+ return;
3535
+ }
3536
+ }
3537
+ }
3538
+
3539
+ /**
3540
+ * @param {any[]} body_nodes
3541
+ * @returns {boolean}
3542
+ */
3543
+ function should_apply_key_to_loop_body(body_nodes) {
3544
+ let keyable_children = 0;
3545
+ for (const node of body_nodes) {
3546
+ if (node.type === 'Element' || node.type === 'JSXElement') {
3547
+ keyable_children += 1;
3548
+ }
3549
+ }
3550
+ return keyable_children === 1;
3551
+ }
3552
+
3553
+ /**
3554
+ * @param {any[]} statements
3555
+ * @param {any} key_expression
3556
+ * @param {TransformContext} transform_context
3557
+ * @returns {void}
3558
+ */
3559
+ function apply_key_to_render_statements(statements, key_expression, transform_context) {
3560
+ for (let i = statements.length - 1; i >= 0; i -= 1) {
3561
+ const statement = statements[i];
3562
+ if (statement?.type !== 'ReturnStatement' || !statement.argument) {
3563
+ continue;
3564
+ }
3565
+
3566
+ if (statement.argument.type === 'JSXElement') {
3567
+ apply_key_to_jsx_element(statement.argument, key_expression);
3568
+ } else if (statement.argument.type === 'JSXFragment') {
3569
+ transform_context.needs_fragment = true;
3570
+ statement.argument = keyed_fragment_to_jsx_element(statement.argument, key_expression);
3183
3571
  }
3184
3572
 
3185
3573
  return;
3186
3574
  }
3187
3575
  }
3188
3576
 
3577
+ /**
3578
+ * @param {any} element
3579
+ * @param {any} key_expression
3580
+ * @returns {void}
3581
+ */
3582
+ function apply_key_to_jsx_element(element, key_expression) {
3583
+ const attributes = element.openingElement?.attributes || [];
3584
+ const has_key = attributes.some(
3585
+ (/** @type {any} */ attr) =>
3586
+ attr.type === 'JSXAttribute' &&
3587
+ attr.name?.type === 'JSXIdentifier' &&
3588
+ attr.name.name === 'key',
3589
+ );
3590
+
3591
+ if (!has_key) {
3592
+ attributes.push(
3593
+ b.jsx_attribute(
3594
+ b.jsx_id('key'),
3595
+ to_jsx_expression_container(clone_expression_node(key_expression), key_expression),
3596
+ ),
3597
+ );
3598
+ }
3599
+ }
3600
+
3601
+ /**
3602
+ * @param {any} fragment
3603
+ * @param {any} key_expression
3604
+ * @returns {any}
3605
+ */
3606
+ function keyed_fragment_to_jsx_element(fragment, key_expression) {
3607
+ const name = b.jsx_id('Fragment');
3608
+ const key_attribute = b.jsx_attribute(
3609
+ b.jsx_id('key'),
3610
+ to_jsx_expression_container(clone_expression_node(key_expression), key_expression),
3611
+ );
3612
+
3613
+ return b.jsx_element_fresh(
3614
+ b.jsx_opening_element(name, [key_attribute]),
3615
+ b.jsx_closing_element(clone_jsx_name(name)),
3616
+ fragment.children,
3617
+ );
3618
+ }
3619
+
3189
3620
  /**
3190
3621
  * @param {any} node
3191
3622
  * @param {TransformContext} transform_context
@@ -3473,6 +3904,27 @@ function inject_try_imports(program, transform_context, platform, suspense_sourc
3473
3904
  /** @type {any[]} */
3474
3905
  const imports = [];
3475
3906
 
3907
+ if (transform_context.needs_fragment && platform.imports.fragment) {
3908
+ const fragment_source = platform.imports.fragment;
3909
+ imports.push({
3910
+ type: 'ImportDeclaration',
3911
+ specifiers: [
3912
+ {
3913
+ type: 'ImportSpecifier',
3914
+ imported: { type: 'Identifier', name: 'Fragment', metadata: { path: [] } },
3915
+ local: { type: 'Identifier', name: 'Fragment', metadata: { path: [] } },
3916
+ metadata: { path: [] },
3917
+ },
3918
+ ],
3919
+ source: {
3920
+ type: 'Literal',
3921
+ value: fragment_source,
3922
+ raw: `'${fragment_source}'`,
3923
+ },
3924
+ metadata: { path: [] },
3925
+ });
3926
+ }
3927
+
3476
3928
  if (transform_context.needs_suspense) {
3477
3929
  imports.push({
3478
3930
  type: 'ImportDeclaration',
@@ -1343,6 +1343,15 @@ export const break_statement = {
1343
1343
  metadata: { path: [] },
1344
1344
  };
1345
1345
 
1346
+ /**
1347
+ * @type {AST.ContinueStatement}
1348
+ */
1349
+ export const continue_statement = {
1350
+ type: 'ContinueStatement',
1351
+ label: null,
1352
+ metadata: { path: [] },
1353
+ };
1354
+
1346
1355
  export {
1347
1356
  await_builder as await,
1348
1357
  let_builder as let,
@@ -1352,6 +1361,7 @@ export {
1352
1361
  true_instance as true,
1353
1362
  false_instance as false,
1354
1363
  break_statement as break,
1364
+ continue_statement as continue,
1355
1365
  for_builder as for,
1356
1366
  switch_builder as switch,
1357
1367
  function_builder as function,
package/types/index.d.ts CHANGED
@@ -74,6 +74,7 @@ interface BaseNodeMetaData {
74
74
  returns?: AST.ReturnStatement[];
75
75
  has_return?: boolean;
76
76
  has_throw?: boolean;
77
+ has_continue?: boolean;
77
78
  is_reactive?: boolean;
78
79
  lone_return?: boolean;
79
80
  forceMapping?: boolean;
@@ -29,6 +29,7 @@ export interface JsxTransformContext {
29
29
  needs_error_boundary: boolean;
30
30
  needs_suspense: boolean;
31
31
  needs_merge_refs: boolean;
32
+ needs_fragment: boolean;
32
33
  helper_state: {
33
34
  base_name: string;
34
35
  next_id: number;
@@ -239,6 +240,11 @@ export interface JsxPlatform {
239
240
  name: string;
240
241
 
241
242
  imports: {
243
+ /**
244
+ * Module to import `Fragment` from when a keyed fragment is required
245
+ * for a multi-child loop body. React: `'react'`. Preact: `'preact'`.
246
+ */
247
+ fragment?: string;
242
248
  /**
243
249
  * Module to import `Suspense` from when a `try { ... } pending { ... }`
244
250
  * block appears. React: `'react'`. Preact: `'preact/compat'`.