Describe the bug
flattenArray in @solidjs/signals (packages/signals/src/boundaries.ts, the Array.isArray(child) branch) overwrites its needsUnwrap flag with the nested call's result instead of OR-ing it:
if (Array.isArray(child)) {
needsUnwrap = flattenArray(child, results, options);
}
So under doNotUnwrap, a function child (a <For>/<Repeat>/memo accessor) followed later at the same level by an array child that contains no functions (a fragment) resets the flag to false. flatten then returns the plain results array with the accessor still inside it instead of the resolving wrapper.
@solidjs/web never sees this because its insertExpression has a function branch. A custom renderer built on @solidjs/universal does not: insert(parent, () => props.children, null) takes the array branch and passes the raw memo to the host insertNode, which crashes (in our case a native host tried to read node.id from a function). It appears whenever a component's children mix a <For> with a fragment-returning component that comes after it, and looks intermittent because it depends on child order and on which branch of a fragment exists at the time.
Fix is one line:
needsUnwrap = flattenArray(child, results, options) || needsUnwrap;
Your Example Website or App
Inline repro below; it only needs @solidjs/signals and @solidjs/universal 2.0.0-rc.4 (bun run repro.ts, no bundler).
Steps to Reproduce the Bug or Issue
import { createRoot, createMemo, flatten } from "@solidjs/signals"
import { createRenderer } from "@solidjs/universal"
// 1. flatten alone: a fragment after an accessor drops the flag.
createRoot(() => {
let accessor = createMemo(() => "from memo")
let ok = flatten([accessor, "text"], { skipNonRendered: true, doNotUnwrap: true })
let bad = flatten([accessor, ["fragment"]], { skipNonRendered: true, doNotUnwrap: true })
console.log("accessor then string ->", typeof ok === "function" ? "wrapper (correct)" : ok)
console.log("accessor then fragment ->", typeof bad === "function" ? "wrapper (correct)" : bad)
})
// 2. through a universal renderer: insertNode receives the memo itself.
type N = { tag: string; children: N[] }
let seen: unknown[] = []
let r = createRenderer<N>({
createElement: (tag) => ({ tag, children: [] }),
createTextNode: (v) => ({ tag: `#text(${v})`, children: [] }),
replaceText: () => {},
setProperty: () => {},
insertNode: (parent, node) => { seen.push(node); parent.children.push(node as N) },
isTextNode: (n) => n.tag.startsWith("#text"),
removeNode: (parent, node) => { parent.children = parent.children.filter((c) => c !== node) },
getParentNode: () => undefined,
getFirstChild: (n) => n.children[0],
getNextSibling: () => undefined,
})
createRoot(() => {
let root: N = { tag: "root", children: [] }
let memo = createMemo(() => r.createElement("from-memo"))
let fragment = [r.createElement("frag-a"), r.createElement("frag-b")]
r.insert(root, () => [memo, fragment], null)
})
console.log("insertNode received:", seen.map((n) => (typeof n === "function" ? "FUNCTION (bug)" : (n as N).tag)))
Output on 2.0.0-rc.4:
accessor then string -> wrapper (correct)
accessor then fragment -> [ [Function: read], "fragment" ]
insertNode received: [ "#text()", "FUNCTION (bug)", "frag-a", "frag-b" ]
In JSX terms the trigger is a component's props.children holding a <For> followed by a component that returns a fragment:
function Chip() { return <><text>a</text><text>b</text></> }
function Panel(props) { return <view>{props.children}</view> }
<Panel>
<For each={rows}>{(row) => <text>{row}</text>}</For>
<Chip />
</Panel>
Direct host children (<view><For …/><Chip /></view>) are unaffected: the compiler inserts each of them separately, so they never form one array. Swapping the order inside Panel (<Chip /><For …/>) does not trigger it either, since the last child sets the final flag value.
Expected behavior
flatten returns the resolving wrapper whenever any child at any depth is an accessor, regardless of the siblings that follow it, so insertNode only ever receives host nodes. The second flatten call above should print wrapper (correct) and the renderer line should list from-memo instead of FUNCTION (bug).
Versions
@solidjs/signals, @solidjs/universal, solid-js 2.0.0-rc.3 and 2.0.0-rc.4; the same line is on next at d6a4a52f (2026-08-30).
Describe the bug
flattenArrayin@solidjs/signals(packages/signals/src/boundaries.ts, theArray.isArray(child)branch) overwrites itsneedsUnwrapflag with the nested call's result instead of OR-ing it:So under
doNotUnwrap, a function child (a<For>/<Repeat>/memo accessor) followed later at the same level by an array child that contains no functions (a fragment) resets the flag tofalse.flattenthen returns the plainresultsarray with the accessor still inside it instead of the resolving wrapper.@solidjs/webnever sees this because itsinsertExpressionhas a function branch. A custom renderer built on@solidjs/universaldoes not:insert(parent, () => props.children, null)takes the array branch and passes the raw memo to the hostinsertNode, which crashes (in our case a native host tried to readnode.idfrom a function). It appears whenever a component's children mix a<For>with a fragment-returning component that comes after it, and looks intermittent because it depends on child order and on which branch of a fragment exists at the time.Fix is one line:
Your Example Website or App
Inline repro below; it only needs
@solidjs/signalsand@solidjs/universal2.0.0-rc.4 (bun run repro.ts, no bundler).Steps to Reproduce the Bug or Issue
Output on 2.0.0-rc.4:
In JSX terms the trigger is a component's
props.childrenholding a<For>followed by a component that returns a fragment:Direct host children (
<view><For …/><Chip /></view>) are unaffected: the compiler inserts each of them separately, so they never form one array. Swapping the order insidePanel(<Chip /><For …/>) does not trigger it either, since the last child sets the final flag value.Expected behavior
flattenreturns the resolving wrapper whenever any child at any depth is an accessor, regardless of the siblings that follow it, soinsertNodeonly ever receives host nodes. The secondflattencall above should printwrapper (correct)and the renderer line should listfrom-memoinstead ofFUNCTION (bug).Versions
@solidjs/signals,@solidjs/universal,solid-js2.0.0-rc.3 and 2.0.0-rc.4; the same line is onnextatd6a4a52f(2026-08-30).