Skip to content

fix(solid): judge SSR waterfall waits by when their work started - #4108

Merged
ryansolid merged 3 commits into
nextfrom
fix/lazy-code-waterfall-4105
Oct 16, 2026
Merged

ryansolid merged 3 commits into
nextfrom
fix/lazy-code-waterfall-4105

Conversation

@ryansolid

@ryansolid ryansolid commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Summary

Fixes #4105.

Before: on the first request to a cold server (a fresh dev server, or the first render after an edit invalidates a route module), a nested file route warned [SSR_BOUNDARY_WATERFALL] … 3 sequential async waits and pointed at queries, even with every query preloaded and behind its own <Loading>.

After: the server check judges each wait by when its work started, using the same origin rule as the client's ASYNC_WATERFALL. Preloaded route chunks and preloaded data no longer read as a chain. Route chunks nobody preloaded still do, and so does a real data chain, whose message names the waits it left out (… 4 sequential async waits (not counting 1 on work already started: a preload or lazy() import)).

The router was already doing the right thing: createRouteContext calls component.preload() and runs preload for every matched level in one pass. The boundary still meets each level a render pass later, because a child is only reached once its parent has rendered. The old check counted passes, so it read work that was already running as sequential.

How:

  • Start stamps move to the core. The flight-origin map moves from the attribution engine onto OBSERVE.attribution as markFlight(flight, startedAt?) and flightOrigin(flight). A router or cache can stamp what it hands out without loading the engine (the router already calls OBSERVE && OBSERVE.attribution.withOrigin the same way), and the server can read the stamps. attribution.markFlight now writes to the same map, so the client engine's behaviour is unchanged. No new exports.
  • The boundary uses the origin rule. A wait counts toward the waterfall only if something it waited on started at or after the previous wait landed. An unstamped promise counts as starting when the boundary meets it, which is today's behaviour. The finding gains data.parallelWaits when any waits were left out. The boundary record's passes is unchanged.
  • Stamps. lazy() and lazyModule() stamp their import when it starts, on both client and server. The client also stamps lazy()'s read wrapper. The server's async memo carries its source promise's stamp onto the deferred the boundary waits on. Lazy asset lookups and their gates carry their start.
  • Tiers. Client stamps are gated on IS_OBSERVE and server ones on IS_DEV. The prod artifacts don't change (dist/solid.js, dist/server.js and the signals prod build carry none of it).
  • The SSR_BOUNDARY_WATERFALL section of the diagnostics skill describes the rule and how a cache should stamp.

A companion router PR stamps query()'s fetches and the wrappers it hands out, so preloaded route data is covered too. A cache that doesn't stamp falls back to today's behaviour.

Size: the observe tier only. Against next with #4107 merged in, app: CSR, observe tier goes 14,498 → 14,588 B br (+90 B; +228 B minified) and + attribution engine enabled 29,108 → 29,180 B (+72 B; +210 B minified). Both caps are raised with ledger notes. Every prod scenario is unchanged.

How did you test this change?

  • New packages/web/test/server/lazy-waterfall-diagnostic.spec.tsx, with three cold nested lazy levels on an async (dev-style) manifest:
    • Preloaded, there is no finding.
    • Not preloaded, the 4-pass chain still warns.
    • A three-deep data chain under preloaded levels warns with 4 flights and parallelWaits: 1.
    • Page data a preload started, read under the page chunk through a fresh wrapper the way query() does, gives no finding when stamped and a counted info when not.
    • Four of the five fail on next.
  • packages/signals: a new test checks that a mark made through OBSERVE.attribution.markFlight (no engine call) breaks the client chain the same way. The signals suite passes: 5,315 tests.
  • packages/solid: 868 passed. packages/web client: 1,403 passed. packages/web server: 1,735 passed, with only the 2 known environmental failures (the Node 22 adapter test and [2.0 rc.15 / next] SSR never evaluates a ref on an element that has a spread, so a directive factory's setup misaligns hydration ids #4081's compiler binary).
  • node size.mjs locally, check-floor-caps.mjs origin/next (no floor raised), prettier, and tsc for solid.

🤖 Generated with Claude Code

https://claude.ai/code/session_018LY7F9czoRqdMEvk2QwPr1


Generated by Claude Code

…4105)

On a cold module cache, nested lazy() route levels resolve one level per
<Loading> render pass even when the router kicked every matched level's
import in the first pass, so SSR_BOUNDARY_WATERFALL reported parallel chunk
loads as sequential data waits and pointed at queries. lazy()/lazyModule()
now mark their import and asset-lookup promises (dev only), and the boundary
leaves passes that waited only on those out of the waterfall count. The
record's `passes` is unchanged; the finding gains `codePasses` when any were
left out.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LY7F9czoRqdMEvk2QwPr1
@changeset-bot

changeset-bot Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b75d93b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
Name Type
solid-js Patch
@solidjs/signals Patch
@solidjs/diagnostics Patch
@solidjs/element Patch
@solidjs/h Patch
@solidjs/html Patch
test-integration Patch
@solidjs/universal Patch
@solidjs/web Patch
board-example Patch
server-dbmon-example Patch
todos-server-example Patch
@solidjs/babel-plugin Patch
@solidjs/compiler Patch

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

@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base minified vs base minified vs recorded cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.24 KB 0 B 0 B 0 B 7.25 KB ✅
signals: + createStore 14.77 KB 0 B 0 B 0 B 14.79 KB ✅
signals: + isPending/latest 9.94 KB 0 B 0 B 0 B 9.96 KB ✅
signals: + until/refresh (truth readers, no optimism) 7.85 KB 0 B 0 B −1132 B 7.86 KB ✅
app: render + one signal (the simple-app floor) 9.55 KB 0 B 0 B 0 B 9.56 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 17.85 KB 0 B 0 B +2 B 17.87 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 29.84 KB 0 B 0 B +2 B 29.91 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 12.84 KB 0 B 0 B +2 B 12.87 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.59 KB +90 B (+0.6%) +228 B 0 B 14.60 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 29.18 KB +72 B (+0.2%) +210 B 0 B 29.19 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 9.75 KB 0 B 0 B +2 B 9.77 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 25.69 KB 0 B 0 B +4 B 25.66 KB ⚠️ over by 26 B, 16 B minified headroom stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 31.63 KB 0 B 0 B +4 B 31.72 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.87 KB 0 B 0 B 0 B 11.87 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 34.51 KB 0 B 0 B +2 B 34.62 KB ✅ assets.js 0.78 KB, bind.js 2.54 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 9.04 KB, wire.js 1.35 KB
page: live server components (base + live/GET + action + isPending/latest) 38.71 KB 0 B 0 B +6 B 38.70 KB ⚠️ over by 12 B, 14 B minified headroom assets.js 0.78 KB, bind.js 2.54 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 9.04 KB, wire.js 1.35 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.40 KB 0 B 0 B +2 B 35.37 KB ⚠️ over by 34 B, 18 B minified headroom assets.js 0.78 KB, bind.js 2.54 KB, decode.js 6.25 KB, regions.js 0.80 KB, sc-comments.js 0.20 KB, trace.js 9.01 KB, wire.js 1.35 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 41.46 KB 0 B 0 B +2 B 41.21 KB ⚠️ over by 247 B, 18 B minified headroom eager (counted): web.js 21.96 KB; assets.js 0.78 KB, bind.js 2.55 KB, decode.js 6.25 KB, regions.js 0.79 KB, sc-comments.js 0.19 KB, trace.js 8.99 KB, wire.js 1.35 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 41.94 KB 0 B 0 B +2 B 41.96 KB ✅ assets.js 0.78 KB, bind.js 2.56 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, server.js 1.02 KB, serverForms.js 3.59 KB, trace.js 9.03 KB, wire.js 1.36 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 48.15 KB 0 B 0 B 0 B 48.16 KB ✅ eager (counted): client.js 28.17 KB; assets.js 0.78 KB, bind.js 2.56 KB, decode.js 6.25 KB, lazy-page.js 0.04 KB, regions.js 0.81 KB, server.js 1.02 KB, serverForms.js 3.30 KB, trace.js 9.01 KB, wire.js 1.36 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 0 B 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.57 KB 0 B 0 B +8 B 20.58 KB ✅

⚠️ Over the brotli cap within the minified allowance (passes)

  • app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store): over brotli cap by 26 B; minified 80,210 B vs 80,206 B recorded with the cap (+4 B) — 16 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: live server components (base + live/GET + action + isPending/latest): over brotli cap by 12 B; minified 119,546 B vs 119,540 B recorded with the cap (+6 B) — 14 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread): over brotli cap by 34 B; minified 109,102 B vs 109,100 B recorded with the cap (+2 B) — 18 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: compiled live server components (the compiled base page + live/GET + action + isPending/latest): over brotli cap by 247 B; minified 124,012 B vs 124,010 B recorded with the cap (+2 B) — 18 B of the 20 B minified allowance left; +0 B minified over this PR's base

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. A scenario fails only when it is over its brotli cap and its minified size is more than 20 B over the minified recorded with the cap; over the cap within that allowance is brotli layout noise and passes with a warning. Caps and their recorded minified in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body). npm run ratchet lowers caps per RC; it never raises one (scripts/size/README.md).

@coveralls

coveralls commented Oct 15, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 38852941060

Coverage increased (+0.02%) to 77.554%

Details

  • Coverage increased (+0.02%) from the base build.
  • Patch coverage: 9 of 9 lines across 2 files are fully covered (100%).
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1338
Covered Lines: 1096
Line Coverage: 81.91%
Relevant Branches: 1050
Covered Branches: 756
Branch Coverage: 72.0%
Branches in Coverage %: Yes
Coverage Strength: 27.85 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

⚠️ 2 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

✅ 195 untouched benchmarks


Comparing fix/lazy-code-waterfall-4105 (b75d93b) with next (4a99c84)

Open in CodSpeed

ryansolid commented Oct 15, 2026 •

Copy link
Copy Markdown
Member Author

The red CodSpeed Performance Analysis check isn't from this PR. Resolved: on b75d93b (with next merged in), CodSpeed is green and reports no performance change across 195 benchmarks. The earlier red was the next-side regression on mount 4000 rows › memo + sync render effect only (reference), a benchmark this PR doesn't touch.

— Claude via Claude Code


Generated by Claude Code

Replaces the lazy()-only exclusion with the client's origin rule. A
<Loading> boundary meets a nested route's levels one render pass at a time,
but the router starts every matched level's lazy() import and preload
together; SSR_BOUNDARY_WATERFALL counted each pass as a sequential wait. A
wait now counts only if something it waited on started at or after the
previous wait landed (`data.parallelWaits` names the ones left out).

The start stamps move from the attribution engine onto the core's
`OBSERVE.attribution` (`markFlight`, `flightOrigin`), so a router or cache
can stamp what it hands out without loading the engine, and the server can
read them. `attribution.markFlight` writes to the same map. lazy() and
lazyModule() stamp their import (and lazy()'s client read wrapper); the
server's async memo carries a stamp from its source promise onto the
deferred the boundary waits on; lazy asset lookups and their gates carry
their start. Prod artifacts are unchanged; the observe tier pays ~56 B br.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018LY7F9czoRqdMEvk2QwPr1
@ryansolid ryansolid changed the title fix(solid): don't count lazy() code waits as an SSR data waterfall fix(solid): judge SSR waterfall waits by when their work started Oct 16, 2026
# Conflicts:
#	scripts/size/scenarios.js
@ryansolid
ryansolid marked this pull request as ready for review October 16, 2026 05:19
@ryansolid
ryansolid merged commit dbc5d00 into next Oct 16, 2026
8 checks passed
ryansolid added a commit to solidjs/solid-router that referenced this pull request Oct 16, 2026
query() hands each reader a fresh `.then` wrapper over the fetch. A route's
preload starts the fetch; the page reads it a render pass later behind its
lazy chunk, and the waterfall checks (ASYNC_WATERFALL on the client,
SSR_BOUNDARY_WATERFALL on the server) read that wrapper as work that only
started then. Stamp the fetch when it starts and each wrapper with the
fetch's start through `OBSERVE.attribution.markFlight` (solidjs/solid#4108).
Observe and dev only: `OBSERVE` folds out of shipped bundles, and a solid-js
without the stamp functions is left alone.


Claude-Session: https://claude.ai/code/session_018LY7F9czoRqdMEvk2QwPr1

Co-authored-by: Claude <noreply@anthropic.com>
ryansolid added a commit that referenced this pull request Oct 16, 2026
Resolves the size-cap conflict with #4108's observe-tier raises: the list
scenarios' caps are re-based on the merged tree (measured + 10 B), the
observe-tier ones on top of #4108's new values, and the two router pages
keep the caps CI's own bytes set; ledger lines say which.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants