Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .changeset/ssr-element-join-in-place.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@solidjs/web": patch
---

`ssrElement` serializes the common element shapes without the general resolver's allocations

- Children that are one string, a number, nothing, or one finished node now join the open and close tags in place. The general path — `resolveSSRNode` into a fresh `{ t, h, p }` result (an object and three arrays), then `ssr()` over a template array — is taken only for arrays and pending nodes, which need it. Output is unchanged; on a text-content-heavy page this was the single largest cost of a spread element.
- The array-sources form over plain objects, and an omit over a merge, walk the array they were handed: the resolved `sources`/`kinds` lists (and the `fill(SOURCE_OMIT)` list) are built only when entries differ in kind. A plain source in `pushEntry` is classified with one `$PROXY in` check instead of two.

On yak's element-dense SSR cases (`dyn-translate`, `dyn-fair`, `dyn-inline`) this is +15–21% throughput and −27% bytes allocated per instance, closing the gap to the hand-written writer from 1.76× to 1.46×; `multifile-composition`/`tabs` +3–6%.
51 changes: 44 additions & 7 deletions packages/web/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3889,6 +3889,12 @@ function pushEntry(resolved, kinds, s, kind) {
}
if (typeof s === "function") s = s();
if (s == null) return;
// The common source is a plain object: one check, no view lookup.
if (!($PROXY in s)) {
resolved.push(s);
kinds.push(SOURCE_PLAIN);
return;
}
const view = viewOf(s);
if (view instanceof OmitView) {
resolved.push(view);
Expand All @@ -3899,7 +3905,7 @@ function pushEntry(resolved, kinds, s, kind) {
for (let j = 0; j < f.length; j++) pushEntry(resolved, kinds, f[j], k[j]);
} else {
resolved.push(s);
kinds.push($PROXY in s ? SOURCE_PROXY : SOURCE_PLAIN);
kinds.push(SOURCE_PROXY);
}
}

Expand Down Expand Up @@ -3931,12 +3937,26 @@ export function ssrElement(tag, props, children, needsId, skip) {
// element — and nothing is asked of a store proxy per key but the read.
// The common case, one plain object, allocates nothing here.
let sources = null;
// The kind of every entry of `sources`: an array, one per entry, or — when
// they are all of one kind, the usual case — that kind itself, so the
// array form over plain objects and an omit over a merge walk the array
// they were handed and allocate no lists of their own.
let kinds = null;
let kind = SOURCE_PLAIN;
if (Array.isArray(props)) {
sources = [];
kinds = [];
for (let i = 0; i < props.length; i++) pushEntry(sources, kinds, props[i], SOURCE_MEMO);
let i = 0;
for (; i < props.length; i++) {
const s = props[i];
if (s == null || typeof s === "function" || $PROXY in s) break;
}
if (i === props.length) {
sources = props;
kinds = SOURCE_PLAIN;
} else {
sources = [];
kinds = [];
for (let i = 0; i < props.length; i++) pushEntry(sources, kinds, props[i], SOURCE_MEMO);
}
} else if (props == null) {
// A nullish source (static or resolved) is an empty spread (#3297).
props = {};
Expand All @@ -3953,7 +3973,7 @@ export function ssrElement(tag, props, children, needsId, skip) {
const entries = view.entries;
if (entries !== undefined) {
sources = entries;
kinds = new Array(entries.length).fill(SOURCE_OMIT);
kinds = SOURCE_OMIT;
} else {
props = view;
kind = SOURCE_OMIT;
Expand Down Expand Up @@ -3982,13 +4002,14 @@ export function ssrElement(tag, props, children, needsId, skip) {
let keysOf = null;
if (sources !== null) {
keysOf = new Array(last + 1);
for (let s = 0; s <= last; s++) keysOf[s] = sourceKeys(sources[s], kinds[s]);
for (let s = 0; s <= last; s++)
keysOf[s] = sourceKeys(sources[s], typeof kinds === "number" ? kinds : kinds[s]);
}
for (let s = 0; s <= last; s++) {
const keys = keysOf !== null ? keysOf[s] : sourceKeys(props, kind);
if (sources !== null) {
props = sources[s];
kind = kinds[s];
kind = typeof kinds === "number" ? kinds : kinds[s];
}
nextKey: for (let i = 0; i < keys.length; i++) {
const prop = keys[i];
Expand Down Expand Up @@ -4071,6 +4092,22 @@ export function ssrElement(tag, props, children, needsId, skip) {
// `/>` or the slash becomes part of the key's value.
if (skipChildren) return { t: result + " />" };
if (typeof children === "function") children = children();
// The content most elements end up with — one string (a text child, escaped
// above or by the compiler), a number, nothing, or one finished node — joins
// in place. That is exactly what the general path below produces for these
// shapes, minus its allocations: `resolveSSRNode` builds a `{ t, h, p }`
// result (an object and three arrays) to append one string to, and `ssr()`
// takes a template array and runs its hole loop to join it back. A spread
// element is serialized here on every render, so that was the largest
// single cost of the element path (profiled at a third of it on a
// text-content-heavy page). The text-adjacency marker state
// (`ssrTextTail`) is not touched: the parent resets it on this element's
// finished node either way.
const ct = typeof children;
if (ct === "string" || ct === "number") return { t: result + ">" + children + "</" + tag + ">" };
if (children == null || ct === "boolean") return { t: result + "></" + tag + ">" };
if (ct === "object" && !children.h && typeof children.t === "string")
return { t: result + ">" + children.t + "</" + tag + ">" };
return ssr([result + ">", `</${tag}>`], resolveSSRNode(children, undefined, true));
}
export function ssrAttribute(key: string, value: any): string;
Expand Down
31 changes: 31 additions & 0 deletions packages/web/test/server/ssr-element-sources.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,37 @@ describe("ssrElement with multiple sources", () => {
expect(render("div", null)).toBe("<div></div>");
expect(render("br", {}, undefined, true)).toMatch(/^<br _hk=\w+ \/>$/);
});

// One string, a number, nothing, or one finished node joins the open and
// close tags in place; anything else — arrays, pending nodes — goes through
// the tree resolver. Same output either way; this pins the shapes.
test("children of every shape serialize as the resolver would", () => {
expect(render("p", {}, "text")).toBe("<p>text</p>");
expect(render("p", {}, () => "thunked")).toBe("<p>thunked</p>");
expect(render("p", {}, 42)).toBe("<p>42</p>");
expect(render("p", {}, 0)).toBe("<p>0</p>");
expect(render("p", {}, null)).toBe("<p></p>");
expect(render("p", {}, undefined)).toBe("<p></p>");
expect(render("p", {}, false)).toBe("<p></p>");
expect(render("p", {}, true)).toBe("<p></p>");
// a finished node from a nested element
expect(render("p", {}, () => ssrElement("b", { id: "i" }, "in", false))).toBe(
'<p><b id="i">in</b></p>'
);
// from the sources, escaped or raw
expect(render("p", [{ children: 7 }])).toBe("<p>7</p>");
expect(render("style", [{ children: "a > b {}" }])).toBe("<style>a > b {}</style>");
// arrays take the resolver: an element's direct children are never
// separated from each other, a nested array keeps the text separators the
// client needs to claim two text nodes
expect(render("p", {}, ["a", "b"])).toBe("<p>ab</p>");
expect(render("p", {}, () => [1, 2])).toBe("<p>12</p>");
expect(render("p", {}, ["a", ssrElement("i", {}, "x", false), "b"])).toBe("<p>a<i>x</i>b</p>");
expect(render("p", {}, [["a", "b"]])).toBe("<p>a<!--!$-->b</p>");
expect(
renderToString(() => [ssrElement("p", {}, "a", false), ssrElement("p", {}, "b", false)])
).toBe("<p>a</p><p>b</p>");
});
});

// omit() and merge() results are walked as VIEWS — the underlying sources,
Expand Down
Loading