diff --git a/.changeset/fix-ssr-textarea-spread-value.md b/.changeset/fix-ssr-textarea-spread-value.md
new file mode 100644
index 000000000..178914f1a
--- /dev/null
+++ b/.changeset/fix-ssr-textarea-spread-value.md
@@ -0,0 +1,5 @@
+---
+"@solidjs/web": patch
+---
+
+Render textarea values supplied through SSR spreads as text content rather than invalid HTML attributes.
diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts
index f824aeef5..210c6cd96 100644
--- a/packages/web/src/server.ts
+++ b/packages/web/src/server.ts
@@ -3569,6 +3569,15 @@ export function ssrElement(tag, props, children, needsId) {
let result = `<${tag}${hk} `;
for (let i = 0; i < keys.length; i++) {
const prop = keys[i];
+ const value = props[prop];
+ // The compiler moves static textarea values into children, but an
+ // element with a spread is serialized here instead. Keep the runtime
+ // path equivalent: textarea value/defaultValue are its text content,
+ // never HTML attributes.
+ if (tag === "textarea" && (prop === "value" || prop === "defaultValue")) {
+ if (value !== null) children = escape(value);
+ continue;
+ }
if (ChildProperties.has(prop)) {
if (children === undefined && !skipChildren)
children =
@@ -3577,7 +3586,6 @@ export function ssrElement(tag, props, children, needsId) {
: escape(props[prop]);
continue;
}
- const value = props[prop];
if (prop === "style") {
result += `style="${ssrStyle(value)}"`;
} else if (prop === "class") {
diff --git a/packages/web/test/server/spread-function-source.spec.tsx b/packages/web/test/server/spread-function-source.spec.tsx
index 1594a6b45..cc3957277 100644
--- a/packages/web/test/server/spread-function-source.spec.tsx
+++ b/packages/web/test/server/spread-function-source.spec.tsx
@@ -32,6 +32,37 @@ describe("SSR spread with function source (#2815)", () => {
expect(html).toContain('id="y"');
});
+ test("textarea value from props merged around a spread becomes text content (#3286)", () => {
+ const value = () => "something";
+ const html = renderToString(() => );
+
+ expect(html).toMatch(/data-x="x"\s*>something<\/textarea>/);
+ expect(html).not.toContain(' value="something"');
+ });
+
+ test("textarea value supplied by a spread becomes text content (#3286)", () => {
+ const html = renderToString(() => );
+
+ expect(html).toMatch(/data-x="x"\s*>something<\/textarea>/);
+ expect(html).not.toContain(' value="something"');
+ });
+
+ test("textarea value before a spread remains text content (#3286)", () => {
+ const html = renderToString(() => );
+
+ expect(html).toMatch(/data-x="x"\s*>something<\/textarea>/);
+ expect(html).not.toContain(' value="something"');
+ });
+
+ test("textarea defaultValue supplied by a spread becomes text content (#3286)", () => {
+ const html = renderToString(() => (
+
+ ));
+
+ expect(html).toMatch(/data-x="x"\s*>something<\/textarea>/);
+ expect(html).not.toContain(' defaultValue="something"');
+ });
+
test("Dynamic routes spreads through mergeProps", () => {
const props = { "data-x": "1", id: "y" };
const html = renderToString(() => );