From 13811f571da411a6fdb3165c8aa2953b75abe2bb Mon Sep 17 00:00:00 2001 From: tsushanth <78000697+tsushanth@users.noreply.github.com> Date: Sat, 13 Jun 2026 15:31:04 -0700 Subject: [PATCH 1/2] fix(signals): reset Repeat offset when window goes empty (closes #2767) --- packages/solid-signals/src/map.ts | 5 ++++ packages/solid-signals/tests/repeat.test.ts | 29 +++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/solid-signals/src/map.ts b/packages/solid-signals/src/map.ts index 3cee27b79..c35f8cb12 100644 --- a/packages/solid-signals/src/map.ts +++ b/packages/solid-signals/src/map.ts @@ -325,6 +325,11 @@ function updateRepeat(this: RepeatData): any[] { this._nodes = []; this._mappings = []; this._len = 0; + // Reset offset to match the cleared data. Without this, a subsequent + // nonzero render with a smaller `from` would enter the end-clear loop + // with `prevTo = stale_offset + 0` > `to` and dispose `_nodes[-1]` + // (#2767, repro 2). + this._offset = 0; } if (this._fallback && !this._mappings[0]) { // create fallback diff --git a/packages/solid-signals/tests/repeat.test.ts b/packages/solid-signals/tests/repeat.test.ts index 3cde96708..ebbfd8640 100644 --- a/packages/solid-signals/tests/repeat.test.ts +++ b/packages/solid-signals/tests/repeat.test.ts @@ -284,3 +284,32 @@ it("should retain instances when only `offset` changes", () => { expect(e4.index).toBe(4); expect(computed).toHaveBeenCalledTimes(7); }); + +it("recovers when count goes to 0, from changes, and count comes back nonzero (#2767)", () => { + const [count, setCount] = createSignal(2); + const [from, setFrom] = createSignal(2); + + const map = repeat(count, (index) => ({ index }), { from }); + + let snapshot: any[] = []; + createRoot(() => { + createEffect(map, (rows) => { + snapshot = rows; + }); + }); + flush(); + expect(snapshot.map((r) => r.index)).toEqual([2, 3]); + + // 1. clear rows + setCount(0); + flush(); + expect(snapshot.map((r: any) => r.index)).toEqual([]); + + // 2. show row 0 — simultaneously moves `from` to 0 and `count` to 1. + // Without the `_offset` reset this throws "Cannot read properties of + // undefined (reading 'dispose')" inside updateRepeat. + setFrom(0); + setCount(1); + flush(); + expect(snapshot.map((r: any) => r.index)).toEqual([0]); +}); From e0f80d27c9b4b16bb37bdee5ac880b39b3a5185e Mon Sep 17 00:00:00 2001 From: tsushanth <78000697+tsushanth@users.noreply.github.com> Date: Sat, 13 Jun 2026 15:32:45 -0700 Subject: [PATCH 2/2] fix(signals): await non-Promise thenables yielded from action (closes #2765) --- packages/solid-signals/src/core/action.ts | 21 ++++++++++++++----- packages/solid-signals/tests/action.test.ts | 23 +++++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/packages/solid-signals/src/core/action.ts b/packages/solid-signals/src/core/action.ts index 96937d669..0319d54d4 100644 --- a/packages/solid-signals/src/core/action.ts +++ b/packages/solid-signals/src/core/action.ts @@ -15,6 +15,14 @@ function restoreTransition(transition: Transition, fn: () => T): T { return result; } +function isThenable(value: unknown): value is PromiseLike { + return ( + value != null && + (typeof value === "object" || typeof value === "function") && + typeof (value as { then?: unknown }).then === "function" + ); +} + /** * Wraps a generator function so each invocation runs as a single transaction * (a "transition") that batches every signal/store write between yields. The @@ -75,15 +83,18 @@ export function action( } catch (e) { return done(undefined, e); } - if (r instanceof Promise) - return void r.then(run, e => restoreTransition(ctx, () => step(e, true))); - run(r); + if (isThenable(r)) + return void (r as PromiseLike>).then( + run, + e => restoreTransition(ctx, () => step(e, true)) + ); + run(r as IteratorResult); }; const run = (r: IteratorResult) => { if (r.done) return done(r.value); - if (r.value instanceof Promise) - return void r.value.then( + if (isThenable(r.value)) + return void (r.value as PromiseLike).then( v => restoreTransition(ctx, () => step(v)), e => restoreTransition(ctx, () => step(e, true)) ); diff --git a/packages/solid-signals/tests/action.test.ts b/packages/solid-signals/tests/action.test.ts index d41cd8032..e1a24f4f7 100644 --- a/packages/solid-signals/tests/action.test.ts +++ b/packages/solid-signals/tests/action.test.ts @@ -135,6 +135,29 @@ describe("action", () => { expect(receivedValue).toBe(42); }); + it("should await a non-Promise thenable and resume with its fulfilled value (#2765)", async () => { + let receivedValue: any; + + const thenable = { + then(onFulfilled: (v: number) => void) { + queueMicrotask(() => onFulfilled(42)); + } + }; + + const myAction = action(function* () { + receivedValue = yield thenable; + }); + + myAction(); + // Before the fix, the generator resumed synchronously with the raw + // thenable object because `instanceof Promise` was false; after the + // fix the thenable's `then` callback fires and the resumed value is 42. + expect(receivedValue).toBeUndefined(); + await new Promise(resolve => queueMicrotask(() => resolve(undefined))); + await Promise.resolve(); + expect(receivedValue).toBe(42); + }); + it("should handle multiple async yields in sequence", async () => { const values: number[] = [];