Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/ref-array-lval-assignment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@solidjs/babel-plugin": patch
"@solidjs/compiler": patch
---

Assign bare variable refs inside `ref` arrays. `ref={[el]}` type-checked and
compiled but `el` was never assigned: the array branch passed the array
straight through, and the runtime only *calls* ref-array entries (a bare
variable evaluates to `undefined` at mount, so it was silently skipped).
Bare identifiers and member expressions inside a ref array are now lowered
to assignment callbacks — read the target once, call it with the element
when it holds a function, otherwise assign the element — matching the
non-array `ref={el}` contract. Nested arrays are recursed, callback refs
and const/module refs pass through untouched, and falsy slots
(`null`/`undefined`/`false`) keep short-circuiting.
8 changes: 7 additions & 1 deletion packages/babel-plugin/src/dom/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ import {
inlineCallExpression,
hasStaticMarker,
canChildSlotAllocateIds,
isFunctionShapedHole
isFunctionShapedHole,
transformRefArrayLiteral
} from "../shared/utils";
import { transformNode } from "../shared/transform";
import { InlineElements, BlockElements } from "./constants";
Expand Down Expand Up @@ -1003,6 +1004,11 @@ function transformAttributes(
t.isFunction(value.expression) ||
t.isArrayExpression(value.expression)
) {
if (t.isArrayExpression(value.expression)) {
// Bare lval elements (e.g. `ref={[elRef]}`) become callback refs
// so the variable actually gets assigned (#3285).
transformRefArrayLiteral(path.scope, value.expression);
}
results.exprs.unshift(
t.expressionStatement(
t.callExpression(
Expand Down
8 changes: 7 additions & 1 deletion packages/babel-plugin/src/shared/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import {
filterChildren,
trimWhitespace,
transformCondition,
convertJSXIdentifier
convertJSXIdentifier,
transformRefArrayLiteral
} from "./utils";
import { transformNode, getCreateTemplate } from "./transform";
import type { PluginConfig } from "../config";
Expand Down Expand Up @@ -212,6 +213,11 @@ export default function transformComponent(
t.isFunction(value.expression) ||
t.isArrayExpression(value.expression)
) {
if (t.isArrayExpression(value.expression)) {
// Bare lval elements (e.g. `ref={[elRef]}`) become callback refs
// so the variable actually gets assigned (#3285).
transformRefArrayLiteral(path.scope, value.expression);
}
runningObject.push(
t.objectProperty(t.identifier("ref"), value.expression as t.Expression)
);
Expand Down
72 changes: 71 additions & 1 deletion packages/babel-plugin/src/shared/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as t from "@babel/types";
import { addNamed } from "@babel/helper-module-imports";
import { DOMWithState } from "../../../web/src/constants.js";
import type { NodePath, Visitor } from "@babel/traverse";
import type { NodePath, Scope, Visitor } from "@babel/traverse";
import type { PluginConfig, RendererConfig } from "../config";
import type {
BabelHubWithMetadata,
Expand Down Expand Up @@ -569,6 +569,76 @@ export function canNativeSpread(
return true;
}

/**
* Rewrite bare assignment targets inside a `ref` array literal into callback
* refs, so `ref={[elRef]}` assigns the element just like `ref={elRef}` does
* (#3285). Nested arrays are recursed because the runtime flattens ref arrays;
* call expressions and const/module bindings (a function ref passed by
* reference, e.g. `ref={[callbackRef]}`) are left untouched.
*
* Mutable bindings and member targets keep the same runtime contract as the
* non-array lval path: the current value is read once — when it is a function
* it is invoked with the element, otherwise the element is assigned back.
* Mutates the array expression in place.
*/
export function transformRefArrayLiteral(scope: Scope, array: t.ArrayExpression): void {
for (let i = 0; i < array.elements.length; i++) {
let element = array.elements[i];
if (element === null || t.isSpreadElement(element)) continue;
if (t.isArrayExpression(element)) {
transformRefArrayLiteral(scope, element);
continue;
}
// Normalize TS non-null / as / satisfies wrappers
while (
t.isTSNonNullExpression(element) ||
t.isTSAsExpression(element) ||
t.isTSSatisfiesExpression(element)
) {
element = (element as t.TSNonNullExpression | t.TSAsExpression | t.TSSatisfiesExpression)
.expression;
}
let target: t.LVal | null = null;
if (t.isIdentifier(element)) {
// Only declared, mutable bindings can be assignment targets.
// const/module bindings are function refs passed by reference, and
// globals (`undefined`, `NaN`, ...) are not assignable at all — leave
// both untouched so falsy ref-array slots keep short-circuiting.
const binding = scope.getBinding(element.name);
if (!binding || binding.kind === "const" || binding.kind === "module") continue;
target = element;
} else if (t.isMemberExpression(element) && !t.isOptionalMemberExpression(element)) {
// Optional member targets (`a?.b`) keep their existing best-effort
// behavior; they cannot be assigned without an object guard.
target = element;
}
if (!target) continue;
const param = scope.generateUidIdentifier("el$");
const cached = scope.generateUidIdentifier("_ref$");
// `(el$) => { var _ref$ = <target>; typeof _ref$ === "function" ? _ref$(el$) : <target> = el$; }`
const callback = t.arrowFunctionExpression(
[param],
t.blockStatement([
t.variableDeclaration("var", [
t.variableDeclarator(cached, t.cloneNode(target) as t.Expression)
]),
t.expressionStatement(
t.conditionalExpression(
t.binaryExpression(
"===",
t.unaryExpression("typeof", t.cloneNode(cached)),
t.stringLiteral("function")
),
t.callExpression(t.cloneNode(cached), [t.cloneNode(param)]),
t.assignmentExpression("=", target, t.cloneNode(param))
)
)
])
);
array.elements[i] = callback;
}
}

export function inlineCallExpression(node: t.Expression): t.Expression {
return t.isCallExpression(node) &&
!node.arguments.length &&
Expand Down
8 changes: 7 additions & 1 deletion packages/babel-plugin/src/universal/element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import {
convertJSXIdentifier,
canNativeSpread,
transformCondition,
escapeStringForTemplate
escapeStringForTemplate,
transformRefArrayLiteral
} from "../shared/utils";
import { transformNode } from "../shared/transform";
import type { BabelPath, TransformInfo, TransformResult, UniversalTransformResult } from "../types";
Expand Down Expand Up @@ -149,6 +150,11 @@ function transformAttributes(
t.isFunction(value.expression) ||
t.isArrayExpression(value.expression)
) {
if (t.isArrayExpression(value.expression)) {
// Bare lval elements (e.g. `ref={[elRef]}`) become callback refs
// so the variable actually gets assigned (#3285).
transformRefArrayLiteral(path.scope, value.expression);
}
results.exprs.unshift(
t.expressionStatement(
t.callExpression(
Expand Down
27 changes: 27 additions & 0 deletions packages/babel-plugin/test/__dom_fixtures__/refArrays/code.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Bare variables inside a ref array must be assigned at mount (#3285).
let elementRef;
const el1 = <div ref={[elementRef]} />;

// Multiple bare variables, each gets its own assignment callback.
let a, b;
const el2 = <div ref={[a, b]} />;

// Callback refs keep working and pass through untouched.
const el3 = <div ref={[node => calls.push(node)]} />;

// Member-expression targets are assignment callbacks too.
const holder = {};
const el4 = <div ref={[holder.el]} />;

// Nested arrays are recursed so runtime flattening reaches every callback.
let nested;
const el5 = <div ref={[[nested]]} />;

// Falsy/placeholder slots stay as-is (the runtime short-circuits them);
// globals like `undefined` are never treated as assignment targets.
const el6 = <div ref={[null, undefined, false]} />;

// A mutable binding that currently holds a function is *called*, not
// overwritten — same contract as the non-array lval ref branch.
let cb = () => {};
const el7 = <div ref={[cb]} />;
90 changes: 90 additions & 0 deletions packages/babel-plugin/test/__dom_fixtures__/refArrays/output.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { template as _$template } from "r-dom";
import { ref as _$ref } from "r-dom";
var _tmpl$ = /*#__PURE__*/ _$template(`<div>`);
// Bare variables inside a ref array must be assigned at mount (#3285).
let elementRef;
var _el$ = _tmpl$();
_$ref(
() => [
_el$2 => {
var _ref$ = elementRef;
typeof _ref$ === "function" ? _ref$(_el$2) : (elementRef = _el$2);
}
],
_el$
);
const el1 = _el$;

// Multiple bare variables, each gets its own assignment callback.
let a, b;
var _el$3 = _tmpl$();
_$ref(
() => [
_el$4 => {
var _ref$2 = a;
typeof _ref$2 === "function" ? _ref$2(_el$4) : (a = _el$4);
},
_el$5 => {
var _ref$3 = b;
typeof _ref$3 === "function" ? _ref$3(_el$5) : (b = _el$5);
}
],
_el$3
);
const el2 = _el$3;

// Callback refs keep working and pass through untouched.
var _el$6 = _tmpl$();
_$ref(() => [node => calls.push(node)], _el$6);
const el3 = _el$6;

// Member-expression targets are assignment callbacks too.
const holder = {};
var _el$7 = _tmpl$();
_$ref(
() => [
_el$8 => {
var _ref$4 = holder.el;
typeof _ref$4 === "function" ? _ref$4(_el$8) : (holder.el = _el$8);
}
],
_el$7
);
const el4 = _el$7;

// Nested arrays are recursed so runtime flattening reaches every callback.
let nested;
var _el$9 = _tmpl$();
_$ref(
() => [
[
_el$0 => {
var _ref$5 = nested;
typeof _ref$5 === "function" ? _ref$5(_el$0) : (nested = _el$0);
}
]
],
_el$9
);
const el5 = _el$9;

// Falsy/placeholder slots stay as-is (the runtime short-circuits them);
// globals like `undefined` are never treated as assignment targets.
var _el$1 = _tmpl$();
_$ref(() => [null, undefined, false], _el$1);
const el6 = _el$1;

// A mutable binding that currently holds a function is *called*, not
// overwritten — same contract as the non-array lval ref branch.
let cb = () => {};
var _el$10 = _tmpl$();
_$ref(
() => [
_el$11 => {
var _ref$6 = cb;
typeof _ref$6 === "function" ? _ref$6(_el$11) : (cb = _el$11);
}
],
_el$10
);
const el7 = _el$10;
3 changes: 3 additions & 0 deletions packages/compiler/__tests__/babel-fixtures.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const fixtureParity = {
keyedElements: parityLevel.subset,
multipleClassAttributes: parityLevel.subset,
namespaceElements: parityLevel.subset,
refArrays: parityLevel.subset,
simpleElements: parityLevel.subset,
textInterpolation: parityLevel.subset
};
Expand Down Expand Up @@ -153,6 +154,8 @@ function supportedSubset(fixture) {
return source;
case "attributeExpressions":
return source;
case "refArrays":
return source;
default:
throw new Error(`No supported AST-native subset for ${fixture}`);
}
Expand Down
74 changes: 74 additions & 0 deletions packages/compiler/__tests__/fixtures/dom/refArrays/output.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { template as _$template } from "r-dom";
import { ref as _$ref } from "r-dom";
var _tmpl$ = /* @__PURE__ */ _$template(`<div>`);
// Bare variables inside a ref array must be assigned at mount (#3285).
let elementRef;
var _el$ = _tmpl$();
_$ref(() => {
return [(_ref$) => {
var _ref$2 = elementRef;
typeof _ref$2 === "function" ? _ref$2(_ref$) : elementRef = _ref$;
}];
}, _el$);
const el1 = _el$;
// Multiple bare variables, each gets its own assignment callback.
let a, b;
var _el$2 = _tmpl$();
_$ref(() => {
return [(_ref$3) => {
var _ref$4 = a;
typeof _ref$4 === "function" ? _ref$4(_ref$3) : a = _ref$3;
}, (_ref$5) => {
var _ref$6 = b;
typeof _ref$6 === "function" ? _ref$6(_ref$5) : b = _ref$5;
}];
}, _el$2);
const el2 = _el$2;
var _el$3 = _tmpl$();
_$ref(() => {
return [(node) => calls.push(node)];
}, _el$3);
// Callback refs keep working and pass through untouched.
const el3 = _el$3;
// Member-expression targets are assignment callbacks too.
const holder = {};
var _el$4 = _tmpl$();
_$ref(() => {
return [(_ref$7) => {
var _ref$8 = holder.el;
typeof _ref$8 === "function" ? _ref$8(_ref$7) : holder.el = _ref$7;
}];
}, _el$4);
const el4 = _el$4;
// Nested arrays are recursed so runtime flattening reaches every callback.
let nested;
var _el$5 = _tmpl$();
_$ref(() => {
return [[(_ref$9) => {
var _ref$10 = nested;
typeof _ref$10 === "function" ? _ref$10(_ref$9) : nested = _ref$9;
}]];
}, _el$5);
const el5 = _el$5;
var _el$6 = _tmpl$();
_$ref(() => {
return [
null,
undefined,
false
];
}, _el$6);
// Falsy/placeholder slots stay as-is (the runtime short-circuits them);
// globals like `undefined` are never treated as assignment targets.
const el6 = _el$6;
// A mutable binding that currently holds a function is *called*, not
// overwritten — same contract as the non-array lval ref branch.
let cb = () => {};
var _el$7 = _tmpl$();
_$ref(() => {
return [(_ref$11) => {
var _ref$12 = cb;
typeof _ref$12 === "function" ? _ref$12(_ref$11) : cb = _ref$11;
}];
}, _el$7);
const el7 = _el$7;
4 changes: 4 additions & 0 deletions packages/compiler/src/dom/attrs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,10 @@ impl<'a> AstDomTransform<'a, '_> {
}
}

// Lower bare lvals inside `ref={[el]}` arrays to assignment callbacks
// (https://github.com/solidjs/solid/issues/3285).
value = crate::shared::refs::transform_ref_array_literal(self, span, value);

let is_constant = matches!(
&value,
Expression::Identifier(identifier)
Expand Down
Loading
Loading