@tsrx/core 0.1.31 → 0.1.33

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.
@@ -5,10 +5,12 @@
5
5
  import { walk } from 'zimmerframe';
6
6
  import { print } from 'esrap';
7
7
  import { error } from '../../errors.js';
8
+ import { is_template_value_position } from '../../analyze/validation.js';
8
9
  import { analyze_css } from '../../analyze/css-analyze.js';
9
10
  import { prune_css } from '../../analyze/prune.js';
10
11
  import {
11
12
  in_jsx_child_context,
13
+ is_empty_jsx_fragment,
12
14
  set_node_path_metadata,
13
15
  tsx_with_ts_locations,
14
16
  is_template_if_node,
@@ -74,6 +76,16 @@ const TSRX_IF_CONTINUE_ERROR =
74
76
  'Continue statements are not allowed inside TSRX template @if blocks. Filter before rendering or use conditional output instead.';
75
77
  const DYNAMIC_IMPORT_LOCAL = 'TsrxDynamic';
76
78
  const DYNAMIC_FACTORY_LOCAL = '_tsrx_dynamic';
79
+ const LEADING_INLINE_WHITESPACE = /^[ \t]+/;
80
+ const TRAILING_INLINE_WHITESPACE = /[ \t]+$/;
81
+
82
+ /**
83
+ * @param {string | undefined} ch
84
+ * @returns {boolean}
85
+ */
86
+ function is_newline_char(ch) {
87
+ return ch === '\n' || ch === '\r';
88
+ }
77
89
 
78
90
  /**
79
91
  * @param {AST.Node} node
@@ -248,29 +260,170 @@ function is_jsx_control_flow_expression(node) {
248
260
 
249
261
  /**
250
262
  * Wrap a render-output node in a native TSRX fragment so it flows through the
251
- * same single-child render path as a `<> … </>` output.
263
+ * same single-child render path as a `<> … </>` output. This is a compiler
264
+ * GENERATED wrapper (it wraps a control-flow directive / render output so it
265
+ * lowers to a value) — it is marked `tsrx_generated_wrapper` so the single-child
266
+ * collapse keeps unwrapping it, unlike an AUTHORED `<> … </>` which is kept.
252
267
  * @param {any} node
253
268
  * @returns {any}
254
269
  */
255
270
  function wrap_in_native_tsrx_fragment(node) {
256
271
  const fragment = b.jsx_fragment([node]);
257
- fragment.metadata = { ...(fragment.metadata || {}), native_tsrx: true };
272
+ fragment.metadata = {
273
+ ...(fragment.metadata || {}),
274
+ native_tsrx: true,
275
+ tsrx_generated_wrapper: true,
276
+ };
258
277
  return fragment;
259
278
  }
260
279
 
261
280
  /**
262
- * Wrap a bare JSX control-flow directive that sits directly in an expression
263
- * position an expression-bodied arrow (`() => @switch (…) { … }`), a
264
- * `return @switch (…) { }`, an unused expression statement,
265
- * assignment to a variable
266
- * (`const x = @switch (…) {}`, `x = @switch (…) { … }`), or a call/`new`
267
- * argument (`render(@if (…) { … })`) — in a native TSRX fragment.
281
+ * An AUTHORED `<> </>` fragment (not a compiler-generated wrapper, nor a Ripple
282
+ * code-block-chain wrapper). These are kept verbatim in the output instead of
283
+ * being unwrapped to their single child.
284
+ * @param {any} node
285
+ * @returns {boolean}
286
+ */
287
+ function is_authored_native_fragment(node) {
288
+ return (
289
+ node?.type === 'JSXFragment' &&
290
+ node.metadata?.native_tsrx === true &&
291
+ node.metadata?.tsrx_generated_wrapper !== true
292
+ );
293
+ }
294
+
295
+ /**
296
+ * Slots whose value is a render child / statement, not a JavaScript value
297
+ * expression. A control-flow directive (`@if`/`@for`/`@switch`/`@try`) is
298
+ * legitimate render output in these positions, so it must NOT be treated as a
299
+ * stray "control flow used as a value". Everything else is a value position:
300
+ * an unhandled control-flow directive there is the raw-value error case
301
+ * (a `@for` iterable, an `@if`/`@switch` test, etc.).
302
+ * @param {any} parent
303
+ * @param {string} key
304
+ * @returns {boolean}
305
+ */
306
+ function is_statement_or_template_slot(parent, key) {
307
+ // JSX children, and the body of any block/program/function/loop.
308
+ if (key === 'children' || key === 'body') return true;
309
+ // A `@{ … }` code block's trailing output (`render`) is render position.
310
+ if (parent?.type === 'JSXCodeBlock' && key === 'render') return true;
311
+ // `{ @if … }` containers lower their expression through the render machinery.
312
+ if (parent?.type === 'JSXExpressionContainer' && key === 'expression') return true;
313
+ // Switch-case statement lists.
314
+ if (parent?.type === 'SwitchCase' && key === 'consequent') return true;
315
+ // An if-node branch is a statement block; its `alternate` is also where the
316
+ // `@else if` chain (another control-flow node) legitimately lives.
317
+ if (is_if_control_node(parent) && (key === 'consequent' || key === 'alternate')) return true;
318
+ return false;
319
+ }
320
+
321
+ /**
322
+ * Render-output value slots: the only expression positions a directive may be
323
+ * the SOLE value of. A control-flow directive here collapses to its rendered
324
+ * value (wrapped in a native fragment by `wrap_control_flow_expression_values`)
325
+ * and a `@{ … }` code block self-lowers to an IIFE. These are established forms
326
+ * (`const x = @switch …`, `() => @if …`, `return @if …`, `render(@for …)`),
327
+ * distinct from combining a directive INTO an expression (an operator operand, a
328
+ * `@for` iterable, an `@if`/`@switch` test), which is an error.
329
+ * @param {any} parent
330
+ * @param {string} key
331
+ * @returns {boolean}
332
+ */
333
+ function is_render_output_value_slot(parent, key) {
334
+ switch (parent?.type) {
335
+ case 'ArrowFunctionExpression':
336
+ return key === 'body';
337
+ case 'ReturnStatement':
338
+ return key === 'argument';
339
+ case 'ExpressionStatement':
340
+ return key === 'expression';
341
+ case 'VariableDeclarator':
342
+ return key === 'init';
343
+ case 'AssignmentExpression':
344
+ return key === 'right';
345
+ case 'CallExpression':
346
+ case 'NewExpression':
347
+ return key === 'arguments';
348
+ default:
349
+ return false;
350
+ }
351
+ }
352
+
353
+ /**
354
+ * A `<> … </>` is combined INTO a surrounding expression (an operator operand, a
355
+ * conditional branch, an array element, a template-literal hole) — as opposed to
356
+ * being the sole value of a render-output slot, where its single-child collapse
357
+ * is invisible because the value is only rendered. In a combined position the
358
+ * collapse is NOT invisible: a fragment is always a truthy element, but its
359
+ * collapsed content may be falsy, so `<>{0}</> || 'x'` (renders `0`) must not turn
360
+ * into `0 || 'x'` (renders `'x'`). Keep the fragment in these positions.
361
+ * @param {any} parent
362
+ * @param {any} child
363
+ * @returns {boolean}
364
+ */
365
+ function is_combined_expression_position(parent, child) {
366
+ if (!parent || !is_template_value_position(parent, child)) return false;
367
+ switch (parent.type) {
368
+ // Sole-value render-output slots: the collapse is invisible, keep it.
369
+ case 'VariableDeclarator':
370
+ return parent.init !== child;
371
+ case 'AssignmentExpression':
372
+ return parent.right !== child;
373
+ case 'CallExpression':
374
+ case 'NewExpression':
375
+ return !(Array.isArray(parent.arguments) && parent.arguments.includes(child));
376
+ default:
377
+ return true;
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Re-wrap an already-lowered render value in a `<> … </>` fragment so a fragment
383
+ * combined into an expression keeps its fragment identity (see
384
+ * `is_combined_expression_position`). A value that is already a fragment is left
385
+ * as-is; a JSX element/text nests directly (`<><span /></>`); any other
386
+ * expression goes in a `{ … }` container (`<>{0}</>`).
387
+ * @param {any} expression
388
+ * @param {any} source
389
+ * @returns {any}
390
+ */
391
+ function wrap_lowered_value_in_fragment(expression, source) {
392
+ if (expression?.type === 'JSXFragment') return expression;
393
+ const child =
394
+ expression?.type === 'JSXElement' ||
395
+ expression?.type === 'JSXText' ||
396
+ expression?.type === 'JSXExpressionContainer'
397
+ ? expression
398
+ : to_jsx_expression_container(expression, source);
399
+ return set_loc(b.jsx_fragment([child]), source?.loc ? source : undefined);
400
+ }
401
+
402
+ /**
403
+ * Lower bare JSX control-flow directives that sit as the SOLE value of a
404
+ * render-output slot — an expression-bodied arrow (`() => @switch (…) { … }`), a
405
+ * `return @switch (…) { … }`, an unused expression statement, a variable
406
+ * initializer (`const x = @switch (…) { … }`), an assignment
407
+ * (`x = @switch (…) { … }`), or a call/`new` argument (`render(@if (…) { … })`)
408
+ * — by wrapping them in a native TSRX fragment so they flow through the same
409
+ * render machinery as a `<> … </>` output instead of leaking to the printer as a
410
+ * raw `JSX…Expression`.
411
+ *
412
+ * A control-flow directive or `@{ … }` code block used anywhere ELSE in a value
413
+ * position — COMBINED into an expression (`(@if …) || fallback`, an operator
414
+ * operand, an array element, a template-literal hole, a `@for` iterable, an
415
+ * `@if`/`@switch` test) — is likewise wrapped in a native TSRX fragment. In an
416
+ * operand position the fragment is then KEPT (a fragment is a truthy value, so
417
+ * `<>{…}</> || x` is preserved); in a "raw value" slot the fragment collapses to
418
+ * its rendered value. Either way nothing leaks to the printer as a raw
419
+ * `JSX…Expression`.
420
+ *
268
421
  * @param {any} node
269
- * @param {TransformContext | null} lower_dynamic_context
422
+ * @param {TransformContext} transform_context
270
423
  * @param {Set<any>} [seen]
271
424
  * @returns {void}
272
425
  */
273
- function wrap_control_flow_expression_values(node, lower_dynamic_context, seen = new Set()) {
426
+ function wrap_control_flow_expression_values(node, transform_context, seen = new Set()) {
274
427
  if (!node || typeof node !== 'object' || seen.has(node)) return;
275
428
  seen.add(node);
276
429
 
@@ -282,19 +435,40 @@ function wrap_control_flow_expression_values(node, lower_dynamic_context, seen =
282
435
  // `<{'div'}>`) is hoisted to a module-level static const while still
283
436
  // carrying the raw dynamic tag. Alias lowerings return a replacement
284
437
  // fragment, which is swapped into the child's position here.
438
+ const lower_dynamic = !!transform_context?.platform?.imports?.dynamicFactory;
285
439
  const lower_child = (/** @type {any} */ child) => {
286
- if (!lower_dynamic_context || child?.type !== 'JSXElement') return child;
287
- return lower_dynamic_jsx_element(child, lower_dynamic_context) ?? child;
440
+ if (!lower_dynamic || child?.type !== 'JSXElement') return child;
441
+ return lower_dynamic_jsx_element(child, transform_context) ?? child;
288
442
  };
289
443
 
444
+ // A control-flow directive or `@{ … }` code block combined into an expression
445
+ // (an operator operand, a `@for` iterable, an `@if`/`@switch` test, …) is
446
+ // wrapped in a native TSRX fragment so it flows through the render machinery
447
+ // instead of leaking to the printer as a raw `JSX…Expression`. In an operand
448
+ // position the fragment is then KEPT (a fragment is a truthy value); in a
449
+ // "raw value" slot like a `@for` iterable it collapses to its rendered value
450
+ // (see the JSXFragment visitor and `is_combined_expression_position`).
451
+ const wrap_directive_in_expression = (/** @type {any} */ value) =>
452
+ is_jsx_control_flow_expression(value) || value?.type === 'JSXCodeBlock'
453
+ ? wrap_in_native_tsrx_fragment(value)
454
+ : value;
455
+
290
456
  if (Array.isArray(node)) {
291
457
  for (let i = 0; i < node.length; i++) {
292
458
  node[i] = lower_child(node[i]);
293
- wrap_control_flow_expression_values(node[i], lower_dynamic_context, seen);
459
+ wrap_control_flow_expression_values(node[i], transform_context, seen);
294
460
  }
295
461
  return;
296
462
  }
297
463
 
464
+ // Wrap a bare control-flow directive that is the sole value of a render-output
465
+ // slot in a native TSRX fragment, collapsing to its rendered value. (A `@{ … }`
466
+ // code block in the same slots already self-lowers to an IIFE, so it is left
467
+ // as-is.) These render-output slots are the only value positions a directive is
468
+ // allowed in; see `is_render_output_value_slot`.
469
+ const wrap_value = (/** @type {any} */ value) =>
470
+ is_jsx_control_flow_expression(value) ? wrap_in_native_tsrx_fragment(value) : value;
471
+
298
472
  if (
299
473
  node.type === 'ArrowFunctionExpression' &&
300
474
  node.body?.type !== 'BlockStatement' &&
@@ -316,15 +490,28 @@ function wrap_control_flow_expression_values(node, lower_dynamic_context, seen =
316
490
  (node.type === 'CallExpression' || node.type === 'NewExpression') &&
317
491
  Array.isArray(node.arguments)
318
492
  ) {
319
- node.arguments = node.arguments.map((/** @type {any} */ arg) =>
320
- is_jsx_control_flow_expression(arg) ? wrap_in_native_tsrx_fragment(arg) : arg,
321
- );
493
+ node.arguments = node.arguments.map(wrap_value);
322
494
  }
323
495
 
324
496
  for (const key of Object.keys(node)) {
325
497
  if (key === 'loc' || key === 'start' || key === 'end' || key === 'metadata') continue;
326
- node[key] = lower_child(node[key]);
327
- wrap_control_flow_expression_values(node[key], lower_dynamic_context, seen);
498
+ // A directive is allowed as a render child/statement, and as the sole value
499
+ // of a render-output slot (handled above for control flow; `@{ … }` blocks
500
+ // self-lower). Everywhere else it is combined into an expression — wrap it.
501
+ const allowed_slot =
502
+ is_statement_or_template_slot(node, key) || is_render_output_value_slot(node, key);
503
+ const value = node[key];
504
+ if (Array.isArray(value)) {
505
+ for (let i = 0; i < value.length; i++) {
506
+ value[i] = lower_child(value[i]);
507
+ if (!allowed_slot) value[i] = wrap_directive_in_expression(value[i]);
508
+ wrap_control_flow_expression_values(value[i], transform_context, seen);
509
+ }
510
+ } else {
511
+ node[key] = lower_child(node[key]);
512
+ if (!allowed_slot) node[key] = wrap_directive_in_expression(node[key]);
513
+ wrap_control_flow_expression_values(node[key], transform_context, seen);
514
+ }
328
515
  }
329
516
  }
330
517
 
@@ -392,10 +579,7 @@ export function createJsxTransform(platform) {
392
579
  };
393
580
 
394
581
  expand_child_code_blocks(/** @type {any} */ (ast));
395
- wrap_control_flow_expression_values(
396
- /** @type {any} */ (ast),
397
- platform.imports.dynamicFactory ? transform_context : null,
398
- );
582
+ wrap_control_flow_expression_values(/** @type {any} */ (ast), transform_context);
399
583
 
400
584
  if (!transform_context.typeOnly) {
401
585
  preallocate_lazy_ids(/** @type {any} */ (ast), transform_context);
@@ -419,8 +603,37 @@ export function createJsxTransform(platform) {
419
603
 
420
604
  const style_context = prepare_tsrx_fragment_styles(node, state);
421
605
  const target = style_context?.fragment ?? next() ?? node;
422
- const in_jsx_child = in_jsx_child_context(path);
423
- const expression = tsrx_node_to_jsx_expression(target, state, in_jsx_child);
606
+ // An EMPTY fragment that is the sole expression of a `{ … }` container in a
607
+ // JSX child slot (`<b>{<></>}</b>`) must stay `<></>`: the container already
608
+ // supplies the `{}` wrapper, so lowering it to a bare `null` (the default
609
+ // expression-position behavior) drops the source fragment. This matches how
610
+ // the same fragment is preserved in an attribute value (`a={<></>}`).
611
+ // Non-empty fragments keep their existing lowering.
612
+ const immediate_parent = /** @type {any} */ (path[path.length - 1]);
613
+ const is_empty_container_child =
614
+ immediate_parent?.type === 'JSXExpressionContainer' &&
615
+ in_jsx_child_context(path.slice(0, -1)) &&
616
+ !(target.children || []).some(
617
+ (/** @type {any} */ child) =>
618
+ child &&
619
+ child.type !== 'EmptyStatement' &&
620
+ (child.type !== 'JSXText' || child.value !== ''),
621
+ );
622
+ const in_jsx_child = in_jsx_child_context(path) || is_empty_container_child;
623
+ let expression = tsrx_node_to_jsx_expression(target, state, in_jsx_child);
624
+ // Keep a fragment's `<> … </>` identity in expression position when it is
625
+ // either AUTHORED (the author wrote `<>{1}</>`, so it must not unwrap to a
626
+ // bare `1`) or combined into a surrounding expression (collapsing `<>{0}</>`
627
+ // to `0` would flip `<>{0}</> || 'x'` from rendering `0` to `'x'` — a
628
+ // fragment is always truthy). A compiler-generated wrapper (around a
629
+ // control-flow directive) is NOT authored, so it still collapses.
630
+ if (
631
+ !in_jsx_child &&
632
+ (is_authored_native_fragment(node) ||
633
+ is_combined_expression_position(path[path.length - 1], node))
634
+ ) {
635
+ expression = wrap_lowered_value_in_fragment(expression, node);
636
+ }
424
637
  for (const statement of create_tsrx_style_ref_setup_statements(
425
638
  target,
426
639
  style_context,
@@ -534,6 +747,12 @@ export function createJsxTransform(platform) {
534
747
  inject_try_imports(expanded, transform_context, platform, suspense_source);
535
748
  }
536
749
 
750
+ // Lower any `@{ … }` code blocks left in generated helper bodies before the
751
+ // lazy transform runs, so every `@{ … }` block / `@`-directive has already
752
+ // been lowered to its final closure / block shape. The lazy transform can
753
+ // then walk the complete function structure in one pass.
754
+ lower_remaining_jsx_code_blocks(expanded, transform_context);
755
+
537
756
  // Apply lazy destructuring transforms to module-level code (top-level function
538
757
  // declarations, arrow functions, etc.).
539
758
  // In type-only mode, the lazy patterns survive untouched: esrap ignores the
@@ -541,12 +760,25 @@ export function createJsxTransform(platform) {
541
760
  // = expr` prints as `let [a] = expr`, and the bare statement-level form
542
761
  // `&[x] = expr;` (used when `x` is already declared) prints as `[x] =
543
762
  // expr;` — a valid destructuring assignment to the existing binding.
763
+ //
764
+ // Re-run `preallocate_lazy_ids` first. The initial pre-walk pass stamps
765
+ // `metadata.has_lazy_descendants` (the fast-path gate that tells
766
+ // `apply_lazy_transforms` a function body is worth walking) on the function
767
+ // boundaries that existed in the source. Lowering `@{ … }` blocks and
768
+ // `@if`/`@for`/`@switch`/`@try` directives introduces NEW function
769
+ // boundaries — scoped IIFEs and `.map(...)` callbacks — that wrap those same
770
+ // lazy patterns but were never stamped. Re-running over the lowered tree
771
+ // stamps them too (it is idempotent: already-allocated `lazy_id`s are kept),
772
+ // so lazy bindings declared inside a nested block or directive body are
773
+ // rewritten just like a flat function body.
774
+ if (!transform_context.typeOnly) {
775
+ preallocate_lazy_ids(/** @type {any} */ (expanded), transform_context);
776
+ }
544
777
  const final_program = /** @type {any} */ (
545
778
  transform_context.typeOnly
546
779
  ? expanded
547
780
  : apply_lazy_transforms(/** @type {any} */ (expanded), new Map())
548
781
  );
549
- lower_remaining_jsx_code_blocks(final_program, transform_context);
550
782
 
551
783
  const result = print(/** @type {any} */ (final_program), tsx_with_ts_locations(), {
552
784
  sourceMapSource: filename,
@@ -922,13 +1154,33 @@ function lower_code_block_stream_node(block, transform_context) {
922
1154
  * @param {any[]} body_nodes
923
1155
  * @param {boolean} return_null_when_empty
924
1156
  * @param {TransformContext} transform_context
1157
+ * @param {any} [source_authored_fragment] When the render output is an AUTHORED
1158
+ * `<> … </>` (`is_authored_native_fragment`), the built return value is re-wrapped
1159
+ * in a fragment so the author's fragment is kept verbatim (not collapsed to its
1160
+ * single child), matching value positions. A generated wrapper passes nothing.
925
1161
  * @returns {any[]}
926
1162
  */
927
- function build_render_statements(body_nodes, return_null_when_empty, transform_context) {
1163
+ function build_render_statements(
1164
+ body_nodes,
1165
+ return_null_when_empty,
1166
+ transform_context,
1167
+ source_authored_fragment = null,
1168
+ ) {
928
1169
  body_nodes = body_nodes.flatMap((node) =>
929
1170
  node?.type === 'JSXCodeBlock' ? lower_code_block_stream_node(node, transform_context) : [node],
930
1171
  );
931
1172
 
1173
+ // When a caller (e.g. a directive branch / loop / switch-case body) passes the
1174
+ // authored `<> … </>` as the trailing body node rather than a pre-unwrapped child
1175
+ // list, detect it here so its wrapper is kept too. A generated wrapper carries
1176
+ // `tsrx_generated_wrapper`, so it is excluded and still collapses.
1177
+ if (!source_authored_fragment) {
1178
+ const last_body_node = body_nodes[body_nodes.length - 1];
1179
+ if (is_authored_native_fragment(last_body_node)) {
1180
+ source_authored_fragment = last_body_node;
1181
+ }
1182
+ }
1183
+
932
1184
  const statements = [];
933
1185
  const render_nodes = [];
934
1186
  let has_terminal_return = false;
@@ -1062,7 +1314,22 @@ function build_render_statements(body_nodes, return_null_when_empty, transform_c
1062
1314
  hoist_static_render_nodes(render_nodes, transform_context);
1063
1315
  }
1064
1316
 
1065
- const return_arg = build_return_expression(render_nodes);
1317
+ let return_arg = build_return_expression(render_nodes);
1318
+ // Keep an authored `<> … </>` render output verbatim instead of collapsing it:
1319
+ // an empty `<></>` stays `<></>` (not `null`), and a single child stays wrapped
1320
+ // (not its bare value). The `!== 'JSXFragment'` guard avoids double-wrapping a
1321
+ // multi-child / nested result already returned as a fragment — matching the value
1322
+ // seam. A generated wrapper is not authored, so it still collapses.
1323
+ if (is_authored_native_fragment(source_authored_fragment)) {
1324
+ if (return_arg === null) {
1325
+ return_arg = set_loc(
1326
+ b.jsx_fragment([]),
1327
+ source_authored_fragment.loc ? source_authored_fragment : undefined,
1328
+ );
1329
+ } else if (return_arg.type !== 'JSXFragment') {
1330
+ return_arg = wrap_lowered_value_in_fragment(return_arg, source_authored_fragment);
1331
+ }
1332
+ }
1066
1333
  if (return_arg || (return_null_when_empty && !has_terminal_return)) {
1067
1334
  statements.push(b.return(return_arg || b.literal(null)));
1068
1335
  }
@@ -1383,10 +1650,15 @@ function transform_return_statement(node, { next, visit, state, path }) {
1383
1650
  function transform_jsx_code_block(node, { state, path, visit }) {
1384
1651
  const body_nodes = get_jsx_code_block_body_nodes(node, state);
1385
1652
  const parent = /** @type {any} */ (path.at(-1));
1653
+ // Keep an authored `<> … </>` trailing render output verbatim (a generated
1654
+ // control-flow wrapper carries `tsrx_generated_wrapper`, so it stays null).
1655
+ const render_authored_fragment = is_authored_native_fragment(node.render) ? node.render : null;
1386
1656
 
1387
1657
  if (parent && parent.body === node && is_function_or_class_boundary(parent)) {
1388
1658
  const block = b.block(
1389
- mark_native_pretransformed_jsx(build_render_statements(body_nodes, true, state)),
1659
+ mark_native_pretransformed_jsx(
1660
+ build_render_statements(body_nodes, true, state, render_authored_fragment),
1661
+ ),
1390
1662
  node,
1391
1663
  );
1392
1664
  block.metadata = {
@@ -1400,7 +1672,9 @@ function transform_jsx_code_block(node, { state, path, visit }) {
1400
1672
  b.arrow(
1401
1673
  [],
1402
1674
  b.block(
1403
- mark_native_pretransformed_jsx(build_render_statements(body_nodes, true, state)),
1675
+ mark_native_pretransformed_jsx(
1676
+ build_render_statements(body_nodes, true, state, render_authored_fragment),
1677
+ ),
1404
1678
  node,
1405
1679
  ),
1406
1680
  ),
@@ -1473,7 +1747,11 @@ function lower_jsx_code_block_function_body(node) {
1473
1747
  // component render output. Wrap it in a native fragment so it flows
1474
1748
  // through the same children-rendering path as a `<> … </>` render.
1475
1749
  const fragment = b.jsx_fragment([render]);
1476
- fragment.metadata = { ...fragment.metadata, native_tsrx: true };
1750
+ fragment.metadata = {
1751
+ ...fragment.metadata,
1752
+ native_tsrx: true,
1753
+ tsrx_generated_wrapper: true,
1754
+ };
1477
1755
  render = fragment;
1478
1756
  }
1479
1757
  statements.push(b.return(render, code_block.render));
@@ -2254,7 +2532,7 @@ function create_native_tsrx_render_statements(fragment, transform_context) {
2254
2532
  target.type === 'JSXFragment' ? get_tsrx_render_children(target) : [target];
2255
2533
  return [
2256
2534
  ...create_tsrx_style_ref_setup_statements(target, style_context, transform_context),
2257
- ...build_render_statements(render_nodes, true, transform_context),
2535
+ ...build_render_statements(render_nodes, true, transform_context, fragment),
2258
2536
  ];
2259
2537
  });
2260
2538
  }
@@ -2413,9 +2691,7 @@ function mark_native_pretransformed_jsx(node, seen = new Set()) {
2413
2691
  function get_tsrx_render_children(node) {
2414
2692
  return (node.children || []).filter(
2415
2693
  (/** @type {any} */ child) =>
2416
- child &&
2417
- child.type !== 'EmptyStatement' &&
2418
- (child.type !== 'JSXText' || child.value.trim() !== ''),
2694
+ child && child.type !== 'EmptyStatement' && (child.type !== 'JSXText' || child.value !== ''),
2419
2695
  );
2420
2696
  }
2421
2697
 
@@ -2785,7 +3061,12 @@ function lower_remaining_jsx_code_blocks(node, transform_context, seen = new Set
2785
3061
  if (child?.type !== 'JSXCodeBlock') return [child];
2786
3062
  const body_nodes = get_jsx_code_block_body_nodes(child, transform_context);
2787
3063
  return mark_native_pretransformed_jsx(
2788
- build_render_statements(body_nodes, true, transform_context),
3064
+ build_render_statements(
3065
+ body_nodes,
3066
+ true,
3067
+ transform_context,
3068
+ is_authored_native_fragment(child.render) ? child.render : null,
3069
+ ),
2789
3070
  );
2790
3071
  });
2791
3072
  }
@@ -3399,7 +3680,9 @@ function create_element_children(children, transform_context) {
3399
3680
  const saved_inside_element_child = transform_context.inside_element_child;
3400
3681
  transform_context.inside_element_child = true;
3401
3682
  try {
3402
- return children.map((/** @type {any} */ child) => to_jsx_child(child, transform_context));
3683
+ return wrap_edge_whitespace(
3684
+ children.map((/** @type {any} */ child) => to_jsx_child(child, transform_context)),
3685
+ );
3403
3686
  } finally {
3404
3687
  transform_context.inside_element_child = saved_inside_element_child;
3405
3688
  }
@@ -3949,6 +4232,67 @@ function is_try_control_node(node) {
3949
4232
  return node?.type === 'TryStatement' || node?.type === 'JSXTryExpression';
3950
4233
  }
3951
4234
 
4235
+ /**
4236
+ * Wrap the inline whitespace at a fragment/element's content edges in `{' '}`
4237
+ * containers. A bare leading/trailing space is fragile: once the output is
4238
+ * line-wrapped (by prettier or the host JSX compiler) it becomes newline-adjacent
4239
+ * and is trimmed away, dropping a significant space. Whitespace BETWEEN siblings
4240
+ * stays bare text — it is not at an edge and is preserved as-is. Only spaces/tabs
4241
+ * are pulled out; whitespace runs containing a newline are layout indentation and
4242
+ * are left for the host compiler to collapse.
4243
+ *
4244
+ * @param {any[]} nodes
4245
+ * @returns {any[]}
4246
+ */
4247
+ export function wrap_edge_whitespace(nodes) {
4248
+ const length = nodes.length;
4249
+ if (length === 0) {
4250
+ return nodes;
4251
+ }
4252
+
4253
+ const first = nodes[0];
4254
+ const last = nodes[length - 1];
4255
+ if (first?.type !== 'JSXText' && last?.type !== 'JSXText') {
4256
+ return nodes;
4257
+ }
4258
+
4259
+ /** @type {(ESTreeJSX.JSXExpressionContainer | ESTreeJSX.JSXText)[]} */
4260
+ const out = [];
4261
+ for (let i = 0; i < length; i++) {
4262
+ const node = nodes[i];
4263
+ const at_start = i === 0;
4264
+ const at_end = i === length - 1;
4265
+ if (!node || node.type !== 'JSXText' || (!at_start && !at_end)) {
4266
+ out.push(node);
4267
+ continue;
4268
+ }
4269
+ let value = /** @type {string} */ (node.value);
4270
+ if (at_start) {
4271
+ const lead = LEADING_INLINE_WHITESPACE.exec(value);
4272
+ if (lead && !is_newline_char(value[lead[0].length])) {
4273
+ out.push(to_jsx_expression_container(b.literal(lead[0]), node));
4274
+ value = value.slice(lead[0].length);
4275
+ }
4276
+ }
4277
+ /** @type {ESTreeJSX.JSXExpressionContainer | null} */
4278
+ let trailing = null;
4279
+ if (at_end) {
4280
+ const trail = TRAILING_INLINE_WHITESPACE.exec(value);
4281
+ if (trail && !is_newline_char(value[value.length - trail[0].length - 1])) {
4282
+ trailing = to_jsx_expression_container(b.literal(trail[0]), node);
4283
+ value = value.slice(0, value.length - trail[0].length);
4284
+ }
4285
+ }
4286
+ if (value !== '') {
4287
+ out.push(b.jsx_text(value, value));
4288
+ }
4289
+ if (trailing) {
4290
+ out.push(trailing);
4291
+ }
4292
+ }
4293
+ return out;
4294
+ }
4295
+
3952
4296
  /**
3953
4297
  * @param {any} node
3954
4298
  * @param {TransformContext} transform_context
@@ -4034,15 +4378,28 @@ function to_jsx_child(node, transform_context) {
4034
4378
  function tsrx_node_to_jsx_expression(node, transform_context, in_jsx_child = false) {
4035
4379
  const children = (node.children || []).filter(
4036
4380
  (/** @type {any} */ child) =>
4037
- child &&
4038
- child.type !== 'EmptyStatement' &&
4039
- (child.type !== 'JSXText' || child.value.trim() !== ''),
4381
+ child && child.type !== 'EmptyStatement' && (child.type !== 'JSXText' || child.value !== ''),
4040
4382
  );
4041
4383
 
4042
4384
  /** @type {any} */
4043
4385
  let expression;
4044
4386
  if (children.length === 0) {
4045
- expression = create_null_literal();
4387
+ // An empty fragment is a real value: keep it as `<></>` in BOTH child and
4388
+ // expression position. Lowering it to a bare `null` in expression position
4389
+ // (e.g. `let b = <></>`) drops the author's fragment and changes its type;
4390
+ // `<></>` is a valid value and keeps the to_ts/runtime view faithful.
4391
+ expression = set_loc(b.jsx_fragment([]), node.loc ? node : undefined);
4392
+ } else if (
4393
+ children.length === 1 &&
4394
+ (is_empty_jsx_fragment(children[0]) ||
4395
+ (children[0]?.type === 'JSXFragment' && is_authored_native_fragment(node)))
4396
+ ) {
4397
+ // `<><X></></>` — a fragment whose only child is a fragment. The generic
4398
+ // single-child collapse below would unwrap it to the bare inner fragment,
4399
+ // dropping the outer fragment the author wrote. Keep both levels. (`<><></></>`
4400
+ // is kept regardless; a non-empty inner is only kept for an authored outer, so
4401
+ // a generated wrapper still collapses.)
4402
+ expression = set_loc(b.jsx_fragment(children), node.loc ? node : undefined);
4046
4403
  } else {
4047
4404
  expression = return_value_body_to_expression(children, node, transform_context);
4048
4405
  }
@@ -4052,10 +4409,10 @@ function tsrx_node_to_jsx_expression(node, transform_context, in_jsx_child = fal
4052
4409
  const saved_inside_element_child = transform_context.inside_element_child;
4053
4410
  transform_context.inside_element_child = true;
4054
4411
  try {
4055
- const render_nodes = children.map((/** @type {any} */ child) =>
4056
- to_jsx_child(child, transform_context),
4412
+ const render_nodes = wrap_edge_whitespace(
4413
+ children.map((/** @type {any} */ child) => to_jsx_child(child, transform_context)),
4057
4414
  );
4058
- expression = build_return_expression(render_nodes) || create_null_literal();
4415
+ expression = build_return_expression(render_nodes, in_jsx_child) || create_null_literal();
4059
4416
  } finally {
4060
4417
  transform_context.inside_element_child = saved_inside_element_child;
4061
4418
  }
@@ -5839,22 +6196,26 @@ function value_has_unmappable_jsx_loc(value) {
5839
6196
 
5840
6197
  /**
5841
6198
  * @param {any[]} render_nodes
6199
+ * @param {boolean} [in_jsx_child]
5842
6200
  * @returns {any}
5843
6201
  */
5844
- function build_return_expression(render_nodes) {
6202
+ export function build_return_expression(render_nodes, in_jsx_child = false) {
5845
6203
  if (render_nodes.length === 0) return null;
5846
6204
  if (render_nodes.length === 1) {
5847
6205
  const only = render_nodes[0];
5848
6206
  if (only.type === 'JSXExpressionContainer') {
5849
- // Reactive-block containers (dynamic tags) must stay expression
5850
- // children so the host JSX compiler wraps them in a render block;
5851
- // returning the bare call would evaluate them once.
5852
6207
  if (only.metadata?.tsrx_reactive_block === true) {
5853
6208
  return set_loc(b.jsx_fragment([only]), only.loc ? only : undefined);
5854
6209
  }
6210
+ if (only.expression?.type === 'JSXEmptyExpression') {
6211
+ return set_loc(b.jsx_fragment([]), only.loc ? only : undefined);
6212
+ }
5855
6213
  return only.expression;
5856
6214
  }
5857
6215
  if (only.type === 'JSXText') {
6216
+ if (in_jsx_child) {
6217
+ return set_loc(b.jsx_fragment([only]), only.loc ? only : undefined);
6218
+ }
5858
6219
  const value = (only.value ?? '').trim();
5859
6220
  return b.literal(value, JSON.stringify(value), only);
5860
6221
  }