Skip to content

2.0.0-rc.3 | createProjection SSR flush-loop hang — reading a property of a pending projection proxy re-allocates the projection every pass #3068

Description

@Jeysef

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 })).

  • /bugBUG: 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

  1. 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.
  2. Open the /bug route — or run curl -H "Accept: text/html" http://localhost:3000/bug.
  3. 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:

  1. createProjection returns createPendingProxy(state, deferred.promise) — the proxy throws NotReadyError until markReady() settles the deferred.

  2. The provider body reads state.items while pending → NotReadyError escapes to the root render → @solidjs/web's resolveSSRNodebuildAsyncWrap captures it as a root hole:

    const fn = () => runWithOwner(owner, node);   // re-runs the WHOLE provider under the same owner, no child reset
  3. 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).

  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions