diff --git a/docs/guides/query-invalidation.md b/docs/guides/query-invalidation.md index cbd1093e49..0d8aac55bc 100644 --- a/docs/guides/query-invalidation.md +++ b/docs/guides/query-invalidation.md @@ -48,7 +48,7 @@ You can even invalidate queries with specific variables by passing a more specif ```tsx queryClient.invalidateQueries({ - queryKey: ['todos', { type: 'done' }, + queryKey: ['todos', { type: 'done' }], }) // The query below will be invalidated diff --git a/packages/eslint-plugin-query/package.json b/packages/eslint-plugin-query/package.json index 84f55d0ee6..a13d5fdf0a 100644 --- a/packages/eslint-plugin-query/package.json +++ b/packages/eslint-plugin-query/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/eslint-plugin-query", - "version": "4.14.6", + "version": "4.15.1", "description": "ESLint plugin for TanStack Query", "author": "Eliya Cohen", "license": "MIT", diff --git a/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts b/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts index f608dddfcb..70b511ed20 100644 --- a/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts +++ b/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.rule.ts @@ -56,13 +56,26 @@ export const rule = createRule({ return } - if (queryKey.value.type !== AST_NODE_TYPES.ArrayExpression) { + let queryKeyNode = queryKey.value + + if (queryKeyNode.type === AST_NODE_TYPES.Identifier) { + const expression = ASTUtils.getReferencedExpressionByIdentifier({ + context, + node: queryKeyNode, + }) + + if (expression?.type === AST_NODE_TYPES.ArrayExpression) { + queryKeyNode = expression + } + } + + if (queryKeyNode.type !== AST_NODE_TYPES.ArrayExpression) { // TODO support query key factory return } const sourceCode = context.getSourceCode() - const queryKeyValue = queryKey.value + const queryKeyValue = queryKeyNode const refs = ASTUtils.getExternalRefs({ scopeManager, node: queryFn.value, diff --git a/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.test.ts b/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.test.ts index 5f9c588f7f..f6baec6da4 100644 --- a/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.test.ts +++ b/packages/eslint-plugin-query/src/rules/exhaustive-deps/exhaustive-deps.test.ts @@ -181,6 +181,7 @@ ruleTester.run('exhaustive-deps', rule, { suggestions: [ { messageId: 'fixTo', + // eslint-disable-next-line no-template-curly-in-string data: { result: '["entity/${id}", id]' }, output: normalizeIndent` const id = 1; @@ -205,6 +206,7 @@ ruleTester.run('exhaustive-deps', rule, { suggestions: [ { messageId: 'fixTo', + // eslint-disable-next-line no-template-curly-in-string data: { result: '[`entity/${a}`, b]' }, output: normalizeIndent` const a = 1; @@ -343,5 +345,32 @@ ruleTester.run('exhaustive-deps', rule, { }, ], }, + { + name: 'should fail when a queryKey is a reference of an array expression with a missing dep', + code: normalizeIndent` + const x = 5; + const queryKey = ['foo'] + useQuery({ queryKey, queryFn: () => x }) + `, + errors: [ + { + messageId: 'missingDeps', + data: { deps: 'x' }, + suggestions: [ + { + messageId: 'fixTo', + data: { + result: "['foo', x]", + }, + output: normalizeIndent` + const x = 5; + const queryKey = ['foo', x] + useQuery({ queryKey, queryFn: () => x }) + `, + }, + ], + }, + ], + }, ], }) diff --git a/packages/eslint-plugin-query/src/utils/detect-react-query-imports.ts b/packages/eslint-plugin-query/src/utils/detect-react-query-imports.ts index 5964107c74..24b448e01c 100644 --- a/packages/eslint-plugin-query/src/utils/detect-react-query-imports.ts +++ b/packages/eslint-plugin-query/src/utils/detect-react-query-imports.ts @@ -26,6 +26,8 @@ export function detectTanstackQueryImports(create: EnhancedCreate): Create { if (specifier.type === 'ImportSpecifier') { return node.name === specifier.local.name } + + return false }) }, } diff --git a/packages/query-async-storage-persister/package.json b/packages/query-async-storage-persister/package.json index 453c56411f..4446ae5460 100644 --- a/packages/query-async-storage-persister/package.json +++ b/packages/query-async-storage-persister/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/query-async-storage-persister", - "version": "4.14.5", + "version": "4.15.1", "description": "A persister for asynchronous storages, to be used with TanStack/Query", "author": "tannerlinsley", "license": "MIT", diff --git a/packages/query-broadcast-client-experimental/package.json b/packages/query-broadcast-client-experimental/package.json index 1cb99ffdb0..fae43e44af 100644 --- a/packages/query-broadcast-client-experimental/package.json +++ b/packages/query-broadcast-client-experimental/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/query-broadcast-client-experimental", - "version": "4.14.5", + "version": "4.15.1", "description": "An experimental plugin to for broadcasting the state of your queryClient between browser tabs/windows", "author": "tannerlinsley", "license": "MIT", diff --git a/packages/query-core/package.json b/packages/query-core/package.json index 28a46b5d37..5ed07b5a7e 100644 --- a/packages/query-core/package.json +++ b/packages/query-core/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/query-core", - "version": "4.14.5", + "version": "4.15.1", "description": "The framework agnostic core that powers TanStack Query", "author": "tannerlinsley", "license": "MIT", diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index 1e0062a07c..c9fbc5e164 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -117,6 +117,10 @@ export class QueriesObserver extends Subscribable { return this.observers.map((observer) => observer.getCurrentQuery()) } + getObservers() { + return this.observers + } + getOptimisticResult(queries: QueryObserverOptions[]): QueryObserverResult[] { return this.findMatchingObservers(queries).map((match) => match.observer.getOptimisticResult(match.defaultedQueryOptions), diff --git a/packages/query-persist-client-core/package.json b/packages/query-persist-client-core/package.json index 6f4b86ff01..d2d2c5a718 100644 --- a/packages/query-persist-client-core/package.json +++ b/packages/query-persist-client-core/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/query-persist-client-core", - "version": "4.14.5", + "version": "4.15.1", "description": "Set of utilities for interacting with persisters, which can save your queryClient for later use", "author": "tannerlinsley", "license": "MIT", diff --git a/packages/query-sync-storage-persister/package.json b/packages/query-sync-storage-persister/package.json index 437c0d375a..afc012a31f 100644 --- a/packages/query-sync-storage-persister/package.json +++ b/packages/query-sync-storage-persister/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/query-sync-storage-persister", - "version": "4.14.5", + "version": "4.15.1", "description": "A persister for synchronous storages, to be used with TanStack/Query", "author": "tannerlinsley", "license": "MIT", diff --git a/packages/react-query-devtools/package.json b/packages/react-query-devtools/package.json index c6b996ab78..031d8784d7 100644 --- a/packages/react-query-devtools/package.json +++ b/packages/react-query-devtools/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/react-query-devtools", - "version": "4.14.6", + "version": "4.16.0", "description": "Developer tools to interact with and visualize the TanStack/react-query cache", "author": "tannerlinsley", "license": "MIT", @@ -55,7 +55,7 @@ "@tanstack/react-query": "workspace:*" }, "dependencies": { - "@tanstack/match-sorter-utils": "^8.1.1", + "@tanstack/match-sorter-utils": "8.1.1", "superjson": "^1.10.0", "use-sync-external-store": "^1.2.0" }, diff --git a/packages/react-query-devtools/src/Explorer.tsx b/packages/react-query-devtools/src/Explorer.tsx index bc55515ade..04871143fd 100644 --- a/packages/react-query-devtools/src/Explorer.tsx +++ b/packages/react-query-devtools/src/Explorer.tsx @@ -1,6 +1,7 @@ import * as React from 'react' import { displayValue, styled } from './utils' +import superjson from 'superjson' export const Entry = styled('div', { fontFamily: 'Menlo, monospace', @@ -29,6 +30,55 @@ export const ExpandButton = styled('button', { padding: 0, }) +type CopyState = 'NoCopy' | 'SuccessCopy' | 'ErrorCopy' + +export const CopyButton = ({ value }: { value: unknown }) => { + const [copyState, setCopyState] = React.useState('NoCopy') + + return ( + + ) +} + export const Value = styled('span', (_props, theme) => ({ color: theme.danger, })) @@ -62,6 +112,76 @@ export const Expander = ({ expanded, style = {} }: ExpanderProps) => ( ) +const Copier = () => ( + + + + + + +) + +const ErrorCopier = () => ( + + + + + + See console + + +) + +const CopiedCopier = () => ( + + + + + +) + type Entry = { label: string } @@ -74,6 +194,7 @@ type RendererProps = { subEntryPages: Entry[][] type: string expanded: boolean + copyable: boolean toggleExpanded: () => void pageSize: number } @@ -108,6 +229,7 @@ export const DefaultRenderer: Renderer = ({ subEntryPages = [], type, expanded = false, + copyable = false, toggleExpanded, pageSize, }) => { @@ -124,6 +246,7 @@ export const DefaultRenderer: Renderer = ({ {subEntries.length} {subEntries.length > 1 ? `items` : `item`} + {copyable ? : null} {expanded ? ( subEntryPages.length === 1 ? ( {subEntries.map(handleEntry)} @@ -166,6 +289,7 @@ export const DefaultRenderer: Renderer = ({ type ExplorerProps = Partial & { renderer?: Renderer defaultExpanded?: true | Record + copyable?: boolean } type Property = { @@ -183,6 +307,7 @@ export default function Explorer({ defaultExpanded, renderer = DefaultRenderer, pageSize = 100, + copyable = false, ...rest }: ExplorerProps) { const [expanded, setExpanded] = React.useState(Boolean(defaultExpanded)) @@ -241,6 +366,7 @@ export default function Explorer({ key={entry.label} value={value} renderer={renderer} + copyable={copyable} {...rest} {...entry} /> @@ -250,6 +376,7 @@ export default function Explorer({ subEntryPages, value, expanded, + copyable, toggleExpanded, pageSize, ...rest, diff --git a/packages/react-query-devtools/src/__tests__/Explorer.test.tsx b/packages/react-query-devtools/src/__tests__/Explorer.test.tsx index 5cdccec1f6..7d4dd7e00e 100644 --- a/packages/react-query-devtools/src/__tests__/Explorer.test.tsx +++ b/packages/react-query-devtools/src/__tests__/Explorer.test.tsx @@ -1,7 +1,7 @@ -import { fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, act } from '@testing-library/react' import * as React from 'react' -import { chunkArray, DefaultRenderer } from '../Explorer' +import { chunkArray, CopyButton, DefaultRenderer } from '../Explorer' import { displayValue } from '../utils' describe('Explorer', () => { @@ -38,6 +38,7 @@ describe('Explorer', () => { toggleExpanded={toggleExpanded} pageSize={10} expanded={false} + copyable={false} subEntryPages={[[{ label: 'A lovely label' }]]} handleEntry={() => <>} value={undefined} @@ -54,6 +55,69 @@ describe('Explorer', () => { expect(toggleExpanded).toHaveBeenCalledTimes(1) }) + + it('when the copy button is clicked, update the clipboard value', async () => { + // Mock clipboard + let clipBoardContent = null + const value = 'someValue' + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: async () => { + return new Promise(() => (clipBoardContent = value)) + }, + }, + configurable: true, + }) + + act(() => { + render() + }) + + // After rendering the clipboard content should be null + expect(clipBoardContent).toBe(null) + + const copyButton = screen.getByRole('button') + + await screen.findByLabelText('Copy object to clipboard') + + // After clicking the content should be added to the clipboard + await act(async () => { + fireEvent.click(copyButton) + }) + + expect(clipBoardContent).toBe(value) + screen.findByLabelText('Object copied to clipboard') + }) + + it('when the copy button is clicked but there is an error, show error state', async () => { + // Mock clipboard with error state + Object.defineProperty(navigator, 'clipboard', { + value: { + writeText: async () => { + return new Promise(() => { + throw Error + }) + }, + }, + configurable: true, + }) + + act(() => { + render() + }) + + const copyButton = screen.getByRole('button') + + await screen.findByLabelText('Copy object to clipboard') + + // After clicking the content should NOT be added to the clipboard + await act(async () => { + fireEvent.click(copyButton) + }) + + // Check that it has failed + await screen.findByLabelText('Failed copying to clipboard') + }) }) describe('displayValue', () => { diff --git a/packages/react-query-devtools/src/__tests__/devtools.test.tsx b/packages/react-query-devtools/src/__tests__/devtools.test.tsx index 543ffb4c59..be0150f3b3 100644 --- a/packages/react-query-devtools/src/__tests__/devtools.test.tsx +++ b/packages/react-query-devtools/src/__tests__/devtools.test.tsx @@ -4,7 +4,7 @@ import { ErrorBoundary } from 'react-error-boundary' import '@testing-library/jest-dom' import type { QueryClient } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query' -import { sortFns } from '../utils' +import { defaultPanelSize, sortFns } from '../utils' import { getByTextContent, renderWithClient, @@ -36,6 +36,7 @@ Object.defineProperty(window, 'matchMedia', { describe('ReactQueryDevtools', () => { beforeEach(() => { localStorage.removeItem('reactQueryDevtoolsOpen') + localStorage.removeItem('reactQueryDevtoolsPanelPosition') }) it('should be able to open and close devtools', async () => { const { queryClient } = createQueryClient() @@ -863,4 +864,55 @@ describe('ReactQueryDevtools', () => { expect(panel.style.width).toBe('500px') expect(panel.style.height).toBe('100vh') }) + + it('should restore parent element padding after closing', async () => { + const { queryClient } = createQueryClient() + + function Page() { + const { data = 'default' } = useQuery(['check'], async () => 'test') + + return ( +
+

{data}

+
+ ) + } + + const parentElementTestid = 'parentElement' + const parentPaddings = { + paddingTop: '428px', + paddingBottom: '39px', + paddingLeft: '-373px', + paddingRight: '20%', + } + + function Parent({ children }: { children: React.ReactElement }) { + return ( +
+ {children} +
+ ) + } + + renderWithClient( + queryClient, + , + { + initialIsOpen: true, + panelPosition: 'bottom', + }, + { wrapper: Parent }, + ) + + const parentElement = screen.getByTestId(parentElementTestid) + expect(parentElement).toHaveStyle({ + paddingTop: '0px', + paddingLeft: '0px', + paddingRight: '0px', + paddingBottom: defaultPanelSize, + }) + + fireEvent.click(screen.getByRole('button', { name: /^close$/i })) + expect(parentElement).toHaveStyle(parentPaddings) + }) }) diff --git a/packages/react-query-devtools/src/__tests__/utils.tsx b/packages/react-query-devtools/src/__tests__/utils.tsx index e5ea0e83e6..197b782afd 100644 --- a/packages/react-query-devtools/src/__tests__/utils.tsx +++ b/packages/react-query-devtools/src/__tests__/utils.tsx @@ -1,4 +1,4 @@ -import { render } from '@testing-library/react' +import { render, type RenderOptions } from '@testing-library/react' import * as React from 'react' import { ReactQueryDevtools } from '../devtools' @@ -12,12 +12,14 @@ export function renderWithClient( client: QueryClient, ui: React.ReactElement, devtoolsOptions: Parameters[number] = {}, + renderOptions?: RenderOptions, ): ReturnType { const { rerender, ...result } = render( {ui} , + renderOptions, ) return { ...result, diff --git a/packages/react-query-devtools/src/devtools.tsx b/packages/react-query-devtools/src/devtools.tsx index 7d6d0fcc4e..0f846f2790 100644 --- a/packages/react-query-devtools/src/devtools.tsx +++ b/packages/react-query-devtools/src/devtools.tsx @@ -247,26 +247,37 @@ export function ReactQueryDevtools({ }, [isResolvedOpen]) React.useEffect(() => { - if (isResolvedOpen) { - const root = rootRef.current + if (isResolvedOpen && rootRef.current?.parentElement) { + const { parentElement } = rootRef.current const styleProp = getSidedProp('padding', panelPosition) const isVertical = isVerticalSide(panelPosition) - const previousValue = root?.parentElement?.style[styleProp] + + const previousPaddings = (({ + padding, + paddingTop, + paddingBottom, + paddingLeft, + paddingRight, + }) => ({ + padding, + paddingTop, + paddingBottom, + paddingLeft, + paddingRight, + }))(parentElement.style) const run = () => { - if (root?.parentElement) { - // reset the padding - root.parentElement.style.padding = '0px' - root.parentElement.style.paddingTop = '0px' - root.parentElement.style.paddingBottom = '0px' - root.parentElement.style.paddingLeft = '0px' - root.parentElement.style.paddingRight = '0px' - // set the new padding based on the new panel position - - root.parentElement.style[styleProp] = `${ - isVertical ? devtoolsWidth : devtoolsHeight - }px` - } + // reset the padding + parentElement.style.padding = '0px' + parentElement.style.paddingTop = '0px' + parentElement.style.paddingBottom = '0px' + parentElement.style.paddingLeft = '0px' + parentElement.style.paddingRight = '0px' + // set the new padding based on the new panel position + + parentElement.style[styleProp] = `${ + isVertical ? devtoolsWidth : devtoolsHeight + }px` } run() @@ -276,9 +287,12 @@ export function ReactQueryDevtools({ return () => { window.removeEventListener('resize', run) - if (root?.parentElement && typeof previousValue === 'string') { - root.parentElement.style[styleProp] = previousValue - } + Object.entries(previousPaddings).forEach( + ([property, previousValue]) => { + parentElement.style[property as keyof typeof previousPaddings] = + previousValue + }, + ) } } } @@ -966,6 +980,7 @@ const ActiveQuery = ({ label="Data" value={activeQueryState.data} defaultExpanded={{}} + copyable />
{ const queryCache = new QueryCache() @@ -1011,3 +1011,228 @@ describe("useQuery's in Suspense mode", () => { expect(rendered.queryByText('rendered')).not.toBeNull() }) }) + +describe('useQueries with suspense', () => { + const queryClient = createQueryClient() + it('should suspend all queries in parallel', async () => { + const key1 = queryKey() + const key2 = queryKey() + const results: string[] = [] + + function Fallback() { + results.push('loading') + return
loading
+ } + + function Page() { + const result = useQueries({ + queries: [ + { + queryKey: key1, + queryFn: async () => { + results.push('1') + await sleep(10) + return '1' + }, + suspense: true, + }, + { + queryKey: key2, + queryFn: async () => { + results.push('2') + await sleep(20) + return '2' + }, + suspense: true, + }, + ], + }) + return ( +
+

data: {result.map((it) => it.data ?? 'null').join(',')}

+
+ ) + } + + const rendered = renderWithClient( + queryClient, + }> + + , + ) + + await waitFor(() => rendered.getByText('loading')) + await waitFor(() => rendered.getByText('data: 1,2')) + + expect(results).toEqual(['1', '2', 'loading']) + }) + + it('should allow to mix suspense with non-suspense', async () => { + const key1 = queryKey() + const key2 = queryKey() + const results: string[] = [] + + function Fallback() { + results.push('loading') + return
loading
+ } + + function Page() { + const result = useQueries({ + queries: [ + { + queryKey: key1, + queryFn: async () => { + results.push('1') + await sleep(10) + return '1' + }, + suspense: true, + }, + { + queryKey: key2, + queryFn: async () => { + results.push('2') + await sleep(20) + return '2' + }, + suspense: false, + }, + ], + }) + return ( +
+

data: {result.map((it) => it.data ?? 'null').join(',')}

+

status: {result.map((it) => it.status).join(',')}

+
+ ) + } + + const rendered = renderWithClient( + queryClient, + }> + + , + ) + await waitFor(() => rendered.getByText('loading')) + await waitFor(() => rendered.getByText('status: success,loading')) + await waitFor(() => rendered.getByText('data: 1,null')) + await waitFor(() => rendered.getByText('data: 1,2')) + + expect(results).toEqual(['1', '2', 'loading']) + }) + + it("shouldn't unmount before all promises fetched", async () => { + const key1 = queryKey() + const key2 = queryKey() + const results: string[] = [] + const refs: number[] = [] + + function Fallback() { + results.push('loading') + return
loading
+ } + + function Page() { + const ref = React.useRef(Math.random()) + const result = useQueries({ + queries: [ + { + queryKey: key1, + queryFn: async () => { + refs.push(ref.current) + results.push('1') + await sleep(10) + return '1' + }, + suspense: true, + }, + { + queryKey: key2, + queryFn: async () => { + refs.push(ref.current) + results.push('2') + await sleep(20) + return '2' + }, + suspense: true, + }, + ], + }) + return ( +
+

data: {result.map((it) => it.data ?? 'null').join(',')}

+
+ ) + } + + const rendered = renderWithClient( + queryClient, + }> + + , + ) + await waitFor(() => rendered.getByText('loading')) + expect(refs.length).toBe(2) + await waitFor(() => rendered.getByText('data: 1,2')) + expect(refs[0]).toBe(refs[1]) + }) + + it('should suspend all queries in parallel - global configuration', async () => { + const queryClientSuspenseMode = createQueryClient({ + defaultOptions: { + queries: { + suspense: true, + }, + }, + }) + const key1 = queryKey() + const key2 = queryKey() + const results: string[] = [] + + function Fallback() { + results.push('loading') + return
loading
+ } + + function Page() { + const result = useQueries({ + queries: [ + { + queryKey: key1, + queryFn: async () => { + results.push('1') + await sleep(10) + return '1' + }, + }, + { + queryKey: key2, + queryFn: async () => { + results.push('2') + await sleep(20) + return '2' + }, + }, + ], + }) + return ( +
+

data: {result.map((it) => it.data ?? 'null').join(',')}

+
+ ) + } + + const rendered = renderWithClient( + queryClientSuspenseMode, + }> + + , + ) + + await waitFor(() => rendered.getByText('loading')) + await waitFor(() => rendered.getByText('data: 1,2')) + + expect(results).toEqual(['1', '2', 'loading']) + }) +}) diff --git a/packages/react-query/src/suspense.ts b/packages/react-query/src/suspense.ts new file mode 100644 index 0000000000..682409e75d --- /dev/null +++ b/packages/react-query/src/suspense.ts @@ -0,0 +1,59 @@ +import type { DefaultedQueryObserverOptions } from '@tanstack/query-core' +import type { QueryObserver } from '@tanstack/query-core' +import type { QueryErrorResetBoundaryValue } from './QueryErrorResetBoundary' +import type { QueryObserverResult } from '@tanstack/query-core' +import type { QueryKey } from '@tanstack/query-core' + +export const ensureStaleTime = ( + defaultedOptions: DefaultedQueryObserverOptions, +) => { + if (defaultedOptions.suspense) { + // Always set stale time when using suspense to prevent + // fetching again when directly mounting after suspending + if (typeof defaultedOptions.staleTime !== 'number') { + defaultedOptions.staleTime = 1000 + } + } +} + +export const willFetch = ( + result: QueryObserverResult, + isRestoring: boolean, +) => result.isLoading && result.isFetching && !isRestoring + +export const shouldSuspend = ( + defaultedOptions: + | DefaultedQueryObserverOptions + | undefined, + result: QueryObserverResult, + isRestoring: boolean, +) => defaultedOptions?.suspense && willFetch(result, isRestoring) + +export const fetchOptimistic = < + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey extends QueryKey, +>( + defaultedOptions: DefaultedQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + >, + observer: QueryObserver, + errorResetBoundary: QueryErrorResetBoundaryValue, +) => + observer + .fetchOptimistic(defaultedOptions) + .then(({ data }) => { + defaultedOptions.onSuccess?.(data as TData) + defaultedOptions.onSettled?.(data, null) + }) + .catch((error) => { + errorResetBoundary.clearReset() + defaultedOptions.onError?.(error) + defaultedOptions.onSettled?.(undefined, error) + }) diff --git a/packages/react-query/src/useBaseQuery.ts b/packages/react-query/src/useBaseQuery.ts index 607ee1f74a..df537ab745 100644 --- a/packages/react-query/src/useBaseQuery.ts +++ b/packages/react-query/src/useBaseQuery.ts @@ -12,6 +12,7 @@ import { getHasError, useClearResetErrorBoundary, } from './errorBoundaryUtils' +import { ensureStaleTime, shouldSuspend, fetchOptimistic } from './suspense' export function useBaseQuery< TQueryFnData, @@ -58,14 +59,7 @@ export function useBaseQuery< ) } - if (defaultedOptions.suspense) { - // Always set stale time when using suspense to prevent - // fetching again when directly mounting after suspending - if (typeof defaultedOptions.staleTime !== 'number') { - defaultedOptions.staleTime = 1000 - } - } - + ensureStaleTime(defaultedOptions) ensurePreventErrorBoundaryRetry(defaultedOptions, errorResetBoundary) useClearResetErrorBoundary(errorResetBoundary) @@ -99,23 +93,8 @@ export function useBaseQuery< }, [defaultedOptions, observer]) // Handle suspense - if ( - defaultedOptions.suspense && - result.isLoading && - result.isFetching && - !isRestoring - ) { - throw observer - .fetchOptimistic(defaultedOptions) - .then(({ data }) => { - defaultedOptions.onSuccess?.(data as TData) - defaultedOptions.onSettled?.(data, null) - }) - .catch((error) => { - errorResetBoundary.clearReset() - defaultedOptions.onError?.(error) - defaultedOptions.onSettled?.(undefined, error) - }) + if (shouldSuspend(defaultedOptions, result, isRestoring)) { + throw fetchOptimistic(defaultedOptions, observer, errorResetBoundary) } // Handle error boundary diff --git a/packages/react-query/src/useQueries.ts b/packages/react-query/src/useQueries.ts index ab9a98ab1f..0682d30f1e 100644 --- a/packages/react-query/src/useQueries.ts +++ b/packages/react-query/src/useQueries.ts @@ -12,6 +12,12 @@ import { getHasError, useClearResetErrorBoundary, } from './errorBoundaryUtils' +import { + ensureStaleTime, + shouldSuspend, + fetchOptimistic, + willFetch, +} from './suspense' // This defines the `UseQueryOptions` that are accepted in `QueriesOptions` & `GetOptions`. // - `context` is omitted as it is passed as a root-level option to `useQueries` instead. @@ -170,7 +176,7 @@ export function useQueries({ () => new QueriesObserver(queryClient, defaultedQueries), ) - const result = observer.getOptimisticResult(defaultedQueries) + const optimisticResult = observer.getOptimisticResult(defaultedQueries) useSyncExternalStore( React.useCallback( @@ -194,22 +200,48 @@ export function useQueries({ defaultedQueries.forEach((query) => { ensurePreventErrorBoundaryRetry(query, errorResetBoundary) + ensureStaleTime(query) }) useClearResetErrorBoundary(errorResetBoundary) - const firstSingleResultWhichShouldThrow = result.find((singleResult, index) => - getHasError({ - result: singleResult, - errorResetBoundary, - useErrorBoundary: defaultedQueries[index]?.useErrorBoundary ?? false, - query: observer.getQueries()[index]!, - }), + const shouldAtLeastOneSuspend = optimisticResult.some((result, index) => + shouldSuspend(defaultedQueries[index], result, isRestoring), + ) + + const suspensePromises = shouldAtLeastOneSuspend + ? optimisticResult.flatMap((result, index) => { + const options = defaultedQueries[index] + const queryObserver = observer.getObservers()[index] + + if (options && queryObserver) { + if (shouldSuspend(options, result, isRestoring)) { + return fetchOptimistic(options, queryObserver, errorResetBoundary) + } else if (willFetch(result, isRestoring)) { + void fetchOptimistic(options, queryObserver, errorResetBoundary) + } + } + return [] + }) + : [] + + if (suspensePromises.length > 0) { + throw Promise.all(suspensePromises) + } + + const firstSingleResultWhichShouldThrow = optimisticResult.find( + (result, index) => + getHasError({ + result, + errorResetBoundary, + useErrorBoundary: defaultedQueries[index]?.useErrorBoundary ?? false, + query: observer.getQueries()[index]!, + }), ) if (firstSingleResultWhichShouldThrow?.error) { throw firstSingleResultWhichShouldThrow.error } - return result as QueriesResults + return optimisticResult as QueriesResults } diff --git a/packages/solid-query/package.json b/packages/solid-query/package.json index aa61831a15..e57f527580 100644 --- a/packages/solid-query/package.json +++ b/packages/solid-query/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/solid-query", - "version": "4.14.5", + "version": "4.15.1", "description": "Primitives for managing, caching and syncing asynchronous and remote data in Solid", "author": "tannerlinsley", "license": "MIT", diff --git a/packages/vue-query/package.json b/packages/vue-query/package.json index a2943d9e5c..dd9afb287c 100644 --- a/packages/vue-query/package.json +++ b/packages/vue-query/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/vue-query", - "version": "4.14.5", + "version": "4.15.1", "description": "Hooks for managing, caching and syncing asynchronous and remote data in Vue", "author": "Damian Osipiuk", "license": "MIT", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37dff20b3a..294efd9857 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -650,7 +650,7 @@ importers: vite: ^3.1.8 vue: ^3.2.41 dependencies: - '@tanstack/vue-query': 4.13.3_vue@3.2.41 + '@tanstack/vue-query': link:../../../packages/vue-query vue: 3.2.41 devDependencies: '@vitejs/plugin-vue': 3.1.2_vite@3.1.8+vue@3.2.41 @@ -731,7 +731,7 @@ importers: packages/react-query-devtools: specifiers: - '@tanstack/match-sorter-utils': ^8.1.1 + '@tanstack/match-sorter-utils': 8.1.1 '@tanstack/react-query': workspace:* '@types/react': ^18.0.14 '@types/react-dom': ^18.0.5 @@ -5541,10 +5541,6 @@ packages: remove-accents: 0.4.2 dev: false - /@tanstack/query-core/4.13.0: - resolution: {integrity: sha512-PzmLQcEgC4rl2OzkiPHYPC9O79DFcMGaKsOzDEP+U4PJ+tbkcEP+Z+FQDlfvX8mCwYC7UNH7hXrQ5EdkGlJjVg==} - dev: false - /@tanstack/react-location/3.7.4_biqbaboplfbrettd7655fr4n2y: resolution: {integrity: sha512-6rH2vNHGr0uyeUz5ZHvWMYjeYKGgIKFzvs5749QtnS9f+FU7t7fQE0hKZAzltBZk82LT7iYbcHBRyUg2lW13VA==} engines: {node: '>=12'} @@ -5558,22 +5554,6 @@ packages: react-dom: 18.2.0_react@18.2.0 dev: false - /@tanstack/vue-query/4.13.3_vue@3.2.41: - resolution: {integrity: sha512-+AMt5pG0UYGfJbdKuGtR0NzTExqT8rS3BkVPRKHpBcrvedVwf7RVnNOlC9jXPiKg0jvTiaK7ON7N+eh4ktHdLw==} - peerDependencies: - '@vue/composition-api': ^1.1.2 - vue: ^2.5.0 || ^3.0.0 - peerDependenciesMeta: - '@vue/composition-api': - optional: true - dependencies: - '@tanstack/match-sorter-utils': 8.1.1 - '@tanstack/query-core': 4.13.0 - '@vue/devtools-api': 6.4.2 - vue: 3.2.41 - vue-demi: 0.13.11_vue@3.2.41 - dev: false - /@testing-library/dom/7.31.2: resolution: {integrity: sha512-3UqjCpey6HiTZT92vODYLPxTBWlM8ZOOjr3LX5F37/VRipW2M1kX6I/Cm4VXzteZqfGfagg8yXywpcOgQBlNsQ==} engines: {node: '>=10'} @@ -15621,21 +15601,6 @@ packages: resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} dev: false - /vue-demi/0.13.11_vue@3.2.41: - resolution: {integrity: sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==} - engines: {node: '>=12'} - hasBin: true - requiresBuild: true - peerDependencies: - '@vue/composition-api': ^1.0.0-rc.1 - vue: ^3.0.0-0 || ^2.6.0 - peerDependenciesMeta: - '@vue/composition-api': - optional: true - dependencies: - vue: 3.2.41 - dev: false - /vue-demi/0.13.11_zv57lmwz3lpne326jxcwg2uc6q: resolution: {integrity: sha512-IR8HoEEGM65YY3ZJYAjMlKygDQn25D5ajNFNoKh9RSDMQtlzCxtfQjdQgv9jjK+m3377SsJXY8ysq8kLCZL25A==} engines: {node: '>=12'}