Describe the bug
Reading a property from a createProjection result inside the provider body hangs streaming SSR: the request sends 0 bytes, the Node process pins ~100% of one core, and it never recovers — every later request hangs too.
When the projection's async generator suspends (its first yield), the returned store proxy is PENDING. Reading any property off it in the creator body throws NotReadyError. That throw makes the SSR framework capture the whole provider as a streaming hole (root hole). Re-running the hole allocates a new createProjection — a fresh pending proxy — instead of re-observing the settled one, so the property read throws again on every pass. @solidjs/web adds a fresh promise to its flush queue on each pass and never prunes it: unbounded growth, 100% CPU, no HTML flushed.
The bug does not reproduce when the store is passed by reference (children read properties lazily), because the hole scopes to the child and the render converges. Reproduced on 2.0.0-rc.3.
Your Example Website or App
StackBlitz repro — https://stackblitz.com/edit/solidjs-templates-og7afj4c?file=src%2Fpages%2FHome.tsx
Solid Start app (solid({ ssr: true, start: true, serverFunctions: true })).
/bug → BUG: the request never completes (0 bytes) and the dev server stays at ~100% CPU.
- the same provider with the store passed by reference → control: renders correctly.
Steps to Reproduce the Bug or Issue
- Open the StackBlitz link (or copy
src/ into a workspace with solid-js@2.0.0-rc.3 + @solidjs/web@2.0.0-rc.3 installed) and run npm run dev.
- Open the
/bug route — or run curl -H "Accept: text/html" http://localhost:3000/bug.
- Observe: the request never completes (0 bytes); the dev-server process stays at ~100% CPU and never recovers.
Minimal repro (the only part that matters):
const state = createProjection<{ items: Item[] }>(
async function* (draft) {
// ...
yield; // suspends -> proxy is PENDING
// ...
},
{ items: [] },
);
// reading a property of the PENDING proxy in the provider body:
const value = { items: state.items }; // throws NotReadyError on SSR
Key detail: the hang is the property read of a pending proxy, not the projection itself. state.items throws NotReadyError (the proxy is unset until the first yield commits), which turns the provider into a root hole. Re-running that hole runs createProjection again → a new pending proxy → the read throws again → a new hole → a new promise, forever.
Expected behavior
Reading a property of a pending projection should either suspend once (fallback → resolve when the projection settles) or re-observe the same projection on re-render — not allocate a fresh pending proxy per pass. The flush queue must terminate.
Screenshots or Videos
None — the symptom is a silent, permanent hang: curl stalls at 0 bytes, the process pins a core, and the terminal prints no error.
Platform
- OS: Linux
- Browser: N/A (SSR; Node stream verified with
curl)
- Version: Node 24
Additional context
Observed behavior:
| Scenario |
SSR result |
Property read of pending proxy in provider body (/bug) |
hangs (0 bytes, 100% CPU, permanent) |
Store passed by reference ({ items: state }) |
converges |
{ ssrSource: "client" } |
converges |
Root cause:
-
createProjection returns createPendingProxy(state, deferred.promise) — the proxy throws NotReadyError until markReady() settles the deferred.
-
The provider body reads state.items while pending → NotReadyError escapes to the root render → @solidjs/web's resolveSSRNode → buildAsyncWrap captures it as a root hole:
const fn = () => runWithOwner(owner, node); // re-runs the WHOLE provider under the same owner, no child reset
-
resolveRootHoles() re-invokes the hole → the provider body runs again → createProjection() calls const owner = createOwner() unconditionally, allocating a fresh child owner + fresh deferred + fresh pending proxy (there is no idempotency).
-
state.items throws again → new hole → blockingPromises.add(p) appends a fresh promise each pass, never pruned → flush() never reaches flushEnd → 0 bytes, ~100% CPU.
This is the same class as #2900 (fixed in c7bb2c8b), but that fix only reset child state on the projection's internal retry (runProjection) — it did not reset on the root-hole re-run of the enclosing provider, nor make createProjection idempotent. The sync-compute pending-store path was separately guarded in bff4c21c.
Affected versions: 2.0.0-rc.2 and 2.0.0-rc.3 (verified — createProjection in dist/server.js still does const owner = createOwner() unconditionally; @solidjs/web's buildAsyncWrap/resolveRootHoles are unchanged).
Real-world trigger: any provider that derives a value by reading a property of a streaming createProjection store in the creator body (e.g. const value = { items: store.items }), rendered during SSR.
Verified workaround: pass the store by reference — no property read in the creator body:
const value = { items: state }; // children read items lazily
The hole then scopes to the child (inside the <Loading> boundary), which registers a fragment and converges.
Proposed fix (two parts):
-
solid-js — make createProjection stable across re-runs: cache the pending proxy per owner slot so a hole re-run returns the same in-flight projection (and its deferred) instead of a fresh one.
function createProjection(fn, initialValue, options) {
const ctx = sharedConfig.context;
+ // reuse the in-flight projection across hole re-runs (same slot ⇒ same instance)
+ const parent = currentOwner;
+ const slotKey = parent && parent.id != null ? nextChildIdFor(parent, false) : undefined;
+ const cached = slotKey !== undefined && parent._projections?.get(slotKey);
+ if (cached) return cached;
const owner = createOwner();
const [state] = createStore(initialValue);
// ...
+ // cache `pending` under slotKey before returning it
return pending;
}
-
@solidjs/web — stop the flush queue from growing unboundedly: rewind the owner's child state in buildAsyncWrap before re-running, and prune settled/duplicate promises in resolveRootHoles.
// buildAsyncWrap
const fn = () => runWithOwner(owner, node);
+ // reset owner children before re-running (mirror resetOwnerForRerun)
fn.$rw = true;
// resolveRootHoles
for (const p of res.p) blockingPromises.add(p);
+ // drop p once it settles (or dedupe by id) so the set can't grow unboundedly
The solid-js part is the actual fix; the @solidjs/web part is a defensive bound against the permanent-hang/OOM symptom.
Describe the bug
Reading a property from a
createProjectionresult inside the provider body hangs streaming SSR: the request sends 0 bytes, the Node process pins ~100% of one core, and it never recovers — every later request hangs too.When the projection's async generator suspends (its first
yield), the returned store proxy is PENDING. Reading any property off it in the creator body throwsNotReadyError. That throw makes the SSR framework capture the whole provider as a streaming hole (root hole). Re-running the hole allocates a newcreateProjection— a fresh pending proxy — instead of re-observing the settled one, so the property read throws again on every pass.@solidjs/webadds a fresh promise to its flush queue on each pass and never prunes it: unbounded growth, 100% CPU, no HTML flushed.The bug does not reproduce when the store is passed by reference (children read properties lazily), because the hole scopes to the child and the render converges. Reproduced on
2.0.0-rc.3.Your Example Website or App
StackBlitz repro — https://stackblitz.com/edit/solidjs-templates-og7afj4c?file=src%2Fpages%2FHome.tsx
Solid Start app (
solid({ ssr: true, start: true, serverFunctions: true }))./bug→ BUG: the request never completes (0 bytes) and the dev server stays at ~100% CPU.Steps to Reproduce the Bug or Issue
src/into a workspace withsolid-js@2.0.0-rc.3+@solidjs/web@2.0.0-rc.3installed) and runnpm run dev./bugroute — or runcurl -H "Accept: text/html" http://localhost:3000/bug.Minimal repro (the only part that matters):
Key detail: the hang is the property read of a pending proxy, not the projection itself.
state.itemsthrowsNotReadyError(the proxy is unset until the firstyieldcommits), which turns the provider into a root hole. Re-running that hole runscreateProjectionagain → a new pending proxy → the read throws again → a new hole → a new promise, forever.Expected behavior
Reading a property of a pending projection should either suspend once (fallback → resolve when the projection settles) or re-observe the same projection on re-render — not allocate a fresh pending proxy per pass. The flush queue must terminate.
Screenshots or Videos
None — the symptom is a silent, permanent hang:
curlstalls at 0 bytes, the process pins a core, and the terminal prints no error.Platform
curl)Additional context
Observed behavior:
/bug){ items: state }){ ssrSource: "client" }Root cause:
createProjectionreturnscreatePendingProxy(state, deferred.promise)— the proxy throwsNotReadyErroruntilmarkReady()settles the deferred.The provider body reads
state.itemswhile pending →NotReadyErrorescapes to the root render →@solidjs/web'sresolveSSRNode→buildAsyncWrapcaptures it as a root hole:resolveRootHoles()re-invokes the hole → the provider body runs again →createProjection()callsconst owner = createOwner()unconditionally, allocating a fresh child owner + freshdeferred+ fresh pending proxy (there is no idempotency).state.itemsthrows again → new hole →blockingPromises.add(p)appends a fresh promise each pass, never pruned →flush()never reachesflushEnd→ 0 bytes, ~100% CPU.This is the same class as #2900 (fixed in
c7bb2c8b), but that fix only reset child state on the projection's internal retry (runProjection) — it did not reset on the root-hole re-run of the enclosing provider, nor makecreateProjectionidempotent. The sync-compute pending-store path was separately guarded inbff4c21c.Affected versions:
2.0.0-rc.2and2.0.0-rc.3(verified —createProjectionindist/server.jsstill doesconst owner = createOwner()unconditionally;@solidjs/web'sbuildAsyncWrap/resolveRootHolesare unchanged).Real-world trigger: any provider that derives a value by reading a property of a streaming
createProjectionstore in the creator body (e.g.const value = { items: store.items }), rendered during SSR.Verified workaround: pass the store by reference — no property read in the creator body:
The hole then scopes to the child (inside the
<Loading>boundary), which registers a fragment and converges.Proposed fix (two parts):
solid-js— makecreateProjectionstable across re-runs: cache the pending proxy per owner slot so a hole re-run returns the same in-flight projection (and itsdeferred) instead of a fresh one.function createProjection(fn, initialValue, options) { const ctx = sharedConfig.context; + // reuse the in-flight projection across hole re-runs (same slot ⇒ same instance) + const parent = currentOwner; + const slotKey = parent && parent.id != null ? nextChildIdFor(parent, false) : undefined; + const cached = slotKey !== undefined && parent._projections?.get(slotKey); + if (cached) return cached; const owner = createOwner(); const [state] = createStore(initialValue); // ... + // cache `pending` under slotKey before returning it return pending; }@solidjs/web— stop the flush queue from growing unboundedly: rewind the owner's child state inbuildAsyncWrapbefore re-running, and prune settled/duplicate promises inresolveRootHoles.The
solid-jspart is the actual fix; the@solidjs/webpart is a defensive bound against the permanent-hang/OOM symptom.