diff --git a/.changeset/latest-shadow-owned-write.md b/.changeset/latest-shadow-owned-write.md new file mode 100644 index 000000000..815d2a6b5 --- /dev/null +++ b/.changeset/latest-shadow-owned-write.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Give the latest() shadow companion the `ownedWrite` flag its isPending companion already carries (#3378). A companion sync is internal plumbing that can run from inside a computation — a transition-held memo recompute pulled mid-tick by a reader creating or refreshing its latest() shadow — and the dev owned-scope write guard halted the app on the shadow write. Toggling a JSX branch that reads `latest(memo)` off while an action is pending and restoring it as the action resumes threw REACTIVE_WRITE_IN_OWNED_SCOPE. diff --git a/packages/signals/src/core/verdict.ts b/packages/signals/src/core/verdict.ts index c6330b038..b7966361c 100644 --- a/packages/signals/src/core/verdict.ts +++ b/packages/signals/src/core/verdict.ts @@ -364,7 +364,7 @@ function getLatestValueComputed(el: Signal | Computed): Computed { setPendingCheckActive(false); const prevContext = context; setContextInternal(null); // Detach from owner so it isn't disposed with effects - lvc = optimisticComputed(() => read(el)); + lvc = optimisticComputed(() => read(el), { ownedWrite: true }); ext(el)._latestValueComputed = lvc; el._config |= CONFIG_HAS_COMPANIONS; markFirewallChildCompanions(el); diff --git a/packages/signals/tests/latest-shadow-owned-write.test.ts b/packages/signals/tests/latest-shadow-owned-write.test.ts new file mode 100644 index 000000000..3c1cf9dee --- /dev/null +++ b/packages/signals/tests/latest-shadow-owned-write.test.ts @@ -0,0 +1,117 @@ +/** + * #3378: the latest() shadow was the one companion created without + * `ownedWrite`. Companion syncs are internal plumbing that can run from inside + * a computation: a transition-held memo recompute pulled mid-tick by a reader + * (core.ts's held branch of recompute → syncCompanions → setSignal on the + * shadow) fires with `context` set to the pulling node. The isPending signal + * companion already carried the flag, so only the shadow write tripped the + * dev owned-scope write guard — and halted the app. + * + * The issue's shape: a branch reading `latest(memo)` is toggled off while an + * action is pending (its shadow goes dormant with the reader) and restored as + * the action resumes. The restored reader runs before the memo's own heap + * slot, so the fresh shadow's first compute pulls the still-dirty memo, whose + * held recompute syncs companions from inside that compute. A live shadow + * brought current through latestRead's mid-tick pull hits the same write with + * the pulling reader as context. + */ +import { describe, expect, it } from "vitest"; +import { + action, + createMemo, + createRenderEffect, + createRoot, + createSignal, + flush, + latest +} from "../src/index.js"; + +const tick = async () => { + await new Promise(r => setTimeout(r, 0)); + flush(); +}; + +function mount(keepShadowAlive: boolean) { + const log: string[] = []; + const errors: Error[] = []; + let setCount!: (v: number) => void; + let setShow!: (v: boolean) => void; + let dispose!: () => void; + createRoot(d => { + dispose = d; + const [count, s1] = createSignal(0); + setCount = s1; + const [show, s2] = createSignal(true); + setShow = s2; + const copy = createMemo(() => count()); + // A permanent reader keeps the memo's shadow alive across the toggle, so + // the restored branch pulls the EXISTING shadow current instead of + // creating a fresh one. + if (keepShadowAlive) + createRenderEffect( + () => latest(() => copy()), + () => {} + ); + // `{show() ?

Latest: {latest(copy)}

: "hidden"}`: the branch mounts + // a nested reader inside the conditional's own render effect. + createRenderEffect( + () => { + if (!show()) return "hidden"; + createRenderEffect( + () => { + try { + return latest(() => copy()); + } catch (e) { + errors.push(e as Error); + throw e; + } + }, + v => { + log.push(`latest:${v}`); + } + ); + return "branch"; + }, + v => { + log.push(`cond:${v}`); + } + ); + }); + flush(); + return { log, errors, setCount, setShow, dispose }; +} + +describe("latest() shadow companion writes from inside a computation (#3378)", () => { + for (const keepShadowAlive of [false, true]) { + it(`restoring a latest(memo) branch as an action resumes (${ + keepShadowAlive ? "live shadow pulled current" : "fresh shadow" + })`, async () => { + const { log, errors, setCount, setShow, dispose } = mount(keepShadowAlive); + expect(log).toEqual(["latest:0", "cond:branch"]); + + // Toggle the branch off while nothing is held. + setShow(false); + flush(); + expect(log[log.length - 1]).toBe("cond:hidden"); + + // The action resumes after its async gap: restore the branch and write + // the memo's source in one held slice. The conditional runs first, and + // its nested reader pulls the dirty memo through latest(). + const run = action(async function* () { + await new Promise(r => setTimeout(r, 0)); + yield; + setShow(true); + setCount(1); + }); + const done = run(); + flush(); + await tick(); + await done; + await tick(); + + expect(errors).toEqual([]); + expect(log.slice(-2)).toEqual(["latest:1", "cond:branch"]); + dispose(); + }); + } +}); diff --git a/packages/web/test/latest-shadow-owned-write-issue-3378.spec.tsx b/packages/web/test/latest-shadow-owned-write-issue-3378.spec.tsx new file mode 100644 index 000000000..1cfd67e9d --- /dev/null +++ b/packages/web/test/latest-shadow-owned-write-issue-3378.spec.tsx @@ -0,0 +1,69 @@ +/** + * @jsxImportSource @solidjs/web + * @vitest-environment jsdom + * + * #3378: a JSX branch reading `latest(memo)` toggled off while an action is + * pending and restored as the action resumes halted dev with + * REACTIVE_WRITE_IN_OWNED_SCOPE. The restored branch's insert effect runs + * before the memo's own heap slot and pulls the still-dirty memo through the + * fresh latest() shadow; the memo's held recompute then synced its shadow + * companion from inside that compute, and the shadow — unlike the isPending + * companion — was created without `ownedWrite`. + */ +import { afterEach, expect, test, vi } from "vitest"; +import { action, createMemo, createSignal, flush, latest } from "solid-js"; +import { render } from "../src/index.js"; + +function deferred() { + let resolve!: () => void; + const promise = new Promise(r => (resolve = r)); + return { promise, resolve }; +} + +async function settle() { + for (let i = 0; i < 8; i++) await Promise.resolve(); + flush(); +} + +afterEach(() => vi.restoreAllMocks()); + +test("restoring a branch that reads latest(memo) as an action resumes (#3378)", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const div = document.createElement("div"); + const [count, setCount] = createSignal(0); + const [show, setShow] = createSignal(true); + const gate = deferred(); + + const dispose = render(() => { + // Derived through another memo so `copy` sits at the branch effect's own + // heap height and the restore's read pulls it before its heap slot. + const doubled = createMemo(() => count() * 2); + const copy = createMemo(() => doubled()); + return
{show() ?

Latest: {latest(copy)}

:

hidden

}
; + }, div); + flush(); + expect(div.textContent).toBe("Latest: 0"); + + // Toggle the branch off while nothing is held: its shadow goes dormant. + setShow(false); + flush(); + expect(div.textContent).toBe("hidden"); + + // The action resumes after its async gap and restores the branch in the + // same held slice that writes the memo's source. + const run = action(async function* () { + await gate.promise; + yield; + setShow(true); + setCount(1); + }); + void run(); + flush(); + gate.resolve(); + await settle(); + await settle(); + + expect(error).not.toHaveBeenCalled(); + expect(div.textContent).toBe("Latest: 2"); + dispose(); +});