diff --git a/.changeset/fix-lane-derivation-prev-3955.md b/.changeset/fix-lane-derivation-prev-3955.md new file mode 100644 index 000000000..80df8ebd4 --- /dev/null +++ b/.changeset/fix-lane-derivation-prev-3955.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +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 a16a7cdc6..d162fbf42 100644 --- a/packages/signals/src/core/core.ts +++ b/packages/signals/src/core/core.ts @@ -374,12 +374,12 @@ 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 - : 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..634dcfa64 --- /dev/null +++ b/packages/signals/tests/held-latest-derivation-prev-3955.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + createMemo, + createRenderEffect, + createRoot, + createSignal, + flush, + isPending, + latest, + resetErrorHalt +} 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", () => { + 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]; + }; + 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([[], [], []]); + + gates.current.resolve(); + for (let i = 0; i < 4; i++) await Promise.resolve(); + expect(() => flush()).not.toThrow(); + expect(prevs).toEqual(read === "isPending" ? [[], [], [], []] : [[], [], []]); + dispose(); + }); + } + } +});