From c71ea562054b7db5bf1a4791c031dfe4503d285f Mon Sep 17 00:00:00 2001 From: Nick Date: Wed, 9 Sep 2026 14:41:24 +0300 Subject: [PATCH] feat: support iterable JSX children --- .changeset/iterable-children.md | 7 +++ packages/signals/src/boundaries.ts | 19 ++++++++ .../universal/test/runtime-insert.spec.js | 10 +++++ packages/web/src/server.ts | 7 +++ .../test/hydration/iterable-children.spec.tsx | 41 +++++++++++++++++ packages/web/test/runtime/insert.spec.js | 45 +++++++++++++++++++ .../test/server/iterable-children.spec.tsx | 21 +++++++++ 7 files changed, 150 insertions(+) create mode 100644 .changeset/iterable-children.md create mode 100644 packages/web/test/hydration/iterable-children.spec.tsx create mode 100644 packages/web/test/server/iterable-children.spec.tsx diff --git a/.changeset/iterable-children.md b/.changeset/iterable-children.md new file mode 100644 index 000000000..0ad8f1784 --- /dev/null +++ b/.changeset/iterable-children.md @@ -0,0 +1,7 @@ +--- +"@solidjs/signals": patch +"@solidjs/universal": patch +"@solidjs/web": patch +--- + +Support finite synchronous iterables, such as `Set` and custom `Symbol.iterator` collections, as JSX children. Iterables are normalized into arrays for DOM and universal rendering, SSR, and hydration. diff --git a/packages/signals/src/boundaries.ts b/packages/signals/src/boundaries.ts index 4b6fe1d6a..72f8e997b 100644 --- a/packages/signals/src/boundaries.ts +++ b/packages/signals/src/boundaries.ts @@ -590,6 +590,10 @@ export function createRevealOrder( }); } +function isIterable(value: any): value is Iterable { + return value != null && typeof value === "object" && typeof value[Symbol.iterator] === "function"; +} + /** * Resolves a children value to its renderable form: unwraps zero-arg functions * (accessors), recursively flattens arrays, and optionally skips @@ -640,6 +644,19 @@ export function flatten( } return results; } + + if (isIterable(children)) { + const results: any[] = []; + if (flattenArray(Array.from(children), results, options)) { + return () => { + const nested: any[] = []; + flattenArray(results, nested, { ...options, doNotUnwrap: false }); + return nested; + }; + } + return results; + } + return children; } @@ -668,6 +685,8 @@ function flattenArray( // still needs the resolving wrapper even when a later sibling // fragment contains no functions (#3133). needsUnwrap = flattenArray(child, results, options) || needsUnwrap; + } else if (isIterable(child)) { + needsUnwrap = flattenArray(Array.from(child), results, options) || needsUnwrap; } else if ( options?.skipNonRendered && (child == null || child === true || child === false || child === "") diff --git a/packages/universal/test/runtime-insert.spec.js b/packages/universal/test/runtime-insert.spec.js index 7c3fd8b1b..3eb3a5e4e 100644 --- a/packages/universal/test/runtime-insert.spec.js +++ b/packages/universal/test/runtime-insert.spec.js @@ -27,6 +27,16 @@ describe("universal insert (static values)", () => { expect(parent.innerHTML).toBe(""); }); + it("inserts a static Set of nodes", () => { + const parent = document.createElement("div"); + const a = document.createElement("a"); + const b = document.createElement("b"); + + r.insert(parent, new Set([a, b])); + + expect(parent.innerHTML).toBe(""); + }); + it("inserts nothing for a static null", () => { const parent = document.createElement("div"); r.insert(parent, null); diff --git a/packages/web/src/server.ts b/packages/web/src/server.ts index 360f40b77..16419de13 100644 --- a/packages/web/src/server.ts +++ b/packages/web/src/server.ts @@ -4388,6 +4388,10 @@ function flattenClassList(list, result) { } } +function isIterable(value: any): value is Iterable { + return value != null && typeof value === "object" && typeof value[Symbol.iterator] === "function"; +} + // Best-effort sync resolution. Returns a string when the entire `node` // resolves synchronously to text. Otherwise returns one of three shapes // shared with `ssrFirstGroupHit`: @@ -4425,6 +4429,7 @@ function tryResolveString(node) { } return s; } + if (isIterable(node)) return tryResolveString(Array.from(node)); if (node.h && node.h.length > 0) return { merge: node }; if (node.t === undefined) { // Not a template object — mirror the client's dev warn-and-skip @@ -4483,6 +4488,8 @@ export function resolveSSRNode( } finally { if (slotLive) slotLive.suppressed--; } + } else if (isIterable(node)) { + return resolveSSRNode(Array.from(node), result, top); } else if (t === "object") { if (node.h) { result.t[result.t.length - 1] += node.t[0]; diff --git a/packages/web/test/hydration/iterable-children.spec.tsx b/packages/web/test/hydration/iterable-children.spec.tsx new file mode 100644 index 000000000..219fe67e3 --- /dev/null +++ b/packages/web/test/hydration/iterable-children.spec.tsx @@ -0,0 +1,41 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + */ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { flush } from "solid-js"; +import { hydrate } from "@solidjs/web"; + +describe("iterable children hydration", () => { + const container = document.createElement("div"); + let dispose: (() => void) | undefined; + + beforeEach(() => { + (globalThis as any)._$HY = { events: [], completed: new WeakSet(), r: {}, fe() {} }; + document.body.appendChild(container); + }); + + afterEach(() => { + dispose?.(); + dispose = undefined; + container.remove(); + container.innerHTML = ""; + }); + + test("adopts server-rendered iterable children", async () => { + const values = new Set(["before", "after"]); + // Captured from renderToString(() =>
{values}
). + container.innerHTML = '
beforeafter
'; + const element = container.firstElementChild!; + const firstText = element.childNodes[0]; + const secondText = element.childNodes[2]; + + dispose = hydrate(() =>
{values}
, container); + await new Promise(resolve => setTimeout(resolve, 0)); + flush(); + + expect(element.textContent).toBe("beforeafter"); + expect(element.childNodes[0]).toBe(firstText); + expect(element.childNodes[1]).toBe(secondText); + }); +}); diff --git a/packages/web/test/runtime/insert.spec.js b/packages/web/test/runtime/insert.spec.js index 6ee010fe1..28ce503e4 100644 --- a/packages/web/test/runtime/insert.spec.js +++ b/packages/web/test/runtime/insert.spec.js @@ -190,6 +190,21 @@ describe("r.insert", () => { ); }); + it("can insert a Set of strings", () => { + expect(insert(new Set(["foo", "bar"])).innerHTML).toBe("foobar"); + }); + + it("can insert a Set of nodes", () => { + const a = document.createElement("a"); + const b = document.createElement("b"); + + expect(insert(new Set([a, b])).innerHTML).toBe(""); + }); + + it("flattens an iterable nested in an array", () => { + expect(insert(["before", new Set(["middle", "after"])]).innerHTML).toBe("beforemiddleafter"); + }); + it("can insert and clear strings", () => { var parent = document.createElement("div"); r.insert(parent, "foo"); @@ -346,6 +361,36 @@ describe("r.insert with Markers", () => { ); }); + it("can insert an iterable within a marker range", () => { + expect(insert(new Set(["foo", "bar"])).innerHTML).toBe("beforefoobarafter"); + }); + + it("reconciles changing iterables by node identity", () => { + const parent = document.createElement("div"); + const marker = parent.appendChild(document.createTextNode("")); + const a = document.createElement("a"); + const b = document.createElement("b"); + const c = document.createElement("c"); + const [items, setItems] = createSignal(new Set([a, b])); + + let dispose; + createRoot(d => { + dispose = d; + r.insert(parent, () => items(), marker); + }); + flush(); + + expect([...parent.children]).toEqual([a, b]); + + setItems(new Set([b, c])); + flush(); + + expect([...parent.children]).toEqual([b, c]); + expect(parent.children[0]).toBe(b); + + dispose(); + }); + it("can insert and clear strings with marker", () => { var parent = document.createElement("div"); parent.innerHTML = " bar"; diff --git a/packages/web/test/server/iterable-children.spec.tsx b/packages/web/test/server/iterable-children.spec.tsx new file mode 100644 index 000000000..5f51e3b00 --- /dev/null +++ b/packages/web/test/server/iterable-children.spec.tsx @@ -0,0 +1,21 @@ +/** + * @jsxImportSource @solidjs/web + */ +import { describe, expect, test } from "vitest"; +import { renderToString } from "@solidjs/web"; + +describe("SSR iterable children", () => { + test("render a Set of children", () => { + const values = new Set(["before", "after"]); + + expect(renderToString(() =>
{values}
)).toContain(">beforeafter"); + }); + + test("renders an iterable nested in array children", () => { + const values = ["before", new Set(["middle", "after"])]; + + expect(renderToString(() =>
{values}
)).toContain( + ">beforemiddleafter" + ); + }); +});