@tsrx/core 0.1.32 → 0.1.34

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.34",
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;
@@ -50,6 +50,17 @@ export const mapping_data_verify_complete = {
50
50
  completion: true,
51
51
  };
52
52
 
53
+ /**
54
+ * Completion only — no verification/hover/navigation. Used for positions that
55
+ * should surface completions but must not be type-checked, e.g. a `@`-leading text
56
+ * node that is an in-progress template directive (verifying it as code would raise
57
+ * spurious diagnostics).
58
+ * @type {Partial<VolarCodeMapping['data']>}
59
+ */
60
+ export const mapping_data_completion_only = {
61
+ completion: true,
62
+ };
63
+
53
64
  /**
54
65
  * Convert byte offset to line/column
55
66
  * @param {number} offset
@@ -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;
@@ -960,7 +1202,9 @@ function build_render_statements(body_nodes, return_null_when_empty, transform_c
960
1202
  const child = body_nodes[i];
961
1203
 
962
1204
  if (is_loop_skip_return_statement(child)) {
963
- statements.push(create_component_return_statement(render_nodes, child));
1205
+ statements.push(
1206
+ create_component_return_statement(render_nodes, child, true, transform_context.typeOnly),
1207
+ );
964
1208
  render_nodes.length = 0;
965
1209
  has_terminal_return = true;
966
1210
  continue;
@@ -1072,7 +1316,22 @@ function build_render_statements(body_nodes, return_null_when_empty, transform_c
1072
1316
  hoist_static_render_nodes(render_nodes, transform_context);
1073
1317
  }
1074
1318
 
1075
- const return_arg = build_return_expression(render_nodes);
1319
+ let return_arg = build_return_expression(render_nodes, false, transform_context.typeOnly);
1320
+ // Keep an authored `<> … </>` render output verbatim instead of collapsing it:
1321
+ // an empty `<></>` stays `<></>` (not `null`), and a single child stays wrapped
1322
+ // (not its bare value). The `!== 'JSXFragment'` guard avoids double-wrapping a
1323
+ // multi-child / nested result already returned as a fragment — matching the value
1324
+ // seam. A generated wrapper is not authored, so it still collapses.
1325
+ if (is_authored_native_fragment(source_authored_fragment)) {
1326
+ if (return_arg === null) {
1327
+ return_arg = set_loc(
1328
+ b.jsx_fragment([]),
1329
+ source_authored_fragment.loc ? source_authored_fragment : undefined,
1330
+ );
1331
+ } else if (return_arg.type !== 'JSXFragment') {
1332
+ return_arg = wrap_lowered_value_in_fragment(return_arg, source_authored_fragment);
1333
+ }
1334
+ }
1076
1335
  if (return_arg || (return_null_when_empty && !has_terminal_return)) {
1077
1336
  statements.push(b.return(return_arg || b.literal(null)));
1078
1337
  }
@@ -1393,10 +1652,15 @@ function transform_return_statement(node, { next, visit, state, path }) {
1393
1652
  function transform_jsx_code_block(node, { state, path, visit }) {
1394
1653
  const body_nodes = get_jsx_code_block_body_nodes(node, state);
1395
1654
  const parent = /** @type {any} */ (path.at(-1));
1655
+ // Keep an authored `<> … </>` trailing render output verbatim (a generated
1656
+ // control-flow wrapper carries `tsrx_generated_wrapper`, so it stays null).
1657
+ const render_authored_fragment = is_authored_native_fragment(node.render) ? node.render : null;
1396
1658
 
1397
1659
  if (parent && parent.body === node && is_function_or_class_boundary(parent)) {
1398
1660
  const block = b.block(
1399
- mark_native_pretransformed_jsx(build_render_statements(body_nodes, true, state)),
1661
+ mark_native_pretransformed_jsx(
1662
+ build_render_statements(body_nodes, true, state, render_authored_fragment),
1663
+ ),
1400
1664
  node,
1401
1665
  );
1402
1666
  block.metadata = {
@@ -1410,7 +1674,9 @@ function transform_jsx_code_block(node, { state, path, visit }) {
1410
1674
  b.arrow(
1411
1675
  [],
1412
1676
  b.block(
1413
- mark_native_pretransformed_jsx(build_render_statements(body_nodes, true, state)),
1677
+ mark_native_pretransformed_jsx(
1678
+ build_render_statements(body_nodes, true, state, render_authored_fragment),
1679
+ ),
1414
1680
  node,
1415
1681
  ),
1416
1682
  ),
@@ -1483,7 +1749,11 @@ function lower_jsx_code_block_function_body(node) {
1483
1749
  // component render output. Wrap it in a native fragment so it flows
1484
1750
  // through the same children-rendering path as a `<> … </>` render.
1485
1751
  const fragment = b.jsx_fragment([render]);
1486
- fragment.metadata = { ...fragment.metadata, native_tsrx: true };
1752
+ fragment.metadata = {
1753
+ ...fragment.metadata,
1754
+ native_tsrx: true,
1755
+ tsrx_generated_wrapper: true,
1756
+ };
1487
1757
  render = fragment;
1488
1758
  }
1489
1759
  statements.push(b.return(render, code_block.render));
@@ -2264,7 +2534,7 @@ function create_native_tsrx_render_statements(fragment, transform_context) {
2264
2534
  target.type === 'JSXFragment' ? get_tsrx_render_children(target) : [target];
2265
2535
  return [
2266
2536
  ...create_tsrx_style_ref_setup_statements(target, style_context, transform_context),
2267
- ...build_render_statements(render_nodes, true, transform_context),
2537
+ ...build_render_statements(render_nodes, true, transform_context, fragment),
2268
2538
  ];
2269
2539
  });
2270
2540
  }
@@ -2793,7 +3063,12 @@ function lower_remaining_jsx_code_blocks(node, transform_context, seen = new Set
2793
3063
  if (child?.type !== 'JSXCodeBlock') return [child];
2794
3064
  const body_nodes = get_jsx_code_block_body_nodes(child, transform_context);
2795
3065
  return mark_native_pretransformed_jsx(
2796
- build_render_statements(body_nodes, true, transform_context),
3066
+ build_render_statements(
3067
+ body_nodes,
3068
+ true,
3069
+ transform_context,
3070
+ is_authored_native_fragment(child.render) ? child.render : null,
3071
+ ),
2797
3072
  );
2798
3073
  });
2799
3074
  }
@@ -2869,18 +3144,23 @@ function get_generated_component_metadata_list(node) {
2869
3144
  * @param {any[]} render_nodes
2870
3145
  * @param {any} source_node
2871
3146
  * @param {boolean} [map_render_node_locations]
3147
+ * @param {boolean} [type_only]
2872
3148
  * @returns {any}
2873
3149
  */
2874
3150
  function create_component_return_statement(
2875
3151
  render_nodes,
2876
3152
  source_node,
2877
3153
  map_render_node_locations = true,
3154
+ type_only = false,
2878
3155
  ) {
2879
3156
  const cloned = render_nodes.map((node) =>
2880
3157
  map_render_node_locations ? clone_expression_node(node) : clone_expression_node(node, false),
2881
3158
  );
2882
3159
 
2883
- return set_loc(b.return(build_return_expression(cloned) || create_null_literal()), source_node);
3160
+ return set_loc(
3161
+ b.return(build_return_expression(cloned, false, type_only) || create_null_literal()),
3162
+ source_node,
3163
+ );
2884
3164
  }
2885
3165
 
2886
3166
  /**
@@ -2923,7 +3203,11 @@ function get_loop_skip_if_consequent_body(node) {
2923
3203
  function create_component_loop_skip_if_statement(node, render_nodes, transform_context) {
2924
3204
  const consequent_body = /** @type {any[]} */ (get_loop_skip_if_consequent_body(node));
2925
3205
  const branch_statements = build_render_statements(consequent_body, true, transform_context);
2926
- prepend_render_nodes_to_return_statements(branch_statements, render_nodes);
3206
+ prepend_render_nodes_to_return_statements(
3207
+ branch_statements,
3208
+ render_nodes,
3209
+ transform_context.typeOnly,
3210
+ );
2927
3211
 
2928
3212
  const statement = set_loc(
2929
3213
  b.if(node.test, set_loc(b.block(branch_statements), node.consequent), null),
@@ -2939,15 +3223,16 @@ function create_component_loop_skip_if_statement(node, render_nodes, transform_c
2939
3223
  /**
2940
3224
  * @param {any[]} statements
2941
3225
  * @param {any[]} render_nodes
3226
+ * @param {boolean} [type_only]
2942
3227
  * @returns {void}
2943
3228
  */
2944
- function prepend_render_nodes_to_return_statements(statements, render_nodes) {
3229
+ function prepend_render_nodes_to_return_statements(statements, render_nodes, type_only = false) {
2945
3230
  if (render_nodes.length === 0) {
2946
3231
  return;
2947
3232
  }
2948
3233
 
2949
3234
  for (const statement of statements) {
2950
- prepend_render_nodes_to_return_statement(statement, render_nodes, false);
3235
+ prepend_render_nodes_to_return_statement(statement, render_nodes, false, type_only);
2951
3236
  }
2952
3237
  }
2953
3238
 
@@ -2955,9 +3240,15 @@ function prepend_render_nodes_to_return_statements(statements, render_nodes) {
2955
3240
  * @param {any} node
2956
3241
  * @param {any[]} render_nodes
2957
3242
  * @param {boolean} inside_nested_function
3243
+ * @param {boolean} [type_only]
2958
3244
  * @returns {void}
2959
3245
  */
2960
- function prepend_render_nodes_to_return_statement(node, render_nodes, inside_nested_function) {
3246
+ function prepend_render_nodes_to_return_statement(
3247
+ node,
3248
+ render_nodes,
3249
+ inside_nested_function,
3250
+ type_only = false,
3251
+ ) {
2961
3252
  if (!node || typeof node !== 'object') {
2962
3253
  return;
2963
3254
  }
@@ -2971,13 +3262,18 @@ function prepend_render_nodes_to_return_statement(node, render_nodes, inside_nes
2971
3262
  }
2972
3263
 
2973
3264
  if (!inside_nested_function && node.type === 'ReturnStatement') {
2974
- node.argument = combine_render_return_argument(render_nodes, node.argument);
3265
+ node.argument = combine_render_return_argument(render_nodes, node.argument, type_only);
2975
3266
  return;
2976
3267
  }
2977
3268
 
2978
3269
  if (Array.isArray(node)) {
2979
3270
  for (const child of node) {
2980
- prepend_render_nodes_to_return_statement(child, render_nodes, inside_nested_function);
3271
+ prepend_render_nodes_to_return_statement(
3272
+ child,
3273
+ render_nodes,
3274
+ inside_nested_function,
3275
+ type_only,
3276
+ );
2981
3277
  }
2982
3278
  return;
2983
3279
  }
@@ -2986,23 +3282,29 @@ function prepend_render_nodes_to_return_statement(node, render_nodes, inside_nes
2986
3282
  if (key === 'loc' || key === 'start' || key === 'end' || key === 'metadata') {
2987
3283
  continue;
2988
3284
  }
2989
- prepend_render_nodes_to_return_statement(node[key], render_nodes, inside_nested_function);
3285
+ prepend_render_nodes_to_return_statement(
3286
+ node[key],
3287
+ render_nodes,
3288
+ inside_nested_function,
3289
+ type_only,
3290
+ );
2990
3291
  }
2991
3292
  }
2992
3293
 
2993
3294
  /**
2994
3295
  * @param {any[]} render_nodes
2995
3296
  * @param {any} return_argument
3297
+ * @param {boolean} [type_only]
2996
3298
  * @returns {any}
2997
3299
  */
2998
- function combine_render_return_argument(render_nodes, return_argument) {
3300
+ function combine_render_return_argument(render_nodes, return_argument, type_only = false) {
2999
3301
  const combined = render_nodes.map((node) => clone_expression_node(node, false));
3000
3302
 
3001
3303
  if (return_argument != null && !is_null_literal(return_argument)) {
3002
3304
  combined.push(return_argument_to_render_node(return_argument));
3003
3305
  }
3004
3306
 
3005
- return build_return_expression(combined) || create_null_literal();
3307
+ return build_return_expression(combined, false, type_only) || create_null_literal();
3006
3308
  }
3007
3309
 
3008
3310
  /**
@@ -4011,7 +4313,9 @@ export function wrap_edge_whitespace(nodes) {
4011
4313
  }
4012
4314
  }
4013
4315
  if (value !== '') {
4014
- out.push(b.jsx_text(value, value));
4316
+ // keep the location as we need it for @ autocomplete
4317
+ // and perhaps other things in the future
4318
+ out.push(b.jsx_text(value, value, node));
4015
4319
  }
4016
4320
  if (trailing) {
4017
4321
  out.push(trailing);
@@ -4111,9 +4415,22 @@ function tsrx_node_to_jsx_expression(node, transform_context, in_jsx_child = fal
4111
4415
  /** @type {any} */
4112
4416
  let expression;
4113
4417
  if (children.length === 0) {
4114
- expression = in_jsx_child
4115
- ? set_loc(b.jsx_fragment([]), node.loc ? node : undefined)
4116
- : create_null_literal();
4418
+ // An empty fragment is a real value: keep it as `<></>` in BOTH child and
4419
+ // expression position. Lowering it to a bare `null` in expression position
4420
+ // (e.g. `let b = <></>`) drops the author's fragment and changes its type;
4421
+ // `<></>` is a valid value and keeps the to_ts/runtime view faithful.
4422
+ expression = set_loc(b.jsx_fragment([]), node.loc ? node : undefined);
4423
+ } else if (
4424
+ children.length === 1 &&
4425
+ (is_empty_jsx_fragment(children[0]) ||
4426
+ (children[0]?.type === 'JSXFragment' && is_authored_native_fragment(node)))
4427
+ ) {
4428
+ // `<><X></></>` — a fragment whose only child is a fragment. The generic
4429
+ // single-child collapse below would unwrap it to the bare inner fragment,
4430
+ // dropping the outer fragment the author wrote. Keep both levels. (`<><></></>`
4431
+ // is kept regardless; a non-empty inner is only kept for an authored outer, so
4432
+ // a generated wrapper still collapses.)
4433
+ expression = set_loc(b.jsx_fragment(children), node.loc ? node : undefined);
4117
4434
  } else {
4118
4435
  expression = return_value_body_to_expression(children, node, transform_context);
4119
4436
  }
@@ -4126,7 +4443,9 @@ function tsrx_node_to_jsx_expression(node, transform_context, in_jsx_child = fal
4126
4443
  const render_nodes = wrap_edge_whitespace(
4127
4444
  children.map((/** @type {any} */ child) => to_jsx_child(child, transform_context)),
4128
4445
  );
4129
- expression = build_return_expression(render_nodes, in_jsx_child) || create_null_literal();
4446
+ expression =
4447
+ build_return_expression(render_nodes, in_jsx_child, transform_context.typeOnly) ||
4448
+ create_null_literal();
4130
4449
  } finally {
4131
4450
  transform_context.inside_element_child = saved_inside_element_child;
4132
4451
  }
@@ -5319,7 +5638,12 @@ function build_switch_with_lift(switch_node, transform_context) {
5319
5638
  if (helper) {
5320
5639
  return set_loc(
5321
5640
  b.switch_case(original_case.test, [
5322
- create_component_return_statement([helper.component_element], original_case),
5641
+ create_component_return_statement(
5642
+ [helper.component_element],
5643
+ original_case,
5644
+ true,
5645
+ transform_context.typeOnly,
5646
+ ),
5323
5647
  ]),
5324
5648
  original_case,
5325
5649
  );
@@ -5340,7 +5664,14 @@ function build_switch_with_lift(switch_node, transform_context) {
5340
5664
 
5341
5665
  for (const child of own_body) {
5342
5666
  if (is_loop_skip_return_statement(child)) {
5343
- case_body.push(create_component_return_statement(render_nodes, child));
5667
+ case_body.push(
5668
+ create_component_return_statement(
5669
+ render_nodes,
5670
+ child,
5671
+ true,
5672
+ transform_context.typeOnly,
5673
+ ),
5674
+ );
5344
5675
  has_terminal = true;
5345
5676
  break;
5346
5677
  }
@@ -5360,7 +5691,14 @@ function build_switch_with_lift(switch_node, transform_context) {
5360
5691
 
5361
5692
  if (!has_terminal) {
5362
5693
  if (render_nodes.length > 0) {
5363
- case_body.push(create_component_return_statement(render_nodes, original_case));
5694
+ case_body.push(
5695
+ create_component_return_statement(
5696
+ render_nodes,
5697
+ original_case,
5698
+ true,
5699
+ transform_context.typeOnly,
5700
+ ),
5701
+ );
5364
5702
  } else if (case_body.length > 0) {
5365
5703
  case_body.push(create_null_return_statement());
5366
5704
  } else if (has_terminator) {
@@ -5913,25 +6251,29 @@ function value_has_unmappable_jsx_loc(value) {
5913
6251
  * @param {boolean} [in_jsx_child]
5914
6252
  * @returns {any}
5915
6253
  */
5916
- function build_return_expression(render_nodes, in_jsx_child = false) {
6254
+ export function build_return_expression(render_nodes, in_jsx_child = false, type_only = false) {
5917
6255
  if (render_nodes.length === 0) return null;
5918
6256
  if (render_nodes.length === 1) {
5919
6257
  const only = render_nodes[0];
5920
6258
  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
6259
  if (only.metadata?.tsrx_reactive_block === true) {
5925
6260
  return set_loc(b.jsx_fragment([only]), only.loc ? only : undefined);
5926
6261
  }
6262
+ if (only.expression?.type === 'JSXEmptyExpression') {
6263
+ return set_loc(b.jsx_fragment([]), only.loc ? only : undefined);
6264
+ }
5927
6265
  return only.expression;
5928
6266
  }
5929
6267
  if (only.type === 'JSXText') {
5930
- if (in_jsx_child) {
5931
- return set_loc(b.jsx_fragment([only]), only.loc ? only : undefined);
6268
+ // Keep a single text child faithful to the source (e.g. `<>@</>`) — never
6269
+ // promote it to a `{'text'}` string-literal expression, in either the
6270
+ // type-only editor view or runtime codegen. At runtime we additionally drop a
6271
+ // nullish/whitespace-only child so it renders nothing instead of emitting
6272
+ // empty output.
6273
+ if (!type_only && !in_jsx_child && (only.value ?? '').trim() === '') {
6274
+ return null;
5932
6275
  }
5933
- const value = (only.value ?? '').trim();
5934
- return b.literal(value, JSON.stringify(value), only);
6276
+ return set_loc(b.jsx_fragment([only]), only.loc ? only : undefined);
5935
6277
  }
5936
6278
  return only;
5937
6279
  }
@@ -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;
@@ -53,6 +53,7 @@ import {
53
53
  mapping_data,
54
54
  mapping_data_verify_only,
55
55
  mapping_data_verify_complete,
56
+ mapping_data_completion_only,
56
57
  build_line_offsets,
57
58
  get_mapping_from_node,
58
59
  } from '../source-map-utils.js';
@@ -806,7 +807,27 @@ export function convert_source_map_to_mappings(
806
807
  }
807
808
  return;
808
809
  } else if (node.type === 'JSXText') {
809
- // Text content, no tokens to collect
810
+ // A text node whose first non-whitespace char is `@` is an in-progress template
811
+ // directive (`@`, `@i`, `@if …`) the parser recovered as text; emit a completion-only
812
+ // mapping so the editor can still offer `@if`/`@for`/`@switch`/`@try` completions there.
813
+ //
814
+ // Use a token (resolved by matching generated CONTENT) rather than get_mapping_from_node.
815
+ // At a control-flow boundary the text node's source start maps to several generated
816
+ // positions — e.g. a preceding `@switch` value-IIFE's `return null; })()` tail AND the
817
+ // text itself — and get_mapping_from_node just takes the first, so its generated length
818
+ // spans the wrong region and the editor can't map a completion's edit back to source
819
+ // (it then drops the item). The token resolves to the position whose generated text
820
+ // matches the node's value, giving a well-formed same-length mapping. Ripple keeps text
821
+ // verbatim in to_ts, so `source` and `generated` are identical. Other text stays unmapped.
822
+ if (node.loc && typeof node.value === 'string' && node.value.trimStart().startsWith('@')) {
823
+ tokens.push({
824
+ source: node.value,
825
+ generated: node.value,
826
+ loc: node.loc,
827
+ metadata: {},
828
+ mappingData: mapping_data_completion_only,
829
+ });
830
+ }
810
831
  return;
811
832
  } else if (node.type === 'JSXCodeBlock') {
812
833
  for (const statement of node.body) {
@@ -1369,15 +1369,18 @@ export function jsx_spread_attribute(argument, loc_info) {
1369
1369
  /**
1370
1370
  * @param {string} value
1371
1371
  * @param {string} raw
1372
+ * @param {AST.NodeWithLocation} [loc_info]
1372
1373
  * @returns {ESTreeJSX.JSXText}
1373
1374
  */
1374
- export function jsx_text(value, raw) {
1375
- return {
1375
+ export function jsx_text(value, raw, loc_info) {
1376
+ const node = /** @type {ESTreeJSX.JSXText} */ ({
1376
1377
  type: 'JSXText',
1377
1378
  value,
1378
1379
  raw,
1379
1380
  metadata: { path: [] },
1380
- };
1381
+ });
1382
+
1383
+ return set_location(node, loc_info);
1381
1384
  }
1382
1385
 
1383
1386
  /**
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 {
@@ -1474,6 +1480,13 @@ export interface TransformServerState extends BaseState {
1474
1480
  dev?: boolean;
1475
1481
  return_flags?: Map<AST.ReturnStatement, { name: string; tracked: boolean }>;
1476
1482
  template_child?: boolean;
1483
+ /**
1484
+ * True while transforming the direct body of a control-flow branch
1485
+ * (`@if`/`@else`/`@for`/`@switch`/`@try`). A `<>…</>` in this position is
1486
+ * bracketed with hydration block markers so the client's fragment
1487
+ * `expression()` finds a matching boundary during hydration.
1488
+ */
1489
+ control_flow_branch_body?: boolean;
1477
1490
  skip_regular_blocks?: boolean;
1478
1491
  in_regular_block?: boolean;
1479
1492
  is_tsrx_element?: boolean;
@@ -1558,7 +1571,10 @@ export type TransformClientContext = Context<AST.Node, TransformClientState>;
1558
1571
  export type TransformServerContext = Context<AST.Node, TransformServerState>;
1559
1572
  export type AnalysisContext = Context<AST.Node, AnalysisState>;
1560
1573
  export type CommonContext = TransformClientContext | TransformServerContext | AnalysisContext;
1561
- export type VisitorClientContext = TransformClientContext & { root?: boolean };
1574
+ export type VisitorClientContext = TransformClientContext & {
1575
+ root?: boolean;
1576
+ value_position?: boolean;
1577
+ };
1562
1578
 
1563
1579
  /**
1564
1580
  * Delegated event result