Repository: tannerlinsley/react-query (ts)

Issue:
Improve aria-tagging of Devtools
**Describe the bug**

1. Devtools is now defaulting to being rendered as `footer`, which doesn't make a lot of sense to me.
2. Menu-items for sorting seem to be rendered and visible in the Accessibility Tree regardless of open/closed state.
3. The Devtools toggle-button have no aria-attributes for toggled state or link to the panel it toggles.

**To Reproduce**
Steps to reproduce the behavior:
1. Render React-Query Devtools in an app
2. Explore the DOM tree and the Accessibility tree

**Expected behavior**
1. A better default should probably be `aside`, and it should also have an `aria-label` (like "React Query Devtools" for the landmark it generates.
2. Not sure if this is an actual issue, or just how the accessibility tree works, but I'm thinking the panel should maybe either not be rendered at all, or be rendered with `aria-hidden` or something like that when not opened?
3. Toggle-button should have appropriate aria attributes (`aria-controls`, `aria-expanded` and be linked to the panel as it should.

**Screenshots**
In the screenshot below, the React Query Devtools "root" is the generic `contentinfo` node
![image](https://user-images.githubusercontent.com/142162/140752640-7a6d35bb-35fc-4859-9b63-a06b02418602.png)
Changing the element-type to `aside` and adding an `aria-label` (just edited the HTML in Chrome for this) gives this instead, which to me at least looks more appropriate:
![image](https://user-images.githubusercontent.com/142162/140754113-689f949b-77b3-4e51-9609-16a492b660b1.png)


Pull request description:
Fix/2901 improve devtools a11y
Closes #2901 

## Test plan

Devtools is rendered as `aside` by default
![image](https://user-images.githubusercontent.com/52666982/141731680-b321b781-856c-4202-ada1-3f9e6bd70182.png)

Button aria: expanded
![image](https://user-images.githubusercontent.com/52666982/141730212-d7042fef-6bed-47be-b13f-2e8fad8b03b3.png)

Button aria: collapsed
![image](https://user-images.githubusercontent.com/52666982/141730216-97d493ea-8740-4a78-9dca-f6babc6aaa40.png)

Hide `menuitem` when Devtools is closed 
![image](https://user-images.githubusercontent.com/52666982/141730226-e5ca490a-62ea-4dbf-9054-21d131c4fff3.png)


Patch:
diff --git a/docs/src/components/LayoutDocs.js b/docs/src/components/LayoutDocs.js
index cc229a70c..d890d5d94 100644
--- a/docs/src/components/LayoutDocs.js
+++ b/docs/src/components/LayoutDocs.js
@@ -106,7 +106,7 @@ export const LayoutDocs = props => {
                       width: 200,
                     }}
                   >
-                    <div className="sticky top-24 overflow-y-auto">
+                    <div className="sticky top-24">
                       <h4 className="font-semibold uppercase text-sm mb-2 mt-2 text-gray-500">
                         On this page
                       </h4>
diff --git a/docs/src/pages/guides/query-cancellation.md b/docs/src/pages/guides/query-cancellation.md
index 4e228aa52..2c00fe162 100644
--- a/docs/src/pages/guides/query-cancellation.md
+++ b/docs/src/pages/guides/query-cancellation.md
@@ -97,6 +97,19 @@ const query = useQuery('todos', ({ signal }) => {
 })
 ```
 
+## Using `graphql-request`
+
+An `AbortSignal` can be set in the `GraphQLClient` constructor.
+
+```js
+const query = useQuery('todos', ({ signal }) => {
+  const client = new GraphQLClient(endpoint, {
+    signal,
+  });
+  return client.request(query, variables)
+})
+```
+
 ## Manual Cancellation
 
 You might want to cancel a query manually. For example, if the request takes a long time to finish, you can allow the user to click a cancel button to stop the request. To do this, you just need to call `queryClient.cancelQueries(key)`, which will cancel the query and revert it back to its previous state. If `promise.cancel` is available, or you have consumed the `signal` passed to the query function, React Query will additionally also cancel the Promise.
diff --git a/docs/src/pages/reference/useMutation.md b/docs/src/pages/reference/useMutation.md
index 1750c9c14..e8b2e9cdc 100644
--- a/docs/src/pages/reference/useMutation.md
+++ b/docs/src/pages/reference/useMutation.md
@@ -23,6 +23,7 @@ const {
   onSettled,
   onSuccess,
   useErrorBoundary,
+  meta,
 })
 
 mutate(variables, {
@@ -71,6 +72,9 @@ mutate(variables, {
   - Set this to `true` if you want mutation errors to be thrown in the render phase and propagate to the nearest error boundary
   - Set this to `false` to disable the behavior of throwing errors to the error boundary.
   - If set to a function, it will be passed the error and should return a boolean indicating whether to show the error in an error boundary (`true`) or return the error as state (`false`)
+- `meta: Record<string, unknown>`
+  - Optional
+  - If set, stores additional information on the mutation cache entry that can be used as needed. It will be accessible wherever the `mutation` is available (eg. `onError`, `onSuccess` functions of the `MutationCache`).
 
 **Returns**
 
diff --git a/src/core/mutation.ts b/src/core/mutation.ts
index 54ba5473a..3846e60ba 100644
--- a/src/core/mutation.ts
+++ b/src/core/mutation.ts
@@ -1,4 +1,4 @@
-import type { MutationOptions, MutationStatus } from './types'
+import type { MutationOptions, MutationStatus, MutationMeta } from './types'
 import type { MutationCache } from './mutationCache'
 import type { MutationObserver } from './mutationObserver'
 import { getLogger } from './logger'
@@ -14,6 +14,7 @@ interface MutationConfig<TData, TError, TVariables, TContext> {
   options: MutationOptions<TData, TError, TVariables, TContext>
   defaultOptions?: MutationOptions<TData, TError, TVariables, TContext>
   state?: MutationState<TData, TError, TVariables, TContext>
+  meta?: MutationMeta
 }
 
 export interface MutationState<
@@ -84,6 +85,7 @@ export class Mutation<
   state: MutationState<TData, TError, TVariables, TContext>
   options: MutationOptions<TData, TError, TVariables, TContext>
   mutationId: number
+  meta: MutationMeta | undefined
 
   private observers: MutationObserver<TData, TError, TVariables, TContext>[]
   private mutationCache: MutationCache
@@ -98,6 +100,7 @@ export class Mutation<
     this.mutationCache = config.mutationCache
     this.observers = []
     this.state = config.state || getDefaultState()
+    this.meta = config.meta
   }
 
   setState(state: MutationState<TData, TError, TVariables, TContext>): void {
diff --git a/src/core/mutationCache.ts b/src/core/mutationCache.ts
index 02b97b19b..eef98e3ec 100644
--- a/src/core/mutationCache.ts
+++ b/src/core/mutationCache.ts
@@ -52,6 +52,7 @@ export class MutationCache extends Subscribable<MutationCacheListener> {
       defaultOptions: options.mutationKey
         ? client.getMutationDefaults(options.mutationKey)
         : undefined,
+      meta: options.meta,
     })
 
     this.add(mutation)
diff --git a/src/core/types.ts b/src/core/types.ts
index 2aa3dda46..583b34a15 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -501,6 +501,8 @@ export type MutationKey = string | readonly unknown[]
 
 export type MutationStatus = 'idle' | 'loading' | 'success' | 'error'
 
+export type MutationMeta = Record<string, unknown>
+
 export type MutationFunction<TData = unknown, TVariables = unknown> = (
   variables: TVariables
 ) => Promise<TData>
@@ -536,6 +538,7 @@ export interface MutationOptions<
   retry?: RetryValue<TError>
   retryDelay?: RetryDelayValue<TError>
   _defaulted?: boolean
+  meta?: MutationMeta
 }
 
 export interface MutationObserverOptions<
diff --git a/src/devtools/devtools.tsx b/src/devtools/devtools.tsx
index af098e597..00a226de1 100644
--- a/src/devtools/devtools.tsx
+++ b/src/devtools/devtools.tsx
@@ -55,7 +55,7 @@ interface DevtoolsOptions {
   /**
    * Use this to render the devtools inside a different type of container element for a11y purposes.
    * Any string which corresponds to a valid intrinsic JSX element is allowed.
-   * Defaults to 'footer'.
+   * Defaults to 'aside'.
    */
   containerElement?: string | any
 }
@@ -91,7 +91,7 @@ export function ReactQueryDevtools({
   closeButtonProps = {},
   toggleButtonProps = {},
   position = 'bottom-left',
-  containerElement: Container = 'footer',
+  containerElement: Container = 'aside',
 }: DevtoolsOptions): React.ReactElement | null {
   const rootRef = React.useRef<HTMLDivElement>(null)
   const panelRef = React.useRef<HTMLDivElement>(null)
@@ -221,7 +221,11 @@ export function ReactQueryDevtools({
   if (!isMounted()) return null
 
   return (
-    <Container ref={rootRef} className="ReactQueryDevtools">
+    <Container
+      ref={rootRef}
+      className="ReactQueryDevtools"
+      aria-label="React Query Devtools"
+    >
       <ThemeProvider theme={theme}>
         <ReactQueryDevtoolsPanel
           ref={panelRef as any}
@@ -265,6 +269,9 @@ export function ReactQueryDevtools({
           <Button
             type="button"
             aria-label="Close React Query Devtools"
+            aria-controls="ReactQueryDevtoolsPanel"
+            aria-haspopup="true"
+            aria-expanded="true"
             {...(otherCloseButtonProps as unknown)}
             onClick={e => {
               setIsOpen(false)
@@ -302,6 +309,9 @@ export function ReactQueryDevtools({
           type="button"
           {...otherToggleButtonProps}
           aria-label="Open React Query Devtools"
+          aria-controls="ReactQueryDevtoolsPanel"
+          aria-haspopup="true"
+          aria-expanded="false"
           onClick={e => {
             setIsOpen(true)
             onToggleClick && onToggleClick(e)
@@ -449,7 +459,13 @@ export const ReactQueryDevtoolsPanel = React.forwardRef<
 
   return (
     <ThemeProvider theme={theme}>
-      <Panel ref={ref} className="ReactQueryDevtoolsPanel" {...panelProps}>
+      <Panel
+        ref={ref}
+        className="ReactQueryDevtoolsPanel"
+        aria-label="React Query Devtools Panel"
+        id="ReactQueryDevtoolsPanel"
+        {...panelProps}
+      >
         <style
           dangerouslySetInnerHTML={{
             __html: `
@@ -494,7 +510,7 @@ export const ReactQueryDevtoolsPanel = React.forwardRef<
             maxHeight: '100%',
             overflow: 'auto',
             borderRight: `1px solid ${theme.grayAlt}`,
-            display: 'flex',
+            display: isOpen ? 'flex' : 'none',
             flexDirection: 'column',
           }}
         >
diff --git a/src/react/types.ts b/src/react/types.ts
index fce406915..79aa3d450 100644
--- a/src/react/types.ts
+++ b/src/react/types.ts
@@ -9,6 +9,7 @@ import {
   QueryKey,
   MutationFunction,
   MutateOptions,
+  MutationMeta,
 } from '../core/types'
 
 export interface UseBaseQueryOptions<
@@ -97,6 +98,7 @@ export interface UseMutationOptions<
   retry?: RetryValue<TError>
   retryDelay?: RetryDelayValue<TError>
   useErrorBoundary?: boolean | ((error: TError) => boolean)
+  meta?: MutationMeta
 }
 
 export type UseMutateFunction<


Test patch:
diff --git a/src/react/tests/useMutation.test.tsx b/src/react/tests/useMutation.test.tsx
index b253fd235..f4c25e099 100644
--- a/src/react/tests/useMutation.test.tsx
+++ b/src/react/tests/useMutation.test.tsx
@@ -519,4 +519,68 @@ describe('useMutation', () => {
 
     consoleMock.mockRestore()
   })
+
+  it('should pass meta to mutation', async () => {
+    const consoleMock = mockConsoleError()
+
+    const errorMock = jest.fn()
+    const successMock = jest.fn()
+
+    const queryClientMutationMeta = new QueryClient({
+      mutationCache: new MutationCache({
+        onSuccess: (_, __, ___, mutation) => {
+          successMock(mutation.meta?.metaSuccessMessage)
+        },
+        onError: (_, __, ___, mutation) => {
+          errorMock(mutation.meta?.metaErrorMessage)
+        },
+      }),
+    })
+
+    const metaSuccessMessage = 'mutation succeeded'
+    const metaErrorMessage = 'mutation failed'
+
+    function Page() {
+      const { mutate: succeed, isSuccess } = useMutation(async () => '', {
+        meta: { metaSuccessMessage },
+      })
+      const { mutate: error, isError } = useMutation(
+        async () => {
+          throw new Error('')
+        },
+        {
+          meta: { metaErrorMessage },
+        }
+      )
+
+      return (
+        <div>
+          <button onClick={() => succeed()}>succeed</button>
+          <button onClick={() => error()}>error</button>
+          {isSuccess && <div>successTest</div>}
+          {isError && <div>errorTest</div>}
+        </div>
+      )
+    }
+
+    const { getByText, queryByText } = renderWithClient(
+      queryClientMutationMeta,
+      <Page />
+    )
+
+    fireEvent.click(getByText('succeed'))
+    fireEvent.click(getByText('error'))
+
+    await waitFor(() => {
+      expect(queryByText('successTest')).not.toBeNull()
+      expect(queryByText('errorTest')).not.toBeNull()
+    })
+
+    expect(successMock).toHaveBeenCalledTimes(1)
+    expect(successMock).toHaveBeenCalledWith(metaSuccessMessage)
+    expect(errorMock).toHaveBeenCalledTimes(1)
+    expect(errorMock).toHaveBeenCalledWith(metaErrorMessage)
+
+    consoleMock.mockRestore()
+  })
 })