From f91a9b09719344367a3eda8174e8a047dbe23a44 Mon Sep 17 00:00:00 2001 From: Junaid Khan Date: Thu, 5 Nov 2026 13:37:53 +0530 Subject: [PATCH 1/2] feat(signals): add DEV.setSignal for signals and store slots --- packages/signals/src/core/dev.ts | 49 +++++++++++++++++- packages/signals/src/core/index.ts | 2 + packages/signals/src/store/store.ts | 17 +++++++ packages/signals/tests/dev.test.ts | 78 +++++++++++++++++++++++++++++ 4 files changed, 144 insertions(+), 2 deletions(-) diff --git a/packages/signals/src/core/dev.ts b/packages/signals/src/core/dev.ts index cfb5b9fd40..c8732dcb02 100644 --- a/packages/signals/src/core/dev.ts +++ b/packages/signals/src/core/dev.ts @@ -22,8 +22,16 @@ import type { // Cycle note: core.ts imports this module; we read its live `context` binding // only at call time (emitDiagnostic's default subject), never during module // evaluation, so the cycle is inert — same shape as the attribution.ts edge. -import { callbackDepth, context, disposalDepth, resetDisposalDepth, untrack } from "./core.js"; -import { STATUS_UNINITIALIZED } from "./constants.js"; +import { + callbackDepth, + context, + disposalDepth, + resetDisposalDepth, + runWithOwner, + setSignal as writeSignal, + untrack +} from "./core.js"; +import { CONFIG_SLOT_NODE, STATUS_UNINITIALIZED } from "./constants.js"; import { NotReadyError } from "./error.js"; import type { Computed, Link, Owner, Root, Signal } from "./types.js"; @@ -513,6 +521,16 @@ export interface Dev { getParent: typeof getParent; getSources: typeof getSources; getObservers: typeof getObservers; + /** + * Write a signal or store-slot node from outside the app's setters — + * for example from a devtools state editor that only holds the node from + * `getSignals` / `getSources`. Runs as an unowned write (devtools UI + * events are not owned scopes). Store property slots go through the + * store setter path so `onStoreNodeUpdate` and backing state stay in sync. + * + * @see https://github.com/solidjs/solid/issues/4679 + */ + setSignal: typeof setDevSignal; /** Console face of an emitted event — see `reportDiagnostic`. */ report(entry: DiagnosticEvent): void; /** @@ -528,6 +546,32 @@ export interface Dev { guideUrl(code: DiagnosticCode): string; } +type DevStoreSlotWriter = (node: Signal, value: unknown) => void; +let writeDevStoreSlot: DevStoreSlotWriter | undefined; + +/** + * @internal The store package registers the slot-write path so this module + * does not import the store (avoids a core ↔ store cycle). + */ +export function registerDevStoreSlotWriter(fn: DevStoreSlotWriter): void { + writeDevStoreSlot = fn; +} + +/** + * Devtools write entry for a reactive node. Prefer the app's own setters in + * application code; this exists for tools that only have the node. + */ +export function setDevSignal(node: Signal, value: unknown): void { + if (!__DEV__) return; + runWithOwner(null, () => { + if (node._config & CONFIG_SLOT_NODE && writeDevStoreSlot) { + writeDevStoreSlot(node, value); + return; + } + writeSignal(node, value as any); + }); +} + // A dev build without the wiring is a build whose checks emit into a channel // nobody can subscribe to. Fail at module init rather than at the first // silently dropped finding. @@ -756,6 +800,7 @@ export const DEV: Dev = __DEV__ getParent, getSources, getObservers, + setSignal: setDevSignal, report: reportDiagnostic, guideUrl(code) { return GUIDE_URL + code.toLowerCase().replace(/_/g, "-"); diff --git a/packages/signals/src/core/index.ts b/packages/signals/src/core/index.ts index 4697494533..45061365f7 100644 --- a/packages/signals/src/core/index.ts +++ b/packages/signals/src/core/index.ts @@ -68,6 +68,8 @@ export { DEV, OBSERVE, setConsoleFooter, + registerDevStoreSlotWriter, + setDevSignal, type AttributionSlot, type Dev, type Observe, diff --git a/packages/signals/src/store/store.ts b/packages/signals/src/store/store.ts index ade0477285..2d865f3b79 100644 --- a/packages/signals/src/store/store.ts +++ b/packages/signals/src/store/store.ts @@ -77,6 +77,7 @@ import { checkPostAwaitRead, DEV, emitDiagnostic, + registerDevStoreSlotWriter, registerGraph, reportDiagnostic, warnStrictReadUntracked @@ -2874,3 +2875,19 @@ function snapshotRecord( } return copy ?? src; } + +// Devtools `DEV.setSignal` for store property slots (#4679): go through the +// store setter so backing state and `onStoreNodeUpdate` stay consistent. +if (__DEV__) { + registerDevStoreSlotWriter((node, value) => { + const host = (node as any)._host as StoreTarget; + const key = (node as any)._key as PropertyKey; + if (!host?.px || key === $TRACK) { + setSignal(node, value as any); + return; + } + storeSetter(host.px, (draft: any) => { + draft[key] = value; + }); + }); +} diff --git a/packages/signals/tests/dev.test.ts b/packages/signals/tests/dev.test.ts index 1792b7e586..37eb369c5f 100644 --- a/packages/signals/tests/dev.test.ts +++ b/packages/signals/tests/dev.test.ts @@ -362,4 +362,82 @@ describe("Phase 1c: Graph traversal helpers", () => { expect(isDisposed(savedOwner!)).toBe(true); }); }); + + describe("setSignal (#4679)", () => { + it("writes a plain signal and notifies subscribers", () => { + let seen = -1; + createRoot(() => { + const owner = getOwner()!; + const [count] = createSignal(0, { name: "count" }); + createEffect( + () => count(), + v => { + seen = v; + } + ); + flush(); + expect(seen).toBe(0); + const [node] = getSignals(owner); + DEV!.setSignal(node, 7); + flush(); + expect(count()).toBe(7); + expect(seen).toBe(7); + }); + }); + + it("respects equals and skips notification when unchanged", () => { + const updates: number[] = []; + createRoot(() => { + const owner = getOwner()!; + const [count] = createSignal(1); + createEffect( + () => count(), + v => { + updates.push(v); + } + ); + flush(); + expect(updates).toEqual([1]); + DEV!.setSignal(getSignals(owner)[0], 1); + flush(); + expect(updates).toEqual([1]); + }); + }); + + it("writes a store property slot and fires onStoreNodeUpdate", () => { + const updates: any[] = []; + DEV!.hooks.onStoreNodeUpdate = (state, property, value, prev) => { + updates.push({ state, property, value, prev }); + }; + createRoot(() => { + const [store] = createStore({ count: 0 }); + // Touch the key so a slot node exists for getSources/devtools. + createEffect( + () => store.count, + () => {} + ); + flush(); + const sources = getSources(getChildren(getOwner()!)[0] as any); + const slot = sources.find((n: any) => n._key === "count"); + expect(slot).toBeTruthy(); + DEV!.setSignal(slot!, 9); + flush(); + expect(store.count).toBe(9); + expect(updates.some(u => u.property === "count" && u.value === 9)).toBe(true); + }); + }); + + it("can be called from an owned scope without REACTIVE_WRITE_IN_OWNED_SCOPE", () => { + createRoot(() => { + const owner = getOwner()!; + const [count] = createSignal(0); + createMemo(() => { + DEV!.setSignal(getSignals(owner)[0], 3); + return count(); + })(); + flush(); + expect(count()).toBe(3); + }); + }); + }); }); From c9d301c28f35573a8ccae56b824c1afa699380b6 Mon Sep 17 00:00:00 2001 From: Junaid Khan Date: Thu, 5 Nov 2026 13:41:58 +0530 Subject: [PATCH 2/2] chore: add changeset for DEV.setSignal --- .changeset/dev-set-signal-4679.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/dev-set-signal-4679.md diff --git a/.changeset/dev-set-signal-4679.md b/.changeset/dev-set-signal-4679.md new file mode 100644 index 0000000000..4c13877d8a --- /dev/null +++ b/.changeset/dev-set-signal-4679.md @@ -0,0 +1,5 @@ +--- +"@solidjs/signals": patch +--- + +Add `DEV.setSignal` so development tooling can write signal values and store property slots (#4679).