Skip to content

2.0.0-beta.20: throwing thenable yielded by action leaks its transaction and freezes signals #2918

Description

@brenelz

Describe the bug

In Solid 2.0.0-beta.20, yielding a Promise-like value whose then getter or then() method throws causes the returned action promise to reject, but the action iterator is never removed from its transition.

Any plain signal writes performed before the yield remain permanently held. Later writes to those signals rejoin the leaked transition and also never commit.

This violates the documented support for yielding "promises (or any awaitable)" and the existing action contract that partial plain writes commit when an action rejects.

Your Example Website or App

Focused reproduction against solidjs/solid next at ec432b40:

import { action, createSignal, flush } from "solid-js";

const [value, setValue] = createSignal(0);

const badThenable = Object.defineProperty({}, "then", {
  get() {
    throw new Error("then getter failed");
  }
});

const run = action(function* () {
  setValue(1);
  yield badThenable as PromiseLike<void>;
});

await run().catch(error => {
  console.log(error.message); // "then getter failed"
});

flush();

// Expected: 1. Plain writes before an action rejection commit.
// Actual: 0.
console.log(value());

setValue(2);
flush();

// Expected: 2.
// Actual: still 0; the leaked action keeps the transition incomplete.
console.log(value());

A thenable with a normal getter but a synchronously throwing method has the same failure:

const badThenable = {
  then() {
    throw new Error("then call failed");
  }
};

Steps to Reproduce the Bug or Issue

  1. Start an action and write a plain signal.
  2. Yield an object whose then getter or method throws synchronously.
  3. Catch the returned action promise rejection.
  4. Flush and read the signal.
  5. Write the signal again and flush.
  6. Observe that neither write ever commits.

Expected behavior

Thenable-assimilation failures should reject through the action's normal done(..., failed = true) path, remove the iterator from _actions, and let pre-rejection plain writes commit. Later signal writes must remain usable.

Screenshots or Videos

Not applicable.

Platform

  • OS: macOS
  • Runtime: Node.js 25
  • Version: Solid 2.0.0-beta.20, next at ec432b40

Additional context

The failure is in packages/solid-signals/src/core/action.ts:114-135.

step() catches exceptions from it.next() and it.throw(), but the yielded value is assimilated later in run():

if (isThenable(r.value))
  return void r.value.then(
    v => restoreTransition(ctx, () => step(v)),
    e => restoreTransition(ctx, () => step(e, true))
  );

Both isThenable(r.value) and r.value.then(...) can throw. Those exceptions escape the Promise executor, so JavaScript rejects the returned promise automatically, but Solid never calls done(). The iterator therefore remains in ctx._actions, making transitionComplete() false forever.

isThenable at packages/solid-signals/src/core/async.ts:129-135 directly reads the then property, which is where the getter variant throws.

Existing tests cover rejected promises, generator throws, and normally settling custom thenables, but not failures during thenable assimilation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions