Skip to content

[2.0] The MIGRATION.md replacement for from() keeps the upstream subscription after dispose; add a subscription helper #4182

Closed

Description

@devagrawal09

Describe the bug

documentation/solid-2.0/MIGRATION.md ("from / observable → async iterators / effects") recommends:

const value = createMemo(async function* () {
  for await (const val of observable$) yield val;
});

After the owner is disposed, the upstream iterator's return() is not called. It runs only when the upstream produces another value, which may never happen. The cause is that the generator is suspended at an await (inside for await), so its return() is queued behind the pending step. Solid's side is correct: consumeIterator calls return() on dispose and on supersede.

Your Example Website or App

Minimal repro inline below (signals-only).

Steps to Reproduce the Bug or Issue

import { createMemo, createRoot, createRenderEffect, flush } from "@solidjs/signals";

let push: (v: number) => void;
let closed = 0;
const observable$: AsyncIterable<number> = {
  [Symbol.asyncIterator]: () => ({
    next: () => new Promise(r => (push = v => r({ value: v, done: false }))),
    return: async () => { closed++; return { value: undefined, done: true }; }
  })
};

const dispose = createRoot(d => {
  const value = createMemo(async function* () { for await (const v of observable$) yield v; }, { loadingValue: 0 });
  createRenderEffect(value, v => console.log("value", v));
  return d;
});
flush(); push!(1); /* await a few microtasks */ flush(); // logs 0, 1

dispose(); /* await 50 microtasks */
console.log(closed); // 0: upstream still subscribed
push!(2);  /* await */
console.log(closed); // 1: closes only after the next upstream value

By contrast, a hand-written iterator whose return() unsubscribes synchronously behaves well. I tested:

  • isPending stays false while the memo waits for the next value;
  • loadingValue covers the first read;
  • return() unsubscribes immediately on dispose;
  • bursts coalesce into one update with a dirty flag.

It's about 20 lines and easy to get wrong:

function fromSubscription<T>(subscribe: (cb: () => void) => () => void, getSnapshot: () => T): AsyncIterable<T> {
  return {
    [Symbol.asyncIterator]() {
      let dirty = true, done = false;
      let wake: ((r: IteratorResult<T>) => void) | undefined;
      const unsubscribe = subscribe(() => {
        dirty = true;
        if (wake) { const w = wake; wake = undefined; dirty = false; w({ value: getSnapshot(), done: false }); }
      });
      return {
        next: () => {
          if (done) return Promise.resolve({ value: undefined, done: true });
          if (dirty) { dirty = false; return Promise.resolve({ value: getSnapshot(), done: false }); }
          return new Promise(r => (wake = r));
        },
        return: () => {
          done = true; unsubscribe();
          wake?.({ value: undefined, done: true }); wake = undefined;
          return Promise.resolve({ value: undefined, done: true });
        }
      };
    }
  };
}

Expected behavior

  • Docs: fix the MIGRATION.md snippet so disposal actually unsubscribes, and document (or flag in dev) that async function* + await delays teardown until the next upstream value.
  • API: provide a helper equivalent to React's useSyncExternalStore / Solid 1's from:
    const online = createMemo(() => fromSubscription(subscribe, () => navigator.onLine), { loadingValue: true });
    MIGRATION.md says this is expected to "move into @solid-primitives". Given how central external stores are to incremental migrations (keeping existing domain stores and putting Solid on top), it may be worth a minimal version in core. Either way, a correct, reusable implementation should exist somewhere official.

Screenshots or Videos

No response

Platform

  • OS: macOS (Darwin 25.5)
  • Browser: n/a (reproduced in Node v24.18.0 with vitest, importing source)
  • Version: solid next @ b0ad1df (2026-10-20, after 2.0.0-rc.16)

Additional context

This came up while documenting useSyncExternalStore for the From React page (solidjs/solid-docs#1664). The From React additions describe the hand-written iterator and warn against async function* for this.

Activity

  1. devagrawal09 commented on Oct 21, 2026

    @devagrawal09
    Author

    Prompt cleanup for async function* computes needs a hook from Solid

    Follow-up to the MIGRATION.md note in #4183. For now, that PR accepts that cleanup is delayed for generator-based from() replacements. Here's why there isn't a good user-side fix, and some options for a proper one.

    The problem: JavaScript can't interrupt an await. A generator paused at await source.next() only resumes when that promise settles. Solid already tears down a replaced or disposed stream immediately (_flightTeardown → the iterator's return()), but for a generator that return() is queued behind the pending await. Whatever closes the source has to run outside the generator, at teardown time, and user code has no hook for that moment.

    What I tried on next @ b0ad1df: a memo whose source is chosen by a signal, plus a filter and a scale. Source A is replaced by B, then the owner is disposed.

    Pattern Old source closed on replace Closed on dispose Usable in docs?
    async function* + for await (the current MIGRATION.md example) after A's next value after the next upstream value yes, cleanup is late
    Return the iterable directly immediately immediately only when forwarding values
    onCleanup(() => it.return()) in the compute only after B's first value immediately no
    onCleanup inside the generator body, before the first await only after B's first value immediately no
    Hand-wrapping the generator so its return() closes the source first immediately immediately technically correct, but ~15 lines of boilerplate

    onCleanup falls short because cleanups registered during a run are held until the replacement value commits. That's fine for values, but a replaced run's values are thrown away anyway, so keeping its source alive gains nothing.

    Options:

    1. A cancellation signal for each async run, aborted synchronously when the run is replaced or disposed. For example, createMemo(async function* (prev, { signal }) { … }) or a getAbortSignal() usable before the first await. It's standard, works with fetch and anything else that accepts AbortSignal, and also covers cancelling replaced requests (not just streams).
    2. An iterate(source) helper built on option 1, so for await (const v of iterate(source)) finishes and closes the source on teardown. User code stays a plain generator. It pairs naturally with the fromSubscription helper this issue already asks for.
    3. Run onCleanup callbacks registered during an async run when that run is torn down, rather than when its replacement commits. This adds no API, and code people already write would start working. But it changes a timing contract, so it needs a decision on whether anything relies on held cleanups for replaced runs.

    My preference is 1 with 2 on top. 3 is the smallest change if held cleanups for replaced runs aren't relied on anywhere.

  2. ryansolid commented on Oct 22, 2026

    @ryansolid
    Member

    Fixed on next by #4218: from(source) adapts a subscribe function, an Observable or an AsyncIterable, and closes the source when the memo re-runs or is disposed. MIGRATION.md now shows createMemo(() => from(source)) in place of the hand-written generator. One limit remains: a for await over from that starts after an async generator's first await has no owner, so it closes only when the loop ends. That is documented in MIGRATION.md and the JSDoc.

    — 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