Repository navigation
perf(web): build direct-call carrier shells only when a value is bound - #4333
Conversation
🦋 Changeset detectedLatest commit: 635f8a8 The changes in this PR will be included in the next version bump. This PR includes changesets to release 15 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Merging this PR will degrade performance by 18.83%2 warnings
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing Footnotes
|
Summary
With
serverFunctionsenabled, every'use server'call made during SSR (aquery()loader, for example) runs its result throughscopeDeferredResult, which binds deferred bodies such as generators and streams to the call's request scope.enterScopeCarrierbuilt a shallow-rebuilt shell for every plain object and array it reached, usingObject.create(prototype, descriptors)orslice(), before knowing whether anything inside needed binding. For a result with nothing deferred, which is the common case, every shell was thrown away at the end.I found this while porting the e18e framework-tracker benchmark app to Solid 2.0 (e18e/framework-tracker#709). Its
/ssr-throughputpage returns 1,000 rows from a'use server'loader. TurningserverFunctionson dropped that page from 707 to 457 req/s with 10 concurrent in-process requests.ScopeFramenow records the container's prototype and builds its shell on first need, throughframeShell(): when a child value is bound, or when a cycle reaches a frame still being walked.seenholds the in-progress frame, so a cycle builds the shell and resolves to it as before, and a completed frame records its final container. The walk itself is unchanged, including which slots it visits, the descriptors it copies, the frozen and non-plain exclusions, and the iterative driver.What remains of the walk's cost is reading descriptors for every object, which the walk needs so it never calls a getter outside the request scope. Avoiding that would mean not walking direct-call results at all, which is a design question I left alone.
How did you test this change?
npx vitest run --config vite.config.server.mjsinpackages/web: 207 files, 1,877 passed, 3 expected failures, 2 skipped.server-functions-nested-deferred-scope.spec.tsxpin the cycle contract the lazy shell has to keep. A cyclic result with nothing deferred resolves its cycles inside the rebuilt graph, and a cyclic result with a bound body resolves every cycle to the rebuilt shells. Both pass onnextand with this change.test/server/direct-call-result.bench.tsx, run withnpx vitest bench --run --config vite.config.server-bench.mjs(local Mac, means):/ssr-throughputin the framework-tracker app (rc.19, 10 concurrent in-process requests, local) went from 2.92 to about 2.5 ms of CPU per request, and from 457 to 545–572 req/s. The rendered HTML is identical.🤖 Generated with Claude Code