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/dev-set-signal-4679.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solidjs/signals": patch
---

Add `DEV.setSignal` so development tooling can write signal values and store property slots (#4679).
49 changes: 47 additions & 2 deletions packages/signals/src/core/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
/**
Expand All @@ -528,6 +546,32 @@ export interface Dev {
guideUrl(code: DiagnosticCode): string;
}

type DevStoreSlotWriter = (node: Signal<any>, 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<any>, 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.
Expand Down Expand Up @@ -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, "-");
Expand Down
2 changes: 2 additions & 0 deletions packages/signals/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export {
DEV,
OBSERVE,
setConsoleFooter,
registerDevStoreSlotWriter,
setDevSignal,
type AttributionSlot,
type Dev,
type Observe,
Expand Down
17 changes: 17 additions & 0 deletions packages/signals/src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import {
checkPostAwaitRead,
DEV,
emitDiagnostic,
registerDevStoreSlotWriter,
registerGraph,
reportDiagnostic,
warnStrictReadUntracked
Expand Down Expand Up @@ -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;
});
});
}
78 changes: 78 additions & 0 deletions packages/signals/tests/dev.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
});