From 633495765727721bb97917ef7d3e77c469329335 Mon Sep 17 00:00:00 2001 From: Artem Kyrychenko Date: Tue, 27 Oct 2026 12:55:50 +0200 Subject: [PATCH 1/4] feat(highlight-updates): add ghost flash highlighter for fine-grained reactive updates --- src/dev-toolbar/highlight-updates/clone.ts | 296 ++++++++++++++++++ src/dev-toolbar/highlight-updates/overlay.css | 24 ++ src/dev-toolbar/highlight-updates/overlay.tsx | 67 ++++ src/dev-toolbar/highlight-updates/tracker.ts | 201 ++++++++++++ src/dev-toolbar/icons.tsx | 22 ++ src/dev-toolbar/index.tsx | 56 +++- 6 files changed, 660 insertions(+), 6 deletions(-) create mode 100644 src/dev-toolbar/highlight-updates/clone.ts create mode 100644 src/dev-toolbar/highlight-updates/overlay.css create mode 100644 src/dev-toolbar/highlight-updates/overlay.tsx create mode 100644 src/dev-toolbar/highlight-updates/tracker.ts diff --git a/src/dev-toolbar/highlight-updates/clone.ts b/src/dev-toolbar/highlight-updates/clone.ts new file mode 100644 index 0000000..0b3c849 --- /dev/null +++ b/src/dev-toolbar/highlight-updates/clone.ts @@ -0,0 +1,296 @@ +import { getFlashColors, FADE_OUT_DURATION_MS } from './tracker.js'; + +function getNodePath(root: Node, target: Node): number[] { + const path: number[] = []; + let current: Node | null = target; + while (current && current !== root) { + const parent: Node | null = current.parentNode; + if (!parent) break; + const index = Array.prototype.indexOf.call(parent.childNodes, current); + path.unshift(index); + current = parent; + } + return path; +} + +function getNodeByPath(root: Node, path: number[]): Node | null { + let current: Node = root; + for (const index of path) { + if (!current.childNodes || !current.childNodes[index]) return null; + current = current.childNodes[index]; + } + return current; +} + +function findSvgRoot(element: Element): SVGElement | null { + if (typeof SVGElement !== 'undefined' && element instanceof SVGElement) { + if (element.tagName.toLowerCase() === 'svg') { + return element; + } + return element.ownerSVGElement ?? (element.closest ? element.closest('svg') : null); + } + if (element.tagName.toLowerCase() === 'svg') { + return element as unknown as SVGElement; + } + return element.closest ? (element.closest('svg') as SVGElement | null) : null; +} + +function applySvgColors(cloneSvg: SVGElement, sourceSvg: SVGElement, color: string): void { + const win = + sourceSvg.ownerDocument?.defaultView ?? (typeof window !== 'undefined' ? window : null); + + const cloneElements: SVGElement[] = [ + cloneSvg, + ...Array.from(cloneSvg.querySelectorAll('*')), + ]; + const sourceElements: SVGElement[] = [ + sourceSvg, + ...Array.from(sourceSvg.querySelectorAll('*')), + ]; + + for (let i = 0; i < cloneElements.length; i++) { + const cloneEl = cloneElements[i]; + const sourceEl = sourceElements[i]; + if (!cloneEl || !sourceEl) continue; + + cloneEl.style.color = color; + + if (win) { + const computed = win.getComputedStyle(sourceEl); + const stroke = computed.stroke; + const fill = computed.fill; + const strokeWidth = computed.strokeWidth; + + const attrStroke = sourceEl.getAttribute('stroke'); + const attrFill = sourceEl.getAttribute('fill'); + + const hasVisibleStroke = + (stroke && stroke !== 'none' && stroke !== 'transparent' && strokeWidth !== '0px') || + (attrStroke && attrStroke !== 'none'); + + if (hasVisibleStroke) { + cloneEl.style.setProperty('stroke', color, 'important'); + } + + const hasVisibleFill = + (fill && fill !== 'none' && fill !== 'transparent') || (attrFill && attrFill !== 'none'); + + if (hasVisibleFill) { + cloneEl.style.setProperty('fill', color, 'important'); + } + } else { + cloneEl.style.setProperty('stroke', color, 'important'); + cloneEl.style.setProperty('fill', color, 'important'); + } + } +} + +/** + * Creates an exact deep clone of the element that contains the updating node, + * preserving all typography, line-height, text-transform, and layout metrics. + * + * - Text updates: Non-target elements/text are hidden with visibility: hidden + * so wrapping and layout match 100%, while only the updating text flashes. + * - SVG updates: Cloned with transparent background, flashing stroke/fill in update color. + * - Div / container updates: Flashes background tint and outline, while inner text + * and icons align pixel-perfectly and flash in the exact same update color. + */ +export function createGhostClone(node: Node, count: number): HTMLElement | SVGElement | null { + if (typeof document === 'undefined') return null; + + const isText = node.nodeType === 3; /* Node.TEXT_NODE */ + const hostElement = isText ? node.parentElement : (node as Element); + if (!hostElement || !(hostElement instanceof HTMLElement || hostElement instanceof SVGElement)) { + return null; + } + + const svgRoot = findSvgRoot(hostElement); + const colors = getFlashColors(count); + + if (svgRoot) { + const rect = svgRoot.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) return null; + + const clone = svgRoot.cloneNode(true) as SVGElement; + + clone.removeAttribute('id'); + const elementsWithId = clone.querySelectorAll('[id]'); + for (let i = 0; i < elementsWithId.length; i++) { + elementsWithId[i].removeAttribute('id'); + } + + clone.setAttribute('data-solid-flash-clone', ''); + clone.style.position = 'fixed'; + clone.style.left = `${rect.left}px`; + clone.style.top = `${rect.top}px`; + clone.style.width = `${rect.width}px`; + clone.style.height = `${rect.height}px`; + clone.style.margin = '0'; + clone.style.pointerEvents = 'none'; + clone.style.userSelect = 'none'; + clone.style.zIndex = '999998'; + clone.style.transform = 'none'; + clone.style.animation = `solid-flash-fade ${FADE_OUT_DURATION_MS}ms cubic-bezier(0, 0, 0.2, 1) forwards`; + + clone.style.background = 'transparent'; + clone.style.backgroundColor = 'transparent'; + clone.style.outline = 'none'; + clone.style.border = 'none'; + clone.style.boxShadow = 'none'; + + applySvgColors(clone, svgRoot, colors.color); + + return clone; + } + + const rect = hostElement.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) return null; + + const clone = hostElement.cloneNode(true) as HTMLElement; + + clone.removeAttribute('id'); + const elementsWithId = clone.querySelectorAll('[id]'); + for (let i = 0; i < elementsWithId.length; i++) { + elementsWithId[i].removeAttribute('id'); + } + + clone.setAttribute('data-solid-flash-clone', ''); + clone.style.position = 'fixed'; + clone.style.left = `${rect.left}px`; + clone.style.top = `${rect.top}px`; + clone.style.width = `${rect.width}px`; + clone.style.height = `${rect.height}px`; + clone.style.margin = '0'; + clone.style.pointerEvents = 'none'; + clone.style.userSelect = 'none'; + clone.style.zIndex = '999998'; + clone.style.animation = `solid-flash-fade ${FADE_OUT_DURATION_MS}ms cubic-bezier(0, 0, 0.2, 1) forwards`; + + const win = + hostElement.ownerDocument?.defaultView ?? (typeof window !== 'undefined' ? window : null); + + if (win) { + const computed = win.getComputedStyle(hostElement); + + clone.style.setProperty('font-family', computed.fontFamily, 'important'); + clone.style.setProperty('font-size', computed.fontSize, 'important'); + clone.style.setProperty('font-weight', computed.fontWeight, 'important'); + clone.style.setProperty('font-style', computed.fontStyle, 'important'); + clone.style.setProperty('line-height', computed.lineHeight, 'important'); + clone.style.setProperty('letter-spacing', computed.letterSpacing, 'important'); + clone.style.setProperty('text-transform', computed.textTransform, 'important'); + clone.style.setProperty('text-align', computed.textAlign, 'important'); + clone.style.setProperty('text-indent', computed.textIndent, 'important'); + clone.style.setProperty('white-space', computed.whiteSpace, 'important'); + clone.style.setProperty('word-break', computed.wordBreak, 'important'); + clone.style.setProperty('tab-size', computed.tabSize, 'important'); + + clone.style.setProperty('box-sizing', 'border-box', 'important'); + clone.style.setProperty('padding-top', computed.paddingTop, 'important'); + clone.style.setProperty('padding-right', computed.paddingRight, 'important'); + clone.style.setProperty('padding-bottom', computed.paddingBottom, 'important'); + clone.style.setProperty('padding-left', computed.paddingLeft, 'important'); + clone.style.setProperty('border-radius', computed.borderRadius, 'important'); + clone.style.setProperty('transform', 'none', 'important'); + + if (computed.display === 'flex' || computed.display === 'inline-flex') { + clone.style.display = computed.display; + clone.style.flexDirection = computed.flexDirection; + clone.style.alignItems = computed.alignItems; + clone.style.justifyContent = computed.justifyContent; + clone.style.gap = computed.gap; + } else if (computed.display === 'grid' || computed.display === 'inline-grid') { + clone.style.display = computed.display; + clone.style.gridTemplateColumns = computed.gridTemplateColumns; + clone.style.gridTemplateRows = computed.gridTemplateRows; + clone.style.gap = computed.gap; + } + } + + if (isText) { + clone.style.background = 'transparent'; + clone.style.backgroundColor = 'transparent'; + clone.style.backgroundImage = 'none'; + clone.style.borderColor = 'transparent'; + clone.style.boxShadow = 'none'; + clone.style.outline = 'none'; + + const path = getNodePath(hostElement, node); + const targetInClone = getNodeByPath(clone, path); + + if (targetInClone) { + const walker = document.createTreeWalker(clone, NodeFilter.SHOW_ALL); + const toHide: (HTMLElement | SVGElement)[] = []; + const toWrap: Node[] = []; + + let current: Node | null = walker.currentNode; + while (current) { + if (current === targetInClone) { + // Keep target visible + } else if ( + current !== clone && + !current.contains(targetInClone) && + !targetInClone.contains(current) + ) { + if (current instanceof HTMLElement || current instanceof SVGElement) { + toHide.push(current); + } else if (current.nodeType === 3) { + toWrap.push(current); + } + } + current = walker.nextNode(); + } + + for (const el of toHide) { + el.style.visibility = 'hidden'; + } + + for (const textNode of toWrap) { + const hiddenSpan = document.createElement('span'); + hiddenSpan.textContent = textNode.textContent; + hiddenSpan.style.visibility = 'hidden'; + textNode.parentNode?.replaceChild(hiddenSpan, textNode); + } + + const highlightSpan = document.createElement('span'); + highlightSpan.textContent = targetInClone.textContent; + highlightSpan.style.setProperty('color', colors.color, 'important'); + highlightSpan.style.visibility = 'visible'; + targetInClone.parentNode?.replaceChild(highlightSpan, targetInClone); + } else { + clone.style.setProperty('color', colors.color, 'important'); + } + } else { + clone.style.backgroundColor = colors.background; + clone.style.borderColor = colors.color; + clone.style.outline = `1.5px solid ${colors.color}`; + clone.style.setProperty('color', colors.color, 'important'); + + const cloneChildren = clone.querySelectorAll('*'); + const sourceChildren = hostElement.querySelectorAll('*'); + + for (let i = 0; i < cloneChildren.length; i++) { + const childClone = cloneChildren[i]; + const childSource = sourceChildren[i]; + + if (childClone instanceof HTMLElement) { + childClone.style.setProperty('color', colors.color, 'important'); + + if (win && childSource instanceof HTMLElement) { + const childComputed = win.getComputedStyle(childSource); + childClone.style.setProperty('font-family', childComputed.fontFamily, 'important'); + childClone.style.setProperty('font-size', childComputed.fontSize, 'important'); + childClone.style.setProperty('font-weight', childComputed.fontWeight, 'important'); + childClone.style.setProperty('line-height', childComputed.lineHeight, 'important'); + childClone.style.setProperty('letter-spacing', childComputed.letterSpacing, 'important'); + childClone.style.setProperty('text-transform', childComputed.textTransform, 'important'); + childClone.style.setProperty('white-space', childComputed.whiteSpace, 'important'); + } + } else if (childClone instanceof SVGElement && childSource instanceof SVGElement) { + applySvgColors(childClone, childSource, colors.color); + } + } + } + + return clone; +} diff --git a/src/dev-toolbar/highlight-updates/overlay.css b/src/dev-toolbar/highlight-updates/overlay.css new file mode 100644 index 0000000..41cee1d --- /dev/null +++ b/src/dev-toolbar/highlight-updates/overlay.css @@ -0,0 +1,24 @@ +[data-solid-highlight-overlay] { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 999998; + overflow: hidden; +} + +[data-solid-flash-clone] { + will-change: opacity; + animation: solid-flash-fade 400ms cubic-bezier(0, 0, 0.2, 1) forwards; +} + +@keyframes solid-flash-fade { + 0% { + opacity: 1; + } + 15% { + opacity: 1; + } + 100% { + opacity: 0; + } +} diff --git a/src/dev-toolbar/highlight-updates/overlay.tsx b/src/dev-toolbar/highlight-updates/overlay.tsx new file mode 100644 index 0000000..74837e1 --- /dev/null +++ b/src/dev-toolbar/highlight-updates/overlay.tsx @@ -0,0 +1,67 @@ +import { JSX, Portal } from '@solidjs/web'; +import { createEffect, Show } from 'solid-js'; +import { createGhostClone } from './clone.js'; +import { FADE_OUT_DURATION_MS, UpdateHighlightTracker } from './tracker.js'; +import './overlay.css'; + +export interface HighlightUpdatesOverlayProps { + active: boolean; +} + +export function HighlightUpdatesOverlay(props: HighlightUpdatesOverlayProps): JSX.Element { + let containerRef: HTMLDivElement | undefined; + const tracker = new UpdateHighlightTracker(); + const activeClones = new Map(); + + createEffect( + () => props.active, + (active) => { + if (!active || typeof window === 'undefined') return; + + const unsubscribe = tracker.subscribe((event) => { + if (!containerRef) return; + + const clone = createGhostClone(event.node, event.count); + if (!clone) return; + + // Replace any existing active clone for this node + const previous = activeClones.get(event.node); + if (previous) { + previous.remove(); + } + activeClones.set(event.node, clone); + + containerRef.appendChild(clone); + + const onEnd = () => { + if (activeClones.get(event.node) === clone) { + activeClones.delete(event.node); + } + clone.remove(); + }; + + clone.addEventListener('animationend', onEnd, { once: true }); + setTimeout(onEnd, FADE_OUT_DURATION_MS + 50); + }); + + tracker.start(); + + return () => { + unsubscribe(); + tracker.stop(); + activeClones.clear(); + if (containerRef) { + containerRef.innerHTML = ''; + } + }; + }, + ); + + return ( + + + + { From 91a98bb3ad4ef19d62a76c62bbafaccda2fadace Mon Sep 17 00:00:00 2001 From: Artem Kyrychenko Date: Tue, 27 Oct 2026 12:55:57 +0200 Subject: [PATCH 2/4] test(highlight-updates): add unit and e2e test coverage --- .../highlight-updates/clone.test.ts | 329 ++++++++++++++++++ .../highlight-updates/tracker.test.ts | 81 +++++ tests/e2e/devtools.spec.ts | 43 +++ 3 files changed, 453 insertions(+) create mode 100644 src/dev-toolbar/highlight-updates/clone.test.ts create mode 100644 src/dev-toolbar/highlight-updates/tracker.test.ts diff --git a/src/dev-toolbar/highlight-updates/clone.test.ts b/src/dev-toolbar/highlight-updates/clone.test.ts new file mode 100644 index 0000000..337def7 --- /dev/null +++ b/src/dev-toolbar/highlight-updates/clone.test.ts @@ -0,0 +1,329 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createGhostClone } from './clone.js'; +import { getFlashColors } from './tracker.js'; + +function createMockStyle(): any { + const store: Record = {}; + const styleObj = { + setProperty(name: string, value: string) { + store[name] = value; + const camel = name.replace(/-([a-z])/g, (_, g) => g.toUpperCase()); + store[camel] = value; + (styleObj as any)[camel] = value; + }, + }; + return new Proxy(styleObj, { + get(target, prop: string) { + if (prop in target) return (target as any)[prop]; + return store[prop]; + }, + set(_target, prop: string, value: string) { + store[prop] = value; + const kebab = prop.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`); + store[kebab] = value; + return true; + }, + }); +} + +class MockNode { + public nodeType: number; + public textContent: string; + public parentNode: MockNode | null = null; + public parentElement: MockElement | null = null; + public childNodes: MockNode[] = []; + + constructor(nodeType: number, textContent = '') { + this.nodeType = nodeType; + this.textContent = textContent; + } + + public appendChild(child: MockNode) { + child.parentNode = this; + if (this instanceof MockElement) { + child.parentElement = this; + } + this.childNodes.push(child); + } + + public replaceChild(newChild: MockNode, oldChild: MockNode) { + const index = this.childNodes.indexOf(oldChild); + if (index !== -1) { + newChild.parentNode = this; + if (this instanceof MockElement) { + newChild.parentElement = this; + } + this.childNodes[index] = newChild; + } + } + + public contains(target: MockNode): boolean { + if (target === this) return true; + for (const child of this.childNodes) { + if (child.contains(target)) return true; + } + return false; + } +} + +class MockElement extends MockNode { + public tagName: string; + public style = createMockStyle(); + public attributes: Record = {}; + public isConnected = true; + + constructor(tagName: string) { + super(1, ''); + this.tagName = tagName.toUpperCase(); + } + + public setAttribute(name: string, value: string) { + this.attributes[name] = value; + } + + public getAttribute(name: string): string | null { + return this.attributes[name] ?? null; + } + + public removeAttribute(name: string) { + delete this.attributes[name]; + } + + public getBoundingClientRect() { + return { + left: 10, + top: 20, + width: 100, + height: 30, + bottom: 50, + right: 110, + x: 10, + y: 20, + toJSON: () => {}, + }; + } + + public cloneNode(deep = true): MockElement { + const clone = new (this.constructor as any)(this.tagName); + clone.attributes = { ...this.attributes }; + Object.assign(clone.style, this.style); + if (deep) { + for (const child of this.childNodes) { + const childClone = (child as any).cloneNode ? (child as any).cloneNode(true) : child; + clone.appendChild(childClone); + } + } + return clone; + } + + public querySelectorAll(selector: string): MockElement[] { + const results: MockElement[] = []; + const walk = (node: MockNode) => { + for (const child of node.childNodes) { + if (child instanceof MockElement) { + if (selector === '*' || (selector === '[id]' && child.getAttribute('id'))) { + results.push(child); + } else if (selector.toLowerCase() === child.tagName.toLowerCase()) { + results.push(child); + } + walk(child); + } + } + }; + walk(this); + return results; + } + + public querySelector(selector: string): MockElement | null { + return this.querySelectorAll(selector)[0] ?? null; + } + + public closest(selector: string): MockElement | null { + if (selector.toLowerCase() === this.tagName.toLowerCase()) return this; + return this.parentElement ? this.parentElement.closest(selector) : null; + } +} + +class MockSVGElement extends MockElement { + public ownerSVGElement: MockSVGElement | null = null; +} + +class MockTextNode extends MockNode { + constructor(text: string) { + super(3, text); + } + + public cloneNode(): MockTextNode { + return new MockTextNode(this.textContent); + } +} + +describe('createGhostClone', () => { + const originalDocument = globalThis.document; + const originalWindow = globalThis.window; + const originalSVGElement = (globalThis as any).SVGElement; + const originalHTMLElement = (globalThis as any).HTMLElement; + + beforeEach(() => { + (globalThis as any).HTMLElement = MockElement; + (globalThis as any).SVGElement = MockSVGElement; + (globalThis as any).NodeFilter = { SHOW_ALL: 1 }; + + const mockDoc: any = { + createElement: (tag: string) => new MockElement(tag), + createTreeWalker: (root: MockNode) => { + const all: MockNode[] = []; + const collect = (n: MockNode) => { + all.push(n); + for (const c of n.childNodes) collect(c); + }; + collect(root); + let idx = 0; + return { + currentNode: all[0], + nextNode: () => { + idx++; + return all[idx] ?? null; + }, + }; + }, + }; + + const mockWin: any = { + getComputedStyle: (_el: any) => ({ + fontFamily: 'ui-monospace, monospace', + fontSize: '14px', + fontWeight: '500', + fontStyle: 'normal', + lineHeight: '20px', + letterSpacing: '0px', + textTransform: 'none', + textAlign: 'left', + textIndent: '0px', + whiteSpace: 'nowrap', + wordBreak: 'normal', + tabSize: '2', + boxSizing: 'border-box', + paddingTop: '4px', + paddingRight: '8px', + paddingBottom: '4px', + paddingLeft: '8px', + borderRadius: '6px', + display: 'block', + stroke: 'none', + fill: 'none', + strokeWidth: '0px', + }), + }; + + globalThis.document = mockDoc; + globalThis.window = mockWin; + }); + + afterEach(() => { + globalThis.document = originalDocument; + globalThis.window = originalWindow; + (globalThis as any).SVGElement = originalSVGElement; + (globalThis as any).HTMLElement = originalHTMLElement; + }); + + it('returns null when element has 0 width or height', () => { + const el = new MockElement('div'); + vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({ + left: 0, + top: 0, + width: 0, + height: 0, + bottom: 0, + right: 0, + x: 0, + y: 0, + toJSON: () => {}, + }); + + expect(createGhostClone(el as unknown as Node, 1)).toBeNull(); + }); + + it('gives SVGs text-like ghost treatment with stroke/fill/color flashing', () => { + const svg = new MockSVGElement('svg'); + svg.setAttribute('stroke', 'currentColor'); + const path = new MockSVGElement('path'); + path.ownerSVGElement = svg; + svg.appendChild(path); + + const colors = getFlashColors(1); + const clone = createGhostClone(svg as unknown as Node, 1); + expect(clone).not.toBeNull(); + expect(clone).toBeInstanceOf(MockSVGElement); + + const svgClone = clone as unknown as MockSVGElement; + expect(svgClone.style.background).toBe('transparent'); + expect(svgClone.style.outline).toBe('none'); + expect(svgClone.style.color).toBe(colors.color); + expect(svgClone.style.stroke).toBe(colors.color); + }); + + it('finds SVG root and flashes icon when an inner path updates', () => { + const svg = new MockSVGElement('svg'); + const path = new MockSVGElement('path'); + path.setAttribute('fill', 'currentColor'); + path.ownerSVGElement = svg; + svg.appendChild(path); + + const colors = getFlashColors(2); + const clone = createGhostClone(path as unknown as Node, 2); + expect(clone).not.toBeNull(); + expect(clone).toBeInstanceOf(MockSVGElement); + expect((clone as any).tagName.toLowerCase()).toBe('svg'); + + const svgClone = clone as unknown as MockSVGElement; + expect(svgClone.style.color).toBe(colors.color); + const pathClone = svgClone.querySelector('path'); + expect(pathClone?.style.fill).toBe(colors.color); + }); + + it('colors text inside div clones with the same update color and keeps box outline/tint', () => { + const div = new MockElement('div'); + const span = new MockElement('span'); + span.textContent = '// entry-server.tsx'; + div.appendChild(span); + + const colors = getFlashColors(1); + const clone = createGhostClone(div as unknown as Node, 1); + expect(clone).not.toBeNull(); + expect(clone).toBeInstanceOf(MockElement); + + const htmlClone = clone as unknown as MockElement; + expect(htmlClone.style.backgroundColor).toBe(colors.background); + expect(htmlClone.style.outline).toBe(`1.5px solid ${colors.color}`); + expect(htmlClone.style.color).toBe(colors.color); + expect(htmlClone.style.fontFamily).toBe('ui-monospace, monospace'); + + const spanClone = htmlClone.querySelector('span'); + expect(spanClone).not.toBeNull(); + expect(spanClone?.style.color).toBe(colors.color); + }); + + it('preserves text layout while highlighting only target text for text nodes', () => { + const p = new MockElement('p'); + p.appendChild(new MockTextNode('Prefix ')); + const target = new MockTextNode('Counter 42'); + p.appendChild(target); + p.appendChild(new MockTextNode(' Suffix')); + + const colors = getFlashColors(1); + const clone = createGhostClone(target as unknown as Node, 1); + expect(clone).not.toBeNull(); + + const htmlClone = clone as unknown as MockElement; + expect(htmlClone.style.background).toBe('transparent'); + expect(htmlClone.style.outline).toBe('none'); + expect(htmlClone.style.fontFamily).toBe('ui-monospace, monospace'); + + const spans = htmlClone.querySelectorAll('span'); + const highlighted = spans.find((s) => s.style.color === colors.color); + expect(highlighted).toBeDefined(); + expect(highlighted?.textContent).toBe('Counter 42'); + expect(highlighted?.style.color).toBe(colors.color); + }); +}); diff --git a/src/dev-toolbar/highlight-updates/tracker.test.ts b/src/dev-toolbar/highlight-updates/tracker.test.ts new file mode 100644 index 0000000..88dfd30 --- /dev/null +++ b/src/dev-toolbar/highlight-updates/tracker.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + getFlashColors, + INACTIVITY_RESET_MS, + type UpdateEvent, + UpdateHighlightTracker, +} from './tracker.js'; + +describe('getFlashColors', () => { + it('returns solid light blue for 1 update', () => { + const colors = getFlashColors(1); + expect(colors.color).toBe('#4483ec'); + expect(colors.background).toContain('rgba(68, 131, 236'); + }); + + it('returns purple for 2-3 updates', () => { + expect(getFlashColors(2).color).toBe('#9333ea'); + expect(getFlashColors(3).color).toBe('#9333ea'); + }); + + it('returns pink for 4-7 updates', () => { + expect(getFlashColors(4).color).toBe('#ec4899'); + expect(getFlashColors(7).color).toBe('#ec4899'); + }); + + it('returns red for 8+ updates', () => { + expect(getFlashColors(8).color).toBe('#ef4444'); + expect(getFlashColors(15).color).toBe('#ef4444'); + }); +}); + +describe('UpdateHighlightTracker', () => { + function createMockElement(): Element { + const el = { + tagName: 'BUTTON', + isConnected: true, + closest: vi.fn().mockReturnValue(null), + }; + return el as unknown as Element; + } + + function createMockTextNode(text = '42') { + const parent = createMockElement(); + return { + nodeType: 3, + textContent: text, + parentElement: parent, + isConnected: true, + } as unknown as Node; + } + + it('tracks node updates and increments count within window', () => { + vi.useFakeTimers(); + const tracker = new UpdateHighlightTracker(); + + const events: UpdateEvent[] = []; + tracker.subscribe((event) => { + events.push(event); + }); + + const textNode = createMockTextNode('100'); + tracker.recordNodeUpdate(textNode); + + expect(events).toHaveLength(1); + expect(events[0].count).toBe(1); + expect(events[0].node).toBe(textNode); + + // Repeated update within inactivity window increments count + tracker.recordNodeUpdate(textNode); + expect(events).toHaveLength(2); + expect(events[1].count).toBe(2); + + // After inactivity resets, next update starts at 1 + vi.advanceTimersByTime(INACTIVITY_RESET_MS); + tracker.recordNodeUpdate(textNode); + expect(events).toHaveLength(3); + expect(events[2].count).toBe(1); + + vi.useRealTimers(); + }); +}); diff --git a/tests/e2e/devtools.spec.ts b/tests/e2e/devtools.spec.ts index 53e087c..dbdd88f 100644 --- a/tests/e2e/devtools.spec.ts +++ b/tests/e2e/devtools.spec.ts @@ -500,6 +500,49 @@ test('copies reports for an agent', async ({ page, context }) => { expect(await clipboard()).toContain('- signal `count` = `0`'); }); +test('highlights fine-grained reactive updates with frequency colors', async ({ page }) => { + await page.goto('/'); + + // Open the devtools dock. + await page.getByRole('button', { name: 'Open Start Devtools' }).click(); + const highlightButton = page.getByRole('button', { + name: 'Highlight updates (Alt+Shift+H)', + }); + await expect(highlightButton).toBeVisible(); + + // 1. Enable highlight updates. + await highlightButton.click(); + await expect(highlightButton).toHaveAttribute('data-active', ''); + + // 2. Trigger an update on the Counter component. + await page.locator('#increment-count').click(); + + // Ghost clone appears overlaying the exact text in-place. + const flash = page.locator('[data-solid-flash-clone]'); + await expect(flash).toBeVisible(); + await expect(flash).toContainText('count 1'); + // First update is Solid light blue (#4483ec / rgb(68, 131, 236)). + const highlighted = flash.locator('span[style*="color"]'); + expect(await highlighted.evaluate((el) => el.style.color)).toBe('rgb(68, 131, 236)'); + + // 3. Fast repeated update elevates color to purple (#9333ea / rgb(147, 51, 234)). + await page.locator('#increment-count').click(); + await expect(flash).toContainText('count 2'); + expect(await highlighted.evaluate((el) => el.style.color)).toBe('rgb(147, 51, 234)'); + + // 4. Toggle off with shortcut Alt+Shift+H. + await page.keyboard.press('Alt+Shift+H'); + await expect(highlightButton).not.toHaveAttribute('data-active', ''); + + // 5. Toggle on again and verify persistence across reload. + await page.keyboard.press('Alt+Shift+H'); + await expect(highlightButton).toHaveAttribute('data-active', ''); + await page.reload(); + await expect( + page.getByRole('button', { name: 'Highlight updates (Alt+Shift+H)' }), + ).toHaveAttribute('data-active', ''); +}); + test('mounts once and disposes', async ({ page }) => { await page.goto('/?mount'); From 168dbcc56b6aba7be7ae17a3e5c1d11b1f6f2072 Mon Sep 17 00:00:00 2001 From: Artem Kyrychenko Date: Tue, 27 Oct 2026 12:57:44 +0200 Subject: [PATCH 3/4] chore: add changeset for highlight updates --- .changeset/highlight-updates.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/highlight-updates.md diff --git a/.changeset/highlight-updates.md b/.changeset/highlight-updates.md new file mode 100644 index 0000000..1d78947 --- /dev/null +++ b/.changeset/highlight-updates.md @@ -0,0 +1,10 @@ +--- +'@solidjs/start-devtools': minor +--- + +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. +- 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. From 9b58602718309a81b56e33d734d75483e9710bf4 Mon Sep 17 00:00:00 2001 From: "Alexis H. Munsayac" Date: Thu, 29 Oct 2026 14:52:42 +0800 Subject: [PATCH 4/4] fix(highlight-updates): keep the ghost clone apart from the original A deep clone put in the document can take the checked state from a radio group with the same name, load an iframe again and start an autoplay video again. Strip names, frame sources and autoplay along with the ids. Co-Authored-By: Claude Opus 5.5 --- .../highlight-updates/clone.test.ts | 23 ++++++++++++++ src/dev-toolbar/highlight-updates/clone.ts | 31 +++++++++++++------ 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/src/dev-toolbar/highlight-updates/clone.test.ts b/src/dev-toolbar/highlight-updates/clone.test.ts index 337def7..4dfaa47 100644 --- a/src/dev-toolbar/highlight-updates/clone.test.ts +++ b/src/dev-toolbar/highlight-updates/clone.test.ts @@ -282,6 +282,29 @@ describe('createGhostClone', () => { expect(pathClone?.style.fill).toBe(colors.color); }); + it('strips ids, names, frame sources and autoplay from the clone', () => { + const div = new MockElement('div'); + div.setAttribute('id', 'panel'); + const radio = new MockElement('input'); + radio.setAttribute('name', 'choice'); + const frame = new MockElement('iframe'); + frame.setAttribute('src', 'https://example.com'); + const video = new MockElement('video'); + video.setAttribute('autoplay', ''); + div.appendChild(radio); + div.appendChild(frame); + div.appendChild(video); + + const clone = createGhostClone(div as unknown as Node, 1) as unknown as MockElement; + expect(clone.getAttribute('id')).toBeNull(); + expect(clone.querySelector('input')?.getAttribute('name')).toBeNull(); + expect(clone.querySelector('iframe')?.getAttribute('src')).toBe('about:blank'); + expect(clone.querySelector('video')?.getAttribute('autoplay')).toBeNull(); + + expect(div.getAttribute('id')).toBe('panel'); + expect(radio.getAttribute('name')).toBe('choice'); + }); + it('colors text inside div clones with the same update color and keeps box outline/tint', () => { const div = new MockElement('div'); const span = new MockElement('span'); diff --git a/src/dev-toolbar/highlight-updates/clone.ts b/src/dev-toolbar/highlight-updates/clone.ts index 0b3c849..f2be9a2 100644 --- a/src/dev-toolbar/highlight-updates/clone.ts +++ b/src/dev-toolbar/highlight-updates/clone.ts @@ -85,6 +85,25 @@ function applySvgColors(cloneSvg: SVGElement, sourceSvg: SVGElement, color: stri } } +/** + * Strips what would make the clone act like the original once it is in the + * document: duplicate ids, radio groups that would take the checked state from + * the original, and frames and media that would load or play a second time. + */ +function detachClone(clone: Element): void { + for (const element of [clone, ...Array.from(clone.querySelectorAll('*'))]) { + element.removeAttribute('id'); + element.removeAttribute('name'); + const tag = element.tagName.toLowerCase(); + if (tag === 'iframe') { + element.removeAttribute('srcdoc'); + element.setAttribute('src', 'about:blank'); + } else if (tag === 'video' || tag === 'audio') { + element.removeAttribute('autoplay'); + } + } +} + /** * Creates an exact deep clone of the element that contains the updating node, * preserving all typography, line-height, text-transform, and layout metrics. @@ -113,11 +132,7 @@ export function createGhostClone(node: Node, count: number): HTMLElement | SVGEl const clone = svgRoot.cloneNode(true) as SVGElement; - clone.removeAttribute('id'); - const elementsWithId = clone.querySelectorAll('[id]'); - for (let i = 0; i < elementsWithId.length; i++) { - elementsWithId[i].removeAttribute('id'); - } + detachClone(clone); clone.setAttribute('data-solid-flash-clone', ''); clone.style.position = 'fixed'; @@ -148,11 +163,7 @@ export function createGhostClone(node: Node, count: number): HTMLElement | SVGEl const clone = hostElement.cloneNode(true) as HTMLElement; - clone.removeAttribute('id'); - const elementsWithId = clone.querySelectorAll('[id]'); - for (let i = 0; i < elementsWithId.length; i++) { - elementsWithId[i].removeAttribute('id'); - } + detachClone(clone); clone.setAttribute('data-solid-flash-clone', ''); clone.style.position = 'fixed';