@tsrx/core 0.0.17 → 0.0.19

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.17",
6
+ "version": "0.0.19",
7
7
  "type": "module",
8
8
  "repository": {
9
9
  "type": "git",
@@ -27,6 +27,10 @@
27
27
  "./types/acorn": {
28
28
  "types": "./types/acorn.d.ts"
29
29
  },
30
+ "./runtime/merge-refs": {
31
+ "types": "./types/runtime/merge-refs.d.ts",
32
+ "default": "./src/runtime/merge-refs.js"
33
+ },
30
34
  "./test-harness/source-mappings": "./tests/shared/source-mappings.js",
31
35
  "./test-harness/compile": "./tests/shared/compile.js"
32
36
  },
package/src/index.js CHANGED
@@ -139,6 +139,9 @@ export { escape } from './utils/escaping.js';
139
139
  // Transform
140
140
  export {
141
141
  createJsxTransform,
142
+ merge_duplicate_refs as mergeDuplicateRefs,
143
+ to_jsx_attribute as toJsxAttribute,
144
+ validate_at_most_one_ref_attribute as validateAtMostOneRefAttribute,
142
145
  component_to_function_declaration as componentToFunctionDeclaration,
143
146
  } from './transform/jsx/index.js';
144
147
  export {
package/src/plugin.js CHANGED
@@ -176,6 +176,7 @@ export function TSRXPlugin(config) {
176
176
  /** @type {AST.Node[]} */
177
177
  #path = [];
178
178
  #allowTagStartAfterDoubleQuotedText = false;
179
+ #allowDoubleQuotedTextChildAfterBrace = false;
179
180
  #commentContextId = 0;
180
181
  #loose = false;
181
182
  /** @type {import('../types/index').CompileError[] | undefined} */
@@ -229,7 +230,7 @@ export function TSRXPlugin(config) {
229
230
  prev === 34 || // "
230
231
  prev === 59 || // ;
231
232
  prev === 62 || // >
232
- prev === 123 || // {
233
+ (prev === 123 && this.#allowDoubleQuotedTextChildAfterBrace) || // {
233
234
  prev === 125 // }
234
235
  );
235
236
  }
@@ -632,8 +633,14 @@ export function TSRXPlugin(config) {
632
633
  * @type {Parse.Parser['getTokenFromCode']}
633
634
  */
634
635
  getTokenFromCode(code) {
635
- if (code === 34 && this.#isDoubleQuotedTextChildStart()) {
636
- return this.#readDoubleQuotedTextChildToken();
636
+ if (code === 34) {
637
+ const is_double_quoted_text_child = this.#isDoubleQuotedTextChildStart();
638
+ this.#allowDoubleQuotedTextChildAfterBrace = false;
639
+ if (is_double_quoted_text_child) {
640
+ return this.#readDoubleQuotedTextChildToken();
641
+ }
642
+ } else {
643
+ this.#allowDoubleQuotedTextChildAfterBrace = false;
637
644
  }
638
645
 
639
646
  if (code !== 60) {
@@ -1058,6 +1065,9 @@ export function TSRXPlugin(config) {
1058
1065
  const parent_function_body_depth = this.#functionBodyDepth;
1059
1066
  this.#functionBodyDepth = 0;
1060
1067
 
1068
+ if (this.type === tt.braceL) {
1069
+ this.#allowDoubleQuotedTextChildAfterBrace = true;
1070
+ }
1061
1071
  this.eat(tt.braceL);
1062
1072
  node.body = [];
1063
1073
  this.#path.push(node);
@@ -2654,6 +2664,7 @@ export function TSRXPlugin(config) {
2654
2664
  if (node === void 0) node = /** @type {AST.BlockStatement} */ (this.startNode());
2655
2665
 
2656
2666
  node.body = [];
2667
+ this.#allowDoubleQuotedTextChildAfterBrace = true;
2657
2668
  this.expect(tt.braceL);
2658
2669
  if (createNewLexicalScope) {
2659
2670
  this.enterScope(0);
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Merge multiple refs (function refs and ref objects) into a single
3
+ * callback ref. Used by the tsrx-react, tsrx-preact, and tsrx-vue
4
+ * compilers when an element has more than one `ref` attribute, since
5
+ * those runtimes treat duplicate `ref` props as a regular duplicate-prop
6
+ * collision (last wins) rather than running both. Solid does not use this
7
+ * helper — its native runtime accepts an array of refs and the compiler
8
+ * emits an array literal directly.
9
+ *
10
+ * The returned callback ref handles four cases per input:
11
+ * - `null` / `undefined`: skipped.
12
+ * - function ref: invoked with the node. If it returns a function, that
13
+ * return value is treated as a React 19 cleanup. Otherwise we record a
14
+ * cleanup that calls the ref with `null` so the legacy unmount contract
15
+ * still fires.
16
+ * - React-style ref object (`{ current }`): assigned on mount, cleared
17
+ * on unmount.
18
+ * - Vue-style ref object (`{ value }`, e.g. `ref()` / `useTemplateRef()`):
19
+ * assigned on mount, cleared on unmount.
20
+ *
21
+ * The merged ref always returns a cleanup. Under React 19 the cleanup
22
+ * runs on unmount and the inner refs are not separately re-invoked with
23
+ * `null`. Under older React, Preact, and Vue the cleanup return value
24
+ * is ignored, so the runtime instead invokes the merged ref a second
25
+ * time with `null` — which re-runs the loop body, calling each inner
26
+ * with `null` and clearing each ref object. Either way every inner ref
27
+ * sees a balanced mount/unmount.
28
+ *
29
+ * @param {...((node: any) => void | (() => void)) | { current: any } | { value: any } | null | undefined} refs
30
+ * @returns {(node: any) => (() => void)}
31
+ */
32
+ export function mergeRefs(...refs) {
33
+ return (node) => {
34
+ /** @type {Array<() => void>} */
35
+ const cleanups = [];
36
+ for (const ref of refs) {
37
+ if (ref == null) continue;
38
+ if (typeof ref === 'function') {
39
+ const result = ref(node);
40
+ if (typeof result === 'function') {
41
+ cleanups.push(result);
42
+ } else {
43
+ cleanups.push(() => ref(null));
44
+ }
45
+ } else if ('current' in ref) {
46
+ ref.current = node;
47
+ cleanups.push(() => {
48
+ ref.current = null;
49
+ });
50
+ } else if ('value' in ref) {
51
+ ref.value = node;
52
+ cleanups.push(() => {
53
+ ref.value = null;
54
+ });
55
+ }
56
+ }
57
+ return () => {
58
+ for (const cleanup of cleanups) cleanup();
59
+ };
60
+ };
61
+ }
@@ -1,9 +1,10 @@
1
1
  /** @import * as AST from 'estree' */
2
2
  /** @import * as ESTreeJSX from 'estree-jsx' */
3
- /** @import { JsxPlatform, JsxTransformOptions, JsxTransformResult } from '@tsrx/core/types' */
3
+ /** @import { JsxPlatform, JsxTransformContext, JsxTransformOptions, JsxTransformResult } from '@tsrx/core/types' */
4
4
 
5
5
  import { walk } from 'zimmerframe';
6
6
  import { print } from 'esrap';
7
+ import { error } from '../../errors.js';
7
8
  import {
8
9
  ensure_function_metadata,
9
10
  in_jsx_child_context,
@@ -14,7 +15,6 @@ import {
14
15
  clone_expression_node,
15
16
  clone_identifier,
16
17
  clone_jsx_name,
17
- create_compile_error,
18
18
  create_generated_identifier,
19
19
  create_null_literal,
20
20
  flatten_switch_consequent,
@@ -26,7 +26,11 @@ import {
26
26
  to_text_expression,
27
27
  } from './ast-builders.js';
28
28
  import { render_stylesheets as renderStylesheets } from '../stylesheet.js';
29
- import { set_location as setLocation } from '../../utils/builders.js';
29
+ import {
30
+ set_location as setLocation,
31
+ jsx_attribute as build_jsx_attribute,
32
+ jsx_id as build_jsx_id,
33
+ } from '../../utils/builders.js';
30
34
  import {
31
35
  apply_lazy_transforms,
32
36
  collect_lazy_bindings_from_component,
@@ -45,17 +49,11 @@ import {
45
49
  import { is_hoist_safe_jsx_node } from '../jsx-hoist.js';
46
50
 
47
51
  /**
48
- * @typedef {{
49
- * platform: JsxPlatform,
50
- * local_statement_component_index: number,
51
- * needs_error_boundary: boolean,
52
- * needs_suspense: boolean,
53
- * helper_state: { base_name: string, next_id: number, helpers: any[], statics: any[] } | null,
54
- * available_bindings: Map<string, AST.Identifier>,
55
- * lazy_next_id: number,
56
- * current_css_hash: string | null,
57
- * inside_element_child?: boolean,
58
- * }} TransformContext
52
+ * Local alias for the shared `JsxTransformContext`. Kept as a typedef so the
53
+ * rest of this file's `@param {TransformContext}` annotations don't all have
54
+ * to spell out the import.
55
+ *
56
+ * @typedef {JsxTransformContext} TransformContext
59
57
  */
60
58
 
61
59
  /**
@@ -98,19 +96,24 @@ export function createJsxTransform(platform) {
98
96
  const stylesheets = [];
99
97
 
100
98
  /** @type {TransformContext} */
101
- const transform_context = /** @type {any} */ ({
99
+ const transform_context = {
102
100
  platform,
103
101
  local_statement_component_index: 0,
104
102
  needs_error_boundary: false,
105
103
  needs_suspense: false,
104
+ needs_merge_refs: false,
106
105
  helper_state: null,
107
106
  available_bindings: new Map(),
108
107
  lazy_next_id: 0,
109
108
  current_css_hash: null,
109
+ filename: filename ?? null,
110
+ loose: !!options?.loose,
111
+ errors: options?.loose ? options?.errors : undefined,
112
+ comments: options?.comments,
110
113
  // Platforms can seed their own tracking state (e.g. solid's
111
114
  // needs_show / needs_for flags) via `hooks.initialState`.
112
115
  ...(platform.hooks?.initialState?.() ?? {}),
113
- });
116
+ };
114
117
 
115
118
  preallocate_lazy_ids(/** @type {any} */ (ast), transform_context);
116
119
 
@@ -141,9 +144,12 @@ export function createJsxTransform(platform) {
141
144
  source,
142
145
  );
143
146
  } else if (!module_uses_server_directive) {
144
- throw create_compile_error(
145
- await_expression,
147
+ error(
146
148
  `${platform.name} components can only use \`await\` when the module has a top-level "use server" directive.`,
149
+ state.filename,
150
+ await_expression,
151
+ state.errors,
152
+ state.comments,
147
153
  );
148
154
  }
149
155
 
@@ -210,10 +216,10 @@ export function createJsxTransform(platform) {
210
216
  return /** @type {any} */ (tsx_node_to_jsx_expression(inner, in_jsx_child_context(path)));
211
217
  },
212
218
 
213
- TsxCompat(node, { next, path }) {
219
+ TsxCompat(node, { next, path, state }) {
214
220
  const inner = /** @type {any} */ (next() ?? node);
215
221
  return /** @type {any} */ (
216
- tsx_compat_node_to_jsx_expression(inner, platform, in_jsx_child_context(path))
222
+ tsx_compat_node_to_jsx_expression(inner, state, in_jsx_child_context(path))
217
223
  );
218
224
  },
219
225
 
@@ -1483,7 +1489,22 @@ const TEMPLATE_FRAGMENT_ERROR =
1483
1489
  function to_jsx_element(node, transform_context, raw_children = node.children || []) {
1484
1490
  if (node.type === 'JSXElement') return node;
1485
1491
  if (!node.id) {
1486
- throw create_compile_error(node, TEMPLATE_FRAGMENT_ERROR);
1492
+ error(
1493
+ TEMPLATE_FRAGMENT_ERROR,
1494
+ transform_context.filename,
1495
+ node,
1496
+ transform_context.errors,
1497
+ transform_context.comments,
1498
+ );
1499
+ return set_loc(
1500
+ /** @type {any} */ ({
1501
+ type: 'JSXFragment',
1502
+ openingFragment: { type: 'JSXOpeningFragment' },
1503
+ closingFragment: { type: 'JSXClosingFragment' },
1504
+ children: [],
1505
+ }),
1506
+ node,
1507
+ );
1487
1508
  }
1488
1509
  if (is_dynamic_element_id(node.id)) {
1489
1510
  return dynamic_element_to_jsx_child(node, transform_context);
@@ -2109,7 +2130,7 @@ function to_jsx_child(node, transform_context) {
2109
2130
  // JSXExpressionContainer wrapper for bare `{expr}` children.
2110
2131
  return tsx_node_to_jsx_expression(node, true);
2111
2132
  case 'TsxCompat':
2112
- return tsx_compat_node_to_jsx_expression(node, transform_context.platform, true);
2133
+ return tsx_compat_node_to_jsx_expression(node, transform_context, true);
2113
2134
  case 'Element':
2114
2135
  return to_jsx_element(node, transform_context);
2115
2136
  case 'Text':
@@ -2280,9 +2301,12 @@ function find_key_expression_in_body(body_nodes) {
2280
2301
  */
2281
2302
  function for_of_statement_to_jsx_child(node, transform_context) {
2282
2303
  if (node.await) {
2283
- throw create_compile_error(
2284
- node,
2304
+ error(
2285
2305
  `${transform_context.platform.name} TSRX does not support \`for await...of\` in component templates.`,
2306
+ transform_context.filename,
2307
+ node,
2308
+ transform_context.errors,
2309
+ transform_context.comments,
2286
2310
  );
2287
2311
  }
2288
2312
 
@@ -2458,23 +2482,33 @@ function try_statement_to_jsx_child(node, transform_context) {
2458
2482
  const finalizer = node.finalizer;
2459
2483
 
2460
2484
  if (finalizer) {
2461
- throw create_compile_error(
2462
- finalizer,
2485
+ error(
2463
2486
  `${transform_context.platform.name} TSRX does not support JavaScript \`try/finally\` in component templates. \`finally\` is not part of TSRX control flow; move the try/finally into a function if you need cleanup logic.`,
2487
+ transform_context.filename,
2488
+ finalizer,
2489
+ transform_context.errors,
2490
+ transform_context.comments,
2464
2491
  );
2465
2492
  }
2466
2493
 
2467
2494
  if (!pending && !handler) {
2468
- throw create_compile_error(
2469
- node,
2495
+ error(
2470
2496
  'Component try statements must have a `pending` or `catch` block.',
2497
+ transform_context.filename,
2498
+ node,
2499
+ transform_context.errors,
2500
+ transform_context.comments,
2471
2501
  );
2502
+ return to_jsx_expression_container(create_null_literal());
2472
2503
  }
2473
2504
 
2474
2505
  if (pending && transform_context.platform.validation.unsupportedTryPendingMessage) {
2475
- throw create_compile_error(
2476
- pending,
2506
+ error(
2477
2507
  transform_context.platform.validation.unsupportedTryPendingMessage,
2508
+ transform_context.filename,
2509
+ pending,
2510
+ transform_context.errors,
2511
+ transform_context.comments,
2478
2512
  );
2479
2513
  }
2480
2514
 
@@ -2482,16 +2516,22 @@ function try_statement_to_jsx_child(node, transform_context) {
2482
2516
  if (pending) {
2483
2517
  const try_body = node.block.body || [];
2484
2518
  if (!try_body.some(is_jsx_child)) {
2485
- throw create_compile_error(
2486
- node.block,
2519
+ error(
2487
2520
  'Component try statements must contain a template in their main body. Move the try statement into a function if it does not render anything.',
2521
+ transform_context.filename,
2522
+ node.block,
2523
+ transform_context.errors,
2524
+ transform_context.comments,
2488
2525
  );
2489
2526
  }
2490
2527
  const pending_body = pending.body || [];
2491
2528
  if (!pending_body.some(is_jsx_child)) {
2492
- throw create_compile_error(
2493
- pending,
2529
+ error(
2494
2530
  'Component try statements must contain a template in their "pending" body. Rendering a pending fallback is required to have a template.',
2531
+ transform_context.filename,
2532
+ pending,
2533
+ transform_context.errors,
2534
+ transform_context.comments,
2495
2535
  );
2496
2536
  }
2497
2537
  }
@@ -2679,10 +2719,11 @@ function create_jsx_element(tag_name, attributes, children) {
2679
2719
  }
2680
2720
 
2681
2721
  /**
2682
- * Inject import declarations for `Suspense` and `TsrxErrorBoundary` if the
2683
- * transform determined they are needed. The import sources are platform-
2684
- * specific (e.g. `react` vs `preact/compat`, `@tsrx/react/error-boundary`
2685
- * vs `@tsrx/preact/error-boundary`).
2722
+ * Inject runtime-helper import declarations the transform decided it needed
2723
+ * during the walk: `Suspense` for `try { ... } pending { ... }`,
2724
+ * `TsrxErrorBoundary` for `try { ... } catch (...)`, and `mergeRefs` for
2725
+ * elements with multiple `ref` attributes under the `'merge-refs'`
2726
+ * strategy. Import sources are platform-specific.
2686
2727
  *
2687
2728
  * @param {AST.Program} program
2688
2729
  * @param {TransformContext} transform_context
@@ -2743,6 +2784,35 @@ function inject_try_imports(program, transform_context, platform, suspense_sourc
2743
2784
  });
2744
2785
  }
2745
2786
 
2787
+ if (transform_context.needs_merge_refs && platform.imports.mergeRefs) {
2788
+ const merge_refs_source = platform.imports.mergeRefs;
2789
+ imports.push({
2790
+ type: 'ImportDeclaration',
2791
+ specifiers: [
2792
+ {
2793
+ type: 'ImportSpecifier',
2794
+ imported: {
2795
+ type: 'Identifier',
2796
+ name: 'mergeRefs',
2797
+ metadata: { path: [] },
2798
+ },
2799
+ local: {
2800
+ type: 'Identifier',
2801
+ name: MERGE_REFS_LOCAL_NAME,
2802
+ metadata: { path: [] },
2803
+ },
2804
+ metadata: { path: [] },
2805
+ },
2806
+ ],
2807
+ source: {
2808
+ type: 'Literal',
2809
+ value: merge_refs_source,
2810
+ raw: `'${merge_refs_source}'`,
2811
+ },
2812
+ metadata: { path: [] },
2813
+ });
2814
+ }
2815
+
2746
2816
  if (imports.length > 0) {
2747
2817
  program.body.unshift(...imports);
2748
2818
  }
@@ -2915,9 +2985,15 @@ function to_jsx_expression_container(expression, source_node = expression) {
2915
2985
  /**
2916
2986
  * Dispatch point for element attribute transformation. Platforms can replace
2917
2987
  * the default "map over `to_jsx_attribute`" via
2918
- * `hooks.transformElementAttributes` Solid uses this to collapse
2919
- * `<elem>{'text'}</elem>` into a `textContent` attribute and to route
2920
- * attributes through its composite-element handling.
2988
+ * `hooks.transformElementAttributes`. Whether or not the hook is used,
2989
+ * the result is run through `merge_duplicate_refs` so platforms with a
2990
+ * `multiRefStrategy` get duplicate-`ref` handling for free.
2991
+ *
2992
+ * Before lowering, the raw attribute list is validated to reject elements
2993
+ * with more than one TSX-style `ref={...}` attribute — that shape produces
2994
+ * duplicate JSX props which the JSX runtime collapses to last-wins (and
2995
+ * which TypeScript can't type cleanly). Multiple Ripple `{ref expr}`
2996
+ * keyword-form refs remain valid and merge into a single ref attribute.
2921
2997
  *
2922
2998
  * @param {any[]} attrs
2923
2999
  * @param {TransformContext} transform_context
@@ -2925,21 +3001,204 @@ function to_jsx_expression_container(expression, source_node = expression) {
2925
3001
  * @returns {any[]}
2926
3002
  */
2927
3003
  function transform_element_attributes_dispatch(attrs, transform_context, element) {
3004
+ validate_at_most_one_ref_attribute(attrs, transform_context);
2928
3005
  const preprocess = transform_context.platform.hooks?.preprocessElementAttributes;
2929
3006
  if (preprocess) {
2930
3007
  attrs = preprocess(attrs, transform_context, element);
2931
3008
  }
2932
3009
  const hook = transform_context.platform.hooks?.transformElementAttributes;
2933
- if (hook) return hook(attrs, transform_context, element);
2934
- return attrs.map((/** @type {any} */ a) => to_jsx_attribute(a, transform_context));
3010
+ const result = hook
3011
+ ? hook(attrs, transform_context, element)
3012
+ : attrs.map((/** @type {any} */ a) => to_jsx_attribute(a, transform_context));
3013
+ return merge_duplicate_refs(result, transform_context);
3014
+ }
3015
+
3016
+ /**
3017
+ * Reject elements with more than one TSX-style `ref={...}` attribute.
3018
+ * Ripple's `{ref expr}` keyword form is parsed as a `RefAttribute` node
3019
+ * and is excluded from the count — multiple keyword-form refs are a Ripple
3020
+ * feature that compose via the merge pass. This validator runs over the
3021
+ * raw, pre-lowering attribute list so each shape is still distinguishable
3022
+ * by `type`. Ripple `Element` attributes have type `Attribute` with an
3023
+ * `Identifier` name (the parser normalizes `JSXAttribute`/`JSXIdentifier`
3024
+ * for non-Tsx elements); inside `<tsx:react>` compat blocks they retain
3025
+ * the original `JSXAttribute`/`JSXIdentifier` shape, so we accept both.
3026
+ *
3027
+ * @param {any[]} raw_attrs
3028
+ * @param {TransformContext} [transform_context]
3029
+ */
3030
+ export function validate_at_most_one_ref_attribute(raw_attrs, transform_context) {
3031
+ /** @type {any[]} */
3032
+ const refs = [];
3033
+ for (const attr of raw_attrs) {
3034
+ if (!attr) continue;
3035
+ const is_ref_attr =
3036
+ (attr.type === 'Attribute' &&
3037
+ attr.name &&
3038
+ attr.name.type === 'Identifier' &&
3039
+ attr.name.name === 'ref') ||
3040
+ (attr.type === 'JSXAttribute' &&
3041
+ attr.name &&
3042
+ attr.name.type === 'JSXIdentifier' &&
3043
+ attr.name.name === 'ref');
3044
+ if (!is_ref_attr) continue;
3045
+ refs.push(attr.name);
3046
+ }
3047
+ if (refs.length < 2) {
3048
+ return;
3049
+ }
3050
+ for (let i = 0; i < refs.length; i++) {
3051
+ const node = refs[i];
3052
+ if (!transform_context?.loose && i === 0) {
3053
+ // in the non-loose mode, only throw on the second duplicate
3054
+ continue;
3055
+ }
3056
+ error(
3057
+ 'Element has multiple `ref={...}` attributes; an element may have at most one. ' +
3058
+ "Use Ripple's `{ref expr}` keyword form to combine multiple refs on one element.",
3059
+ transform_context?.filename ?? null,
3060
+ node,
3061
+ transform_context?.errors,
3062
+ transform_context?.comments,
3063
+ );
3064
+ }
3065
+ }
3066
+
3067
+ /**
3068
+ * Collapse multiple `ref` JSXAttributes on a single element into one. Both
3069
+ * Ripple's `{ref expr}` keyword form and TSX-style `ref={expr}` are handled
3070
+ * because they have already been normalized to `JSXAttribute` named `ref`
3071
+ * by `to_jsx_attribute` (Ripple) or the parser (TSX-style). The shape of
3072
+ * the merged value depends on `platform.jsx.multiRefStrategy`:
3073
+ *
3074
+ * - `'merge-refs'` — emit `ref={__mergeRefs(a, b, ...)}` and flag
3075
+ * `needs_merge_refs` so an import is injected later. React and Preact
3076
+ * need this because their runtimes dedupe duplicate `ref` props.
3077
+ * - `'array'` — emit `ref={[a, b, ...]}`. Solid's runtime iterates
3078
+ * array refs natively, so no helper is required.
3079
+ * - `undefined` — return the list unchanged. The platform takes care
3080
+ * of duplicate refs at runtime (or doesn't support them).
3081
+ *
3082
+ * Single-ref elements are always left unchanged so trivial cases stay
3083
+ * import-free and produce no helper call.
3084
+ *
3085
+ * @param {any[]} jsx_attrs
3086
+ * @param {TransformContext} transform_context
3087
+ * @returns {any[]}
3088
+ */
3089
+ export function merge_duplicate_refs(jsx_attrs, transform_context) {
3090
+ const strategy = transform_context.platform.jsx.multiRefStrategy;
3091
+ if (!strategy) return jsx_attrs;
3092
+
3093
+ let count = 0;
3094
+ let tsx_form_count = 0;
3095
+ for (const attr of jsx_attrs) {
3096
+ if (!is_jsx_ref_attribute(attr)) continue;
3097
+ count += 1;
3098
+ if (!attr.metadata?.from_ref_keyword) tsx_form_count += 1;
3099
+ }
3100
+ if (count <= 1) return jsx_attrs;
3101
+ // Two or more genuine `ref={...}` (TSX-form) attributes are already a
3102
+ // validator-flagged compile error and TypeScript flags them as duplicate
3103
+ // JSX props. Leave them in place so the user gets all three signals
3104
+ // instead of silently composing them into `__mergeRefs(...)`.
3105
+ if (tsx_form_count >= 2) return jsx_attrs;
3106
+
3107
+ /** @type {any[]} */
3108
+ const ref_exprs = [];
3109
+ /** @type {any[]} */
3110
+ const result = [];
3111
+ /** @type {any} */
3112
+ let source_attr = null;
3113
+ for (const attr of jsx_attrs) {
3114
+ if (is_jsx_ref_attribute(attr)) {
3115
+ ref_exprs.push(attr.value.expression);
3116
+ // Inherit loc from the (at most one) `ref={expr}`-form attribute so
3117
+ // the kept `ref` keyword in the generated `ref={__mergeRefs(...)}`
3118
+ // retains a source mapping back to its original `ref=` keyword.
3119
+ if (!source_attr && !attr.metadata?.from_ref_keyword) {
3120
+ source_attr = attr;
3121
+ }
3122
+ } else {
3123
+ result.push(attr);
3124
+ }
3125
+ }
3126
+
3127
+ const merged_value =
3128
+ strategy === 'merge-refs'
3129
+ ? /** @type {any} */ ({
3130
+ type: 'CallExpression',
3131
+ callee: {
3132
+ type: 'Identifier',
3133
+ name: MERGE_REFS_LOCAL_NAME,
3134
+ metadata: { path: [] },
3135
+ },
3136
+ arguments: ref_exprs,
3137
+ optional: false,
3138
+ metadata: { path: [] },
3139
+ })
3140
+ : /** @type {any} */ ({
3141
+ type: 'ArrayExpression',
3142
+ elements: ref_exprs,
3143
+ metadata: { path: [] },
3144
+ });
3145
+
3146
+ if (strategy === 'merge-refs') {
3147
+ transform_context.needs_merge_refs = true;
3148
+ }
3149
+
3150
+ // Inherit start/end/loc from the (at most one) `ref={expr}`-form attribute
3151
+ // so segments.js emits a normal source-to-generated mapping for the
3152
+ // merged attribute and its name. Without this the kept `ref` keyword in
3153
+ // `ref={__mergeRefs(...)}` has no source mapping back to the user's `ref=`
3154
+ // keyword.
3155
+ const merged_name = build_jsx_id('ref', source_attr?.name);
3156
+ const merged_attr = build_jsx_attribute(
3157
+ merged_name,
3158
+ /** @type {any} */ ({
3159
+ type: 'JSXExpressionContainer',
3160
+ expression: merged_value,
3161
+ metadata: { path: [] },
3162
+ }),
3163
+ false,
3164
+ source_attr,
3165
+ );
3166
+ result.push(merged_attr);
3167
+
3168
+ return result;
2935
3169
  }
2936
3170
 
3171
+ /**
3172
+ * @param {any} attr
3173
+ * @returns {boolean}
3174
+ */
3175
+ function is_jsx_ref_attribute(attr) {
3176
+ return (
3177
+ !!attr &&
3178
+ attr.type === 'JSXAttribute' &&
3179
+ !!attr.name &&
3180
+ attr.name.type === 'JSXIdentifier' &&
3181
+ attr.name.name === 'ref' &&
3182
+ !!attr.value &&
3183
+ attr.value.type === 'JSXExpressionContainer' &&
3184
+ !!attr.value.expression &&
3185
+ attr.value.expression.type !== 'JSXEmptyExpression'
3186
+ );
3187
+ }
3188
+
3189
+ /**
3190
+ * Local alias used for the injected `mergeRefs` import. The leading
3191
+ * double-underscore matches the convention for compiler-generated
3192
+ * identifiers and avoids shadowing user-declared `mergeRefs` symbols.
3193
+ */
3194
+ const MERGE_REFS_LOCAL_NAME = '__mergeRefs';
3195
+
2937
3196
  /**
2938
3197
  * @param {any} attr
2939
3198
  * @param {TransformContext} transform_context
2940
3199
  * @returns {ESTreeJSX.JSXAttribute | ESTreeJSX.JSXSpreadAttribute}
2941
3200
  */
2942
- function to_jsx_attribute(attr, transform_context) {
3201
+ export function to_jsx_attribute(attr, transform_context) {
2943
3202
  if (!attr) return attr;
2944
3203
  if (attr.type === 'JSXAttribute' || attr.type === 'JSXSpreadAttribute') {
2945
3204
  return attr;
@@ -2954,15 +3213,23 @@ function to_jsx_attribute(attr, transform_context) {
2954
3213
  );
2955
3214
  }
2956
3215
  if (attr.type === 'RefAttribute') {
2957
- // RefAttribute uses `{ref expr}` syntax whose source positions don't map to the
2958
- // generated `ref={expr}` JSX attribute, so we intentionally omit loc.
2959
- return /** @type {any} */ ({
2960
- type: 'JSXAttribute',
2961
- name: { type: 'JSXIdentifier', name: 'ref', metadata: { path: [] } },
2962
- value: to_jsx_expression_container(attr.argument),
2963
- shorthand: false,
2964
- metadata: { path: [] },
2965
- });
3216
+ // `{ref expr}` and the generated `ref={expr}` have different shapes,
3217
+ // so the source-to-generated mapping is imprecise but pointing
3218
+ // editors at the `{ref expr}` span is still useful for hover/jump,
3219
+ // matching how shorthand `{name}` → `name={name}` carries loc.
3220
+ // `from_ref_keyword` lets `merge_duplicate_refs` tell this form apart
3221
+ // from genuine `ref={...}` attributes without inferring it from
3222
+ // whether `name.loc` happens to be present.
3223
+ return set_loc(
3224
+ /** @type {any} */ ({
3225
+ type: 'JSXAttribute',
3226
+ name: { type: 'JSXIdentifier', name: 'ref', metadata: { path: [] } },
3227
+ value: to_jsx_expression_container(attr.argument),
3228
+ shorthand: false,
3229
+ metadata: { path: [], from_ref_keyword: true },
3230
+ }),
3231
+ attr,
3232
+ );
2966
3233
  }
2967
3234
 
2968
3235
  // Platforms that expect React-style DOM attrs (React) rewrite `class` to
@@ -2992,13 +3259,7 @@ function to_jsx_attribute(attr, transform_context) {
2992
3259
  }
2993
3260
  }
2994
3261
 
2995
- const jsx_attribute = /** @type {any} */ ({
2996
- type: 'JSXAttribute',
2997
- name,
2998
- value: value || null,
2999
- shorthand: false,
3000
- metadata: { path: [] },
3001
- });
3262
+ const jsx_attribute = build_jsx_attribute(name, value || null, attr.shorthand === true);
3002
3263
 
3003
3264
  if (value_has_unmappable_jsx_loc(value)) {
3004
3265
  /** @type {any} */ (jsx_attribute.metadata).has_unmappable_value = true;
@@ -3163,16 +3424,20 @@ function build_return_expression(render_nodes) {
3163
3424
 
3164
3425
  /**
3165
3426
  * @param {any} node
3166
- * @param {JsxPlatform} platform
3427
+ * @param {TransformContext} transform_context
3167
3428
  * @param {boolean} [in_jsx_child]
3168
3429
  * @returns {any}
3169
3430
  */
3170
- function tsx_compat_node_to_jsx_expression(node, platform, in_jsx_child = false) {
3431
+ function tsx_compat_node_to_jsx_expression(node, transform_context, in_jsx_child = false) {
3432
+ const platform = transform_context.platform;
3171
3433
  if (!platform.jsx.acceptedTsxKinds.includes(node.kind)) {
3172
3434
  const accepted = platform.jsx.acceptedTsxKinds.map((k) => `<tsx:${k}>`).join(', ');
3173
- throw create_compile_error(
3174
- node,
3435
+ error(
3175
3436
  `${platform.name} TSRX does not support <tsx:${node.kind}> blocks. Use <tsx> or one of: ${accepted}.`,
3437
+ transform_context.filename,
3438
+ node,
3439
+ transform_context.errors,
3440
+ transform_context.comments,
3176
3441
  );
3177
3442
  }
3178
3443
 
package/types/index.d.ts CHANGED
@@ -8,13 +8,21 @@ import type { RequireAllOrNone } from '../src/helpers.js';
8
8
  import type {
9
9
  JsxPlatform,
10
10
  JsxPlatformHooks,
11
+ JsxTransformContext,
11
12
  JsxTransformOptions,
12
13
  JsxTransformResult,
13
14
  componentToFunctionDeclaration,
14
15
  createJsxTransform,
15
16
  } from './jsx-platform';
16
17
 
17
- export type { Parse, JsxPlatform, JsxPlatformHooks, JsxTransformOptions, JsxTransformResult };
18
+ export type {
19
+ Parse,
20
+ JsxPlatform,
21
+ JsxPlatformHooks,
22
+ JsxTransformContext,
23
+ JsxTransformOptions,
24
+ JsxTransformResult,
25
+ };
18
26
  export { createJsxTransform, componentToFunctionDeclaration };
19
27
 
20
28
  /**
@@ -1,5 +1,6 @@
1
1
  import type * as AST from 'estree';
2
2
  import type { RawSourceMap } from 'source-map';
3
+ import type { CompileError } from './index';
3
4
 
4
5
  /**
5
6
  * Result returned by a JSX platform transform (React, Preact, Solid).
@@ -16,6 +17,38 @@ export interface JsxTransformResult {
16
17
  css: { code: string; hash: string } | null;
17
18
  }
18
19
 
20
+ /**
21
+ * Shared base for the per-call transform context that the JSX factory passes
22
+ * into every visitor and helper. Platform-specific transforms (e.g. Solid)
23
+ * extend this with their own `needs_*` flags via `hooks.initialState`; helpers
24
+ * defined in `@tsrx/core` only ever rely on these base fields.
25
+ */
26
+ export interface JsxTransformContext {
27
+ platform: JsxPlatform;
28
+ local_statement_component_index: number;
29
+ needs_error_boundary: boolean;
30
+ needs_suspense: boolean;
31
+ needs_merge_refs: boolean;
32
+ helper_state: {
33
+ base_name: string;
34
+ next_id: number;
35
+ helpers: any[];
36
+ statics: any[];
37
+ } | null;
38
+ available_bindings: Map<string, AST.Identifier>;
39
+ lazy_next_id: number;
40
+ current_css_hash: string | null;
41
+ inside_element_child?: boolean;
42
+ /** Source filename for diagnostics; null when the caller did not supply one. */
43
+ filename: string | null;
44
+ /** True when recoverable errors should be collected onto `errors` instead of thrown. */
45
+ loose: boolean;
46
+ /** Collected non-fatal errors. Undefined when `loose` is false. */
47
+ errors: CompileError[] | undefined;
48
+ /** Module-level comments used to honor `@tsrx-ignore` / `@tsrx-expect-error`. */
49
+ comments: AST.CommentWithLocation[] | undefined;
50
+ }
51
+
19
52
  /**
20
53
  * Optional per-call compile options passed to a created JSX transform.
21
54
  */
@@ -26,6 +59,22 @@ export interface JsxTransformOptions {
26
59
  * host pick `preact/compat` vs. another compat entry point.
27
60
  */
28
61
  suspenseSource?: string;
62
+ /**
63
+ * When true, recoverable transform errors are pushed onto `errors` instead
64
+ * of thrown so editor tooling can surface them as diagnostics. Errors that
65
+ * leave the transform in an unrecoverable state are still thrown.
66
+ */
67
+ loose?: boolean;
68
+ /**
69
+ * Collected non-fatal errors. The transform appends to this array when
70
+ * `loose` is true; callers read it after the transform returns.
71
+ */
72
+ errors?: CompileError[];
73
+ /**
74
+ * Module-level comments used to suppress diagnostics via `@tsrx-ignore` /
75
+ * `@tsrx-expect-error` line comments.
76
+ */
77
+ comments?: AST.CommentWithLocation[];
29
78
  }
30
79
 
31
80
  /**
@@ -91,8 +140,10 @@ export interface JsxPlatformHooks {
91
140
  injectImports?: (program: AST.Program, ctx: any, suspenseSource: string) => void;
92
141
  /**
93
142
  * Transform a Ripple element's attributes to JSX attributes. Default
94
- * is "map over `to_jsx_attribute`". Solid replaces this to route
95
- * attributes through its composite-element handling.
143
+ * is "map over `to_jsx_attribute`" plus the shared multi-`ref` merge
144
+ * pass. Platforms that own a `transformElement` hook (e.g. Solid) bypass
145
+ * this entirely — they never reach the dispatch path that would call
146
+ * it — and run their own attribute pass inside their `transformElement`.
96
147
  */
97
148
  transformElementAttributes?: (attrs: any[], ctx: any, element: any) => any[];
98
149
  /**
@@ -194,6 +245,13 @@ export interface JsxPlatform {
194
245
  * block appears. Usually `'@tsrx/<platform>/error-boundary'`.
195
246
  */
196
247
  errorBoundary: string;
248
+ /**
249
+ * Module to import `mergeRefs` from when an element has more than one
250
+ * `ref` attribute and the platform uses the `'merge-refs'` strategy.
251
+ * Required when `jsx.multiRefStrategy === 'merge-refs'`; ignored
252
+ * otherwise. React: `'@tsrx/react/merge-refs'`. Preact: `'@tsrx/preact/merge-refs'`.
253
+ */
254
+ mergeRefs?: string;
197
255
  };
198
256
 
199
257
  jsx: {
@@ -207,6 +265,20 @@ export interface JsxPlatform {
207
265
  * only `'react'`. Preact accepts both `'preact'` and `'react'`.
208
266
  */
209
267
  acceptedTsxKinds: readonly string[];
268
+ /**
269
+ * How to collapse multiple `ref` attributes on the same element into
270
+ * one. React's and Preact's runtimes treat duplicate `ref` props as
271
+ * a normal duplicate-prop collision (last wins), so they need a
272
+ * compile-time merge. Solid's runtime accepts an array of refs
273
+ * natively, so it can use the cheaper array form.
274
+ *
275
+ * - `'merge-refs'`: emit `ref={mergeRefs(a, b, ...)}` and inject an
276
+ * import from `imports.mergeRefs`.
277
+ * - `'array'`: emit `ref={[a, b, ...]}`. No runtime helper needed.
278
+ * - `undefined`: no merging — duplicate `ref` attributes pass through
279
+ * unchanged. The platform's runtime is responsible.
280
+ */
281
+ multiRefStrategy?: 'merge-refs' | 'array';
210
282
  };
211
283
 
212
284
  validation: {
@@ -0,0 +1,12 @@
1
+ export type MergeableRefCallback<T> = (node: T | null) => void | (() => void);
2
+ export type MergeableRefObject<T> = { current: T | null };
3
+ export type MergeableVueRef<T> = { value: T | null };
4
+
5
+ export type MergeableRef<T> =
6
+ | MergeableRefCallback<T>
7
+ | MergeableRefObject<T>
8
+ | MergeableVueRef<T>
9
+ | null
10
+ | undefined;
11
+
12
+ export function mergeRefs<T = any>(...refs: Array<MergeableRef<T>>): (node: T | null) => () => void;