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
11 changes: 11 additions & 0 deletions .changeset/reactivity-graph.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@solidjs/start-devtools': patch
---

Add a reactivity graph panel to the dev toolbar.

The panel maps live signals, memos and effects as a directed graph.
Hovering a node shows its value, state, owner and edge counts.
Selecting a node highlights everything upstream and downstream of it and lists its sources and observers.
The selected node's value is shown as an expandable tree, the same one the server function panel uses for serialized values.
Nodes pulse and count changes as the app updates, and the graph can be filtered by kind or searched by name, value or owner.
26 changes: 24 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.
- A reactivity graph of the live signals, memos and effects in the app.

```sh
pnpm add @solidjs/start-devtools@next
Expand All @@ -25,4 +29,22 @@ 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/demo` is a small orders dashboard that exercises every panel.

```sh
pnpm demo
```

## Reactivity graph

The graph panel maps the running reactive graph. Signals, memos and effects are nodes,
and an edge points from a source to the computation that reads it. Hover a node for its
value, state and edge counts. Select one to dim the rest of the graph, list what it
reads and what reads it, and inspect its value as an expandable tree.

The panel reads the graph 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 component tree inspection, see [Solid Devtools](https://github.com/thetarnav/solid-devtools).
32 changes: 32 additions & 0 deletions examples/demo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Dev toolbar demo

A small orders dashboard that exercises every panel of the toolbar.

```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/demo/src/App.tsx` is the whole app.

- The reactivity graph has named nodes. `orders`, `query`, `status-filter`, `sort-order`
and `currency` are signals. `matching-orders`, `sorted-orders`, `revenue`, `open-orders`,
`average-order`, `exchange-rate` and `converted-revenue` are memos. `sync-title` is an
effect that writes the document title.
- `exchange-rate` is an async memo backed by a `"use server"` function. While it is in
flight, it and `converted-revenue` read as pending in the graph.
- The server function panel lists the same call with its request and response bodies.
- The error panel catches the error behind the "Throw an error" button, with a
source-mapped stack frame.

## Things to try

1. Open the graph and select `revenue`. Everything it reads turns blue and everything that
reads it turns green.
2. Type in the search box and watch `matching-orders` and the nodes after it count changes.
3. Switch the currency and open the graph quickly to catch the pending state.
4. Turn the `Render` filter off to hide the nodes the JSX compiler creates.
248 changes: 248 additions & 0 deletions examples/demo/src/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
import { createEffect, createMemo, createSignal, For, Loading, Show } from 'solid-js';
import { createOrder, SEED_ORDERS, type Order, type OrderStatus } from './orders.js';
import { reportCall } from './report-call.js';
import './styles.css';

type Filter = 'all' | OrderStatus;
type Sort = 'newest' | 'total';

const FILTERS: Filter[] = ['all', 'paid', 'open', 'refunded'];
const CURRENCIES = ['USD', 'EUR', 'JPY'];

/**
* Runs on the server. The toolbar's server function panel shows the request and
* the response, including the serialized body.
*/
async function loadRate(currency: string): Promise<{ currency: string; rate: number }> {
'use server';
const rates: Record<string, number> = { USD: 1, EUR: 0.92, JPY: 155.4 };
await new Promise((resolve) => setTimeout(resolve, 450));
return { currency, rate: rates[currency] ?? 1 };
}

function Boom(): never {
throw new Error('The demo threw this on purpose.');
}

export default function App() {
const [orders, setOrders] = createSignal<Order[]>(SEED_ORDERS, { name: 'orders' });
const [query, setQuery] = createSignal('', { name: 'query' });
const [filter, setFilter] = createSignal<Filter>('all', { name: 'status-filter' });
const [sort, setSort] = createSignal<Sort>('newest', { name: 'sort-order' });
const [currency, setCurrency] = createSignal('USD', { name: 'currency' });
const [broken, setBroken] = createSignal(false, { name: 'broken' });

const matching = createMemo(
() => {
const search = query().trim().toLowerCase();
const status = filter();
return orders().filter((order) => {
if (status !== 'all' && order.status !== status) return false;
if (!search) return true;
return (
order.customer.toLowerCase().includes(search) ||
order.item.toLowerCase().includes(search) ||
order.id.toLowerCase().includes(search)
);
});
},
{ name: 'matching-orders' },
);

const sorted = createMemo(
() =>
[...matching()].sort((a, b) =>
sort() === 'total' ? b.total - a.total : b.placedAt - a.placedAt,
),
{ name: 'sorted-orders' },
);

const revenue = createMemo(
() =>
matching().reduce((total, order) => total + (order.status === 'paid' ? order.total : 0), 0),
{ name: 'revenue' },
);

const openCount = createMemo(() => matching().filter((order) => order.status === 'open').length, {
name: 'open-orders',
});

const averageOrder = createMemo(
() => (matching().length === 0 ? 0 : revenue() / matching().length),
{ name: 'average-order' },
);

// An async memo. While the server function is in flight the graph shows this
// node, and everything derived from it, as pending.
const rate = createMemo(
async () => {
const target = currency();
const answer = await reportCall('loadRate', { currency: target }, () => loadRate(target));
return answer.rate;
},
{ name: 'exchange-rate' },
);

const converted = createMemo(() => revenue() * rate(), { name: 'converted-revenue' });

createEffect(
() => ({ open: openCount(), total: matching().length }),
(counts) => {
document.title = `${counts.open} open of ${counts.total} orders`;
},
{ name: 'sync-title' },
);

function addOrder(): void {
setOrders((current) => [createOrder(), ...current]);
}

function refund(id: string): void {
setOrders((current) =>
current.map((order) => (order.id === id ? { ...order, status: 'refunded' } : order)),
);
}

return (
<main class="page">
<header class="masthead">
<div>
<h1>Orders</h1>
<p class="subtitle">
A demo app for the Solid Start dev toolbar. Open the toolbar and pick the graph icon.
</p>
</div>
<div class="masthead-actions">
<button class="primary" onClick={addOrder}>
Add order
</button>
<button onClick={() => setBroken(true)}>Throw an error</button>
</div>
</header>

<section class="stats">
<article class="stat">
<span class="stat-label">Paid revenue</span>
<span class="stat-value">{`$${revenue().toFixed(2)}`}</span>
<span class="stat-note">signal → matching-orders → revenue</span>
</article>
<article class="stat">
<span class="stat-label">Average order</span>
<span class="stat-value">{`$${averageOrder().toFixed(2)}`}</span>
<span class="stat-note">revenue ÷ matching-orders</span>
</article>
<article class="stat">
<span class="stat-label">Open</span>
<span class="stat-value">{openCount()}</span>
<span class="stat-note">drives the document title effect</span>
</article>
<article class="stat">
<span class="stat-label">{`Revenue in ${currency()}`}</span>
<Loading fallback={<span class="stat-value pending">loading…</span>}>
<span class="stat-value">{converted().toFixed(2)}</span>
</Loading>
<span class="stat-note">server function through an async memo</span>
</article>
</section>

<section class="controls">
<input
type="search"
placeholder="Search customer, item or id"
value={query()}
onInput={(event) => setQuery(event.currentTarget.value)}
/>
<div class="chips">
<For each={FILTERS}>
{(value) => (
<button
class="chip"
aria-pressed={filter() === value ? 'true' : 'false'}
onClick={() => setFilter(value)}
>
{value}
</button>
)}
</For>
</div>
<div class="chips">
<button
class="chip"
aria-pressed={sort() === 'newest' ? 'true' : 'false'}
onClick={() => setSort('newest')}
>
newest
</button>
<button
class="chip"
aria-pressed={sort() === 'total' ? 'true' : 'false'}
onClick={() => setSort('total')}
>
highest total
</button>
</div>
<div class="chips">
<For each={CURRENCIES}>
{(value) => (
<button
class="chip"
aria-pressed={currency() === value ? 'true' : 'false'}
onClick={() => setCurrency(value)}
>
{value}
</button>
)}
</For>
</div>
</section>

<table class="orders">
<thead>
<tr>
<th>Order</th>
<th>Customer</th>
<th>Item</th>
<th>Status</th>
<th class="right">Total</th>
<th />
</tr>
</thead>
<tbody>
<For
each={sorted()}
fallback={
<tr>
<td colspan={6} class="empty">
Nothing matches this filter.
</td>
</tr>
}
>
{(order) => (
<tr>
<td class="mono">{order.id}</td>
<td>{order.customer}</td>
<td>{order.item}</td>
<td>
<span class={`status status-${order.status}`}>{order.status}</span>
</td>
<td class="right mono">{`$${order.total.toFixed(2)}`}</td>
<td class="right">
<Show when={order.status !== 'refunded'}>
<button class="link" onClick={() => refund(order.id)}>
Refund
</button>
</Show>
</td>
</tr>
)}
</For>
</tbody>
</table>

<Show when={broken()}>
<Boom />
</Show>
</main>
);
}
1 change: 1 addition & 0 deletions examples/demo/src/css.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare module '*.css';
Loading
Loading