Local bun patch applied to @solidjs/signals@2.0.0-beta.14,
solid-js@2.0.0-beta.14, and @solidjs/web@2.0.0-beta.14. Bug catalog
(reproductions, expected behavior, severity) lives in solid-bugs.md; this
file documents what the patches do and how to drop them when upstream
ships a fix.
Patch files:
patches/@solidjs%2Fsignals@2.0.0-beta.14.patch — Patch A + B (below)patches/solid-js@2.0.0-beta.14.patch — Patch C (below)patches/@solidjs%2Fweb@2.0.0-beta.14.patch — Patch D (below)Wired up via patchedDependencies in package.json; bun replays them on
every bun install.
The signals patch covers dist/dev.js, dist/prod.js, and dist/node.cjs
(browser ESM + production ESM + node CJS). All three builds carry the same
logic, so all three are patched for parity.
Regenerated for beta.14 (was beta.9). All three bugs are still present in pristine beta.14, so the patches are still required. The beta.9 patch could not be reused: beta.14's minified identifiers changed (e.g. node.cjs
_inFlightis nowe.ke,_timeise._e,globalQueueisz, the status constants are_/m) and thehandleAsync/actionregions shifted, so bun's fuzzy apply spliced the hunks into the wrong functions (addPendingSource) and silently corrupted all three builds — inprod.jsit even produced a stray}that made the package fail to import. Regenerated from pristine beta.14 viabun patch. To re-derive after a future bump, see Removal / re-derivation below.
action() rejection routing (bug #1)In function action(genFn), the step helper re-throws into the async
generator via it.throw(v) when an awaited inner promise rejects. For an
already-completed async generator, it.throw(v) returns a Promise that
rejects with v — which the original code routed back through
step(e, true), recursing forever. The action's outer Promise never settles.
The patch: in the r instanceof Promise branch, when err is true (we're
inside a re-throw), route a re-rejection straight to done(undefined, e)
instead of recursing.
| 1 | if (r instanceof Promise) { |
| 2 | if (err) return void r.then(run, e => done(undefined, e)); |
| 3 | return void r.then(run, e => restoreTransition(ctx, () => step(e, true))); |
| 4 | } |
Behavior change: await action(...)() now rejects with the thrown error
instead of hanging. Success path is unchanged — if (err) only fires on
the rejection-into-generator path.
In function handleAsync(...), the handleError callback (invoked when
the inner promise rejects) only called notifyStatus(...STATUS_ERROR...)
and updated _time. The success-path sibling asyncWrite ends with
insertSubs(el); schedule(); flush(); to wake dependent computeds and run
the outer flush cycle. handleError was missing all three, so dependent
nodes (including resolve()'s computed) never re-ran — await resolve(...)
hung.
The patch:
| 1 | const handleError = error => { |
| 2 | if (el._inFlight !== result) return; |
| 3 | globalQueue.initTransition(resolveTransition(el)); |
| 4 | const isPending = error instanceof NotReadyError; |
| 5 | notifyStatus(el, isPending ? STATUS_PENDING : STATUS_ERROR, error); |
| 6 | el._time = clock; |
| 7 | if (!isPending) { |
| 8 | insertSubs(el); |
| 9 | schedule(); |
| 10 | flush(); |
| 11 | } |
| 12 | }; |
Gated on !isPending so existing NotReadyError propagation (which
already worked) is untouched. Only real errors trigger the new wake-up.
Behavior change: await resolve(() => rejectingMemo()) now rejects with
the underlying error. Render-tree <Errored> boundary path is unchanged
(it never depended on handleError's scheduling).
createContext (solid-js)Separate package (solid-js, not @solidjs/signals). In the provider
returned by createContext, the value write reads props.value while the
surrounding createRoot is the active reactive scope, so the provider's
root subscribes to whatever signals props.value touches and re-runs the
whole subtree on unrelated upstream changes. Wrapping the write in
untrack makes the context value a non-reactive snapshot at provider setup
(the canonical Solid contract — context updates flow through the value's
own reactivity, not the provider re-running).
| 1 | // dist/dev.js (ESM) — bare bindings |
| 2 | untrack(() => setContext(provider, props.value)); |
| 3 | // dist/dev.cjs (CJS) — signals.* namespace |
| 4 | signals.untrack(() => signals.setContext(provider, props.value)); |
Scope caveat: this patch covers only the two dev builds
(dist/dev.js, dist/dev.cjs) — matching the original beta.9 patch. The
bug is also present in the prod (solid.js / solid.cjs) and server
(server.js / server.cjs) builds, which remain unpatched. Tests run in
dev mode so this is invisible there, but test:prod and SSR exercise the
unpatched builds. Extending Patch C to all six builds for parity is an open
decision — left dev-only to preserve the prior scope.
Portal passes a raw element, not a Proxy, to contains (@solidjs/web)Separate package (@solidjs/web, added 2026-05-31). Portal wraps its
mount node in a Proxy via createElementProxy(props.mount || document.body, treeMarker) — the proxy overrides appendChild / insertBefore so Solid
can stamp _$host for cross-portal event delegation. The first
(createRenderEffect) needs that proxy. But the second effect also
received the proxy and passed it to a native DOM call:
| 1 | createEffect(mount, m => { // m = the Proxy |
| 2 | const ownerRoot = getDelegatedRoot(treeMarker); |
| 3 | if (!ownerRoot || ownerRoot.contains(m)) return; // ownerRoot.contains(<Proxy>) |
| 4 | registerDelegatedContainer(m, ownerRoot); |
| 5 | return () => unregisterDelegatedContainer(m, ownerRoot); |
| 6 | }); |
Chromium's native Node.prototype.contains brand-checks its argument
against real platform-object internal slots, which a Proxy exotic object
lacks, and throws TypeError: parameter 1 is not of type 'Node'.
getDelegatedRoot(treeMarker) is truthy for any Portal inside a render()ed
app, so the contains() always runs. This crashes every portalled
component (toast, dialog, alert-dialog, popover, tooltip, menu, select). It
is Chromium-only: happy-dom (unit tests) and WebKit (the Bun.WebView
harness) silently return false for contains(Proxy), so the full
bun test suite and the WebKit browser harness stay green — only the
Chromium demo surfaces it (first thrower is the toast demo, whose Portal
mounts eagerly; dialog/popover Portals are <Show>-gated so they only crash
on open).
The patch: track the raw mount element in the second effect — the proxy
is only needed by the first. m is then a real Node, so contains works.
| 1 | createEffect(() => props.mount || document.body, m => { |
| 2 | const ownerRoot = getDelegatedRoot(treeMarker); |
| 3 | if (!ownerRoot || ownerRoot.contains(m)) return; |
| 4 | registerDelegatedContainer(m, ownerRoot); |
| 5 | return () => unregisterDelegatedContainer(m, ownerRoot); |
| 6 | }); |
This is correct because the second effect only registers the delegated
container for event delegation — it keys off the real DOM node (Map key,
addEventListener target) and must stay consistent with getDelegatedRoot,
which walks real nodes. props.mount || document.body is exactly what the
proxy wrapped, so registration is unchanged apart from dropping the proxy.
Scope: covers all four dist builds —
dist/web.js, dist/dev.js, dist/web.cjs, dist/dev.cjs — since Vite
uses the dev build and the library build resolves web.js / .cjs. (No
SSR build is touched: the server render path doesn't run this effect.)
Release blocker.
patchedDependenciesis a bun/pnpm install-time mechanism — it does not ship to npm/yarn consumers, and never applies transitively for a published dependency. So on stock@solidjs/web@beta.14every portalledquoincomponent still crashes in Chromium. This patch unblocks the demo / dev / tests only; the fix must be upstreamed to Solid (or land in a Solid release we can depend on) before publishing.
Scratch repros covering both patches: action() throws (before yield,
after yield, await rejects), action() success, resolve() on rejecting
memo, resolve() on resolving memo — all six pass after the patch.
Repo test suite (bun test): 1635 pass, 0 fail at time of patch.
Patch D (2026-05-31): node --check on all four patched builds, clean
re-install re-applies the fix to all four, @solidjs/web imports under node.
bun test 1894 / 0 fail + SSR gate 42/42 (no regression), and the Chromium
demo's toast/dialog/popover now render — the parameter 1 is not of type 'Node' crash is gone.
When upstream ships a fix:
solid-js / @solidjs/web / @solidjs/signals / babel-preset-solid
to the version that includes the fix.bun install may warn that a patch no longer applies cleanly — but note
it can also silently mis-apply (see the beta.14 banner above), so always
re-verify the markers after a bump rather than trusting a clean install.patches/*.patch file(s) and the matching key(s) in
the patchedDependencies block of package.json.solid-bugs.md.The pendingCommitError userland workaround was already removed
2026-04-28 — create-async-source.ts now wraps action() plainly,
relying on Patch A's behavior. No userland change needed at upstream-fix
time.
If upstream partially fixes (e.g., only #1), keep the patch but trim it
down to just the unfixed half. Re-run bun patch @solidjs/signals,
re-apply the remaining hunks, bun patch --commit.
A bump does not carry these patches forward — the minified identifiers
and line offsets drift every release, and the beta.9 → beta.14 jump proved
bun will fuzzy-mis-apply a stale patch and corrupt the dist without erroring.
To re-derive against a new @solidjs/signals / solid-js:
patchedDependencies in package.json, then
rm -rf node_modules/@solidjs/signals node_modules/solid-js node_modules/@solidjs/web && bun install
to get the pristine new builds.handleError /
action / createContext provider / Portal createEffect(mount, m =>
sites); drop any patch upstream fixed.bun patch @solidjs/signals (and bun patch solid-js, bun patch @solidjs/web),
re-apply the hunks below to each build using that build's identifier names
(read them out of the pristine file first — don't assume), node --check
every touched build, then bun patch --commit <path>.bun install, and node -e import
each build (the corrupted prod build was a syntax error that only surfaced
on import, not in typecheck).Patch D is the easy one to re-derive: unlike A/B/C, its edit site lives
in @solidjs/web's readable (non-minified) Portal source, so the target
text createEffect(mount, m => (and solidJs.createEffect(mount, m => in
the .cjs builds) is stable across releases. Re-derive with a literal
replace → createEffect(() => props.mount || document.body, m => across all
four builds. Confirm the bug still reproduces first — Solid may fix it
upstream, in which case drop Patch D entirely (and the release blocker with
it). Verify in real Chromium (the demo), not bun test — happy-dom and
WebKit mask it. See the Chromium dep-cache gotcha when verifying: clear
node_modules/.vite, kill all stray vite processes (a busy port silently
bumps the new server to 5174), and hard-reload to beat the immutable dep
cache.
Bug #1's userland workaround (pendingCommitError) was mandatory in every
primitive that wraps action() — described in solid-bugs.md §1 as a
"library-wide landmine." Patching upstream removes that landmine for all
future primitives without each having to remember the dance.
create-async-source.ts was first written with the workaround, then
collapsed to a plain action(async function* …) once Patch A landed
(2026-04-28); the test suite stays green.
Bug #2 has no clean workaround at all (the spike works around it by
avoiding resolve() for rejection paths entirely); the patch makes
imperative await resolve(...) settle in pure-headless code. Render-tree
<Errored> remains the canonical rejection contract in tests because
the patch's synchronous flush() inside handleError lets the re-thrown
rejection escape resolve's .catch once @solidjs/web is loaded.
Bug #3 (effect-bundle phase asymmetry) is not patched — see
solid-bugs.md §3 for why (semantic change, downstream patch maintenance
cost outweighs ergonomic gain). The create-validator.ts workaround
stays.