@tsrx/core 0.0.16 → 0.0.18

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.16",
6
+ "version": "0.0.18",
7
7
  "type": "module",
8
8
  "repository": {
9
9
  "type": "git",
@@ -27,6 +27,9 @@
27
27
  "./types/acorn": {
28
28
  "types": "./types/acorn.d.ts"
29
29
  },
30
+ "./runtime/merge-refs": {
31
+ "default": "./src/runtime/merge-refs.js"
32
+ },
30
33
  "./test-harness/source-mappings": "./tests/shared/source-mappings.js",
31
34
  "./test-harness/compile": "./tests/shared/compile.js"
32
35
  },
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} */
@@ -196,6 +197,79 @@ export function TSRXPlugin(config) {
196
197
  this.#filename = tsrx_options?.filename || null;
197
198
  }
198
199
 
200
+ #previousNonWhitespaceChar() {
201
+ let index = this.pos - 1;
202
+ while (index >= 0) {
203
+ const ch = this.input.charCodeAt(index);
204
+ if (ch !== 32 && ch !== 9 && ch !== 10 && ch !== 13) {
205
+ return ch;
206
+ }
207
+ index--;
208
+ }
209
+ return null;
210
+ }
211
+
212
+ #isDoubleQuotedTextChildStart() {
213
+ if (this.#path.findLast((n) => n.type === 'TsxCompat' || n.type === 'Tsx')) {
214
+ return false;
215
+ }
216
+
217
+ const parent = this.#path.at(-1);
218
+ if (!parent || (parent.type !== 'Component' && parent.type !== 'Element')) {
219
+ return false;
220
+ }
221
+
222
+ const context = this.curContext();
223
+ if (context === tstc.tc_oTag || context === tstc.tc_cTag) {
224
+ return false;
225
+ }
226
+
227
+ const prev = this.#previousNonWhitespaceChar();
228
+ return (
229
+ prev === null ||
230
+ prev === 34 || // "
231
+ prev === 59 || // ;
232
+ prev === 62 || // >
233
+ (prev === 123 && this.#allowDoubleQuotedTextChildAfterBrace) || // {
234
+ prev === 125 // }
235
+ );
236
+ }
237
+
238
+ #readDoubleQuotedTextChildToken() {
239
+ const start = this.pos;
240
+ let out = '';
241
+ this.pos++;
242
+ let chunkStart = this.pos;
243
+
244
+ while (this.pos < this.input.length) {
245
+ const ch = this.input.charCodeAt(this.pos);
246
+
247
+ if (ch === 34 /* " */) {
248
+ out += this.input.slice(chunkStart, this.pos);
249
+ this.pos++;
250
+ return this.finishToken(tt.string, out);
251
+ }
252
+
253
+ if (ch === 38 /* & */) {
254
+ out += this.input.slice(chunkStart, this.pos);
255
+ out += this.jsx_readEntity();
256
+ chunkStart = this.pos;
257
+ continue;
258
+ }
259
+
260
+ if (acorn.isNewLine(ch)) {
261
+ out += this.input.slice(chunkStart, this.pos);
262
+ out += this.jsx_readNewLine(true);
263
+ chunkStart = this.pos;
264
+ continue;
265
+ }
266
+
267
+ this.pos++;
268
+ }
269
+
270
+ this.raise(start, 'Unterminated double-quoted text child');
271
+ }
272
+
199
273
  /**
200
274
  * @param {number} position
201
275
  * @param {number} end
@@ -559,6 +633,16 @@ export function TSRXPlugin(config) {
559
633
  * @type {Parse.Parser['getTokenFromCode']}
560
634
  */
561
635
  getTokenFromCode(code) {
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;
644
+ }
645
+
562
646
  if (code !== 60) {
563
647
  this.#allowTagStartAfterDoubleQuotedText = false;
564
648
  }
@@ -981,6 +1065,9 @@ export function TSRXPlugin(config) {
981
1065
  const parent_function_body_depth = this.#functionBodyDepth;
982
1066
  this.#functionBodyDepth = 0;
983
1067
 
1068
+ if (this.type === tt.braceL) {
1069
+ this.#allowDoubleQuotedTextChildAfterBrace = true;
1070
+ }
984
1071
  this.eat(tt.braceL);
985
1072
  node.body = [];
986
1073
  this.#path.push(node);
@@ -1318,12 +1405,12 @@ export function TSRXPlugin(config) {
1318
1405
  parseDoubleQuotedTextChild() {
1319
1406
  const node = /** @type {AST.TextNode} */ (this.startNode());
1320
1407
  const expression = /** @type {AST.Literal} */ (this.startNode());
1321
- const raw = this.input.slice(this.start, this.end);
1408
+ node.raw = this.input.slice(this.start, this.end);
1322
1409
  const end = this.end;
1323
1410
  const endLoc = this.endLoc;
1324
1411
 
1325
1412
  expression.value = this.value;
1326
- expression.raw = raw;
1413
+ expression.raw = JSON.stringify(this.value);
1327
1414
  node.expression = this.finishNodeAt(expression, 'Literal', end, endLoc);
1328
1415
 
1329
1416
  this.#allowTagStartAfterDoubleQuotedText = true;
@@ -2577,6 +2664,7 @@ export function TSRXPlugin(config) {
2577
2664
  if (node === void 0) node = /** @type {AST.BlockStatement} */ (this.startNode());
2578
2665
 
2579
2666
  node.body = [];
2667
+ this.#allowDoubleQuotedTextChildAfterBrace = true;
2580
2668
  this.expect(tt.braceL);
2581
2669
  if (createNewLexicalScope) {
2582
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
+ }
@@ -50,6 +50,7 @@ import { is_hoist_safe_jsx_node } from '../jsx-hoist.js';
50
50
  * local_statement_component_index: number,
51
51
  * needs_error_boundary: boolean,
52
52
  * needs_suspense: boolean,
53
+ * needs_merge_refs: boolean,
53
54
  * helper_state: { base_name: string, next_id: number, helpers: any[], statics: any[] } | null,
54
55
  * available_bindings: Map<string, AST.Identifier>,
55
56
  * lazy_next_id: number,
@@ -103,6 +104,7 @@ export function createJsxTransform(platform) {
103
104
  local_statement_component_index: 0,
104
105
  needs_error_boundary: false,
105
106
  needs_suspense: false,
107
+ needs_merge_refs: false,
106
108
  helper_state: null,
107
109
  available_bindings: new Map(),
108
110
  lazy_next_id: 0,
@@ -2679,10 +2681,11 @@ function create_jsx_element(tag_name, attributes, children) {
2679
2681
  }
2680
2682
 
2681
2683
  /**
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`).
2684
+ * Inject runtime-helper import declarations the transform decided it needed
2685
+ * during the walk: `Suspense` for `try { ... } pending { ... }`,
2686
+ * `TsrxErrorBoundary` for `try { ... } catch (...)`, and `mergeRefs` for
2687
+ * elements with multiple `ref` attributes under the `'merge-refs'`
2688
+ * strategy. Import sources are platform-specific.
2686
2689
  *
2687
2690
  * @param {AST.Program} program
2688
2691
  * @param {TransformContext} transform_context
@@ -2743,6 +2746,35 @@ function inject_try_imports(program, transform_context, platform, suspense_sourc
2743
2746
  });
2744
2747
  }
2745
2748
 
2749
+ if (transform_context.needs_merge_refs && platform.imports.mergeRefs) {
2750
+ const merge_refs_source = platform.imports.mergeRefs;
2751
+ imports.push({
2752
+ type: 'ImportDeclaration',
2753
+ specifiers: [
2754
+ {
2755
+ type: 'ImportSpecifier',
2756
+ imported: {
2757
+ type: 'Identifier',
2758
+ name: 'mergeRefs',
2759
+ metadata: { path: [] },
2760
+ },
2761
+ local: {
2762
+ type: 'Identifier',
2763
+ name: MERGE_REFS_LOCAL_NAME,
2764
+ metadata: { path: [] },
2765
+ },
2766
+ metadata: { path: [] },
2767
+ },
2768
+ ],
2769
+ source: {
2770
+ type: 'Literal',
2771
+ value: merge_refs_source,
2772
+ raw: `'${merge_refs_source}'`,
2773
+ },
2774
+ metadata: { path: [] },
2775
+ });
2776
+ }
2777
+
2746
2778
  if (imports.length > 0) {
2747
2779
  program.body.unshift(...imports);
2748
2780
  }
@@ -2915,9 +2947,15 @@ function to_jsx_expression_container(expression, source_node = expression) {
2915
2947
  /**
2916
2948
  * Dispatch point for element attribute transformation. Platforms can replace
2917
2949
  * 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.
2950
+ * `hooks.transformElementAttributes`. Whether or not the hook is used,
2951
+ * the result is run through `merge_duplicate_refs` so platforms with a
2952
+ * `multiRefStrategy` get duplicate-`ref` handling for free.
2953
+ *
2954
+ * Before lowering, the raw attribute list is validated to reject elements
2955
+ * with more than one TSX-style `ref={...}` attribute — that shape produces
2956
+ * duplicate JSX props which the JSX runtime collapses to last-wins (and
2957
+ * which TypeScript can't type cleanly). Multiple Ripple `{ref expr}`
2958
+ * keyword-form refs remain valid and merge into a single ref attribute.
2921
2959
  *
2922
2960
  * @param {any[]} attrs
2923
2961
  * @param {TransformContext} transform_context
@@ -2925,21 +2963,175 @@ function to_jsx_expression_container(expression, source_node = expression) {
2925
2963
  * @returns {any[]}
2926
2964
  */
2927
2965
  function transform_element_attributes_dispatch(attrs, transform_context, element) {
2966
+ validate_at_most_one_ref_attribute(attrs);
2928
2967
  const preprocess = transform_context.platform.hooks?.preprocessElementAttributes;
2929
2968
  if (preprocess) {
2930
2969
  attrs = preprocess(attrs, transform_context, element);
2931
2970
  }
2932
2971
  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));
2972
+ const result = hook
2973
+ ? hook(attrs, transform_context, element)
2974
+ : attrs.map((/** @type {any} */ a) => to_jsx_attribute(a, transform_context));
2975
+ return merge_duplicate_refs(result, transform_context);
2976
+ }
2977
+
2978
+ /**
2979
+ * Reject elements with more than one TSX-style `ref={...}` attribute.
2980
+ * Ripple's `{ref expr}` keyword form is parsed as a `RefAttribute` node
2981
+ * and is excluded from the count — multiple keyword-form refs are a Ripple
2982
+ * feature that compose via the merge pass. This validator runs over the
2983
+ * raw, pre-lowering attribute list so each shape is still distinguishable
2984
+ * by `type`. Ripple `Element` attributes have type `Attribute` with an
2985
+ * `Identifier` name (the parser normalizes `JSXAttribute`/`JSXIdentifier`
2986
+ * for non-Tsx elements); inside `<tsx:react>` compat blocks they retain
2987
+ * the original `JSXAttribute`/`JSXIdentifier` shape, so we accept both.
2988
+ *
2989
+ * @param {any[]} raw_attrs
2990
+ */
2991
+ export function validate_at_most_one_ref_attribute(raw_attrs) {
2992
+ let first = null;
2993
+ for (const attr of raw_attrs) {
2994
+ if (!attr) continue;
2995
+ const is_ref_attr =
2996
+ (attr.type === 'Attribute' &&
2997
+ attr.name &&
2998
+ attr.name.type === 'Identifier' &&
2999
+ attr.name.name === 'ref') ||
3000
+ (attr.type === 'JSXAttribute' &&
3001
+ attr.name &&
3002
+ attr.name.type === 'JSXIdentifier' &&
3003
+ attr.name.name === 'ref');
3004
+ if (!is_ref_attr) continue;
3005
+ if (first) {
3006
+ throw create_compile_error(
3007
+ attr,
3008
+ 'Element has multiple `ref={...}` attributes; an element may have at most one. ' +
3009
+ "Use Ripple's `{ref expr}` keyword form to combine multiple refs on one element.",
3010
+ );
3011
+ }
3012
+ first = attr;
3013
+ }
3014
+ }
3015
+
3016
+ /**
3017
+ * Collapse multiple `ref` JSXAttributes on a single element into one. Both
3018
+ * Ripple's `{ref expr}` keyword form and TSX-style `ref={expr}` are handled
3019
+ * because they have already been normalized to `JSXAttribute` named `ref`
3020
+ * by `to_jsx_attribute` (Ripple) or the parser (TSX-style). The shape of
3021
+ * the merged value depends on `platform.jsx.multiRefStrategy`:
3022
+ *
3023
+ * - `'merge-refs'` — emit `ref={__mergeRefs(a, b, ...)}` and flag
3024
+ * `needs_merge_refs` so an import is injected later. React and Preact
3025
+ * need this because their runtimes dedupe duplicate `ref` props.
3026
+ * - `'array'` — emit `ref={[a, b, ...]}`. Solid's runtime iterates
3027
+ * array refs natively, so no helper is required.
3028
+ * - `undefined` — return the list unchanged. The platform takes care
3029
+ * of duplicate refs at runtime (or doesn't support them).
3030
+ *
3031
+ * Single-ref elements are always left unchanged so trivial cases stay
3032
+ * import-free and produce no helper call.
3033
+ *
3034
+ * @param {any[]} jsx_attrs
3035
+ * @param {TransformContext} transform_context
3036
+ * @returns {any[]}
3037
+ */
3038
+ export function merge_duplicate_refs(jsx_attrs, transform_context) {
3039
+ const strategy = transform_context.platform.jsx.multiRefStrategy;
3040
+ if (!strategy) return jsx_attrs;
3041
+
3042
+ let count = 0;
3043
+ for (const attr of jsx_attrs) {
3044
+ if (is_jsx_ref_attribute(attr)) count += 1;
3045
+ }
3046
+ if (count <= 1) return jsx_attrs;
3047
+
3048
+ /** @type {any[]} */
3049
+ const ref_exprs = [];
3050
+ /** @type {any[]} */
3051
+ const result = [];
3052
+ for (const attr of jsx_attrs) {
3053
+ if (is_jsx_ref_attribute(attr)) {
3054
+ ref_exprs.push(attr.value.expression);
3055
+ } else {
3056
+ result.push(attr);
3057
+ }
3058
+ }
3059
+
3060
+ const merged_value =
3061
+ strategy === 'merge-refs'
3062
+ ? /** @type {any} */ ({
3063
+ type: 'CallExpression',
3064
+ callee: {
3065
+ type: 'Identifier',
3066
+ name: MERGE_REFS_LOCAL_NAME,
3067
+ metadata: { path: [] },
3068
+ },
3069
+ arguments: ref_exprs,
3070
+ optional: false,
3071
+ metadata: { path: [] },
3072
+ })
3073
+ : /** @type {any} */ ({
3074
+ type: 'ArrayExpression',
3075
+ elements: ref_exprs,
3076
+ metadata: { path: [] },
3077
+ });
3078
+
3079
+ if (strategy === 'merge-refs') {
3080
+ transform_context.needs_merge_refs = true;
3081
+ }
3082
+
3083
+ // The merged ref attribute is a synthesis of multiple input refs and
3084
+ // has no single source position to map back to, so we omit `loc` for
3085
+ // the same reason `to_jsx_attribute` does for `RefAttribute`-derived
3086
+ // JSX attributes.
3087
+ result.push(
3088
+ /** @type {any} */ ({
3089
+ type: 'JSXAttribute',
3090
+ name: { type: 'JSXIdentifier', name: 'ref', metadata: { path: [] } },
3091
+ value: {
3092
+ type: 'JSXExpressionContainer',
3093
+ expression: merged_value,
3094
+ metadata: { path: [] },
3095
+ },
3096
+ shorthand: false,
3097
+ metadata: { path: [] },
3098
+ }),
3099
+ );
3100
+
3101
+ return result;
2935
3102
  }
2936
3103
 
3104
+ /**
3105
+ * @param {any} attr
3106
+ * @returns {boolean}
3107
+ */
3108
+ function is_jsx_ref_attribute(attr) {
3109
+ return (
3110
+ !!attr &&
3111
+ attr.type === 'JSXAttribute' &&
3112
+ !!attr.name &&
3113
+ attr.name.type === 'JSXIdentifier' &&
3114
+ attr.name.name === 'ref' &&
3115
+ !!attr.value &&
3116
+ attr.value.type === 'JSXExpressionContainer' &&
3117
+ !!attr.value.expression &&
3118
+ attr.value.expression.type !== 'JSXEmptyExpression'
3119
+ );
3120
+ }
3121
+
3122
+ /**
3123
+ * Local alias used for the injected `mergeRefs` import. The leading
3124
+ * double-underscore matches the convention for compiler-generated
3125
+ * identifiers and avoids shadowing user-declared `mergeRefs` symbols.
3126
+ */
3127
+ const MERGE_REFS_LOCAL_NAME = '__mergeRefs';
3128
+
2937
3129
  /**
2938
3130
  * @param {any} attr
2939
3131
  * @param {TransformContext} transform_context
2940
3132
  * @returns {ESTreeJSX.JSXAttribute | ESTreeJSX.JSXSpreadAttribute}
2941
3133
  */
2942
- function to_jsx_attribute(attr, transform_context) {
3134
+ export function to_jsx_attribute(attr, transform_context) {
2943
3135
  if (!attr) return attr;
2944
3136
  if (attr.type === 'JSXAttribute' || attr.type === 'JSXSpreadAttribute') {
2945
3137
  return attr;
@@ -2954,15 +3146,20 @@ function to_jsx_attribute(attr, transform_context) {
2954
3146
  );
2955
3147
  }
2956
3148
  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
- });
3149
+ // `{ref expr}` and the generated `ref={expr}` have different shapes,
3150
+ // so the source-to-generated mapping is imprecise but pointing
3151
+ // editors at the `{ref expr}` span is still useful for hover/jump,
3152
+ // matching how shorthand `{name}` → `name={name}` carries loc.
3153
+ return set_loc(
3154
+ /** @type {any} */ ({
3155
+ type: 'JSXAttribute',
3156
+ name: { type: 'JSXIdentifier', name: 'ref', metadata: { path: [] } },
3157
+ value: to_jsx_expression_container(attr.argument),
3158
+ shorthand: false,
3159
+ metadata: { path: [] },
3160
+ }),
3161
+ attr,
3162
+ );
2966
3163
  }
2967
3164
 
2968
3165
  // Platforms that expect React-style DOM attrs (React) rewrite `class` to
package/types/index.d.ts CHANGED
@@ -398,6 +398,7 @@ declare module 'estree' {
398
398
  export interface TextNode extends AST.BaseExpression {
399
399
  type: 'Text';
400
400
  expression: AST.Expression;
401
+ raw?: string;
401
402
  loc?: AST.SourceLocation;
402
403
  }
403
404
 
@@ -91,8 +91,10 @@ export interface JsxPlatformHooks {
91
91
  injectImports?: (program: AST.Program, ctx: any, suspenseSource: string) => void;
92
92
  /**
93
93
  * 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.
94
+ * is "map over `to_jsx_attribute`" plus the shared multi-`ref` merge
95
+ * pass. Platforms that own a `transformElement` hook (e.g. Solid) bypass
96
+ * this entirely — they never reach the dispatch path that would call
97
+ * it — and run their own attribute pass inside their `transformElement`.
96
98
  */
97
99
  transformElementAttributes?: (attrs: any[], ctx: any, element: any) => any[];
98
100
  /**
@@ -194,6 +196,13 @@ export interface JsxPlatform {
194
196
  * block appears. Usually `'@tsrx/<platform>/error-boundary'`.
195
197
  */
196
198
  errorBoundary: string;
199
+ /**
200
+ * Module to import `mergeRefs` from when an element has more than one
201
+ * `ref` attribute and the platform uses the `'merge-refs'` strategy.
202
+ * Required when `jsx.multiRefStrategy === 'merge-refs'`; ignored
203
+ * otherwise. React: `'@tsrx/react/merge-refs'`. Preact: `'@tsrx/preact/merge-refs'`.
204
+ */
205
+ mergeRefs?: string;
197
206
  };
198
207
 
199
208
  jsx: {
@@ -207,6 +216,20 @@ export interface JsxPlatform {
207
216
  * only `'react'`. Preact accepts both `'preact'` and `'react'`.
208
217
  */
209
218
  acceptedTsxKinds: readonly string[];
219
+ /**
220
+ * How to collapse multiple `ref` attributes on the same element into
221
+ * one. React's and Preact's runtimes treat duplicate `ref` props as
222
+ * a normal duplicate-prop collision (last wins), so they need a
223
+ * compile-time merge. Solid's runtime accepts an array of refs
224
+ * natively, so it can use the cheaper array form.
225
+ *
226
+ * - `'merge-refs'`: emit `ref={mergeRefs(a, b, ...)}` and inject an
227
+ * import from `imports.mergeRefs`.
228
+ * - `'array'`: emit `ref={[a, b, ...]}`. No runtime helper needed.
229
+ * - `undefined`: no merging — duplicate `ref` attributes pass through
230
+ * unchanged. The platform's runtime is responsible.
231
+ */
232
+ multiRefStrategy?: 'merge-refs' | 'array';
210
233
  };
211
234
 
212
235
  validation: {