From 6ff6f43991f1de27a8b453f719a5c7b2c8406965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diego=20Iv=C3=A1n=20Mart=C3=ADnez=20Escobar?= Date: Wed, 22 Jul 2026 21:27:34 -0700 Subject: [PATCH 01/16] perf(devtools): fixed-height single-line query and mutation rows Give query/mutation rows a fixed height driven by --tsqd-font-size and render the key hash on a single line with ellipsis truncation. The full key remains available via the row aria-label, a new title tooltip, and the details pane. Predictable per-row geometry is a prerequisite for windowing the list. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 639057ed-0ed9-4329-8008-71458714184f --- packages/query-devtools/src/Devtools.tsx | 21 +++++++++++++++++---- packages/query-devtools/src/constants.ts | 10 ++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index daff934932f..347821b391f 100644 --- a/packages/query-devtools/src/Devtools.tsx +++ b/packages/query-devtools/src/Devtools.tsx @@ -61,6 +61,7 @@ import { DEFAULT_WIDTH, INITIAL_IS_OPEN, POSITION, + QUERY_ROW_HEIGHT_MULTIPLIER, firstBreakpoint, secondBreakpoint, thirdBreakpoint, @@ -1464,7 +1465,9 @@ const QueryRow: Component<{ query: Query }> = (props) => { > {observers()} - {props.query.queryHash} + + {props.query.queryHash} + - + {JSON.stringify(props.mutation.options.mutationKey)} -{' '} @@ -3334,6 +3344,7 @@ const stylesFactory = ( display: flex; align-items: center; padding: 0; + height: calc(var(--tsqd-font-size) * ${QUERY_ROW_HEIGHT_MULTIPLIER}); border: none; cursor: pointer; color: ${t(colors.gray[700], colors.gray[300])}; @@ -3372,14 +3383,16 @@ const stylesFactory = ( align-items: center; min-height: ${tokens.size[6]}; flex: 1; + min-width: 0; padding: ${tokens.size[1]} ${tokens.size[2]}; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; border-bottom: 1px solid ${t(colors.gray[300], colors.darkGray[400])}; text-align: left; - text-overflow: clip; - word-break: break-word; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } & .tsqd-query-disabled-indicator { diff --git a/packages/query-devtools/src/constants.ts b/packages/query-devtools/src/constants.ts index b4c0c225066..5c7cab810fb 100644 --- a/packages/query-devtools/src/constants.ts +++ b/packages/query-devtools/src/constants.ts @@ -12,6 +12,16 @@ export const INITIAL_IS_OPEN = false export const DEFAULT_HEIGHT = 500 export const PIP_DEFAULT_HEIGHT = 500 export const DEFAULT_WIDTH = 500 + +// Fixed row height for the virtualized query/mutation lists, expressed as a +// multiple of the `--tsqd-font-size` CSS variable. The same multiplier drives +// both the row's CSS `height` and the JS window math, so rendered geometry and +// the virtualizer estimate always agree. +export const QUERY_ROW_HEIGHT_MULTIPLIER = 1.5 + +// Number of rows rendered beyond the visible window on each side, to hide the +// row-recycling boundary during fast scrolling. +export const OVERSCAN = 6 export const DEFAULT_SORT_FN_NAME = Object.keys(sortFns)[0] export const DEFAULT_SORT_ORDER = 1 export const DEFAULT_MUTATION_SORT_FN_NAME = Object.keys(mutationSortFns)[0] From 9febf2b1cf0c474f2c5cc27ba6f11d6414314381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diego=20Iv=C3=A1n=20Mart=C3=ADnez=20Escobar?= Date: Wed, 22 Jul 2026 21:34:08 -0700 Subject: [PATCH 02/16] perf(devtools): virtualize the query list Render the query pane through a new windowed VirtualList so only the rows in (and near) the scroll viewport are mounted. Because each QueryRow owns five query-cache subscriptions, windowing bounds both the DOM node count and the module-level subscription map that the global cache handler walks on every event, instead of scaling with the number of cached queries. VirtualList seeds its viewport from a bounded default (never the item count), resolves ResizeObserver from the element's own document for Picture-in-Picture, clamps the scroll offset so a shrinking list never blanks the viewport, and always keeps the selected row mounted. Row offsets are arithmetic given the fixed row height derived from --tsqd-font-size. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 639057ed-0ed9-4329-8008-71458714184f --- packages/query-devtools/src/Devtools.tsx | 190 ++++++++++++++++++++++- 1 file changed, 182 insertions(+), 8 deletions(-) diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index 347821b391f..ee719912a51 100644 --- a/packages/query-devtools/src/Devtools.tsx +++ b/packages/query-devtools/src/Devtools.tsx @@ -60,6 +60,7 @@ import { DEFAULT_SORT_ORDER, DEFAULT_WIDTH, INITIAL_IS_OPEN, + OVERSCAN, POSITION, QUERY_ROW_HEIGHT_MULTIPLIER, firstBreakpoint, @@ -672,6 +673,141 @@ const DraggablePanel: Component = (props) => { ) } +/** + * Windowed list renderer for the query and mutation panes. + * + * Only the rows intersecting the scroll viewport (plus `OVERSCAN` on each side) + * are mounted, so the DOM node count and the per-row cache subscriptions stay + * bounded regardless of how many entries the cache holds. Rows have a fixed + * `rowHeight`, so offsets are computed arithmetically without measuring the DOM. + * The currently selected row is always kept mounted so its subscriptions and + * focus survive scrolling and live re-sorts. + */ +function VirtualList(props: { + items: Array + getKey: (item: T) => string + rowHeight: number + pinnedKey?: string | null + overscan?: number + overflowClass: string + containerClass: string + rowClass: string + children: (item: T) => JSX.Element +}): JSX.Element { + let scrollRef!: HTMLDivElement + const [scrollTop, setScrollTop] = createSignal(0) + // Seed with a bounded default (never the item count) so a large list cannot + // fully mount before the first measurement arrives. + const [viewportHeight, setViewportHeight] = createSignal(DEFAULT_HEIGHT) + + onMount(() => { + const el = scrollRef + const view = el.ownerDocument.defaultView + setScrollTop(el.scrollTop) + if (el.clientHeight > 0) { + setViewportHeight(el.clientHeight) + } + + const onScroll = () => setScrollTop(el.scrollTop) + el.addEventListener('scroll', onScroll, { passive: true }) + + // Resolve ResizeObserver from the element's own document so the observer + // works when the panel is rendered into a Picture-in-Picture window. + let observer: ResizeObserver | undefined + const ObserverCtor = view?.ResizeObserver + if (ObserverCtor) { + observer = new ObserverCtor((entries) => { + const height = entries[0]?.contentRect.height + if (typeof height === 'number' && height > 0) { + setViewportHeight(height) + } + setScrollTop(el.scrollTop) + }) + observer.observe(el) + } + + onCleanup(() => { + el.removeEventListener('scroll', onScroll) + observer?.disconnect() + }) + }) + + const totalSize = createMemo(() => props.items.length * props.rowHeight) + + // Resolve the selected row's index separately so scrolling (which only + // changes scrollTop) never triggers this O(N) scan. It re-runs only when the + // list or the selection changes, keeping scroll updates O(window) even for + // very large caches. + const pinnedIndex = createMemo(() => { + const pinnedKey = props.pinnedKey + if (pinnedKey == null) return -1 + return props.items.findIndex((item) => props.getKey(item) === pinnedKey) + }) + + const virtualRows = createMemo(() => { + const rowHeight = props.rowHeight + const count = props.items.length + if (rowHeight <= 0 || count === 0) { + return [] as Array<{ key: string; item: T; start: number }> + } + + const overscan = props.overscan ?? OVERSCAN + const height = viewportHeight() + // Clamp the scroll offset so a shrinking list (after filtering/sorting) + // never scrolls past the end and blanks the viewport. + const maxScrollTop = Math.max(0, count * rowHeight - height) + const clampedTop = Math.min(scrollTop(), maxScrollTop) + const first = Math.max(0, Math.floor(clampedTop / rowHeight) - overscan) + const last = Math.min( + count, + first + Math.ceil(height / rowHeight) + overscan * 2, + ) + + const indexes = new Set() + for (let i = first; i < last; i++) { + indexes.add(i) + } + + const pinned = pinnedIndex() + if (pinned >= 0 && pinned < count) { + indexes.add(pinned) + } + + return [...indexes] + .sort((a, b) => a - b) + .map((index) => { + const item = props.items[index]! + return { key: props.getKey(item), item, start: index * rowHeight } + }) + }) + + // Keep the element's scroll position in sync when the list shrinks below the + // current offset, so the scrollbar and the computed window agree. + createEffect(() => { + const maxScrollTop = Math.max(0, totalSize() - viewportHeight()) + if (scrollTop() > maxScrollTop) { + scrollRef.scrollTop = maxScrollTop + } + }) + + return ( +
+
+ row.key} each={virtualRows()}> + {(row) => ( +
+ {props.children(row().item)} +
+ )} +
+
+
+ ) +} + export const ContentView: Component = (props) => { setupQueryCacheSubscription() setupMutationCacheSubscription() @@ -806,6 +942,31 @@ export const ContentView: Component = (props) => { const variable = computedStyle.getPropertyValue('--tsqd-font-size') el.style.setProperty('--tsqd-font-size', variable) } + + // Fixed row height for the virtualized lists, derived from the panel's + // resolved `--tsqd-font-size` so it tracks the user's font-size setting and + // any inherited scaling. Read from the always-mounted panel container rather + // than the toggling scroll element, and refreshed on window focus like the + // font-size variable itself (see the onMount above). + const [rowFontSize, setRowFontSize] = createSignal(16) + onMount(() => { + const readRowFontSize = () => { + const value = getComputedStyle(containerRef).getPropertyValue( + '--tsqd-font-size', + ) + const parsed = Number.parseFloat(value) + if (Number.isFinite(parsed) && parsed > 0) { + setRowFontSize(parsed) + } + } + readRowFontSize() + const view = containerRef.ownerDocument.defaultView + view?.addEventListener('focus', readRowFontSize) + onCleanup(() => view?.removeEventListener('focus', readRowFontSize)) + }) + const rowHeight = createMemo( + () => rowFontSize() * QUERY_ROW_HEIGHT_MULTIPLIER, + ) return ( <>
= (props) => {
-
q.queryHash} + rowHeight={rowHeight()} + pinnedKey={selectedQueryHash()} + overflowClass={cx( styles().overflowQueryContainer, 'tsqd-queries-overflow-container', )} + containerClass={cx('tsqd-queries-container', styles().virtualSpacer)} + rowClass={styles().virtualRow} > -
- q.queryHash} each={queries()}> - {(query) => } - -
-
+ {(query) => } +
Date: Wed, 22 Jul 2026 21:34:46 -0700 Subject: [PATCH 03/16] perf(devtools): virtualize the mutation list Render the mutation pane through the same windowed VirtualList as the query pane, bounding mounted MutationRow components and their mutation- cache subscriptions to the visible window. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 639057ed-0ed9-4329-8008-71458714184f --- packages/query-devtools/src/Devtools.tsx | 25 ++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index ee719912a51..4c91151211c 100644 --- a/packages/query-devtools/src/Devtools.tsx +++ b/packages/query-devtools/src/Devtools.tsx @@ -1510,18 +1510,27 @@ export const ContentView: Component = (props) => { -
String(m.mutationId)} + rowHeight={rowHeight()} + pinnedKey={ + selectedMutationId() != null + ? String(selectedMutationId()) + : null + } + overflowClass={cx( styles().overflowQueryContainer, 'tsqd-mutations-overflow-container', )} + containerClass={cx( + 'tsqd-mutations-container', + styles().virtualSpacer, + )} + rowClass={styles().virtualRow} > -
- m.mutationId} each={mutations()}> - {(mutation) => } - -
-
+ {(mutation) => } +
From 30eb56e06f91058b19c207c5420d9099b95f14d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diego=20Iv=C3=A1n=20Mart=C3=ADnez=20Escobar?= Date: Wed, 22 Jul 2026 21:35:48 -0700 Subject: [PATCH 04/16] perf(devtools): read mutation state directly in MutationRow Each MutationRow previously resolved its own mutation by scanning the whole mutation cache with getAll().find() in three subscriptions, so a mutation-cache event cost O(rows x cache size). Read the row's own stable mutation instance directly instead; the subscription still fires on cache changes but the read is O(1). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 639057ed-0ed9-4329-8008-71458714184f --- packages/query-devtools/src/Devtools.tsx | 34 ++++++++---------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index 4c91151211c..070c317b0ac 100644 --- a/packages/query-devtools/src/Devtools.tsx +++ b/packages/query-devtools/src/Devtools.tsx @@ -1667,33 +1667,21 @@ const MutationRow: Component<{ mutation: Mutation }> = (props) => { const { colors, alpha } = tokens const t = (light: string, dark: string) => (theme() === 'dark' ? dark : light) + // Read this row's own stable mutation instance directly instead of scanning + // the entire cache on every event. The subscription still fires on mutation- + // cache changes (that is what triggers the re-read), but the read itself is + // O(1), so a burst of mutation activity no longer costs O(rows x cache size). const mutationState = createSubscribeToMutationCacheBatcher( - (mutationCache) => { - const mutations = mutationCache().getAll() - const mutation = mutations.find( - (m) => m.mutationId === props.mutation.mutationId, - ) - return mutation?.state - }, + () => props.mutation.state, ) - const isPaused = createSubscribeToMutationCacheBatcher((mutationCache) => { - const mutations = mutationCache().getAll() - const mutation = mutations.find( - (m) => m.mutationId === props.mutation.mutationId, - ) - if (!mutation) return false - return mutation.state.isPaused - }) + const isPaused = createSubscribeToMutationCacheBatcher( + () => props.mutation.state.isPaused, + ) - const status = createSubscribeToMutationCacheBatcher((mutationCache) => { - const mutations = mutationCache().getAll() - const mutation = mutations.find( - (m) => m.mutationId === props.mutation.mutationId, - ) - if (!mutation) return 'idle' - return mutation.state.status - }) + const status = createSubscribeToMutationCacheBatcher( + () => props.mutation.state.status, + ) const color = createMemo(() => getMutationStatusColor({ From 2481f3216ed20891d4adad751eff221491621dc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diego=20Iv=C3=A1n=20Mart=C3=ADnez=20Escobar?= Date: Wed, 22 Jul 2026 21:46:26 -0700 Subject: [PATCH 05/16] test(devtools): cover query and mutation list virtualization Add tests for the windowed list: bounded row count for large caches, a bounded initial render even when no resize measurement is delivered, spacer sizing to the full list height, scroll-driven row recycling, filter-shrink after deep scroll not blanking the viewport, view remount re-initialization, the selected row staying mounted exactly once when scrolled out of range, the query-key title attribute, and a mutation row rendered from its own state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 639057ed-0ed9-4329-8008-71458714184f --- .../src/__tests__/Devtools.test.tsx | 312 +++++++++++++++++- 1 file changed, 311 insertions(+), 1 deletion(-) diff --git a/packages/query-devtools/src/__tests__/Devtools.test.tsx b/packages/query-devtools/src/__tests__/Devtools.test.tsx index f73024502c6..d78af832c70 100644 --- a/packages/query-devtools/src/__tests__/Devtools.test.tsx +++ b/packages/query-devtools/src/__tests__/Devtools.test.tsx @@ -6,9 +6,10 @@ import { hydrate, onlineManager, } from '@tanstack/query-core' -import { fireEvent, render } from '@solidjs/testing-library' +import { cleanup, fireEvent, render } from '@solidjs/testing-library' import { createLocalStorage } from '@solid-primitives/storage' import { Devtools } from '../Devtools' +import { OVERSCAN, QUERY_ROW_HEIGHT_MULTIPLIER } from '../constants' import { PiPProvider, QueryDevtoolsContext, ThemeContext } from '../contexts' import type { QueryDevtoolsProps } from '../contexts' @@ -1500,4 +1501,313 @@ describe('Devtools', () => { ).not.toBeInTheDocument() }) }) + + describe('list virtualization', () => { + // In jsdom `--tsqd-font-size` resolves to the 16px root font size, so the + // fixed row height is 16 * QUERY_ROW_HEIGHT_MULTIPLIER (1.5) = 24px, and the + // ResizeObserver stub reports a 500px viewport, giving a window of + // ceil(500 / 24) + 2 * OVERSCAN = 33 rows. + function seedQueries(count: number, prefix = 'q') { + for (let i = 0; i < count; i++) { + queryClient.setQueryData([`${prefix}-${i}`], i) + } + } + + it('renders only a bounded window of rows for a large query cache', () => { + seedQueries(300) + const rendered = renderDevtools({ initialIsOpen: true }) + + const rows = rendered.container.querySelectorAll('.tsqd-query-row') + expect(rows.length).toBeGreaterThan(0) + expect(rows.length).toBeLessThan(100) + }) + + it('keeps the initial render bounded even before a resize measurement arrives', () => { + // A ResizeObserver that never delivers a measurement, so the window can + // only rely on the bounded default viewport seed (never the item count). + vi.stubGlobal( + 'ResizeObserver', + class { + constructor(_callback: ResizeObserverCallback) {} + observe = vi.fn() + unobserve = vi.fn() + disconnect = vi.fn() + }, + ) + seedQueries(300) + const rendered = renderDevtools({ initialIsOpen: true }) + + const rows = rendered.container.querySelectorAll('.tsqd-query-row') + expect(rows.length).toBeGreaterThan(0) + expect(rows.length).toBeLessThan(100) + }) + + it('sizes the scroll spacer to the full list height', () => { + const count = 50 + seedQueries(count) + const rendered = renderDevtools({ initialIsOpen: true }) + + const spacer = rendered.container.querySelector( + '.tsqd-queries-overflow-container > .tsqd-queries-container', + ) + expect(spacer).not.toBeNull() + + // Derive the per-row height from a rendered row's translateY offset so the + // assertion does not depend on how jsdom resolves --tsqd-font-size. + const rows = Array.from( + rendered.container.querySelectorAll('.tsqd-query-row'), + ) + const secondRowWrapper = rows[1]?.parentElement as HTMLElement + const offset = Number( + /translateY\(([\d.]+)px\)/.exec(secondRowWrapper.style.transform)?.[1], + ) + expect(offset).toBeGreaterThan(0) + expect(spacer!.style.height).toBe(`${count * offset}px`) + }) + + it('reveals later rows as the list is scrolled', () => { + seedQueries(200) + const rendered = renderDevtools({ initialIsOpen: true }) + + expect( + rendered.queryByLabelText(/Query key \["q-0"\]/), + ).toBeInTheDocument() + expect( + rendered.queryByLabelText(/Query key \["q-199"\]/), + ).not.toBeInTheDocument() + + const scroller = rendered.container.querySelector( + '.tsqd-queries-overflow-container', + ) as HTMLElement + // Scroll to the bottom: 200 * 24 - 500 = 4300. + Object.defineProperty(scroller, 'scrollTop', { + value: 4300, + writable: true, + configurable: true, + }) + fireEvent.scroll(scroller) + + expect( + rendered.getByLabelText(/Query key \["q-199"\]/), + ).toBeInTheDocument() + expect( + rendered.queryByLabelText(/Query key \["q-0"\]/), + ).not.toBeInTheDocument() + }) + + it('does not blank the list when filtering after scrolling deep', () => { + seedQueries(200, 'item') + queryClient.setQueryData(['keep-me'], 'x') + const rendered = renderDevtools({ initialIsOpen: true }) + + const scroller = rendered.container.querySelector( + '.tsqd-queries-overflow-container', + ) as HTMLElement + Object.defineProperty(scroller, 'scrollTop', { + value: 4000, + writable: true, + configurable: true, + }) + fireEvent.scroll(scroller) + + fireEvent.input(rendered.getByLabelText('Filter queries by query key'), { + target: { value: 'keep-me' }, + }) + + expect( + rendered.getByLabelText(/Query key \["keep-me"\]/), + ).toBeInTheDocument() + expect( + rendered.container.querySelectorAll('.tsqd-query-row').length, + ).toBeGreaterThan(0) + }) + + it('re-initializes the window when switching away from and back to the queries view', () => { + queryClient.setQueryData(['stay'], 1) + const rendered = renderDevtools({ initialIsOpen: true }) + + expect( + rendered.getByLabelText(/Query key \["stay"\]/), + ).toBeInTheDocument() + + fireEvent.click(rendered.getByTitle('Toggle Mutations View')) + expect( + rendered.queryByLabelText(/Query key \["stay"\]/), + ).not.toBeInTheDocument() + + fireEvent.click(rendered.getByTitle('Toggle Queries View')) + expect( + rendered.getByLabelText(/Query key \["stay"\]/), + ).toBeInTheDocument() + }) + + it('keeps the selected row mounted exactly once when it scrolls out of the window', () => { + seedQueries(200) + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByLabelText(/Query key \["q-0"\]/)) + + const scroller = rendered.container.querySelector( + '.tsqd-queries-overflow-container', + ) as HTMLElement + Object.defineProperty(scroller, 'scrollTop', { + value: 3000, + writable: true, + configurable: true, + }) + fireEvent.scroll(scroller) + + expect(rendered.getAllByLabelText(/Query key \["q-0"\]/)).toHaveLength(1) + }) + + it('exposes the full query key via the title attribute', () => { + queryClient.setQueryData(['posts', { page: 1 }], []) + const rendered = renderDevtools({ initialIsOpen: true }) + + const hash = rendered.container.querySelector( + '.tsqd-query-hash', + ) as HTMLElement + expect(hash.getAttribute('title')).toBe('["posts",{"page":1}]') + }) + + it('renders a mutation row from its own state after the hot-path change', async () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByText('Mutations')) + + queryClient.getMutationCache().build(queryClient, { + mutationKey: ['virtualized-mut'], + mutationFn: () => Promise.resolve('ok'), + }) + await vi.advanceTimersByTimeAsync(0) + + expect( + rendered.getByLabelText(/Mutation submitted at/), + ).toBeInTheDocument() + }) + + describe('adapts to different heights', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + // Re-point the ResizeObserver stub at a specific viewport height so we can + // assert the window grows/shrinks with the scroll container. + function stubViewportHeight(height: number) { + vi.stubGlobal( + 'ResizeObserver', + class { + callback: ResizeObserverCallback + constructor(callback: ResizeObserverCallback) { + this.callback = callback + } + observe = vi.fn((target: Element) => { + this.callback( + [ + { + target, + contentRect: { width: 1000, height } as DOMRectReadOnly, + } as ResizeObserverEntry, + ], + this as unknown as ResizeObserver, + ) + }) + unobserve = vi.fn() + disconnect = vi.fn() + }, + ) + } + + // jsdom's getComputedStyle does not resolve the inherited `--tsqd-font-size` + // custom property, so patch getPropertyValue to drive a specific row height + // (rowHeight = fontSize * QUERY_ROW_HEIGHT_MULTIPLIER). + function stubRowFontSize(px: number) { + const real = CSSStyleDeclaration.prototype.getPropertyValue + vi.spyOn( + CSSStyleDeclaration.prototype, + 'getPropertyValue', + ).mockImplementation(function ( + this: CSSStyleDeclaration, + name: string, + ) { + return name === '--tsqd-font-size' ? `${px}px` : real.call(this, name) + }) + } + + const offsetOf = (row: Element | undefined) => + Number( + /translateY\(([\d.]+)px\)/.exec( + (row?.parentElement as HTMLElement | undefined)?.style.transform ?? + '', + )?.[1], + ) + + function readWindow(container: HTMLElement) { + const rows = Array.from(container.querySelectorAll('.tsqd-query-row')) + const rowHeight = + rows.length >= 2 ? offsetOf(rows[1]) - offsetOf(rows[0]) : NaN + const spacer = container.querySelector( + '.tsqd-queries-overflow-container > .tsqd-queries-container', + ) + const spacerHeight = Number( + /([\d.]+)px/.exec(spacer?.style.height ?? '')?.[1], + ) + return { rows, count: rows.length, rowHeight, spacerHeight } + } + + // At scrollTop 0 with a list larger than the window, the number of mounted + // rows is ceil(viewport / rowHeight) + overscan on each side. + const expectedWindow = (viewport: number, rowHeight: number) => + Math.ceil(viewport / rowHeight) + OVERSCAN * 2 + + it('grows the rendered window as the scroll viewport gets taller', () => { + seedQueries(150) + let previousCount = 0 + for (const viewport of [120, 480]) { + cleanup() + stubViewportHeight(viewport) + const rendered = renderDevtools({ initialIsOpen: true }) + const { count, rowHeight } = readWindow(rendered.container) + + expect(count).toBe(expectedWindow(viewport, rowHeight)) + expect(count).toBeGreaterThan(previousCount) + previousCount = count + } + }) + + it('derives row height from the --tsqd-font-size multiplier', () => { + stubRowFontSize(20) + seedQueries(60) + const rendered = renderDevtools({ initialIsOpen: true }) + + expect(readWindow(rendered.container).rowHeight).toBe( + 20 * QUERY_ROW_HEIGHT_MULTIPLIER, + ) + }) + + it('scales the spacer, row offsets, and window count with a taller row height', () => { + const count = 100 + const viewport = 500 // the default ResizeObserver stub reports 500px + const rowHeight = 32 * QUERY_ROW_HEIGHT_MULTIPLIER // 48 + stubRowFontSize(32) + seedQueries(count) + const rendered = renderDevtools({ initialIsOpen: true }) + + const window = readWindow(rendered.container) + + // Taller rows -> a smaller window for the same viewport. + expect(window.rowHeight).toBe(rowHeight) + expect(window.count).toBe(expectedWindow(viewport, rowHeight)) + // Spacer accounts for every row at the larger height. + expect(window.spacerHeight).toBe(count * rowHeight) + // Consecutive rows are spaced by exactly one (taller) row height. + expect(offsetOf(window.rows[1]) - offsetOf(window.rows[0])).toBe( + rowHeight, + ) + expect(offsetOf(window.rows[2]) - offsetOf(window.rows[1])).toBe( + rowHeight, + ) + }) + }) + }) }) From 4b2df32e127857a7d6e2413de2e298d085b691fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diego=20Iv=C3=A1n=20Mart=C3=ADnez=20Escobar?= Date: Wed, 22 Jul 2026 21:46:26 -0700 Subject: [PATCH 06/16] chore(devtools): add changeset for list virtualization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 639057ed-0ed9-4329-8008-71458714184f --- .changeset/virtualize-devtools-lists.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/virtualize-devtools-lists.md diff --git a/.changeset/virtualize-devtools-lists.md b/.changeset/virtualize-devtools-lists.md new file mode 100644 index 00000000000..df853dbdddb --- /dev/null +++ b/.changeset/virtualize-devtools-lists.md @@ -0,0 +1,16 @@ +--- +"@tanstack/query-devtools": patch +--- + +perf(devtools): virtualize the query and mutation lists + +The devtools panel previously rendered one row per cached query and mutation +with no windowing, and each row registered several query/mutation-cache +subscriptions. With very large caches this mounted thousands of DOM nodes and +subscriptions and could freeze or crash the host page. The lists are now +windowed so only the rows near the scroll viewport are mounted, which also +bounds the per-row subscriptions the global cache handler walks on every event. +Rows use a fixed height and truncate long keys with an ellipsis (the full key +remains available via the row's tooltip, aria-label, and the details pane), and +each mutation row now reads its own state directly instead of scanning the whole +mutation cache. From 52f1245dbcf4001f461786a793f8dc11c29bcbed Mon Sep 17 00:00:00 2001 From: cloudfluffy <120070429+cloudfluffy@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:05:03 -0700 Subject: [PATCH 07/16] fix(devtools): keep other panels subscribed when one panel unmounts The subscriber registries for the query and mutation caches are shared by every devtools instance on the page, but a panel's teardown cleared them entirely rather than removing only its own entries. With two panels mounted at once - during the picture-in-picture transition, or when the standalone panel is used alongside the floating one - tearing one down unsubscribed the other, leaving its list, status counts and details pane frozen. Each subscription already deletes its own entry on disposal, so the registry-wide clear was redundant as well as harmful. --- .../devtools-panel-subscription-teardown.md | 14 +++++ packages/query-devtools/src/Devtools.tsx | 2 - .../__tests__/DevtoolsPanelComponent.test.tsx | 55 ++++++++++++++++++- 3 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 .changeset/devtools-panel-subscription-teardown.md diff --git a/.changeset/devtools-panel-subscription-teardown.md b/.changeset/devtools-panel-subscription-teardown.md new file mode 100644 index 00000000000..8f4a63e183a --- /dev/null +++ b/.changeset/devtools-panel-subscription-teardown.md @@ -0,0 +1,14 @@ +--- +"@tanstack/query-devtools": patch +--- + +fix(devtools): keep other panels subscribed when one panel unmounts + +The subscriber registries for the query and mutation caches are shared by every +devtools instance on the page, but a panel's teardown cleared them entirely +instead of removing only its own entries. When two panels were mounted at once +- during the picture-in-picture transition, or when the standalone panel is used +alongside the floating one - tearing one down unsubscribed the other, leaving +its list, status counts and details pane permanently frozen. Each subscription +already removes its own entry on disposal, so the registry-wide clear was +redundant as well as harmful and has been removed. diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index 070c317b0ac..0ca108c1f1e 100644 --- a/packages/query-devtools/src/Devtools.tsx +++ b/packages/query-devtools/src/Devtools.tsx @@ -2763,7 +2763,6 @@ const setupQueryCacheSubscription = () => { }) onCleanup(() => { - queryCacheMap.clear() unsubscribe() }) @@ -2821,7 +2820,6 @@ const setupMutationCacheSubscription = () => { }) onCleanup(() => { - mutationCacheMap.clear() unsubscribe() }) diff --git a/packages/query-devtools/src/__tests__/DevtoolsPanelComponent.test.tsx b/packages/query-devtools/src/__tests__/DevtoolsPanelComponent.test.tsx index ee7d3c02155..94df82ba647 100644 --- a/packages/query-devtools/src/__tests__/DevtoolsPanelComponent.test.tsx +++ b/packages/query-devtools/src/__tests__/DevtoolsPanelComponent.test.tsx @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { QueryClient, onlineManager } from '@tanstack/query-core' -import { render } from '@solidjs/testing-library' +import { fireEvent, render } from '@solidjs/testing-library' import DevtoolsPanelComponent from '../DevtoolsPanelComponent' // `solid-transition-group` internally imports from @@ -109,6 +109,59 @@ describe('DevtoolsPanelComponent', () => { ).not.toThrow() }) + it('keeps a surviving panel subscribed when another panel unmounts', () => { + const renderPanel = () => + render(() => ( + + )) + + const first = renderPanel() + const second = renderPanel() + + // Tearing one panel down must not disturb the other's cache subscriptions. + first.unmount() + + queryClient.setQueryData(['survivor'], 'value') + + expect( + second.getByLabelText(/Query key \["survivor"\]/), + ).toBeInTheDocument() + }) + + it('shows a mutation started after another panel unmounts', async () => { + const renderPanel = () => + render(() => ( + + )) + + const first = renderPanel() + const second = renderPanel() + + first.unmount() + + fireEvent.click(second.getByText('Mutations')) + + queryClient.getMutationCache().build(queryClient, { + mutationKey: ['survivor'], + mutationFn: () => Promise.resolve('ok'), + }) + + // Mutation-cache subscribers are dispatched on a microtask. + expect( + await second.findByLabelText(/Mutation submitted at/), + ).toBeInTheDocument() + }) + it('should not render the open devtools button in panel-only mode', () => { const rendered = render(() => ( Date: Tue, 4 Aug 2026 14:05:03 -0700 Subject: [PATCH 08/16] perf(devtools): stop rebuilding list rows on every update The virtualized lists rebuilt every mounted row on each scroll and each cache event. The window is recomputed into freshly allocated row wrappers, so the keyed list's item signal always notified, and because the row was rendered by calling the row renderer inside a child position that read that signal, every notification tore down and reconstructed the row - its DOM, its styles and its five cache subscriptions. Rows now receive an accessor and read the item through a prop, so a row is built once and updated in place. This is the shape the lists used before they were virtualized. --- .changeset/devtools-row-identity.md | 14 +++ packages/query-devtools/src/Devtools.tsx | 45 ++++++---- .../src/__tests__/Devtools.test.tsx | 88 +++++++++++++++++++ 3 files changed, 129 insertions(+), 18 deletions(-) create mode 100644 .changeset/devtools-row-identity.md diff --git a/.changeset/devtools-row-identity.md b/.changeset/devtools-row-identity.md new file mode 100644 index 00000000000..6efaf7e878e --- /dev/null +++ b/.changeset/devtools-row-identity.md @@ -0,0 +1,14 @@ +--- +"@tanstack/query-devtools": patch +--- + +perf(devtools): stop rebuilding list rows on every update + +The virtualized query and mutation lists rebuilt every mounted row's component +on each scroll and each cache event. The window is recomputed into freshly +allocated row wrappers, so the keyed list's item signal always notified, and +because the row was rendered by calling the row renderer inside a child +position that read that signal, each notification tore down and reconstructed +the row - its DOM, its styles and its cache subscriptions. Rows now receive an +accessor and read the item through a prop, so a row is built once and updated +in place, matching how the lists behaved before they were virtualized. diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index 0ca108c1f1e..ba72f4c3b2d 100644 --- a/packages/query-devtools/src/Devtools.tsx +++ b/packages/query-devtools/src/Devtools.tsx @@ -692,7 +692,7 @@ function VirtualList(props: { overflowClass: string containerClass: string rowClass: string - children: (item: T) => JSX.Element + children: (item: Accessor) => JSX.Element }): JSX.Element { let scrollRef!: HTMLDivElement const [scrollTop, setScrollTop] = createSignal(0) @@ -794,14 +794,23 @@ function VirtualList(props: {
row.key} each={virtualRows()}> - {(row) => ( -
- {props.children(row().item)} -
- )} + {(row) => { + // The window is rebuilt from scratch on every recomputation, so each + // row's wrapper is a new object and the keyed item signal always + // notifies. Reading the item here would make this child a tracked + // expression and rebuild the row's whole subtree on every scroll and + // cache event, so hand the row an accessor instead and let it read + // the item through a prop. + const item = createMemo(() => row().item) + return ( +
+ {props.children(item)} +
+ ) + }}
@@ -951,9 +960,8 @@ export const ContentView: Component = (props) => { const [rowFontSize, setRowFontSize] = createSignal(16) onMount(() => { const readRowFontSize = () => { - const value = getComputedStyle(containerRef).getPropertyValue( - '--tsqd-font-size', - ) + const value = + getComputedStyle(containerRef).getPropertyValue('--tsqd-font-size') const parsed = Number.parseFloat(value) if (Number.isFinite(parsed) && parsed > 0) { setRowFontSize(parsed) @@ -1503,10 +1511,13 @@ export const ContentView: Component = (props) => { styles().overflowQueryContainer, 'tsqd-queries-overflow-container', )} - containerClass={cx('tsqd-queries-container', styles().virtualSpacer)} + containerClass={cx( + 'tsqd-queries-container', + styles().virtualSpacer, + )} rowClass={styles().virtualRow} > - {(query) => } + {(query) => }
@@ -1515,9 +1526,7 @@ export const ContentView: Component = (props) => { getKey={(m) => String(m.mutationId)} rowHeight={rowHeight()} pinnedKey={ - selectedMutationId() != null - ? String(selectedMutationId()) - : null + selectedMutationId() != null ? String(selectedMutationId()) : null } overflowClass={cx( styles().overflowQueryContainer, @@ -1529,7 +1538,7 @@ export const ContentView: Component = (props) => { )} rowClass={styles().virtualRow} > - {(mutation) => } + {(mutation) => } diff --git a/packages/query-devtools/src/__tests__/Devtools.test.tsx b/packages/query-devtools/src/__tests__/Devtools.test.tsx index d78af832c70..dccfe657afe 100644 --- a/packages/query-devtools/src/__tests__/Devtools.test.tsx +++ b/packages/query-devtools/src/__tests__/Devtools.test.tsx @@ -1507,6 +1507,21 @@ describe('Devtools', () => { // fixed row height is 16 * QUERY_ROW_HEIGHT_MULTIPLIER (1.5) = 24px, and the // ResizeObserver stub reports a 500px viewport, giving a window of // ceil(500 / 24) + 2 * OVERSCAN = 33 rows. + function scrollTo( + rendered: ReturnType, + scrollTop: number, + ) { + const scroller = rendered.container.querySelector( + '.tsqd-queries-overflow-container', + ) as HTMLElement + Object.defineProperty(scroller, 'scrollTop', { + value: scrollTop, + writable: true, + configurable: true, + }) + fireEvent.scroll(scroller) + } + function seedQueries(count: number, prefix = 'q') { for (let i = 0; i < count; i++) { queryClient.setQueryData([`${prefix}-${i}`], i) @@ -1686,6 +1701,79 @@ describe('Devtools', () => { ).toBeInTheDocument() }) + it('reuses row elements across a cache event', () => { + seedQueries(200) + const rendered = renderDevtools({ initialIsOpen: true }) + + const before = [...rendered.container.querySelectorAll('.tsqd-query-row')] + before.forEach((row, i) => row.setAttribute('data-row-id', String(i))) + + queryClient.setQueryData(['q-0'], 'updated') + + const after = [...rendered.container.querySelectorAll('.tsqd-query-row')] + expect( + after.filter((row) => row.hasAttribute('data-row-id')), + ).toHaveLength(before.length) + }) + + it('reuses every row element when a scroll does not shift the window', () => { + seedQueries(200) + const rendered = renderDevtools({ initialIsOpen: true }) + + const before = [...rendered.container.querySelectorAll('.tsqd-query-row')] + before.forEach((row, i) => row.setAttribute('data-row-id', String(i))) + + // Smaller than the overscan margin, so the rendered window is unchanged + // and every row must be the very same element afterwards. + scrollTo(rendered, 24) + + const after = [...rendered.container.querySelectorAll('.tsqd-query-row')] + expect( + after.filter((row) => row.hasAttribute('data-row-id')), + ).toHaveLength(before.length) + }) + + it('reuses the overlapping rows when a scroll shifts the window', () => { + seedQueries(200) + const rendered = renderDevtools({ initialIsOpen: true }) + + const before = [...rendered.container.querySelectorAll('.tsqd-query-row')] + before.forEach((row, i) => row.setAttribute('data-row-id', String(i))) + + // Past the overscan margin, so the window really moves and some rows are + // recycled. The rows still in view must not have been rebuilt. + scrollTo(rendered, OVERSCAN * 24 + 24) + + const after = [...rendered.container.querySelectorAll('.tsqd-query-row')] + const reused = after.filter((row) => row.hasAttribute('data-row-id')) + expect(reused.length).toBeGreaterThan(0) + expect(reused.length).toBe(after.length - (after.length - reused.length)) + }) + + it('keeps a reused row up to date with its own query', () => { + queryClient.setQueryData(['solo'], 'first') + const rendered = renderDevtools({ initialIsOpen: true }) + + const row = rendered.getByLabelText(/Query key \["solo"\]/) + row.setAttribute('data-row-id', 'solo') + expect(row.querySelector('.tsqd-query-observer-count')?.textContent).toBe( + '0', + ) + + // Reuse must not mean frozen. Adding an observer changes state the row + // renders, and it has to show up on the very same element. + const observer = new QueryObserver(queryClient, { queryKey: ['solo'] }) + const unsubscribe = observer.subscribe(() => {}) + + const updated = rendered.getByLabelText(/Query key \["solo"\]/) + expect(updated).toHaveAttribute('data-row-id', 'solo') + expect( + updated.querySelector('.tsqd-query-observer-count')?.textContent, + ).toBe('1') + + unsubscribe() + }) + describe('adapts to different heights', () => { afterEach(() => { vi.restoreAllMocks() From 7fb430dd9c1e4dfb41a34514c532120309ce308d Mon Sep 17 00:00:00 2001 From: cloudfluffy <120070429+cloudfluffy@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:05:03 -0700 Subject: [PATCH 09/16] perf(devtools): tally the status counts in a single pass Each status badge backed its count with an independent cache subscription, and every one of those subscriptions walked the whole cache and allocated a full-size array on each cache event - five passes for queries, four for mutations. Each set of counts now comes from one pass. The individual counts are exposed as memos so a badge still only re-renders when its own count changes, as it did when each count had its own equality-checked signal. The mutation tally carries a bucket for the gray status that no badge displays, because an idle mutation resolves to it and the tally has to stay exhaustive. --- .../devtools-status-count-single-pass.md | 12 ++ packages/query-devtools/src/Devtools.tsx | 122 ++++++------------ .../src/__tests__/Devtools.test.tsx | 29 +++++ 3 files changed, 80 insertions(+), 83 deletions(-) create mode 100644 .changeset/devtools-status-count-single-pass.md diff --git a/.changeset/devtools-status-count-single-pass.md b/.changeset/devtools-status-count-single-pass.md new file mode 100644 index 00000000000..3ae0f0628ef --- /dev/null +++ b/.changeset/devtools-status-count-single-pass.md @@ -0,0 +1,12 @@ +--- +"@tanstack/query-devtools": patch +--- + +perf(devtools): tally the status counts in a single pass + +The query and mutation status badges each backed their count with an +independent cache subscription, and every one of those subscriptions walked the +entire cache and allocated a full-size array on every cache event - five passes +for queries, four for mutations. Each set of counts is now derived from one +pass, with the individual counts exposed as memos so a badge still only updates +when its own count changes. diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index ba72f4c3b2d..2de587e0b6b 100644 --- a/packages/query-devtools/src/Devtools.tsx +++ b/packages/query-devtools/src/Devtools.tsx @@ -1771,40 +1771,25 @@ const MutationRow: Component<{ mutation: Mutation }> = (props) => { } const QueryStatusCount: Component = () => { - const stale = createSubscribeToQueryCacheBatcher( - (queryCache) => - queryCache() - .getAll() - .filter((q) => getQueryStatusLabel(q) === 'stale').length, - ) - - const fresh = createSubscribeToQueryCacheBatcher( - (queryCache) => - queryCache() - .getAll() - .filter((q) => getQueryStatusLabel(q) === 'fresh').length, - ) - - const fetching = createSubscribeToQueryCacheBatcher( - (queryCache) => - queryCache() - .getAll() - .filter((q) => getQueryStatusLabel(q) === 'fetching').length, - ) + // Tally every status in one pass. Five independent subscriptions each walked + // the whole cache on every cache event, so the status badges alone cost five + // full scans per event. `getQueryStatusLabel` returns exactly these five + // labels, so the tally is exhaustive. + const counts = createSubscribeToQueryCacheBatcher((queryCache) => { + const tally = { fresh: 0, stale: 0, fetching: 0, paused: 0, inactive: 0 } + for (const query of queryCache().getAll()) { + tally[getQueryStatusLabel(query)]++ + } + return tally + }) - const paused = createSubscribeToQueryCacheBatcher( - (queryCache) => - queryCache() - .getAll() - .filter((q) => getQueryStatusLabel(q) === 'paused').length, - ) - - const inactive = createSubscribeToQueryCacheBatcher( - (queryCache) => - queryCache() - .getAll() - .filter((q) => getQueryStatusLabel(q) === 'inactive').length, - ) + // Memos so each badge still only updates when its own count changes, as it + // did when every count had its own equality-checked signal. + const stale = createMemo(() => counts().stale) + const fresh = createMemo(() => counts().fresh) + const fetching = createMemo(() => counts().fetching) + const paused = createMemo(() => counts().paused) + const inactive = createMemo(() => counts().inactive) const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget @@ -1828,57 +1813,28 @@ const QueryStatusCount: Component = () => { } const MutationStatusCount: Component = () => { - const success = createSubscribeToMutationCacheBatcher( - (mutationCache) => - mutationCache() - .getAll() - .filter( - (m) => - getMutationStatusColor({ - isPaused: m.state.isPaused, - status: m.state.status, - }) === 'green', - ).length, - ) - - const pending = createSubscribeToMutationCacheBatcher( - (mutationCache) => - mutationCache() - .getAll() - .filter( - (m) => - getMutationStatusColor({ - isPaused: m.state.isPaused, - status: m.state.status, - }) === 'yellow', - ).length, - ) - - const paused = createSubscribeToMutationCacheBatcher( - (mutationCache) => - mutationCache() - .getAll() - .filter( - (m) => - getMutationStatusColor({ - isPaused: m.state.isPaused, - status: m.state.status, - }) === 'purple', - ).length, - ) + // Tally every status color in one pass; four independent subscriptions each + // walked the whole cache on every mutation-cache event. `gray` is counted + // even though no badge displays it, because an idle mutation resolves to it + // and the tally has to stay exhaustive. + const counts = createSubscribeToMutationCacheBatcher((mutationCache) => { + const tally = { green: 0, yellow: 0, purple: 0, red: 0, gray: 0 } + for (const mutation of mutationCache().getAll()) { + tally[ + getMutationStatusColor({ + isPaused: mutation.state.isPaused, + status: mutation.state.status, + }) + ]++ + } + return tally + }) - const error = createSubscribeToMutationCacheBatcher( - (mutationCache) => - mutationCache() - .getAll() - .filter( - (m) => - getMutationStatusColor({ - isPaused: m.state.isPaused, - status: m.state.status, - }) === 'red', - ).length, - ) + // Memos so each badge still only updates when its own count changes. + const success = createMemo(() => counts().green) + const pending = createMemo(() => counts().yellow) + const paused = createMemo(() => counts().purple) + const error = createMemo(() => counts().red) const theme = useTheme() const css = useQueryDevtoolsContext().shadowDOMTarget diff --git a/packages/query-devtools/src/__tests__/Devtools.test.tsx b/packages/query-devtools/src/__tests__/Devtools.test.tsx index dccfe657afe..4f193eb07fe 100644 --- a/packages/query-devtools/src/__tests__/Devtools.test.tsx +++ b/packages/query-devtools/src/__tests__/Devtools.test.tsx @@ -551,6 +551,35 @@ describe('Devtools', () => { expect(rendered.getByLabelText(/Inactive: \d+/)).toBeInTheDocument() }) + it('tallies each status into its own badge', () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + expect(rendered.getByLabelText('Fresh: 0')).toBeInTheDocument() + expect(rendered.getByLabelText('Stale: 0')).toBeInTheDocument() + expect(rendered.getByLabelText('Fetching: 0')).toBeInTheDocument() + expect(rendered.getByLabelText('Paused: 0')).toBeInTheDocument() + expect(rendered.getByLabelText('Inactive: 0')).toBeInTheDocument() + + // Observed and never stale, so this lands in "fresh" and nowhere else. + queryClient.setQueryData(['fresh-one'], 1) + const observer = new QueryObserver(queryClient, { + queryKey: ['fresh-one'], + staleTime: Infinity, + }) + const unsubscribe = observer.subscribe(() => {}) + + // No observer, so this lands in "inactive". + queryClient.setQueryData(['inactive-one'], 1) + + expect(rendered.getByLabelText('Fresh: 1')).toBeInTheDocument() + expect(rendered.getByLabelText('Inactive: 1')).toBeInTheDocument() + expect(rendered.getByLabelText('Stale: 0')).toBeInTheDocument() + expect(rendered.getByLabelText('Fetching: 0')).toBeInTheDocument() + expect(rendered.getByLabelText('Paused: 0')).toBeInTheDocument() + + unsubscribe() + }) + it('should reflect the inactive count when a query is added without observers', () => { const rendered = renderDevtools({ initialIsOpen: true }) From 7a06f4155d758a2cdc68d76ce41b1c371289b99c Mon Sep 17 00:00:00 2001 From: cloudfluffy <120070429+cloudfluffy@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:05:03 -0700 Subject: [PATCH 10/16] perf(devtools): stop rescanning the cache in the details panes While a query was selected, the details pane derived seven values through seven separate subscriptions, and each located the query by scanning the whole cache and allocating a full-size array on every cache event. The cache is keyed by query hash, so each of those lookups is now a direct retrieval - the same correction the query rows already had. The mutation cache has no keyed access, so the mutation pane keeps its scan but derives all three of its values from one shared lookup rather than three. That lookup's equality check stays disabled: a mutation's object identity does not change across status transitions, so the pane would otherwise freeze on its first rendered state. --- .changeset/devtools-details-keyed-lookup.md | 12 ++++ packages/query-devtools/src/Devtools.tsx | 72 +++++++------------ .../src/__tests__/Devtools.test.tsx | 33 +++++++++ 3 files changed, 69 insertions(+), 48 deletions(-) create mode 100644 .changeset/devtools-details-keyed-lookup.md diff --git a/.changeset/devtools-details-keyed-lookup.md b/.changeset/devtools-details-keyed-lookup.md new file mode 100644 index 00000000000..6cd3099e5b5 --- /dev/null +++ b/.changeset/devtools-details-keyed-lookup.md @@ -0,0 +1,12 @@ +--- +"@tanstack/query-devtools": patch +--- + +perf(devtools): stop rescanning the cache in the details panes + +While a query was selected, the details pane derived seven values through seven +separate subscriptions, and each located the query by scanning the whole cache +and allocating a full-size array. The cache is keyed by query hash, so each of +those lookups is now a direct retrieval - the same correction the query rows +already had. The mutation details pane has no keyed access available, so its +three lookups are instead derived from one shared scan. diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index 2de587e0b6b..61377dc661c 100644 --- a/packages/query-devtools/src/Devtools.tsx +++ b/packages/query-devtools/src/Devtools.tsx @@ -1993,59 +1993,44 @@ const QueryDetails = () => { return useQueryDevtoolsContext().errorTypes || [] }) + // The cache is keyed by query hash, so resolve the selected query directly + // instead of scanning every query in the cache on each event. const activeQuery = createSubscribeToQueryCacheBatcher( - (queryCache) => - queryCache() - .getAll() - .find((query) => query.queryHash === selectedQueryHash()), + (queryCache) => queryCache().get(selectedQueryHash()!), false, ) const activeQueryFresh = createSubscribeToQueryCacheBatcher((queryCache) => { - return queryCache() - .getAll() - .find((query) => query.queryHash === selectedQueryHash()) + return queryCache().get(selectedQueryHash()!) }, false) const activeQueryState = createSubscribeToQueryCacheBatcher( - (queryCache) => - queryCache() - .getAll() - .find((query) => query.queryHash === selectedQueryHash())?.state, + (queryCache) => queryCache().get(selectedQueryHash()!)?.state, false, ) const activeQueryStateData = createSubscribeToQueryCacheBatcher( (queryCache) => { - return queryCache() - .getAll() - .find((query) => query.queryHash === selectedQueryHash())?.state.data + return queryCache().get(selectedQueryHash()!)?.state.data }, false, ) const statusLabel = createSubscribeToQueryCacheBatcher((queryCache) => { - const query = queryCache() - .getAll() - .find((q) => q.queryHash === selectedQueryHash()) + const query = queryCache().get(selectedQueryHash()!) if (!query) return 'inactive' return getQueryStatusLabel(query) }) const queryStatus = createSubscribeToQueryCacheBatcher((queryCache) => { - const query = queryCache() - .getAll() - .find((q) => q.queryHash === selectedQueryHash()) + const query = queryCache().get(selectedQueryHash()!) if (!query) return 'pending' return query.state.status }) const observerCount = createSubscribeToQueryCacheBatcher( (queryCache) => - queryCache() - .getAll() - .find((query) => query.queryHash === selectedQueryHash()) - ?.getObserversCount() ?? 0, + queryCache().get(selectedQueryHash()!)?.getObserversCount() ?? 0, ) const color = createMemo(() => getQueryStatusColorByLabel(statusLabel())) @@ -2528,23 +2513,22 @@ const MutationDetails = () => { const { colors } = tokens const t = (light: string, dark: string) => (theme() === 'dark' ? dark : light) - const isPaused = createSubscribeToMutationCacheBatcher((mutationCache) => { - const mutations = mutationCache().getAll() - const mutation = mutations.find( - (m) => m.mutationId === selectedMutationId(), - ) - if (!mutation) return false - return mutation.state.isPaused - }) + // The mutation cache has no keyed lookup, so the scan cannot be avoided - but + // all three values come from the same mutation, so one scan serves them all. + // The equality check stays disabled: a mutation's object identity does not + // change across status transitions, so the subscription would otherwise never + // notify and the pane would freeze on its first rendered state. + const activeMutation = createSubscribeToMutationCacheBatcher( + (mutationCache) => + mutationCache() + .getAll() + .find((mutation) => mutation.mutationId === selectedMutationId()), + false, + ) - const status = createSubscribeToMutationCacheBatcher((mutationCache) => { - const mutations = mutationCache().getAll() - const mutation = mutations.find( - (m) => m.mutationId === selectedMutationId(), - ) - if (!mutation) return 'idle' - return mutation.state.status - }) + const isPaused = createMemo(() => activeMutation()?.state.isPaused ?? false) + + const status = createMemo(() => activeMutation()?.state.status ?? 'idle') const color = createMemo(() => getMutationStatusColor({ @@ -2553,14 +2537,6 @@ const MutationDetails = () => { }), ) - const activeMutation = createSubscribeToMutationCacheBatcher( - (mutationCache) => - mutationCache() - .getAll() - .find((mutation) => mutation.mutationId === selectedMutationId()), - false, - ) - const getQueryStatusColors = () => { if (color() === 'gray') { return css` diff --git a/packages/query-devtools/src/__tests__/Devtools.test.tsx b/packages/query-devtools/src/__tests__/Devtools.test.tsx index 4f193eb07fe..4f2dca56ff3 100644 --- a/packages/query-devtools/src/__tests__/Devtools.test.tsx +++ b/packages/query-devtools/src/__tests__/Devtools.test.tsx @@ -826,6 +826,39 @@ describe('Devtools', () => { }) }) + describe('mutation details status', () => { + it('follows the selected mutation through a status transition', async () => { + const rendered = renderDevtools({ initialIsOpen: true }) + + fireEvent.click(rendered.getByText('Mutations')) + + let resolve: (value: string) => void = () => {} + const mutation = queryClient.getMutationCache().build(queryClient, { + mutationKey: ['status-transition'], + mutationFn: () => + new Promise((r) => { + resolve = r + }), + }) + const executed = mutation.execute({}) + await vi.advanceTimersByTimeAsync(0) + + fireEvent.click(rendered.getByLabelText(/Mutation submitted at/)) + expect(rendered.getByText('Mutation Details')).toBeInTheDocument() + + const details = rendered.getByText('Mutation Details').closest('div') + ?.parentElement as HTMLElement + expect(details.textContent).toContain('pending') + + resolve('ok') + await executed + // The mutation fan-out is dispatched on a microtask. + await vi.advanceTimersByTimeAsync(0) + + expect(details.textContent).toContain('success') + }) + }) + describe('mutation sort order', () => { it('should toggle the mutation sort order in the mutations view', () => { const rendered = renderDevtools({ initialIsOpen: true }) From ed305c957a26e9e7501ac11f59577a7987eb2fa2 Mon Sep 17 00:00:00 2001 From: cloudfluffy <120070429+cloudfluffy@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:05:03 -0700 Subject: [PATCH 11/16] perf(devtools): batch the mutation cache fan-out A mutation-cache event scheduled every subscriber on its own microtask, so each signal write triggered an independent downstream update. The fan-out now runs inside a single microtask with the writes batched together, so one event produces one update. The batch has to sit inside the microtask rather than around the loop: a batch around the loop would exit before any deferred setter ran. Iterating the registry inside the tick also means subscriptions disposed in the meantime are skipped rather than written to after disposal. --- .changeset/devtools-batch-mutation-fanout.md | 11 +++++++++++ packages/query-devtools/src/Devtools.tsx | 14 ++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 .changeset/devtools-batch-mutation-fanout.md diff --git a/.changeset/devtools-batch-mutation-fanout.md b/.changeset/devtools-batch-mutation-fanout.md new file mode 100644 index 00000000000..aee83692d59 --- /dev/null +++ b/.changeset/devtools-batch-mutation-fanout.md @@ -0,0 +1,11 @@ +--- +"@tanstack/query-devtools": patch +--- + +perf(devtools): batch the mutation cache fan-out + +A mutation-cache event scheduled every subscriber on its own microtask, so each +signal write triggered an independent downstream update. The fan-out now runs +inside a single microtask with the writes batched together, so one event +produces one update. The dispatch stays deferred, unlike the query cache path +which notifies synchronously. diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index 61377dc661c..c7ef1d76f2d 100644 --- a/packages/query-devtools/src/Devtools.tsx +++ b/packages/query-devtools/src/Devtools.tsx @@ -2753,11 +2753,17 @@ const setupMutationCacheSubscription = () => { }) const unsubscribe = mutationCache().subscribe(() => { - for (const [callback, setter] of mutationCacheMap.entries()) { - queueMicrotask(() => { - setter(callback(mutationCache)) + // One microtask around the whole fan-out, with the writes batched inside + // it, so a mutation-cache event produces a single downstream update rather + // than one per subscriber. The batch has to live inside the microtask: a + // batch around the loop would exit before any deferred setter ran. + queueMicrotask(() => { + batch(() => { + for (const [callback, setter] of mutationCacheMap.entries()) { + setter(callback(mutationCache)) + } }) - } + }) }) onCleanup(() => { From a6df5cbb1b30009a47d578b241781c5e95744e3d Mon Sep 17 00:00:00 2001 From: cloudfluffy <120070429+cloudfluffy@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:05:03 -0700 Subject: [PATCH 12/16] perf(devtools): compile the stylesheet once per theme, not once per component `stylesFactory` builds around sixty CSS-in-JS templates, and twelve components in the panel plus six in the JSON explorer each called it from inside their own per-instance memo. Two of those components are the query and mutation rows, and the explorer is recursive - one instance per node of the rendered object - so with a large cache the whole stylesheet was recompiled for every row the virtualized list mounted and for every node the details pane expanded. The compiled result depends only on the theme and the `css` instance, so it is now memoized on both. Memoizing on the `css` instance only helps if that instance is stable, and it was not: every call site built its own with `css.bind({ target })`, which returns a new function object each call, so a cache keyed on it would never hit for a panel mounted in a shadow root - which is how the devtools are normally embedded. The bound function is therefore cached per shadow root as well. Both helpers live in `utils.tsx` so that the panel and the explorer, which each keep their own stylesheet, can share them. --- .../devtools-shared-stylesheet-cache.md | 17 ++++++ packages/query-devtools/src/Devtools.tsx | 58 ++++++------------- packages/query-devtools/src/Explorer.tsx | 34 +++++------ packages/query-devtools/src/utils.tsx | 38 ++++++++++++ 4 files changed, 87 insertions(+), 60 deletions(-) create mode 100644 .changeset/devtools-shared-stylesheet-cache.md diff --git a/.changeset/devtools-shared-stylesheet-cache.md b/.changeset/devtools-shared-stylesheet-cache.md new file mode 100644 index 00000000000..10cc9fed133 --- /dev/null +++ b/.changeset/devtools-shared-stylesheet-cache.md @@ -0,0 +1,17 @@ +--- +'@tanstack/query-devtools': patch +--- + +Compile the devtools stylesheet once per theme instead of once per component instance + +`stylesFactory` builds around sixty CSS-in-JS templates, and twelve components +called it from inside their own per-instance memo. Two of those components are +the query and mutation rows, so the whole stylesheet was recompiled for every +row the virtualized list mounted, and again for every row it recycled while the +cache was changing. + +The compiled result depends only on the theme and the `css` instance, so it is +now memoized on both. Memoizing on the `css` instance only works if that +instance is stable, and it was not: every call site built its own with +`css.bind({ target })`, which returns a new function each time. The bound +function is now cached per shadow root as well. diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index c7ef1d76f2d..df687a83229 100644 --- a/packages/query-devtools/src/Devtools.tsx +++ b/packages/query-devtools/src/Devtools.tsx @@ -10,7 +10,6 @@ import { onMount, } from 'solid-js' import { rankItem } from '@tanstack/match-sorter-utils' -import * as goober from 'goober' import { clsx as cx } from 'clsx' import { TransitionGroup } from 'solid-transition-group' import { Key } from '@solid-primitives/keyed' @@ -20,6 +19,8 @@ import { Portal } from 'solid-js/web' import { tokens } from './theme' import { convertRemToPixels, + createStylesCache, + cssForTarget, displayValue, getMutationStatusColor, getQueryStatusColor, @@ -80,6 +81,7 @@ import type { QueryCacheNotifyEvent, } from '@tanstack/query-core' import type { StorageObject, StorageSetter } from '@solid-primitives/storage' +import type * as goober from 'goober' import type { Accessor, Component, JSX, Setter } from 'solid-js' interface DevtoolsPanelProps { @@ -115,9 +117,7 @@ export type DevtoolsComponentType = Component & { export const Devtools: Component = (props) => { const theme = useTheme() - const css = useQueryDevtoolsContext().shadowDOMTarget - ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) - : goober.css + const css = cssForTarget(useQueryDevtoolsContext().shadowDOMTarget) const styles = createMemo(() => { return theme() === 'dark' ? darkStyles(css) : lightStyles(css) }) @@ -285,9 +285,7 @@ const PiPPanel: Component<{ }> = (props) => { const pip = usePiPWindow() const theme = useTheme() - const css = useQueryDevtoolsContext().shadowDOMTarget - ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) - : goober.css + const css = cssForTarget(useQueryDevtoolsContext().shadowDOMTarget) const styles = createMemo(() => { return theme() === 'dark' ? darkStyles(css) : lightStyles(css) }) @@ -354,9 +352,7 @@ export const ParentPanel: Component<{ children: JSX.Element }> = (props) => { const theme = useTheme() - const css = useQueryDevtoolsContext().shadowDOMTarget - ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) - : goober.css + const css = cssForTarget(useQueryDevtoolsContext().shadowDOMTarget) const styles = createMemo(() => { return theme() === 'dark' ? darkStyles(css) : lightStyles(css) }) @@ -411,9 +407,7 @@ export const ParentPanel: Component<{ const DraggablePanel: Component = (props) => { const theme = useTheme() - const css = useQueryDevtoolsContext().shadowDOMTarget - ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) - : goober.css + const css = cssForTarget(useQueryDevtoolsContext().shadowDOMTarget) const styles = createMemo(() => { return theme() === 'dark' ? darkStyles(css) : lightStyles(css) }) @@ -822,9 +816,7 @@ export const ContentView: Component = (props) => { setupMutationCacheSubscription() let containerRef!: HTMLDivElement const theme = useTheme() - const css = useQueryDevtoolsContext().shadowDOMTarget - ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) - : goober.css + const css = cssForTarget(useQueryDevtoolsContext().shadowDOMTarget) const styles = createMemo(() => { return theme() === 'dark' ? darkStyles(css) : lightStyles(css) }) @@ -1555,9 +1547,7 @@ export const ContentView: Component = (props) => { const QueryRow: Component<{ query: Query }> = (props) => { const theme = useTheme() - const css = useQueryDevtoolsContext().shadowDOMTarget - ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) - : goober.css + const css = cssForTarget(useQueryDevtoolsContext().shadowDOMTarget) const styles = createMemo(() => { return theme() === 'dark' ? darkStyles(css) : lightStyles(css) }) @@ -1666,9 +1656,7 @@ const QueryRow: Component<{ query: Query }> = (props) => { const MutationRow: Component<{ mutation: Mutation }> = (props) => { const theme = useTheme() - const css = useQueryDevtoolsContext().shadowDOMTarget - ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) - : goober.css + const css = cssForTarget(useQueryDevtoolsContext().shadowDOMTarget) const styles = createMemo(() => { return theme() === 'dark' ? darkStyles(css) : lightStyles(css) }) @@ -1792,9 +1780,7 @@ const QueryStatusCount: Component = () => { const inactive = createMemo(() => counts().inactive) const theme = useTheme() - const css = useQueryDevtoolsContext().shadowDOMTarget - ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) - : goober.css + const css = cssForTarget(useQueryDevtoolsContext().shadowDOMTarget) const styles = createMemo(() => { return theme() === 'dark' ? darkStyles(css) : lightStyles(css) }) @@ -1837,9 +1823,7 @@ const MutationStatusCount: Component = () => { const error = createMemo(() => counts().red) const theme = useTheme() - const css = useQueryDevtoolsContext().shadowDOMTarget - ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) - : goober.css + const css = cssForTarget(useQueryDevtoolsContext().shadowDOMTarget) const styles = createMemo(() => { return theme() === 'dark' ? darkStyles(css) : lightStyles(css) }) @@ -1858,9 +1842,7 @@ const MutationStatusCount: Component = () => { const QueryStatus: Component = (props) => { const theme = useTheme() - const css = useQueryDevtoolsContext().shadowDOMTarget - ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) - : goober.css + const css = cssForTarget(useQueryDevtoolsContext().shadowDOMTarget) const styles = createMemo(() => { return theme() === 'dark' ? darkStyles(css) : lightStyles(css) }) @@ -1973,9 +1955,7 @@ const QueryStatus: Component = (props) => { const QueryDetails = () => { const theme = useTheme() - const css = useQueryDevtoolsContext().shadowDOMTarget - ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) - : goober.css + const css = cssForTarget(useQueryDevtoolsContext().shadowDOMTarget) const styles = createMemo(() => { return theme() === 'dark' ? darkStyles(css) : lightStyles(css) }) @@ -2503,9 +2483,7 @@ const QueryDetails = () => { const MutationDetails = () => { const theme = useTheme() - const css = useQueryDevtoolsContext().shadowDOMTarget - ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) - : goober.css + const css = cssForTarget(useQueryDevtoolsContext().shadowDOMTarget) const styles = createMemo(() => { return theme() === 'dark' ? darkStyles(css) : lightStyles(css) }) @@ -3897,5 +3875,7 @@ const stylesFactory = ( } } -const lightStyles = (css: (typeof goober)['css']) => stylesFactory('light', css) -const darkStyles = (css: (typeof goober)['css']) => stylesFactory('dark', css) +const cachedStyles = createStylesCache(stylesFactory) + +const lightStyles = (css: (typeof goober)['css']) => cachedStyles('light', css) +const darkStyles = (css: (typeof goober)['css']) => cachedStyles('dark', css) diff --git a/packages/query-devtools/src/Explorer.tsx b/packages/query-devtools/src/Explorer.tsx index 3f3debcada7..4922c86b841 100644 --- a/packages/query-devtools/src/Explorer.tsx +++ b/packages/query-devtools/src/Explorer.tsx @@ -10,9 +10,10 @@ import { createUniqueId, } from 'solid-js' import { Key } from '@solid-primitives/keyed' -import * as goober from 'goober' import { tokens } from './theme' import { + createStylesCache, + cssForTarget, deleteNestedDataByPath, displayValue, updateNestedDataByPath, @@ -27,6 +28,7 @@ import { Trash, } from './icons' import { useQueryDevtoolsContext, useTheme } from './contexts' +import type * as goober from 'goober' import type { Query } from '@tanstack/query-core' /** @@ -54,9 +56,7 @@ function chunkArray( const Expander = (props: { expanded: boolean }) => { const theme = useTheme() - const css = useQueryDevtoolsContext().shadowDOMTarget - ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) - : goober.css + const css = cssForTarget(useQueryDevtoolsContext().shadowDOMTarget) const styles = createMemo(() => { return theme() === 'dark' ? darkStyles(css) : lightStyles(css) }) @@ -97,9 +97,7 @@ const Expander = (props: { expanded: boolean }) => { type CopyState = 'NoCopy' | 'SuccessCopy' | 'ErrorCopy' const CopyButton = (props: { value: unknown }) => { const theme = useTheme() - const css = useQueryDevtoolsContext().shadowDOMTarget - ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) - : goober.css + const css = cssForTarget(useQueryDevtoolsContext().shadowDOMTarget) const styles = createMemo(() => { return theme() === 'dark' ? darkStyles(css) : lightStyles(css) }) @@ -158,9 +156,7 @@ const ClearArrayButton = (props: { activeQuery: Query }) => { const theme = useTheme() - const css = useQueryDevtoolsContext().shadowDOMTarget - ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) - : goober.css + const css = cssForTarget(useQueryDevtoolsContext().shadowDOMTarget) const styles = createMemo(() => { return theme() === 'dark' ? darkStyles(css) : lightStyles(css) }) @@ -187,9 +183,7 @@ const DeleteItemButton = (props: { activeQuery: Query }) => { const theme = useTheme() - const css = useQueryDevtoolsContext().shadowDOMTarget - ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) - : goober.css + const css = cssForTarget(useQueryDevtoolsContext().shadowDOMTarget) const styles = createMemo(() => { return theme() === 'dark' ? darkStyles(css) : lightStyles(css) }) @@ -217,9 +211,7 @@ const ToggleValueButton = (props: { value: boolean }) => { const theme = useTheme() - const css = useQueryDevtoolsContext().shadowDOMTarget - ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) - : goober.css + const css = cssForTarget(useQueryDevtoolsContext().shadowDOMTarget) const styles = createMemo(() => { return theme() === 'dark' ? darkStyles(css) : lightStyles(css) }) @@ -268,9 +260,7 @@ function isIterable(x: any): x is Iterable { export default function Explorer(props: ExplorerProps) { const theme = useTheme() - const css = useQueryDevtoolsContext().shadowDOMTarget - ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) - : goober.css + const css = cssForTarget(useQueryDevtoolsContext().shadowDOMTarget) const styles = createMemo(() => { return theme() === 'dark' ? darkStyles(css) : lightStyles(css) }) @@ -670,5 +660,7 @@ const stylesFactory = ( } } -const lightStyles = (css: (typeof goober)['css']) => stylesFactory('light', css) -const darkStyles = (css: (typeof goober)['css']) => stylesFactory('dark', css) +const cachedStyles = createStylesCache(stylesFactory) + +const lightStyles = (css: (typeof goober)['css']) => cachedStyles('light', css) +const darkStyles = (css: (typeof goober)['css']) => cachedStyles('dark', css) diff --git a/packages/query-devtools/src/utils.tsx b/packages/query-devtools/src/utils.tsx index 77247607af8..8ef01476bb9 100644 --- a/packages/query-devtools/src/utils.tsx +++ b/packages/query-devtools/src/utils.tsx @@ -1,4 +1,5 @@ import { serialize } from 'superjson' +import * as goober from 'goober' import { createSignal, onCleanup, onMount } from 'solid-js' import type { Mutation, Query } from '@tanstack/query-core' import type { DevtoolsPosition } from './contexts' @@ -322,3 +323,40 @@ export const setupStyleSheet = (nonce?: string, target?: ShadowRoot) => { styleTag.setAttribute('nonce', nonce) root.appendChild(styleTag) } + +// `bind` returns a new function on every call, so binding per component would +// give the style caches below a key that never repeats. Bind once per shadow +// root instead, and reuse the unbound `css` when there is no shadow root. +const boundCssCache = new WeakMap() + +export const cssForTarget = (target: ShadowRoot | undefined) => { + if (!target) return goober.css + let bound = boundCssCache.get(target) + if (!bound) { + bound = goober.css.bind({ target }) + boundCssCache.set(target, bound) + } + return bound +} + +// A stylesheet depends only on its theme and the `css` instance it compiles +// with, so it can be compiled once and shared by every component instance +// rather than recompiled for each one. +export const createStylesCache = ( + factory: (theme: 'light' | 'dark', css: (typeof goober)['css']) => T, +) => { + const cache = new WeakMap<(typeof goober)['css'], Map<'light' | 'dark', T>>() + return (theme: 'light' | 'dark', css: (typeof goober)['css']) => { + let byTheme = cache.get(css) + if (!byTheme) { + byTheme = new Map() + cache.set(css, byTheme) + } + let styles = byTheme.get(theme) + if (!styles) { + styles = factory(theme, css) + byTheme.set(theme, styles) + } + return styles + } +} From a2828047180d71ce98902f6505d0d800ec3b4a71 Mon Sep 17 00:00:00 2001 From: cloudfluffy <120070429+cloudfluffy@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:05:03 -0700 Subject: [PATCH 13/16] perf(devtools): keep the query list array stable when its contents are unchanged The list of queries to render was rebuilt into a fresh array on every cache event. Most events leave both the membership and the order of that list untouched - a query's data changing, or a fetch settling, cannot reorder anything, and half of all events are observer notifications - but the new array was still a new value, so every downstream consumer recomputed and the virtualizer rediffed. The memo now holds the previous array whenever the recomputed one contains the same queries in the same order, so consumers only rerun when the list has genuinely changed. The sorted array is always freshly allocated before it is sorted in place, so a retained array is never mutated afterwards. --- .changeset/devtools-stable-list-identity.md | 15 ++++++ packages/query-devtools/src/Devtools.tsx | 26 ++++++++-- .../src/__tests__/Devtools.test.tsx | 48 +++++++++++++++++++ 3 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 .changeset/devtools-stable-list-identity.md diff --git a/.changeset/devtools-stable-list-identity.md b/.changeset/devtools-stable-list-identity.md new file mode 100644 index 00000000000..ec9b3ad3d55 --- /dev/null +++ b/.changeset/devtools-stable-list-identity.md @@ -0,0 +1,15 @@ +--- +'@tanstack/query-devtools': patch +--- + +Keep the devtools query list array stable when its contents have not changed + +The list of queries to render was rebuilt into a fresh array on every cache +event. Most events - a query's data changing, a fetch settling - leave both the +membership and the order of that list untouched, but the new array was still a +new value, so every downstream consumer recomputed and the virtualizer +rediffed. + +The memo now returns the previous array when the recomputed one holds the same +queries in the same order, so consumers only rerun when the list has genuinely +changed. diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index df687a83229..05fedf7dc2b 100644 --- a/packages/query-devtools/src/Devtools.tsx +++ b/packages/query-devtools/src/Devtools.tsx @@ -856,9 +856,26 @@ export const ContentView: Component = (props) => { return useQueryDevtoolsContext().client.getMutationCache() }) - const queryCount = createSubscribeToQueryCacheBatcher((queryCache) => { - return queryCache().getAll().length - }, false) + const queryCount = createSubscribeToQueryCacheBatcher( + (queryCache) => { + return queryCache().getAll().length + }, + false, + ) + + // Every cache event recomputes the list, and the result is usually the + // same queries in the same order - half of all events are observer + // notifications that cannot reorder anything. Returning a new array each + // time makes the whole downstream chain re-run regardless, so hold the + // previous array whenever the recompute produced an identical list. + let previousQueries: Array = [] + const sameAsPrevious = (next: Array) => { + if (next.length !== previousQueries.length) return false + for (let i = 0; i < next.length; i++) { + if (next[i] !== previousQueries[i]) return false + } + return true + } const queries = createMemo( on( @@ -887,6 +904,9 @@ export const ContentView: Component = (props) => { const sorted = sortFn() ? filtered.sort((a, b) => sortFn()!(a, b) * sortOrder()) : filtered + + if (sameAsPrevious(sorted)) return previousQueries + previousQueries = sorted return sorted }, ), diff --git a/packages/query-devtools/src/__tests__/Devtools.test.tsx b/packages/query-devtools/src/__tests__/Devtools.test.tsx index 4f2dca56ff3..ad7298760d3 100644 --- a/packages/query-devtools/src/__tests__/Devtools.test.tsx +++ b/packages/query-devtools/src/__tests__/Devtools.test.tsx @@ -338,6 +338,54 @@ describe('Devtools', () => { }) }) + describe('list identity', () => { + it('reflects an update that reorders the list under a time-based sort', async () => { + queryClient.setQueryData(['a'], 1) + // Fake timers freeze the clock, so without this both queries would carry + // the same `dataUpdatedAt` and a time-based sort could not order them. + await vi.advanceTimersByTimeAsync(10) + queryClient.setQueryData(['b'], 1) + const rendered = renderDevtools( + { initialIsOpen: true }, + { TanstackQueryDevtools_sortFn: '"Last Updated"' }, + ) + + const keysInOrder = () => + rendered + .getAllByLabelText(/Query key/) + .map((row) => row.getAttribute('aria-label') || '') + + const before = keysInOrder() + + // Touching one query changes its updatedAt, which under this sort moves + // it relative to the other - so holding the previous array would be wrong. + await vi.advanceTimersByTimeAsync(10) + queryClient.setQueryData(['a'], 2) + await vi.advanceTimersByTimeAsync(50) + + expect(keysInOrder()).not.toEqual(before) + }) + + it('reflects a change that does reorder the list', async () => { + queryClient.setQueryData(['a'], 1) + queryClient.setQueryData(['b'], 1) + const rendered = renderDevtools({ initialIsOpen: true }) + + const keysInOrder = () => + rendered + .getAllByLabelText(/Query key/) + .map((row) => row.getAttribute('aria-label')) + + expect(keysInOrder()[0]).toMatch(/\["a"\]/) + + // Removing the first query must change what the list renders. + queryClient.removeQueries({ queryKey: ['a'] }) + await vi.advanceTimersByTimeAsync(50) + + expect(keysInOrder()[0]).toMatch(/\["b"\]/) + }) + }) + describe('view toggle', () => { it('should switch to mutations view when the mutations toggle is clicked', () => { const rendered = renderDevtools({ initialIsOpen: true }) From ee31469c662500c6c310fe15ff7b04d6c96269a2 Mon Sep 17 00:00:00 2001 From: cloudfluffy <120070429+cloudfluffy@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:05:03 -0700 Subject: [PATCH 14/16] perf(devtools): coalesce the subscribers that walk the whole query cache Two devtools subscribers - the query count and the status tallies - inspect every query in the cache each time they run, and they ran once per cache event. A stream of arriving, updating and expiring queries therefore cost a full-cache pass per event, and each `setQueryData` emits two events. With several thousand queries that is enough to stall the page for as long as the panel is open. These two now coalesce. A subscriber that has been idle for a window runs immediately, so an isolated change is never delayed; events arriving inside a window fold into a single trailing pass. Per-row subscribers are already filtered to one query and are unchanged. Three details of the window matter. It is measured in time rather than microtasks, because cache events usually arrive in separate tasks - one per network response - and a microtask window would close between every pair of them, coalescing nothing. It is stamped when a pass finishes rather than when it starts, because a start-to-start window shorter than the pass leaves every event outside it, so nothing coalesces and the passes run back to back. And it widens to a multiple of the last pass, which bounds the share of the main thread the subscriber can take however large the cache becomes. A pass being in flight is tracked separately from a trailing pass being armed. An event raised from inside a pass has nothing in flight that can reflect it, so it is remembered and a trailing pass is armed once the pass unwinds, rather than being folded into a pass that has already read the cache. The clock is `performance.now()`; `Date.now()` can step backwards on a clock correction, which would hold a subscriber closed for the size of the step. At eight thousand queries with a mixed stream of arrivals, updates and removals these three changes together take the panel from blocking around 90% of wall clock, with a longest block of 142 ms, to blocking under half with a longest block of around 32 ms. --- ...vtools-coalesce-whole-cache-subscribers.md | 18 ++ packages/query-devtools/src/Devtools.tsx | 177 ++++++++++++++++-- .../src/__tests__/Devtools.test.tsx | 10 +- 3 files changed, 187 insertions(+), 18 deletions(-) create mode 100644 .changeset/devtools-coalesce-whole-cache-subscribers.md diff --git a/.changeset/devtools-coalesce-whole-cache-subscribers.md b/.changeset/devtools-coalesce-whole-cache-subscribers.md new file mode 100644 index 00000000000..9f4c5cd7a69 --- /dev/null +++ b/.changeset/devtools-coalesce-whole-cache-subscribers.md @@ -0,0 +1,18 @@ +--- +'@tanstack/query-devtools': patch +--- + +Coalesce the devtools subscribers that walk the whole query cache + +Two devtools subscribers - the query count and the status tallies - inspect +every query in the cache each time they run. They ran once per cache event, so +a stream of arriving, updating and expiring queries cost a full-cache pass per +event, and each `setQueryData` emits two events. With several thousand queries +this was enough to stall the page while the panel was open. + +These two subscribers now coalesce: a subscriber that has been idle runs +immediately, and further events arriving within roughly one frame are folded +into a single trailing pass. The window is measured in time rather than +microtasks, so it still coalesces when each event arrives in its own task, as +network responses do. Per-row subscribers are already filtered to a single +query and are unchanged. diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index 05fedf7dc2b..9b66e2705c2 100644 --- a/packages/query-devtools/src/Devtools.tsx +++ b/packages/query-devtools/src/Devtools.tsx @@ -861,6 +861,8 @@ export const ContentView: Component = (props) => { return queryCache().getAll().length }, false, + () => true, + true, ) // Every cache event recomputes the list, and the result is usually the @@ -1783,13 +1785,18 @@ const QueryStatusCount: Component = () => { // the whole cache on every cache event, so the status badges alone cost five // full scans per event. `getQueryStatusLabel` returns exactly these five // labels, so the tally is exhaustive. - const counts = createSubscribeToQueryCacheBatcher((queryCache) => { - const tally = { fresh: 0, stale: 0, fetching: 0, paused: 0, inactive: 0 } - for (const query of queryCache().getAll()) { - tally[getQueryStatusLabel(query)]++ - } - return tally - }) + const counts = createSubscribeToQueryCacheBatcher( + (queryCache) => { + const tally = { fresh: 0, stale: 0, fetching: 0, paused: 0, inactive: 0 } + for (const query of queryCache().getAll()) { + tally[getQueryStatusLabel(query)]++ + } + return tally + }, + true, + () => true, + true, + ) // Memos so each badge still only updates when its own count changes, as it // did when every count had its own equality-checked signal. @@ -2678,14 +2685,107 @@ const MutationDetails = () => { ) } +// Longest a coalesced subscriber will wait before reflecting a change, in +// milliseconds. Roughly one frame: long enough to fold a stream of cache +// events into a single pass, short enough to stay imperceptible. +const COALESCE_WINDOW_MS = 16 + +// Share of the main thread a coalesced subscriber is allowed to take when its +// passes cost more than the window. The next window is widened to this +// multiple of the last pass, so the panel keeps well clear of starving the +// application no matter how large the cache grows. +const COALESCE_PASS_MULTIPLE = 3 + +// Monotonic, unlike `Date.now`, which can step backwards on a clock +// correction and would then hold a subscriber closed for the size of the step. +const now = () => + typeof performance !== 'undefined' ? performance.now() : Date.now() + +type QueryCacheSubscriber = { + setter: Setter + shouldUpdate: (event: QueryCacheNotifyEvent) => boolean + // Subscribers whose callback walks the whole cache opt into coalescing, so + // a stream of events costs one pass per window rather than one per event. + coalesce: boolean + scheduled: boolean + running: boolean + missedWhileRunning: boolean + timeout: ReturnType | undefined + lastRun: number + lastPassMs: number +} + const queryCacheMap = new Map< (q: Accessor) => any, - { - setter: Setter - shouldUpdate: (event: QueryCacheNotifyEvent) => boolean - } + QueryCacheSubscriber >() +// A window is at least one frame, and widens when a pass costs enough that a +// frame would not contain it, which caps the share of the main thread a +// subscriber can take however large the cache grows. +const windowFor = (value: QueryCacheSubscriber) => + Math.max(COALESCE_WINDOW_MS, value.lastPassMs * COALESCE_PASS_MULTIPLE) + +// Runs a coalesced subscriber and records what it cost, so the next window can +// be widened to match. `lastRun` is stamped on the way out rather than on the +// way in: the window has to measure the gap between passes, not the gap +// between their start times, or an expensive pass consumes its own window. +const runPass = ( + callback: (q: Accessor) => any, + value: QueryCacheSubscriber, + queryCache: Accessor, +) => { + const started = now() + // Marked for the duration of the pass so that a cache event raised from + // inside it is deferred rather than starting a nested pass. It is a separate + // flag from `scheduled`, which means a trailing pass is already armed: an + // event arriving mid-pass has nothing armed to pick it up, so it has to be + // remembered and armed once the pass unwinds. + value.running = true + try { + value.setter(callback(queryCache)) + } finally { + const finished = now() + value.lastPassMs = finished - started + value.lastRun = finished + value.running = false + } +} + +// Arms the single trailing pass that everything arriving inside a window folds +// into. +const scheduleTrailingPass = ( + callback: (q: Accessor) => any, + value: QueryCacheSubscriber, + queryCache: Accessor, + delay: number, +) => { + value.scheduled = true + value.timeout = setTimeout(() => { + value.scheduled = false + value.timeout = undefined + // The entry may have been disposed, or replaced, while the pass was + // pending; either way this pass no longer owns the signal. + if (queryCacheMap.get(callback) !== value) return + batch(() => runPassAndRearm(callback, value, queryCache)) + }, delay) +} + +// Both exits from a pass have to behave the same way. An event raised while a +// pass was unwinding has nothing in flight that can reflect it, so it is +// remembered and armed here; doing this on only one of the two paths would turn +// a would-be nested pass into a silently lost one. +const runPassAndRearm = ( + callback: (q: Accessor) => any, + value: QueryCacheSubscriber, + queryCache: Accessor, +) => { + runPass(callback, value, queryCache) + if (!value.missedWhileRunning) return + value.missedWhileRunning = false + scheduleTrailingPass(callback, value, queryCache, windowFor(value)) +} + const setupQueryCacheSubscription = () => { const queryCache = createMemo(() => { const client = useQueryDevtoolsContext().client @@ -2696,7 +2796,37 @@ const setupQueryCacheSubscription = () => { batch(() => { for (const [callback, value] of queryCacheMap.entries()) { if (!value.shouldUpdate(q)) continue - value.setter(callback(queryCache)) + + if (!value.coalesce) { + value.setter(callback(queryCache)) + continue + } + + // A pass is already scheduled for this subscriber; it will pick the + // latest state up when it runs. + if (value.scheduled) continue + + // Raised from inside a pass that is still unwinding. Nothing in flight + // can reflect it, so record it and let the pass arm a trailing one. + if (value.running) { + value.missedWhileRunning = true + continue + } + + // Without the floor in `windowFor`, a pass costing more than the + // window would leave every event outside the window, so nothing would + // coalesce and the passes would run back to back - the stall this is + // meant to prevent. + const window = windowFor(value) + const elapsed = now() - value.lastRun + if (elapsed >= window) { + runPassAndRearm(callback, value, queryCache) + continue + } + + // Mid-window: fold this and everything else that arrives into one + // trailing pass. + scheduleTrailingPass(callback, value, queryCache, window - elapsed) } }) }) @@ -2712,6 +2842,7 @@ const createSubscribeToQueryCacheBatcher = ( callback: (queryCache: Accessor) => Exclude, equalityCheck: boolean = true, shouldUpdate: (event: QueryCacheNotifyEvent) => boolean = () => true, + coalesce: boolean = false, ) => { const queryCache = createMemo(() => { const client = useQueryDevtoolsContext().client @@ -2727,13 +2858,27 @@ const createSubscribeToQueryCacheBatcher = ( setValue(callback(queryCache)) }) - queryCacheMap.set(callback, { - setter: setValue, + const entry = { + setter: setValue as Setter, shouldUpdate: shouldUpdate, - }) + coalesce, + scheduled: false, + running: false, + missedWhileRunning: false, + timeout: undefined as ReturnType | undefined, + // Never run, so the first change is always a leading edge no matter what + // origin the clock happens to count from. + lastRun: -Infinity, + lastPassMs: 0, + } + queryCacheMap.set(callback, entry) onCleanup(() => { - queryCacheMap.delete(callback) + if (entry.timeout !== undefined) clearTimeout(entry.timeout) + // Only retract our own registration: reading the map back would let a + // subscriber that happened to share this callback identity be torn down + // by someone else's cleanup. + if (queryCacheMap.get(callback) === entry) queryCacheMap.delete(callback) }) return value diff --git a/packages/query-devtools/src/__tests__/Devtools.test.tsx b/packages/query-devtools/src/__tests__/Devtools.test.tsx index ad7298760d3..e41d8f93057 100644 --- a/packages/query-devtools/src/__tests__/Devtools.test.tsx +++ b/packages/query-devtools/src/__tests__/Devtools.test.tsx @@ -324,7 +324,9 @@ describe('Devtools', () => { queryFn: () => [{ id: 1 }], queryKeyHashFn: () => 'custom-posts-hash', }) - await vi.advanceTimersByTimeAsync(0) + // Whole-cache tallies are coalesced, so a stream of changes settles once + // the coalescing window closes rather than once per change. + await vi.advanceTimersByTimeAsync(50) const dehydratedState = dehydrate(queryClient) queryClient = new QueryClient() @@ -599,7 +601,7 @@ describe('Devtools', () => { expect(rendered.getByLabelText(/Inactive: \d+/)).toBeInTheDocument() }) - it('tallies each status into its own badge', () => { + it('tallies each status into its own badge', async () => { const rendered = renderDevtools({ initialIsOpen: true }) expect(rendered.getByLabelText('Fresh: 0')).toBeInTheDocument() @@ -619,6 +621,10 @@ describe('Devtools', () => { // No observer, so this lands in "inactive". queryClient.setQueryData(['inactive-one'], 1) + // Whole-cache tallies are coalesced, so a run of changes settles once the + // coalescing window closes rather than once per change. + await vi.advanceTimersByTimeAsync(50) + expect(rendered.getByLabelText('Fresh: 1')).toBeInTheDocument() expect(rendered.getByLabelText('Inactive: 1')).toBeInTheDocument() expect(rendered.getByLabelText('Stale: 0')).toBeInTheDocument() From 5be658743c3128e85f1ab17642646d8b136c4a5c Mon Sep 17 00:00:00 2001 From: cloudfluffy <120070429+cloudfluffy@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:20:35 -0700 Subject: [PATCH 15/16] chore(devtools): consolidate the changeset and tighten a virtualization test Collapses the per-fix changesets into the single entry this pull request should produce, matching how the devtools changelog is written elsewhere - one entry per pull request rather than one per commit. The entry also records that the coalescing window widens with the measured cost of a pass, which is the one user-visible timing change in this work. Also replaces an assertion in the row-recycling test that reduced to `reused.length === reused.length` and so passed whatever the virtualizer did. The scroll in that test advances the window by exactly one row, so the exact count is assertable. Drops an `overscan` prop on the internal virtual list that no call site ever set. --- .changeset/devtools-batch-mutation-fanout.md | 11 ----------- ...evtools-coalesce-whole-cache-subscribers.md | 18 ------------------ .changeset/devtools-details-keyed-lookup.md | 12 ------------ .changeset/devtools-large-cache-performance.md | 15 +++++++++++++++ .../devtools-panel-subscription-teardown.md | 14 -------------- .changeset/devtools-row-identity.md | 14 -------------- .changeset/devtools-shared-stylesheet-cache.md | 17 ----------------- .changeset/devtools-stable-list-identity.md | 15 --------------- .../devtools-status-count-single-pass.md | 12 ------------ .changeset/virtualize-devtools-lists.md | 16 ---------------- packages/query-devtools/src/Devtools.tsx | 6 ++---- .../src/__tests__/Devtools.test.tsx | 6 ++++-- 12 files changed, 21 insertions(+), 135 deletions(-) delete mode 100644 .changeset/devtools-batch-mutation-fanout.md delete mode 100644 .changeset/devtools-coalesce-whole-cache-subscribers.md delete mode 100644 .changeset/devtools-details-keyed-lookup.md create mode 100644 .changeset/devtools-large-cache-performance.md delete mode 100644 .changeset/devtools-panel-subscription-teardown.md delete mode 100644 .changeset/devtools-row-identity.md delete mode 100644 .changeset/devtools-shared-stylesheet-cache.md delete mode 100644 .changeset/devtools-stable-list-identity.md delete mode 100644 .changeset/devtools-status-count-single-pass.md delete mode 100644 .changeset/virtualize-devtools-lists.md diff --git a/.changeset/devtools-batch-mutation-fanout.md b/.changeset/devtools-batch-mutation-fanout.md deleted file mode 100644 index aee83692d59..00000000000 --- a/.changeset/devtools-batch-mutation-fanout.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@tanstack/query-devtools": patch ---- - -perf(devtools): batch the mutation cache fan-out - -A mutation-cache event scheduled every subscriber on its own microtask, so each -signal write triggered an independent downstream update. The fan-out now runs -inside a single microtask with the writes batched together, so one event -produces one update. The dispatch stays deferred, unlike the query cache path -which notifies synchronously. diff --git a/.changeset/devtools-coalesce-whole-cache-subscribers.md b/.changeset/devtools-coalesce-whole-cache-subscribers.md deleted file mode 100644 index 9f4c5cd7a69..00000000000 --- a/.changeset/devtools-coalesce-whole-cache-subscribers.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@tanstack/query-devtools': patch ---- - -Coalesce the devtools subscribers that walk the whole query cache - -Two devtools subscribers - the query count and the status tallies - inspect -every query in the cache each time they run. They ran once per cache event, so -a stream of arriving, updating and expiring queries cost a full-cache pass per -event, and each `setQueryData` emits two events. With several thousand queries -this was enough to stall the page while the panel was open. - -These two subscribers now coalesce: a subscriber that has been idle runs -immediately, and further events arriving within roughly one frame are folded -into a single trailing pass. The window is measured in time rather than -microtasks, so it still coalesces when each event arrives in its own task, as -network responses do. Per-row subscribers are already filtered to a single -query and are unchanged. diff --git a/.changeset/devtools-details-keyed-lookup.md b/.changeset/devtools-details-keyed-lookup.md deleted file mode 100644 index 6cd3099e5b5..00000000000 --- a/.changeset/devtools-details-keyed-lookup.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@tanstack/query-devtools": patch ---- - -perf(devtools): stop rescanning the cache in the details panes - -While a query was selected, the details pane derived seven values through seven -separate subscriptions, and each located the query by scanning the whole cache -and allocating a full-size array. The cache is keyed by query hash, so each of -those lookups is now a direct retrieval - the same correction the query rows -already had. The mutation details pane has no keyed access available, so its -three lookups are instead derived from one shared scan. diff --git a/.changeset/devtools-large-cache-performance.md b/.changeset/devtools-large-cache-performance.md new file mode 100644 index 00000000000..7f484ccb7c2 --- /dev/null +++ b/.changeset/devtools-large-cache-performance.md @@ -0,0 +1,15 @@ +--- +'@tanstack/query-devtools': patch +--- + +Stop the devtools panel freezing the page when the query cache holds thousands of queries. + +The panel rendered one row per cached query and per cached mutation with no windowing, and several of its subscriptions walked the whole cache on every cache event, so both the mount cost and the per-event cost grew with the size of the cache. Opening the panel against a large cache could lock up the page, and it stayed locked up while the cache kept changing. + +- Both lists are virtualized: rows are a fixed height and only those intersecting the scroll viewport, plus a small overscan, are mounted. +- `MutationRow` reads its state directly instead of resolving it through the cache. +- The details panes resolve their query by key rather than scanning the cache, and the status badges tally all five statuses in a single pass instead of one scan each. +- The mutation cache fan-out is batched, and tearing one panel down no longer unsubscribes another panel that is still mounted. +- The stylesheet is compiled once per theme and shared, rather than recompiled for every row and every expanded node of the JSON explorer. +- The query list keeps its array identity when its contents have not changed, so consumers only rerun when the list really changed. +- The two subscribers that walk the whole cache coalesce, so a stream of cache events costs one pass per window rather than one pass per event. The window is at least one frame and widens to a multiple of the last pass's measured cost, which bounds the share of the main thread the panel can take as the cache grows; whole-cache tallies can therefore lag a large cache by more than a frame, while per-row state stays immediate. diff --git a/.changeset/devtools-panel-subscription-teardown.md b/.changeset/devtools-panel-subscription-teardown.md deleted file mode 100644 index 8f4a63e183a..00000000000 --- a/.changeset/devtools-panel-subscription-teardown.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@tanstack/query-devtools": patch ---- - -fix(devtools): keep other panels subscribed when one panel unmounts - -The subscriber registries for the query and mutation caches are shared by every -devtools instance on the page, but a panel's teardown cleared them entirely -instead of removing only its own entries. When two panels were mounted at once -- during the picture-in-picture transition, or when the standalone panel is used -alongside the floating one - tearing one down unsubscribed the other, leaving -its list, status counts and details pane permanently frozen. Each subscription -already removes its own entry on disposal, so the registry-wide clear was -redundant as well as harmful and has been removed. diff --git a/.changeset/devtools-row-identity.md b/.changeset/devtools-row-identity.md deleted file mode 100644 index 6efaf7e878e..00000000000 --- a/.changeset/devtools-row-identity.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@tanstack/query-devtools": patch ---- - -perf(devtools): stop rebuilding list rows on every update - -The virtualized query and mutation lists rebuilt every mounted row's component -on each scroll and each cache event. The window is recomputed into freshly -allocated row wrappers, so the keyed list's item signal always notified, and -because the row was rendered by calling the row renderer inside a child -position that read that signal, each notification tore down and reconstructed -the row - its DOM, its styles and its cache subscriptions. Rows now receive an -accessor and read the item through a prop, so a row is built once and updated -in place, matching how the lists behaved before they were virtualized. diff --git a/.changeset/devtools-shared-stylesheet-cache.md b/.changeset/devtools-shared-stylesheet-cache.md deleted file mode 100644 index 10cc9fed133..00000000000 --- a/.changeset/devtools-shared-stylesheet-cache.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@tanstack/query-devtools': patch ---- - -Compile the devtools stylesheet once per theme instead of once per component instance - -`stylesFactory` builds around sixty CSS-in-JS templates, and twelve components -called it from inside their own per-instance memo. Two of those components are -the query and mutation rows, so the whole stylesheet was recompiled for every -row the virtualized list mounted, and again for every row it recycled while the -cache was changing. - -The compiled result depends only on the theme and the `css` instance, so it is -now memoized on both. Memoizing on the `css` instance only works if that -instance is stable, and it was not: every call site built its own with -`css.bind({ target })`, which returns a new function each time. The bound -function is now cached per shadow root as well. diff --git a/.changeset/devtools-stable-list-identity.md b/.changeset/devtools-stable-list-identity.md deleted file mode 100644 index ec9b3ad3d55..00000000000 --- a/.changeset/devtools-stable-list-identity.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@tanstack/query-devtools': patch ---- - -Keep the devtools query list array stable when its contents have not changed - -The list of queries to render was rebuilt into a fresh array on every cache -event. Most events - a query's data changing, a fetch settling - leave both the -membership and the order of that list untouched, but the new array was still a -new value, so every downstream consumer recomputed and the virtualizer -rediffed. - -The memo now returns the previous array when the recomputed one holds the same -queries in the same order, so consumers only rerun when the list has genuinely -changed. diff --git a/.changeset/devtools-status-count-single-pass.md b/.changeset/devtools-status-count-single-pass.md deleted file mode 100644 index 3ae0f0628ef..00000000000 --- a/.changeset/devtools-status-count-single-pass.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@tanstack/query-devtools": patch ---- - -perf(devtools): tally the status counts in a single pass - -The query and mutation status badges each backed their count with an -independent cache subscription, and every one of those subscriptions walked the -entire cache and allocated a full-size array on every cache event - five passes -for queries, four for mutations. Each set of counts is now derived from one -pass, with the individual counts exposed as memos so a badge still only updates -when its own count changes. diff --git a/.changeset/virtualize-devtools-lists.md b/.changeset/virtualize-devtools-lists.md deleted file mode 100644 index df853dbdddb..00000000000 --- a/.changeset/virtualize-devtools-lists.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@tanstack/query-devtools": patch ---- - -perf(devtools): virtualize the query and mutation lists - -The devtools panel previously rendered one row per cached query and mutation -with no windowing, and each row registered several query/mutation-cache -subscriptions. With very large caches this mounted thousands of DOM nodes and -subscriptions and could freeze or crash the host page. The lists are now -windowed so only the rows near the scroll viewport are mounted, which also -bounds the per-row subscriptions the global cache handler walks on every event. -Rows use a fixed height and truncate long keys with an ellipsis (the full key -remains available via the row's tooltip, aria-label, and the details pane), and -each mutation row now reads its own state directly instead of scanning the whole -mutation cache. diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index 9b66e2705c2..b6824dc9c5e 100644 --- a/packages/query-devtools/src/Devtools.tsx +++ b/packages/query-devtools/src/Devtools.tsx @@ -682,7 +682,6 @@ function VirtualList(props: { getKey: (item: T) => string rowHeight: number pinnedKey?: string | null - overscan?: number overflowClass: string containerClass: string rowClass: string @@ -745,16 +744,15 @@ function VirtualList(props: { return [] as Array<{ key: string; item: T; start: number }> } - const overscan = props.overscan ?? OVERSCAN const height = viewportHeight() // Clamp the scroll offset so a shrinking list (after filtering/sorting) // never scrolls past the end and blanks the viewport. const maxScrollTop = Math.max(0, count * rowHeight - height) const clampedTop = Math.min(scrollTop(), maxScrollTop) - const first = Math.max(0, Math.floor(clampedTop / rowHeight) - overscan) + const first = Math.max(0, Math.floor(clampedTop / rowHeight) - OVERSCAN) const last = Math.min( count, - first + Math.ceil(height / rowHeight) + overscan * 2, + first + Math.ceil(height / rowHeight) + OVERSCAN * 2, ) const indexes = new Set() diff --git a/packages/query-devtools/src/__tests__/Devtools.test.tsx b/packages/query-devtools/src/__tests__/Devtools.test.tsx index e41d8f93057..dfde4525f11 100644 --- a/packages/query-devtools/src/__tests__/Devtools.test.tsx +++ b/packages/query-devtools/src/__tests__/Devtools.test.tsx @@ -1862,8 +1862,10 @@ describe('Devtools', () => { const after = [...rendered.container.querySelectorAll('.tsqd-query-row')] const reused = after.filter((row) => row.hasAttribute('data-row-id')) - expect(reused.length).toBeGreaterThan(0) - expect(reused.length).toBe(after.length - (after.length - reused.length)) + + // The scroll advanced the window by exactly one row, so every row except + // the one that left the top must be the very same element. + expect(reused.length).toBe(before.length - 1) }) it('keeps a reused row up to date with its own query', () => { From 24b3631896fb539de22ee8a73fca0efc09d515c8 Mon Sep 17 00:00:00 2001 From: cloudfluffy <120070429+cloudfluffy@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:03:39 -0700 Subject: [PATCH 16/16] fix(devtools): show an ellipsis when a long query key is truncated `text-overflow` applies to block containers, and the query hash element is a flex container, so the declaration had no effect and a key too long for its row was cut off mid-character instead. Make the element a block and keep the text vertically centred with a line height equal to its minimum height. --- .changeset/devtools-large-cache-performance.md | 2 +- packages/query-devtools/src/Devtools.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/devtools-large-cache-performance.md b/.changeset/devtools-large-cache-performance.md index 7f484ccb7c2..0fcd6e0b54a 100644 --- a/.changeset/devtools-large-cache-performance.md +++ b/.changeset/devtools-large-cache-performance.md @@ -6,7 +6,7 @@ Stop the devtools panel freezing the page when the query cache holds thousands o The panel rendered one row per cached query and per cached mutation with no windowing, and several of its subscriptions walked the whole cache on every cache event, so both the mount cost and the per-event cost grew with the size of the cache. Opening the panel against a large cache could lock up the page, and it stayed locked up while the cache kept changing. -- Both lists are virtualized: rows are a fixed height and only those intersecting the scroll viewport, plus a small overscan, are mounted. +- Both lists are virtualized: rows are a fixed height and only those intersecting the scroll viewport, plus a small overscan, are mounted. A query key too long for its row is truncated with an ellipsis rather than wrapped. - `MutationRow` reads its state directly instead of resolving it through the cache. - The details panes resolve their query by key rather than scanning the cache, and the status badges tally all five statuses in a single pass instead of one scan each. - The mutation cache fan-out is batched, and tearing one panel down no longer unsubscribes another panel that is still mounted. diff --git a/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index b6824dc9c5e..51d6c3551f1 100644 --- a/packages/query-devtools/src/Devtools.tsx +++ b/packages/query-devtools/src/Devtools.tsx @@ -3636,8 +3636,8 @@ const stylesFactory = ( & .tsqd-query-hash { user-select: text; font-size: ${font.size.xs}; - display: flex; - align-items: center; + display: block; + line-height: ${tokens.size[6]}; min-height: ${tokens.size[6]}; flex: 1; min-width: 0;