Skip to content
Open
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
10 changes: 10 additions & 0 deletions .changeset/ownership-tree.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@solidjs/start-devtools': patch
---

Add an ownership tree panel to the dev toolbar.

The panel shows the app as a tree of owners. Component mode lists components only and folds the scopes between them into the component above, so a component shows the signals, memos and effects created inside it. Owner mode shows every owner.
Selecting a row lists its prop names, the signals it holds with their values, the scopes folded into it, its children, and the ancestry it was created under.
Components show where they are declared, and clicking the location opens the file in your editor.
Rows flash when an owner is created, and the tree can be searched by component, scope or signal.
36 changes: 34 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

Development error and server-function tooling for Solid Start mode.

`@solidjs/start-devtools` provides the toolbar used by the Solid Vite plugin in development. It includes runtime error inspection, source-mapped stack frames, and server-function request and response inspection.
`@solidjs/start-devtools` provides the toolbar used by the Solid Vite plugin in development.

- Runtime error inspection with source-mapped stack frames.
- Server-function request and response inspection.
- An ownership tree of the components and scopes the app created.

```sh
pnpm add @solidjs/start-devtools@next
Expand All @@ -25,4 +29,32 @@ does not include the toolbar.

The same import is safe in development and production entries.

For component and reactivity inspection, see [Solid Devtools](https://github.com/thetarnav/solid-devtools).
## Demo

`examples/explorer` is a file explorer whose component tree grows as you open folders.

```sh
pnpm demo
```

## Ownership tree

The ownership panel shows the app as a tree of owners.

Component mode lists components only. The scopes between them are folded into the component
above, so a component shows every signal, memo and effect created inside it. Owner mode
shows every owner instead, including roots, memos and effects.

Selecting a row lists its prop names, the signals it holds with their values, the scopes
folded into it, its children, and the ancestry it was created under. Every frame of the
ancestry is clickable, so you can walk back up the tree. Prop values are getters, so the panel lists their names
and never reads them.

A component also shows where it is declared. The location comes from the hot reload
transform, which `@solidjs/vite-plugin` runs in development, so it is there without any
extra setup. Clicking it asks the dev server to open the file in your editor.

The panel reads the tree through the development hooks in `solid-js`, so it is empty in a
production build of the runtime. It only watches while it is open.

For reactivity inspection, see [Solid Devtools](https://github.com/thetarnav/solid-devtools).
31 changes: 31 additions & 0 deletions examples/explorer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Ownership demo

A file explorer that grows and shrinks its component tree as you use it.

```sh
pnpm demo
```

The command builds the package and starts the app on http://localhost:5173.

## What it shows

The app is built with `@solidjs/vite-plugin` in start mode, so the plugin owns the entries
and mounts the toolbar itself. `examples/explorer/src/App.tsx` is the whole app.

- `FolderNode` renders itself for every nested folder, so the ownership tree has the same
shape as the folder you opened.
- `SelectionProvider` owns the selection signals. Every row reads them out of context,
which is visible in the tree as one owner holding the signals many components use.
- `PreviewPane` is mounted behind a toggle, so hiding it disposes an owner and its scopes.
- `Stats`, `Breadcrumbs` and `FolderNode` each create memos, which component mode folds
into the component that owns them.

## Things to try

1. Open the ownership panel and expand `routes` in the app. New rows appear and flash.
2. Hide the preview. `<PreviewPane>` and the memo and effect it owns leave the tree.
3. Select `<SelectionProvider>` and see the two signals every row depends on.
4. Click the file location under a component name to open it in your editor.
5. Switch to owner mode to see the roots, memos and effects that component mode folds away.
6. Search for `folder-stats` to find every folder memo at once.
254 changes: 254 additions & 0 deletions examples/explorer/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import type { JSX } from '@solidjs/web';
import {
createContext,
createEffect,
createMemo,
createSignal,
For,
Show,
useContext,
} from 'solid-js';
import {
countEntries,
formatBytes,
PROJECT,
type Entry,
type FileEntry,
type FolderEntry,
} from './tree-data.js';
import './styles.css';

interface Selection {
path: () => string;
entry: () => Entry | undefined;
select: (path: string, entry: Entry) => void;
}

const SelectionContext = createContext<Selection>();

function useSelection(): Selection {
const selection = useContext(SelectionContext);
if (!selection) throw new Error('SelectionProvider is missing');
return selection;
}

/** Owns the selection. Every row below reads it out of context. */
function SelectionProvider(props: { children: JSX.Element }): JSX.Element {
const [path, setPath] = createSignal('app', { name: 'selected-path' });
const [entry, setEntry] = createSignal<Entry | undefined>(PROJECT, { name: 'selected-entry' });

const value: Selection = {
path,
entry,
select(next, item) {
setPath(next);
setEntry(() => item);
},
};

return <SelectionContext value={value}>{props.children}</SelectionContext>;
}

function FileNode(props: { entry: FileEntry; path: string; depth: number }): JSX.Element {
const selection = useSelection();
const active = createMemo(() => selection.path() === props.path, { name: 'file-active' });

return (
<button
class={active() ? 'row active' : 'row'}
style={{ 'padding-left': `${props.depth * 0.875 + 0.5}rem` }}
onClick={() => selection.select(props.path, props.entry)}
>
<span class={`dot lang-${props.entry.language}`} />
<span class="row-name">{props.entry.name}</span>
<span class="row-meta">{formatBytes(props.entry.size)}</span>
</button>
);
}

/**
* Renders itself for every nested folder. Opening a folder mounts a component
* for each child, so the ownership tree grows with the folder.
*/
function FolderNode(props: { entry: FolderEntry; path: string; depth: number }): JSX.Element {
const selection = useSelection();
const [open, setOpen] = createSignal(props.depth < 1, { name: 'folder-open' });
const stats = createMemo(() => countEntries(props.entry), { name: 'folder-stats' });

return (
<>
<button
class={selection.path() === props.path ? 'row active' : 'row'}
style={{ 'padding-left': `${props.depth * 0.875 + 0.25}rem` }}
onClick={() => {
setOpen((current) => !current);
selection.select(props.path, props.entry);
}}
>
<span class={open() ? 'chevron open' : 'chevron'} />
<span class="row-name folder">{props.entry.name}</span>
<span class="row-meta">{`${stats().files} files`}</span>
</button>
<Show when={open()}>
<For each={props.entry.entries}>
{(child) =>
child.kind === 'folder' ? (
<FolderNode
entry={child}
path={`${props.path}/${child.name}`}
depth={props.depth + 1}
/>
) : (
<FileNode
entry={child}
path={`${props.path}/${child.name}`}
depth={props.depth + 1}
/>
)
}
</For>
</Show>
</>
);
}

function Breadcrumbs(): JSX.Element {
const selection = useSelection();
const segments = createMemo(() => selection.path().split('/'), { name: 'path-segments' });

return (
<nav class="breadcrumbs">
<For each={segments()}>
{(segment, index) => (
<>
<Show when={index() > 0}>
<span class="crumb-sep">/</span>
</Show>
<span class="crumb">{segment}</span>
</>
)}
</For>
</nav>
);
}

function Stats(props: { entry: Entry }): JSX.Element {
const totals = createMemo(() => countEntries(props.entry), { name: 'entry-totals' });
const average = createMemo(() => (totals().files === 0 ? 0 : totals().bytes / totals().files), {
name: 'average-size',
});

return (
<div class="stats">
<div class="stat">
<span class="stat-label">Files</span>
<span class="stat-value">{totals().files}</span>
</div>
<div class="stat">
<span class="stat-label">Folders</span>
<span class="stat-value">{totals().folders}</span>
</div>
<div class="stat">
<span class="stat-label">Size</span>
<span class="stat-value">{formatBytes(totals().bytes)}</span>
</div>
<div class="stat">
<span class="stat-label">Average</span>
<span class="stat-value">{formatBytes(Math.round(average()))}</span>
</div>
</div>
);
}

/** Mounted and disposed by the toggle, so the tree gains and loses a subtree. */
function PreviewPane(): JSX.Element {
const selection = useSelection();
const lines = createMemo(
() => {
const entry = selection.entry();
if (!entry) return [];
if (entry.kind === 'folder') {
return entry.entries.map(
(child) => `${child.kind === 'folder' ? '📁' : '📄'} ${child.name}`,
);
}
return [
`// ${entry.name}`,
`// ${entry.language}, ${formatBytes(entry.size)}`,
'export function handler() {',
' return new Response("ok");',
'}',
];
},
{ name: 'preview-lines' },
);

createEffect(
() => selection.path(),
(path) => {
document.title = `${path} — explorer`;
},
{ name: 'sync-title' },
);

return (
<pre class="preview">
<For each={lines()}>{(line) => <div>{line}</div>}</For>
</pre>
);
}

function Inspector(): JSX.Element {
const selection = useSelection();
const [showPreview, setShowPreview] = createSignal(true, { name: 'show-preview' });

return (
<section class="inspector">
<header class="inspector-head">
<Breadcrumbs />
<button class="toggle" onClick={() => setShowPreview((current) => !current)}>
{showPreview() ? 'Hide preview' : 'Show preview'}
</button>
</header>
<Show when={selection.entry()}>{(entry) => <Stats entry={entry()} />}</Show>
<Show when={showPreview()}>
<PreviewPane />
</Show>
</section>
);
}

function Explorer(): JSX.Element {
return (
<section class="explorer">
<header class="explorer-head">
<span class="explorer-title">Project</span>
</header>
<div class="rows">
<FolderNode entry={PROJECT} path="app" depth={0} />
</div>
</section>
);
}

export default function App(): JSX.Element {
return (
<SelectionProvider>
<main class="page">
<header class="masthead">
<div>
<h1>Explorer</h1>
<p class="subtitle">
A demo app for the ownership panel. Open the toolbar, pick the tree icon, then expand
a folder and watch the components appear.
</p>
</div>
</header>
<div class="columns">
<Explorer />
<Inspector />
</div>
</main>
</SelectionProvider>
);
}
1 change: 1 addition & 0 deletions examples/explorer/src/css.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare module '*.css';
Loading
Loading