@tsrx/core 0.0.25 → 0.0.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.0.25",
6
+ "version": "0.0.27",
7
7
  "type": "module",
8
8
  "repository": {
9
9
  "type": "git",
@@ -19,6 +19,10 @@ export const COMPONENT_WHILE_STATEMENT_ERROR =
19
19
  'While loops are not supported in components. Move the while loop into a function.';
20
20
  export const COMPONENT_DO_WHILE_STATEMENT_ERROR =
21
21
  'Do...while loops are not supported in components. Move the do...while loop into a function.';
22
+ export const CLASS_COMPONENT_AS_NON_ARROW_PROPERTY_ERROR =
23
+ 'Components declared inside a class must be defined as an arrow function class property (e.g. `Foo = component() => { ... }`). Non-arrow component property values are not allowed.';
24
+ export const COMPONENT_MULTIPLE_PARAMS_ERROR =
25
+ 'Components accept a single props parameter. Move additional inputs into the props object instead.';
22
26
 
23
27
  const invalid_nestings = {
24
28
  // <p> cannot contain block-level elements
@@ -247,6 +251,61 @@ export function validate_component_unsupported_loop_statement(node, filename, er
247
251
  error(message, filename ?? null, node, errors, comments);
248
252
  }
249
253
 
254
+ /**
255
+ * Validates that a component declares at most a single (props) parameter.
256
+ * Components have one slot for props; additional positional parameters are
257
+ * silently dropped or naively passed through depending on the target, so
258
+ * reject them at analysis time. Reports one error per extra parameter so
259
+ * every offending input gets its own TS diagnostic squiggle. In throwing
260
+ * mode the first call raises and aborts before the loop continues.
261
+ *
262
+ * @param {AST.Component} component
263
+ * @param {string | null | undefined} filename
264
+ * @param {CompileError[]} [errors]
265
+ * @param {AST.CommentWithLocation[]} [comments]
266
+ */
267
+ export function validate_component_params(component, filename, errors, comments) {
268
+ const params = /** @type {AST.Pattern[] | undefined} */ (component.params);
269
+ if (!params || params.length <= 1) {
270
+ return;
271
+ }
272
+
273
+ for (let i = 1; i < params.length; i++) {
274
+ error(COMPONENT_MULTIPLE_PARAMS_ERROR, filename ?? null, params[i], errors, comments);
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Validates that components declared at the top level of a class body use the
280
+ * only allowed form: an arrow function class property (regular or static).
281
+ * Reports an error for non-arrow component property values such as
282
+ * `Foo = component() { ... }`. The method form (`component foo() {}` inside
283
+ * a class body) is rejected at parse time and never reaches this check.
284
+ *
285
+ * @param {AST.ClassBody} class_body
286
+ * @param {string | null | undefined} filename
287
+ * @param {CompileError[]} [errors]
288
+ * @param {AST.CommentWithLocation[]} [comments]
289
+ */
290
+ export function validate_class_component_declarations(class_body, filename, errors, comments) {
291
+ for (const member of class_body.body) {
292
+ if (member.type !== 'PropertyDefinition') {
293
+ continue;
294
+ }
295
+
296
+ const value = /** @type {any} */ (member).value;
297
+ if (value && value.type === 'Component' && !value.metadata?.arrow) {
298
+ error(
299
+ CLASS_COMPONENT_AS_NON_ARROW_PROPERTY_ERROR,
300
+ filename ?? null,
301
+ member,
302
+ errors,
303
+ comments,
304
+ );
305
+ }
306
+ }
307
+ }
308
+
250
309
  /**
251
310
  * @param {AST.Element} element
252
311
  * @param {AnalysisContext} context
package/src/index.js CHANGED
@@ -164,12 +164,16 @@ export {
164
164
  flatten_switch_consequent,
165
165
  get_for_of_iteration_params,
166
166
  identifier_to_jsx_name,
167
+ is_bare_render_expression,
167
168
  is_dynamic_element_id,
168
169
  is_jsx_child,
169
170
  set_loc,
170
171
  to_text_expression,
171
172
  } from './transform/jsx/ast-builders.js';
172
- export { render_stylesheets as renderStylesheets } from './transform/stylesheet.js';
173
+ export {
174
+ render_stylesheets as renderStylesheets,
175
+ render_css_result as renderCssResult,
176
+ } from './transform/stylesheet.js';
173
177
  export {
174
178
  prepare_stylesheet_for_render as prepareStylesheetForRender,
175
179
  is_style_element as isStyleElement,
@@ -213,17 +217,21 @@ export {
213
217
  // Analyze
214
218
  export { analyze_css as analyzeCss } from './analyze/css-analyze.js';
215
219
  export {
220
+ CLASS_COMPONENT_AS_NON_ARROW_PROPERTY_ERROR,
216
221
  COMPONENT_DO_WHILE_STATEMENT_ERROR,
217
222
  COMPONENT_FOR_IN_STATEMENT_ERROR,
218
223
  COMPONENT_FOR_STATEMENT_ERROR,
219
224
  COMPONENT_LOOP_BREAK_ERROR,
220
225
  COMPONENT_LOOP_RETURN_ERROR,
226
+ COMPONENT_MULTIPLE_PARAMS_ERROR,
221
227
  COMPONENT_RETURN_VALUE_ERROR,
222
228
  COMPONENT_WHILE_STATEMENT_ERROR,
223
229
  get_return_keyword_node as getReturnKeywordNode,
224
230
  get_statement_keyword_node as getStatementKeywordNode,
231
+ validate_class_component_declarations as validateClassComponentDeclarations,
225
232
  validate_component_loop_break_statement as validateComponentLoopBreakStatement,
226
233
  validate_component_loop_return_statement as validateComponentLoopReturnStatement,
234
+ validate_component_params as validateComponentParams,
227
235
  validate_component_return_statement as validateComponentReturnStatement,
228
236
  validate_component_unsupported_loop_statement as validateComponentUnsupportedLoopStatement,
229
237
  validate_nesting as validateNesting,
package/src/plugin.js CHANGED
@@ -559,65 +559,6 @@ export function TSRXPlugin(config) {
559
559
  return super.parseProperty(isPattern, refDestructuringErrors);
560
560
  }
561
561
 
562
- /**
563
- * Override parseClassElement to support component methods in classes.
564
- * Handles syntax like `class Foo { component something() { <div /> } }`
565
- * Also supports computed names: `class Foo { component ['something']() { <div /> } }`
566
- * @type {Parse.Parser['parseClassElement']}
567
- */
568
- parseClassElement(constructorAllowsSuper) {
569
- // Check if this is a component method: component name( ... ) { ... }
570
- if (this.type === tt.name && this.value === 'component') {
571
- // Look ahead to see if this is "component identifier(",
572
- // "component identifier<", "component [", or "component 'string'"
573
- const lookahead = this.input.slice(this.pos).match(/^\s*(?:(\w+)\s*[(<]|\[|['"])/);
574
- if (lookahead) {
575
- // This is a component method definition
576
- const node = /** @type {AST.MethodDefinition} */ (this.startNode());
577
- const isComputed = lookahead[0].trim().startsWith('[');
578
- const isStringLiteral = /^['"]/.test(lookahead[0].trim());
579
-
580
- if (isComputed) {
581
- // For computed names, consume 'component'
582
- // parse the key, then parse component without name
583
- this.next(); // consume 'component'
584
- this.next(); // consume '['
585
- node.key = this.parseExpression();
586
- this.expect(tt.bracketR);
587
- node.computed = true;
588
-
589
- // Parse component without name (skipName: true)
590
- const component_node = this.parseComponent({ skipName: true });
591
- /** @type {AST.TSRXMethodDefinition} */ (node).value = component_node;
592
- } else if (isStringLiteral) {
593
- // For string literal names, consume 'component'
594
- // parse the string key, then parse component without name
595
- this.next(); // consume 'component'
596
- node.key = /** @type {AST.Literal} */ (this.parseExprAtom());
597
- node.computed = false;
598
-
599
- // Parse component without name (skipName: true)
600
- const component_node = this.parseComponent({ skipName: true });
601
- /** @type {AST.TSRXMethodDefinition} */ (node).value = component_node;
602
- } else {
603
- // Use parseComponent which handles consuming 'component', parsing name, params, and body
604
- const component_node = this.parseComponent({ requireName: true });
605
-
606
- node.key = /** @type {AST.Identifier} */ (component_node.id);
607
- /** @type {AST.TSRXMethodDefinition} */ (node).value = component_node;
608
- node.computed = false;
609
- }
610
-
611
- node.static = false;
612
- node.kind = 'method';
613
-
614
- return this.finishNode(node, 'MethodDefinition');
615
- }
616
- }
617
-
618
- return super.parseClassElement(constructorAllowsSuper);
619
- }
620
-
621
562
  /**
622
563
  * Override parsePropertyValue to support TypeScript generic methods in object literals.
623
564
  * By default, acorn-typescript doesn't handle `{ method<T>() {} }` syntax.
@@ -1371,8 +1312,8 @@ export function TSRXPlugin(config) {
1371
1312
  */
1372
1313
  checkUnreserved(ref) {
1373
1314
  if (ref.name === 'component') {
1374
- // Allow 'component' when it's followed by an identifier and '(' or '<' (component method in object literal or class)
1375
- // e.g., { component something() { ... } } or class Foo { component something<T>() { ... } }
1315
+ // Allow 'component' when it's followed by an identifier and '(' or '<' (component method in object literal)
1316
+ // e.g., { component something() { ... } }
1376
1317
  // Also allow computed names: { component ['name']() { ... } }
1377
1318
  // Also allow string literal names: { component 'name'() { ... } }
1378
1319
  const nextChars = this.input.slice(this.pos).match(/^\s*(?:(\w+)\s*[(<]|\[|['"])/);
@@ -187,6 +187,57 @@ export function is_jsx_child(node) {
187
187
  );
188
188
  }
189
189
 
190
+ /**
191
+ * The parser represents `<>{expr}</>` / `<tsx>{expr}</tsx>` as a Tsx node,
192
+ * and expression-position lowering unwraps that to the inner expression.
193
+ * When such a node appears directly in a component or statement render body,
194
+ * the unwrapped expression is still render output rather than an executable
195
+ * statement.
196
+ *
197
+ * @param {any} node
198
+ * @returns {boolean}
199
+ */
200
+ export function is_bare_render_expression(node) {
201
+ if (!node || typeof node !== 'object') {
202
+ return false;
203
+ }
204
+
205
+ switch (node.type) {
206
+ case 'ArrayExpression':
207
+ case 'ArrowFunctionExpression':
208
+ case 'AssignmentExpression':
209
+ case 'AwaitExpression':
210
+ case 'BinaryExpression':
211
+ case 'CallExpression':
212
+ case 'ChainExpression':
213
+ case 'ClassExpression':
214
+ case 'ConditionalExpression':
215
+ case 'FunctionExpression':
216
+ case 'Identifier':
217
+ case 'ImportExpression':
218
+ case 'Literal':
219
+ case 'LogicalExpression':
220
+ case 'MemberExpression':
221
+ case 'MetaProperty':
222
+ case 'NewExpression':
223
+ case 'ObjectExpression':
224
+ case 'ParenthesizedExpression':
225
+ case 'SequenceExpression':
226
+ case 'TaggedTemplateExpression':
227
+ case 'TemplateLiteral':
228
+ case 'ThisExpression':
229
+ case 'TSAsExpression':
230
+ case 'TSSatisfiesExpression':
231
+ case 'TSNonNullExpression':
232
+ case 'UnaryExpression':
233
+ case 'UpdateExpression':
234
+ case 'YieldExpression':
235
+ return true;
236
+ default:
237
+ return false;
238
+ }
239
+ }
240
+
190
241
  /**
191
242
  * A dynamic element id is one whose identifier is `tracked` — i.e. it was
192
243
  * introduced by reactive destructuring so its value can change at runtime.
@@ -20,12 +20,13 @@ import {
20
20
  flatten_switch_consequent,
21
21
  get_for_of_iteration_params,
22
22
  identifier_to_jsx_name,
23
+ is_bare_render_expression,
23
24
  is_dynamic_element_id,
24
25
  is_jsx_child,
25
26
  set_loc,
26
27
  to_text_expression,
27
28
  } from './ast-builders.js';
28
- import { render_stylesheets as renderStylesheets } from '../stylesheet.js';
29
+ import { render_css_result } from '../stylesheet.js';
29
30
  import {
30
31
  set_location as setLocation,
31
32
  jsx_attribute as build_jsx_attribute,
@@ -41,8 +42,10 @@ import {
41
42
  import { find_first_top_level_await_in_component_body } from '../await.js';
42
43
  import { prepare_stylesheet_for_render, annotate_component_with_hash } from '../scoping.js';
43
44
  import {
45
+ validate_class_component_declarations,
44
46
  validate_component_loop_break_statement,
45
47
  validate_component_loop_return_statement,
48
+ validate_component_params,
46
49
  validate_component_return_statement,
47
50
  validate_component_unsupported_loop_statement,
48
51
  } from '../../analyze/validation.js';
@@ -167,6 +170,8 @@ export function createJsxTransform(platform) {
167
170
  needs_suspense: false,
168
171
  needs_merge_refs: false,
169
172
  needs_fragment: false,
173
+ module_scoped_hook_components:
174
+ options?.moduleScopedHookComponents ?? !!platform.hooks?.moduleScopedHookComponents,
170
175
  helper_state: null,
171
176
  available_bindings: new Map(),
172
177
  lazy_next_id: 0,
@@ -175,12 +180,15 @@ export function createJsxTransform(platform) {
175
180
  collect,
176
181
  errors: collect ? options?.errors : undefined,
177
182
  comments: options?.comments,
183
+ typeOnly: !!options?.typeOnly,
178
184
  // Platforms can seed their own tracking state (e.g. solid's
179
185
  // needs_show / needs_for flags) via `hooks.initialState`.
180
186
  ...(platform.hooks?.initialState?.() ?? {}),
181
187
  };
182
188
 
183
- preallocate_lazy_ids(/** @type {any} */ (ast), transform_context);
189
+ if (!transform_context.typeOnly) {
190
+ preallocate_lazy_ids(/** @type {any} */ (ast), transform_context);
191
+ }
184
192
 
185
193
  walk(/** @type {any} */ (ast), transform_context, {
186
194
  ReturnStatement(node, { next, path }) {
@@ -270,9 +278,26 @@ export function createJsxTransform(platform) {
270
278
  return next();
271
279
  },
272
280
 
281
+ ClassBody(node, { next }) {
282
+ validate_class_component_declarations(
283
+ /** @type {any} */ (node),
284
+ filename,
285
+ transform_context.errors,
286
+ transform_context.comments,
287
+ );
288
+ return next();
289
+ },
290
+
273
291
  Component(node, { next, state }) {
274
292
  const as_any = /** @type {any} */ (node);
275
293
 
294
+ validate_component_params(
295
+ as_any,
296
+ filename,
297
+ transform_context.errors,
298
+ transform_context.comments,
299
+ );
300
+
276
301
  const await_expression = find_first_top_level_await_in_component_body(as_any.body || []);
277
302
 
278
303
  if (await_expression) {
@@ -420,8 +445,15 @@ export function createJsxTransform(platform) {
420
445
  // declarations, arrow functions, etc.). Component bodies have already been
421
446
  // transformed inside component_to_function_declaration; this catches plain
422
447
  // functions outside components and any lazy patterns in module scope.
448
+ // In type-only mode, the lazy patterns survive untouched: esrap ignores the
449
+ // non-standard `lazy` flag, so `&{ a, b }` prints as `{ a, b }`, `let &[a]
450
+ // = expr` prints as `let [a] = expr`, and the bare statement-level form
451
+ // `&[x] = expr;` (used when `x` is already declared) prints as `[x] =
452
+ // expr;` — a valid destructuring assignment to the existing binding.
423
453
  const final_program = /** @type {any} */ (
424
- apply_lazy_transforms(/** @type {any} */ (expanded), new Map())
454
+ transform_context.typeOnly
455
+ ? expanded
456
+ : apply_lazy_transforms(/** @type {any} */ (expanded), new Map())
425
457
  );
426
458
 
427
459
  const result = print(/** @type {any} */ (final_program), tsx_with_ts_locations(), {
@@ -429,17 +461,11 @@ export function createJsxTransform(platform) {
429
461
  sourceMapContent: source,
430
462
  });
431
463
 
432
- const css =
433
- stylesheets.length > 0
434
- ? {
435
- code: renderStylesheets(
436
- /** @type {any} */ (stylesheets.map(prepare_stylesheet_for_render)),
437
- ),
438
- hash: stylesheets.map((s) => s.hash).join(' '),
439
- }
440
- : null;
464
+ const { css, cssHash } = render_css_result(
465
+ /** @type {any} */ (stylesheets.map(prepare_stylesheet_for_render)),
466
+ );
441
467
 
442
- return { ast: final_program, code: result.code, map: result.map, css };
468
+ return { ast: final_program, code: result.code, map: result.map, css, cssHash };
443
469
  }
444
470
 
445
471
  return transform;
@@ -503,7 +529,11 @@ export function component_to_function_declaration(component, transform_context,
503
529
  // Collect lazy binding info WITHOUT mutating patterns. Stores lazy_id on metadata
504
530
  // for later replacement. Body bindings (count, setCount, etc.) are still in the
505
531
  // original patterns, so collect_statement_bindings during build will find them.
506
- const lazy_bindings = collect_lazy_bindings_from_component(params, body, transform_context);
532
+ // In type-only mode the lazy rewrite is skipped entirely so destructuring
533
+ // patterns survive into the virtual TSX and TypeScript can flow real types.
534
+ const lazy_bindings = transform_context.typeOnly
535
+ ? new Map()
536
+ : collect_lazy_bindings_from_component(params, body, transform_context);
507
537
 
508
538
  // Save and set context for this component scope
509
539
  const saved_helper_state = transform_context.helper_state;
@@ -624,6 +654,10 @@ function build_render_statements(body_nodes, return_null_when_empty, transform_c
624
654
  // any JSX is constructed, and every JSX child would observe the final
625
655
  // state of mutable variables.
626
656
  const interleaved = is_interleaved_body(body_nodes);
657
+ const capture_static_early_return_nodes =
658
+ !interleaved &&
659
+ !transform_context.platform.hooks?.isTopLevelSetupCall &&
660
+ body_nodes.filter(is_returning_if_statement).length > 1;
627
661
  let capture_index = 0;
628
662
 
629
663
  for (let i = 0; i < body_nodes.length; i += 1) {
@@ -648,6 +682,15 @@ function build_render_statements(body_nodes, return_null_when_empty, transform_c
648
682
  true,
649
683
  );
650
684
 
685
+ if (capture_static_early_return_nodes) {
686
+ capture_index = capture_static_early_return_render_nodes(
687
+ render_nodes,
688
+ statements,
689
+ capture_index,
690
+ transform_context,
691
+ );
692
+ }
693
+
651
694
  if (branch_has_hooks || continuation_has_hooks) {
652
695
  if (transform_context.platform.hooks?.isTopLevelSetupCall) {
653
696
  statements.push(
@@ -870,6 +913,8 @@ function build_render_statements(body_nodes, return_null_when_empty, transform_c
870
913
  } else {
871
914
  render_nodes.push(jsx);
872
915
  }
916
+ } else if (is_bare_render_expression(child)) {
917
+ render_nodes.push(to_jsx_expression_container(child, child));
873
918
  } else {
874
919
  statements.push(child);
875
920
  collect_statement_bindings(child, transform_context.available_bindings);
@@ -1157,6 +1202,25 @@ function create_helper_state(base_name) {
1157
1202
  };
1158
1203
  }
1159
1204
 
1205
+ /**
1206
+ * @param {TransformContext} transform_context
1207
+ * @returns {boolean}
1208
+ */
1209
+ function should_use_module_scoped_hook_components(transform_context) {
1210
+ return !!(transform_context.helper_state && transform_context.module_scoped_hook_components);
1211
+ }
1212
+
1213
+ /**
1214
+ * @param {AST.Identifier} helper_id
1215
+ * @param {TransformContext} transform_context
1216
+ * @returns {AST.Identifier}
1217
+ */
1218
+ function create_module_scoped_hook_component_id(helper_id, transform_context) {
1219
+ return create_generated_identifier(
1220
+ `${transform_context.helper_state?.base_name || 'Component'}__${helper_id.name}`,
1221
+ );
1222
+ }
1223
+
1160
1224
  /**
1161
1225
  * @param {any[]} params
1162
1226
  * @returns {Map<string, AST.Identifier>}
@@ -1325,6 +1389,59 @@ function hoist_static_render_nodes(render_nodes, transform_context) {
1325
1389
  }
1326
1390
  }
1327
1391
 
1392
+ /**
1393
+ * Static JSX that appears before multiple early-return guards is otherwise
1394
+ * cloned into every generated return. Capture it once at its source position
1395
+ * and reuse the reference, matching the interleaved-statement capture path
1396
+ * without moving dynamic render-time expressions across guards.
1397
+ *
1398
+ * @param {any[]} render_nodes
1399
+ * @param {any[]} statements
1400
+ * @param {number} capture_index
1401
+ * @param {TransformContext} transform_context
1402
+ * @returns {number}
1403
+ */
1404
+ function capture_static_early_return_render_nodes(
1405
+ render_nodes,
1406
+ statements,
1407
+ capture_index,
1408
+ transform_context,
1409
+ ) {
1410
+ for (let i = 0; i < render_nodes.length; i += 1) {
1411
+ const node = render_nodes[i];
1412
+ if (!is_static_early_return_capture_node(node, transform_context)) {
1413
+ continue;
1414
+ }
1415
+
1416
+ const { declaration, reference } = captureJsxChild(node, capture_index++);
1417
+ statements.push(declaration);
1418
+ render_nodes[i] = reference;
1419
+ }
1420
+
1421
+ return capture_index;
1422
+ }
1423
+
1424
+ /**
1425
+ * @param {any} node
1426
+ * @param {TransformContext} transform_context
1427
+ * @returns {boolean}
1428
+ */
1429
+ function is_static_early_return_capture_node(node, transform_context) {
1430
+ if (node?.type !== 'JSXElement' && node?.type !== 'JSXFragment') {
1431
+ return false;
1432
+ }
1433
+ if (!is_hoist_safe_jsx_node(node)) {
1434
+ return false;
1435
+ }
1436
+ if (
1437
+ transform_context.platform.hooks?.canHoistStaticNode &&
1438
+ !transform_context.platform.hooks.canHoistStaticNode(node, transform_context)
1439
+ ) {
1440
+ return false;
1441
+ }
1442
+ return !references_scope_bindings(node, transform_context.available_bindings);
1443
+ }
1444
+
1328
1445
  /**
1329
1446
  * @param {AST.Program} program
1330
1447
  * @returns {AST.Program}
@@ -2083,25 +2200,33 @@ function build_hoisted_for_of_with_hooks(node, continuation_body, transform_cont
2083
2200
  const helper_id = create_generated_identifier(
2084
2201
  create_local_statement_component_name(transform_context),
2085
2202
  );
2086
-
2087
- const outer_aliases = outer_bindings.map((binding) =>
2088
- create_helper_type_alias_declaration(helper_id, binding),
2089
- );
2090
- const loop_aliases = loop_bindings.map((binding) =>
2091
- create_loop_scoped_type_alias_declaration(helper_id, binding, source_id, loop_params),
2092
- );
2203
+ const use_module_scoped_component = should_use_module_scoped_hook_components(transform_context);
2204
+ const component_id = use_module_scoped_component
2205
+ ? create_module_scoped_hook_component_id(helper_id, transform_context)
2206
+ : helper_id;
2207
+
2208
+ const outer_aliases = use_module_scoped_component
2209
+ ? []
2210
+ : outer_bindings.map((binding) => create_helper_type_alias_declaration(helper_id, binding));
2211
+ const loop_aliases = use_module_scoped_component
2212
+ ? []
2213
+ : loop_bindings.map((binding) =>
2214
+ create_loop_scoped_type_alias_declaration(helper_id, binding, source_id, loop_params),
2215
+ );
2093
2216
 
2094
2217
  // Synthetic `isLast` prop on the loop helper when there's a tail. It's
2095
2218
  // passed from the .map callback as `i === source.length - 1` so every
2096
2219
  // loop-helper return can append the tail helper on the last iteration.
2097
2220
  const tail_isLast_alias = has_tail
2098
- ? {
2099
- id: create_generated_identifier(`_tsrx_${helper_id.name}_isLast`),
2100
- declaration: b.ts_type_alias(
2101
- create_generated_identifier(`_tsrx_${helper_id.name}_isLast`),
2102
- b.ts_keyword_type('boolean'),
2103
- ),
2104
- }
2221
+ ? use_module_scoped_component
2222
+ ? null
2223
+ : {
2224
+ id: create_generated_identifier(`_tsrx_${helper_id.name}_isLast`),
2225
+ declaration: b.ts_type_alias(
2226
+ create_generated_identifier(`_tsrx_${helper_id.name}_isLast`),
2227
+ b.ts_keyword_type('boolean'),
2228
+ ),
2229
+ }
2105
2230
  : null;
2106
2231
 
2107
2232
  const ordered_bindings = [...outer_bindings, ...loop_bindings];
@@ -2115,7 +2240,7 @@ function build_hoisted_for_of_with_hooks(node, continuation_body, transform_cont
2115
2240
  const signature_use_typeof = has_tail ? [...ordered_use_typeof, false] : ordered_use_typeof;
2116
2241
 
2117
2242
  const props_type =
2118
- signature_bindings.length > 0
2243
+ signature_bindings.length > 0 && !use_module_scoped_component
2119
2244
  ? create_helper_props_type_literal_with_typeof_flags(
2120
2245
  signature_bindings,
2121
2246
  signature_aliases,
@@ -2123,7 +2248,13 @@ function build_hoisted_for_of_with_hooks(node, continuation_body, transform_cont
2123
2248
  )
2124
2249
  : null;
2125
2250
  const params =
2126
- props_type !== null ? [create_typed_helper_props_pattern(signature_bindings, props_type)] : [];
2251
+ signature_bindings.length > 0
2252
+ ? [
2253
+ props_type !== null
2254
+ ? create_typed_helper_props_pattern(signature_bindings, props_type)
2255
+ : create_helper_props_pattern(signature_bindings),
2256
+ ]
2257
+ : [];
2127
2258
 
2128
2259
  const fn_saved_bindings = transform_context.available_bindings;
2129
2260
  transform_context.available_bindings = new Map(fn_saved_bindings);
@@ -2141,12 +2272,17 @@ function build_hoisted_for_of_with_hooks(node, continuation_body, transform_cont
2141
2272
  transform_context.available_bindings = fn_saved_bindings;
2142
2273
 
2143
2274
  const helper_fn = /** @type {any} */ (
2144
- b.function(clone_identifier(helper_id), params, b.block(fn_body_statements))
2275
+ b.function(clone_identifier(component_id), params, b.block(fn_body_statements))
2145
2276
  );
2146
2277
  helper_fn.metadata = { path: [], is_component: true, is_method: false };
2147
2278
 
2148
2279
  let helper_decl;
2149
- if (transform_context.helper_state) {
2280
+ if (transform_context.helper_state && use_module_scoped_component) {
2281
+ transform_context.helper_state.helpers.push(
2282
+ create_helper_declaration(component_id, helper_fn, node, transform_context),
2283
+ );
2284
+ helper_decl = null;
2285
+ } else if (transform_context.helper_state) {
2150
2286
  const cache_id = create_generated_identifier(
2151
2287
  `${transform_context.helper_state.base_name}__${helper_id.name}`,
2152
2288
  );
@@ -2163,7 +2299,7 @@ function build_hoisted_for_of_with_hooks(node, continuation_body, transform_cont
2163
2299
  transform_context.available_bindings = saved_bindings;
2164
2300
 
2165
2301
  const callback_invocation_element = create_helper_component_element(
2166
- helper_id,
2302
+ component_id,
2167
2303
  ordered_bindings,
2168
2304
  node,
2169
2305
  { mapWrapper: false, mapBindingNames: false, mapBindingValues: false },
@@ -2244,7 +2380,9 @@ function build_hoisted_for_of_with_hooks(node, continuation_body, transform_cont
2244
2380
  if (has_tail && tail_isLast_alias) {
2245
2381
  hoist_statements.push(tail_isLast_alias.declaration);
2246
2382
  }
2247
- hoist_statements.push(helper_decl);
2383
+ if (helper_decl) {
2384
+ hoist_statements.push(helper_decl);
2385
+ }
2248
2386
 
2249
2387
  return {
2250
2388
  hoist_statements,
@@ -2704,8 +2842,8 @@ function create_local_statement_component_name(transform_context) {
2704
2842
  /**
2705
2843
  * Wraps a list of body nodes into a component and returns
2706
2844
  * statements that return `<ComponentName prop1={prop1} ... />`.
2707
- * The component is hoisted to module level via helper_state to avoid
2708
- * recreating the component identity on every render.
2845
+ * Targets can either emit the helper component at module scope or cache the
2846
+ * component identity in module state while initializing it from the parent.
2709
2847
  * Used when a control flow branch contains hook calls that must be moved
2710
2848
  * into their own component boundary to satisfy the Rules of Hooks.
2711
2849
  *
@@ -2778,24 +2916,36 @@ function create_hook_safe_helper(
2778
2916
  const helper_id =
2779
2917
  preallocated_helper_id ??
2780
2918
  create_generated_identifier(create_local_statement_component_name(transform_context));
2919
+ const use_module_scoped_component = should_use_module_scoped_hook_components(transform_context);
2920
+ const component_id = use_module_scoped_component
2921
+ ? create_module_scoped_hook_component_id(helper_id, transform_context)
2922
+ : helper_id;
2781
2923
  const helper_bindings = get_referenced_helper_bindings(
2782
2924
  body_nodes,
2783
2925
  transform_context.available_bindings,
2784
2926
  );
2785
- const aliases = helper_bindings.map((binding) =>
2786
- create_helper_type_alias_declaration(helper_id, binding),
2787
- );
2927
+ const aliases = use_module_scoped_component
2928
+ ? []
2929
+ : helper_bindings.map((binding) => create_helper_type_alias_declaration(helper_id, binding));
2788
2930
  const props_type =
2789
- helper_bindings.length > 0 ? create_helper_props_type_literal(helper_bindings, aliases) : null;
2931
+ helper_bindings.length > 0 && !use_module_scoped_component
2932
+ ? create_helper_props_type_literal(helper_bindings, aliases)
2933
+ : null;
2790
2934
  const params =
2791
- props_type !== null ? [create_typed_helper_props_pattern(helper_bindings, props_type)] : [];
2935
+ helper_bindings.length > 0
2936
+ ? [
2937
+ props_type !== null
2938
+ ? create_typed_helper_props_pattern(helper_bindings, props_type)
2939
+ : create_helper_props_pattern(helper_bindings),
2940
+ ]
2941
+ : [];
2792
2942
 
2793
2943
  const saved_bindings = transform_context.available_bindings;
2794
2944
  transform_context.available_bindings = new Map(saved_bindings);
2795
2945
 
2796
2946
  const helper_fn = /** @type {any} */ ({
2797
2947
  type: 'FunctionExpression',
2798
- id: clone_identifier(helper_id),
2948
+ id: clone_identifier(component_id),
2799
2949
  params,
2800
2950
  body: {
2801
2951
  type: 'BlockStatement',
@@ -2814,7 +2964,7 @@ function create_hook_safe_helper(
2814
2964
  transform_context.available_bindings = saved_bindings;
2815
2965
 
2816
2966
  const component_element = create_helper_component_element(
2817
- helper_id,
2967
+ component_id,
2818
2968
  helper_bindings,
2819
2969
  source_node,
2820
2970
  {
@@ -2845,6 +2995,16 @@ function create_hook_safe_helper(
2845
2995
  };
2846
2996
  }
2847
2997
 
2998
+ if (use_module_scoped_component) {
2999
+ transform_context.helper_state.helpers.push(
3000
+ create_helper_declaration(component_id, helper_fn, source_node, transform_context),
3001
+ );
3002
+ return {
3003
+ setup_statements: [],
3004
+ component_element,
3005
+ };
3006
+ }
3007
+
2848
3008
  const cache_id = create_generated_identifier(
2849
3009
  `${transform_context.helper_state.base_name}__${helper_id.name}`,
2850
3010
  );
@@ -4196,6 +4356,8 @@ function create_render_switch_case(switch_case, transform_context) {
4196
4356
 
4197
4357
  if (is_jsx_child(child)) {
4198
4358
  render_nodes.push(to_jsx_child(child, transform_context));
4359
+ } else if (is_bare_render_expression(child)) {
4360
+ render_nodes.push(to_jsx_expression_container(child, child));
4199
4361
  } else {
4200
4362
  case_body.push(child);
4201
4363
  }
@@ -439,7 +439,6 @@ export function convert_source_map_to_mappings(
439
439
  }
440
440
 
441
441
  /**
442
- * @typedef {AST.MethodDefinition & {value: {metadata: {is_component: true}}}} MethodIsComponent
443
442
  * @typedef {AST.Property & {value: AST.FunctionExpression, method: true} & {value: {metadata: {is_component: true}}}} PropertyIsComponent
444
443
  */
445
444
 
@@ -447,7 +446,7 @@ export function convert_source_map_to_mappings(
447
446
  * Maps `component` to the identifier's location
448
447
  * e.g. const obj = { component something() { } }
449
448
  * since there is no function keyword in source maps
450
- * @param {MethodIsComponent | PropertyIsComponent} node
449
+ * @param {PropertyIsComponent} node
451
450
  * @returns {void}
452
451
  */
453
452
  function set_component_mapping_to_name(node) {
@@ -1530,15 +1529,8 @@ export function convert_source_map_to_mappings(
1530
1529
  set_bracket_computed_mapping(node, mappings);
1531
1530
  }
1532
1531
 
1533
- if (node.value.metadata.is_component) {
1534
- set_component_mapping_to_name(/** @type {MethodIsComponent} */ (node));
1535
- }
1536
-
1537
1532
  if (node.key.type === 'Literal') {
1538
- handle_literal(
1539
- node.key,
1540
- /** @type {AST.FunctionExpression} */ (node.value).metadata.is_component,
1541
- );
1533
+ handle_literal(node.key);
1542
1534
  } else {
1543
1535
  visit(node.key);
1544
1536
  }
@@ -543,3 +543,22 @@ export function render_stylesheets(stylesheets, minify = false) {
543
543
 
544
544
  return css;
545
545
  }
546
+
547
+ /**
548
+ * Render the `{ css, cssHash }` slice of a `CompileResult` from a list of
549
+ * stylesheets. Returns `{ css: '', cssHash: null }` when the list is empty
550
+ * so consumers can pass the result straight into a flat compile result.
551
+ *
552
+ * @param {AST.CSS.StyleSheet[]} stylesheets
553
+ * @param {boolean} [minify]
554
+ * @returns {{ css: string, cssHash: string | null }}
555
+ */
556
+ export function render_css_result(stylesheets, minify = false) {
557
+ if (stylesheets.length === 0) {
558
+ return { css: '', cssHash: null };
559
+ }
560
+ return {
561
+ css: render_stylesheets(stylesheets, minify),
562
+ cssHash: stylesheets.map((s) => s.hash).join(' '),
563
+ };
564
+ }
package/types/index.d.ts CHANGED
@@ -463,10 +463,6 @@ declare module 'estree' {
463
463
  body: (Program['body'][number] | Component | FunctionExpression)[];
464
464
  }
465
465
 
466
- interface TSRXMethodDefinition extends Omit<AST.MethodDefinition, 'value'> {
467
- value: AST.MethodDefinition['value'] | Component;
468
- }
469
-
470
466
  interface TSRXProperty extends Omit<AST.Property, 'value'> {
471
467
  value: AST.Property['value'] | Component;
472
468
  }
@@ -1575,15 +1571,17 @@ export interface VolarMappingsResult {
1575
1571
  * Result of compilation operation
1576
1572
  */
1577
1573
  export interface CompileResult {
1578
- /** The transformed AST */
1579
- ast: AST.Program;
1580
- /** The generated JavaScript code with source map */
1581
- js: {
1582
- code: string;
1583
- map: import('source-map').RawSourceMap;
1584
- };
1585
- /** The generated CSS */
1574
+ /** The generated JavaScript code */
1575
+ code: string;
1576
+ /** Source map for the generated code */
1577
+ map: import('source-map').RawSourceMap;
1578
+ /** Rendered CSS for the module, or `''` when the module emits no styles. */
1586
1579
  css: string;
1580
+ /**
1581
+ * Space-separated scope hashes for the rendered CSS, or `null` when the
1582
+ * module emits no styles.
1583
+ */
1584
+ cssHash: string | null;
1587
1585
  /**
1588
1586
  * Non-fatal errors collected during compilation. Populated only when the
1589
1587
  * caller passes `collect: true` or `loose: true`; empty otherwise.
@@ -1599,6 +1597,46 @@ export interface VolarCompileOptions extends Omit<ParseOptions, 'errors' | 'comm
1599
1597
  dev?: boolean;
1600
1598
  }
1601
1599
 
1600
+ /**
1601
+ * Common base options accepted by every TSRX target's `compile` entry point.
1602
+ * Targets that need extra knobs (e.g. ripple's `mode`/`dev`/`hmr`, preact's
1603
+ * `suspenseSource`) intersect their own option type with this base when
1604
+ * declaring their `compile` export.
1605
+ */
1606
+ export interface BaseCompileOptions {
1607
+ collect?: boolean;
1608
+ loose?: boolean;
1609
+ }
1610
+
1611
+ /**
1612
+ * Shared `compile` signature for every TSRX target package. Per-target
1613
+ * `compile` declarations should be `CompileFn<TOptions, TResult>` so any
1614
+ * drift in the shared contract becomes a typecheck error in every package.
1615
+ *
1616
+ * @template TOptions Per-target options accepted as the third argument.
1617
+ * Defaults to {@link BaseCompileOptions}.
1618
+ * @template TResult Per-target result type. Must extend {@link CompileResult};
1619
+ * targets may add fields (e.g. ripple's deprecated `js` back-compat field)
1620
+ * via intersection.
1621
+ */
1622
+ export type CompileFn<
1623
+ TOptions = BaseCompileOptions,
1624
+ TResult extends CompileResult = CompileResult,
1625
+ > = (source: string, filename?: string, options?: TOptions) => TResult;
1626
+
1627
+ /**
1628
+ * Shared `compile_to_volar_mappings` signature for every TSRX target package.
1629
+ *
1630
+ * @template TOptions Per-target options accepted as the third argument.
1631
+ * Defaults to {@link ParseOptions}; targets may intersect their own option
1632
+ * type to add e.g. `suspenseSource`.
1633
+ */
1634
+ export type VolarCompileFn<TOptions = ParseOptions> = (
1635
+ source: string,
1636
+ filename?: string,
1637
+ options?: TOptions,
1638
+ ) => VolarMappingsResult;
1639
+
1602
1640
  /**
1603
1641
  * Source map transformation types
1604
1642
  */
@@ -14,7 +14,14 @@ export interface JsxTransformResult {
14
14
  * downstream Vite / Rollup plugins to chain source maps.
15
15
  */
16
16
  map: RawSourceMap;
17
- css: { code: string; hash: string } | null;
17
+ /** Rendered CSS for the module, or `''` when the module emits no styles. */
18
+ css: string;
19
+ /**
20
+ * Space-separated scope hashes for the rendered CSS, or `null` when the
21
+ * module emits no styles. When multiple `<style>` blocks contribute, the
22
+ * hashes appear in source order.
23
+ */
24
+ cssHash: string | null;
18
25
  }
19
26
 
20
27
  /**
@@ -30,6 +37,7 @@ export interface JsxTransformContext {
30
37
  needs_suspense: boolean;
31
38
  needs_merge_refs: boolean;
32
39
  needs_fragment: boolean;
40
+ module_scoped_hook_components: boolean;
33
41
  helper_state: {
34
42
  base_name: string;
35
43
  next_id: number;
@@ -48,6 +56,8 @@ export interface JsxTransformContext {
48
56
  errors: CompileError[] | undefined;
49
57
  /** Module-level comments used to honor `@tsrx-ignore` / `@tsrx-expect-error`. */
50
58
  comments: AST.CommentWithLocation[] | undefined;
59
+ /** True when emitting a type-only virtual TSX module; preserves lazy destructuring patterns. */
60
+ typeOnly: boolean;
51
61
  }
52
62
 
53
63
  /**
@@ -80,6 +90,20 @@ export interface JsxTransformOptions {
80
90
  * `@tsrx-expect-error` line comments.
81
91
  */
82
92
  comments?: AST.CommentWithLocation[];
93
+ /**
94
+ * Override whether hook-isolation helper components are emitted directly at
95
+ * module scope. React runtime compilation enables this, while editor tooling
96
+ * can disable it to preserve lexical `typeof` helper prop types.
97
+ */
98
+ moduleScopedHookComponents?: boolean;
99
+ /**
100
+ * Emit a type-only virtual TSX module — output is fed to TypeScript for
101
+ * editor diagnostics / completions and never executed. Skips the lazy
102
+ * destructuring rewrite (`&{ a, b }` → `__lazy0: { a: any; b: any }`) so
103
+ * destructuring patterns survive and TypeScript can flow real types to the
104
+ * bindings.
105
+ */
106
+ typeOnly?: boolean;
83
107
  }
84
108
 
85
109
  /**
@@ -135,6 +159,13 @@ export interface JsxPlatformHooks {
135
159
  * state behaves like normal component state.
136
160
  */
137
161
  wrapHelperComponent?: (helperFn: any, helperId: any, ctx: any, sourceNode: any) => any;
162
+ /**
163
+ * Emit hook-isolation helper components as unique module-scope declarations
164
+ * instead of lazily creating and caching them from the parent component body.
165
+ * React enables this so generated branches stay compatible with the React
166
+ * Compiler's Rules of Hooks validation.
167
+ */
168
+ moduleScopedHookComponents?: boolean;
138
169
  /**
139
170
  * Inject module-level imports after the main walk. Default: import
140
171
  * `Suspense` from `platform.imports.suspense` and `TsrxErrorBoundary`
@@ -162,7 +193,7 @@ export interface JsxPlatformHooks {
162
193
  * Optionally replace the default React-style `.map(...)` lowering for a
163
194
  * `for...of` body after the shared transform has already produced its render
164
195
  * statements and applied any explicit or implicit keys. Vue uses this to hand
165
- * the loop to the downstream Vapor JSX compiler as a native `v-for` template.
196
+ * the loop to the downstream Vapor JSX compiler as a typed `VaporFor` component.
166
197
  */
167
198
  renderForOf?: (node: any, loopParams: any[], bodyStatements: any[], ctx: any) => any | null;
168
199
  /**