Skip to content

Pending wedge: superseded async flight + re-park leaves a stale self pending-source (breaks router navigation, solid-router#595) #3226

Description

@ryansolid

Summary

A computed whose in-flight async result is superseded by a recompute that re-parks (throws NotReadyError on a different source) wedges in pending forever. The dead flight leaves a stale self entry in the node's _pendingSources; every later settle of the new source sees it as remaining and never re-enqueues the node. Nothing recovers it — the app hangs with a Loading fallback showing while isPending reads false.

This is the root cause of solid-router#595 (navigation into a lazy route that reads a query() gated behind another pending query() hangs forever). Reproduced on solid-js@2.0.0-rc.5 and confirmed still present on next tip (ee73e053) by linking the workspace packages into the router repo and running the failing spec.

Shape that triggers it

Three async waves inside one navigation transition (all it takes is a common "wait for the session before fetching the record" pattern):

// query() returns a fresh `.then` chain per read — every re-run of a memo
// that reads it observes a momentarily-pending promise even when the
// underlying cache entry is resolved
const profile = createMemo(() => getProfile("me"));   // async memo
const orgId = createMemo(() => profile()?.orgId);
const record = createMemo(() => {
  const oid = orgId();
  return oid ? getRecord(`${oid}:${params.id}`) : undefined;
});

Observed sequence (instrumented trace, memo names added):

record run 1  throws NotReadyError            // parked on profile
profile lands → settle walk enqueues record
record run 2  oid=org-1                       // starts its own flight P2
                                              // _pendingSources: {record(self)}
profile re-runs (fresh pending promise)       // mid-transition re-execution
record run 3  throws NotReadyError            // supersedes P2, re-parks on profile
                                              // _pendingSources: {record(SELF—STALE), profile}
profile lands again
  settlePendingSource(profile):
    settling record blocked=true              // removes `profile`…
    remaining = record (the stale self entry) // …takes the `remaining` branch
                                              // → NO enqueueSub. Wedged.
P2 resolves → asyncWrite: stale-flight skip   // correct — but it was the only
                                              // janitor for the self entry

No run 4 ever happens. The navigation transition commits (isRouting()false) with the boundary permanently parked.

Root cause

recompute() (packages/signals/src/core/core.ts) nulls _inFlight at supersede time, but the self entry in _pendingSources is only removed by the flight's landing (clearStatusclearPendingSources) — a landing the stale-flight guard in asyncWrite correctly discards. The #3181 sweep for a superseding recompute is guarded by:

if (wasPendingSource && !(el._statusFlags & (STATUS_PENDING | STATUS_UNINITIALIZED)))
  settlePendingSource(el);

so it only covers a superseding run that lands synchronously. A superseding run that re-parks (STATUS_PENDING set by the catch path's notifyStatus) skips it, and notifyStatus(STATUS_PENDING) only adds the new source — it deliberately does not clear existing entries. The stale self entry then has no janitor.

Validation

A one-line diagnostic at the supersede point makes the full router repro pass on next tip:

// recompute(), where the previous flight dies:
const hadFlight = el._x._inFlight !== null;
el._x._inFlight = null;
if (hadFlight) removePendingSource(el, el);  // ← unwedges router#595

(Not proposed as the final fix — the dependents' stale src=el entries from the dead flight's propagation deserve a look too, i.e. whether the #3181 sweep should also run on the re-park path.)

Why there's no bare-signals repro (yet)

A direct distillation (async memo superseded + re-parked, observed by a render effect) passes: the effect's status-notifier/pull path re-reads the memo and recovers. The wedge needs the only observers to be boundary machinery that waits for a status notification inside an already-committed transition — the router navigation shape (lazy route + Loading boundary + Show). The failing spec below reproduces it deterministically through @solidjs/router@next (test/scratch-595.spec.tsx in the router repo, distilled from @davedbase's StackBlitz):

// routes: [{ path: "/", component: lazy(Home) },
//          { path: "/detail/:id", component: lazy(DetailPage) }]
// DetailPage -> AppShell (profile query + orgId context) -> DetailContent
// DetailContent: record memo gated on orgId(), rendered under <Loading>
// navigate("/detail/42") → hangs at the Loading fallback forever;
// profile and record fetches both complete. Passes on router next.19
// (whose navigation didn't ride the transition engine), hangs on next.20+.

Versions: solid-js / @solidjs/web / @solidjs/signals 2.0.0-rc.5 and workspace next @ ee73e053; @solidjs/router 2.0.0-next.20+.

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