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 (clearStatus → clearPendingSources) — 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+.
Summary
A computed whose in-flight async result is superseded by a recompute that re-parks (throws
NotReadyErroron 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 asremainingand never re-enqueues the node. Nothing recovers it — the app hangs with aLoadingfallback showing whileisPendingreads false.This is the root cause of solid-router#595 (navigation into a lazy route that reads a
query()gated behind another pendingquery()hangs forever). Reproduced onsolid-js@2.0.0-rc.5and confirmed still present onnexttip (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):
Observed sequence (instrumented trace, memo names added):
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_inFlightat supersede time, but the self entry in_pendingSourcesis only removed by the flight's landing (clearStatus→clearPendingSources) — a landing the stale-flight guard inasyncWritecorrectly discards. The #3181 sweep for a superseding recompute is guarded by:so it only covers a superseding run that lands synchronously. A superseding run that re-parks (
STATUS_PENDINGset by the catch path'snotifyStatus) skips it, andnotifyStatus(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
nexttip:(Not proposed as the final fix — the dependents' stale
src=elentries 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 +
Loadingboundary +Show). The failing spec below reproduces it deterministically through@solidjs/router@next(test/scratch-595.spec.tsxin the router repo, distilled from @davedbase's StackBlitz):Versions:
solid-js/@solidjs/web/@solidjs/signals2.0.0-rc.5 and workspacenext@ee73e053;@solidjs/router2.0.0-next.20+.