@tsrx/core 0.1.25 → 0.1.27

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.25",
6
+ "version": "0.1.27",
7
7
  "type": "module",
8
8
  "repository": {
9
9
  "type": "git",
@@ -1,4 +1,5 @@
1
1
  /** @import * as AST from 'estree' */
2
+ /** @import * as ESTreeJSX from 'estree-jsx' */
2
3
  /** @import { Visitors, TopScopedClasses, StyleClasses } from '../../types/index' */
3
4
  /** @typedef {0 | 1} Direction */
4
5
 
@@ -66,8 +67,14 @@ function get_attribute_value(attribute) {
66
67
  * @param {AST.Node} node
67
68
  * @returns {boolean}
68
69
  */
69
- function is_runtime_dynamic_element(node) {
70
- return node?.metadata?.runtime_dynamic_element === true;
70
+ function is_dynamic_element(node) {
71
+ // `metadata.dynamicElement` marks lowered dynamic tags; `isDynamic` is the
72
+ // parser flag on a not-yet-lowered `<{expr}>` element. Both resolve their
73
+ // tag at runtime, so they can match any type selector.
74
+ return (
75
+ node?.metadata?.dynamicElement === true ||
76
+ /** @type {ESTreeJSX.JSXExpressionContainer} */ (node)?.isDynamic === true
77
+ );
71
78
  }
72
79
 
73
80
  /**
@@ -390,7 +397,7 @@ function get_descendant_elements(node, adjacent_only) {
390
397
  * @returns {boolean}
391
398
  */
392
399
  function can_render_dynamic_content(element, check_classes = false) {
393
- if (is_runtime_dynamic_element(element)) {
400
+ if (is_dynamic_element(element)) {
394
401
  return true;
395
402
  }
396
403
 
@@ -1014,7 +1021,7 @@ function relative_selector_might_apply_to_node(relative_selector, rule, element,
1014
1021
  }
1015
1022
 
1016
1023
  case 'TypeSelector': {
1017
- if (is_runtime_dynamic_element(element)) {
1024
+ if (is_dynamic_element(element)) {
1018
1025
  break;
1019
1026
  }
1020
1027
 
package/src/index.js CHANGED
@@ -173,6 +173,7 @@ export {
173
173
  get_style_element_stylesheet as getStyleElementStylesheet,
174
174
  } from './transform/style-ref.js';
175
175
  export {
176
+ add_extra_source_mappings_from_matching_expression,
176
177
  clone_expression_node,
177
178
  clone_identifier,
178
179
  clone_jsx_name,
package/src/plugin.js CHANGED
@@ -55,25 +55,23 @@ const CharCode = Object.freeze({
55
55
  closeBrace: 125,
56
56
  });
57
57
 
58
- /**
59
- * Keywords after which a `/` begins a regex literal rather than division, used
60
- * by the look-ahead scanners to track expression position in script content.
61
- */
62
- const REGEX_PRECEDING_KEYWORDS = new Set([
63
- 'return',
64
- 'typeof',
65
- 'instanceof',
66
- 'in',
67
- 'of',
68
- 'new',
69
- 'delete',
70
- 'void',
71
- 'do',
72
- 'else',
73
- 'yield',
74
- 'await',
75
- 'case',
76
- 'throw',
58
+ // Transparent wrappers to look through when validating a dynamic tag
59
+ // expression (`<{expr}>`), and syntax that disqualifies one outright.
60
+ const DYNAMIC_TAG_WRAPPER_TYPES = new Set([
61
+ 'TSAsExpression',
62
+ 'TSTypeAssertion',
63
+ 'TSNonNullExpression',
64
+ 'ParenthesizedExpression',
65
+ 'ChainExpression',
66
+ ]);
67
+ const DYNAMIC_TAG_DISALLOWED_TYPES = new Set([
68
+ 'SpreadElement',
69
+ 'ExperimentalSpreadProperty',
70
+ 'ObjectExpression',
71
+ 'ArrayExpression',
72
+ 'CallExpression',
73
+ 'NewExpression',
74
+ 'TaggedTemplateExpression',
77
75
  ]);
78
76
 
79
77
  /** @type {WeakMap<Record<string, boolean>, Map<string, number>>} */
@@ -577,6 +575,26 @@ export function TSRXPlugin(config) {
577
575
  }
578
576
  continue;
579
577
  }
578
+ if (this.#isTemplateBlockCommentStart(index)) {
579
+ const comment_start = index;
580
+ const comment_start_loc = acorn.getLineInfo(this.input, comment_start);
581
+ const close = this.input.indexOf('*/', index + 2);
582
+ const value_end = close === -1 ? this.input.length : close;
583
+ index = close === -1 ? this.input.length : close + 2;
584
+ if (this.options.onComment && comment_start >= token_end) {
585
+ const comment_end_loc = acorn.getLineInfo(this.input, index);
586
+ this.options.onComment(
587
+ true,
588
+ this.input.slice(comment_start + 2, value_end),
589
+ comment_start,
590
+ index,
591
+ new acorn.Position(comment_start_loc.line, comment_start_loc.column),
592
+ new acorn.Position(comment_end_loc.line, comment_end_loc.column),
593
+ /** @type {any} */ (null),
594
+ );
595
+ }
596
+ continue;
597
+ }
580
598
  const ch = this.input.charCodeAt(index);
581
599
  if (
582
600
  ch === CharCode.lessThan ||
@@ -953,6 +971,19 @@ export function TSRXPlugin(config) {
953
971
  );
954
972
  }
955
973
 
974
+ /**
975
+ * Unlike `//` (which is only a comment at line-start so inline text like
976
+ * `https://…` stays text), `/*` starts a comment anywhere in template
977
+ * text, matching `jsx_readToken`.
978
+ * @param {number} index
979
+ */
980
+ #isTemplateBlockCommentStart(index) {
981
+ return (
982
+ this.input.charCodeAt(index) === CharCode.slash &&
983
+ this.input.charCodeAt(index + 1) === CharCode.asterisk
984
+ );
985
+ }
986
+
956
987
  /**
957
988
  * @param {number} start
958
989
  */
@@ -965,7 +996,8 @@ export function TSRXPlugin(config) {
965
996
  ch === CharCode.openBrace ||
966
997
  ch === CharCode.closeBrace ||
967
998
  this.#isJSXControlFlowDirectiveAt(index) ||
968
- this.#isTemplateLineCommentStart(index)
999
+ this.#isTemplateLineCommentStart(index) ||
1000
+ this.#isTemplateBlockCommentStart(index)
969
1001
  ) {
970
1002
  break;
971
1003
  }
@@ -1163,6 +1195,7 @@ export function TSRXPlugin(config) {
1163
1195
  if (next === CharCode.slash) return false;
1164
1196
  const tagLike =
1165
1197
  next === CharCode.greaterThan ||
1198
+ next === CharCode.openBrace ||
1166
1199
  next === CharCode.at ||
1167
1200
  next === CharCode.dollar ||
1168
1201
  next === CharCode.underscore ||
@@ -1985,9 +2018,10 @@ export function TSRXPlugin(config) {
1985
2018
  );
1986
2019
  const closingEnd = closingStart + '</style>'.length;
1987
2020
  const closingEndInfo = acorn.getLineInfo(this.input, closingEnd);
1988
- const closingElement = /** @type {ESTreeJSX.JSXClosingElement & AST.NodeWithLocation} */ (
1989
- this.startNodeAt(closingStart, closingStartLoc)
1990
- );
2021
+ const closingElement =
2022
+ /** @type {ESTreeJSX.TSRXJSXClosingElement & AST.NodeWithLocation} */ (
2023
+ this.startNodeAt(closingStart, closingStartLoc)
2024
+ );
1991
2025
  closingElement.name = name;
1992
2026
  this.finishNodeAt(
1993
2027
  closingElement,
@@ -2581,10 +2615,70 @@ export function TSRXPlugin(config) {
2581
2615
  } else if (node.type === 'MemberExpression' || node.type === 'JSXMemberExpression') {
2582
2616
  // For components like <Foo.Bar>, return "Foo.Bar"
2583
2617
  return this.getElementName(node.object) + '.' + this.getElementName(node.property);
2618
+ } else if (this.#isDynamicJSXElementName(node)) {
2619
+ // Dynamic tags (`<{Tag}>`) name by expression source. The braces keep
2620
+ // them from colliding with static tag names ('style', 'head', ...) and
2621
+ // read as source syntax in error messages (`</{Tag}>`).
2622
+ const expression = /** @type {AST.Expression} */ (/** @type {any} */ (node).expression);
2623
+ return `{${this.input.slice(expression.start, expression.end).trim()}}`;
2584
2624
  }
2585
2625
  return null;
2586
2626
  }
2587
2627
 
2628
+ /**
2629
+ * @param {any} name
2630
+ * @returns {boolean}
2631
+ */
2632
+ #isDynamicJSXElementName(name) {
2633
+ return !!(name && name.type === 'JSXExpressionContainer' && name.isDynamic === true);
2634
+ }
2635
+
2636
+ /**
2637
+ * Dynamic tag expressions must be able to resolve to an element name:
2638
+ * an identifier, member access, static string, or a runtime expression
2639
+ * composed of those. Constructed values (calls, spreads, concatenation,
2640
+ * interpolation, object/array literals) and static non-string literals
2641
+ * can never be valid tag names.
2642
+ * @param {any} expression
2643
+ * @returns {boolean}
2644
+ */
2645
+ #isValidDynamicTagExpression(expression) {
2646
+ let node = expression;
2647
+ while (node && DYNAMIC_TAG_WRAPPER_TYPES.has(node.type)) {
2648
+ node = node.expression;
2649
+ }
2650
+ if (!node || node.type?.startsWith?.('JSX')) return false;
2651
+ if (node.type === 'Identifier') return node.name !== 'undefined';
2652
+ if (node.type === 'Literal') return typeof node.value === 'string';
2653
+ if (node.type === 'UnaryExpression' && node.operator === 'void') return false;
2654
+ return !this.#containsDisallowedDynamicTagSyntax(node);
2655
+ }
2656
+
2657
+ /**
2658
+ * @param {any} node
2659
+ * @param {Set<any>} [seen]
2660
+ * @returns {boolean}
2661
+ */
2662
+ #containsDisallowedDynamicTagSyntax(node, seen = new Set()) {
2663
+ if (!node || typeof node !== 'object' || seen.has(node)) return false;
2664
+ seen.add(node);
2665
+ if (Array.isArray(node)) {
2666
+ return node.some((child) => this.#containsDisallowedDynamicTagSyntax(child, seen));
2667
+ }
2668
+ if (
2669
+ DYNAMIC_TAG_DISALLOWED_TYPES.has(node.type) ||
2670
+ (node.type === 'TemplateLiteral' && node.expressions.length > 0) ||
2671
+ (node.type === 'BinaryExpression' && node.operator === '+')
2672
+ ) {
2673
+ return true;
2674
+ }
2675
+ for (const key of Object.keys(node)) {
2676
+ if (key === 'loc' || key === 'start' || key === 'end' || key === 'metadata') continue;
2677
+ if (this.#containsDisallowedDynamicTagSyntax(node[key], seen)) return true;
2678
+ }
2679
+ return false;
2680
+ }
2681
+
2588
2682
  /**
2589
2683
  * `<T,>(x: T) => x` and `<T>(x: T): T => x` should parse as generic
2590
2684
  * arrow functions, not JSX elements. acorn-typescript's `readToken`
@@ -2639,6 +2733,7 @@ export function TSRXPlugin(config) {
2639
2733
  const isTagLikeAfterLt =
2640
2734
  next === CharCode.slash ||
2641
2735
  next === CharCode.greaterThan ||
2736
+ next === CharCode.openBrace ||
2642
2737
  next === CharCode.at ||
2643
2738
  next === CharCode.dollar ||
2644
2739
  next === CharCode.underscore ||
@@ -2759,6 +2854,7 @@ export function TSRXPlugin(config) {
2759
2854
  !isWhitespaceAfterLt &&
2760
2855
  (nextChar === CharCode.slash ||
2761
2856
  nextChar === CharCode.greaterThan ||
2857
+ nextChar === CharCode.openBrace ||
2762
2858
  nextChar === CharCode.at ||
2763
2859
  nextChar === CharCode.dollar ||
2764
2860
  nextChar === CharCode.underscore ||
@@ -3444,6 +3540,18 @@ export function TSRXPlugin(config) {
3444
3540
  return this.finishNode(node, 'JSXIdentifier');
3445
3541
  }
3446
3542
 
3543
+ #parseJSXDynamicElementName() {
3544
+ const container = this.jsx_parseExpressionContainer();
3545
+ container.isDynamic = true;
3546
+ if (!this.#isValidDynamicTagExpression(container.expression)) {
3547
+ this.raise(
3548
+ /** @type {number} */ (container.expression?.start ?? container.start),
3549
+ 'Dynamic element names must be an identifier, member expression, static string, or runtime expression; calls, spreads, string concatenation, string interpolation, and static null, undefined, boolean, number, object, and array literals are not valid tag names.',
3550
+ );
3551
+ }
3552
+ return container;
3553
+ }
3554
+
3447
3555
  /**
3448
3556
  * @type {Parse.Parser['jsx_parseElementName']}
3449
3557
  */
@@ -3452,6 +3560,10 @@ export function TSRXPlugin(config) {
3452
3560
  return '';
3453
3561
  }
3454
3562
 
3563
+ if (this.type === tt.braceL) {
3564
+ return this.#parseJSXDynamicElementName();
3565
+ }
3566
+
3455
3567
  let node = this.jsx_parseNamespacedName();
3456
3568
 
3457
3569
  if (node.type === 'JSXNamespacedName') {
@@ -3983,7 +4095,10 @@ export function TSRXPlugin(config) {
3983
4095
  );
3984
4096
  node.attributes = [];
3985
4097
  const nodeName = this.jsx_parseElementName();
3986
- if (nodeName) node.name = nodeName;
4098
+ if (nodeName) node.name = /** @type {any} */ (nodeName);
4099
+ if (this.#isDynamicJSXElementName(nodeName)) {
4100
+ /** @type {any} */ (node).isDynamic = true;
4101
+ }
3987
4102
  if (this.match(tt.relational) || this.match(tt.bitShift)) {
3988
4103
  const typeArguments = /** @type {any} */ (this).tsTryParseAndCatch(() =>
3989
4104
  /** @type {any} */ (this).tsParseTypeArgumentsInExpression(),
@@ -4003,6 +4118,9 @@ export function TSRXPlugin(config) {
4003
4118
  this.getElementName(nodeName) === 'style' ? 'JSXStyleElement' : 'JSXElement';
4004
4119
  /** @type {any} */ (opening_template_node).openingElement = node;
4005
4120
  /** @type {any} */ (opening_template_node).closingElement = null;
4121
+ if (this.#isDynamicJSXElementName(nodeName)) {
4122
+ /** @type {any} */ (opening_template_node).isDynamic = true;
4123
+ }
4006
4124
  } else {
4007
4125
  /** @type {any} */ (opening_template_node).type = 'JSXFragment';
4008
4126
  /** @type {any} */ (opening_template_node).openingFragment =
@@ -4076,6 +4194,7 @@ export function TSRXPlugin(config) {
4076
4194
  this.#openingNativeTemplateNode = previous_opening_native_template_node;
4077
4195
  }
4078
4196
  const tag_name = open.name ? this.getElementName(open.name) : null;
4197
+ const is_dynamic = this.#isDynamicJSXElementName(open.name);
4079
4198
  const is_style = tag_name === 'style';
4080
4199
  const inside_head = this.#path.findLast((n) => this.#isNativeElementNamed(n, 'head'));
4081
4200
 
@@ -4103,12 +4222,17 @@ export function TSRXPlugin(config) {
4103
4222
  } else {
4104
4223
  if (is_style) {
4105
4224
  /** @type {AST.JSXStyleElement} */ (node).type = 'JSXStyleElement';
4106
- /** @type {AST.JSXStyleElement} */ (node).openingElement = open;
4107
- /** @type {AST.JSXStyleElement} */ (node).closingElement = null;
4225
+ /** @type {AST.JSXStyleElement} */ (node).openingElement =
4226
+ /** @type {AST.JSXStyleElement['openingElement']} */ (open);
4227
+ /** @type {AST.JSXStyleElement} */ (node).closingElement =
4228
+ /** @type {AST.JSXStyleElement['closingElement']} */ (null);
4108
4229
  } else {
4109
4230
  /** @type {ESTreeJSX.JSXElement} */ (node).type = 'JSXElement';
4110
4231
  /** @type {ESTreeJSX.JSXElement} */ (node).openingElement = open;
4111
4232
  /** @type {ESTreeJSX.JSXElement} */ (node).closingElement = null;
4233
+ if (is_dynamic) {
4234
+ /** @type {any} */ (node).isDynamic = true;
4235
+ }
4112
4236
  }
4113
4237
  }
4114
4238
 
@@ -4315,6 +4439,9 @@ export function TSRXPlugin(config) {
4315
4439
  } finally {
4316
4440
  this.#closingNativeTemplateNode = false;
4317
4441
  }
4442
+ if (this.#isDynamicJSXElementName(closingElement.name)) {
4443
+ /** @type {any} */ (closingElement).isDynamic = true;
4444
+ }
4318
4445
  this.exprAllowed = false;
4319
4446
 
4320
4447
  // Validate that the closing tag matches the opening tag
@@ -89,6 +89,33 @@ export function clone_jsx_name(name, source_node = name) {
89
89
  return name;
90
90
  }
91
91
 
92
+ /**
93
+ * Record extra source positions on a generated expression so one generated
94
+ * range can map back to several source ranges. Used for dynamic tags, where
95
+ * the generated `is={expr}` value stands in for both `<{expr}` and `</{expr}>`;
96
+ * segments.js turns each recorded node into an additional mapping token.
97
+ * @param {any} generated
98
+ * @param {any} source
99
+ * @returns {void}
100
+ */
101
+ export function add_extra_source_mappings_from_matching_expression(generated, source) {
102
+ if (!generated || !source || generated.type !== source.type) return;
103
+
104
+ if (generated.type === 'Identifier' || generated.type === 'PrivateIdentifier') {
105
+ if (!source.loc) return;
106
+ generated.metadata ??= { path: [] };
107
+ generated.metadata.extra_source_mappings ??= [];
108
+ generated.metadata.extra_source_mappings.push(source);
109
+ return;
110
+ }
111
+
112
+ for (const key of ['expression', 'object', 'property']) {
113
+ if (generated[key] && source[key]) {
114
+ add_extra_source_mappings_from_matching_expression(generated[key], source[key]);
115
+ }
116
+ }
117
+ }
118
+
92
119
  /**
93
120
  * @returns {AST.Literal}
94
121
  */
@@ -52,7 +52,14 @@ export function tsx_node_to_jsx_expression(node, in_jsx_child = false) {
52
52
  (/** @type {any} */ child) => child.type !== 'JSXText' || child.value.trim() !== '',
53
53
  );
54
54
 
55
- if (children.length === 1 && children[0].type !== 'JSXText') {
55
+ if (
56
+ children.length === 1 &&
57
+ children[0].type !== 'JSXText' &&
58
+ // Reactive-block containers (dynamic tags) must stay expression
59
+ // children so the host JSX compiler wraps them in a render block;
60
+ // unwrapping to a bare call would evaluate them once.
61
+ children[0].metadata?.tsrx_reactive_block !== true
62
+ ) {
56
63
  const only = children[0];
57
64
  if (only.type === 'JSXExpressionContainer' && !in_jsx_child) {
58
65
  return only.expression;
@@ -7,7 +7,6 @@ import { print } from 'esrap';
7
7
  import { error } from '../../errors.js';
8
8
  import { analyze_css } from '../../analyze/css-analyze.js';
9
9
  import { prune_css } from '../../analyze/prune.js';
10
- import { create_scopes, ScopeRoot } from '../../scope.js';
11
10
  import {
12
11
  in_jsx_child_context,
13
12
  set_node_path_metadata,
@@ -15,6 +14,7 @@ import {
15
14
  tsx_with_ts_locations,
16
15
  } from './helpers.js';
17
16
  import {
17
+ add_extra_source_mappings_from_matching_expression,
18
18
  clone_expression_node,
19
19
  clone_identifier,
20
20
  clone_jsx_name,
@@ -69,6 +69,8 @@ const TSRX_IF_RETURN_ERROR =
69
69
  const TSRX_IF_BREAK_ERROR = 'Break statements are not allowed inside TSRX template @if blocks.';
70
70
  const TSRX_IF_CONTINUE_ERROR =
71
71
  'Continue statements are not allowed inside TSRX template @if blocks. Filter before rendering or use conditional output instead.';
72
+ const DYNAMIC_IMPORT_LOCAL = 'TsrxDynamic';
73
+ const DYNAMIC_FACTORY_LOCAL = '_tsrx_dynamic';
72
74
 
73
75
  /**
74
76
  * @param {AST.Node} node
@@ -216,15 +218,32 @@ function wrap_in_native_tsrx_fragment(node) {
216
218
  * (`const x = @switch (…) { … }`, `x = @switch (…) { … }`), or a call/`new`
217
219
  * argument (`render(@if (…) { … })`) — in a native TSRX fragment.
218
220
  * @param {any} node
221
+ * @param {TransformContext | null} lower_dynamic_context
219
222
  * @param {Set<any>} [seen]
220
223
  * @returns {void}
221
224
  */
222
- function wrap_control_flow_expression_values(node, seen = new Set()) {
225
+ function wrap_control_flow_expression_values(node, lower_dynamic_context, seen = new Set()) {
223
226
  if (!node || typeof node !== 'object' || seen.has(node)) return;
224
227
  seen.add(node);
225
228
 
229
+ // Dynamic tags on factory platforms must lower before control-flow
230
+ // conversion and static hoisting run. Production output needs the scoped
231
+ // `const TsrxDynamic_N = ...` binding declared in the scope that owns the
232
+ // tag expression (e.g. a `@for` loop variable); type-only output needs
233
+ // `<TsrxDynamic is={expr}>` in place before a reference-free tree (e.g.
234
+ // `<{'div'}>`) is hoisted to a module-level static const while still
235
+ // carrying the raw dynamic tag. Alias lowerings return a replacement
236
+ // fragment, which is swapped into the child's position here.
237
+ const lower_child = (/** @type {any} */ child) => {
238
+ if (!lower_dynamic_context || child?.type !== 'JSXElement') return child;
239
+ return lower_dynamic_jsx_element(child, lower_dynamic_context) ?? child;
240
+ };
241
+
226
242
  if (Array.isArray(node)) {
227
- for (const item of node) wrap_control_flow_expression_values(item, seen);
243
+ for (let i = 0; i < node.length; i++) {
244
+ node[i] = lower_child(node[i]);
245
+ wrap_control_flow_expression_values(node[i], lower_dynamic_context, seen);
246
+ }
228
247
  return;
229
248
  }
230
249
 
@@ -256,7 +275,8 @@ function wrap_control_flow_expression_values(node, seen = new Set()) {
256
275
 
257
276
  for (const key of Object.keys(node)) {
258
277
  if (key === 'loc' || key === 'start' || key === 'end' || key === 'metadata') continue;
259
- wrap_control_flow_expression_values(node[key], seen);
278
+ node[key] = lower_child(node[key]);
279
+ wrap_control_flow_expression_values(node[key], lower_dynamic_context, seen);
260
280
  }
261
281
  }
262
282
 
@@ -300,6 +320,8 @@ export function createJsxTransform(platform) {
300
320
  needs_normalize_spread_props: false,
301
321
  needs_normalize_spread_props_for_ref_attr: false,
302
322
  needs_fragment: false,
323
+ needs_dynamic_element: false,
324
+ needs_dynamic_factory: false,
303
325
  needs_for_of_iterable: false,
304
326
  needs_iteration_value_type: false,
305
327
  stylesheets,
@@ -310,7 +332,6 @@ export function createJsxTransform(platform) {
310
332
  hook_helpers_enabled: false,
311
333
  available_bindings: new Map(),
312
334
  lazy_next_id: 0,
313
- runtime_dynamic_scopes: null,
314
335
  filename: filename ?? null,
315
336
  source,
316
337
  collect,
@@ -323,10 +344,9 @@ export function createJsxTransform(platform) {
323
344
  };
324
345
 
325
346
  expand_child_code_blocks(/** @type {any} */ (ast));
326
- wrap_control_flow_expression_values(/** @type {any} */ (ast));
327
- transform_context.runtime_dynamic_scopes = create_runtime_dynamic_scopes(
347
+ wrap_control_flow_expression_values(
328
348
  /** @type {any} */ (ast),
329
- transform_context,
349
+ platform.imports.dynamicFactory ? transform_context : null,
330
350
  );
331
351
 
332
352
  if (!transform_context.typeOnly) {
@@ -363,7 +383,15 @@ export function createJsxTransform(platform) {
363
383
  return /** @type {any} */ (wrap_jsx_setup_declarations(expression, in_jsx_child));
364
384
  },
365
385
 
366
- JSXElement(node, { next, path, state }) {
386
+ JSXElement(node, { next, path, state, visit }) {
387
+ const lowered = lower_dynamic_jsx_element(node, state);
388
+ if (lowered) {
389
+ // Alias lowerings replace the element with a fragment; factory
390
+ // platforms normally lower in the pre-walk pass, so this only
391
+ // covers elements introduced after it.
392
+ return /** @type {any} */ (visit(lowered, state));
393
+ }
394
+
367
395
  if (!node.metadata?.native_tsrx) {
368
396
  return next() ?? node;
369
397
  }
@@ -462,6 +490,7 @@ export function createJsxTransform(platform) {
462
490
  transformed_program.body.unshift(...type_only_style_anchors);
463
491
  }
464
492
  const expanded = expand_component_helpers(transformed_program);
493
+ inject_dynamic_import(expanded, transform_context);
465
494
  if (platform.hooks?.injectImports) {
466
495
  platform.hooks.injectImports(expanded, transform_context, suspense_source);
467
496
  } else {
@@ -497,6 +526,179 @@ export function createJsxTransform(platform) {
497
526
  return transform;
498
527
  }
499
528
 
529
+ /**
530
+ * Lower a single parser-native dynamic tag (`<{expr}>`) into the target runtime
531
+ * `<Dynamic is={expr}>` helper shape while the existing JSXElement walker is
532
+ * already visiting it. The dynamic name container travels by reference through
533
+ * element rebuilds, so checking it covers rebuilt elements too; once lowered,
534
+ * the name is a plain `JSXIdentifier` and the element is skipped on re-visits.
535
+ *
536
+ * The parsed element is never mutated: every lowering builds a fresh
537
+ * replacement node (an element, or a fragment for the alias lowering) that
538
+ * the caller must put in the original element's position.
539
+ *
540
+ * @param {any} node
541
+ * @param {TransformContext} transform_context
542
+ * @returns {ESTreeJSX.JSXElement | ESTreeJSX.JSXFragment | undefined}
543
+ */
544
+ function lower_dynamic_jsx_element(node, transform_context) {
545
+ const dynamic_name = node.openingElement?.name;
546
+ if (dynamic_name?.type !== 'JSXExpressionContainer' || dynamic_name.isDynamic !== true) return;
547
+
548
+ // Type-only output always uses the `<TsrxDynamic is={expr}>` component
549
+ // shape; production output prefers the platform's runtime factory when one
550
+ // is configured (e.g. Solid's `dynamic`).
551
+ const factory = transform_context.typeOnly
552
+ ? undefined
553
+ : transform_context.platform.imports.dynamicFactory;
554
+ if (!factory && !transform_context.platform.imports.dynamic) return;
555
+
556
+ const dynamic_expression = dynamic_name.expression;
557
+ if (!dynamic_expression) return;
558
+ const generated_expression = clone_expression_node(dynamic_expression);
559
+ if (node.closingElement?.name?.expression) {
560
+ // One generated expression stands in for both tags; record the closing
561
+ // tag's positions so editor features keep working on `</{expr}>`.
562
+ add_extra_source_mappings_from_matching_expression(
563
+ generated_expression,
564
+ clone_expression_node(node.closingElement.name.expression),
565
+ );
566
+ }
567
+
568
+ /**
569
+ * Rebuild the element as an ordinary component reference named `name_id`,
570
+ * carrying the original attributes (after any `extra_attributes`) and
571
+ * children over by reference.
572
+ *
573
+ * @param {ESTreeJSX.JSXIdentifier} name_id
574
+ * @param {ESTreeJSX.JSXAttribute[]} [extra_attributes]
575
+ * @returns {ESTreeJSX.JSXElement}
576
+ */
577
+ const rebuild_element = (name_id, extra_attributes = []) => {
578
+ const element = b.jsx_element_fresh(
579
+ b.jsx_opening_element(
580
+ name_id,
581
+ [...extra_attributes, ...(node.openingElement.attributes || [])],
582
+ node.openingElement.selfClosing,
583
+ node.openingElement.typeArguments,
584
+ node.openingElement,
585
+ ),
586
+ node.closingElement
587
+ ? b.jsx_closing_element(b.jsx_id(name_id.name), node.closingElement)
588
+ : null,
589
+ node.children,
590
+ node,
591
+ );
592
+ element.metadata = { ...(node.metadata || {}), path: [] };
593
+ return element;
594
+ };
595
+
596
+ /**
597
+ * Scoped-CSS passes treat lowered dynamic tags like the imported `Dynamic`
598
+ * helper: type selectors survive pruning and the scope hash lands on the
599
+ * element's class.
600
+ *
601
+ * @param {ESTreeJSX.JSXElement} element
602
+ * @returns {ESTreeJSX.JSXElement}
603
+ */
604
+ const mark_dynamic_element = (element) => {
605
+ element.metadata.dynamicElement = true;
606
+ return element;
607
+ };
608
+
609
+ if (factory) {
610
+ // Bind the tag expression to a scoped component const and reference it
611
+ // like an ordinary component.
612
+ transform_context.local_statement_component_index += 1;
613
+ const local = `${DYNAMIC_IMPORT_LOCAL}_${transform_context.local_statement_component_index}`;
614
+ const local_id = b.jsx_id(local);
615
+ transform_context.needs_dynamic_factory = true;
616
+
617
+ if (factory.renderBlock) {
618
+ // Import-free alias inside a reactive render block (Vue): the const
619
+ // is a plain snapshot and Vapor never re-runs setup, so the whole
620
+ // element is rebuilt in a native fragment whose expression-container
621
+ // child holds
622
+ // `(() => { const TsrxDynamic_1 = expr; return <TsrxDynamic_1 ...>; })()`
623
+ // — vue-jsx-vapor compiles expression children into `createNodes(...)`
624
+ // render blocks, which re-run the IIFE when the tag expression
625
+ // changes. The container is marked so downstream lone-child
626
+ // collapsing keeps it in expression-child position instead of
627
+ // unwrapping to a bare call.
628
+ const element = mark_dynamic_element(rebuild_element(local_id));
629
+ const wrapper = b.arrow(
630
+ [],
631
+ b.block([b.const(b.id(local), generated_expression), b.return(element)], node),
632
+ );
633
+ // Lets scoped-CSS collection descend into this generated closure;
634
+ // user function boundaries are otherwise skipped.
635
+ wrapper.metadata = /** @type {any} */ ({
636
+ ...(wrapper.metadata || { path: [] }),
637
+ tsrx_dynamic_wrapper: true,
638
+ });
639
+ const container = to_jsx_expression_container(b.call(wrapper), element);
640
+ container.metadata = /** @type {any} */ ({
641
+ ...(container.metadata || { path: [] }),
642
+ tsrx_reactive_block: true,
643
+ });
644
+
645
+ return set_loc(wrap_in_native_tsrx_fragment(container), node);
646
+ }
647
+
648
+ // Statement placement: `const TsrxDynamic_1 = ...;` next to the
649
+ // template. With a factory, the thunk keeps the tag reactive (Solid:
650
+ // `_tsrx_dynamic(() => expr)`); without one, the plain alias is
651
+ // re-evaluated by the host's render cycle (React/Preact re-run the
652
+ // component body). The declaration rides on the name node's metadata:
653
+ // element rebuilds clone names with a shared metadata reference, so
654
+ // setup extraction still finds it afterwards.
655
+ add_jsx_setup_declaration(
656
+ local_id,
657
+ b.const(
658
+ b.id(local),
659
+ factory.name
660
+ ? b.call(b.id(DYNAMIC_FACTORY_LOCAL), b.arrow([], generated_expression))
661
+ : generated_expression,
662
+ ),
663
+ );
664
+ return mark_dynamic_element(rebuild_element(local_id));
665
+ }
666
+
667
+ transform_context.needs_dynamic_element = true;
668
+ return mark_dynamic_element(
669
+ rebuild_element(b.jsx_id(DYNAMIC_IMPORT_LOCAL), [
670
+ b.jsx_attribute(
671
+ b.jsx_id('is'),
672
+ b.jsx_expression_container(generated_expression, dynamic_name),
673
+ false,
674
+ dynamic_name,
675
+ ),
676
+ ]),
677
+ );
678
+ }
679
+
680
+ /**
681
+ * @param {AST.Program} program
682
+ * @param {TransformContext} transform_context
683
+ * @returns {void}
684
+ */
685
+ function inject_dynamic_import(program, transform_context) {
686
+ const factory = transform_context.platform.imports.dynamicFactory;
687
+ if (transform_context.needs_dynamic_factory && factory?.name && factory.source) {
688
+ program.body.unshift(
689
+ b.import_declaration(
690
+ [b.import_specifier(factory.name, DYNAMIC_FACTORY_LOCAL)],
691
+ factory.source,
692
+ ),
693
+ );
694
+ }
695
+ const source = transform_context.platform.imports.dynamic;
696
+ if (!transform_context.needs_dynamic_element || !source) return;
697
+ program.body.unshift(
698
+ b.import_declaration([b.import_specifier('Dynamic', DYNAMIC_IMPORT_LOCAL)], source),
699
+ );
700
+ }
701
+
500
702
  /**
501
703
  * Attach selector-location metadata used by editor definitions/hover before
502
704
  * the shared scoping pass mutates class attributes with the component hash.
@@ -563,15 +765,18 @@ function collect_css_prunable_elements(value, elements = [], transform_context =
563
765
  }
564
766
 
565
767
  if (
566
- value.type === 'FunctionDeclaration' ||
567
- value.type === 'FunctionExpression' ||
568
- value.type === 'ArrowFunctionExpression'
768
+ (value.type === 'FunctionDeclaration' ||
769
+ value.type === 'FunctionExpression' ||
770
+ value.type === 'ArrowFunctionExpression') &&
771
+ // Generated dynamic-tag wrappers are render-block closures, not user
772
+ // component boundaries — the element inside still belongs to this
773
+ // component's scoped CSS.
774
+ value.metadata?.tsrx_dynamic_wrapper !== true
569
775
  ) {
570
776
  return elements;
571
777
  }
572
778
 
573
779
  if (value.type === 'JSXElement' && value.metadata?.native_tsrx) {
574
- mark_runtime_dynamic_element(value, transform_context);
575
780
  if (!is_style_element(value)) {
576
781
  elements.push(value);
577
782
  }
@@ -587,241 +792,6 @@ function collect_css_prunable_elements(value, elements = [], transform_context =
587
792
  return elements;
588
793
  }
589
794
 
590
- /**
591
- * @param {AST.Program} ast
592
- * @param {TransformContext} transform_context
593
- * @returns {Map<any, any> | null}
594
- */
595
- function create_runtime_dynamic_scopes(ast, transform_context) {
596
- const dynamic_source = transform_context.platform.imports.dynamic;
597
- if (!dynamic_source) {
598
- return null;
599
- }
600
- if (!has_runtime_dynamic_import(ast, dynamic_source)) {
601
- return null;
602
- }
603
-
604
- const { scopes } = create_scopes(ast, new ScopeRoot(), null, {
605
- collect: true,
606
- errors: [],
607
- filename: transform_context.filename ?? '',
608
- comments: transform_context.comments,
609
- });
610
-
611
- return scopes;
612
- }
613
-
614
- /**
615
- * @param {any} node
616
- * @param {TransformContext | null} transform_context
617
- * @returns {void}
618
- */
619
- function mark_runtime_dynamic_element(node, transform_context) {
620
- const dynamic_source = transform_context?.platform.imports.dynamic;
621
- const scopes = transform_context?.runtime_dynamic_scopes;
622
- if (
623
- !dynamic_source ||
624
- !scopes ||
625
- node.metadata?.runtime_dynamic_element === true ||
626
- !has_jsx_attribute(node, 'is') ||
627
- !is_runtime_dynamic_jsx_name(node.openingElement?.name, scopes.get(node), dynamic_source)
628
- ) {
629
- return;
630
- }
631
-
632
- node.metadata.runtime_dynamic_element = true;
633
- }
634
-
635
- /**
636
- * @param {AST.Program} ast
637
- * @param {string} dynamic_source
638
- * @returns {boolean}
639
- */
640
- function has_runtime_dynamic_import(ast, dynamic_source) {
641
- return ast.body.some(
642
- (/** @type {any} */ node) =>
643
- node.type === 'ImportDeclaration' &&
644
- node.importKind !== 'type' &&
645
- node.source?.type === 'Literal' &&
646
- node.source.value === dynamic_source &&
647
- node.specifiers.some(
648
- (/** @type {any} */ specifier) =>
649
- is_runtime_dynamic_import_specifier(specifier, 'component') ||
650
- is_runtime_dynamic_import_specifier(specifier, 'namespace'),
651
- ),
652
- );
653
- }
654
-
655
- /**
656
- * @param {any} node
657
- * @param {string} name
658
- * @returns {boolean}
659
- */
660
- function has_jsx_attribute(node, name) {
661
- return (node.openingElement?.attributes ?? []).some(
662
- (/** @type {any} */ attr) =>
663
- attr.type === 'JSXAttribute' &&
664
- attr.name?.type === 'JSXIdentifier' &&
665
- attr.name.name === name,
666
- );
667
- }
668
-
669
- /**
670
- * @param {any} name
671
- * @param {any} scope
672
- * @param {string} dynamic_source
673
- * @returns {boolean}
674
- */
675
- function is_runtime_dynamic_jsx_name(name, scope, dynamic_source) {
676
- if (!scope || !name) {
677
- return false;
678
- }
679
-
680
- if (name.type === 'JSXIdentifier') {
681
- return is_runtime_dynamic_binding(scope.get(name.name), dynamic_source, 'component', new Set());
682
- }
683
-
684
- if (
685
- name.type === 'JSXMemberExpression' &&
686
- name.object?.type === 'JSXIdentifier' &&
687
- name.property?.type === 'JSXIdentifier' &&
688
- name.property.name === 'Dynamic'
689
- ) {
690
- return is_runtime_dynamic_binding(
691
- scope.get(name.object.name),
692
- dynamic_source,
693
- 'namespace',
694
- new Set(),
695
- );
696
- }
697
-
698
- return false;
699
- }
700
-
701
- /**
702
- * @param {any} binding
703
- * @param {string} dynamic_source
704
- * @param {'component' | 'namespace'} kind
705
- * @param {Set<any>} seen
706
- * @returns {boolean}
707
- */
708
- function is_runtime_dynamic_binding(binding, dynamic_source, kind, seen) {
709
- if (!binding || seen.has(binding)) {
710
- return false;
711
- }
712
- seen.add(binding);
713
-
714
- if (is_runtime_dynamic_import_binding(binding, dynamic_source, kind)) {
715
- return true;
716
- }
717
-
718
- if (binding.reassigned) {
719
- return false;
720
- }
721
-
722
- const initial = unwrap_reference_expression(binding.initial);
723
- if (!initial) {
724
- return false;
725
- }
726
-
727
- if (initial.type === 'Identifier') {
728
- return is_runtime_dynamic_binding(binding.scope.get(initial.name), dynamic_source, kind, seen);
729
- }
730
-
731
- if (
732
- kind === 'component' &&
733
- initial.type === 'MemberExpression' &&
734
- !initial.computed &&
735
- initial.object?.type === 'Identifier' &&
736
- initial.property?.type === 'Identifier' &&
737
- initial.property.name === 'Dynamic'
738
- ) {
739
- return is_runtime_dynamic_binding(
740
- binding.scope.get(initial.object.name),
741
- dynamic_source,
742
- 'namespace',
743
- new Set(),
744
- );
745
- }
746
-
747
- return false;
748
- }
749
-
750
- /**
751
- * @param {any} binding
752
- * @param {string} dynamic_source
753
- * @param {'component' | 'namespace'} kind
754
- * @returns {boolean}
755
- */
756
- function is_runtime_dynamic_import_binding(binding, dynamic_source, kind) {
757
- const declaration = binding?.initial;
758
- if (
759
- binding?.declaration_kind !== 'import' ||
760
- declaration?.type !== 'ImportDeclaration' ||
761
- declaration.importKind === 'type' ||
762
- declaration.source?.type !== 'Literal' ||
763
- declaration.source.value !== dynamic_source
764
- ) {
765
- return false;
766
- }
767
-
768
- return declaration.specifiers.some(
769
- (/** @type {any} */ specifier) =>
770
- specifier.local?.name === binding.node?.name &&
771
- is_runtime_dynamic_import_specifier(specifier, kind),
772
- );
773
- }
774
-
775
- /**
776
- * @param {any} specifier
777
- * @param {'component' | 'namespace'} kind
778
- * @returns {boolean}
779
- */
780
- function is_runtime_dynamic_import_specifier(specifier, kind) {
781
- if (kind === 'namespace') {
782
- return specifier.type === 'ImportNamespaceSpecifier';
783
- }
784
- return (
785
- specifier.type === 'ImportSpecifier' &&
786
- specifier.importKind !== 'type' &&
787
- get_imported_name(specifier) === 'Dynamic'
788
- );
789
- }
790
-
791
- /**
792
- * @param {any} specifier
793
- * @returns {string | null}
794
- */
795
- function get_imported_name(specifier) {
796
- const imported = specifier.imported;
797
- if (imported?.type === 'Identifier') {
798
- return imported.name;
799
- }
800
- if (imported?.type === 'Literal') {
801
- return String(imported.value);
802
- }
803
- return null;
804
- }
805
-
806
- /**
807
- * @param {any} expression
808
- * @returns {any}
809
- */
810
- function unwrap_reference_expression(expression) {
811
- let node = expression;
812
- while (
813
- node &&
814
- (node.type === 'TSAsExpression' ||
815
- node.type === 'TSTypeAssertion' ||
816
- node.type === 'TSNonNullExpression' ||
817
- node.type === 'ParenthesizedExpression' ||
818
- node.type === 'ChainExpression')
819
- ) {
820
- node = node.expression;
821
- }
822
- return node;
823
- }
824
-
825
795
  /**
826
796
  * @param {any[]} body_nodes
827
797
  * @param {TransformContext} transform_context
@@ -3262,12 +3232,23 @@ function to_jsx_element(
3262
3232
  ? null
3263
3233
  : set_loc(
3264
3234
  b.jsx_closing_element(
3265
- clone_jsx_name(name, node.closingElement?.name || node.closingElement || node),
3235
+ // Clone from the actual closing name when there is one: a dynamic
3236
+ // tag's closing expression (`</{Tag}>`) has its own source positions,
3237
+ // which editor mappings need.
3238
+ clone_jsx_name(
3239
+ node.closingElement?.name ?? name,
3240
+ node.closingElement?.name || node.closingElement || node,
3241
+ ),
3266
3242
  ),
3267
3243
  node.closingElement || node,
3268
3244
  );
3269
3245
 
3270
3246
  const element = set_loc(b.jsx_element_fresh(openingElement, closingElement, children), node);
3247
+ if (node.metadata?.dynamicElement === true) {
3248
+ // Keep lowered dynamic tags recognizable to scoped-CSS passes and the
3249
+ // static-hoist veto after the rebuild.
3250
+ element.metadata.dynamicElement = true;
3251
+ }
3271
3252
  if (transform_context.typeOnly && is_style_element(node)) {
3272
3253
  disable_style_anchor_verification(element);
3273
3254
  }
@@ -5784,6 +5765,12 @@ function build_return_expression(render_nodes) {
5784
5765
  if (render_nodes.length === 1) {
5785
5766
  const only = render_nodes[0];
5786
5767
  if (only.type === 'JSXExpressionContainer') {
5768
+ // Reactive-block containers (dynamic tags) must stay expression
5769
+ // children so the host JSX compiler wraps them in a render block;
5770
+ // returning the bare call would evaluate them once.
5771
+ if (only.metadata?.tsrx_reactive_block === true) {
5772
+ return set_loc(b.jsx_fragment([only]), only.loc ? only : undefined);
5773
+ }
5787
5774
  return only.expression;
5788
5775
  }
5789
5776
  if (only.type === 'JSXText') {
@@ -112,6 +112,12 @@ export function is_hoist_safe_jsx_node(node) {
112
112
  return node.children.every(is_hoist_safe_jsx_child);
113
113
  }
114
114
 
115
+ // Lowered dynamic tags reference a component-scoped const and resolve at
116
+ // runtime — never static, never hoistable.
117
+ if (/** @type {any} */ (node).metadata?.dynamicElement === true) {
118
+ return false;
119
+ }
120
+
115
121
  return (
116
122
  node.openingElement.attributes.every(is_hoist_safe_jsx_attribute) &&
117
123
  node.children.every(is_hoist_safe_jsx_child)
@@ -43,6 +43,10 @@ export function is_interleaved_body(body_nodes, is_jsx_child) {
43
43
  */
44
44
  export function is_capturable_jsx_child(jsx) {
45
45
  if (!jsx) return false;
46
+ // Reactive-block containers (dynamic tags) must stay expression children
47
+ // so the host JSX compiler wraps them in a render block; capturing them
48
+ // into a const would evaluate them once.
49
+ if (jsx.metadata?.tsrx_reactive_block === true) return false;
46
50
  const t = jsx.type;
47
51
  return t === 'JSXElement' || t === 'JSXFragment' || t === 'JSXExpressionContainer';
48
52
  }
@@ -75,15 +75,19 @@ export function annotate_with_hash(
75
75
  ) {
76
76
  if (!node || typeof node !== 'object') return node;
77
77
  if (
78
- node.type === 'FunctionDeclaration' ||
79
- node.type === 'FunctionExpression' ||
80
- node.type === 'ArrowFunctionExpression'
78
+ (node.type === 'FunctionDeclaration' ||
79
+ node.type === 'FunctionExpression' ||
80
+ node.type === 'ArrowFunctionExpression') &&
81
+ // Generated dynamic-tag wrappers are render-block closures, not user
82
+ // component boundaries — the element inside still belongs to this
83
+ // component's scoped CSS.
84
+ node.metadata?.tsrx_dynamic_wrapper !== true
81
85
  ) {
82
86
  return node;
83
87
  }
84
88
 
85
89
  if (node.type === 'JSXElement') {
86
- if (!is_composite_jsx_element(node) || node.metadata?.runtime_dynamic_element) {
90
+ if (!is_composite_jsx_element(node) || node.metadata?.dynamicElement) {
87
91
  add_hash_class_to_jsx_element(node, hash, jsx_class_attr_name);
88
92
  }
89
93
  if (Array.isArray(node.children)) {
@@ -27,6 +27,7 @@
27
27
  generated: string;
28
28
  loc: AST.SourceLocation;
29
29
  metadata: PluginActionOverrides;
30
+ generatedLoc?: AST.SourceLocation;
30
31
  end_loc?: AST.SourceLocation;
31
32
  sourceLength?: number;
32
33
  mappingData?: Partial<VolarCodeMapping['data']>;
@@ -387,7 +388,8 @@ export function convert_source_map_to_mappings(
387
388
  * @returns {{ line: number; column: number }}
388
389
  */
389
390
  function get_generated_position_for_token(token) {
390
- const key = `${token.loc.start.line}:${token.loc.start.column}`;
391
+ const generated_loc = token.generatedLoc ?? token.loc;
392
+ const key = `${generated_loc.start.line}:${generated_loc.start.column}`;
391
393
  const positions = src_to_gen_map.get(key);
392
394
  if (!positions || positions.length === 0) {
393
395
  throw new Error(`No source map entry for position "${key}"`);
@@ -478,6 +480,32 @@ export function convert_source_map_to_mappings(
478
480
  }
479
481
  }
480
482
 
483
+ /**
484
+ * @param {any} generated_node
485
+ * @returns {void}
486
+ */
487
+ function add_extra_source_mapping_tokens(generated_node) {
488
+ if (!generated_node?.loc || !Array.isArray(generated_node.metadata?.extra_source_mappings)) {
489
+ return;
490
+ }
491
+
492
+ for (const source_node of generated_node.metadata.extra_source_mappings) {
493
+ if (!source_node?.loc) continue;
494
+
495
+ tokens.push({
496
+ source: source_node.name ?? generated_node.name,
497
+ generated: generated_node.name,
498
+ loc: source_node.loc,
499
+ generatedLoc: generated_node.loc,
500
+ metadata: {},
501
+ sourceLength:
502
+ typeof source_node.start === 'number' && typeof source_node.end === 'number'
503
+ ? source_node.end - source_node.start
504
+ : undefined,
505
+ });
506
+ }
507
+ }
508
+
481
509
  // We have to visit everything in generated order to maintain correct indices
482
510
 
483
511
  walk(ast, null, {
@@ -521,6 +549,7 @@ export function convert_source_map_to_mappings(
521
549
  token.mappingData = { ...mapping_data, verification: false };
522
550
  }
523
551
  tokens.push(token);
552
+ add_extra_source_mapping_tokens(node);
524
553
 
525
554
  if (Array.isArray(node.metadata?.lazy_param_binding_mappings)) {
526
555
  for (const binding_mapping of node.metadata.lazy_param_binding_mappings) {
@@ -558,6 +587,7 @@ export function convert_source_map_to_mappings(
558
587
  token.mappingData = { ...mapping_data, verification: false };
559
588
  }
560
589
  tokens.push(token);
590
+ add_extra_source_mapping_tokens(node);
561
591
  }
562
592
  return; // Leaf node, don't traverse further
563
593
  } else if (node.type === 'Literal') {
package/types/index.d.ts CHANGED
@@ -84,7 +84,7 @@ interface BaseNodeMetaData {
84
84
  parenthesized?: boolean;
85
85
  native_tsrx?: boolean;
86
86
  native_tsrx_template_block?: boolean;
87
- runtime_dynamic_element?: boolean;
87
+ dynamicElement?: boolean;
88
88
  templateMode?: 'script' | 'template';
89
89
  script_only?: boolean;
90
90
  tsrxDirective?: 'if' | 'for' | 'switch' | 'try';
@@ -253,6 +253,9 @@ declare module 'estree' {
253
253
  TsrxFragment: TsrxFragment;
254
254
  Text: Text;
255
255
  TSRXJSXElement: TSRXJSXElement;
256
+ TSRXJSXFragment: TSRXJSXFragment;
257
+ TSRXJSXOpeningElement: ESTreeJSX.TSRXJSXOpeningElement;
258
+ TSRXJSXClosingElement: ESTreeJSX.TSRXJSXClosingElement;
256
259
  TSRXExpression: TSRXExpression;
257
260
  Attribute: Attribute;
258
261
  SpreadAttribute: SpreadAttribute;
@@ -299,13 +302,14 @@ declare module 'estree' {
299
302
 
300
303
  interface Element extends AST.BaseExpression {
301
304
  type: 'Element';
302
- id: AST.Identifier | AST.MemberExpression | AST.Literal;
305
+ id: AST.Expression;
303
306
  attributes: Array<Attribute | SpreadAttribute>;
304
307
  children: AST.Node[];
305
308
  openingElement: ESTreeJSX.JSXOpeningElement;
306
309
  closingElement: ESTreeJSX.JSXClosingElement | null;
307
310
  selfClosing?: boolean;
308
311
  unclosed?: boolean;
312
+ isDynamic?: boolean;
309
313
  css?: string;
310
314
  metadata: BaseNodeMetaData;
311
315
  start: number;
@@ -345,7 +349,11 @@ declare module 'estree' {
345
349
  | AST.JSXCodeBlock;
346
350
 
347
351
  interface TSRXJSXElement
348
- extends Omit<ESTreeJSX.JSXElement, 'children'>, AST.NodeWithMaybeComments {
352
+ extends
353
+ Omit<ESTreeJSX.JSXElement, 'children' | 'openingElement' | 'closingElement'>,
354
+ AST.NodeWithMaybeComments {
355
+ openingElement: ESTreeJSX.TSRXJSXOpeningElement;
356
+ closingElement: ESTreeJSX.TSRXJSXClosingElement | null;
349
357
  children: TSRXJSXChild[];
350
358
  metadata: BaseNodeMetaData & {
351
359
  ts_name?: string;
@@ -365,13 +373,10 @@ declare module 'estree' {
365
373
  innerComments?: AST.Comment[] | undefined;
366
374
  }
367
375
 
368
- interface JSXStyleElement extends AST.BaseExpression {
376
+ interface JSXStyleElement extends Omit<AST.TSRXJSXElement, 'type' | 'children'> {
369
377
  type: 'JSXStyleElement';
370
- openingElement: ESTreeJSX.JSXOpeningElement;
371
- closingElement: ESTreeJSX.JSXClosingElement | null;
372
378
  children: AST.CSS.StyleSheet[];
373
379
  css?: string;
374
- metadata: BaseNodeMetaData;
375
380
  unclosed?: boolean;
376
381
  }
377
382
 
@@ -519,7 +524,7 @@ declare module 'estree' {
519
524
 
520
525
  type TSRXStatement = AST.Statement | TSESTree.Statement;
521
526
 
522
- type NodeWithChildren = TSRXJSXElement | TSRXJSXFragment | JSXStyleElement;
527
+ type NodeWithChildren = TSRXJSXElement | TSRXJSXFragment | JSXStyleElement | ESTreeJSX.JSXElement;
523
528
 
524
529
  export namespace CSS {
525
530
  export interface BaseNode extends AST.NodeWithMaybeComments {
@@ -727,6 +732,7 @@ declare module 'estree-jsx' {
727
732
  interface JSXExpressionContainer {
728
733
  text?: boolean;
729
734
  style?: boolean;
735
+ isDynamic?: boolean;
730
736
  }
731
737
 
732
738
  interface JSXMemberExpression {
@@ -734,11 +740,11 @@ declare module 'estree-jsx' {
734
740
  }
735
741
 
736
742
  interface TSRXJSXOpeningElement extends Omit<JSXOpeningElement, 'name'> {
737
- name: AST.MemberExpression | JSXIdentifier | JSXNamespacedName;
743
+ name: AST.MemberExpression | JSXIdentifier | JSXNamespacedName | JSXExpressionContainer;
738
744
  }
739
745
 
740
746
  interface TSRXJSXClosingElement extends Omit<JSXClosingElement, 'name'> {
741
- name: AST.MemberExpression | JSXIdentifier | JSXNamespacedName;
747
+ name: AST.MemberExpression | JSXIdentifier | JSXNamespacedName | JSXExpressionContainer;
742
748
  }
743
749
 
744
750
  interface ExpressionMap {
@@ -39,6 +39,8 @@ export interface JsxTransformContext {
39
39
  needs_normalize_spread_props: boolean;
40
40
  needs_normalize_spread_props_for_ref_attr: boolean;
41
41
  needs_fragment: boolean;
42
+ needs_dynamic_element: boolean;
43
+ needs_dynamic_factory: boolean;
42
44
  needs_for_of_iterable: boolean;
43
45
  needs_iteration_value_type: boolean;
44
46
  stylesheets: AST.CSS.StyleSheet[];
@@ -53,8 +55,6 @@ export interface JsxTransformContext {
53
55
  hook_helpers_enabled: boolean;
54
56
  available_bindings: Map<string, AST.Identifier>;
55
57
  lazy_next_id: number;
56
- /** Scope map used to resolve runtime Dynamic imports for scoped CSS pruning. */
57
- runtime_dynamic_scopes: Map<any, any> | null;
58
58
  inside_element_child?: boolean;
59
59
  /** Full source text for source-aware diagnostics. */
60
60
  source: string;
@@ -316,11 +316,30 @@ export interface JsxPlatform {
316
316
  */
317
317
  suspense: string;
318
318
  /**
319
- * Module that exports the target runtime `Dynamic` component. When set,
320
- * the shared JSX transform treats imported `Dynamic` elements with an
321
- * `is` prop as runtime-dynamic for scoped CSS pruning.
319
+ * Module the type-only transform imports the `Dynamic` component type
320
+ * from when lowering dynamic tags to `<TsrxDynamic is={expr}>`. The
321
+ * module only needs type declarations; production output never imports
322
+ * it.
322
323
  */
323
324
  dynamic?: string;
325
+ /**
326
+ * Scoped-binding lowering for dynamic tags (`<{expr}>`) in production
327
+ * output; each tag binds a scoped component const referenced like an
328
+ * ordinary component. With `name`/`source`, the factory is imported as
329
+ * `_tsrx_dynamic` and wraps the tag expression in a reactive thunk:
330
+ * `const TsrxDynamic_N = _tsrx_dynamic(() => expr);` (Solid:
331
+ * `{ name: 'dynamic', source: '@solidjs/web' }`). With an empty object,
332
+ * the const is a plain import-free alias re-evaluated by the host's
333
+ * render cycle: `const TsrxDynamic_N = expr;` (React/Preact). With
334
+ * `renderBlock: true`, the alias and element move inside an
335
+ * expression-child IIFE,
336
+ * `{(() => { const TsrxDynamic_N = expr; return <TsrxDynamic_N ...>; })()}`,
337
+ * relying on the host JSX compiler's reactive render block for
338
+ * expression children (Vue Vapor, which never re-runs setup). In
339
+ * type-only output, dynamic tags always lower to
340
+ * `<TsrxDynamic is={expr}>` imported from `imports.dynamic`.
341
+ */
342
+ dynamicFactory?: { name?: string; source?: string; renderBlock?: boolean };
324
343
  /**
325
344
  * Module to import `TsrxErrorBoundary` from when an `@try { ... } @catch (...)`
326
345
  * block appears. Usually `'@tsrx/<platform>/error-boundary'`.
package/types/parse.d.ts CHANGED
@@ -1621,11 +1621,13 @@ export namespace Parse {
1621
1621
  | ReturnType<Parser['jsx_parseIdentifier']>;
1622
1622
 
1623
1623
  /**
1624
- * Parse JSX element name (identifier, member, namespaced)
1624
+ * Parse JSX element name (identifier, member, namespaced, or a dynamic
1625
+ * `{expression}` container)
1625
1626
  */
1626
1627
  jsx_parseElementName():
1627
1628
  | ESTreeJSX.JSXMemberExpression
1628
1629
  | ReturnType<Parser['jsx_parseNamespacedName']>
1630
+ | ESTreeJSX.JSXExpressionContainer
1629
1631
  | '';
1630
1632
 
1631
1633
  /**