@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 +5 -1
- package/src/index.js +3 -0
- package/src/plugin.js +14 -3
- package/src/runtime/merge-refs.js +61 -0
- package/src/transform/jsx/index.js +329 -64
- package/types/index.d.ts +9 -1
- package/types/jsx-platform.d.ts +74 -2
- package/types/runtime/merge-refs.d.ts +12 -0
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.
|
|
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
|
|
636
|
-
|
|
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 {
|
|
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
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2683
|
-
*
|
|
2684
|
-
*
|
|
2685
|
-
*
|
|
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
|
|
2919
|
-
*
|
|
2920
|
-
*
|
|
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
|
-
|
|
2934
|
-
|
|
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
|
-
//
|
|
2958
|
-
//
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
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 =
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
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
|
/**
|
package/types/jsx-platform.d.ts
CHANGED
|
@@ -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`"
|
|
95
|
-
*
|
|
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;
|