solid/patches.md

Solid 2.0 beta 14 — local patches

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 _inFlight is now e.ke, _time is e._e, globalQueue is z, the status constants are _/m) and the handleAsync/action regions shifted, so bun's fuzzy apply spliced the hunks into the wrong functions (addPendingSource) and silently corrupted all three builds — in prod.js it even produced a stray } that made the package fail to import. Regenerated from pristine beta.14 via bun patch. To re-derive after a future bump, see Removal / re-derivation below.


Patch A — fix 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.

js
1if (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.

Patch B — wake subs on async-memo rejection (bug #2)

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:

js
1const 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).

Patch C — untrack context value write in 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).

js
1// dist/dev.js (ESM) — bare bindings
2untrack(() => setContext(provider, props.value));
3// dist/dev.cjs (CJS) — signals.* namespace
4signals.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.

Patch D — 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:

js
1createEffect(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.

js
1createEffect(() => 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. patchedDependencies is 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.14 every portalled quoin component 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.


Verification

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.


Removal

When upstream ships a fix:

  1. Bump solid-js / @solidjs/web / @solidjs/signals / babel-preset-solid to the version that includes the fix.
  2. 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.
  3. Delete the relevant patches/*.patch file(s) and the matching key(s) in the patchedDependencies block of package.json.
  4. Cross off the relevant entries in 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.

Removal / re-derivation after a version bump

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:

  1. Empty 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.
  2. Confirm each bug still exists in pristine (grep the handleError / action / createContext provider / Portal createEffect(mount, m => sites); drop any patch upstream fixed.
  3. 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>.
  4. Verify markers after a final clean 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.


Why patch over workaround

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.