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
5 changes: 5 additions & 0 deletions .changeset/lazy-scope-carrier-shell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/web": patch
---

A direct server-function call no longer copies every plain object and array in its result. The carrier walk that binds deferred bodies to the call's request scope now builds a container's shallow-rebuilt shell only when a value inside it is bound or a cycle reaches it. Before, it built one for every container up front and discarded it when nothing changed. A call returning 1,000 plain rows walks in 0.56 ms instead of 0.91 ms.
45 changes: 30 additions & 15 deletions packages/web/server-functions/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -919,25 +919,35 @@ function scopeDeferredResult(value, scope) {
/** One carrier mid-walk on the direct road's descent. Module-private, so a
* user value can never satisfy the driver's `instanceof` dispatch. */
class ScopeFrame {
constructor(value, next, keys, descriptors) {
constructor(value, prototype, keys, descriptors) {
this.value = value;
this.next = next;
this.prototype = prototype;
this.next = null;
this.keys = keys;
this.descriptors = descriptors;
this.i = 0;
this.changed = false;
}
}

/** The frame's shallow-rebuilt shell, built on first need: most carriers
* hold nothing to bind and hand the author's container back unbuilt. */
function frameShell(frame) {
return (frame.next ??= frame.keys
? Object.create(frame.prototype, frame.descriptors)
: frame.value.slice());
}

/** Resolve one nested value for the direct road's carrier descent: leaves,
* already-walked entries and deferred bodies answer immediately; a plain
* object or array answers a frame for the driver, its shallow-rebuilt shell
* pre-registered so cycles resolve to the shell being built. */
* object or array answers a frame for the driver, pre-registered so a cycle
* builds and resolves to its shell. */
function enterScopeCarrier(value, state) {
if (value === null || (typeof value !== "object" && typeof value !== "function")) return value;
if (state.seen.has(value)) {
state.cyclic.add(value);
return state.seen.get(value);
const seen = state.seen.get(value);
return seen instanceof ScopeFrame ? frameShell(seen) : seen;
}
const bound = bindDeferredBody(value, state.scope);
if (bound !== value) {
Expand All @@ -952,9 +962,9 @@ function enterScopeCarrier(value, state) {
state.seen.set(value, value);
return value;
}
const next = value.slice();
state.seen.set(value, next);
return new ScopeFrame(value, next, null, null);
const frame = new ScopeFrame(value, null, null, null);
state.seen.set(value, frame);
return frame;
}
const prototype = Object.getPrototypeOf(value);
if ((prototype !== Object.prototype && prototype !== null) || Object.isFrozen(value)) {
Expand All @@ -965,10 +975,14 @@ function enterScopeCarrier(value, state) {
// accessors and non-writable data stay exactly as authored (accessors are
// the caller's to invoke, non-writable slots are out of the carrier set);
// only writable enumerable data slots are candidates for binding.
const descriptors = Object.getOwnPropertyDescriptors(value);
const next = Object.create(prototype, descriptors);
state.seen.set(value, next);
return new ScopeFrame(value, next, Object.keys(value), descriptors);
const frame = new ScopeFrame(
value,
prototype,
Object.keys(value),
Object.getOwnPropertyDescriptors(value)
);
state.seen.set(value, frame);
return frame;
}

// The descent is ITERATIVE for the same reason the codec road's walk is
Expand Down Expand Up @@ -1014,8 +1028,8 @@ function scopeDeferredContents(root, scope) {
}
}
if (bound !== original) {
if (top.keys) top.next[top.keys[i]] = bound;
else top.next[i] = bound;
if (top.keys) frameShell(top)[top.keys[i]] = bound;
else frameShell(top)[i] = bound;
top.changed = true;
}
top.i++;
Expand All @@ -1029,7 +1043,8 @@ function scopeDeferredContents(root, scope) {
// took the shell; otherwise the caller keeps the author's container
let out;
if (top.changed || state.cyclic.has(top.value)) {
out = top.next;
out = frameShell(top);
state.seen.set(top.value, out);
} else {
state.seen.set(top.value, top.value);
out = top.value;
Expand Down
44 changes: 44 additions & 0 deletions packages/web/test/server/direct-call-result.bench.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Tier-1 SSR-lane bench for a direct server-function call's result walk: the
// call an SSR `query()` makes, returning 1,000 plain rows. The direct road
// walks the result to bind deferred bodies to the call's request scope; a
// result with nothing deferred should hand its containers back unbuilt.
//
// - plain rows: nothing to bind anywhere in the graph
// - one deferred body: the same rows plus a cursor, so the root rebuilds

import { AsyncLocalStorage } from "node:async_hooks";
import { bench } from "vitest";
import { createRequestEvent } from "@solidjs/web";
import { createServerReference } from "@solidjs/web/server-functions/server";

const RequestContext = Symbol.for("solid.RequestContext");
const requestContext = new AsyncLocalStorage<any>();
(globalThis as any)[RequestContext] = requestContext;

const rows = Array.from({ length: 1000 }, (_, i) => ({
id: `00000000-0000-4000-8000-${String(i).padStart(12, "0")}`,
name: `row ${i}`
}));

async function* cursor() {
yield "one";
}

const plain = createServerReference({
id: "bench-direct-plain",
fn: () => rows
} as any) as () => typeof rows;
const deferred = createServerReference({
id: "bench-direct-deferred",
fn: () => ({ rows, more: cursor() })
} as any) as () => { rows: typeof rows };

const event = createRequestEvent(new Request("https://app.example/page"));

bench("direct call, 1,000 plain rows", () => {
requestContext.run(event, () => plain());
});

bench("direct call, 1,000 rows and one deferred body", () => {
requestContext.run(event, () => deferred());
});
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,59 @@ describe("nested deferred work on the direct SSR road (#3241)", () => {
expect(result).toBe(marker);
});

it("resolves cycles within one carrier when nothing deferred is inside", async () => {
const marker: any = { rows: [{ id: 1 }], meta: { total: 1 } };
marker.self = marker;
marker.rows.push(marker.rows);
const reference = createServerReference({
id: "nested-direct-cyclic-plain",
fn: () => marker
} as any) as () => typeof marker;

const result = await requestContext.run(eventFor("RENDER"), async () => reference());

// a cycle rebuilds the containers it passes through; the cycles then
// point into the rebuilt graph, and untouched leaves keep identity
expect(result.self).toBe(result);
expect(result.rows[1]).toBe(result.rows);
expect(result.rows[0]).toBe(marker.rows[0]);
expect(result.meta).toBe(marker.meta);
});

it("resolves a cycle to the rebuilt carrier when a body below is bound", async () => {
const seen: string[] = [];
let returned: any;
const reference = createServerReference({
id: "nested-direct-cyclic-bound",
fn: () => {
async function* rows() {
seen.push(`resume:${(getRequestEvent() as any)?.derived === true ? "call" : "ambient"}`);
yield "one";
}
(getRequestEvent() as any).derived = true;
returned = { rows: rows(), list: [] as any[] };
returned.self = returned;
returned.list.push(returned.list, returned);
return returned;
}
} as any) as () => any;

const result = await requestContext.run(eventFor("RENDER"), async () => {
const value = reference();
await drain(value.rows);
return value;
});

expect(seen).toEqual(["resume:call"]);
// the carrier was rebuilt around the bound body, and every cycle in it
// resolves to the rebuilt shells, never back to the author's containers
expect(result).not.toBe(returned);
expect(result.self).toBe(result);
expect(result.list[1]).toBe(result);
expect(result.list[0]).toBe(result.list);
expect(returned.self).toBe(returned);
});

it("pins Set members and frozen slots as out of the carrier set", async () => {
// RULED OUT OF SCOPE (#3241): the supported carriers are plain objects
// and arrays only. A generator reached through a Set member or a
Expand Down
5 changes: 5 additions & 0 deletions packages/web/vite.config.server-bench.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ export default defineConfig({
resolve: {
conditions: ["node"],
alias: {
// as in vite.config.server.mjs: the server-function runtime and the
// codec subpaths it imports, ahead of the bare "@solidjs/web" alias
"@solidjs/web/server-functions/server": resolve(rootDir, "server-functions/dist/server.js"),
"@solidjs/web/serialization/decode": resolve(rootDir, "serialization/dist/decode.js"),
"@solidjs/web/serialization": resolve(rootDir, "serialization/dist/serialization.js"),
"@solidjs/web": resolve(rootDir, "src/index.server.ts"),
// Before "solid-js": a string alias prefix-matches its subpaths.
"solid-js/internal": resolve(rootDir, "../solid/src/internal.ts"),
Expand Down
Loading