Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/live-state-editor.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions src/dev-toolbar/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -760,3 +760,23 @@ export function InspectIcon(props: JSX.IntrinsicElements['svg'] & { title: strin
</svg>
);
}

export function EditIcon(props: JSX.IntrinsicElements['svg'] & { title: string }): JSX.Element {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
{...props}
>
<title>{props.title}</title>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.6"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
/>
</svg>
);
}
164 changes: 135 additions & 29 deletions src/dev-toolbar/ownership/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<div data-solid-ownership-signal-block>
<div data-solid-ownership-signal>
<span data-solid-ownership-signal-name>
<Show when={isComplex()}>
<button
type="button"
data-solid-ownership-signal-chevron
data-expanded={expanded() ? 'true' : undefined}
onClick={() => setExpanded((v) => !v)}
aria-label={expanded() ? 'Collapse value' : 'Expand value'}
/>
</Show>
<Text
options={{
size: 'xs',
weight: 'semibold',
font: 'mono',
wrap: 'nowrap',
}}
>
{props.signal.name}
</Text>
</span>
<Show
when={edit.editing()}
fallback={
<>
<Show
when={typeof props.signal.value === 'boolean'}
fallback={
<Text
data-solid-ownership-signal-value
data-solid-ownership-edit-failed={edit.failed() ? '' : undefined}
options={{ size: 'xs', font: 'mono' }}
onClick={() => {
if (isComplex()) setExpanded((v) => !v);
}}
onDblClick={() => {
if (textEditable()) edit.start();
}}
title={valueTitle()}
>
{previewValue(props.signal.value)}
</Text>
}
>
<button
type="button"
data-solid-ownership-toggle-btn
data-value={props.signal.value ? 'true' : 'false'}
data-solid-ownership-edit-failed={edit.failed() ? '' : undefined}
onClick={() => edit.write(!props.signal.value)}
title={edit.failed() ? valueTitle() : 'Click to toggle boolean'}
>
<Text options={{ size: 'xs', font: 'mono' }}>{`${props.signal.value}`}</Text>
</button>
</Show>
<div data-solid-ownership-signal-actions>
<Show when={textEditable()}>
<button
type="button"
data-solid-ownership-edit-signal
aria-label={`Edit ${props.signal.name}`}
title={`Edit ${props.signal.name}`}
onClick={edit.start}
>
<EditIcon title={`Edit ${props.signal.name}`} />
</button>
</Show>
<Show when={props.onViewInGraph}>
<button
type="button"
data-solid-ownership-view-graph
aria-label="View in graph"
onClick={() => props.onViewInGraph?.(props.signal.node)}
>
<EyeIcon title="View in graph" />
</button>
</Show>
</div>
</>
}
>
<DraftInput edit={edit} marker="data-solid-ownership-signal-input" />
</Show>
</div>
<Show when={isComplex() && expanded()}>
<div data-solid-ownership-signal-tree>
<ValueInspector
value={props.signal.value}
open
editable
onEdit={(path, nextValue) => writeReactiveValue(props.signal.node, path, nextValue)}
/>
</div>
</Show>
</div>
);
}

export default function OwnershipViewer(props: OwnershipViewerProps): JSX.Element {
// The panel renders inside the app it inspects, so its own scope is marked.
excludeOwner(getOwner());
Expand Down Expand Up @@ -433,34 +563,10 @@ export default function OwnershipViewer(props: OwnershipViewerProps): JSX.Elemen
}
>
{(signal) => (
<div data-solid-ownership-signal>
<Text
options={{
size: 'xs',
weight: 'semibold',
font: 'mono',
wrap: 'nowrap',
}}
>
{signal.name}
</Text>
<Text
data-solid-ownership-signal-value
options={{ size: 'xs', font: 'mono' }}
>
{previewValue(signal.value)}
</Text>
<Show when={props.onViewInGraph}>
<button
type="button"
data-solid-ownership-view-graph
aria-label="View in graph"
onClick={() => props.onViewInGraph?.(signal.node)}
>
<EyeIcon title="View in graph" />
</button>
</Show>
</div>
<OwnershipSignalRow
signal={signal}
onViewInGraph={props.onViewInGraph}
/>
)}
</For>
</div>
Expand Down
135 changes: 132 additions & 3 deletions src/dev-toolbar/ownership/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;

Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
}
Loading
Loading