From d5d6eb216beaf16e1233121f24b32514c6b4f373 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Mon, 1 Jul 2024 13:05:46 -0700 Subject: [PATCH 1/2] docs(useQuery): Write a story that visually shows useQuery loading/error/success states --- static/app/utils/api/useQuery.stories.tsx | 318 ++++++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 static/app/utils/api/useQuery.stories.tsx diff --git a/static/app/utils/api/useQuery.stories.tsx b/static/app/utils/api/useQuery.stories.tsx new file mode 100644 index 00000000000000..5ba3c385447e62 --- /dev/null +++ b/static/app/utils/api/useQuery.stories.tsx @@ -0,0 +1,318 @@ +import {Fragment, type ReactNode, Suspense, useEffect, useState} from 'react'; +import {useQuery} from '@tanstack/react-query'; + +import {Button} from 'sentry/components/button'; +import ButtonBar from 'sentry/components/buttonBar'; +import {Flex} from 'sentry/components/container/flex'; +import ObjectInspector from 'sentry/components/objectInspector'; +import SideBySide from 'sentry/components/stories/sideBySide'; +import SizingWindow from 'sentry/components/stories/sizingWindow'; +import storyBook from 'sentry/stories/storyBook'; +import {space} from 'sentry/styles/space'; +import {useQueryClient, type UseQueryOptions} from 'sentry/utils/queryClient'; + +/** + * Fake endpoint to simulate loading data with 5 second delay + */ +function fetchData() { + return new Promise(resolve => { + setTimeout(() => { + resolve({some: 'data'}); + }, 5_000); + }); +} + +function fetchThrowsError() { + return new Promise((_, reject) => { + setTimeout(() => { + reject('An error happened'); + }, 5_000); + }); +} + +/** + * Helper react component to track how long something has been rendered + */ +function LoadingFallback() { + const [seconds, setSeconds] = useState(0); + useEffect(() => { + const interval = setInterval(() => setSeconds(prev => prev + 1), 1_000); + return () => clearInterval(interval); + }, []); + + return ( +
+ Loading... +

Rendered for {seconds} seconds

+
+ ); +} + +function ToggleMounted({ + children, + queryKeys, +}: { + children: ReactNode; + queryKeys: Array; +}) { + const [isMounted, setIsMounted] = useState(true); + const queryClient = useQueryClient(); + + return ( + + + + + + + {isMounted ? children : null} + + + ); +} + +function DataContainer({ + children, + options, +}: { + options: UseQueryOptions; + children?: ReactNode; +}) { + const { + data, + error, + isError, + isFetching, + isInitialLoading, + isLoading, + isSuccess, + refetch, + status, + } = useQuery(options); + + return ( + + {children} + + + + ); +} + +export default storyBook('useQuery', story => { + story('README', () => ( + +

+ This is a set of examples for how to call useQuery() and make the + most of it's built-in fetching/loading/error/success states. +

+

+ These examples import from @tanstack/react-query directly, instead of + from sentry/utils/queryClient in order to directly test the api + without sentry specific helpers getting in the way. What's being tested are the + return types, which are consistent between sentry/utils/queryClient{' '} + and @tanstack/react-query. +

+

+ You should prefer to import from sentry/utils/queryClient as much as + possible to benefit from easier and consistent data fetching within sentry. +

+
+ )); + story('TL/DR', () => ( + +

+ It seems like you can get really far by checking BOTH{' '} + isFetching || isLoading when you are waiting for the first render, + particularly when using enabled but not setting{' '} + initialData (which is a common situation). +

+

+ Also using suspense is a pattern that we should adopt more because it + simplifies a lot of state coalescing, especially when multiple fetches are + happening concurrently. +

+
+ )); + + story('Basic', () => { + return ( + + + +
status begins as "loading"
+
+ +
expected to throw an error
+
+
+
+ ); + }); + + story('enabled: false', () => { + return ( + + + +
+ manual refetch ignores enabled: false +
+
+ +
+ manual refetch ignores enabled: false +
+
+
+
+ ); + }); + + story('initialData: {...}', () => { + return ( + + + +
status begins as "success"
+
+ +
expected to throw an error
+
+
+
+ ); + }); + + story('enabled:false, initialData: {...}', () => { + return ( + + + +
status begins as "success"
+
+ +
expected to throw an error
+
+
+
+ ); + }); + + story('suspense: true, useErrorBoundary: false', () => { + return ( + + }> + + + }> + + + + ); + }); + + story('useErrorBoundary: true', () => { + return ( +

+ TODO: I was unable to get an example to work with react-query v4 and react 18.2.0 +

+ ); + }); +}); From c070ae8a8c309b79b83810fb855e4e3b25de0c7c Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Mon, 1 Jul 2024 13:14:16 -0700 Subject: [PATCH 2/2] guard all stories with a wrapper --- static/app/stories/storyBook.tsx | 7 +++-- static/app/utils/api/useQuery.stories.tsx | 31 ++++++++++++++++++++--- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/static/app/stories/storyBook.tsx b/static/app/stories/storyBook.tsx index 3bcda703180f75..e287d8e1d3ec90 100644 --- a/static/app/stories/storyBook.tsx +++ b/static/app/stories/storyBook.tsx @@ -1,8 +1,9 @@ import type {JSXElementConstructor, ReactNode} from 'react'; -import {Children} from 'react'; +import {Children, Suspense} from 'react'; import styled from '@emotion/styled'; import {Flex} from 'sentry/components/container/flex'; +import Placeholder from 'sentry/components/placeholder'; import SideBySide from 'sentry/components/stories/sideBySide'; import {space} from 'sentry/styles/space'; @@ -39,7 +40,9 @@ export default function storyBook( return ( {name} - {isOneChild ? children : {children}} + }> + {isOneChild ? children : {children}} + ); })} diff --git a/static/app/utils/api/useQuery.stories.tsx b/static/app/utils/api/useQuery.stories.tsx index 5ba3c385447e62..a329113e51d351 100644 --- a/static/app/utils/api/useQuery.stories.tsx +++ b/static/app/utils/api/useQuery.stories.tsx @@ -282,11 +282,11 @@ export default storyBook('useQuery', story => { story('suspense: true, useErrorBoundary: false', () => { return ( - + }> { }> { ); }); + story('suspense: true, without ', () => { + return ( + + + + + + ); + }); + story('useErrorBoundary: true', () => { return (