Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/iterable-children.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions packages/signals/src/boundaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,10 @@ export function createRevealOrder<T>(
});
}

function isIterable(value: any): value is Iterable<any> {
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
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 === "")
Expand Down
10 changes: 10 additions & 0 deletions packages/universal/test/runtime-insert.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ describe("universal insert (static values)", () => {
expect(parent.innerHTML).toBe("<a></a><b></b>");
});

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("<a></a><b></b>");
});

it("inserts nothing for a static null", () => {
const parent = document.createElement("div");
r.insert(parent, null);
Expand Down
7 changes: 7 additions & 0 deletions packages/web/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4388,6 +4388,10 @@ function flattenClassList(list, result) {
}
}

function isIterable(value: any): value is Iterable<any> {
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`:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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];
Expand Down
41 changes: 41 additions & 0 deletions packages/web/test/hydration/iterable-children.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(() => <div>{values}</div>).
container.innerHTML = '<div _hk="0">before<!--!$-->after</div>';
const element = container.firstElementChild!;
const firstText = element.childNodes[0];
const secondText = element.childNodes[2];

dispose = hydrate(() => <div>{values}</div>, 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);
});
});
45 changes: 45 additions & 0 deletions packages/web/test/runtime/insert.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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("<a></a><b></b>");
});

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");
Expand Down Expand Up @@ -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";
Expand Down
21 changes: 21 additions & 0 deletions packages/web/test/server/iterable-children.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(() => <div>{values}</div>)).toContain(">before<!--!$-->after</div>");
});

test("renders an iterable nested in array children", () => {
const values = ["before", new Set(["middle", "after"])];

expect(renderToString(() => <div>{values}</div>)).toContain(
">beforemiddle<!--!$-->after</div>"
);
});
});
Loading