From 2ecedf406fef1e9c7df917763d66097e066485f2 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Mon, 31 Aug 2026 00:59:05 -0700 Subject: [PATCH] OR flattenArray's needsUnwrap with nested results instead of overwriting (#3133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under doNotUnwrap, an accessor child followed at the same level by a function-free fragment reset the flag, so flatten returned a plain array with the raw accessor inside instead of the resolving wrapper. Every renderer crashes on the raw function: universal hosts receive it in insertNode (as reported), and the DOM renderer throws insertBefore 'parameter 1 is not of type Node' — the protective function branch remembered from 1.x dom-expressions does not exist in 2.0, so this was not universal-specific. Fix as proposed in the report; pinned at both the signals and web layers. Co-authored-by: Cursor --- .changeset/fix-flatten-needsunwrap-or.md | 5 +++ packages/signals/src/boundaries.ts | 5 ++- .../tests/flatten-needs-unwrap.test.ts | 40 +++++++++++++++++++ .../test/insert-flatten-needs-unwrap.spec.tsx | 29 ++++++++++++++ 4 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-flatten-needsunwrap-or.md create mode 100644 packages/signals/tests/flatten-needs-unwrap.test.ts create mode 100644 packages/web/test/insert-flatten-needs-unwrap.spec.tsx diff --git a/.changeset/fix-flatten-needsunwrap-or.md b/.changeset/fix-flatten-needsunwrap-or.md new file mode 100644 index 000000000..f764c6a1f --- /dev/null +++ b/.changeset/fix-flatten-needsunwrap-or.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Fix `flattenArray` overwriting its `needsUnwrap` flag with a nested call's result instead of OR-ing it (#3133). Under `doNotUnwrap`, an accessor child (a ``/``/memo) followed at the same level by a fragment containing no functions reset the flag, so `flatten` returned a plain array with the raw accessor still inside instead of the resolving wrapper. Every renderer crashed on the raw function: universal hosts received it in `insertNode` (as reported), and the DOM renderer threw `insertBefore … parameter 1 is not of type 'Node'` — the protective function branch remembered from 1.x dom-expressions does not exist in 2.0. Reported with the fix by @antoinevanwel; also submitted by @nickshiro. diff --git a/packages/signals/src/boundaries.ts b/packages/signals/src/boundaries.ts index 0091de7e9..902f71b7f 100644 --- a/packages/signals/src/boundaries.ts +++ b/packages/signals/src/boundaries.ts @@ -656,7 +656,10 @@ function flattenArray( } while (typeof child === "function" && !child.length); } if (Array.isArray(child)) { - needsUnwrap = flattenArray(child, results, options); + // OR, don't overwrite: an accessor already pushed under doNotUnwrap + // still needs the resolving wrapper even when a later sibling + // fragment contains no functions (#3133). + needsUnwrap = flattenArray(child, results, options) || needsUnwrap; } else if ( options?.skipNonRendered && (child == null || child === true || child === false || child === "") diff --git a/packages/signals/tests/flatten-needs-unwrap.test.ts b/packages/signals/tests/flatten-needs-unwrap.test.ts new file mode 100644 index 000000000..1410dd4ec --- /dev/null +++ b/packages/signals/tests/flatten-needs-unwrap.test.ts @@ -0,0 +1,40 @@ +/** + * #3133: `flattenArray` must OR its `needsUnwrap` flag with a nested call's + * result, not overwrite it. Under `doNotUnwrap`, a function child (a + * ``/``/memo accessor) followed at the same level by an array + * child containing no functions (a fragment) reset the flag, so `flatten` + * returned the plain results array with the raw accessor still inside it + * instead of the resolving wrapper. `@solidjs/web` masked this with its + * insertExpression function branch; universal renderers passed the raw memo + * to the host's insertNode and crashed. + */ +import { describe, expect, it } from "vitest"; +import { createMemo, createRoot, flatten } from "../src/index.js"; + +const OPTS = { skipNonRendered: true, doNotUnwrap: true }; + +describe("#3133: flatten needsUnwrap under doNotUnwrap", () => { + it("keeps the wrapper when a function-free fragment follows an accessor", () => { + createRoot(() => { + const accessor = createMemo(() => "from memo"); + const out = flatten([accessor, ["a", "b"]], OPTS); + expect(typeof out).toBe("function"); + expect(out()).toEqual(["from memo", "a", "b"]); + }); + }); + + it("keeps the wrapper when the accessor is inside an earlier fragment", () => { + createRoot(() => { + const accessor = createMemo(() => "nested"); + const out = flatten([[accessor], ["plain"]], OPTS); + expect(typeof out).toBe("function"); + expect(out()).toEqual(["nested", "plain"]); + }); + }); + + it("still returns a plain array when nothing needs unwrapping", () => { + const out = flatten(["a", ["b", "c"]], OPTS); + expect(Array.isArray(out)).toBe(true); + expect(out).toEqual(["a", "b", "c"]); + }); +}); diff --git a/packages/web/test/insert-flatten-needs-unwrap.spec.tsx b/packages/web/test/insert-flatten-needs-unwrap.spec.tsx new file mode 100644 index 000000000..e2f197c0d --- /dev/null +++ b/packages/web/test/insert-flatten-needs-unwrap.spec.tsx @@ -0,0 +1,29 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * #3133 at the DOM layer: flattenArray losing `needsUnwrap` when a + * function-free fragment follows an accessor in the same children array made + * `normalize` hand `insertExpression` a plain array with the raw memo still + * inside — `appendNodes` then threw `Failed to execute 'insertBefore' on + * 'Node': parameter 1 is not of type 'Node'`. The issue was reported against + * universal renderers on the belief that web's insertExpression had a + * function branch protecting it; that branch is 1.x dom-expressions — 2.0 + * crashes identically. + */ +import { expect, test } from "vitest"; +import { createMemo, flush } from "solid-js"; +import { render } from "../src/index.js"; + +test("accessor followed by a fragment inside one children array renders (#3133)", () => { + const container = document.createElement("div"); + function App() { + const label = createMemo(() => "from memo"); + const children = [label, ["a", "b"]]; + return
{children}
; + } + const dispose = render(() => , container); + flush(); + expect(container.textContent).toBe("from memoab"); + dispose(); +});