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 ( +
+
+ + + + +
+ + + + + + +
+ + } + > + + +
+ +
+ 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)} - - - - -
+ )} 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 ( + + + + ); + } + + 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/] } }, + }, + }, + ], }, });