From fe83b040c60d6fe74188b08fee2e35635435b4a6 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Fri, 9 Oct 2026 15:17:43 +0000 Subject: [PATCH 1/2] fix(signals): pass a lane node's committed value as prev (#3955) A derivation that reads `latest()` or `isPending()` of a signal an in-flight async memo is holding enters the verdict lane with `_x._lane` still `NOT_PENDING`. When that first lane pass is equal to the previous value, nothing writes the slot, so the next pass under the hold received the `NOT_PENDING` marker as `prev` and a `createSignal((prev = []) => [...prev])` threw `prev is not iterable`, halting the reactive system. The pass now falls back to the committed value when the slot is empty, as `compareValue` already does. --- .changeset/fix-lane-derivation-prev-3955.md | 5 ++ packages/signals/src/core/core.ts | 2 +- .../held-latest-derivation-prev-3955.test.ts | 69 +++++++++++++++++++ 3 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-lane-derivation-prev-3955.md create mode 100644 packages/signals/tests/held-latest-derivation-prev-3955.test.ts diff --git a/.changeset/fix-lane-derivation-prev-3955.md b/.changeset/fix-lane-derivation-prev-3955.md new file mode 100644 index 000000000..6594aba59 --- /dev/null +++ b/.changeset/fix-lane-derivation-prev-3955.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +A derivation re-running under a lane whose first pass was equal receives its committed value as `prev` instead of the empty `NOT_PENDING` marker. diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index a16a7cdc6..cf7c138e6 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -379,7 +379,7 @@ export function recompute(el: Computed, create: boolean = false): void { let value = el._pendingValue !== NOT_PENDING ? el._pendingValue - : el._config & CONFIG_OVERRIDE + : el._config & CONFIG_OVERRIDE && el._x!._lane !== NOT_PENDING ? el._x!._lane : el._value; let oldHeight = el._height; diff --git a/packages/signals/tests/held-latest-derivation-prev-3955.test.ts b/packages/signals/tests/held-latest-derivation-prev-3955.test.ts new file mode 100644 index 000000000..3e5bbdb36 --- /dev/null +++ b/packages/signals/tests/held-latest-derivation-prev-3955.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { + createMemo, + createRenderEffect, + createRoot, + createSignal, + flush, + isPending, + latest +} from "../src/index.js"; + +function deferred() { + let resolve!: (v: T) => void; + const promise = new Promise(r => (resolve = r)); + return { promise, resolve }; +} + +async function tick(n = 4) { + for (let i = 0; i < n; i++) await Promise.resolve(); + flush(); +} + +describe("a derivation reading a held signal through latest()/isPending() keeps its prev", () => { + for (const read of ["latest", "isPending"] as const) { + it(`${read}(): an equal pass under the hold does not turn the next pass's prev into NOT_PENDING`, async () => { + const prevs: unknown[] = []; + const gates = { current: deferred() }; + let setFilter!: (v: string) => void; + let dispose!: () => void; + createRoot(d => { + dispose = d; + const [filter, set] = createSignal("a"); + setFilter = set; + const data = createMemo(async () => { + const f = filter(); + await gates.current.promise; + return f; + }); + createRenderEffect(data, () => {}); + const [names] = createSignal( + (prev = []) => { + read === "latest" ? latest(filter) : isPending(filter); + prevs.push(prev); + return [...prev]; + }, + { equals: (a, b) => a.length === b.length } + ); + createRenderEffect(names, () => {}); + }); + flush(); + const mountGate = gates.current; + gates.current = deferred(); + mountGate.resolve(); + await tick(); + expect(prevs).toEqual([[]]); + + setFilter("b"); + flush(); + setFilter("c"); + expect(() => flush()).not.toThrow(); + await tick(); + expect(prevs).toEqual([[], [], []]); + + gates.current.resolve(); + await tick(); + dispose(); + }); + } +}); From e8dcf6cd6dd2a339ff0e6b08683ccad7fb43d818 Mon Sep 17 00:00:00 2001 From: Brenley Dueck Date: Fri, 9 Oct 2026 15:30:38 +0000 Subject: [PATCH 2/2] fix: address review findings The comment above the pass's previous value in `recompute` now says a lane node's lane value is used only when the slot holds one, matching the guarded expression beneath it. The #3955 test now covers `createMemo(fn)` beside `createSignal(fn)`, asserts that the landing after the equal lane passes flushes without throwing and records only arrays, and resets a halted system after each case so each case fails on its own pre-fix. The changeset describes the thrown `prev is not iterable` instead of the internal marker. --- .changeset/fix-lane-derivation-prev-3955.md | 2 +- packages/signals/src/core/core.ts | 4 +- .../held-latest-derivation-prev-3955.test.ts | 93 +++++++++++-------- 3 files changed, 55 insertions(+), 44 deletions(-) diff --git a/.changeset/fix-lane-derivation-prev-3955.md b/.changeset/fix-lane-derivation-prev-3955.md index 6594aba59..80df8ebd4 100644 --- a/.changeset/fix-lane-derivation-prev-3955.md +++ b/.changeset/fix-lane-derivation-prev-3955.md @@ -2,4 +2,4 @@ "@solidjs/signals": patch --- -A derivation re-running under a lane whose first pass was equal receives its committed value as `prev` instead of the empty `NOT_PENDING` marker. +A `createSignal(fn)` or `createMemo(fn)` derivation that reads `latest()` or `isPending()` of a signal an in-flight async memo is holding receives its previous value as `prev` on every re-run under the hold. When its first pass under the hold was equal to the previous value, the next pass received an empty internal marker instead, so `(prev = []) => [...prev]` threw `prev is not iterable` and halted the reactive system. diff --git a/packages/signals/src/core/core.ts b/packages/signals/src/core/core.ts index cf7c138e6..d162fbf42 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -374,8 +374,8 @@ export function recompute(el: Computed, create: boolean = false): void { // — here, the finally below, updateIfNecessary — carries it (#3543). el._flags = REACTIVE_RECOMPUTING_DEPS | (el._flags & REACTIVE_ZOMBIE); el._time = clock; - // The pass's previous value: its staging, else the lane's value for a - // lane's node (lanes.ts), else the committed one. + // The pass's previous value: its staging, else a lane node's lane value + // when it has one (lanes.ts), else the committed one. let value = el._pendingValue !== NOT_PENDING ? el._pendingValue diff --git a/packages/signals/tests/held-latest-derivation-prev-3955.test.ts b/packages/signals/tests/held-latest-derivation-prev-3955.test.ts index 3e5bbdb36..634dcfa64 100644 --- a/packages/signals/tests/held-latest-derivation-prev-3955.test.ts +++ b/packages/signals/tests/held-latest-derivation-prev-3955.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { createMemo, createRenderEffect, @@ -6,7 +6,8 @@ import { createSignal, flush, isPending, - latest + latest, + resetErrorHalt } from "../src/index.js"; function deferred() { @@ -21,49 +22,59 @@ async function tick(n = 4) { } describe("a derivation reading a held signal through latest()/isPending() keeps its prev", () => { - for (const read of ["latest", "isPending"] as const) { - it(`${read}(): an equal pass under the hold does not turn the next pass's prev into NOT_PENDING`, async () => { - const prevs: unknown[] = []; - const gates = { current: deferred() }; - let setFilter!: (v: string) => void; - let dispose!: () => void; - createRoot(d => { - dispose = d; - const [filter, set] = createSignal("a"); - setFilter = set; - const data = createMemo(async () => { - const f = filter(); - await gates.current.promise; - return f; - }); - createRenderEffect(data, () => {}); - const [names] = createSignal( - (prev = []) => { + afterEach(() => { + resetErrorHalt(); + }); + + for (const kind of ["createSignal", "createMemo"] as const) { + for (const read of ["latest", "isPending"] as const) { + it(`${kind} + ${read}(): an equal pass under the hold does not turn the next pass's prev into NOT_PENDING`, async () => { + const prevs: unknown[] = []; + const gates = { current: deferred() }; + let setFilter!: (v: string) => void; + let dispose!: () => void; + createRoot(d => { + dispose = d; + const [filter, set] = createSignal("a"); + setFilter = set; + const data = createMemo(async () => { + const f = filter(); + await gates.current.promise; + return f; + }); + createRenderEffect(data, () => {}); + const compute = (prev: string[] = []) => { read === "latest" ? latest(filter) : isPending(filter); prevs.push(prev); return [...prev]; - }, - { equals: (a, b) => a.length === b.length } - ); - createRenderEffect(names, () => {}); - }); - flush(); - const mountGate = gates.current; - gates.current = deferred(); - mountGate.resolve(); - await tick(); - expect(prevs).toEqual([[]]); + }; + const equals = (a: string[], b: string[]) => a.length === b.length; + const names = + kind === "createSignal" + ? createSignal(compute, { equals })[0] + : createMemo(compute, { equals }); + createRenderEffect(names, () => {}); + }); + flush(); + const mountGate = gates.current; + gates.current = deferred(); + mountGate.resolve(); + await tick(); + expect(prevs).toEqual([[]]); - setFilter("b"); - flush(); - setFilter("c"); - expect(() => flush()).not.toThrow(); - await tick(); - expect(prevs).toEqual([[], [], []]); + setFilter("b"); + flush(); + setFilter("c"); + expect(() => flush()).not.toThrow(); + await tick(); + expect(prevs).toEqual([[], [], []]); - gates.current.resolve(); - await tick(); - dispose(); - }); + gates.current.resolve(); + for (let i = 0; i < 4; i++) await Promise.resolve(); + expect(() => flush()).not.toThrow(); + expect(prevs).toEqual(read === "isPending" ? [[], [], [], []] : [[], [], []]); + dispose(); + }); + } } });