Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/query-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
],
"devDependencies": {
"@tanstack/query-test-utils": "workspace:*",
"npm-run-all2": "^5.0.0"
"npm-run-all2": "^5.0.0",
"stable-hash": "^0.0.6"
}
}
15 changes: 10 additions & 5 deletions packages/query-core/src/__tests__/hydration.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { QueryCache } from '../queryCache'
import { dehydrate, hydrate } from '../hydration'
import { MutationCache } from '../mutationCache'
import { executeMutation, mockOnlineManagerIsOnline } from './utils'
import { hashKey } from '../utils';

describe('dehydration and rehydration', () => {
beforeEach(() => {
Expand Down Expand Up @@ -631,6 +632,7 @@ describe('dehydration and rehydration', () => {

it('should set the fetchStatus to idle when creating a query with dehydrate', async () => {
const key = queryKey()
const keyHash = hashKey(key)
const queryCache = new QueryCache()
const queryClient = new QueryClient({ queryCache })

Expand Down Expand Up @@ -660,7 +662,7 @@ describe('dehydration and rehydration', () => {
const dehydrated = dehydrate(queryClient)
resolvePromise('string')
expect(
dehydrated.queries.find((q) => q.queryHash === JSON.stringify(key))?.state
dehydrated.queries.find((q) => q.queryHash === keyHash)?.state
.fetchStatus,
).toBe('fetching')
const stringified = JSON.stringify(dehydrated)
Expand All @@ -677,7 +679,9 @@ describe('dehydration and rehydration', () => {

it('should dehydrate and hydrate meta for queries', async () => {
const metaKey = queryKey()
const metaKeyHash = hashKey(metaKey)
const noMetaKey = queryKey()
const noMetaKeyHash = hashKey(noMetaKey)
const queryCache = new QueryCache()
const queryClient = new QueryClient({ queryCache })
queryClient.prefetchQuery({
Expand All @@ -696,21 +700,21 @@ describe('dehydration and rehydration', () => {
const dehydrated = dehydrate(queryClient)

expect(
dehydrated.queries.find((q) => q.queryHash === JSON.stringify(metaKey))
dehydrated.queries.find((q) => q.queryHash === metaKeyHash)
?.meta,
).toEqual({
some: 'meta',
})

expect(
dehydrated.queries.find((q) => q.queryHash === JSON.stringify(noMetaKey))
dehydrated.queries.find((q) => q.queryHash === noMetaKeyHash)
?.meta,
).toEqual(undefined)

expect(
Object.keys(
dehydrated.queries.find(
(q) => q.queryHash === JSON.stringify(noMetaKey),
(q) => q.queryHash === noMetaKeyHash,
)!,
),
).not.toEqual(expect.arrayContaining(['meta']))
Expand Down Expand Up @@ -796,6 +800,7 @@ describe('dehydration and rehydration', () => {

it('should not change fetchStatus when updating a query with dehydrate', async () => {
const key = queryKey()
const keyHash = hashKey(key)
const queryClient = new QueryClient()

const options = {
Expand All @@ -809,7 +814,7 @@ describe('dehydration and rehydration', () => {

const dehydrated = dehydrate(queryClient)
expect(
dehydrated.queries.find((q) => q.queryHash === JSON.stringify(key))?.state
dehydrated.queries.find((q) => q.queryHash === keyHash)?.state
.fetchStatus,
).toBe('idle')
const stringified = JSON.stringify(dehydrated)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { queryKey, sleep } from '@tanstack/query-test-utils'
import { CancelledError, InfiniteQueryObserver, QueryClient } from '..'
import { infiniteQueryBehavior } from '../infiniteQueryBehavior'
import type { InfiniteData, InfiniteQueryObserverResult, QueryCache } from '..'
import type { InfiniteData, InfiniteQueryObserverResult } from '..'

describe('InfiniteQueryBehavior', () => {
let queryClient: QueryClient
let queryCache: QueryCache

beforeEach(() => {
vi.useFakeTimers()
queryClient = new QueryClient()
queryCache = queryClient.getQueryCache()
queryClient.mount()
})

Expand Down Expand Up @@ -39,10 +37,9 @@ describe('InfiniteQueryBehavior', () => {
})

await vi.advanceTimersByTimeAsync(0)
const query = queryCache.find({ queryKey: key })!
expect(observerResult).toMatchObject({
isError: true,
error: new Error(`Missing queryFn: '${query.queryHash}'`),
error: new Error(`Missing queryFn: '${JSON.stringify(key)}'`),
})

unsubscribe()
Expand Down
3 changes: 1 addition & 2 deletions packages/query-core/src/__tests__/query.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -861,10 +861,9 @@ describe('query', () => {
const unsubscribe = observer.subscribe(() => undefined)

await vi.advanceTimersByTimeAsync(10)
const query = queryCache.find({ queryKey: key })!
expect(observer.getCurrentResult()).toMatchObject({
status: 'error',
error: new Error(`Missing queryFn: '${query.queryHash}'`),
error: new Error(`Missing queryFn: '${JSON.stringify(key)}'`),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't describeKey be used here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just so we're not inadvertently also testing describeKey. Ideally this wouldn't even use stringify, it'd be a snapshot or something

})
unsubscribe()
})
Expand Down
12 changes: 6 additions & 6 deletions packages/query-core/src/__tests__/utils.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
skipToken,
} from '../utils'
import { Mutation } from '../mutation'
import type { QueryFunctionContext } from '..'
import type { QueryFunctionContext, QueryKey } from '..'

describe('core/utils', () => {
describe('hashQueryKeyByOptions', () => {
Expand Down Expand Up @@ -543,9 +543,9 @@ describe('core/utils', () => {

describe('hashKey', () => {
it('should hash primitives correctly', () => {
expect(hashKey(['test'])).toEqual(JSON.stringify(['test']))
expect(hashKey([123])).toEqual(JSON.stringify([123]))
expect(hashKey([null])).toEqual(JSON.stringify([null]))
expect(hashKey(['test'])).toEqual('@"test",')
expect(hashKey([123])).toEqual('@123,')
expect(hashKey([null])).toEqual('@null,')
})

it('should hash objects with sorted keys consistently', () => {
Expand All @@ -556,7 +556,7 @@ describe('core/utils', () => {
const hash2 = hashKey(key2)

expect(hash1).toEqual(hash2)
expect(hash1).toEqual(JSON.stringify([{ a: 1, b: 2 }]))
expect(hash1).toEqual('@#b:2,a:1,,')
})

it('should hash arrays consistently', () => {
Expand Down Expand Up @@ -641,7 +641,7 @@ describe('core/utils', () => {

const resolved = ensureQueryFn({
queryFn: skipToken,
queryHash: '["skip"]',
queryKey: ['skip'] as QueryKey,
})

expect(consoleErrorSpy).toHaveBeenCalledWith(
Expand Down
4 changes: 2 additions & 2 deletions packages/query-core/src/hydration.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { tryResolveSync } from './thenable'
import { noop } from './utils'
import { describeKey, noop } from './utils'
import type {
DefaultError,
MutationKey,
Expand Down Expand Up @@ -89,7 +89,7 @@ function dehydrateQuery(
// If not in production, log original error before rejecting redacted error
if (process.env.NODE_ENV !== 'production') {
console.error(
`A query that was dehydrated as pending ended up rejecting. [${query.queryHash}]: ${error}; The error will be redacted in production builds`,
`A query that was dehydrated as pending ended up rejecting. [${describeKey(query.queryKey)}]: ${error}; The error will be redacted in production builds`,
)
}
return Promise.reject(new Error('redacted'))
Expand Down
5 changes: 3 additions & 2 deletions packages/query-core/src/query.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
describeKey,
ensureQueryFn,
noop,
replaceData,
Expand Down Expand Up @@ -569,10 +570,10 @@ export class Query<
if (data === undefined) {
if (process.env.NODE_ENV !== 'production') {
console.error(
`Query data cannot be undefined. Please make sure to return a value other than undefined from your query function. Affected query key: ${this.queryHash}`,
`Query data cannot be undefined. Please make sure to return a value other than undefined from your query function. Affected query key: ${describeKey(this.queryKey)}`,
)
}
throw new Error(`${this.queryHash} data is undefined`)
throw new Error(`${describeKey(this.queryKey)} data is undefined`)
}

this.setData(data)
Expand Down
34 changes: 21 additions & 13 deletions packages/query-core/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import stableHash from 'stable-hash';
import { timeoutManager } from './timeoutManager'
import type {
DefaultError,
Expand Down Expand Up @@ -230,16 +231,22 @@ export function hashQueryKeyByOptions<TQueryKey extends QueryKey = QueryKey>(
* Hashes the value into a stable hash.
*/
export function hashKey(queryKey: QueryKey | MutationKey): string {
return JSON.stringify(queryKey, (_, val) =>
isPlainObject(val)
? Object.keys(val)
.sort()
.reduce((result, key) => {
result[key] = val[key]
return result
}, {} as any)
: val,
)
return stableHash(queryKey)
}

/**
* Renders a key as a human readable string for logging and error messages.
* Keys are not guaranteed to be JSON serializable, so falls back to the hash.
*/
export function describeKey(key: QueryKey | MutationKey | undefined): string {
if (key === undefined) {
return 'undefined'
}
try {
return JSON.stringify(key)
} catch {
return hashKey(key)
}
}

/**
Expand Down Expand Up @@ -396,7 +403,7 @@ export function replaceData<
return replaceEqualDeep(prevData, data)
} catch (error) {
console.error(
`Structural sharing requires data to be JSON serializable. To fix this, turn off structuralSharing or return JSON-serializable data from your queryFn. [${options.queryHash}]: ${error}`,
`Structural sharing requires data to be JSON serializable. To fix this, turn off structuralSharing or return JSON-serializable data from your queryFn. [${describeKey(options.queryKey)}]: ${error}`,
)

// Prevent the replaceEqualDeep from being called again down below.
Expand Down Expand Up @@ -435,13 +442,14 @@ export function ensureQueryFn<
options: {
queryFn?: QueryFunction<TQueryFnData, TQueryKey> | SkipToken
queryHash?: string
queryKey?: TQueryKey
},
fetchOptions?: FetchOptions<TQueryFnData>,
): QueryFunction<TQueryFnData, TQueryKey> {
if (process.env.NODE_ENV !== 'production') {
if (options.queryFn === skipToken) {
console.error(
`Attempted to invoke queryFn when set to skipToken. This is likely a configuration error. Query hash: '${options.queryHash}'`,
`Attempted to invoke queryFn when set to skipToken. This is likely a configuration error. Query key: '${describeKey(options.queryKey)}'`,
)
}
}
Expand All @@ -455,7 +463,7 @@ export function ensureQueryFn<

if (!options.queryFn || options.queryFn === skipToken) {
return () =>
Promise.reject(new Error(`Missing queryFn: '${options.queryHash}'`))
Promise.reject(new Error(`Missing queryFn: '${describeKey(options.queryKey)}'`))
}

return options.queryFn
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.