From 9637c17f78a3595c9a0db649028affd628d66ee3 Mon Sep 17 00:00:00 2001 From: Artem Kyrychenko Date: Mon, 2 Nov 2026 16:18:42 +0200 Subject: [PATCH 1/4] feat(state-editor): add live signal and store editing with object tree inspection --- .changeset/live-state-editor.md | 11 + src/dev-toolbar/icons.tsx | 20 ++ src/dev-toolbar/ownership/index.tsx | 185 +++++++++++--- src/dev-toolbar/ownership/styles.css | 131 +++++++++- src/dev-toolbar/reactivity/ValueInspector.tsx | 157 +++++++++++- src/dev-toolbar/reactivity/index.tsx | 8 + .../reactivity/value-editor.test.ts | 147 +++++++++++ src/dev-toolbar/reactivity/value-editor.ts | 230 ++++++++++++++++++ src/ui/ValueTree.css | 82 +++++++ tests/e2e/devtools.spec.ts | 50 ++++ 10 files changed, 980 insertions(+), 41 deletions(-) create mode 100644 .changeset/live-state-editor.md create mode 100644 src/dev-toolbar/reactivity/value-editor.test.ts create mode 100644 src/dev-toolbar/reactivity/value-editor.ts diff --git a/.changeset/live-state-editor.md b/.changeset/live-state-editor.md new file mode 100644 index 0000000..8ce1a3e --- /dev/null +++ b/.changeset/live-state-editor.md @@ -0,0 +1,11 @@ +--- +'@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. 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..700d2a6 100644 --- a/src/dev-toolbar/ownership/index.tsx +++ b/src/dev-toolbar/ownership/index.tsx @@ -4,11 +4,17 @@ 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 { + parseValueInput, + writeReactiveValue, + formatDraftValue, +} from '../reactivity/value-editor.js'; +import { ValueInspector } from '../reactivity/ValueInspector.js'; import { previewValue, typeName } from './format.js'; import { excludeOwner, @@ -54,6 +60,151 @@ 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 [editing, setEditing] = createSignal(false); + const [expanded, setExpanded] = createSignal(false); + const [draft, setDraft] = createSignal(formatDraftValue(props.signal.value)); + + const isComplex = createMemo( + () => typeof props.signal.value === 'object' && props.signal.value !== null, + ); + + function startEditing(): void { + setDraft(formatDraftValue(props.signal.value)); + setEditing(true); + } + + function commit(): void { + if (!editing()) return; + setEditing(false); + const parsed = parseValueInput(draft(), props.signal.value); + if (parsed !== props.signal.value) { + writeReactiveValue(props.signal.node, [], parsed); + } + } + + return ( +
+
+ + + + +
+ + + + +
+ + } + > + setDraft(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commit(); + if (e.key === 'Escape') setEditing(false); + }} + onBlur={commit} + ref={(el) => { + queueMicrotask(() => { + el.focus(); + el.select(); + }); + }} + /> + +
+ +
+ { + 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 +584,10 @@ export default function OwnershipViewer(props: OwnershipViewerProps): JSX.Elemen } > {(signal) => ( -
- - {signal.name} - - - {previewValue(signal.value)} - - - - -
+ )} diff --git a/src/dev-toolbar/ownership/styles.css b/src/dev-toolbar/ownership/styles.css index efdbc72..9e7331e 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; } diff --git a/src/dev-toolbar/reactivity/ValueInspector.tsx b/src/dev-toolbar/reactivity/ValueInspector.tsx index a20e7c3..3961082 100644 --- a/src/dev-toolbar/reactivity/ValueInspector.tsx +++ b/src/dev-toolbar/reactivity/ValueInspector.tsx @@ -1,9 +1,18 @@ 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 { parseValueInput } from './value-editor.js'; function LinkIcon(props: { title: string }): JSX.Element { return ( @@ -18,6 +27,95 @@ function LinkIcon(props: { title: string }): JSX.Element { ); } +interface EditableValueProps { + value: string | number | boolean | bigint | null | undefined; + kind?: ValueTokenKind; + path: readonly (string | number)[]; + onEdit: (path: readonly (string | number)[], nextValue: unknown) => void; +} + +function EditableValue(props: EditableValueProps): JSX.Element { + const [editing, setEditing] = createSignal(false); + const [draft, setDraft] = createSignal(`${props.value ?? ''}`); + + function startEditing(): void { + setDraft(`${props.value ?? ''}`); + setEditing(true); + } + + function commit(): void { + if (!editing()) return; + setEditing(false); + const parsed = parseValueInput(draft(), props.value); + if (parsed !== props.value) { + props.onEdit(props.path, parsed); + } + } + + if (typeof props.value === 'boolean') { + return ( + + + + ); + } + + return ( + + + + + + } + > + setDraft(e.currentTarget.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commit(); + if (e.key === 'Escape') setEditing(false); + }} + onBlur={commit} + ref={(el) => { + queueMicrotask(() => { + el.focus(); + el.select(); + }); + }} + /> + + + ); +} + export interface ValueInspectorProps { value: unknown; label?: JSX.Element; @@ -25,6 +123,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?: (path: readonly (string | number)[], nextValue: unknown) => void; + /** Path from the root inspected value down to this node. */ + path?: readonly (string | number)[]; } /** @@ -38,7 +142,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 +160,16 @@ function renderValue( label: JSX.Element | undefined, open: boolean | undefined, seen: object[], + editable: boolean | undefined, + onEdit: ((path: readonly (string | number)[], nextValue: unknown) => void) | undefined, + path: readonly (string | number)[], ): JSX.Element { if (value === null) { return ( - + }> + + ); } @@ -61,31 +178,50 @@ function renderValue( case 'undefined': return ( - + } + > + + ); case 'string': return ( - + } + > + + ); case 'number': return ( - + }> + + ); case 'bigint': return ( - + } + > + + ); case 'boolean': return ( - + }> + + ); case 'symbol': @@ -177,6 +313,9 @@ function renderValue( value={entry.value} seen={nested} label={} + editable={editable} + onEdit={onEdit} + path={[...path, entry.key]} /> )} diff --git a/src/dev-toolbar/reactivity/index.tsx b/src/dev-toolbar/reactivity/index.tsx index acb21ab..f0e0c9a 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.test.ts b/src/dev-toolbar/reactivity/value-editor.test.ts new file mode 100644 index 0000000..8c08180 --- /dev/null +++ b/src/dev-toolbar/reactivity/value-editor.test.ts @@ -0,0 +1,147 @@ +import { createMemo, createRoot, createSignal } from 'solid-js'; +import { describe, expect, it } from 'vitest'; +import { + formatDraftValue, + parseValueInput, + updatePath, + writeReactiveValue, +} 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'); + }); + + 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); + expect(parseValueInput('1', false)).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('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('writeReactiveValue', () => { + it('returns false for null or undefined target', () => { + expect(writeReactiveValue(null as any, [], 10)).toBe(false); + expect(writeReactiveValue(undefined as any, [], 10)).toBe(false); + }); + + it('updates plain signal node value and notifies observers', () => { + createRoot((dispose) => { + const [count] = createSignal(10); + const double = createMemo(() => count() * 2); + + expect(count()).toBe(10); + expect(double()).toBe(20); + + const node: any = { + _value: 10, + _subs: null, + }; + + writeReactiveValue(node, [], 25); + expect(node._value).toBe(25); + + dispose(); + }); + }); + + it('updates nested object signal at path', () => { + const node: any = { + _value: { user: { name: 'Alice', active: false } }, + _subs: null, + }; + + writeReactiveValue(node, ['user', 'active'], true); + expect(node._value.user.active).toBe(true); + expect(node._value.user.name).toBe('Alice'); + }); + + it('updates store slot node backing data', () => { + const host = { + v: { title: 'Old Title', completed: false }, + pb: null as any, + }; + const slotNode: any = { + _host: host, + _key: 'title', + _value: 'Old Title', + _subs: null, + }; + + writeReactiveValue(slotNode, [], 'New Title'); + expect(host.v.title).toBe('New Title'); + expect(slotNode._value).toBe('New Title'); + }); +}); diff --git a/src/dev-toolbar/reactivity/value-editor.ts b/src/dev-toolbar/reactivity/value-editor.ts new file mode 100644 index 0000000..16980b4 --- /dev/null +++ b/src/dev-toolbar/reactivity/value-editor.ts @@ -0,0 +1,230 @@ +import { createRoot, createSignal, DEV, flush, getOwner, runWithOwner } from 'solid-js'; + +/** + * Updates a nested property along `path` immutably. + */ +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; +} + +/** + * 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 === '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') { + return trimmed === 'true' || trimmed === '1'; + } + 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; +} + +/** + * Bridges external writes into Solid's internal reactivity scheduler. + * + * 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. + */ +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, + path: readonly (string | number)[], + nextValue: unknown, + ): boolean { + if (!target || typeof target !== 'object') return false; + + // Handle direct store proxy references ($TARGET / [Symbol(STORE_PROXY)]). + if ( + '$TARGET' in target || + (typeof Symbol === 'function' && Object.getOwnPropertySymbols(target).length > 0) + ) { + const targetShape = target['$TARGET'] ?? target; + if (targetShape && typeof targetShape === 'object' && 'v' in targetShape && targetShape.v) { + if (path.length === 0 && typeof nextValue === 'object' && nextValue !== null) { + Object.assign(targetShape.v, nextValue); + } else if (path.length > 0) { + let curr = targetShape.v; + for (let i = 0; i < path.length - 1; i++) { + curr = curr[path[i]]; + } + curr[path[path.length - 1]] = nextValue; + } + return true; + } + } + + const isStoreSlot = '_host' in target; + if (isStoreSlot && target._host) { + const key = target._key; + if (path.length === 0) { + if (target._host.v) target._host.v[key] = nextValue; + if (target._host.pb) target._host.pb[key] = nextValue; + } else { + const rootVal = target._host.v ? target._host.v[key] : target._value; + const updated = updatePath(rootVal, path, nextValue); + if (target._host.v) target._host.v[key] = updated; + if (target._host.pb) target._host.pb[key] = updated; + nextValue = updated; + } + } else if (path.length > 0) { + nextValue = updatePath(target._value, path, nextValue); + } + + this.ensureBridge(); + if (!this.setter || !this.bridgeNode) { + target._value = nextValue; + return true; + } + + 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); + }); + + try { + flush(); + } catch { + // In browser microtask environments flush may already be pending. + } + + return true; + } +} + +const writer = new ReactiveWriter(); + +/** + * Writes a new value to a reactive signal or store node at the specified path. + */ +export function writeReactiveValue( + target: object, + path: readonly (string | number)[], + nextValue: unknown, +): boolean { + return writer.write(target as Record, path, nextValue); +} diff --git a/src/ui/ValueTree.css b/src/ui/ValueTree.css index 913252d..9526ec3 100644 --- a/src/ui/ValueTree.css +++ b/src/ui/ValueTree.css @@ -135,3 +135,85 @@ 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; +} 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'); From 1e31ee60424f27a314a0402830d7c2914f50939d Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Wed, 4 Nov 2026 09:00:23 +0800 Subject: [PATCH 2/4] fix(state-editor): refuse writes that would lose data or skip the app writeReactiveValue now returns false instead of setting _value directly when the bridge cannot be set up, and refuses a path that passes through a Map, a Set, a typed array or a class instance, which updatePath would turn into a plain object. The store proxy branch is removed: both callers pass a reactive node. parseValueInput keeps the old value for a boolean given anything other than true or false, and keeps a bigint a bigint. The writeReactiveValue tests now run against the browser dev build of solid-js in a second vitest project, on real signal and store nodes, and check that a dependent memo updates. Co-Authored-By: Claude Opus 5.5 --- .../reactivity/value-editor.dev.test.ts | 100 +++++++++++++++ .../reactivity/value-editor.test.ts | 103 ++++++++------- src/dev-toolbar/reactivity/value-editor.ts | 119 ++++++++++++++---- vitest.config.ts | 23 +++- 4 files changed, 269 insertions(+), 76 deletions(-) create mode 100644 src/dev-toolbar/reactivity/value-editor.dev.test.ts 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..2d4452b --- /dev/null +++ b/src/dev-toolbar/reactivity/value-editor.dev.test.ts @@ -0,0 +1,100 @@ +import { createMemo, createRoot, createSignal, createStore, DEV, flush, getOwner } from 'solid-js'; +import { describe, expect, it } 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(); + }); + }); +}); diff --git a/src/dev-toolbar/reactivity/value-editor.test.ts b/src/dev-toolbar/reactivity/value-editor.test.ts index 8c08180..aa02c94 100644 --- a/src/dev-toolbar/reactivity/value-editor.test.ts +++ b/src/dev-toolbar/reactivity/value-editor.test.ts @@ -1,10 +1,12 @@ -import { createMemo, createRoot, createSignal } from 'solid-js'; import { describe, expect, it } from 'vitest'; import { + canUpdatePath, formatDraftValue, + isJsonValue, + isPlainContainer, + isTextEditable, parseValueInput, updatePath, - writeReactiveValue, } from './value-editor.js'; describe('formatDraftValue', () => { @@ -14,6 +16,7 @@ describe('formatDraftValue', () => { 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', () => { @@ -39,7 +42,9 @@ describe('parseValueInput', () => { it('parses boolean values correctly', () => { expect(parseValueInput('true', false)).toBe(true); expect(parseValueInput('false', true)).toBe(false); - expect(parseValueInput('1', false)).toBe(true); + // 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); }); @@ -57,6 +62,12 @@ describe('parseValueInput', () => { 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]); @@ -91,57 +102,53 @@ describe('updatePath', () => { }); }); -describe('writeReactiveValue', () => { - it('returns false for null or undefined target', () => { - expect(writeReactiveValue(null as any, [], 10)).toBe(false); - expect(writeReactiveValue(undefined as any, [], 10)).toBe(false); +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); }); +}); - it('updates plain signal node value and notifies observers', () => { - createRoot((dispose) => { - const [count] = createSignal(10); - const double = createMemo(() => count() * 2); - - expect(count()).toBe(10); - expect(double()).toBe(20); - - const node: any = { - _value: 10, - _subs: null, - }; - - writeReactiveValue(node, [], 25); - expect(node._value).toBe(25); - - dispose(); - }); +describe('isJsonValue', () => { + it('accepts values a JSON round trip gives back equal', () => { + expect(isJsonValue({ a: [1, 'b', null, { c: true }] })).toBe(true); }); - it('updates nested object signal at path', () => { - const node: any = { - _value: { user: { name: 'Alice', active: false } }, - _subs: null, - }; - - writeReactiveValue(node, ['user', 'active'], true); - expect(node._value.user.active).toBe(true); - expect(node._value.user.name).toBe('Alice'); + 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); }); +}); - it('updates store slot node backing data', () => { - const host = { - v: { title: 'Old Title', completed: false }, - pb: null as any, - }; - const slotNode: any = { - _host: host, - _key: 'title', - _value: 'Old Title', - _subs: null, - }; +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); + }); +}); - writeReactiveValue(slotNode, [], 'New Title'); - expect(host.v.title).toBe('New Title'); - expect(slotNode._value).toBe('New Title'); +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 index 16980b4..4300908 100644 --- a/src/dev-toolbar/reactivity/value-editor.ts +++ b/src/dev-toolbar/reactivity/value-editor.ts @@ -1,7 +1,62 @@ import { createRoot, createSignal, DEV, flush, getOwner, runWithOwner } from 'solid-js'; /** - * Updates a nested property along `path` immutably. + * 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; @@ -16,6 +71,25 @@ export function updatePath(target: T, path: readonly (string | number)[], val 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. */ @@ -23,6 +97,7 @@ 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); @@ -49,7 +124,14 @@ export function parseValueInput(raw: string, currentValue?: unknown): unknown { if (!Number.isNaN(num) && trimmed !== '') return num; } if (typeof currentValue === 'boolean') { - return trimmed === 'true' || trimmed === '1'; + 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; @@ -124,25 +206,10 @@ class ReactiveWriter { ): boolean { if (!target || typeof target !== 'object') return false; - // Handle direct store proxy references ($TARGET / [Symbol(STORE_PROXY)]). - if ( - '$TARGET' in target || - (typeof Symbol === 'function' && Object.getOwnPropertySymbols(target).length > 0) - ) { - const targetShape = target['$TARGET'] ?? target; - if (targetShape && typeof targetShape === 'object' && 'v' in targetShape && targetShape.v) { - if (path.length === 0 && typeof nextValue === 'object' && nextValue !== null) { - Object.assign(targetShape.v, nextValue); - } else if (path.length > 0) { - let curr = targetShape.v; - for (let i = 0; i < path.length - 1; i++) { - curr = curr[path[i]]; - } - curr[path[path.length - 1]] = nextValue; - } - return true; - } - } + // 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; const isStoreSlot = '_host' in target; if (isStoreSlot && target._host) { @@ -152,21 +219,17 @@ class ReactiveWriter { if (target._host.pb) target._host.pb[key] = nextValue; } else { const rootVal = target._host.v ? target._host.v[key] : target._value; + if (!canUpdatePath(rootVal, path)) return false; const updated = updatePath(rootVal, path, nextValue); if (target._host.v) target._host.v[key] = updated; if (target._host.pb) target._host.pb[key] = updated; nextValue = updated; } } else if (path.length > 0) { + if (!canUpdatePath(target._value, path)) return false; nextValue = updatePath(target._value, path, nextValue); } - this.ensureBridge(); - if (!this.setter || !this.bridgeNode) { - target._value = nextValue; - return true; - } - const keys = new Set([ ...Object.getOwnPropertyNames(this.bridgeNode), ...Object.getOwnPropertyNames(target), @@ -220,6 +283,8 @@ 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, 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/] } }, + }, + }, + ], }, }); From 1de544d57becce4b97e2f51edf1d58fa01a5900d Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Wed, 4 Nov 2026 09:00:24 +0800 Subject: [PATCH 3/4] fix(state-editor): leave values alone when an edit changes nothing Leaving an object signal's JSON draft as it was no longer writes a JSON copy over the value. Only values that survive a JSON round trip are editable as text, and the value tree stops offering edits below a container that is not a plain object or array. An edit the app did not take is marked in the panel. EditableValue and OwnershipSignalRow share the draft, commit and cancel steps in createDraftEdit and the input in DraftInput. Co-Authored-By: Claude Opus 5.5 --- .changeset/live-state-editor.md | 1 + src/dev-toolbar/ownership/index.tsx | 95 ++++++++----------- src/dev-toolbar/ownership/styles.css | 4 + src/dev-toolbar/reactivity/DraftInput.tsx | 31 ++++++ src/dev-toolbar/reactivity/ValueInspector.tsx | 78 ++++++--------- .../reactivity/draft-edit.dev.test.ts | 53 +++++++++++ src/dev-toolbar/reactivity/draft-edit.ts | 58 +++++++++++ src/dev-toolbar/reactivity/index.tsx | 6 +- src/ui/ValueTree.css | 4 + 9 files changed, 221 insertions(+), 109 deletions(-) create mode 100644 src/dev-toolbar/reactivity/DraftInput.tsx create mode 100644 src/dev-toolbar/reactivity/draft-edit.dev.test.ts create mode 100644 src/dev-toolbar/reactivity/draft-edit.ts diff --git a/.changeset/live-state-editor.md b/.changeset/live-state-editor.md index 8ce1a3e..6917d8e 100644 --- a/.changeset/live-state-editor.md +++ b/.changeset/live-state-editor.md @@ -9,3 +9,4 @@ Signals and stores can now be inspected and edited live on-the-fly directly from - 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/ownership/index.tsx b/src/dev-toolbar/ownership/index.tsx index 700d2a6..c19a430 100644 --- a/src/dev-toolbar/ownership/index.tsx +++ b/src/dev-toolbar/ownership/index.tsx @@ -9,11 +9,9 @@ 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 { - parseValueInput, - writeReactiveValue, - formatDraftValue, -} from '../reactivity/value-editor.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 { @@ -66,26 +64,23 @@ interface OwnershipSignalRowProps { } function OwnershipSignalRow(props: OwnershipSignalRowProps): JSX.Element { - const [editing, setEditing] = createSignal(false); const [expanded, setExpanded] = createSignal(false); - const [draft, setDraft] = createSignal(formatDraftValue(props.signal.value)); + 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, ); - - function startEditing(): void { - setDraft(formatDraftValue(props.signal.value)); - setEditing(true); - } - - function commit(): void { - if (!editing()) return; - setEditing(false); - const parsed = parseValueInput(draft(), props.signal.value); - if (parsed !== props.signal.value) { - writeReactiveValue(props.signal.node, [], parsed); - } + // 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 ( @@ -113,7 +108,7 @@ function OwnershipSignalRow(props: OwnershipSignalRowProps): JSX.Element { { if (isComplex()) setExpanded((v) => !v); }} - onDblClick={startEditing} - title={ - isComplex() - ? 'Click to expand / double-click to edit JSON' - : 'Double-click to edit' - } + onDblClick={() => { + if (textEditable()) edit.start(); + }} + title={valueTitle()} > {previewValue(props.signal.value)} @@ -140,22 +134,25 @@ function OwnershipSignalRow(props: OwnershipSignalRowProps): JSX.Element { type="button" data-solid-ownership-toggle-btn data-value={props.signal.value ? 'true' : 'false'} - onClick={() => writeReactiveValue(props.signal.node, [], !props.signal.value)} - title="Click to toggle boolean" + data-solid-ownership-edit-failed={edit.failed() ? '' : undefined} + onClick={() => edit.write(!props.signal.value)} + title={edit.failed() ? valueTitle() : 'Click to toggle boolean'} > {`${props.signal.value}`}
- + + +
@@ -195,9 +176,7 @@ function OwnershipSignalRow(props: OwnershipSignalRowProps): JSX.Element { value={props.signal.value} open editable - onEdit={(path, nextValue) => { - writeReactiveValue(props.signal.node, path, nextValue); - }} + onEdit={(path, nextValue) => writeReactiveValue(props.signal.node, path, nextValue)} /> diff --git a/src/dev-toolbar/ownership/styles.css b/src/dev-toolbar/ownership/styles.css index 9e7331e..a881e6a 100644 --- a/src/dev-toolbar/ownership/styles.css +++ b/src/dev-toolbar/ownership/styles.css @@ -621,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 3961082..f12b85c 100644 --- a/src/dev-toolbar/reactivity/ValueInspector.tsx +++ b/src/dev-toolbar/reactivity/ValueInspector.tsx @@ -12,7 +12,9 @@ import { import { EditIcon } from '../icons.js'; import { formatValue, typeName } from './format.js'; import { containerEntries, entryCount, ownEntries } from './value-entries.js'; -import { parseValueInput } from './value-editor.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 ( @@ -27,30 +29,23 @@ 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: (path: readonly (string | number)[], nextValue: unknown) => void; + onEdit: ValueEditHandler; } -function EditableValue(props: EditableValueProps): JSX.Element { - const [editing, setEditing] = createSignal(false); - const [draft, setDraft] = createSignal(`${props.value ?? ''}`); - - function startEditing(): void { - setDraft(`${props.value ?? ''}`); - setEditing(true); - } +const FAILED_TITLE = 'The app did not take this edit'; - function commit(): void { - if (!editing()) return; - setEditing(false); - const parsed = parseValueInput(draft(), props.value); - if (parsed !== props.value) { - props.onEdit(props.path, parsed); - } - } +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 ( @@ -59,8 +54,9 @@ function EditableValue(props: EditableValueProps): JSX.Element { type="button" data-solid-value-toggle-btn data-value={props.value ? 'true' : 'false'} - onClick={() => props.onEdit(props.path, !props.value)} - title="Click to toggle boolean" + data-solid-value-edit-failed={edit.failed() ? '' : undefined} + onClick={() => edit.write(!props.value)} + title={edit.failed() ? FAILED_TITLE : 'Click to toggle boolean'} > @@ -71,21 +67,19 @@ function EditableValue(props: EditableValueProps): JSX.Element { return ( - +