diff --git a/.changeset/live-state-editor.md b/.changeset/live-state-editor.md
new file mode 100644
index 0000000..6917d8e
--- /dev/null
+++ b/.changeset/live-state-editor.md
@@ -0,0 +1,12 @@
+---
+'@solidjs/start-devtools': minor
+---
+
+Add live state and store editing to the dev toolbar.
+
+Signals and stores can now be inspected and edited live on-the-fly directly from the devtools:
+
+- In the Reactivity Graph detail pane, primitive values and nested properties of selected signals and store slots can be edited inline.
+- In the Ownership Tree detail pane, component signals feature inline editing with immediate updates to dependent memos, effects, and DOM elements.
+- Boolean values support 1-click toggling, while strings, numbers, null, undefined, and JSON objects/arrays are parsed and written through Solid's internal reactivity scheduler.
+- Values inside a `Map`, a `Set`, a typed array or a class instance are shown but not editable. An edit the app does not take is marked in the panel.
diff --git a/src/dev-toolbar/icons.tsx b/src/dev-toolbar/icons.tsx
index 9e8dbe0..2aaceae 100644
--- a/src/dev-toolbar/icons.tsx
+++ b/src/dev-toolbar/icons.tsx
@@ -760,3 +760,23 @@ export function InspectIcon(props: JSX.IntrinsicElements['svg'] & { title: strin
);
}
+
+export function EditIcon(props: JSX.IntrinsicElements['svg'] & { title: string }): JSX.Element {
+ return (
+
+ {props.title}
+
+
+ );
+}
diff --git a/src/dev-toolbar/ownership/index.tsx b/src/dev-toolbar/ownership/index.tsx
index c043692..c19a430 100644
--- a/src/dev-toolbar/ownership/index.tsx
+++ b/src/dev-toolbar/ownership/index.tsx
@@ -4,11 +4,15 @@ import { Badge } from '../../ui/Badge.js';
import IconButton from '../../ui/IconButton.js';
import Placeholder from '../../ui/Placeholder.js';
import { Text } from '../../ui/Text.js';
-import { CollapseIcon, ExpandIcon, EyeIcon, PauseIcon, PlayIcon } from '../icons.js';
+import { CollapseIcon, EditIcon, ExpandIcon, EyeIcon, PauseIcon, PlayIcon } from '../icons.js';
import { openInEditor } from '../open-in-editor.js';
import { CopyReportButton } from '../report/CopyReportButton.js';
import { pageRoute } from '../report/markdown.js';
import { ownerReport } from '../report/owner-report.js';
+import { createDraftEdit } from '../reactivity/draft-edit.js';
+import { DraftInput } from '../reactivity/DraftInput.js';
+import { isTextEditable, writeReactiveValue } from '../reactivity/value-editor.js';
+import { ValueInspector } from '../reactivity/ValueInspector.js';
import { previewValue, typeName } from './format.js';
import {
excludeOwner,
@@ -54,6 +58,132 @@ export interface OwnershipViewerProps {
focus?: { owner: object };
}
+interface OwnershipSignalRowProps {
+ signal: { name: string; value: unknown; node: object };
+ onViewInGraph?: (node: object) => void;
+}
+
+function OwnershipSignalRow(props: OwnershipSignalRowProps): JSX.Element {
+ const [expanded, setExpanded] = createSignal(false);
+ const edit = createDraftEdit({
+ value: () => props.signal.value,
+ write: (nextValue) => writeReactiveValue(props.signal.node, [], nextValue),
+ });
+
+ const isComplex = createMemo(
+ () => typeof props.signal.value === 'object' && props.signal.value !== null,
+ );
+ // A Map, a Date or a class instance would come back from JSON as a plain
+ // object, so only values that survive the round trip are edited as text.
+ const textEditable = createMemo(() => isTextEditable(props.signal.value));
+
+ function valueTitle(): string {
+ if (edit.failed()) return 'The app did not take this edit';
+ if (!textEditable()) return isComplex() ? 'Click to expand' : '';
+ return isComplex() ? 'Click to expand / double-click to edit JSON' : 'Double-click to edit';
+ }
+
+ return (
+
+
+
+
+ setExpanded((v) => !v)}
+ aria-label={expanded() ? 'Collapse value' : 'Expand value'}
+ />
+
+
+ {props.signal.name}
+
+
+
+ {
+ if (isComplex()) setExpanded((v) => !v);
+ }}
+ onDblClick={() => {
+ if (textEditable()) edit.start();
+ }}
+ title={valueTitle()}
+ >
+ {previewValue(props.signal.value)}
+
+ }
+ >
+ edit.write(!props.signal.value)}
+ title={edit.failed() ? valueTitle() : 'Click to toggle boolean'}
+ >
+ {`${props.signal.value}`}
+
+
+
+
+
+
+
+
+
+ props.onViewInGraph?.(props.signal.node)}
+ >
+
+
+
+
+ >
+ }
+ >
+
+
+
+
+
+ writeReactiveValue(props.signal.node, path, nextValue)}
+ />
+
+
+
+ );
+}
+
export default function OwnershipViewer(props: OwnershipViewerProps): JSX.Element {
// The panel renders inside the app it inspects, so its own scope is marked.
excludeOwner(getOwner());
@@ -433,34 +563,10 @@ export default function OwnershipViewer(props: OwnershipViewerProps): JSX.Elemen
}
>
{(signal) => (
-
-
- {signal.name}
-
-
- {previewValue(signal.value)}
-
-
- props.onViewInGraph?.(signal.node)}
- >
-
-
-
-
+
)}
diff --git a/src/dev-toolbar/ownership/styles.css b/src/dev-toolbar/ownership/styles.css
index efdbc72..a881e6a 100644
--- a/src/dev-toolbar/ownership/styles.css
+++ b/src/dev-toolbar/ownership/styles.css
@@ -324,6 +324,56 @@
gap: 0.125rem;
}
+[data-solid-ownership-signal-block] {
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+}
+
+[data-solid-ownership-signal-chevron] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ width: 0.875rem;
+ height: 0.875rem;
+ flex-shrink: 0;
+
+ border: none;
+ background: none;
+ padding: 0;
+ color: var(--start-dt-text-muted);
+ cursor: pointer;
+}
+
+[data-solid-ownership-signal-chevron]::before {
+ content: '';
+
+ width: 0.3125rem;
+ height: 0.3125rem;
+
+ border-right: 1.5px currentColor solid;
+ border-bottom: 1.5px currentColor solid;
+
+ transform: rotate(-45deg);
+ transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+[data-solid-ownership-signal-chevron][data-expanded]::before {
+ transform: rotate(45deg);
+}
+
+[data-solid-ownership-signal-tree] {
+ padding: 0.25rem 0.5rem;
+ margin-top: 0.25rem;
+ margin-left: 0.875rem;
+ border-radius: 0.375rem;
+ border: 1px solid var(--start-dt-border-soft);
+ background: var(--start-dt-bg);
+ max-height: 14rem;
+ overflow: auto;
+}
+
[data-solid-ownership-signal] {
display: grid;
grid-template-columns: minmax(4rem, 40%) 1fr auto;
@@ -341,6 +391,78 @@
}
/* The action stays out of the way until the row is pointed at or focused. */
+[data-solid-ownership-signal-actions] {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ margin-left: auto;
+}
+
+[data-solid-ownership-edit-signal] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ padding: 0.125rem;
+
+ border: none;
+ border-radius: 0.25rem;
+ background: none;
+ color: var(--start-dt-text-muted);
+
+ cursor: pointer;
+ opacity: 0;
+ transition:
+ opacity 150ms ease-out,
+ color 150ms ease-out;
+}
+
+[data-solid-ownership-signal]:hover [data-solid-ownership-edit-signal],
+[data-solid-ownership-edit-signal]:focus-visible {
+ opacity: 1;
+}
+
+[data-solid-ownership-edit-signal]:hover {
+ color: var(--start-dt-accent);
+ background: var(--start-dt-accent-soft);
+}
+
+[data-solid-ownership-signal] [data-solid-ownership-edit-signal] svg {
+ width: 0.875rem;
+ height: 0.875rem;
+}
+
+[data-solid-ownership-signal-input] {
+ flex: 1;
+ min-width: 3.5rem;
+ max-width: 12rem;
+ padding: 0.0625rem 0.25rem;
+ font-family: inherit;
+ font-size: 0.6875rem;
+ line-height: 1rem;
+ border-radius: 0.25rem;
+ border: 1px solid var(--start-dt-accent);
+ background: var(--start-dt-bg);
+ color: var(--start-dt-text);
+ outline: none;
+}
+
+[data-solid-ownership-toggle-btn] {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.0625rem 0.25rem;
+ border: 1px solid var(--start-dt-border-soft, transparent);
+ border-radius: 0.25rem;
+ background: var(--start-dt-surface, transparent);
+ color: var(--start-dt-text);
+ cursor: pointer;
+ transition: border-color 150ms ease-out;
+}
+
+[data-solid-ownership-toggle-btn]:hover {
+ border-color: var(--start-dt-accent);
+}
+
[data-solid-ownership-view-graph] {
display: inline-flex;
align-items: center;
@@ -379,7 +501,8 @@
height: 0.875rem;
}
-[data-solid-ownership-scope-name] {
+[data-solid-ownership-scope-name],
+[data-solid-ownership-signal-name] {
display: inline-flex;
align-items: center;
@@ -389,12 +512,14 @@
overflow: hidden;
}
-[data-solid-ownership-scope-name] > [data-solid-text-size] {
+[data-solid-ownership-scope-name] > [data-solid-text-size],
+[data-solid-ownership-signal-name] > [data-solid-text-size] {
overflow: hidden;
text-overflow: ellipsis;
}
-[data-solid-ownership-signal] > [data-solid-text-size]:first-child {
+[data-solid-ownership-signal] > [data-solid-text-size]:first-child,
+[data-solid-ownership-signal] > [data-solid-ownership-signal-name] {
overflow: hidden;
text-overflow: ellipsis;
}
@@ -496,3 +621,7 @@
button[data-solid-ownership-chip]:hover {
background: var(--start-dt-surface-hover);
}
+
+[data-solid-ownership-edit-failed] {
+ text-decoration: wavy underline var(--start-dt-danger);
+}
diff --git a/src/dev-toolbar/reactivity/DraftInput.tsx b/src/dev-toolbar/reactivity/DraftInput.tsx
new file mode 100644
index 0000000..8a061d1
--- /dev/null
+++ b/src/dev-toolbar/reactivity/DraftInput.tsx
@@ -0,0 +1,31 @@
+import type { JSX } from '@solidjs/web';
+import type { DraftEdit } from './draft-edit.js';
+
+/**
+ * The text input of an inline value editor. It takes focus when it appears.
+ */
+export function DraftInput(props: {
+ edit: DraftEdit;
+ /** The data attribute the input carries, such as `data-solid-value-input`. */
+ marker: string;
+}): JSX.Element {
+ return (
+ props.edit.setDraft(event.currentTarget.value)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') props.edit.commit();
+ if (event.key === 'Escape') props.edit.cancel();
+ }}
+ onBlur={() => props.edit.commit()}
+ ref={(element) => {
+ queueMicrotask(() => {
+ element.focus();
+ element.select();
+ });
+ }}
+ />
+ );
+}
diff --git a/src/dev-toolbar/reactivity/ValueInspector.tsx b/src/dev-toolbar/reactivity/ValueInspector.tsx
index a20e7c3..f12b85c 100644
--- a/src/dev-toolbar/reactivity/ValueInspector.tsx
+++ b/src/dev-toolbar/reactivity/ValueInspector.tsx
@@ -1,9 +1,20 @@
import type { JSX } from '@solidjs/web';
-import { createMemo, For, Show } from 'solid-js';
+import { createMemo, createSignal, For, Show } from 'solid-js';
import { Badge } from '../../ui/Badge.js';
-import { TreeBranch, TreeKey, TreeLeaf, TreeMark, ValueToken } from '../../ui/ValueTree.js';
+import {
+ TreeBranch,
+ TreeKey,
+ TreeLeaf,
+ TreeMark,
+ ValueToken,
+ type ValueTokenKind,
+} from '../../ui/ValueTree.js';
+import { EditIcon } from '../icons.js';
import { formatValue, typeName } from './format.js';
import { containerEntries, entryCount, ownEntries } from './value-entries.js';
+import { createDraftEdit } from './draft-edit.js';
+import { DraftInput } from './DraftInput.js';
+import { formatDraftValue, isPlainContainer } from './value-editor.js';
function LinkIcon(props: { title: string }): JSX.Element {
return (
@@ -18,6 +29,71 @@ function LinkIcon(props: { title: string }): JSX.Element {
);
}
+/** Writes `nextValue` at `path`. Returns false when the app did not take it. */
+export type ValueEditHandler = (path: readonly (string | number)[], nextValue: unknown) => boolean;
+
+interface EditableValueProps {
+ value: string | number | boolean | bigint | null | undefined;
+ kind?: ValueTokenKind;
+ path: readonly (string | number)[];
+ onEdit: ValueEditHandler;
+}
+
+const FAILED_TITLE = 'The app did not take this edit';
+
+function EditableValue(props: EditableValueProps): JSX.Element {
+ const edit = createDraftEdit({
+ value: () => props.value,
+ write: (nextValue) => props.onEdit(props.path, nextValue),
+ });
+
+ if (typeof props.value === 'boolean') {
+ return (
+
+ edit.write(!props.value)}
+ title={edit.failed() ? FAILED_TITLE : 'Click to toggle boolean'}
+ >
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ }
+ >
+
+
+
+ );
+}
+
export interface ValueInspectorProps {
value: unknown;
label?: JSX.Element;
@@ -25,6 +101,12 @@ export interface ValueInspectorProps {
open?: boolean;
/** Values already shown higher up this branch, used to catch cycles. */
seen?: object[];
+ /** Whether leaf values can be edited interactively. */
+ editable?: boolean;
+ /** Callback fired when a leaf or nested property is updated. */
+ onEdit?: ValueEditHandler;
+ /** Path from the root inspected value down to this node. */
+ path?: readonly (string | number)[];
}
/**
@@ -38,7 +120,15 @@ export function ValueInspector(props: ValueInspectorProps): JSX.Element {
// The tree is rebuilt when the value changes, so the reads below have to
// happen inside a tracking scope.
const tree = createMemo(() =>
- renderValue(props.value, props.label, props.open, props.seen ?? []),
+ renderValue(
+ props.value,
+ props.label,
+ props.open,
+ props.seen ?? [],
+ props.editable,
+ props.onEdit,
+ props.path ?? [],
+ ),
);
return <>{tree()}>;
}
@@ -48,11 +138,16 @@ function renderValue(
label: JSX.Element | undefined,
open: boolean | undefined,
seen: object[],
+ editable: boolean | undefined,
+ onEdit: ValueEditHandler | undefined,
+ path: readonly (string | number)[],
): JSX.Element {
if (value === null) {
return (
-
+ }>
+
+
);
}
@@ -61,31 +156,50 @@ function renderValue(
case 'undefined':
return (
-
+ }
+ >
+
+
);
case 'string':
return (
-
+ }
+ >
+
+
);
case 'number':
return (
-
+ }>
+
+
);
case 'bigint':
return (
-
+ }
+ >
+
+
);
case 'boolean':
return (
-
+ }>
+
+
);
case 'symbol':
@@ -148,6 +262,10 @@ function renderValue(
);
const nested = [...seen, object];
+ // Edits copy each container on the path with a spread, which would turn a
+ // Map, a Set or a class instance into a plain object, so only plain
+ // containers pass editing down.
+ const editChildren = editable && isPlainContainer(object);
return (
@@ -177,6 +295,9 @@ function renderValue(
value={entry.value}
seen={nested}
label={ }
+ editable={editChildren}
+ onEdit={onEdit}
+ path={[...path, entry.key]}
/>
)}
diff --git a/src/dev-toolbar/reactivity/draft-edit.dev.test.ts b/src/dev-toolbar/reactivity/draft-edit.dev.test.ts
new file mode 100644
index 0000000..dd1d84f
--- /dev/null
+++ b/src/dev-toolbar/reactivity/draft-edit.dev.test.ts
@@ -0,0 +1,53 @@
+import { createRoot, flush } from 'solid-js';
+import { describe, expect, it } from 'vitest';
+import { createDraftEdit } from './draft-edit.js';
+
+function setup(value: unknown, result = true) {
+ const writes: unknown[] = [];
+ const edit = createRoot(() =>
+ createDraftEdit({
+ value: () => value,
+ write: (nextValue) => {
+ writes.push(nextValue);
+ return result;
+ },
+ }),
+ );
+ return { edit, writes };
+}
+
+describe('createDraftEdit', () => {
+ it('does not write a draft left as it was', () => {
+ const { edit, writes } = setup({ kind: 'folder' });
+ edit.start();
+ flush();
+ edit.commit();
+ expect(writes).toEqual([]);
+ });
+
+ it('writes a changed draft as a parsed value', () => {
+ const { edit, writes } = setup(1);
+ edit.start();
+ edit.setDraft('2');
+ flush();
+ edit.commit();
+ expect(writes).toEqual([2]);
+ });
+
+ it('does not write after cancel', () => {
+ const { edit, writes } = setup(1);
+ edit.start();
+ edit.setDraft('2');
+ edit.cancel();
+ flush();
+ edit.commit();
+ expect(writes).toEqual([]);
+ });
+
+ it('reports a write the app did not take', () => {
+ const { edit } = setup(1, false);
+ edit.write(2);
+ flush();
+ expect(edit.failed()).toBe(true);
+ });
+});
diff --git a/src/dev-toolbar/reactivity/draft-edit.ts b/src/dev-toolbar/reactivity/draft-edit.ts
new file mode 100644
index 0000000..6259283
--- /dev/null
+++ b/src/dev-toolbar/reactivity/draft-edit.ts
@@ -0,0 +1,58 @@
+import { createSignal } from 'solid-js';
+import { formatDraftValue, parseValueInput } from './value-editor.js';
+
+export interface DraftEditOptions {
+ value: () => unknown;
+ /** Writes the parsed draft. Returns false when the app did not take it. */
+ write: (nextValue: unknown) => boolean;
+}
+
+export interface DraftEdit {
+ editing: () => boolean;
+ draft: () => string;
+ setDraft: (draft: string) => void;
+ /** Set when the last write did not reach the app. */
+ failed: () => boolean;
+ start: () => void;
+ commit: () => void;
+ cancel: () => void;
+ /** Writes a value without going through the draft, as a toggle does. */
+ write: (nextValue: unknown) => void;
+}
+
+/**
+ * The draft, commit and cancel steps of an inline value editor.
+ */
+export function createDraftEdit(options: DraftEditOptions): DraftEdit {
+ const [editing, setEditing] = createSignal(false);
+ const [draft, setDraft] = createSignal('');
+ const [failed, setFailed] = createSignal(false);
+ let initial = '';
+
+ function start(): void {
+ initial = formatDraftValue(options.value());
+ setDraft(initial);
+ setEditing(true);
+ }
+
+ function write(nextValue: unknown): void {
+ setFailed(!options.write(nextValue));
+ }
+
+ function commit(): void {
+ if (!editing()) return;
+ setEditing(false);
+ // A draft left as it was is not an edit. Parsing it would still give a new
+ // object for an object value, and writing that would replace the value.
+ if (draft() === initial) return;
+ const current = options.value();
+ const parsed = parseValueInput(draft(), current);
+ if (parsed !== current) write(parsed);
+ }
+
+ function cancel(): void {
+ setEditing(false);
+ }
+
+ return { editing, draft, setDraft, failed, start, commit, cancel, write };
+}
diff --git a/src/dev-toolbar/reactivity/index.tsx b/src/dev-toolbar/reactivity/index.tsx
index acb21ab..4adfb97 100644
--- a/src/dev-toolbar/reactivity/index.tsx
+++ b/src/dev-toolbar/reactivity/index.tsx
@@ -19,6 +19,7 @@ import {
type GraphLayout,
} from './layout.js';
import { ValueInspector } from './ValueInspector.js';
+import { writeReactiveValue } from './value-editor.js';
import {
EMPTY_GRAPH,
excludeReactiveOwner,
@@ -736,6 +737,13 @@ export default function ReactivityViewer(props: ReactivityViewerProps): JSX.Elem
+ writeReactiveValue(node().raw, path, nextValue)
+ }
/>
diff --git a/src/dev-toolbar/reactivity/value-editor.dev.test.ts b/src/dev-toolbar/reactivity/value-editor.dev.test.ts
new file mode 100644
index 0000000..16df4d5
--- /dev/null
+++ b/src/dev-toolbar/reactivity/value-editor.dev.test.ts
@@ -0,0 +1,138 @@
+import { createMemo, createRoot, createSignal, createStore, DEV, flush, getOwner } from 'solid-js';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { writeReactiveValue } from './value-editor.js';
+
+// These tests run against the browser dev build of solid-js (see
+// vitest.config.ts), so the nodes are the ones the panels hand to the editor.
+
+interface Probe {
+ read: () => T;
+ /** The nodes the memo read, as the reactivity graph lists them. */
+ sources: () => Record[];
+}
+
+/** A memo over `fn`, with access to the nodes it read. */
+function probe(fn: () => T): Probe {
+ let node: unknown;
+ const read = createMemo(() => {
+ node = getOwner();
+ return fn();
+ });
+ read();
+ flush();
+ return {
+ read,
+ sources: () => DEV!.getSources(node as never) as unknown as Record[],
+ };
+}
+
+function sourceNamed(probe: Probe, name: string): object {
+ for (const source of probe.sources()) {
+ if (source._name === name) return source;
+ }
+ throw new Error(`no source named ${name}`);
+}
+
+function slotFor(probe: Probe, key: string): object {
+ for (const source of probe.sources()) {
+ if ('_host' in source && source._key === key) return source;
+ }
+ throw new Error(`no store slot for ${key}`);
+}
+
+describe('writeReactiveValue', () => {
+ it('returns false for null or undefined target', () => {
+ expect(writeReactiveValue(null as never, [], 10)).toBe(false);
+ expect(writeReactiveValue(undefined as never, [], 10)).toBe(false);
+ });
+
+ it('writes a signal and updates a dependent memo', () => {
+ createRoot((dispose) => {
+ const [count] = createSignal(10, { name: 'count' });
+ const double = probe(() => count() * 2);
+ expect(double.read()).toBe(20);
+
+ expect(writeReactiveValue(sourceNamed(double, 'count'), [], 25)).toBe(true);
+ expect(count()).toBe(25);
+ expect(double.read()).toBe(50);
+ dispose();
+ });
+ });
+
+ it('writes a nested path of an object signal', () => {
+ createRoot((dispose) => {
+ const [user] = createSignal({ name: 'Alice', active: false }, { name: 'user' });
+ const label = probe(() => `${user().name} ${user().active}`);
+
+ expect(writeReactiveValue(sourceNamed(label, 'user'), ['active'], true)).toBe(true);
+ expect(label.read()).toBe('Alice true');
+ dispose();
+ });
+ });
+
+ it('writes a store key and a nested store path', () => {
+ createRoot((dispose) => {
+ const [store] = createStore({ title: 'Old', todos: [{ done: false }] });
+ const summary = probe(() => `${store.title} ${store.todos[0].done}`);
+
+ expect(writeReactiveValue(slotFor(summary, 'title'), [], 'New')).toBe(true);
+ expect(store.title).toBe('New');
+ expect(summary.read()).toBe('New false');
+
+ expect(writeReactiveValue(slotFor(summary, 'todos'), [0, 'done'], true)).toBe(true);
+ expect(store.todos[0].done).toBe(true);
+ expect(summary.read()).toBe('New true');
+ dispose();
+ });
+ });
+
+ it('refuses a path through a Map and leaves the value alone', () => {
+ createRoot((dispose) => {
+ const map = new Map([['k', 1]]);
+ const [data] = createSignal({ m: map }, { name: 'data' });
+ const read = probe(() => data().m);
+
+ expect(writeReactiveValue(sourceNamed(read, 'data'), ['m', '"k"'], 2)).toBe(false);
+ expect(data().m).toBe(map);
+ dispose();
+ });
+ });
+
+ describe('with a DEV.setSignal hook', () => {
+ const dev = DEV as unknown as { setSignal?: (node: object, value: unknown) => void };
+
+ afterEach(() => {
+ delete dev.setSignal;
+ });
+
+ it('writes through the hook and not the bridge', () => {
+ createRoot((dispose) => {
+ const [user] = createSignal({ name: 'Alice', active: false }, { name: 'user' });
+ const label = probe(() => `${user().name} ${user().active}`);
+ const node = sourceNamed(label, 'user');
+ const setSignal = vi.fn();
+ dev.setSignal = setSignal;
+
+ expect(writeReactiveValue(node, ['active'], true)).toBe(true);
+ expect(setSignal).toHaveBeenCalledWith(node, { name: 'Alice', active: true });
+ expect(label.read()).toBe('Alice false');
+ dispose();
+ });
+ });
+
+ it('passes a store slot the new value of its key', () => {
+ createRoot((dispose) => {
+ const [store] = createStore({ todos: [{ done: false }] });
+ const summary = probe(() => store.todos[0].done);
+ const slot = slotFor(summary, 'todos');
+ const setSignal = vi.fn();
+ dev.setSignal = setSignal;
+
+ expect(writeReactiveValue(slot, [0, 'done'], true)).toBe(true);
+ expect(setSignal).toHaveBeenCalledWith(slot, [{ done: true }]);
+ expect(store.todos[0].done).toBe(false);
+ dispose();
+ });
+ });
+ });
+});
diff --git a/src/dev-toolbar/reactivity/value-editor.test.ts b/src/dev-toolbar/reactivity/value-editor.test.ts
new file mode 100644
index 0000000..aa02c94
--- /dev/null
+++ b/src/dev-toolbar/reactivity/value-editor.test.ts
@@ -0,0 +1,154 @@
+import { describe, expect, it } from 'vitest';
+import {
+ canUpdatePath,
+ formatDraftValue,
+ isJsonValue,
+ isPlainContainer,
+ isTextEditable,
+ parseValueInput,
+ updatePath,
+} from './value-editor.js';
+
+describe('formatDraftValue', () => {
+ it('formats primitives correctly', () => {
+ expect(formatDraftValue('hello')).toBe('"hello"');
+ expect(formatDraftValue(42)).toBe('42');
+ expect(formatDraftValue(true)).toBe('true');
+ expect(formatDraftValue(null)).toBe('null');
+ expect(formatDraftValue(undefined)).toBe('undefined');
+ expect(formatDraftValue(5n)).toBe('5n');
+ });
+
+ it('formats objects and arrays as JSON', () => {
+ expect(formatDraftValue({ kind: 'folder', name: 'app' })).toBe(
+ '{"kind":"folder","name":"app"}',
+ );
+ expect(formatDraftValue([1, 2])).toBe('[1,2]');
+ });
+});
+
+describe('parseValueInput', () => {
+ it('guards against [object Object] corrupting object values', () => {
+ const existingObj = { kind: 'folder', name: 'app' };
+ expect(parseValueInput('[object Object]', existingObj)).toBe(existingObj);
+ });
+ it('parses numeric values correctly', () => {
+ expect(parseValueInput('42', 10)).toBe(42);
+ expect(parseValueInput('-3.14', 0)).toBe(-3.14);
+ expect(parseValueInput('100')).toBe(100);
+ expect(parseValueInput('0', 5)).toBe(0);
+ });
+
+ it('parses boolean values correctly', () => {
+ expect(parseValueInput('true', false)).toBe(true);
+ expect(parseValueInput('false', true)).toBe(false);
+ // Anything else keeps the old value instead of turning into false.
+ expect(parseValueInput('1', false)).toBe(false);
+ expect(parseValueInput('yes', true)).toBe(true);
+ expect(parseValueInput('true')).toBe(true);
+ expect(parseValueInput('false')).toBe(false);
+ });
+
+ it('parses null and undefined literals', () => {
+ expect(parseValueInput('null', null)).toBe(null);
+ expect(parseValueInput('undefined', undefined)).toBe(undefined);
+ expect(parseValueInput('null')).toBe(null);
+ expect(parseValueInput('undefined')).toBe(undefined);
+ });
+
+ it('parses strings and strips surrounding quotes', () => {
+ expect(parseValueInput('"hello world"', 'old')).toBe('hello world');
+ expect(parseValueInput("'single quotes'", 'old')).toBe('single quotes');
+ expect(parseValueInput('plain text', 'old')).toBe('plain text');
+ });
+
+ it('keeps a bigint a bigint', () => {
+ expect(parseValueInput('42', 1n)).toBe(42n);
+ expect(parseValueInput('-7n', 1n)).toBe(-7n);
+ expect(parseValueInput('1.5', 1n)).toBe(1n);
+ });
+
+ it('parses JSON objects and arrays', () => {
+ expect(parseValueInput('{"a": 1, "b": "test"}')).toEqual({ a: 1, b: 'test' });
+ expect(parseValueInput('[1, 2, 3]')).toEqual([1, 2, 3]);
+ expect(parseValueInput('{invalid json}')).toBe('{invalid json}');
+ });
+});
+
+describe('updatePath', () => {
+ it('returns next value for empty path', () => {
+ expect(updatePath({ a: 1 }, [], { a: 2 })).toEqual({ a: 2 });
+ });
+
+ it('updates shallow property on object', () => {
+ const original = { name: 'Ada', age: 36 };
+ const updated = updatePath(original, ['name'], 'Augusta');
+ expect(updated).toEqual({ name: 'Augusta', age: 36 });
+ expect(original.name).toBe('Ada');
+ });
+
+ it('updates nested property', () => {
+ const original = { user: { profile: { theme: 'dark' } }, count: 0 };
+ const updated = updatePath(original, ['user', 'profile', 'theme'], 'light');
+ expect(updated.user.profile.theme).toBe('light');
+ expect(original.user.profile.theme).toBe('dark');
+ });
+
+ it('updates array element', () => {
+ const original = [10, 20, 30];
+ const updated = updatePath(original, [1], 99);
+ expect(updated).toEqual([10, 99, 30]);
+ expect(original[1]).toBe(20);
+ });
+});
+
+describe('isPlainContainer', () => {
+ it('accepts arrays and plain objects only', () => {
+ expect(isPlainContainer([])).toBe(true);
+ expect(isPlainContainer({})).toBe(true);
+ expect(isPlainContainer(Object.create(null))).toBe(true);
+ expect(isPlainContainer(new Map())).toBe(false);
+ expect(isPlainContainer(new Set())).toBe(false);
+ expect(isPlainContainer(new Uint8Array(1))).toBe(false);
+ expect(isPlainContainer(new Date())).toBe(false);
+ expect(isPlainContainer(new (class Point {})())).toBe(false);
+ expect(isPlainContainer(null)).toBe(false);
+ });
+});
+
+describe('isJsonValue', () => {
+ it('accepts values a JSON round trip gives back equal', () => {
+ expect(isJsonValue({ a: [1, 'b', null, { c: true }] })).toBe(true);
+ });
+
+ it('rejects values JSON would change', () => {
+ expect(isJsonValue({ m: new Map() })).toBe(false);
+ expect(isJsonValue({ d: new Date() })).toBe(false);
+ expect(isJsonValue({ f: () => 1 })).toBe(false);
+ expect(isJsonValue({ u: undefined })).toBe(false);
+ expect(isJsonValue({ n: Number.NaN })).toBe(false);
+ expect(isJsonValue({ b: 1n })).toBe(false);
+ const cycle: Record = {};
+ cycle.self = cycle;
+ expect(isJsonValue(cycle)).toBe(false);
+ });
+});
+
+describe('isTextEditable', () => {
+ it('accepts primitives and JSON values', () => {
+ expect(isTextEditable('a')).toBe(true);
+ expect(isTextEditable(1n)).toBe(true);
+ expect(isTextEditable(undefined)).toBe(true);
+ expect(isTextEditable([1, 2])).toBe(true);
+ expect(isTextEditable(new Set([1]))).toBe(false);
+ expect(isTextEditable(Symbol('s'))).toBe(false);
+ });
+});
+
+describe('canUpdatePath', () => {
+ it('allows paths through plain containers only', () => {
+ expect(canUpdatePath({ a: [{ b: 1 }] }, ['a', 0, 'b'])).toBe(true);
+ expect(canUpdatePath({ m: new Map([['k', 1]]) }, ['m', '"k"'])).toBe(false);
+ expect(canUpdatePath(new Set([1]), [0])).toBe(false);
+ });
+});
diff --git a/src/dev-toolbar/reactivity/value-editor.ts b/src/dev-toolbar/reactivity/value-editor.ts
new file mode 100644
index 0000000..89a5e30
--- /dev/null
+++ b/src/dev-toolbar/reactivity/value-editor.ts
@@ -0,0 +1,321 @@
+import { createRoot, createSignal, DEV, flush, getOwner, runWithOwner } from 'solid-js';
+
+/**
+ * Whether a value is an array or an object made by `{}` or
+ * `Object.create(null)`. Only these can be copied with a spread without losing
+ * what they are, so only these are edited in place.
+ */
+export function isPlainContainer(value: unknown): value is Record {
+ if (Array.isArray(value)) return true;
+ if (typeof value !== 'object' || value === null) return false;
+ const proto = Object.getPrototypeOf(value);
+ return proto === Object.prototype || proto === null;
+}
+
+/**
+ * Whether `JSON.stringify` and `JSON.parse` give back an equal value: plain
+ * objects and arrays holding only strings, finite numbers, booleans and null.
+ */
+export function isJsonValue(value: unknown, seen: object[] = []): boolean {
+ if (value === null) return true;
+ switch (typeof value) {
+ case 'string':
+ case 'boolean':
+ return true;
+ case 'number':
+ return Number.isFinite(value);
+ case 'object':
+ break;
+ default:
+ return false;
+ }
+ if (!isPlainContainer(value) || seen.includes(value)) return false;
+ const nested = [...seen, value];
+ for (const key of Reflect.ownKeys(value)) {
+ if (typeof key === 'symbol') return false;
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
+ if (!descriptor || !('value' in descriptor)) return false;
+ if (Array.isArray(value) && key === 'length') continue;
+ if (!isJsonValue(descriptor.value, nested)) return false;
+ }
+ return true;
+}
+
+/**
+ * Whether every container `path` passes through is plain, so `updatePath` can
+ * copy it.
+ */
+export function canUpdatePath(target: unknown, path: readonly (string | number)[]): boolean {
+ let current = target;
+ for (const key of path) {
+ if (!isPlainContainer(current)) return false;
+ current = current[key];
+ }
+ return true;
+}
+
+/**
+ * Updates a nested property along `path` immutably. Every container on the
+ * path has to be plain, which `canUpdatePath` checks.
+ */
+export function updatePath(target: T, path: readonly (string | number)[], value: unknown): T {
+ if (path.length === 0) return value as T;
+ const [head, ...tail] = path;
+ const isArr = Array.isArray(target);
+ const next = isArr ? ([...target] as unknown as T) : ({ ...target } as T);
+ (next as Record)[head] = updatePath(
+ (target as Record)?.[head],
+ tail,
+ value,
+ );
+ return next;
+}
+
+/**
+ * Whether a value can be edited as text: a primitive other than a symbol, or
+ * a value that survives a JSON round trip.
+ */
+export function isTextEditable(value: unknown): boolean {
+ switch (typeof value) {
+ case 'string':
+ case 'number':
+ case 'boolean':
+ case 'bigint':
+ case 'undefined':
+ return true;
+ case 'object':
+ return isJsonValue(value);
+ default:
+ return false;
+ }
+}
+
+/**
+ * Formats a value for editing in an input field.
+ */
+export function formatDraftValue(value: unknown): string {
+ if (value === undefined) return 'undefined';
+ if (value === null) return 'null';
+ if (typeof value === 'string') return `"${value}"`;
+ if (typeof value === 'bigint') return `${value}n`;
+ if (typeof value === 'object') {
+ try {
+ return JSON.stringify(value);
+ } catch {
+ return '';
+ }
+ }
+ return `${value}`;
+}
+
+/**
+ * Parses user string input into an appropriately typed value.
+ */
+export function parseValueInput(raw: string, currentValue?: unknown): unknown {
+ const trimmed = raw.trim();
+
+ // Guard against accidental [object Object] serialization corrupting objects.
+ if (trimmed === '[object Object]' && typeof currentValue === 'object') {
+ return currentValue;
+ }
+
+ if (typeof currentValue === 'number') {
+ const num = Number(trimmed);
+ if (!Number.isNaN(num) && trimmed !== '') return num;
+ }
+ if (typeof currentValue === 'boolean') {
+ if (trimmed === 'true') return true;
+ if (trimmed === 'false') return false;
+ return currentValue;
+ }
+ if (typeof currentValue === 'bigint') {
+ const digits = trimmed.endsWith('n') ? trimmed.slice(0, -1) : trimmed;
+ if (/^-?\d+$/.test(digits)) return BigInt(digits);
+ return currentValue;
+ }
+ if (currentValue === null && trimmed === 'null') {
+ return null;
+ }
+ if (typeof currentValue === 'undefined' && trimmed === 'undefined') {
+ return undefined;
+ }
+ if (typeof currentValue === 'string') {
+ if (
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
+ ) {
+ return trimmed.slice(1, -1);
+ }
+ return raw;
+ }
+
+ if (trimmed === 'true') return true;
+ if (trimmed === 'false') return false;
+ if (trimmed === 'null') return null;
+ if (trimmed === 'undefined') return undefined;
+
+ if (
+ (trimmed.startsWith('{') && trimmed.endsWith('}')) ||
+ (trimmed.startsWith('[') && trimmed.endsWith(']'))
+ ) {
+ try {
+ return JSON.parse(trimmed);
+ } catch {
+ // Non-parseable JSON falls through as literal string.
+ }
+ }
+
+ if (!Number.isNaN(Number(trimmed)) && trimmed !== '') {
+ return Number(trimmed);
+ }
+
+ return raw;
+}
+
+/**
+ * A write hook a solid-js dev build may offer on `DEV`: set a signal node, or
+ * the slot node of a store property, to a value and notify its subscribers.
+ * Proposed in solidjs/solid#4679 and not in any release yet.
+ */
+type SetSignalHook = (node: object, value: unknown) => void;
+
+function runtimeSetSignal(): SetSignalHook | undefined {
+ const hook = (DEV as { setSignal?: unknown } | undefined)?.setSignal;
+ return typeof hook === 'function' ? (hook as SetSignalHook) : undefined;
+}
+
+/**
+ * The value to write to `target` so that `path` inside it holds `nextValue`.
+ * For a store slot this is the new value of the slot's key. Undefined when
+ * the path passes through a container that is not plain.
+ */
+function resolveWrite(
+ target: Record,
+ path: readonly (string | number)[],
+ nextValue: unknown,
+): { value: unknown } | undefined {
+ if (path.length === 0) return { value: nextValue };
+ const isStoreSlot = '_host' in target && target._host;
+ const current = isStoreSlot && target._host.v ? target._host.v[target._key] : target._value;
+ if (!canUpdatePath(current, path)) return undefined;
+ return { value: updatePath(current, path, nextValue) };
+}
+
+/**
+ * Bridges external writes into Solid's internal reactivity scheduler, for
+ * runtimes without the `DEV.setSignal` hook.
+ *
+ * In Solid 2.0, signal writes trigger subscriber notification and queue flush
+ * via the internal `setSignal` function bound during `createSignal`. Devtools
+ * receives the raw reactive node without its user-space setter. By binding a
+ * bridge signal node whose properties are forwarded to the inspected target node,
+ * writes route through Solid's native setter and subscriber propagation without
+ * requiring private module deep imports or reimplementing the internal heap.
+ *
+ * This depends on the private field names of the runtime's nodes, so a rename
+ * in solid-js breaks it. The tests in `value-editor.dev.test.ts` run it against
+ * the dev build to catch that when solid-js is updated.
+ */
+class ReactiveWriter {
+ private setter?: (value: unknown) => unknown;
+ private bridgeNode?: Record;
+
+ private ensureBridge(): void {
+ if (this.setter && this.bridgeNode) return;
+ createRoot(() => {
+ const [_, set] = createSignal(0);
+ this.setter = set as (value: unknown) => unknown;
+ const owner = getOwner();
+ const signals = DEV?.getSignals
+ ? (DEV.getSignals(owner as never) as Record[])
+ : [];
+ this.bridgeNode = signals[0];
+ });
+ }
+
+ public write(target: Record, nextValue: unknown): boolean {
+ // The bridge has to exist before anything is written, or a failed setup
+ // would leave the backing data changed with no subscriber told.
+ this.ensureBridge();
+ if (!this.setter || !this.bridgeNode) return false;
+
+ if ('_host' in target && target._host) {
+ const key = target._key;
+ if (target._host.v) target._host.v[key] = nextValue;
+ if (target._host.pb) target._host.pb[key] = nextValue;
+ }
+
+ const keys = new Set([
+ ...Object.getOwnPropertyNames(this.bridgeNode),
+ ...Object.getOwnPropertyNames(target),
+ '_config',
+ '_firewall',
+ '_transition',
+ '_x',
+ '_pendingValue',
+ '_value',
+ '_statusFlags',
+ '_equals',
+ '_name',
+ '_fn',
+ '_time',
+ '_notifiedAt',
+ '_subs',
+ '_subsTail',
+ '_owner',
+ '_internal',
+ '_host',
+ '_key',
+ ]);
+
+ for (const key of keys) {
+ Object.defineProperty(this.bridgeNode, key, {
+ get: () => target[key],
+ set: (val: unknown) => {
+ target[key] = val;
+ },
+ configurable: true,
+ enumerable: true,
+ });
+ }
+
+ // External devtools writes run without an owner to bypass owned-scope checks.
+ runWithOwner(null, () => {
+ this.setter!(nextValue);
+ });
+ return true;
+ }
+}
+
+const writer = new ReactiveWriter();
+
+/**
+ * Writes a new value to a reactive signal or store node at the specified path.
+ * Returns false when the write did not reach the runtime: the bridge could not
+ * be set up, or the path passes through a container that is not plain.
+ */
+export function writeReactiveValue(
+ target: object,
+ path: readonly (string | number)[],
+ nextValue: unknown,
+): boolean {
+ if (!target || typeof target !== 'object') return false;
+ const node = target as Record;
+ const write = resolveWrite(node, path, nextValue);
+ if (!write) return false;
+
+ // Prefer the runtime's own write hook; fall back to the bridge without it.
+ const setSignal = runtimeSetSignal();
+ if (setSignal) {
+ runWithOwner(null, () => setSignal(node, write.value));
+ } else if (!writer.write(node, write.value)) {
+ return false;
+ }
+
+ try {
+ flush();
+ } catch {
+ // In browser microtask environments flush may already be pending.
+ }
+ return true;
+}
diff --git a/src/ui/ValueTree.css b/src/ui/ValueTree.css
index 913252d..cd74e9a 100644
--- a/src/ui/ValueTree.css
+++ b/src/ui/ValueTree.css
@@ -135,3 +135,89 @@ button[data-solid-value-tree-row]:focus-visible {
color: var(--start-dt-accent, currentColor);
}
+
+[data-solid-value-editable-container] {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ min-width: 0;
+}
+
+[data-solid-value-editable-trigger] {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ cursor: pointer;
+ border-radius: 0.25rem;
+}
+
+[data-solid-value-edit-btn] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+
+ padding: 0.0625rem;
+ border: none;
+ border-radius: 0.25rem;
+ background: transparent;
+ color: var(--start-dt-text-muted);
+
+ cursor: pointer;
+ opacity: 0;
+ transition:
+ opacity 150ms ease-out,
+ color 150ms ease-out;
+}
+
+[data-solid-value-tree-row]:hover [data-solid-value-edit-btn],
+[data-solid-value-editable-trigger]:hover [data-solid-value-edit-btn],
+[data-solid-value-edit-btn]:focus-visible {
+ opacity: 1;
+}
+
+[data-solid-value-edit-btn]:hover {
+ color: var(--start-dt-accent);
+}
+
+[data-solid-value-edit-btn] svg {
+ width: 0.75rem;
+ height: 0.75rem;
+}
+
+[data-solid-value-toggle-btn] {
+ display: inline-flex;
+ align-items: center;
+
+ padding: 0.0625rem 0.25rem;
+ border: 1px solid var(--start-dt-border-soft, transparent);
+ border-radius: 0.25rem;
+ background: var(--start-dt-surface, transparent);
+
+ cursor: pointer;
+ transition:
+ border-color 150ms ease-out,
+ background 150ms ease-out;
+}
+
+[data-solid-value-toggle-btn]:hover {
+ border-color: var(--start-dt-accent);
+ background: var(--start-dt-surface-hover, transparent);
+}
+
+[data-solid-value-input] {
+ padding: 0.0625rem 0.25rem;
+ font-family: inherit;
+ font-size: 0.6875rem;
+ line-height: 1rem;
+ border-radius: 0.25rem;
+ border: 1px solid var(--start-dt-accent);
+ background: var(--start-dt-bg);
+ color: var(--start-dt-text);
+ outline: none;
+ min-width: 3.5rem;
+ max-width: 14rem;
+}
+
+[data-solid-value-edit-failed] {
+ text-decoration: wavy underline var(--start-dt-danger);
+}
diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts
index da63550..718b2ba 100644
--- a/tests/e2e/devtools.spec.ts
+++ b/tests/e2e/devtools.spec.ts
@@ -650,6 +650,56 @@ test('inspects elements and funnels components across panels', async ({ page })
await expect(overlay).toBeHidden();
});
+test('allows live editing of signal state and updates DOM', async ({ page }) => {
+ await page.goto('/');
+
+ // 1. Live edit via Ownership panel
+ await openTab(page, 'Ownership');
+ await page.locator('[data-solid-ownership-label]').filter({ hasText: '' }).click();
+
+ const countSignal = page
+ .locator('[data-solid-ownership-signal]')
+ .filter({ has: page.getByText('count', { exact: true }) });
+ await expect(countSignal).toContainText('0');
+
+ // Trigger inline edit
+ await countSignal.hover();
+ await countSignal.locator('[data-solid-ownership-edit-signal]').click();
+ const ownershipInput = countSignal.locator('[data-solid-ownership-signal-input]');
+ await expect(ownershipInput).toBeVisible();
+ await ownershipInput.fill('42');
+ await ownershipInput.press('Enter');
+
+ // Verify state update in devtools and live app DOM
+ await expect(countSignal).toContainText('42');
+ await expect(page.locator('#increment-count')).toHaveText('count 42 doubled 84');
+ expect(await page.evaluate(() => Reflect.get(window, '__doubled'))).toBe(84);
+
+ // 2. Live edit via Reactivity Graph detail pane
+ await openTab(page, 'Reactivity');
+ const countNode = page
+ .locator('[data-solid-reactivity-node]')
+ .filter({ hasText: /^count/ })
+ .first();
+ await countNode.click();
+
+ const detail = page.locator('[data-solid-reactivity-detail]');
+ await expect(detail).toContainText('Value');
+
+ const valueInputTrigger = detail.locator('[data-solid-value-edit-btn]');
+ await valueInputTrigger.click();
+
+ const reactivityInput = detail.locator('[data-solid-value-input]');
+ await expect(reactivityInput).toBeVisible();
+ await reactivityInput.fill('100');
+ await reactivityInput.press('Enter');
+
+ // Verify state update in devtools and live app DOM
+ await expect(detail.locator('[data-solid-reactivity-detail-value]')).toContainText('100');
+ await expect(page.locator('#increment-count')).toHaveText('count 100 doubled 200');
+ expect(await page.evaluate(() => Reflect.get(window, '__doubled'))).toBe(200);
+});
+
test('mounts once and disposes', async ({ page }) => {
await page.goto('/?mount');
diff --git a/vitest.config.ts b/vitest.config.ts
index 20bf325..5a6b25c 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -2,6 +2,27 @@ import { configDefaults, defineConfig } from 'vitest/config';
export default defineConfig({
test: {
- exclude: [...configDefaults.exclude, 'tests/e2e/**'],
+ projects: [
+ {
+ extends: true,
+ test: {
+ name: 'unit',
+ exclude: [...configDefaults.exclude, 'tests/e2e/**', '**/*.dev.test.ts'],
+ },
+ },
+ {
+ // Tests that drive the reactive runtime the browser dev build ships.
+ // Under Node, solid-js resolves to its server build, which has no
+ // reactive graph.
+ extends: true,
+ resolve: { conditions: ['browser', 'development'] },
+ ssr: { resolve: { conditions: ['browser', 'development'] } },
+ test: {
+ name: 'solid-dev',
+ include: ['src/**/*.dev.test.ts'],
+ server: { deps: { inline: [/solid-js/, /@solidjs\/signals/] } },
+ },
+ },
+ ],
},
});