diff --git a/.changeset/devtools-large-cache-performance.md b/.changeset/devtools-large-cache-performance.md new file mode 100644 index 00000000000..0fcd6e0b54a --- /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. 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. +- 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/packages/query-devtools/src/Devtools.tsx b/packages/query-devtools/src/Devtools.tsx index daff934932f..51d6c3551f1 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, @@ -60,7 +61,9 @@ import { DEFAULT_SORT_ORDER, DEFAULT_WIDTH, INITIAL_IS_OPEN, + OVERSCAN, POSITION, + QUERY_ROW_HEIGHT_MULTIPLIER, firstBreakpoint, secondBreakpoint, thirdBreakpoint, @@ -78,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 { @@ -113,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) }) @@ -283,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) }) @@ -352,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) }) @@ -409,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) }) @@ -671,14 +667,154 @@ 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 + overflowClass: string + containerClass: string + rowClass: string + children: (item: Accessor) => 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 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) => { + // 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)} +
+ ) + }} +
+
+
+ ) +} + export const ContentView: Component = (props) => { setupQueryCacheSubscription() 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) }) @@ -718,9 +854,28 @@ 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, + () => true, + true, + ) + + // 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( @@ -749,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 }, ), @@ -805,6 +963,30 @@ 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) => } +
-
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) => } +
@@ -1373,9 +1567,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) }) @@ -1464,7 +1656,9 @@ const QueryRow: Component<{ query: Query }> = (props) => { > {observers()} - {props.query.queryHash} + + {props.query.queryHash} + - + {JSON.stringify(props.mutation.options.mutationKey)} -{' '} @@ -1592,45 +1779,33 @@ 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, - ) - - const paused = createSubscribeToQueryCacheBatcher( - (queryCache) => - queryCache() - .getAll() - .filter((q) => getQueryStatusLabel(q) === 'paused').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 + }, + true, + () => true, + true, ) - 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 - ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) - : goober.css + const css = cssForTarget(useQueryDevtoolsContext().shadowDOMTarget) const styles = createMemo(() => { return theme() === 'dark' ? darkStyles(css) : lightStyles(css) }) @@ -1649,62 +1824,31 @@ 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, - ) + // 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 paused = createSubscribeToMutationCacheBatcher( - (mutationCache) => - mutationCache() - .getAll() - .filter( - (m) => - getMutationStatusColor({ - isPaused: m.state.isPaused, - status: m.state.status, - }) === 'purple', - ).length, - ) - - 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 - ? goober.css.bind({ target: useQueryDevtoolsContext().shadowDOMTarget }) - : goober.css + const css = cssForTarget(useQueryDevtoolsContext().shadowDOMTarget) const styles = createMemo(() => { return theme() === 'dark' ? darkStyles(css) : lightStyles(css) }) @@ -1723,9 +1867,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) }) @@ -1838,9 +1980,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) }) @@ -1858,59 +1998,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())) @@ -2383,9 +2508,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) }) @@ -2393,23 +2516,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({ @@ -2418,14 +2540,6 @@ const MutationDetails = () => { }), ) - const activeMutation = createSubscribeToMutationCacheBatcher( - (mutationCache) => - mutationCache() - .getAll() - .find((mutation) => mutation.mutationId === selectedMutationId()), - false, - ) - const getQueryStatusColors = () => { if (color() === 'gray') { return css` @@ -2569,14 +2683,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 @@ -2587,13 +2794,42 @@ 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) } }) }) onCleanup(() => { - queryCacheMap.clear() unsubscribe() }) @@ -2604,6 +2840,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 @@ -2619,13 +2856,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 @@ -2643,15 +2894,20 @@ 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(() => { - mutationCacheMap.clear() unsubscribe() }) @@ -3330,10 +3586,22 @@ const stylesFactory = ( flex-direction: column; } `, + virtualSpacer: css` + position: relative; + width: 100%; + `, + virtualRow: css` + position: absolute; + top: 0; + left: 0; + width: 100%; + `, queryRow: css` display: flex; align-items: center; padding: 0; + width: 100%; + height: calc(var(--tsqd-font-size) * ${QUERY_ROW_HEIGHT_MULTIPLIER}); border: none; cursor: pointer; color: ${t(colors.gray[700], colors.gray[300])}; @@ -3368,18 +3636,20 @@ 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; 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 { @@ -3768,5 +4038,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/__tests__/Devtools.test.tsx b/packages/query-devtools/src/__tests__/Devtools.test.tsx index f73024502c6..dfde4525f11 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' @@ -323,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() @@ -337,6 +340,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 }) @@ -550,6 +601,39 @@ describe('Devtools', () => { expect(rendered.getByLabelText(/Inactive: \d+/)).toBeInTheDocument() }) + it('tallies each status into its own badge', async () => { + 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) + + // 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() + 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 }) @@ -796,6 +880,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 }) @@ -1500,4 +1617,403 @@ 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 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) + } + } + + 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() + }) + + 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')) + + // 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', () => { + 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() + }) + + // 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, + ) + }) + }) + }) }) 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(() => ( { 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 + } +}