Repository: tannerlinsley/react-query (ts)

Issue:
placeholderData is not transformed by select
**Describe the bug**

`placeholderData` passed to `useQuery`'s option is not transformed by `select` when it exists.

**To Reproduce**

https://codesandbox.io/s/react-query-placeholderdata-with-select-dsvxl


`data.toString()` renders `2020-02-18T15:29:59.326Z` at first.
After 1 second, it renders formatted date like `Fri Feb 19 2021 00:29:59 GMT+0900` as `data` is transformed to Date object by `select` function.

Then replace `placeholderData` to `initialData`. It shows `Wed Feb 19 2020 00:29:59 GMT+0900` and  1 second later `Fri Feb 19 2021 00:29:59 GMT+0900` as both are Date object.

**Expected behavior**

It seems `placeholderData` should be transformed by `select` to keep return type of `data` like `initialData`.


Pull request description:
Feature/1824 transform placeholder data with select
closes #1824 

Patch:
diff --git a/docs/src/pages/guides/default-query-function.md b/docs/src/pages/guides/default-query-function.md
index d335ed78a..0ec3364c1 100644
--- a/docs/src/pages/guides/default-query-function.md
+++ b/docs/src/pages/guides/default-query-function.md
@@ -7,6 +7,7 @@ If you find yourself wishing for whatever reason that you could just share the s
 
 ```js
 // Define a default query function that will receive the query key
+// the queryKey is guaranteed to be an Array here
 const defaultQueryFn = async ({ queryKey }) => {
   const { data } = await axios.get(`https://jsonplaceholder.typicode.com${queryKey[0]}`);
   return data;
diff --git a/docs/src/pages/guides/initial-query-data.md b/docs/src/pages/guides/initial-query-data.md
index 8466322f0..05d075e77 100644
--- a/docs/src/pages/guides/initial-query-data.md
+++ b/docs/src/pages/guides/initial-query-data.md
@@ -103,7 +103,7 @@ Getting initial data from the cache means the source query you're using to look
 
 ```js
 function Todo({ todoId }) {
-  const result = useQuery(['todo', todoId], () => fetch('/todos'), {
+  const result = useQuery(['todo', todoId], () => fetch(`/todos/${todoId}`), {
     initialData: () =>
       queryClient.getQueryData('todos')?.find(d => d.id === todoId),
     initialDataUpdatedAt: () =>
@@ -118,7 +118,7 @@ If the source query you're using to look up the initial data from is old, you ma
 
 ```js
 function Todo({ todoId }) {
-  const result = useQuery(['todo', todoId], () => fetch('/todos'), {
+  const result = useQuery(['todo', todoId], () => fetch(`/todos/${todoId}`), {
     initialData: () => {
       // Get the query state
       const state = queryClient.getQueryState('todos')
diff --git a/docs/src/pages/guides/placeholder-query-data.md b/docs/src/pages/guides/placeholder-query-data.md
index 82d767abf..754de6d4e 100644
--- a/docs/src/pages/guides/placeholder-query-data.md
+++ b/docs/src/pages/guides/placeholder-query-data.md
@@ -12,7 +12,7 @@ Placeholder data allows a query to behave as if it already has data, similar to
 There are a few ways to supply placeholder data for a query to the cache before you need it:
 
 - Declaratively:
-  - Provide `placeholderData` to a query to prepopulate the its cache if empty
+  - Provide `placeholderData` to a query to prepopulate its cache if empty
 - Imperatively:
   - [Prefetch or fetch the data using `queryClient` and the `placeholderData` option](./prefetching)
 
diff --git a/docs/src/pages/reference/useInfiniteQuery.md b/docs/src/pages/reference/useInfiniteQuery.md
index 77d9198b7..e9fb6b51c 100644
--- a/docs/src/pages/reference/useInfiniteQuery.md
+++ b/docs/src/pages/reference/useInfiniteQuery.md
@@ -27,9 +27,9 @@ The options for `useInfiniteQuery` are identical to the [`useQuery` hook](/refer
   - **Required, but only if no default query function has been defined** [`defaultQueryFn`](/guides/default-query-function)
   - The function that the query will use to request data.
   - Receives a `QueryFunctionContext` object with the following variables:
-    - `queryKey: QueryKey`
+    - `queryKey: EnsuredQueryKey`: the queryKey, guaranteed to be an Array
     - `pageParam: unknown | undefined`
-  - Must return a promise that will either resolves data or throws an error.
+  - Must return a promise that will either resolve data or throw an error.
   - Make sure you return the data *and* the `pageParam` if needed for use in the props below.
 - `getNextPageParam: (lastPage, allPages) => unknown | undefined`
   - When new data is received for this query, this function receives both the last page of the infinite list of data and the full array of all pages.
diff --git a/docs/src/pages/reference/useQuery.md b/docs/src/pages/reference/useQuery.md
index dac5a0ac6..d62f8bd73 100644
--- a/docs/src/pages/reference/useQuery.md
+++ b/docs/src/pages/reference/useQuery.md
@@ -73,8 +73,8 @@ const result = useQuery({
   - **Required, but only if no default query function has been defined** See [Default Query Function](../guides/default-query-function) for more information.
   - The function that the query will use to request data.
   - Receives a `QueryFunctionContext` object with the following variables:
-    - `queryKey: QueryKey`
-  - Must return a promise that will either resolves data or throws an error.
+    - `queryKey: EnsuredQueryKey`: the queryKey, guaranteed to be an Array
+  - Must return a promise that will either resolve data or throw an error.
 - `enabled: boolean`
   - Set this to `false` to disable this query from automatically running.
   - Can be used for [Dependent Queries](../guides/dependent-queries).
@@ -216,7 +216,7 @@ const result = useQuery({
   - Will be `true` if the query has been fetched after the component mounted.
   - This property can be used to not show any previously cached data.
 - `isFetching: boolean`
-  - Defaults to `true` so long as `enabled` is set to `false`
+  - Is `true` whenever a request is in-flight, which includes initial `loading` as well as background refetches.
   - Will be `true` if the query is currently fetching, including background fetching.
 - `failureCount: number`
   - The failure count for the query.
diff --git a/src/core/query.ts b/src/core/query.ts
index 10394221a..77c847656 100644
--- a/src/core/query.ts
+++ b/src/core/query.ts
@@ -5,6 +5,7 @@ import {
   noop,
   replaceEqualDeep,
   timeUntilStale,
+  ensureQueryKeyArray,
 } from './utils'
 import type {
   InitialDataFunction,
@@ -12,6 +13,7 @@ import type {
   QueryOptions,
   QueryStatus,
   QueryFunctionContext,
+  EnsuredQueryKey,
 } from './types'
 import type { QueryCache } from './queryCache'
 import type { QueryObserver } from './queryObserver'
@@ -58,8 +60,8 @@ export interface FetchContext<
 > {
   fetchFn: () => unknown | Promise<unknown>
   fetchOptions?: FetchOptions
-  options: QueryOptions<TQueryFnData, TError, TData, TQueryKey>
-  queryKey: TQueryKey
+  options: QueryOptions<TQueryFnData, TError, TData, any>
+  queryKey: EnsuredQueryKey<TQueryKey>
   state: QueryState<TData, TError>
 }
 
@@ -380,9 +382,11 @@ export class Query<
       }
     }
 
+    const queryKey = ensureQueryKeyArray(this.queryKey)
+
     // Create query function context
     const queryFnContext: QueryFunctionContext<TQueryKey> = {
-      queryKey: this.queryKey,
+      queryKey,
       pageParam: undefined,
     }
 
@@ -393,10 +397,10 @@ export class Query<
         : Promise.reject('Missing queryFn')
 
     // Trigger behavior hook
-    const context: FetchContext<TQueryFnData, TError, TData, any> = {
+    const context: FetchContext<TQueryFnData, TError, TData, TQueryKey> = {
       fetchOptions,
       options: this.options,
-      queryKey: this.queryKey,
+      queryKey: queryKey,
       state: this.state,
       fetchFn,
     }
diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts
index d8e8dd571..cc1cd3e85 100644
--- a/src/core/queryObserver.ts
+++ b/src/core/queryObserver.ts
@@ -507,13 +507,31 @@ export class QueryObserver<
       } else {
         placeholderData =
           typeof options.placeholderData === 'function'
-            ? (options.placeholderData as PlaceholderDataFunction<TData>)()
+            ? (options.placeholderData as PlaceholderDataFunction<TQueryData>)()
             : options.placeholderData
+        if (options.select && typeof placeholderData !== 'undefined') {
+          try {
+            placeholderData = options.select(placeholderData)
+            if (options.structuralSharing !== false) {
+              placeholderData = replaceEqualDeep(
+                prevResult?.data,
+                placeholderData
+              )
+            }
+            this.previousSelectError = null
+          } catch (selectError) {
+            getLogger().error(selectError)
+            error = selectError
+            this.previousSelectError = selectError
+            errorUpdatedAt = Date.now()
+            status = 'error'
+          }
+        }
       }
 
       if (typeof placeholderData !== 'undefined') {
         status = 'success'
-        data = placeholderData
+        data = placeholderData as TData
         isPlaceholderData = true
       }
     }
diff --git a/src/core/types.ts b/src/core/types.ts
index 09429a507..ba3fafd52 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -4,6 +4,9 @@ import type { RetryValue, RetryDelayValue } from './retryer'
 import type { QueryFilters } from './utils'
 
 export type QueryKey = string | readonly unknown[]
+export type EnsuredQueryKey<T extends QueryKey> = T extends string
+  ? [T]
+  : Exclude<T, string>
 
 export type QueryFunction<
   T = unknown,
@@ -14,14 +17,12 @@ export interface QueryFunctionContext<
   TQueryKey extends QueryKey = QueryKey,
   TPageParam = any
 > {
-  queryKey: TQueryKey
+  queryKey: EnsuredQueryKey<TQueryKey>
   pageParam?: TPageParam
 }
 
 export type InitialDataFunction<T> = () => T | undefined
 
-export type InitialStaleFunction = () => boolean
-
 export type PlaceholderDataFunction<TResult> = () => TResult | undefined
 
 export type QueryKeyHashFunction<TQueryKey extends QueryKey> = (
@@ -183,7 +184,7 @@ export interface QueryObserverOptions<
   /**
    * If set, this value will be used as the placeholder data for this particular query observer while the query is still in the `loading` data and no initialData has been provided.
    */
-  placeholderData?: TData | PlaceholderDataFunction<TData>
+  placeholderData?: TQueryData | PlaceholderDataFunction<TQueryData>
   /**
    * If set, the observer will optimistically set the result in fetching state before the query has actually started fetching.
    * This is to make sure the results are not lagging behind.
diff --git a/src/core/utils.ts b/src/core/utils.ts
index eef6deaa8..35b1a85db 100644
--- a/src/core/utils.ts
+++ b/src/core/utils.ts
@@ -1,5 +1,6 @@
 import type { Mutation } from './mutation'
 import type { Query } from './query'
+import { EnsuredQueryKey } from './types'
 import type {
   MutationFunction,
   MutationKey,
@@ -88,8 +89,12 @@ export function isValidTimeout(value: any): value is number {
   return typeof value === 'number' && value >= 0 && value !== Infinity
 }
 
-export function ensureArray<T>(value: T | T[]): T[] {
-  return Array.isArray(value) ? value : [value]
+export function ensureQueryKeyArray<T extends QueryKey>(
+  value: T
+): EnsuredQueryKey<T> {
+  return (Array.isArray(value)
+    ? value
+    : ([value] as unknown)) as EnsuredQueryKey<T>
 }
 
 export function difference<T>(array1: T[], array2: T[]): T[] {
@@ -256,7 +261,7 @@ export function hashQueryKeyByOptions<TQueryKey extends QueryKey = QueryKey>(
  * Default query keys hash function.
  */
 export function hashQueryKey(queryKey: QueryKey): string {
-  const asArray = Array.isArray(queryKey) ? queryKey : [queryKey]
+  const asArray = ensureQueryKeyArray(queryKey)
   return stableValueHash(asArray)
 }
 
@@ -280,7 +285,7 @@ export function stableValueHash(value: any): string {
  * Checks if key `b` partially matches with key `a`.
  */
 export function partialMatchKey(a: QueryKey, b: QueryKey): boolean {
-  return partialDeepEqual(ensureArray(a), ensureArray(b))
+  return partialDeepEqual(ensureQueryKeyArray(a), ensureQueryKeyArray(b))
 }
 
 /**


Test patch:
diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx
index dc8753251..3c752b7e3 100644
--- a/src/react/tests/useQuery.test.tsx
+++ b/src/react/tests/useQuery.test.tsx
@@ -79,7 +79,7 @@ describe('useQuery', () => {
       type MyData = number
       type MyQueryKey = readonly ['my-data', number]
 
-      const getMyData: QueryFunction<MyData, MyQueryKey> = async ({
+      const getMyDataArrayKey: QueryFunction<MyData, MyQueryKey> = async ({
         queryKey: [, n],
       }) => {
         return n + 42
@@ -87,7 +87,17 @@ describe('useQuery', () => {
 
       useQuery({
         queryKey: ['my-data', 100],
-        queryFn: getMyData,
+        queryFn: getMyDataArrayKey,
+      })
+
+      const getMyDataStringKey: QueryFunction<MyData, '1'> = async context => {
+        expectType<['1']>(context.queryKey)
+        return Number(context.queryKey[0]) + 42
+      }
+
+      useQuery({
+        queryKey: '1',
+        queryFn: getMyDataStringKey,
       })
     }
   })
@@ -3452,6 +3462,95 @@ describe('useQuery', () => {
     ])
   })
 
+  it('placeholder data should run through select', async () => {
+    const key1 = queryKey()
+
+    const states: UseQueryResult<string>[] = []
+
+    function Page() {
+      const state = useQuery(key1, () => 1, {
+        placeholderData: 23,
+        select: data => String(data * 2),
+      })
+
+      states.push(state)
+
+      return (
+        <div>
+          <h2>Data: {state.data}</h2>
+          <div>Status: {state.status}</div>
+        </div>
+      )
+    }
+
+    const rendered = renderWithClient(queryClient, <Page />)
+    await waitFor(() => rendered.getByText('Data: 2'))
+
+    expect(states).toMatchObject([
+      {
+        isSuccess: true,
+        isPlaceholderData: true,
+        data: '46',
+      },
+      {
+        isSuccess: true,
+        isPlaceholderData: false,
+        data: '2',
+      },
+    ])
+  })
+
+  it('placeholder data function result should run through select', async () => {
+    const key1 = queryKey()
+
+    const states: UseQueryResult<string>[] = []
+    let placeholderFunctionRunCount = 0
+
+    function Page() {
+      const state = useQuery(key1, () => 1, {
+        placeholderData: () => {
+          placeholderFunctionRunCount++
+          return 23
+        },
+        select: data => String(data * 2),
+      })
+
+      states.push(state)
+
+      return (
+        <div>
+          <h2>Data: {state.data}</h2>
+          <div>Status: {state.status}</div>
+        </div>
+      )
+    }
+
+    const rendered = renderWithClient(queryClient, <Page />)
+    await waitFor(() => rendered.getByText('Data: 2'))
+
+    rendered.rerender(<Page />)
+
+    expect(states).toMatchObject([
+      {
+        isSuccess: true,
+        isPlaceholderData: true,
+        data: '46',
+      },
+      {
+        isSuccess: true,
+        isPlaceholderData: false,
+        data: '2',
+      },
+      {
+        isSuccess: true,
+        isPlaceholderData: false,
+        data: '2',
+      },
+    ])
+
+    expect(placeholderFunctionRunCount).toEqual(1)
+  })
+
   it('should cancel the query function when there are no more subscriptions', async () => {
     const key = queryKey()
     let cancelFn: jest.Mock = jest.fn()