# solid/patches.md · versecafe/quoin

[View on GitCafe](https://git.cafe/versecafe/quoin/blob/7e8ad15cbe77ae07f09e7b548cca25c5e56540a5/solid/patches.md)

Repository: [versecafe/quoin](https://git.cafe/versecafe/quoin)

Visibility: public

Requested revision: 7e8ad15cbe77ae07f09e7b548cca25c5e56540a5

Requested commit: 7e8ad15cbe77ae07f09e7b548cca25c5e56540a5

Commit: 7e8ad15cbe77ae07f09e7b548cca25c5e56540a5

Blob: d85f0c1bc0e0339199d09c95e9ed898bf630b3ab

Size: 13085 bytes

[Immutable source](https://git.cafe/versecafe/quoin/blob/7e8ad15cbe77ae07f09e7b548cca25c5e56540a5/solid/patches.md?format=markdown)

````
# 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
if (r instanceof Promise) {
  if (err) return void r.then(run, e => done(undefined, e));
  return void r.then(run, e => restoreTransition(ctx, () => step(e, true)));
}
```

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
const handleError = error => {
  if (el._inFlight !== result) return;
  globalQueue.initTransition(resolveTransition(el));
  const isPending = error instanceof NotReadyError;
  notifyStatus(el, isPending ? STATUS_PENDING : STATUS_ERROR, error);
  el._time = clock;
  if (!isPending) {
    insertSubs(el);
    schedule();
    flush();
  }
};
```

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

## 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
createEffect(mount, m => {                     // m = the Proxy
  const ownerRoot = getDelegatedRoot(treeMarker);
  if (!ownerRoot || ownerRoot.contains(m)) return;   // ownerRoot.contains(<Proxy>)
  registerDelegatedContainer(m, ownerRoot);
  return () => unregisterDelegatedContainer(m, ownerRoot);
});
```

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
createEffect(() => props.mount || document.body, m => {
  const ownerRoot = getDelegatedRoot(treeMarker);
  if (!ownerRoot || ownerRoot.contains(m)) return;
  registerDelegatedContainer(m, ownerRoot);
  return () => unregisterDelegatedContainer(m, ownerRoot);
});
```

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.

````
