Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/latest-shadow-owned-write.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/signals/src/core/verdict.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ function getLatestValueComputed<T>(el: Signal<T> | Computed<T>): Computed<T> {
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);
Expand Down
117 changes: 117 additions & 0 deletions packages/signals/tests/latest-shadow-owned-write.test.ts
Original file line number Diff line number Diff line change
@@ -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() ? <p>Latest: {latest(copy)}</p> : "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();
});
}
});
69 changes: 69 additions & 0 deletions packages/web/test/latest-shadow-owned-write-issue-3378.spec.tsx
Original file line number Diff line number Diff line change
@@ -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<void>(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 <main>{show() ? <p>Latest: {latest(copy)}</p> : <p>hidden</p>}</main>;
}, 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();
});
Loading