Skip to content

flattenArray drops needsUnwrap when a fragment follows an accessor, universal renderers receive a raw memo in insertNode #3133

Description

@antoinevanwel

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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions