@tsrx/core 0.1.32 → 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.
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.1.32",
6
+ "version": "0.1.33",
7
7
  "type": "module",
8
8
  "repository": {
9
9
  "type": "git",
package/src/constants.js CHANGED
@@ -2,6 +2,10 @@ export const TEMPLATE_FRAGMENT = 1;
2
2
  export const TEMPLATE_USE_IMPORT_NODE = 1 << 1;
3
3
  export const IS_CONTROLLED = 1 << 2;
4
4
  export const IS_INDEXED = 1 << 3;
5
+ // A control-flow block that is its component's sole root: renders before the
6
+ // parent `__anchor` (sibling semantics, no `<!>` wrapper). Must match the value
7
+ // in ripple's runtime constants.
8
+ export const ROOT_CONTROLLED = 1 << 4;
5
9
  export const TEMPLATE_SVG_NAMESPACE = 1 << 5;
6
10
  export const TEMPLATE_MATHML_NAMESPACE = 1 << 6;
7
11
 
package/src/index.js CHANGED
@@ -31,6 +31,7 @@ export {
31
31
  TEMPLATE_USE_IMPORT_NODE,
32
32
  IS_CONTROLLED,
33
33
  IS_INDEXED,
34
+ ROOT_CONTROLLED,
34
35
  TEMPLATE_SVG_NAMESPACE,
35
36
  TEMPLATE_MATHML_NAMESPACE,
36
37
  HYDRATION_START,
@@ -147,6 +148,7 @@ export {
147
148
  create_hook_safe_helper as createHookSafeHelper,
148
149
  create_element_ref_target_type as createElementRefTargetType,
149
150
  create_element_ref_target_type_for_name as createElementRefTargetTypeForName,
151
+ build_return_expression as buildReturnExpression,
150
152
  createJsxTransform,
151
153
  extract_jsx_setup_declarations as extractJsxSetupDeclarations,
152
154
  is_component_like_element,
@@ -163,7 +165,7 @@ export {
163
165
  } from './transform/jsx/index.js';
164
166
  export {
165
167
  in_jsx_child_context as inJsxChildContext,
166
- tsx_node_to_jsx_expression as tsxNodeToJsxExpression,
168
+ is_empty_jsx_fragment as isEmptyJsxFragment,
167
169
  tsx_with_ts_locations as tsxWithTsLocations,
168
170
  is_template_if_node as isTemplateIfNode,
169
171
  is_template_for_of_node as isTemplateForOfNode,
package/src/plugin.js CHANGED
@@ -232,6 +232,7 @@ export function TSRXPlugin(config) {
232
232
  // If we push an undefined context, Acorn's tokenizer will later crash reading `.override`.
233
233
  const b_stat = tc.b_stat || acorn.tokContexts.b_stat;
234
234
  const b_expr = tc.b_expr || acorn.tokContexts.b_expr;
235
+ const q_tmpl = tc.q_tmpl || acorn.tokContexts.q_tmpl;
235
236
  const tstt = Parser.acornTypeScript.tokTypes;
236
237
  const tstc = Parser.acornTypeScript.tokContexts;
237
238
 
@@ -1272,14 +1273,21 @@ export function TSRXPlugin(config) {
1272
1273
  */
1273
1274
  #parseCodeBlockSetupStatement() {
1274
1275
  const previous_context = this.context;
1275
- this.context = previous_context.filter(
1276
- (context) =>
1277
- context !== tstc.tc_expr && context !== tstc.tc_oTag && context !== tstc.tc_cTag,
1278
- );
1276
+ const at_template_literal = this.type === tt.backQuote;
1279
1277
  let pushed_statement_context = false;
1280
- if (this.curContext() !== b_stat) {
1281
- this.context.push(b_stat);
1282
- pushed_statement_context = true;
1278
+ if (at_template_literal) {
1279
+ if (this.curContext() !== q_tmpl) {
1280
+ this.context.push(q_tmpl);
1281
+ }
1282
+ } else {
1283
+ this.context = previous_context.filter(
1284
+ (context) =>
1285
+ context !== tstc.tc_expr && context !== tstc.tc_oTag && context !== tstc.tc_cTag,
1286
+ );
1287
+ if (this.curContext() !== b_stat) {
1288
+ this.context.push(b_stat);
1289
+ pushed_statement_context = true;
1290
+ }
1283
1291
  }
1284
1292
  this.exprAllowed = true;
1285
1293
  const previous_path = this.#path;
@@ -1287,22 +1295,7 @@ export function TSRXPlugin(config) {
1287
1295
  this.#templateScriptParsingDepth++;
1288
1296
  let node;
1289
1297
  try {
1290
- // A code-block/directive body is statements plus at most one render node —
1291
- // never bare text or markup tokens. If the tokenizer mis-read trailing
1292
- // code as JSX (raw text or a tag-name token — both can happen for a
1293
- // statement following the render node, depending on the leftover context),
1294
- // reposition to the token start and re-read it as code now that the
1295
- // template path is hidden. It then parses as a statement so the
1296
- // one-render-node rule reports a clear "statements cannot follow" error
1297
- // instead of a generic parse fault.
1298
1298
  if (this.type === tstt.jsxText || this.type === tstt.jsxName) {
1299
- // Rewinding `pos` to the mis-read token's start must also rewind the
1300
- // line counter: a `jsxText` token can span newlines (e.g. the blank
1301
- // line before a following render node), and reading it already
1302
- // advanced `curLine`/`lineStart` to its end. Resetting only `pos`
1303
- // would leave the line counter ahead of `pos`, inflating the `loc`
1304
- // of this statement and every node after it (which crashes source-map
1305
- // mapping when the inflated end line runs past the file).
1306
1299
  const loc = acorn.getLineInfo(this.input, this.start);
1307
1300
  this.pos = this.start;
1308
1301
  this.curLine = loc.line;
@@ -1316,7 +1309,9 @@ export function TSRXPlugin(config) {
1316
1309
  if (pushed_statement_context && this.curContext() === b_stat) {
1317
1310
  this.context.pop();
1318
1311
  }
1319
- this.context = previous_context;
1312
+ if (!at_template_literal) {
1313
+ this.context = previous_context;
1314
+ }
1320
1315
  }
1321
1316
  if (this.curContext() === tstc.tc_expr) {
1322
1317
  this.context.pop();
@@ -4383,22 +4378,29 @@ export function TSRXPlugin(config) {
4383
4378
  parseTemplateBody(body) {
4384
4379
  const current_template_node = this.#currentNativeTemplateNode();
4385
4380
  if (!current_template_node) return;
4386
- // Outside a `@{ … }` block every element/fragment body is plain JSX (§2,
4387
- // §5). There is no script section and no `---` fence to infer — text is
4388
- // text, and setup code lives only inside a code block.
4389
4381
  current_template_node.metadata ??= { path: [] };
4390
4382
  current_template_node.metadata.templateMode = 'template';
4391
4383
 
4392
- // `@{ … }` code block as element/fragment content (§2 rule 1). Sibling
4393
- // code blocks are allowed, so this is not gated on an empty body;
4394
- // reposition onto the `@` if leading whitespace was tokenized ahead of it.
4395
4384
  if (this.#atCodeBlockStart()) {
4396
4385
  const at_index = skip_whitespace_from(this.input, this.start);
4397
4386
  if (this.start !== at_index) {
4387
+ const ws_start = this.start;
4388
+ const ws_start_loc = this.startLoc;
4389
+ const ws_value = this.input.slice(ws_start, at_index);
4390
+ const text_node = /** @type {ESTreeJSX.JSXText} */ (
4391
+ this.startNodeAt(ws_start, ws_start_loc)
4392
+ );
4393
+ text_node.value = ws_value;
4394
+ text_node.raw = ws_value;
4398
4395
  const loc = acorn.getLineInfo(this.input, at_index);
4396
+ const at_position = new acorn.Position(loc.line, loc.column);
4397
+ this.finishNodeAt(text_node, 'JSXText', at_index, at_position);
4398
+ if (this.#shouldKeepTemplateTextNode(text_node)) {
4399
+ body.push(text_node);
4400
+ }
4399
4401
  this.pos = at_index;
4400
4402
  this.start = at_index;
4401
- this.startLoc = new acorn.Position(loc.line, loc.column);
4403
+ this.startLoc = at_position;
4402
4404
  this.curLine = loc.line;
4403
4405
  this.lineStart = at_index - loc.column;
4404
4406
  }
@@ -4416,8 +4418,23 @@ export function TSRXPlugin(config) {
4416
4418
  // text never starts at `<`, so drop the leaked context and re-read the
4417
4419
  // tag instead of emitting an empty node.
4418
4420
  if (this.input.charCodeAt(this.start) === CharCode.lessThan) {
4419
- while (this.curContext() === tstc.tc_expr) {
4420
- this.context.pop();
4421
+ if (this.input.charCodeAt(this.start + 1) === CharCode.slash) {
4422
+ while (this.curContext() === tstc.tc_expr) {
4423
+ this.context.pop();
4424
+ }
4425
+ } else {
4426
+ let native_depth = 0;
4427
+ for (const node of this.#path) {
4428
+ if (this.#isNativeTemplateNode(node)) native_depth++;
4429
+ }
4430
+ let tc_expr_depth = 0;
4431
+ for (const context of this.context) {
4432
+ if (context === tstc.tc_expr) tc_expr_depth++;
4433
+ }
4434
+ while (tc_expr_depth > native_depth && this.curContext() === tstc.tc_expr) {
4435
+ this.context.pop();
4436
+ tc_expr_depth--;
4437
+ }
4421
4438
  }
4422
4439
  this.pos = this.start;
4423
4440
  this.exprAllowed = true;
@@ -19,6 +19,20 @@ export function in_jsx_child_context(path) {
19
19
  return !!parent && (parent.type === 'JSXElement' || parent.type === 'JSXFragment');
20
20
  }
21
21
 
22
+ /**
23
+ * @param {any} node
24
+ * @returns {boolean}
25
+ */
26
+ export function is_empty_jsx_fragment(node) {
27
+ return (
28
+ node?.type === 'JSXFragment' &&
29
+ !(node.children || []).some(
30
+ (/** @type {any} */ child) =>
31
+ child && (child.type !== 'JSXText' || child.value.trim() !== ''),
32
+ )
33
+ );
34
+ }
35
+
22
36
  /**
23
37
  * Match Ripple's transform path metadata shape: every node seen by the walker
24
38
  * carries its current ancestor path for downstream CSS pruning and mapping
@@ -36,45 +50,6 @@ export function set_node_path_metadata(node, path) {
36
50
  }
37
51
  }
38
52
 
39
- /**
40
- * Flatten a JSX-compatible island's children into a single expression. In a
41
- * JSX-child position, a JSXExpressionContainer `{expr}` is valid and must stay
42
- * wrapped. In an expression position (e.g. `return ...`), `{expr}` parses as
43
- * a block/object literal, so unwrap to `expr`.
44
- *
45
- * @param {any} node
46
- * @param {boolean} [in_jsx_child]
47
- * @returns {any}
48
- */
49
- export function tsx_node_to_jsx_expression(node, in_jsx_child = false) {
50
- const children = (node.children || []).filter(
51
- (/** @type {any} */ child) => child.type !== 'JSXText' || child.value.trim() !== '',
52
- );
53
-
54
- if (
55
- children.length === 1 &&
56
- children[0].type !== 'JSXText' &&
57
- // Reactive-block containers (dynamic tags) must stay expression
58
- // children so the host JSX compiler wraps them in a render block;
59
- // unwrapping to a bare call would evaluate them once.
60
- children[0].metadata?.tsrx_reactive_block !== true
61
- ) {
62
- const only = children[0];
63
- if (only.type === 'JSXExpressionContainer' && !in_jsx_child) {
64
- return only.expression;
65
- }
66
- return only;
67
- }
68
-
69
- return /** @type {any} */ ({
70
- type: 'JSXFragment',
71
- openingFragment: { type: 'JSXOpeningFragment', metadata: { path: [] } },
72
- closingFragment: { type: 'JSXClosingFragment', metadata: { path: [] } },
73
- children,
74
- metadata: { path: [] },
75
- });
76
- }
77
-
78
53
  /**
79
54
  * Wrap esrap's `tsx()` printer with location markers for nodes whose spans
80
55
  * (e.g. the leading `new ` of a NewExpression or the angle-bracket delimiters
@@ -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,
@@ -258,29 +260,170 @@ function is_jsx_control_flow_expression(node) {
258
260
 
259
261
  /**
260
262
  * Wrap a render-output node in a native TSRX fragment so it flows through the
261
- * 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.
262
267
  * @param {any} node
263
268
  * @returns {any}
264
269
  */
265
270
  function wrap_in_native_tsrx_fragment(node) {
266
271
  const fragment = b.jsx_fragment([node]);
267
- fragment.metadata = { ...(fragment.metadata || {}), native_tsrx: true };
272
+ fragment.metadata = {
273
+ ...(fragment.metadata || {}),
274
+ native_tsrx: true,
275
+ tsrx_generated_wrapper: true,
276
+ };
268
277
  return fragment;
269
278
  }
270
279
 
271
280
  /**
272
- * Wrap a bare JSX control-flow directive that sits directly in an expression
273
- * position an expression-bodied arrow (`() => @switch (…) { … }`), a
274
- * `return @switch (…) { }`, an unused expression statement,
275
- * assignment to a variable
276
- * (`const x = @switch (…) {}`, `x = @switch (…) { … }`), or a call/`new`
277
- * 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
+ *
278
421
  * @param {any} node
279
- * @param {TransformContext | null} lower_dynamic_context
422
+ * @param {TransformContext} transform_context
280
423
  * @param {Set<any>} [seen]
281
424
  * @returns {void}
282
425
  */
283
- 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()) {
284
427
  if (!node || typeof node !== 'object' || seen.has(node)) return;
285
428
  seen.add(node);
286
429
 
@@ -292,19 +435,40 @@ function wrap_control_flow_expression_values(node, lower_dynamic_context, seen =
292
435
  // `<{'div'}>`) is hoisted to a module-level static const while still
293
436
  // carrying the raw dynamic tag. Alias lowerings return a replacement
294
437
  // fragment, which is swapped into the child's position here.
438
+ const lower_dynamic = !!transform_context?.platform?.imports?.dynamicFactory;
295
439
  const lower_child = (/** @type {any} */ child) => {
296
- if (!lower_dynamic_context || child?.type !== 'JSXElement') return child;
297
- 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;
298
442
  };
299
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
+
300
456
  if (Array.isArray(node)) {
301
457
  for (let i = 0; i < node.length; i++) {
302
458
  node[i] = lower_child(node[i]);
303
- wrap_control_flow_expression_values(node[i], lower_dynamic_context, seen);
459
+ wrap_control_flow_expression_values(node[i], transform_context, seen);
304
460
  }
305
461
  return;
306
462
  }
307
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
+
308
472
  if (
309
473
  node.type === 'ArrowFunctionExpression' &&
310
474
  node.body?.type !== 'BlockStatement' &&
@@ -326,15 +490,28 @@ function wrap_control_flow_expression_values(node, lower_dynamic_context, seen =
326
490
  (node.type === 'CallExpression' || node.type === 'NewExpression') &&
327
491
  Array.isArray(node.arguments)
328
492
  ) {
329
- node.arguments = node.arguments.map((/** @type {any} */ arg) =>
330
- is_jsx_control_flow_expression(arg) ? wrap_in_native_tsrx_fragment(arg) : arg,
331
- );
493
+ node.arguments = node.arguments.map(wrap_value);
332
494
  }
333
495
 
334
496
  for (const key of Object.keys(node)) {
335
497
  if (key === 'loc' || key === 'start' || key === 'end' || key === 'metadata') continue;
336
- node[key] = lower_child(node[key]);
337
- 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
+ }
338
515
  }
339
516
  }
340
517
 
@@ -402,10 +579,7 @@ export function createJsxTransform(platform) {
402
579
  };
403
580
 
404
581
  expand_child_code_blocks(/** @type {any} */ (ast));
405
- wrap_control_flow_expression_values(
406
- /** @type {any} */ (ast),
407
- platform.imports.dynamicFactory ? transform_context : null,
408
- );
582
+ wrap_control_flow_expression_values(/** @type {any} */ (ast), transform_context);
409
583
 
410
584
  if (!transform_context.typeOnly) {
411
585
  preallocate_lazy_ids(/** @type {any} */ (ast), transform_context);
@@ -429,8 +603,37 @@ export function createJsxTransform(platform) {
429
603
 
430
604
  const style_context = prepare_tsrx_fragment_styles(node, state);
431
605
  const target = style_context?.fragment ?? next() ?? node;
432
- const in_jsx_child = in_jsx_child_context(path);
433
- 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
+ }
434
637
  for (const statement of create_tsrx_style_ref_setup_statements(
435
638
  target,
436
639
  style_context,
@@ -544,6 +747,12 @@ export function createJsxTransform(platform) {
544
747
  inject_try_imports(expanded, transform_context, platform, suspense_source);
545
748
  }
546
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
+
547
756
  // Apply lazy destructuring transforms to module-level code (top-level function
548
757
  // declarations, arrow functions, etc.).
549
758
  // In type-only mode, the lazy patterns survive untouched: esrap ignores the
@@ -551,12 +760,25 @@ export function createJsxTransform(platform) {
551
760
  // = expr` prints as `let [a] = expr`, and the bare statement-level form
552
761
  // `&[x] = expr;` (used when `x` is already declared) prints as `[x] =
553
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
+ }
554
777
  const final_program = /** @type {any} */ (
555
778
  transform_context.typeOnly
556
779
  ? expanded
557
780
  : apply_lazy_transforms(/** @type {any} */ (expanded), new Map())
558
781
  );
559
- lower_remaining_jsx_code_blocks(final_program, transform_context);
560
782
 
561
783
  const result = print(/** @type {any} */ (final_program), tsx_with_ts_locations(), {
562
784
  sourceMapSource: filename,
@@ -932,13 +1154,33 @@ function lower_code_block_stream_node(block, transform_context) {
932
1154
  * @param {any[]} body_nodes
933
1155
  * @param {boolean} return_null_when_empty
934
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.
935
1161
  * @returns {any[]}
936
1162
  */
937
- 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
+ ) {
938
1169
  body_nodes = body_nodes.flatMap((node) =>
939
1170
  node?.type === 'JSXCodeBlock' ? lower_code_block_stream_node(node, transform_context) : [node],
940
1171
  );
941
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
+
942
1184
  const statements = [];
943
1185
  const render_nodes = [];
944
1186
  let has_terminal_return = false;
@@ -1072,7 +1314,22 @@ function build_render_statements(body_nodes, return_null_when_empty, transform_c
1072
1314
  hoist_static_render_nodes(render_nodes, transform_context);
1073
1315
  }
1074
1316
 
1075
- 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
+ }
1076
1333
  if (return_arg || (return_null_when_empty && !has_terminal_return)) {
1077
1334
  statements.push(b.return(return_arg || b.literal(null)));
1078
1335
  }
@@ -1393,10 +1650,15 @@ function transform_return_statement(node, { next, visit, state, path }) {
1393
1650
  function transform_jsx_code_block(node, { state, path, visit }) {
1394
1651
  const body_nodes = get_jsx_code_block_body_nodes(node, state);
1395
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;
1396
1656
 
1397
1657
  if (parent && parent.body === node && is_function_or_class_boundary(parent)) {
1398
1658
  const block = b.block(
1399
- 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
+ ),
1400
1662
  node,
1401
1663
  );
1402
1664
  block.metadata = {
@@ -1410,7 +1672,9 @@ function transform_jsx_code_block(node, { state, path, visit }) {
1410
1672
  b.arrow(
1411
1673
  [],
1412
1674
  b.block(
1413
- 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
+ ),
1414
1678
  node,
1415
1679
  ),
1416
1680
  ),
@@ -1483,7 +1747,11 @@ function lower_jsx_code_block_function_body(node) {
1483
1747
  // component render output. Wrap it in a native fragment so it flows
1484
1748
  // through the same children-rendering path as a `<> … </>` render.
1485
1749
  const fragment = b.jsx_fragment([render]);
1486
- fragment.metadata = { ...fragment.metadata, native_tsrx: true };
1750
+ fragment.metadata = {
1751
+ ...fragment.metadata,
1752
+ native_tsrx: true,
1753
+ tsrx_generated_wrapper: true,
1754
+ };
1487
1755
  render = fragment;
1488
1756
  }
1489
1757
  statements.push(b.return(render, code_block.render));
@@ -2264,7 +2532,7 @@ function create_native_tsrx_render_statements(fragment, transform_context) {
2264
2532
  target.type === 'JSXFragment' ? get_tsrx_render_children(target) : [target];
2265
2533
  return [
2266
2534
  ...create_tsrx_style_ref_setup_statements(target, style_context, transform_context),
2267
- ...build_render_statements(render_nodes, true, transform_context),
2535
+ ...build_render_statements(render_nodes, true, transform_context, fragment),
2268
2536
  ];
2269
2537
  });
2270
2538
  }
@@ -2793,7 +3061,12 @@ function lower_remaining_jsx_code_blocks(node, transform_context, seen = new Set
2793
3061
  if (child?.type !== 'JSXCodeBlock') return [child];
2794
3062
  const body_nodes = get_jsx_code_block_body_nodes(child, transform_context);
2795
3063
  return mark_native_pretransformed_jsx(
2796
- 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
+ ),
2797
3070
  );
2798
3071
  });
2799
3072
  }
@@ -4111,9 +4384,22 @@ function tsrx_node_to_jsx_expression(node, transform_context, in_jsx_child = fal
4111
4384
  /** @type {any} */
4112
4385
  let expression;
4113
4386
  if (children.length === 0) {
4114
- expression = in_jsx_child
4115
- ? set_loc(b.jsx_fragment([]), node.loc ? node : undefined)
4116
- : 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);
4117
4403
  } else {
4118
4404
  expression = return_value_body_to_expression(children, node, transform_context);
4119
4405
  }
@@ -5913,17 +6199,17 @@ function value_has_unmappable_jsx_loc(value) {
5913
6199
  * @param {boolean} [in_jsx_child]
5914
6200
  * @returns {any}
5915
6201
  */
5916
- function build_return_expression(render_nodes, in_jsx_child = false) {
6202
+ export function build_return_expression(render_nodes, in_jsx_child = false) {
5917
6203
  if (render_nodes.length === 0) return null;
5918
6204
  if (render_nodes.length === 1) {
5919
6205
  const only = render_nodes[0];
5920
6206
  if (only.type === 'JSXExpressionContainer') {
5921
- // Reactive-block containers (dynamic tags) must stay expression
5922
- // children so the host JSX compiler wraps them in a render block;
5923
- // returning the bare call would evaluate them once.
5924
6207
  if (only.metadata?.tsrx_reactive_block === true) {
5925
6208
  return set_loc(b.jsx_fragment([only]), only.loc ? only : undefined);
5926
6209
  }
6210
+ if (only.expression?.type === 'JSXEmptyExpression') {
6211
+ return set_loc(b.jsx_fragment([]), only.loc ? only : undefined);
6212
+ }
5927
6213
  return only.expression;
5928
6214
  }
5929
6215
  if (only.type === 'JSXText') {
@@ -99,38 +99,6 @@ function get_lazy_pattern_mapping_range(pattern) {
99
99
  };
100
100
  }
101
101
 
102
- /**
103
- * Synthesize an object-shaped annotation for untyped lazy object params so the
104
- * virtual TSX can expose prop names to TypeScript completions.
105
- *
106
- * @param {any} pattern
107
- * @returns {any | null}
108
- */
109
- function create_lazy_object_type_annotation(pattern) {
110
- if (pattern.type !== 'ObjectPattern') return null;
111
-
112
- const members = [];
113
- for (const prop of pattern.properties || []) {
114
- if (prop.type === 'RestElement' || prop.computed) continue;
115
-
116
- const key = prop.key;
117
- if (key.type !== 'Identifier' && key.type !== 'Literal') continue;
118
-
119
- const member_key =
120
- key.type === 'Identifier'
121
- ? create_generated_identifier(key.name, key)
122
- : set_source_location({ ...key, metadata: { path: [] } }, key);
123
-
124
- members.push(
125
- b.ts_property_signature(member_key, b.ts_type_annotation(b.ts_keyword_type('any'))),
126
- );
127
- }
128
-
129
- if (members.length === 0) return null;
130
-
131
- return b.ts_type_annotation(b.ts_type_literal(members));
132
- }
133
-
134
102
  /**
135
103
  * @param {any} node
136
104
  * @returns {string | null}
@@ -348,12 +316,14 @@ function visit_topmost_lazy_patterns(pattern, visit) {
348
316
 
349
317
  /**
350
318
  * Build the replacement identifier for a lazy pattern. When `is_top` is true
351
- * (the pattern is itself a function parameter) we attach the original
352
- * `typeAnnotation`, synthesize an object-shaped annotation for untyped object
353
- * params so TypeScript sees prop names, and register source-mapping info.
354
- * Nested replacements (inside a non-lazy outer destructure) can't carry an
355
- * inline type annotation that's not valid syntax so they get a plain
356
- * identifier with just source-range info.
319
+ * (the pattern is itself a function parameter) we carry over the author's
320
+ * `typeAnnotation` if one was written and register source-mapping info. An
321
+ * untyped lazy object param gets NO synthesized annotation: since the source
322
+ * specified no type, the generated param is left implicitly `any` rather than
323
+ * being given a fabricated `{ : any }` object type. Nested replacements
324
+ * (inside a non-lazy outer destructure) can't carry an inline type annotation —
325
+ * that's not valid syntax — so they get a plain identifier with just source-range
326
+ * info.
357
327
  *
358
328
  * @param {any} pattern
359
329
  * @param {boolean} is_top
@@ -372,9 +342,6 @@ function build_lazy_id_for_pattern(pattern, is_top) {
372
342
  if (!is_top) return lazy_id;
373
343
  if (pattern.typeAnnotation) {
374
344
  lazy_id.typeAnnotation = pattern.typeAnnotation;
375
- } else {
376
- const type_annotation = create_lazy_object_type_annotation(pattern);
377
- if (type_annotation) lazy_id.typeAnnotation = type_annotation;
378
345
  }
379
346
  set_lazy_param_binding_mappings(lazy_id, pattern);
380
347
  return lazy_id;
@@ -511,6 +478,53 @@ export function preallocate_lazy_ids(root, context) {
511
478
  visit(root);
512
479
  }
513
480
 
481
+ /**
482
+ * Convert an ESTree member-access chain (`__lazy0.Item`, `__lazy0.a.b`) into the
483
+ * equivalent JSX element-name node (a `JSXIdentifier` or `JSXMemberExpression`),
484
+ * so a lazy binding used as a component/element name (`<Item>`) can be rewritten
485
+ * to `<__lazy0.Item>`. Returns null for a computed access (`__lazy0[0]`, from an
486
+ * array destructure), which has no JSX-name form.
487
+ *
488
+ * @param {any} expr
489
+ * @param {any} [source]
490
+ * @returns {any | null}
491
+ */
492
+ function estree_member_to_jsx_name(expr, source) {
493
+ if (expr.type === 'Identifier') {
494
+ return set_source_location(b.jsx_id(expr.name), source);
495
+ }
496
+ if (expr.type === 'MemberExpression' && !expr.computed && expr.property?.type === 'Identifier') {
497
+ const object = estree_member_to_jsx_name(expr.object, source);
498
+ if (!object) return null;
499
+ return set_source_location(b.jsx_member(object, b.jsx_id(expr.property.name)), source);
500
+ }
501
+ return null;
502
+ }
503
+
504
+ /**
505
+ * If a JSX element/component name references a lazy binding, return the rewritten
506
+ * JSX name (`<Item>` → `<__lazy0.Item>`, `<Item.Sub>` → `<__lazy0.Item.Sub>`).
507
+ * Returns null when the name does not reference a lazy binding (or the access has
508
+ * no JSX-name form), so the caller leaves the original name untouched.
509
+ *
510
+ * @param {any} name
511
+ * @param {Map<string, LazyBinding>} lazy_bindings
512
+ * @returns {any | null}
513
+ */
514
+ function rewrite_lazy_jsx_name(name, lazy_bindings) {
515
+ if (!name) return null;
516
+ if (name.type === 'JSXIdentifier') {
517
+ const binding = lazy_bindings.get(name.name);
518
+ if (!binding) return null;
519
+ return estree_member_to_jsx_name(binding.read(name), name);
520
+ }
521
+ if (name.type === 'JSXMemberExpression') {
522
+ const new_object = rewrite_lazy_jsx_name(name.object, lazy_bindings);
523
+ return new_object ? { ...name, object: new_object } : null;
524
+ }
525
+ return null;
526
+ }
527
+
514
528
  /**
515
529
  * Recursively rewrite lazy-binding references in `node`.
516
530
  *
@@ -669,16 +683,36 @@ export function apply_lazy_transforms(node, lazy_bindings) {
669
683
  }
670
684
 
671
685
  if (node.type === 'SwitchStatement') {
686
+ // All case consequents share one lexical block scope, and `@switch`
687
+ // case bodies are flattened (the `{ … }` wrapper is dropped), so a
688
+ // `let &[x] = …` / `let &{ … } = …` declared in a case body is scoped to
689
+ // the whole switch block. Collect names and lazy bindings across every
690
+ // consequent — like the BlockStatement handler does for a block body — so
691
+ // references in any case resolve to the generated id, and an inner
692
+ // declaration shadowing an outer lazy name is dropped.
693
+ const all_consequents = node.cases.flatMap(
694
+ (/** @type {any} */ switch_case) => switch_case.consequent,
695
+ );
696
+ const block_shadowed = collect_block_shadowed_names(all_consequents, lazy_bindings);
697
+ const after_shadow =
698
+ block_shadowed.size > 0 ? remove_shadowed(lazy_bindings, block_shadowed) : lazy_bindings;
699
+
700
+ /** @type {Map<string, LazyBinding>} */
701
+ const block_lazy = new Map();
702
+ collect_lazy_bindings_from_statements(all_consequents, block_lazy);
703
+ const effective_bindings =
704
+ block_lazy.size > 0 ? new Map([...after_shadow, ...block_lazy]) : after_shadow;
705
+
672
706
  let changed = false;
673
- const new_discriminant = apply_lazy_transforms(node.discriminant, lazy_bindings);
707
+ // The discriminant is evaluated before any case body runs, so it sees the
708
+ // bindings visible at the switch (outer, minus inner shadows), not a lazy
709
+ // binding declared inside a case body.
710
+ const new_discriminant = apply_lazy_transforms(node.discriminant, after_shadow);
674
711
  if (new_discriminant !== node.discriminant) changed = true;
675
712
  const new_cases = node.cases.map((/** @type {any} */ switch_case) => {
676
- const case_bindings = collect_block_shadowed_names(switch_case.consequent, lazy_bindings);
677
- const effective_bindings =
678
- case_bindings.size > 0 ? remove_shadowed(lazy_bindings, case_bindings) : lazy_bindings;
679
713
  let case_changed = false;
680
714
  const new_test = switch_case.test
681
- ? apply_lazy_transforms(switch_case.test, lazy_bindings)
715
+ ? apply_lazy_transforms(switch_case.test, effective_bindings)
682
716
  : null;
683
717
  if (new_test !== switch_case.test) case_changed = true;
684
718
  const new_consequent = switch_case.consequent.map((/** @type {any} */ stmt) => {
@@ -823,6 +857,21 @@ export function apply_lazy_transforms(node, lazy_bindings) {
823
857
  // Skip VariableDeclarator id (already handled above).
824
858
  if (key === 'id' && node.type === 'VariableDeclarator') continue;
825
859
 
860
+ // A JSX element/component name that resolves to a lazy binding becomes a
861
+ // JSX member expression (`<Item>` → `<__lazy0.Item>`): the bound name is no
862
+ // longer a local, it is a property of the synthesized lazy source.
863
+ if (
864
+ key === 'name' &&
865
+ (node.type === 'JSXOpeningElement' || node.type === 'JSXClosingElement')
866
+ ) {
867
+ const jsx_name = rewrite_lazy_jsx_name(node[key], lazy_bindings);
868
+ if (jsx_name) {
869
+ clone[key] = jsx_name;
870
+ changed = true;
871
+ continue;
872
+ }
873
+ }
874
+
826
875
  const new_value = apply_lazy_transforms(node[key], lazy_bindings);
827
876
  if (new_value !== node[key]) {
828
877
  clone[key] = new_value;
package/types/index.d.ts CHANGED
@@ -83,6 +83,7 @@ interface BaseNodeMetaData {
83
83
  commentContainerId?: number;
84
84
  parenthesized?: boolean;
85
85
  native_tsrx?: boolean;
86
+ tsrx_generated_wrapper?: boolean;
86
87
  native_tsrx_template_block?: boolean;
87
88
  dynamicElement?: boolean;
88
89
  templateMode?: 'script' | 'template';
@@ -137,6 +138,7 @@ type AcornTSNode<T> = Omit<T, 'parent' | 'loc' | 'range' | 'expression'> & {
137
138
 
138
139
  leadingComments?: AST.Comment[] | undefined;
139
140
  trailingComments?: AST.Comment[] | undefined;
141
+ append_into?: AST.Identifier;
140
142
  };
141
143
 
142
144
  interface FunctionLikeTS {
@@ -295,6 +297,8 @@ declare module 'estree' {
295
297
  TSAsExpression: TSAsExpression;
296
298
  }
297
299
 
300
+ type TraversableAstNode = AST.Node & Record<string, unknown>;
301
+
298
302
  // Ripple-normalized template node shapes. The core parser emits JSX-shaped
299
303
  // TSRX nodes; @tsrx/ripple creates these during its normalization pass.
300
304
  interface Attribute extends AST.BaseNode {
@@ -516,6 +520,8 @@ declare module 'estree' {
516
520
  metadata: BaseNodeMetaData;
517
521
 
518
522
  comments?: Comment[];
523
+
524
+ append_into?: AST.Identifier;
519
525
  }
520
526
 
521
527
  interface NodeWithLocation {