Repository navigation
Description
Summary
On @solidjs/signals 2.0.0-rc.16 the subtree under <Loading> stays empty forever, and <Errored> is never reached, in this case:
- a
createProjectionderive returns a pending promise; - the code that settles it first writes a signal the derive reads, synchronously, and then rejects the promise (a query cache does exactly this: the cache event fires inside the failed fetch's dispatch, before the fetch promise rejects);
- the re-run derive now throws the error synchronously;
- the projection is read in a nested computation, such as an element child that
insertrenders (which is what compiled JSX emits) or aDynamic's children.
The same read returned directly as <Loading>'s child reaches <Errored>. So does the nested read when the write is deferred to a timer. On 2.0.0-rc.15 every variant reaches <Errored>.
Real-world impact: @tanstack/solid-query 6.0.0-rc.5 follows this path for every query that fails, so <span>{query.data}</span> for a failed query renders nothing and never shows the error boundary. [email protected] depends on @solidjs/signals ^2.0.0-rc.15, so fresh installs of rc.15 resolve signals rc.16 and are affected too.
Bisect
Built packages/signals at each commit between ea4438b1 (rc.15 "Version packages") and 8134a908 (rc.16 "Version packages"), with solid-js and @solidjs/web held at rc.15:
- first bad: fb6006e
fix(signals): stage a pass's error before the flush joins its hold (#4109) (#4156) - its parent
a89c6afa: reaches<Errored>
The commit stages a synchronous pass's throw "whenever the pass read a staging inside a flush; the seam holds it with the frame, or the commit publishes it". Here the frame is held by the projection's own still-pending promise flight, the one whose rejection the throw replaces. The flight is superseded and never lands, so the staged error is neither published at a commit nor released with the frame.
Reproduction
repro.mjs beside this file, with no compiler, no hydration, and no third-party library. It runs jsdom and render:
npm install
node --conditions=browser repro.mjs insert sync # rc.16: "" (expected "errored: failed")
node --conditions=browser repro.mjs insert later # "errored: failed"
node --conditions=browser repro.mjs direct sync # "errored: failed"Its package.json pins solid-js and @solidjs/web 2.0.0-rc.16. Control: with solid-js and @solidjs/web at 2.0.0-rc.15 plus "overrides": { "@solidjs/signals": "2.0.0-rc.15" }, insert sync prints errored: failed; the same rc.15 pair without the override resolves signals rc.16 and prints "".
The core of it:
const [version, setVersion] = createSignal(0, { ownedWrite: true })
const entry = { loading: false, error: undefined, promise: undefined }
const load = () => {
entry.loading = true
entry.promise = Promise.resolve().then(() => {
entry.loading = false
entry.error = new Error("failed")
setVersion((v) => v + 1) // the cache event, before the promise rejects
throw entry.error
})
setVersion((v) => v + 1) // the fetch-start event, inside the derive
return entry.promise
}
let chained
const stable = (base) => (chained?.base === base ? chained.value : (chained = { base, value: base.then((value) => ({ value })) }).value)
const store = createProjection(() => {
version()
if (entry.loading) return stable(entry.promise)
if (entry.error) throw entry.error
return stable(load())
}, {})
// <Errored fallback={(e) => `errored: ${e().message}`}>
// <Loading fallback="loading"><span>{String(store.value)}</span></Loading>
// </Errored>Expected
errored: failed, as on rc.15 and as for the direct read.
Environment
solid-js / @solidjs/web / @solidjs/signals 2.0.0-rc.16, Node 26.10, jsdom 30.1.3. Also seen in Chromium, Firefox, and WebKit through a full SSR-and-hydrate app with @tanstack/solid-query 6.0.0-rc.5, after a client navigation.
Files
repro.mjs
// @solidjs/signals 2.0.0-rc.16: a rejection inside a projection never reaches <Errored> when the projection's
// rejecting promise is preceded by a synchronous write to a signal the projection reads, and the read sits in a nested
// computation (an element child inserted by `insert`, which is what compiled JSX emits), under <Loading>.
// On @solidjs/signals 2.0.0-rc.15 every case below prints "errored: failed".
//
// Run: npm install && node --conditions=browser repro.mjs
// (compare: npm install @solidjs/[email protected] via the "overrides" field, then run again)
import { JSDOM } from "jsdom"
const dom = new JSDOM("<!doctype html><html><body><div id=root></div></body></html>")
for (const key of ["window", "document", "Node", "Element", "HTMLElement", "Text", "Comment", "DocumentFragment", "MutationObserver", "navigator"]) {
if (!(key in globalThis)) Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true, writable: true })
}
const solid = await import("solid-js")
const web = await import("@solidjs/web")
const c = solid.createComponent
// A data source the way a query cache feeds one: a version signal written by the source's own events, and a
// projection that derives from it. The first compute starts the load, which writes the version inside the compute
// (an "added/fetch" event); while loading, the compute hands back one stable promise; once failed, it throws.
// `notify` is when the failure's own event writes the version: "sync" (before the promise rejects) or "later".
function source(notify) {
const [version, setVersion] = solid.createSignal(0, { ownedWrite: true })
const entry = { loading: false, error: undefined, promise: undefined }
const load = () => {
entry.loading = true
entry.promise = Promise.resolve().then(() => {
entry.loading = false
entry.error = new Error("failed")
if (notify === "sync") setVersion((v) => v + 1)
else setTimeout(() => setVersion((v) => v + 1))
throw entry.error
})
setVersion((v) => v + 1)
return entry.promise
}
let chained
const stable = (base) => {
if (chained?.base !== base) chained = { base, value: base.then((value) => ({ value })) }
return chained.value
}
const store = solid.createProjection(() => {
version()
if (entry.loading) return stable(entry.promise)
if (entry.error) throw entry.error
return stable(load())
}, {})
return () => store.value
}
// `read`: "direct" returns the read as the Loading's child; "insert" puts it in an element through `insert`.
async function run(read, notify) {
const root = document.getElementById("root")
root.textContent = ""
const [show, setShow] = solid.createSignal(false)
const Page = () => {
const value = source(notify)
return c(solid.Loading, {
fallback: "loading",
get children() {
if (read === "direct") return String(value())
const span = document.createElement("span")
web.insert(span, () => String(value()))
return span
},
})
}
const dispose = web.render(
() =>
c(web.Dynamic, {
component: "main",
get children() {
return show()
? c(solid.Errored, { fallback: (error) => `errored: ${error().message}`, get children() { return c(Page, {}) } })
: "home"
},
}),
root,
)
solid.flush()
setShow(true)
solid.flush()
await new Promise((resolve) => setTimeout(resolve, 100))
solid.flush()
const shown = root.textContent
dispose()
return `${read} read, ${notify} notify: "${shown}"`
}
const { version } = (await import("@solidjs/signals/package.json", { with: { type: "json" } })).default
console.log(`@solidjs/signals ${version}`)
// One case per process (`node --conditions=browser repro.mjs <read> <notify>`), since a case may never return.
const [read = "insert", notify = "sync"] = process.argv.slice(2)
console.log(await run(read, notify))
process.exit(0)package.json
{
"name": "solid-signals-rc16-projection-reject-repro",
"private": true,
"type": "module",
"devDependencies": {
"solid-js": "2.0.0-rc.16",
"@solidjs/web": "2.0.0-rc.16",
"jsdom": "30.1.3"
}
}
Activity
docs(bit-01): the rc.16 query hang is Solid's fb6006eb (solidjs/solid…
neokofg commentedon Oct 21, 2026
A smaller form of the same regression. It needs no projection and no rejection. A plain
createMemois pending on a promise that never settles, then starts throwing synchronously after a signal write:node --conditions=browser repro.mjs(jsdom 26.1.0, Node 25.8.2):"ERRORED: boom""value="(the boundary reveals empty content, and<Errored>never flips)1b2406df.insert) is affected. Returning"value=" + m()directly as theLoadingchild showsERRORED: boomon rc.16."ok"instead of throwing prints"value=ok"on every version, so only the error path is lost.a5857c79(signals built at that commit) still prints"value=". Its guard only covers projection derives (fam), and here the throwing node is an ordinary computed. Nothing else is held here: the only pending thing was the memo's own superseded flight. So the error stays staged and is never committed. That breaks D-41 ("with nothing held, an error publishes at the flush that found it") and D-12 ("the boundary never reveals empty content").next@9f64ca93.fix(signals): publish a projection derive's in-flush throw at once (#…
ryansolid commentedon Oct 21, 2026
Generated by Claude Code
fix(signals): a staged error reaches a derived store's readers
fix(signals): a staged error reaches the memos and derived stores tha…