@tsrx/core 0.0.22 → 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.
@@ -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,
@@ -254,14 +391,13 @@ export function createJsxTransform(platform) {
254
391
  return /** @type {any} */ (to_jsx_expression_container(inner.expression, inner));
255
392
  },
256
393
 
257
- MemberExpression(node, { next, state }) {
258
- const as_any = /** @type {any} */ (node);
259
- if (as_any.object && as_any.object.type === 'StyleIdentifier' && state.current_css_hash) {
260
- const class_name = as_any.computed ? as_any.property.value : as_any.property.name;
261
- const value = `${state.current_css_hash} ${class_name}`;
262
- return /** @type {any} */ ({ type: 'Literal', value, raw: JSON.stringify(value) });
263
- }
264
- return next();
394
+ Style(node, { state, path }) {
395
+ validate_style_directive(node, state, path);
396
+ const class_name = typeof node.value.value === 'string' ? node.value.value : '';
397
+ const value = state.current_css_hash
398
+ ? `${state.current_css_hash} ${class_name}`
399
+ : class_name;
400
+ return /** @type {any} */ (b.literal(value, node));
265
401
  },
266
402
 
267
403
  // Default .metadata on every function-like node so downstream consumers
@@ -441,6 +577,7 @@ function build_component_statements(body_nodes, transform_context) {
441
577
  function build_render_statements(body_nodes, return_null_when_empty, transform_context) {
442
578
  const statements = [];
443
579
  const render_nodes = [];
580
+ let has_bare_return = false;
444
581
 
445
582
  // Create a new bindings map so inner-scope bindings from
446
583
  // collect_statement_bindings don't leak to the caller's scope.
@@ -461,6 +598,7 @@ function build_render_statements(body_nodes, return_null_when_empty, transform_c
461
598
  if (is_bare_return_statement(child)) {
462
599
  statements.push(create_component_return_statement(render_nodes, child));
463
600
  render_nodes.length = 0;
601
+ has_bare_return = true;
464
602
  continue;
465
603
  }
466
604
 
@@ -709,7 +847,7 @@ function build_render_statements(body_nodes, return_null_when_empty, transform_c
709
847
  }
710
848
 
711
849
  const return_arg = build_return_expression(render_nodes);
712
- if (return_arg || return_null_when_empty) {
850
+ if (return_arg || (return_null_when_empty && !has_bare_return)) {
713
851
  statements.push({
714
852
  type: 'ReturnStatement',
715
853
  argument: return_arg || { type: 'Literal', value: null, raw: 'null' },
@@ -1340,6 +1478,126 @@ function append_tail_invocation(body, tail_helper) {
1340
1478
  return [...body, clone_tail_invocation(tail_helper)];
1341
1479
  }
1342
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
+
1343
1601
  /**
1344
1602
  * Build a `return <combined-render-fragment>;` statement, prepending any
1345
1603
  * `render_nodes` collected before the control-flow construct so they don't
@@ -1704,7 +1962,11 @@ function build_hoisted_for_of_with_hooks(node, continuation_body, transform_cont
1704
1962
  }
1705
1963
 
1706
1964
  const has_tail = continuation_body.length > 0;
1707
- 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
+ );
1708
1970
 
1709
1971
  // When there's a tail, build TailHelper first so its component_element can
1710
1972
  // be embedded inside the loop helper's body (gated on isLast). The
@@ -1721,18 +1983,13 @@ function build_hoisted_for_of_with_hooks(node, continuation_body, transform_cont
1721
1983
  } else {
1722
1984
  tail_synthetic_id = /** @type {any} */ (null);
1723
1985
  }
1724
- const loop_body = has_tail
1725
- ? [
1726
- ...original_loop_body,
1727
- b.jsx_expression_container(
1728
- b.logical(
1729
- '&&',
1730
- clone_identifier(tail_synthetic_id),
1731
- clone_tail_invocation(/** @type {any} */ (tail_helper)),
1732
- ),
1733
- ),
1734
- ]
1735
- : 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;
1736
1993
 
1737
1994
  const source_id = create_generated_identifier(
1738
1995
  `_tsrx_iteration_items_${transform_context.local_statement_component_index + 1}`,
@@ -1768,10 +2025,8 @@ function build_hoisted_for_of_with_hooks(node, continuation_body, transform_cont
1768
2025
  );
1769
2026
 
1770
2027
  // Synthetic `isLast` prop on the loop helper when there's a tail. It's
1771
- // passed from the .map callback as `i === source.length - 1` so the loop
1772
- // helper renders the tail helper only on the last iteration. We do not
1773
- // gate on this prop's value here — the JSXLogicalExpression appended to
1774
- // `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.
1775
2030
  const tail_isLast_alias = has_tail
1776
2031
  ? {
1777
2032
  id: create_generated_identifier(`_tsrx_${helper_id.name}_isLast`),
@@ -1809,6 +2064,13 @@ function build_hoisted_for_of_with_hooks(node, continuation_body, transform_cont
1809
2064
  transform_context.available_bindings.set(tail_synthetic_id.name, tail_synthetic_id);
1810
2065
  }
1811
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
+ }
1812
2074
  transform_context.available_bindings = fn_saved_bindings;
1813
2075
 
1814
2076
  const helper_fn = /** @type {any} */ (
@@ -1852,7 +2114,7 @@ function build_hoisted_for_of_with_hooks(node, continuation_body, transform_cont
1852
2114
  index_identifier = null;
1853
2115
  }
1854
2116
 
1855
- const body_key_expression = find_key_expression_in_body(loop_body);
2117
+ const body_key_expression = find_key_expression_in_body(original_loop_body);
1856
2118
  const explicit_key_expression =
1857
2119
  body_key_expression ?? (node.key ? clone_expression_node(node.key) : undefined);
1858
2120
  const key_expression =
@@ -2088,7 +2350,7 @@ function prepend_render_nodes_to_return_statement(node, render_nodes, inside_nes
2088
2350
  function combine_render_return_argument(render_nodes, return_argument) {
2089
2351
  const combined = render_nodes.map((node) => clone_expression_node_without_locations(node));
2090
2352
 
2091
- if (!is_null_literal(return_argument)) {
2353
+ if (return_argument != null && !is_null_literal(return_argument)) {
2092
2354
  combined.push(return_argument_to_render_node(return_argument));
2093
2355
  }
2094
2356
 
@@ -2771,6 +3033,108 @@ function get_body_source_node(body_nodes) {
2771
3033
  return first;
2772
3034
  }
2773
3035
 
3036
+ /**
3037
+ * @param {any} node
3038
+ * @param {TransformContext} transform_context
3039
+ * @param {any[]} path
3040
+ */
3041
+ function validate_style_directive(node, transform_context, path) {
3042
+ const { attribute, element } = get_style_attribute_context(node, path);
3043
+
3044
+ if (!attribute) {
3045
+ error(
3046
+ '`{style "class_name"}` can only be used as an element attribute value.',
3047
+ transform_context.filename,
3048
+ node,
3049
+ transform_context.errors,
3050
+ transform_context.comments,
3051
+ );
3052
+ }
3053
+
3054
+ if (element && is_dom_style_target(element)) {
3055
+ error(
3056
+ '`{style "class_name"}` cannot be used directly on DOM elements. Pass the class to a child component instead.',
3057
+ transform_context.filename,
3058
+ node,
3059
+ transform_context.errors,
3060
+ transform_context.comments,
3061
+ );
3062
+ }
3063
+
3064
+ if (!transform_context.current_css_hash) {
3065
+ error(
3066
+ '`{style "class_name"}` requires a <style> block in the current component.',
3067
+ transform_context.filename,
3068
+ node,
3069
+ transform_context.errors,
3070
+ transform_context.comments,
3071
+ );
3072
+ }
3073
+ }
3074
+
3075
+ /**
3076
+ * @param {any} node
3077
+ * @param {any[]} path
3078
+ * @returns {{ attribute: any, element: any }}
3079
+ */
3080
+ function get_style_attribute_context(node, path) {
3081
+ const parent = path.at(-1);
3082
+ const attribute =
3083
+ parent?.type === 'Attribute' && parent.value === node
3084
+ ? parent
3085
+ : path
3086
+ .findLast((ancestor) => ancestor?.type === 'Element')
3087
+ ?.attributes?.find(
3088
+ (/** @type {any} */ attr) =>
3089
+ attr?.type === 'Attribute' &&
3090
+ (attr.value === node || node_contains(attr.value, node)),
3091
+ );
3092
+ const element = path.findLast(
3093
+ (ancestor) =>
3094
+ ancestor?.type === 'Element' &&
3095
+ (!attribute || ancestor.attributes?.some((/** @type {any} */ attr) => attr === attribute)),
3096
+ );
3097
+
3098
+ return { attribute: attribute ?? null, element: element ?? null };
3099
+ }
3100
+
3101
+ /**
3102
+ * @param {any} root
3103
+ * @param {any} target
3104
+ * @returns {boolean}
3105
+ */
3106
+ function node_contains(root, target) {
3107
+ if (!root || typeof root !== 'object') {
3108
+ return false;
3109
+ }
3110
+ if (root === target) {
3111
+ return true;
3112
+ }
3113
+ if (Array.isArray(root)) {
3114
+ return root.some((child) => node_contains(child, target));
3115
+ }
3116
+ for (const key of Object.keys(root)) {
3117
+ if (key === 'loc' || key === 'start' || key === 'end' || key === 'metadata') {
3118
+ continue;
3119
+ }
3120
+ if (node_contains(root[key], target)) {
3121
+ return true;
3122
+ }
3123
+ }
3124
+ return false;
3125
+ }
3126
+
3127
+ /**
3128
+ * @param {any} element
3129
+ * @returns {boolean}
3130
+ */
3131
+ function is_dom_style_target(element) {
3132
+ if (!element?.id || is_dynamic_element_id(element.id)) {
3133
+ return false;
3134
+ }
3135
+ return element.id.type === 'Identifier' && /^[a-z]/.test(element.id.name);
3136
+ }
3137
+
2774
3138
  /**
2775
3139
  * @param {any} node
2776
3140
  * @param {TransformContext} transform_context
@@ -2948,6 +3312,71 @@ function find_key_expression_in_body(body_nodes) {
2948
3312
  return undefined;
2949
3313
  }
2950
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
+
2951
3380
  /**
2952
3381
  * @param {any} node
2953
3382
  * @param {TransformContext} transform_context
@@ -2965,7 +3394,11 @@ function for_of_statement_to_jsx_child(node, transform_context) {
2965
3394
  }
2966
3395
 
2967
3396
  const loop_params = get_for_of_iteration_params(node.left, node.index);
2968
- 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
+ );
2969
3402
  const has_hooks = body_contains_top_level_hook_call(loop_body, transform_context, true);
2970
3403
  const body_key_expression = find_key_expression_in_body(loop_body);
2971
3404
  const explicit_key_expression =
@@ -2990,14 +3423,14 @@ function for_of_statement_to_jsx_child(node, transform_context) {
2990
3423
  collect_pattern_bindings(param, transform_context.available_bindings);
2991
3424
  }
2992
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
+
2993
3430
  const body_statements = has_hooks
2994
3431
  ? hook_safe_render_statements(loop_body, key_expression, transform_context)
2995
3432
  : build_render_statements(loop_body, true, transform_context);
2996
3433
 
2997
- if (implicit_non_hook_key_expression) {
2998
- apply_key_to_render_statements(body_statements, implicit_non_hook_key_expression);
2999
- }
3000
-
3001
3434
  const platform_for_of = transform_context.platform.hooks?.renderForOf?.(
3002
3435
  node,
3003
3436
  loop_params,
@@ -3009,6 +3442,11 @@ function for_of_statement_to_jsx_child(node, transform_context) {
3009
3442
  return platform_for_of;
3010
3443
  }
3011
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
+
3012
3450
  // Restore bindings
3013
3451
  transform_context.available_bindings = saved_bindings;
3014
3452
 
@@ -3046,19 +3484,33 @@ function for_of_statement_to_jsx_child(node, transform_context) {
3046
3484
  }
3047
3485
 
3048
3486
  /**
3049
- * @param {any[]} statements
3487
+ * @param {any[]} body_nodes
3050
3488
  * @param {any} key_expression
3051
3489
  * @returns {void}
3052
3490
  */
3053
- function apply_key_to_render_statements(statements, key_expression) {
3054
- for (let i = statements.length - 1; i >= 0; i -= 1) {
3055
- const statement = statements[i];
3056
- if (statement?.type !== 'ReturnStatement' || !statement.argument) {
3057
- 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;
3058
3510
  }
3059
3511
 
3060
- if (statement.argument.type === 'JSXElement') {
3061
- const attributes = statement.argument.openingElement?.attributes || [];
3512
+ if (node.type === 'JSXElement') {
3513
+ const attributes = node.openingElement?.attributes || [];
3062
3514
  const has_key = attributes.some(
3063
3515
  (/** @type {any} */ attr) =>
3064
3516
  attr.type === 'JSXAttribute' &&
@@ -3079,12 +3531,92 @@ function apply_key_to_render_statements(statements, key_expression) {
3079
3531
  }),
3080
3532
  );
3081
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);
3082
3571
  }
3083
3572
 
3084
3573
  return;
3085
3574
  }
3086
3575
  }
3087
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
+
3088
3620
  /**
3089
3621
  * @param {any} node
3090
3622
  * @param {TransformContext} transform_context
@@ -3372,6 +3904,27 @@ function inject_try_imports(program, transform_context, platform, suspense_sourc
3372
3904
  /** @type {any[]} */
3373
3905
  const imports = [];
3374
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
+
3375
3928
  if (transform_context.needs_suspense) {
3376
3929
  imports.push({
3377
3930
  type: 'ImportDeclaration',