@tanstack/react-query-devtools 4.26.1 → 4.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/react-query-devtools",
3
- "version": "4.26.1",
3
+ "version": "4.27.0",
4
4
  "description": "Developer tools to interact with and visualize the TanStack/react-query cache",
5
5
  "author": "tannerlinsley",
6
6
  "license": "MIT",
@@ -46,7 +46,7 @@
46
46
  "react-dom": "^18.2.0",
47
47
  "react-dom-17": "npm:react-dom@^17.0.2",
48
48
  "react-error-boundary": "^3.1.4",
49
- "@tanstack/react-query": "4.26.1"
49
+ "@tanstack/react-query": "4.27.0"
50
50
  },
51
51
  "dependencies": {
52
52
  "@tanstack/match-sorter-utils": "^8.7.0",
@@ -56,7 +56,7 @@
56
56
  "peerDependencies": {
57
57
  "react": "^16.8.0 || ^17.0.0 || ^18.0.0",
58
58
  "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0",
59
- "@tanstack/react-query": "4.26.1"
59
+ "@tanstack/react-query": "4.27.0"
60
60
  },
61
61
  "scripts": {
62
62
  "clean": "rimraf ./build",
@@ -11,6 +11,7 @@ import {
11
11
  sleep,
12
12
  createQueryClient,
13
13
  } from './utils'
14
+ import UserEvent from '@testing-library/user-event'
14
15
 
15
16
  // TODO: This should be removed with the types for react-error-boundary get updated.
16
17
  declare module 'react-error-boundary' {
@@ -19,6 +20,13 @@ declare module 'react-error-boundary' {
19
20
  }
20
21
  }
21
22
 
23
+ class CustomError extends Error {
24
+ constructor(message: string) {
25
+ super(message)
26
+ this.name = 'CustomError'
27
+ }
28
+ }
29
+
22
30
  Object.defineProperty(window, 'matchMedia', {
23
31
  writable: true,
24
32
  value: jest.fn().mockImplementation((query: string) => ({
@@ -915,4 +923,135 @@ describe('ReactQueryDevtools', () => {
915
923
  fireEvent.click(screen.getByRole('button', { name: /^close$/i }))
916
924
  expect(parentElement).toHaveStyle(parentPaddings)
917
925
  })
926
+
927
+ it('should simulate loading state', async () => {
928
+ const { queryClient } = createQueryClient()
929
+ let count = 0
930
+ function App() {
931
+ const { data, fetchStatus } = useQuery(['key'], () => {
932
+ count++
933
+ return Promise.resolve('test')
934
+ })
935
+
936
+ return (
937
+ <div>
938
+ <h1>
939
+ {data ?? 'No data'}, {fetchStatus}
940
+ </h1>
941
+ </div>
942
+ )
943
+ }
944
+
945
+ renderWithClient(queryClient, <App />, {
946
+ initialIsOpen: true,
947
+ })
948
+
949
+ await screen.findByRole('heading', { name: /test/i })
950
+
951
+ const loadingButton = await screen.findByRole('button', {
952
+ name: 'Trigger loading',
953
+ })
954
+ fireEvent.click(loadingButton)
955
+
956
+ await waitFor(() => {
957
+ expect(screen.getByText('Restore loading')).toBeInTheDocument()
958
+ })
959
+
960
+ await waitFor(() => {
961
+ expect(screen.getByText('No data, fetching')).toBeInTheDocument()
962
+ })
963
+
964
+ fireEvent.click(screen.getByRole('button', { name: /restore loading/i }))
965
+
966
+ await waitFor(() => {
967
+ expect(screen.getByText('test, idle')).toBeInTheDocument()
968
+ })
969
+
970
+ expect(count).toBe(2)
971
+ })
972
+
973
+ it('should simulate error state', async () => {
974
+ const { queryClient } = createQueryClient()
975
+ function App() {
976
+ const { status, error } = useQuery(['key'], () => {
977
+ return Promise.resolve('test')
978
+ })
979
+
980
+ return (
981
+ <div>
982
+ <h1>
983
+ {!!error ? 'Some error' : 'No error'}, {status}
984
+ </h1>
985
+ </div>
986
+ )
987
+ }
988
+
989
+ renderWithClient(queryClient, <App />, {
990
+ initialIsOpen: true,
991
+ })
992
+
993
+ const errorButton = await screen.findByRole('button', {
994
+ name: 'Trigger error',
995
+ })
996
+ fireEvent.click(errorButton)
997
+
998
+ await waitFor(() => {
999
+ expect(screen.getByText('Restore error')).toBeInTheDocument()
1000
+ })
1001
+
1002
+ await waitFor(() => {
1003
+ expect(screen.getByText('Some error, error')).toBeInTheDocument()
1004
+ })
1005
+
1006
+ fireEvent.click(screen.getByRole('button', { name: /Restore error/i }))
1007
+
1008
+ await waitFor(() => {
1009
+ expect(screen.getByText('No error, success')).toBeInTheDocument()
1010
+ })
1011
+ })
1012
+
1013
+ it('should can simulate a specific error', async () => {
1014
+ const { queryClient } = createQueryClient()
1015
+
1016
+ function App() {
1017
+ const { status, error } = useQuery(['key'], () => {
1018
+ return Promise.resolve('test')
1019
+ })
1020
+
1021
+ return (
1022
+ <div data-testid="test">
1023
+ <h1>
1024
+ {error instanceof CustomError
1025
+ ? error.message.toString()
1026
+ : 'No error'}
1027
+ , {status}
1028
+ </h1>
1029
+ </div>
1030
+ )
1031
+ }
1032
+
1033
+ renderWithClient(queryClient, <App />, {
1034
+ initialIsOpen: true,
1035
+ errorTypes: [
1036
+ {
1037
+ name: 'error1',
1038
+ initializer: () => new CustomError('error1'),
1039
+ },
1040
+ ],
1041
+ })
1042
+
1043
+ const errorOption = await screen.findByLabelText('Trigger error:')
1044
+
1045
+ UserEvent.selectOptions(errorOption, 'error1')
1046
+
1047
+ await waitFor(() => {
1048
+ expect(screen.getByText('error1, error')).toBeInTheDocument()
1049
+ })
1050
+
1051
+ fireEvent.click(screen.getByRole('button', { name: /Restore error/i }))
1052
+
1053
+ await waitFor(() => {
1054
+ expect(screen.getByText('No error, success')).toBeInTheDocument()
1055
+ })
1056
+ })
918
1057
  })
package/src/devtools.tsx CHANGED
@@ -6,6 +6,7 @@ import type {
6
6
  QueryClient,
7
7
  QueryKey as QueryKeyType,
8
8
  ContextOptions,
9
+ Query,
9
10
  } from '@tanstack/react-query'
10
11
  import {
11
12
  useQueryClient,
@@ -41,6 +42,19 @@ import { ThemeProvider, defaultTheme as theme } from './theme'
41
42
  import { getQueryStatusLabel, getQueryStatusColor } from './utils'
42
43
  import Explorer from './Explorer'
43
44
  import Logo from './Logo'
45
+ import { useMemo } from 'react'
46
+
47
+ export interface DevToolsErrorType {
48
+ /**
49
+ * The name of the error.
50
+ */
51
+ name: string
52
+ /**
53
+ * How the error is initialized. Whatever it returns MUST implement toString() so
54
+ * we can check against the current error.
55
+ */
56
+ initializer: (query: Query) => { toString(): string }
57
+ }
44
58
 
45
59
  export interface DevtoolsOptions extends ContextOptions {
46
60
  /**
@@ -79,6 +93,10 @@ export interface DevtoolsOptions extends ContextOptions {
79
93
  * nonce for style element for CSP
80
94
  */
81
95
  styleNonce?: string
96
+ /**
97
+ * Use this so you can define custom errors that can be shown in the devtools.
98
+ */
99
+ errorTypes?: DevToolsErrorType[]
82
100
  }
83
101
 
84
102
  interface DevtoolsPanelOptions extends ContextOptions {
@@ -123,6 +141,10 @@ interface DevtoolsPanelOptions extends ContextOptions {
123
141
  * Use this to add props to the close button. For example, you can add className, style (merge and override default style), onClick (extend default handler), etc.
124
142
  */
125
143
  closeButtonProps?: React.ComponentPropsWithoutRef<'button'>
144
+ /**
145
+ * Use this so you can define custom errors that can be shown in the devtools.
146
+ */
147
+ errorTypes?: DevToolsErrorType[]
126
148
  }
127
149
 
128
150
  export function ReactQueryDevtools({
@@ -135,6 +157,7 @@ export function ReactQueryDevtools({
135
157
  context,
136
158
  styleNonce,
137
159
  panelPosition: initialPanelPosition = 'bottom',
160
+ errorTypes = [],
138
161
  }: DevtoolsOptions): React.ReactElement | null {
139
162
  const rootRef = React.useRef<HTMLDivElement>(null)
140
163
  const panelRef = React.useRef<HTMLDivElement>(null)
@@ -343,6 +366,7 @@ export function ReactQueryDevtools({
343
366
  isOpen={isResolvedOpen}
344
367
  setIsOpen={setIsOpen}
345
368
  onDragStart={(e) => handleDragStart(panelRef.current, e)}
369
+ errorTypes={errorTypes}
346
370
  />
347
371
  </ThemeProvider>
348
372
  {!isResolvedOpen ? (
@@ -433,6 +457,7 @@ export const ReactQueryDevtoolsPanel = React.forwardRef<
433
457
  showCloseButton,
434
458
  position,
435
459
  closeButtonProps = {},
460
+ errorTypes = [],
436
461
  ...panelProps
437
462
  } = props
438
463
 
@@ -605,6 +630,8 @@ export const ReactQueryDevtoolsPanel = React.forwardRef<
605
630
  style={{
606
631
  display: 'flex',
607
632
  alignItems: 'center',
633
+ flexWrap: 'wrap',
634
+ gap: '0.5em',
608
635
  }}
609
636
  >
610
637
  <Input
@@ -617,7 +644,6 @@ export const ReactQueryDevtoolsPanel = React.forwardRef<
617
644
  }}
618
645
  style={{
619
646
  flex: '1',
620
- marginRight: '.5em',
621
647
  width: '100%',
622
648
  }}
623
649
  />
@@ -738,6 +764,7 @@ export const ReactQueryDevtoolsPanel = React.forwardRef<
738
764
  activeQueryHash={activeQueryHash}
739
765
  queryCache={queryCache}
740
766
  queryClient={queryClient}
767
+ errorTypes={errorTypes}
741
768
  />
742
769
  ) : null}
743
770
 
@@ -773,10 +800,12 @@ const ActiveQuery = ({
773
800
  queryCache,
774
801
  activeQueryHash,
775
802
  queryClient,
803
+ errorTypes,
776
804
  }: {
777
805
  queryCache: QueryCache
778
806
  activeQueryHash: string
779
807
  queryClient: QueryClient
808
+ errorTypes: DevToolsErrorType[]
780
809
  }) => {
781
810
  const activeQuery = useSubscribeToQueryCache(queryCache, () =>
782
811
  queryCache.getAll().find((query) => query.queryHash === activeQueryHash),
@@ -810,10 +839,46 @@ const ActiveQuery = ({
810
839
  promise?.catch(noop)
811
840
  }
812
841
 
842
+ const currentErrorTypeName = useMemo(() => {
843
+ if (activeQuery && activeQueryState?.error) {
844
+ const errorType = errorTypes.find(
845
+ (type) =>
846
+ type.initializer(activeQuery).toString() ===
847
+ activeQueryState.error?.toString(),
848
+ )
849
+ return errorType?.name
850
+ }
851
+ return undefined
852
+ }, [activeQuery, activeQueryState?.error, errorTypes])
853
+
813
854
  if (!activeQuery || !activeQueryState) {
814
855
  return null
815
856
  }
816
857
 
858
+ const triggerError = (errorType?: DevToolsErrorType) => {
859
+ const error =
860
+ errorType?.initializer(activeQuery) ??
861
+ new Error('Unknown error from devtools')
862
+
863
+ const __previousQueryOptions = activeQuery.options
864
+
865
+ activeQuery.setState({
866
+ status: 'error',
867
+ error,
868
+ fetchMeta: {
869
+ ...activeQuery.state.fetchMeta,
870
+ __previousQueryOptions,
871
+ },
872
+ })
873
+ }
874
+
875
+ const restoreQueryAfterLoadingOrError = () => {
876
+ activeQuery.fetch(activeQuery.state.fetchMeta.__previousQueryOptions, {
877
+ // Make sure this fetch will cancel the previous one
878
+ cancelRefetch: true,
879
+ })
880
+ }
881
+
817
882
  return (
818
883
  <ActiveQueryPanel>
819
884
  <div
@@ -910,6 +975,10 @@ const ActiveQuery = ({
910
975
  <div
911
976
  style={{
912
977
  padding: '0.5em',
978
+ display: 'flex',
979
+ flexWrap: 'wrap',
980
+ gap: '0.5em',
981
+ alignItems: 'flex-end',
913
982
  }}
914
983
  >
915
984
  <Button
@@ -949,7 +1018,80 @@ const ActiveQuery = ({
949
1018
  }}
950
1019
  >
951
1020
  Remove
952
- </Button>
1021
+ </Button>{' '}
1022
+ <Button
1023
+ type="button"
1024
+ onClick={() => {
1025
+ if (activeQuery.state.data === undefined) {
1026
+ restoreQueryAfterLoadingOrError()
1027
+ } else {
1028
+ const __previousQueryOptions = activeQuery.options
1029
+ // Trigger a fetch in order to trigger suspense as well.
1030
+ activeQuery.fetch({
1031
+ ...__previousQueryOptions,
1032
+ queryFn: () => {
1033
+ return new Promise(() => {
1034
+ // Never resolve
1035
+ })
1036
+ },
1037
+ cacheTime: -1,
1038
+ })
1039
+ activeQuery.setState({
1040
+ data: undefined,
1041
+ status: 'loading',
1042
+ fetchMeta: {
1043
+ ...activeQuery.state.fetchMeta,
1044
+ __previousQueryOptions,
1045
+ },
1046
+ })
1047
+ }
1048
+ }}
1049
+ style={{
1050
+ background: theme.paused,
1051
+ }}
1052
+ >
1053
+ {activeQuery.state.status === 'loading' ? 'Restore' : 'Trigger'}{' '}
1054
+ loading
1055
+ </Button>{' '}
1056
+ {errorTypes.length === 0 || activeQuery.state.status === 'error' ? (
1057
+ <Button
1058
+ type="button"
1059
+ onClick={() => {
1060
+ if (!activeQuery.state.error) {
1061
+ triggerError()
1062
+ } else {
1063
+ queryClient.resetQueries(activeQuery)
1064
+ }
1065
+ }}
1066
+ style={{
1067
+ background: theme.danger,
1068
+ }}
1069
+ >
1070
+ {activeQuery.state.status === 'error' ? 'Restore' : 'Trigger'} error
1071
+ </Button>
1072
+ ) : (
1073
+ <label>
1074
+ Trigger error:
1075
+ <Select
1076
+ value={currentErrorTypeName ?? ''}
1077
+ style={{ marginInlineStart: '.5em' }}
1078
+ onChange={(e) => {
1079
+ const errorType = errorTypes.find(
1080
+ (t) => t.name === e.target.value,
1081
+ )
1082
+
1083
+ triggerError(errorType)
1084
+ }}
1085
+ >
1086
+ <option key="" value="" />
1087
+ {errorTypes.map((errorType) => (
1088
+ <option key={errorType.name} value={errorType.name}>
1089
+ {errorType.name}
1090
+ </option>
1091
+ ))}
1092
+ </Select>
1093
+ </label>
1094
+ )}
953
1095
  </div>
954
1096
  <div
955
1097
  style={{
@@ -50,7 +50,9 @@ export const Button = styled('button', (props, theme) => ({
50
50
  }))
51
51
 
52
52
  export const QueryKeys = styled('span', {
53
- display: 'inline-block',
53
+ display: 'flex',
54
+ flexWrap: 'wrap',
55
+ gap: '0.5em',
54
56
  fontSize: '0.9em',
55
57
  })
56
58