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
4 changes: 2 additions & 2 deletions .changeset/highlight-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
Add fine-grained reactive update highlighter with frequency color progression:

- Toggle via `Alt+Shift+H` shortcut or the highlight icon in the dock toolbar, with state persisted in local storage.
- Flashes fine-grained DOM mutations in real time, visualizing surgical reactivity where only updating text nodes and SVG icons flash without coarse component bounding boxes.
- Outlines each DOM change as it happens. A text change gets a box around the changed text only, one per line it covers. Other changes get a box around the element.
- Frequency-based color progression within an 800ms window: Light Blue (1), Purple (2-3), Pink (4-7), and Red (8+).
- Pixel-perfect ghost overlay using deep DOM node cloning in a dedicated portal layer, preserving typography, line-height, text transforms, and SVG stroke/fill colors.
- The boxes are drawn on one canvas in a shadow root, so they add nothing to the page and page styles cannot reach them. They follow the page when it scrolls. One frame draws at most 200 of them, and the fade turns off when the user prefers reduced motion.
5 changes: 5 additions & 0 deletions .changeset/keyed-panel-rows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@solidjs/start-devtools': patch
---

The Ownership tree rows and the Reactivity graph nodes and edges are now matched by id between refreshes. Before, every refresh rebuilt all of them, so an app that writes a signal every frame kept the page at about 15 to 20 frames per second while one of these panels was open.
38 changes: 37 additions & 1 deletion examples/demo/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, For, Loading, Show } from 'solid-js';
import { createEffect, createMemo, createSignal, For, Loading, onSettled, Show } from 'solid-js';
import { createOrder, SEED_ORDERS, type Order, type OrderStatus } from './orders.js';
import { reportCall } from './report-call.js';
import './styles.css';
Expand All @@ -24,6 +24,41 @@ function Boom(): never {
throw new Error('The demo threw this on purpose.');
}

/**
* Updates on every animation frame while it runs: a counter for a text
* change, and a dot whose style changes for an element change. Useful to
* watch the update highlight while scrolling or resizing.
*/
function Ticker() {
const [running, setRunning] = createSignal(true, { name: 'ticker-running' });
const [frame, setFrame] = createSignal(0, { name: 'ticker-frame' });

onSettled(() => {
let id = requestAnimationFrame(function tick() {
if (running()) setFrame((value) => value + 1);
id = requestAnimationFrame(tick);
});
return () => cancelAnimationFrame(id);
});

return (
<article class="stat">
<span class="stat-label">Frames</span>
<span class="stat-value">{frame()}</span>
<div class="ticker-track">
<div class="ticker-dot" style={{ left: `${(frame() % 120) / 1.2}%` }} />
</div>
<button
type="button"
class="link ticker-toggle"
onClick={() => setRunning((value) => !value)}
>
{running() ? 'Pause' : 'Play'}
</button>
</article>
);
}

export default function App() {
const [orders, setOrders] = createSignal<Order[]>(SEED_ORDERS, { name: 'orders' });
const [query, setQuery] = createSignal('', { name: 'query' });
Expand Down Expand Up @@ -143,6 +178,7 @@ export default function App() {
</Loading>
<span class="stat-note">server function through an async memo</span>
</article>
<Ticker />
</section>

<section class="controls">
Expand Down
22 changes: 22 additions & 0 deletions examples/demo/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,28 @@ button.link {
font-size: 1.25rem;
}

.ticker-track {
position: relative;
height: 0.5rem;
margin: 0.25rem 0;
border-radius: 999px;
background: var(--border);
}

.ticker-dot {
position: absolute;
top: 0;
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
background: var(--accent);
}

.ticker-toggle {
align-self: flex-start;
padding: 0;
}

.stat-note {
color: var(--muted);
font-size: 0.6875rem;
Expand Down
72 changes: 72 additions & 0 deletions src/dev-toolbar/highlight-updates/canvas.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, it, vi } from 'vitest';
import { boxAlpha, drawBoxes, type FlashBox } from './canvas.js';
import { FADE_OUT_DURATION_MS } from './tracker.js';

function box(start: number): FlashBox {
return {
left: 10,
top: 20,
width: 80,
height: 16,
color: '#4483ec',
background: 'rgba(68, 131, 236, 0.22)',
start,
};
}

function mockContext() {
return {
clearRect: vi.fn(),
beginPath: vi.fn(),
roundRect: vi.fn(),
fill: vi.fn(),
stroke: vi.fn(),
globalAlpha: 1,
lineWidth: 1,
fillStyle: '',
strokeStyle: '',
};
}

describe('boxAlpha', () => {
it('holds, then fades to 0 at the end of the duration', () => {
const b = box(0);
expect(boxAlpha(b, 0, true)).toBe(1);
expect(boxAlpha(b, FADE_OUT_DURATION_MS * 0.1, true)).toBe(1);
const middle = boxAlpha(b, FADE_OUT_DURATION_MS * 0.6, true);
expect(middle).toBeGreaterThan(0);
expect(middle).toBeLessThan(1);
expect(boxAlpha(b, FADE_OUT_DURATION_MS, true)).toBe(0);
});

it('stays opaque until the end without the fade', () => {
const b = box(0);
expect(boxAlpha(b, FADE_OUT_DURATION_MS * 0.9, false)).toBe(1);
expect(boxAlpha(b, FADE_OUT_DURATION_MS, false)).toBe(0);
});
});

describe('drawBoxes', () => {
it('draws the boxes still showing and drops the rest', () => {
const ctx = mockContext();
const fresh = box(1000);
const old = box(1000 - FADE_OUT_DURATION_MS);

const showing = drawBoxes(
ctx as unknown as CanvasRenderingContext2D,
800,
600,
[fresh, old],
1000,
true,
);

expect(showing).toEqual([fresh]);
expect(ctx.clearRect).toHaveBeenCalledWith(0, 0, 800, 600);
expect(ctx.roundRect).toHaveBeenCalledTimes(1);
// The border sits inside the box.
expect(ctx.roundRect).toHaveBeenCalledWith(10.75, 20.75, 78.5, 14.5, 3);
expect(ctx.strokeStyle).toBe('#4483ec');
expect(ctx.globalAlpha).toBe(1);
});
});
64 changes: 64 additions & 0 deletions src/dev-toolbar/highlight-updates/canvas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { UpdateRect } from './rects.js';
import { FADE_OUT_DURATION_MS } from './tracker.js';

export interface FlashBox extends UpdateRect {
/** The border color, as `#rrggbb`. */
color: string;
/** The fill color. */
background: string;
/** When the box appeared, in `performance.now()` time. */
start: number;
}

/** The part of the fade a box stays fully opaque, so a short update is still seen. */
const HOLD = 0.15;
const BORDER_WIDTH = 1.5;
const RADIUS = 3;

/** How opaque a box is at `now`: 1 during the hold, then down to 0 at the end. */
export function boxAlpha(box: FlashBox, now: number, fade: boolean): number {
const progress = (now - box.start) / FADE_OUT_DURATION_MS;
if (progress >= 1) return 0;
if (!fade || progress <= HOLD) return 1;
return 1 - (progress - HOLD) / (1 - HOLD);
}

/**
* Clears the canvas and draws every box that has not run out. The context is
* a parameter, so the same code can draw on an OffscreenCanvas in a worker.
* Returns the boxes still showing, for the next frame.
*/
export function drawBoxes(
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
width: number,
height: number,
boxes: FlashBox[],
now: number,
fade: boolean,
): FlashBox[] {
ctx.clearRect(0, 0, width, height);
const showing: FlashBox[] = [];
for (const box of boxes) {
const alpha = boxAlpha(box, now, fade);
if (alpha <= 0) continue;
showing.push(box);
// The border is drawn inside the box, as `box-sizing: border-box` would.
const inset = BORDER_WIDTH / 2;
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.roundRect(
box.left + inset,
box.top + inset,
Math.max(0, box.width - BORDER_WIDTH),
Math.max(0, box.height - BORDER_WIDTH),
RADIUS,
);
ctx.fillStyle = box.background;
ctx.fill();
ctx.lineWidth = BORDER_WIDTH;
ctx.strokeStyle = box.color;
ctx.stroke();
}
ctx.globalAlpha = 1;
return showing;
}
Loading
Loading