Skip to content

2.0.0-rc.16: a projection's error never reaches <Errored> when a signal it reads is written just before its promise rejects (regression from fb6006eb, #4156) #4189

Closed

Description

@lovrozagar

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 createProjection derive 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 insert renders (which is what compiled JSX emits) or a Dynamic'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

  1. neokofg commented on Oct 21, 2026

    @neokofg

    A smaller form of the same regression. It needs no projection and no rejection. A plain createMemo is pending on a promise that never settles, then starts throwing synchronously after a signal write:

    import { JSDOM } from "jsdom";
    const dom = new JSDOM("<!doctype html><div id=app></div>");
    Object.assign(globalThis, { window: dom.window, document: dom.window.document, Node: dom.window.Node, HTMLElement: dom.window.HTMLElement });
    const { createMemo, createSignal, createComponent, Errored, Loading } = await import("solid-js");
    const { render, insert } = await import("@solidjs/web");
    
    const [fail, setFail] = createSignal(false);
    const m = createMemo(() => {
      if (fail()) throw new Error("boom");
      return new Promise(() => {});
    });
    
    const el = document.getElementById("app");
    render(() => createComponent(Errored, {
      fallback: (err) => "ERRORED: " + err().message,
      get children() {
        return createComponent(Loading, { fallback: "LOADING", get children() {
          const p = document.createElement("p");
          p.append("value=");
          insert(p, () => String(m()), null);
          return p;
        } });
      },
    }), el);
    
    setTimeout(() => setFail(true), 20);
    setTimeout(() => { console.log(JSON.stringify(el.textContent)); process.exit(0); }, 100);

    node --conditions=browser repro.mjs (jsdom 26.1.0, Node 25.8.2):

    solid-js / web / signals output
    rc.13, rc.14, rc.15 "ERRORED: boom"
    rc.16 "value=" (the boundary reveals empty content, and <Errored> never flips)
    • I bisected it independently with solid-js/web held at rc.15, and the result is the same: first bad fb6006e, good at 1b2406df.
    • As in the original report, only the nested read (insert) is affected. Returning "value=" + m() directly as the Loading child shows ERRORED: boom on rc.16.
    • The control where the memo returns "ok" instead of throwing prints "value=ok" on every version, so only the error path is lost.
    • fix(signals): publish a projection derive's in-flush throw at once (#4189) #4195 does not fix this form. Its head 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").
    • Still reproduces on next@9f64ca93.
  2. ryansolid commented on Oct 21, 2026

    @ryansolid
    Member

    Fixed in #4195, merged to next as 2c442c4. It will ship in the next RC.

    — Claude via Claude Code


    Generated by Claude Code

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