diff --git a/packages/web/jest.config.js b/packages/web/jest.config.js index 3498b181d8a2..ded53dea3d98 100644 --- a/packages/web/jest.config.js +++ b/packages/web/jest.config.js @@ -8,6 +8,10 @@ module.exports = { '**/*.test.+(ts|tsx|js|jsx)', '!**/__typetests__/*.+(ts|tsx|js|jsx)', ], + globals: { + // Required for code that use experimental flags + RWJS_ENV: {}, + }, }, { displayName: { diff --git a/packages/web/src/apollo/index.tsx b/packages/web/src/apollo/index.tsx index d7060fdd6876..cb845d9e4036 100644 --- a/packages/web/src/apollo/index.tsx +++ b/packages/web/src/apollo/index.tsx @@ -19,6 +19,9 @@ const { useQuery, useMutation, useSubscription, + useBackgroundQuery, + useReadQuery, + useSuspenseQuery, setLogVerbosity: apolloSetLogVerbosity, } = apolloClient @@ -314,6 +317,9 @@ export const RedwoodApolloProvider: React.FunctionComponent<{ useQuery={useQuery} useMutation={useMutation} useSubscription={useSubscription} + useBackgroundQuery={useBackgroundQuery} + useReadQuery={useReadQuery} + useSuspenseQuery={useSuspenseQuery} > {children} diff --git a/packages/web/src/apollo/suspense.tsx b/packages/web/src/apollo/suspense.tsx index fdbd508b619a..addf21fdbc39 100644 --- a/packages/web/src/apollo/suspense.tsx +++ b/packages/web/src/apollo/suspense.tsx @@ -3,7 +3,8 @@ * This is a lift and shift of the original ApolloProvider * but with suspense specific bits. Look for @MARK to find bits I've changed * - * Done this way, to avoid making changes breaking on main. + * Done this way, to avoid making changes breaking on main, due to the experimental-nextjs import + * Eventually we will have one ApolloProvider, not multiple. */ import type { @@ -24,6 +25,9 @@ import { NextSSRApolloClient, NextSSRInMemoryCache, useSuspenseQuery, + useBackgroundQuery, + useReadQuery, + useQuery, } from '@apollo/experimental-nextjs-app-support/ssr' import { UseAuth, useNoAuth } from '@redwoodjs/auth' @@ -229,12 +233,12 @@ export const RedwoodApolloProvider: React.FunctionComponent<{ logLevel={logLevel} > {children} diff --git a/packages/web/src/apollo/typeOverride.ts b/packages/web/src/apollo/typeOverride.ts index 139349e163eb..d4d7e6dd6909 100644 --- a/packages/web/src/apollo/typeOverride.ts +++ b/packages/web/src/apollo/typeOverride.ts @@ -7,6 +7,8 @@ import type { OperationVariables, SubscriptionHookOptions, SubscriptionResult, + UseSuspenseQueryResult, + SuspenseQueryHookOptions, } from '@apollo/client' // @MARK: Override relevant types from Apollo here @@ -36,6 +38,16 @@ declare global { TData, TVariables extends OperationVariables > extends SubscriptionHookOptions {} + + interface SuspenseQueryOperationResult< + TData = any, + TVariables extends OperationVariables = OperationVariables + > extends UseSuspenseQueryResult {} + + interface GraphQLSuspenseQueryHookOptions< + TData, + TVariables extends OperationVariables + > extends SuspenseQueryHookOptions {} } export {} diff --git a/packages/web/src/components/GraphQLHooksProvider.tsx b/packages/web/src/components/GraphQLHooksProvider.tsx index db9e985cb586..45dfc5ff6643 100644 --- a/packages/web/src/components/GraphQLHooksProvider.tsx +++ b/packages/web/src/components/GraphQLHooksProvider.tsx @@ -1,6 +1,20 @@ -import { OperationVariables } from '@apollo/client' +import type { + OperationVariables, + useBackgroundQuery as apolloUseBackgroundQuery, + useReadQuery as apolloUseReadQuery, +} from '@apollo/client' import type { DocumentNode } from 'graphql' +/** + * @NOTE + * The types QueryOperationResult, MutationOperationResult, SubscriptionOperationResult, and SuspenseQueryOperationResult + * are overridden in packages/web/src/apollo/typeOverride.ts. This was originally so that you could bring your own gql client. + * + * The default (empty) types are defined in packages/web/src/global.web-auto-imports.ts + * + * Do not import types for hooks directly from Apollo here, unless it is an Apollo specific hook. + */ + type DefaultUseQueryType = < TData = any, TVariables extends OperationVariables = GraphQLOperationVariables @@ -24,14 +38,29 @@ type DefaultUseSubscriptionType = < subscription: DocumentNode, options?: GraphQLSubscriptionHookOptions ) => SubscriptionOperationResult + +type DefaultUseSuspenseType = < + TData = any, + TVariables extends OperationVariables = GraphQLOperationVariables +>( + query: DocumentNode, + options?: GraphQLSuspenseQueryHookOptions +) => SuspenseQueryOperationResult + export interface GraphQLHooks< TuseQuery = DefaultUseQueryType, TuseMutation = DefaultUseMutationType, - TuseSubscription = DefaultUseSubscriptionType + TuseSubscription = DefaultUseSubscriptionType, + TuseSuspenseQuery = DefaultUseSuspenseType > { useQuery: TuseQuery useMutation: TuseMutation useSubscription: TuseSubscription + useSuspenseQuery: TuseSuspenseQuery + // @NOTE note that we aren't using typeoverride here. + // This is because useBackgroundQuery and useReadQuery are apollo specific hooks. + useBackgroundQuery: typeof apolloUseBackgroundQuery + useReadQuery: typeof apolloUseReadQuery } export const GraphQLHooksContext = React.createContext({ @@ -50,13 +79,37 @@ export const GraphQLHooksContext = React.createContext({ 'You must register a useSubscription hook via the `GraphQLHooksProvider`' ) }, + useSuspenseQuery: () => { + throw new Error( + 'You must register a useSuspenseQuery hook via the `GraphQLHooksProvider`.' + ) + }, + + // These are apollo specific hooks! + useBackgroundQuery: () => { + throw new Error( + 'You must register a useBackgroundQuery hook via the `GraphQLHooksProvider`.' + ) + }, + + useReadQuery: () => { + throw new Error( + 'You must register a useReadQuery hook via the `GraphQLHooksProvider`.' + ) + }, }) interface GraphQlHooksProviderProps< TuseQuery = DefaultUseQueryType, TuseMutation = DefaultUseMutationType, - TuseSubscription = DefaultUseSubscriptionType -> extends GraphQLHooks { + TuseSubscription = DefaultUseSubscriptionType, + TuseSuspenseQuery = DefaultUseSuspenseType +> extends GraphQLHooks< + TuseQuery, + TuseMutation, + TuseSubscription, + TuseSuspenseQuery + > { children: React.ReactNode } @@ -74,6 +127,9 @@ export const GraphQLHooksProvider = < useQuery, useMutation, useSubscription, + useSuspenseQuery, + useBackgroundQuery, + useReadQuery, children, }: GraphQlHooksProviderProps) => { return ( @@ -82,6 +138,9 @@ export const GraphQLHooksProvider = < useQuery, useMutation, useSubscription, + useSuspenseQuery, + useBackgroundQuery, + useReadQuery, }} > {children} @@ -127,3 +186,29 @@ export function useSubscription< TVariables >(query, options) } + +export function useSuspenseQuery< + TData = any, + TVariables extends OperationVariables = GraphQLOperationVariables +>( + query: DocumentNode, + options?: GraphQLSuspenseQueryHookOptions +): SuspenseQueryOperationResult { + return React.useContext(GraphQLHooksContext).useSuspenseQuery< + TData, + TVariables + >(query, options) +} + +export const useBackgroundQuery: typeof apolloUseBackgroundQuery = ( + ...args +) => { + // @TODO something about the apollo types here mean I need to override the return type + return React.useContext(GraphQLHooksContext).useBackgroundQuery( + ...args + ) as any +} + +export const useReadQuery: typeof apolloUseReadQuery = (...args) => { + return React.useContext(GraphQLHooksContext).useReadQuery(...args) +} diff --git a/packages/web/src/components/CellCacheContext.tsx b/packages/web/src/components/cell/CellCacheContext.tsx similarity index 100% rename from packages/web/src/components/CellCacheContext.tsx rename to packages/web/src/components/cell/CellCacheContext.tsx diff --git a/packages/web/src/components/CellErrorBoundary.tsx b/packages/web/src/components/cell/CellErrorBoundary.tsx similarity index 51% rename from packages/web/src/components/CellErrorBoundary.tsx rename to packages/web/src/components/cell/CellErrorBoundary.tsx index 95a671f0dcfa..8a7a4f58bb5e 100644 --- a/packages/web/src/components/CellErrorBoundary.tsx +++ b/packages/web/src/components/cell/CellErrorBoundary.tsx @@ -1,11 +1,18 @@ import React from 'react' -import type { CellFailureProps } from './createCell' +import type { CellFailureProps } from './cellTypes' -type CellErrorBoundaryProps = { +export type FallbackProps = { + error: QueryOperationResult['error'] + resetErrorBoundary: () => void +} + +export type CellErrorBoundaryProps = { // Note that the fallback has to be an FC, not a Node // because the error comes from this component's state - fallback: React.FC + renderFallback: ( + fbProps: FallbackProps + ) => React.ReactElement children: React.ReactNode } @@ -29,21 +36,24 @@ export class CellErrorBoundary extends React.Component< componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { // @TODO do something with this? - console.log(error, errorInfo) + console.log('Cell failure: ', { + error, + errorInfo, + }) } render() { - const { fallback: Fallback } = this.props + // The fallback is constructed with all the props required, except error and errorCode + // in createSusepndingCell.tsx + const { renderFallback } = this.props + if (this.state.hasError) { - return ( - - ) + return renderFallback({ + error: this.state.error, + resetErrorBoundary: () => { + this.setState({ hasError: false, error: undefined }) + }, + }) } return this.props.children diff --git a/packages/web/src/components/cell/cellTypes.tsx b/packages/web/src/components/cell/cellTypes.tsx new file mode 100644 index 000000000000..fa352cc3805d --- /dev/null +++ b/packages/web/src/components/cell/cellTypes.tsx @@ -0,0 +1,225 @@ +import { ComponentProps, JSXElementConstructor } from 'react' + +import type { + ApolloClient, + NetworkStatus, + OperationVariables, + QueryReference, + UseBackgroundQueryResult, +} from '@apollo/client' +import type { DocumentNode } from 'graphql' +import type { A } from 'ts-toolbelt' + +/** + * + * If the Cell has a `beforeQuery` function, then the variables are not required, + * but instead the arguments of the `beforeQuery` function are required. + * + * If the Cell does not have a `beforeQuery` function, then the variables are required. + * + * Note that a query that doesn't take any variables is defined as {[x: string]: never} + * The ternary at the end makes sure we don't include it, otherwise it won't allow merging any + * other custom props from the Success component. + * + */ +type CellPropsVariables = Cell extends { + beforeQuery: (...args: any[]) => any +} + ? Parameters[0] extends unknown + ? Record + : Parameters[0] + : GQLVariables extends Record + ? unknown + : GQLVariables +/** + * Cell component props which is the combination of query variables and Success props. + */ + +export type CellProps< + CellSuccess extends keyof JSX.IntrinsicElements | JSXElementConstructor, + GQLResult, + CellType, + GQLVariables +> = A.Compute< + Omit< + ComponentProps, + | keyof CellPropsVariables + | keyof GQLResult + | 'updating' + | 'queryResult' + > & + CellPropsVariables +> + +export type CellLoadingProps = { + queryResult?: NonSuspenseCellQueryResult | SuspenseCellQueryResult +} + +export type CellFailureProps = { + queryResult?: NonSuspenseCellQueryResult | SuspenseCellQueryResult + error?: QueryOperationResult['error'] | Error // for tests and storybook + + /** + * @see {@link https://www.apollographql.com/docs/apollo-server/data/errors/#error-codes} + */ + errorCode?: string + updating?: boolean +} +// aka guarantee that all properties in T exist +// This is necessary for Cells, because if it doesn't exist it'll go to Empty or Failure +type Guaranteed = { + [K in keyof T]-?: NonNullable +} +/** + * Use this type, if you are forwarding on the data from your Cell's Success component + * Because Cells automatically checks for "empty", or "errors" - if you receive the data type in your + * Success component, it means the data is guaranteed (and non-optional) + * + * @params TData = Type of data based on your graphql query. This can be imported from 'types/graphql' + * @example + * import type {FindPosts} from 'types/graphql' + * + * const { post } = CellSuccessData + * + * post.id // post is non optional, so no need to do post?.id + * + */ + +export type CellSuccessData = Omit, '__typename'> +/** + * @MARK not sure about this partial, but we need to do this for tests and storybook. + * + * `updating` is just `loading` renamed; since Cells default to stale-while-refetch, + * this prop lets users render something like a spinner to show that a request is in-flight. + */ + +export type CellSuccessProps< + TData = any, + TVariables extends OperationVariables = any +> = { + queryResult?: NonSuspenseCellQueryResult | SuspenseCellQueryResult + updating?: boolean +} & A.Compute> // pre-computing makes the types more readable on hover + +/** + * A coarse type for the `data` prop returned by `useQuery`. + * + * ```js + * { + * data: { + * post: { ... } + * } + * } + * ``` + */ +export type DataObject = { [key: string]: unknown } +/** + * The main interface. + */ + +export interface CreateCellProps { + /** + * The GraphQL syntax tree to execute or function to call that returns it. + * If `QUERY` is a function, it's called with the result of `beforeQuery`. + */ + QUERY: DocumentNode | ((variables: Record) => DocumentNode) + /** + * Parse `props` into query variables. Most of the time `props` are appropriate variables as is. + */ + beforeQuery?: + | ((props: CellProps) => { variables: CellVariables }) + | (() => { variables: CellVariables }) + /** + * Sanitize the data returned from the query. + */ + afterQuery?: (data: DataObject) => DataObject + /** + * How to decide if the result of a query should render the `Empty` component. + * The default implementation checks that the first field isn't `null` or an empty array. + * + * @example + * + * In the example below, only `users` is checked: + * + * ```js + * export const QUERY = gql` + * users { + * name + * } + * posts { + * title + * } + * ` + * ``` + */ + isEmpty?: ( + response: DataObject, + options: { + isDataEmpty: (data: DataObject) => boolean + } + ) => boolean + /** + * If the query's in flight and there's no stale data, render this. + */ + Loading?: React.FC> + /** + * If something went wrong, render this. + */ + Failure?: React.FC> + /** + * If no data was returned, render this. + */ + Empty?: React.FC> + /** + * If data was returned, render this. + */ + Success: React.FC> + /** + * What to call the Cell. Defaults to the filename. + */ + displayName?: string +} + +export type SuperSuccessProps = React.PropsWithChildren< + Record +> & { + queryRef: QueryReference // from useBackgroundQuery + suspenseQueryResult: SuspenseCellQueryResult + userProps: Record // we don't really care about the types here, we are just forwarding on +} + +export type NonSuspenseCellQueryResult< + TVariables extends OperationVariables = any +> = Partial< + Omit, 'loading' | 'error' | 'data'> +> + +// We call this queryResult in createCell, sadly a very overloaded term +// This is just the extra things returned from useXQuery hooks +export interface SuspenseCellQueryResult< + _TData = any, + _TVariables extends OperationVariables = any +> extends UseBackgroundQueryResult { + client: ApolloClient + // fetchMore & refetch come from UseBackgroundQueryResult + + // not supplied in Error and Failure + // because it's implicit in these components, but the one edgecase is showing a different loader when refetching + networkStatus?: NetworkStatus + called: boolean // can we assume if we have a queryRef its called? + + // Stuff not here: + // observable: ObservableQuery + // previousData?: TData, May not be relevant anymore. + + // ObservableQueryFields 👇 + // subscribeToMore ~ returned from useSuspenseQuery. What would users use this for? + // updateQuery + // refetch + // reobserve + // variables <~ variables passed to the query. Startup club have reported using this, but why? + // fetchMore + // startPolling <~ Apollo team are not ready to expose Polling yet + // stopPolling + // ~~~ +} diff --git a/packages/web/src/components/createCell.test.tsx b/packages/web/src/components/cell/createCell.test.tsx similarity index 99% rename from packages/web/src/components/createCell.test.tsx rename to packages/web/src/components/cell/createCell.test.tsx index 3739015f6e4f..42e5095ad9ae 100644 --- a/packages/web/src/components/createCell.test.tsx +++ b/packages/web/src/components/cell/createCell.test.tsx @@ -5,8 +5,9 @@ import { render, screen } from '@testing-library/react' import '@testing-library/jest-dom/extend-expect' +import { GraphQLHooksProvider } from '../GraphQLHooksProvider' + import { createCell } from './createCell' -import { GraphQLHooksProvider } from './GraphQLHooksProvider' describe('createCell', () => { beforeAll(() => { diff --git a/packages/web/src/components/cell/createCell.tsx b/packages/web/src/components/cell/createCell.tsx new file mode 100644 index 000000000000..74f5479559b1 --- /dev/null +++ b/packages/web/src/components/cell/createCell.tsx @@ -0,0 +1,179 @@ +import { getOperationName } from '../../graphql' +/** + * This is part of how we let users swap out their GraphQL client while staying compatible with Cells. + */ +import { useQuery } from '../GraphQLHooksProvider' + +import { useCellCacheContext } from './CellCacheContext' +import { CreateCellProps } from './cellTypes' +import { createSuspendingCell } from './createSuspendingCell' +import { isDataEmpty } from './isCellEmpty' + +// 👇 Note how we switch which cell factory to use! +export const createCell = RWJS_ENV.RWJS_EXP_STREAMING_SSR + ? createSuspendingCell + : createNonSuspendingCell + +/** + * Creates a Cell out of a GraphQL query and components that track to its lifecycle. + */ +function createNonSuspendingCell< + CellProps extends Record, + CellVariables extends Record +>({ + QUERY, + beforeQuery = (props) => ({ + // By default, we assume that the props are the gql-variables. + variables: props as unknown as CellVariables, + /** + * We're duplicating these props here due to a suspected bug in Apollo Client v3.5.4 + * (it doesn't seem to be respecting `defaultOptions` in `RedwoodApolloProvider`.) + * + * @see {@link https://github.com/apollographql/apollo-client/issues/9105} + */ + fetchPolicy: 'cache-and-network', + notifyOnNetworkStatusChange: true, + }), + afterQuery = (data) => ({ ...data }), + isEmpty = isDataEmpty, + Loading = () => <>Loading..., + Failure, + Empty, + Success, + displayName = 'Cell', +}: CreateCellProps): React.FC { + function NamedCell(props: React.PropsWithChildren) { + /** + * Right now, Cells don't render `children`. + */ + const { children: _, ...variables } = props + const options = beforeQuery(variables as CellProps) + const query = typeof QUERY === 'function' ? QUERY(options) : QUERY + + // queryRest includes `variables: { ... }`, with any variables returned + // from beforeQuery + let { + // eslint-disable-next-line prefer-const + error, + loading, + data, + ...queryResult + } = useQuery(query, options) + + if (globalThis.__REDWOOD__PRERENDERING) { + // __REDWOOD__PRERENDERING will always either be set, or not set. So + // rules-of-hooks are still respected, even though we wrap this in an if + // statement + /* eslint-disable-next-line react-hooks/rules-of-hooks */ + const { queryCache } = useCellCacheContext() + const operationName = getOperationName(query) + + let cacheKey + + if (operationName) { + cacheKey = operationName + '_' + JSON.stringify(variables) + } else { + const cellName = displayName === 'Cell' ? 'the cell' : displayName + + throw new Error( + `The gql query in ${cellName} is missing an operation name. ` + + 'Something like FindBlogPostQuery in ' + + '`query FindBlogPostQuery($id: Int!)`' + ) + } + + const queryInfo = queryCache[cacheKey] + + // This is true when the graphql handler couldn't be loaded + // So we fallback to the loading state + if (queryInfo?.renderLoading) { + loading = true + } else { + if (queryInfo?.hasProcessed) { + loading = false + data = queryInfo.data + + // All of the gql client's props aren't available when pre-rendering, + // so using `any` here + queryResult = { variables } as any + } else { + queryCache[cacheKey] || + (queryCache[cacheKey] = { + query, + variables: options.variables, + hasProcessed: false, + }) + } + } + } + + if (error) { + if (Failure) { + // errorCode is not part of the type returned by useQuery + // but it is returned as part of the queryResult + type QueryResultWithErrorCode = typeof queryResult & { + errorCode: string + } + + return ( + + ) + } else { + throw error + } + } else if (data) { + const afterQueryData = afterQuery(data) + + if (isEmpty(data, { isDataEmpty }) && Empty) { + return ( + + ) + } else { + return ( + + ) + } + } else if (loading) { + return + } else { + /** + * There really shouldn't be an `else` here, but like any piece of software, GraphQL clients have bugs. + * If there's no `error` and there's no `data` and we're not `loading`, something's wrong. Most likely with the cache. + * + * @see {@link https://github.com/redwoodjs/redwood/issues/2473#issuecomment-971864604} + */ + console.warn( + `If you're using Apollo Client, check for its debug logs here in the console, which may help explain the error.` + ) + throw new Error( + 'Cannot render Cell: reached an unexpected state where the query succeeded but `data` is `null`. If this happened in Storybook, your query could be missing fields; otherwise this is most likely a GraphQL caching bug. Note that adding an `id` field to all the fields on your query may fix the issue.' + ) + } + } + + NamedCell.displayName = displayName + + return (props: CellProps) => { + return + } +} diff --git a/packages/web/src/components/cell/createSuspendingCell.test.tsx b/packages/web/src/components/cell/createSuspendingCell.test.tsx new file mode 100644 index 000000000000..749636fd3d2c --- /dev/null +++ b/packages/web/src/components/cell/createSuspendingCell.test.tsx @@ -0,0 +1,144 @@ +/** + * @jest-environment jsdom + */ +import type { useReadQuery, useBackgroundQuery } from '@apollo/client' +import { loadErrorMessages, loadDevMessages } from '@apollo/client/dev' +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom/extend-expect' + +import { GraphQLHooksProvider } from '../GraphQLHooksProvider' + +import { createSuspendingCell } from './createSuspendingCell' + +type ReadQueryHook = typeof useReadQuery +type BgQueryHook = typeof useBackgroundQuery + +jest.mock('@apollo/client', () => { + return { + useApolloClient: jest.fn(), + } +}) + +// @TODO: once we have finalised implementation, we need to add tests for +// all the other states. We would also need to figure out how to test the Suspense state. +// No point doing this now, as the implementation is in flux! + +describe('createSuspendingCell', () => { + beforeAll(() => { + globalThis.RWJS_ENV = { + RWJS_EXP_STREAMING_SSR: true, + } + loadDevMessages() + loadErrorMessages() + }) + + const mockedUseBgQuery = (() => { + return ['mocked-query-ref', { refetch: jest.fn(), fetchMore: jest.fn() }] + }) as unknown as BgQueryHook + + const mockedQueryHook = () => ({ data: {} }) + + test.only('Renders a static Success component', async () => { + const TestCell = createSuspendingCell({ + // @ts-expect-error - Purposefully using a plain string here. + QUERY: 'query TestQuery { answer }', + Success: () => <>Great success!, + }) + + render( + + + + ) + screen.getByText(/^Great success!$/) + }) + + test.only('Renders Success with data', async () => { + const TestCell = createSuspendingCell({ + // @ts-expect-error - Purposefully using a plain string here. + QUERY: 'query TestQuery { answer }', + Success: ({ answer }) => ( + <> +
+
What's the meaning of life?
+
{answer}
+
+ + ), + }) + + const myUseQueryHook = (() => { + return { data: { answer: 42 } } + }) as unknown as ReadQueryHook + + render( + + + + ) + + screen.getByText(/^What's the meaning of life\?$/) + screen.getByText(/^42$/) + }) + + test.only('Renders Success if any of the fields have data (i.e. not just the first)', async () => { + const TestCell = createSuspendingCell({ + // @ts-expect-error - Purposefully using a plain string here. + QUERY: 'query TestQuery { users { name } posts { title } }', + Empty: () => <>No users or posts, + Success: ({ users, posts }) => ( + <> +
+ {users.length > 0 ? ( +
    + {users.map(({ name }) => ( +
  • {name}
  • + ))} +
+ ) : ( + 'no users' + )} +
+
+ {posts.length > 0 ? ( +
    + {posts.map(({ title }) => ( +
  • {title}
  • + ))} +
+ ) : ( + 'no posts' + )} +
+ + ), + }) + + const myReadQueryHook = (() => { + return { + data: { + users: [], + posts: [{ title: 'bazinga' }, { title: 'kittens' }], + }, + } + }) as unknown as ReadQueryHook + + render( + + + + ) + + screen.getByText(/bazinga/) + screen.getByText(/kittens/) + }) +}) diff --git a/packages/web/src/components/cell/createSuspendingCell.tsx b/packages/web/src/components/cell/createSuspendingCell.tsx new file mode 100644 index 000000000000..e4574ff7b435 --- /dev/null +++ b/packages/web/src/components/cell/createSuspendingCell.tsx @@ -0,0 +1,142 @@ +import { Suspense } from 'react' + +import { QueryReference, useApolloClient } from '@apollo/client' + +import { useBackgroundQuery, useReadQuery } from '../GraphQLHooksProvider' + +/** + * This is part of how we let users swap out their GraphQL client while staying compatible with Cells. + */ +import { CellErrorBoundary, FallbackProps } from './CellErrorBoundary' +import { + CreateCellProps, + DataObject, + SuperSuccessProps, + SuspenseCellQueryResult, +} from './cellTypes' +import { isDataEmpty } from './isCellEmpty' + +type AnyObj = Record +/** + * Creates a Cell ~~ with Apollo Client only ~~ + * using the hooks useBackgroundQuery and useReadQuery + * + */ +export function createSuspendingCell< + CellProps extends AnyObj, + CellVariables extends AnyObj +>( + createCellProps: CreateCellProps // 👈 AnyObj, because using CellProps causes a TS error +): React.FC { + const { + QUERY, + beforeQuery = (props) => ({ + // By default, we assume that the props are the gql-variables. + variables: props as unknown as CellVariables, + /** + * We're duplicating these props here due to a suspected bug in Apollo Client v3.5.4 + * (it doesn't seem to be respecting `defaultOptions` in `RedwoodApolloProvider`.) + * + * @see {@link https://github.com/apollographql/apollo-client/issues/9105} + */ + fetchPolicy: 'cache-and-network', + notifyOnNetworkStatusChange: true, + }), + afterQuery = (data) => ({ ...data }), + isEmpty = isDataEmpty, + Loading = () => <>Loading..., + Failure, + Empty, + Success, + displayName = 'Cell', + } = createCellProps + function SuperSuccess(props: SuperSuccessProps) { + const { queryRef, suspenseQueryResult, userProps } = props + const { data, networkStatus } = useReadQuery(queryRef) + const afterQueryData = afterQuery(data as DataObject) + + const queryResultWithNetworkStatus: SuspenseCellQueryResult = { + ...suspenseQueryResult, + networkStatus, + } + + if (isEmpty(data, { isDataEmpty }) && Empty) { + return ( + + ) + } + + return ( + + ) + } + + SuperSuccess.displayName = displayName + + // @NOTE: Note that we are returning a HoC here! + return (props: CellProps) => { + /** + * Right now, Cells don't render `children`. + */ + const { children: _, ...variables } = props + const options = beforeQuery(variables as CellProps) + const query = typeof QUERY === 'function' ? QUERY(options) : QUERY + const [queryRef, other] = useBackgroundQuery(query, options) + + const client = useApolloClient() + + const suspenseQueryResult: SuspenseCellQueryResult = { + client, + ...other, + called: !!queryRef, + } + + // @TODO(STREAMING) removed prerender handling here + // Until we decide how/if we do prerendering + + const FailureComponent = ({ error, resetErrorBoundary }: FallbackProps) => { + if (!Failure) { + // So that it bubbles up to the nearest error boundary + throw error + } + + const queryResultWithErrorReset = { + ...suspenseQueryResult, + refetch: () => { + resetErrorBoundary() + return suspenseQueryResult.refetch?.() + }, + } + + return ( + + ) + } + + return ( + + } + > + } + suspenseQueryResult={suspenseQueryResult} + /> + + + ) + } +} diff --git a/packages/web/src/components/cell/isCellEmpty.tsx b/packages/web/src/components/cell/isCellEmpty.tsx new file mode 100644 index 000000000000..1615c1674252 --- /dev/null +++ b/packages/web/src/components/cell/isCellEmpty.tsx @@ -0,0 +1,48 @@ +import { DataObject } from './cellTypes' + +/** + * The default `isEmpty` implementation. Checks if any of the field is `null` or an empty array. + * + * Consider the following queries. The former returns an object, the latter a list: + * + * ```js + * export const QUERY = gql` + * post { + * title + * } + * ` + * + * export const QUERY = gql` + * posts { + * title + * } + * ` + * ``` + * + * If either are "empty", they return: + * + * ```js + * { + * data: { + * post: null + * } + * } + * + * { + * data: { + * posts: [] + * } + * } + * ``` + * + * Note that the latter can return `null` as well depending on the SDL (`posts: [Post!]`). + * ``` + */ +function isFieldEmptyArray(field: unknown) { + return Array.isArray(field) && field.length === 0 +} +export function isDataEmpty(data: DataObject) { + return Object.values(data).every((fieldValue) => { + return fieldValue === null || isFieldEmptyArray(fieldValue) + }) +} diff --git a/packages/web/src/components/createCell.tsx b/packages/web/src/components/createCell.tsx deleted file mode 100644 index 88bc7e5123fc..000000000000 --- a/packages/web/src/components/createCell.tsx +++ /dev/null @@ -1,435 +0,0 @@ -import { ComponentProps, JSXElementConstructor, Suspense } from 'react' - -import { OperationVariables } from '@apollo/client' -import type { DocumentNode } from 'graphql' -import type { A } from 'ts-toolbelt' - -import { getOperationName } from '../graphql' - -import { useCellCacheContext } from './CellCacheContext' -/** - * This is part of how we let users swap out their GraphQL client while staying compatible with Cells. - */ -import { CellErrorBoundary } from './CellErrorBoundary' -import { useQuery } from './GraphQLHooksProvider' - -/** - * - * If the Cell has a `beforeQuery` function, then the variables are not required, - * but instead the arguments of the `beforeQuery` function are required. - * - * If the Cell does not have a `beforeQuery` function, then the variables are required. - * - * Note that a query that doesn't take any variables is defined as {[x: string]: never} - * The ternary at the end makes sure we don't include it, otherwise it won't allow merging any - * other custom props from the Success component. - * - */ -type CellPropsVariables = Cell extends { - beforeQuery: (...args: any[]) => any -} - ? Parameters[0] extends unknown - ? Record - : Parameters[0] - : GQLVariables extends Record - ? unknown - : GQLVariables - -/** - * Cell component props which is the combination of query variables and Success props. - */ -export type CellProps< - CellSuccess extends keyof JSX.IntrinsicElements | JSXElementConstructor, - GQLResult, - CellType, - GQLVariables -> = A.Compute< - Omit< - ComponentProps, - | keyof CellPropsVariables - | keyof GQLResult - | 'updating' - | 'queryResult' - > & - CellPropsVariables -> - -export type CellLoadingProps = { - queryResult?: Partial< - Omit, 'loading' | 'error' | 'data'> - > -} - -export type CellFailureProps = { - queryResult?: Partial< - Omit, 'loading' | 'error' | 'data'> - > - error?: QueryOperationResult['error'] | Error // for tests and storybook - /** - * @see {@link https://www.apollographql.com/docs/apollo-server/data/errors/#error-codes} - */ - errorCode?: string - updating?: boolean -} - -// aka guarantee that all properties in T exist -// This is necessary for Cells, because if it doesn't exist it'll go to Empty or Failure -type Guaranteed = { - [K in keyof T]-?: NonNullable -} - -/** - * Use this type, if you are forwarding on the data from your Cell's Success component - * Because Cells automatically checks for "empty", or "errors" - if you receive the data type in your - * Success component, it means the data is guaranteed (and non-optional) - * - * @params TData = Type of data based on your graphql query. This can be imported from 'types/graphql' - * @example - * import type {FindPosts} from 'types/graphql' - * - * const { post } = CellSuccessData - * - * post.id // post is non optional, so no need to do post?.id - * - */ -export type CellSuccessData = Omit, '__typename'> - -/** - * @MARK not sure about this partial, but we need to do this for tests and storybook. - * - * `updating` is just `loading` renamed; since Cells default to stale-while-refetch, - * this prop lets users render something like a spinner to show that a request is in-flight. - */ -export type CellSuccessProps< - TData = any, - TVariables extends OperationVariables = any -> = { - queryResult?: Partial< - Omit, 'loading' | 'error' | 'data'> - > - updating?: boolean -} & A.Compute> // pre-computing makes the types more readable on hover - -/** - * A coarse type for the `data` prop returned by `useQuery`. - * - * ```js - * { - * data: { - * post: { ... } - * } - * } - * ``` - */ -export type DataObject = { [key: string]: unknown } - -/** - * The main interface. - */ -export interface CreateCellProps { - /** - * The GraphQL syntax tree to execute or function to call that returns it. - * If `QUERY` is a function, it's called with the result of `beforeQuery`. - */ - QUERY: DocumentNode | ((variables: Record) => DocumentNode) - /** - * Parse `props` into query variables. Most of the time `props` are appropriate variables as is. - */ - beforeQuery?: - | ((props: CellProps) => { variables: CellVariables }) - | (() => { variables: CellVariables }) - /** - * Sanitize the data returned from the query. - */ - afterQuery?: (data: DataObject) => DataObject - /** - * How to decide if the result of a query should render the `Empty` component. - * The default implementation checks that the first field isn't `null` or an empty array. - * - * @example - * - * In the example below, only `users` is checked: - * - * ```js - * export const QUERY = gql` - * users { - * name - * } - * posts { - * title - * } - * ` - * ``` - */ - isEmpty?: ( - response: DataObject, - options: { - isDataEmpty: (data: DataObject) => boolean - } - ) => boolean - /** - * If the query's in flight and there's no stale data, render this. - */ - Loading?: React.FC> - /** - * If something went wrong, render this. - */ - Failure?: React.FC> - /** - * If no data was returned, render this. - */ - Empty?: React.FC> - /** - * If data was returned, render this. - */ - Success: React.FC> - /** - * What to call the Cell. Defaults to the filename. - */ - displayName?: string -} - -/** - * The default `isEmpty` implementation. Checks if any of the field is `null` or an empty array. - * - * Consider the following queries. The former returns an object, the latter a list: - * - * ```js - * export const QUERY = gql` - * post { - * title - * } - * ` - * - * export const QUERY = gql` - * posts { - * title - * } - * ` - * ``` - * - * If either are "empty", they return: - * - * ```js - * { - * data: { - * post: null - * } - * } - * - * { - * data: { - * posts: [] - * } - * } - * ``` - * - * Note that the latter can return `null` as well depending on the SDL (`posts: [Post!]`). - * ``` - */ -function isFieldEmptyArray(field: unknown) { - return Array.isArray(field) && field.length === 0 -} - -function isDataEmpty(data: DataObject) { - return Object.values(data).every((fieldValue) => { - return fieldValue === null || isFieldEmptyArray(fieldValue) - }) -} - -/** - * Creates a Cell out of a GraphQL query and components that track to its lifecycle. - */ -export function createCell< - CellProps extends Record, - CellVariables extends Record ->({ - QUERY, - beforeQuery = (props) => ({ - // By default, we assume that the props are the gql-variables. - variables: props as unknown as CellVariables, - /** - * We're duplicating these props here due to a suspected bug in Apollo Client v3.5.4 - * (it doesn't seem to be respecting `defaultOptions` in `RedwoodApolloProvider`.) - * - * @see {@link https://github.com/apollographql/apollo-client/issues/9105} - */ - fetchPolicy: 'cache-and-network', - notifyOnNetworkStatusChange: true, - }), - afterQuery = (data) => ({ ...data }), - isEmpty = isDataEmpty, - Loading = () => <>Loading..., - Failure, - Empty, - Success, - displayName = 'Cell', -}: CreateCellProps): React.FC { - function NamedCell(props: React.PropsWithChildren) { - /** - * Right now, Cells don't render `children`. - */ - const { children: _, ...variables } = props - const options = beforeQuery(variables as CellProps) - const query = typeof QUERY === 'function' ? QUERY(options) : QUERY - - // queryRest includes `variables: { ... }`, with any variables returned - // from beforeQuery - let { - // eslint-disable-next-line prefer-const - error, - loading, - data, - ...queryResult - } = useQuery(query, options) - - if (globalThis.__REDWOOD__PRERENDERING) { - // __REDWOOD__PRERENDERING will always either be set, or not set. So - // rules-of-hooks are still respected, even though we wrap this in an if - // statement - /* eslint-disable-next-line react-hooks/rules-of-hooks */ - const { queryCache } = useCellCacheContext() - const operationName = getOperationName(query) - - let cacheKey - - if (operationName) { - cacheKey = operationName + '_' + JSON.stringify(variables) - } else { - const cellName = displayName === 'Cell' ? 'the cell' : displayName - - throw new Error( - `The gql query in ${cellName} is missing an operation name. ` + - 'Something like FindBlogPostQuery in ' + - '`query FindBlogPostQuery($id: Int!)`' - ) - } - - const queryInfo = queryCache[cacheKey] - - // This is true when the graphql handler couldn't be loaded - // So we fallback to the loading state - if (queryInfo?.renderLoading) { - loading = true - } else { - if (queryInfo?.hasProcessed) { - loading = false - data = queryInfo.data - - // All of the gql client's props aren't available when pre-rendering, - // so using `any` here - queryResult = { variables } as any - } else { - queryCache[cacheKey] || - (queryCache[cacheKey] = { - query, - variables: options.variables, - hasProcessed: false, - }) - } - } - } - - if (error) { - if (Failure) { - // errorCode is not part of the type returned by useQuery - // but it is returned as part of the queryResult - type QueryResultWithErrorCode = typeof queryResult & { - errorCode: string - } - - return ( - - ) - } else { - throw error - } - } else if (data) { - const afterQueryData = afterQuery(data) - - if (isEmpty(data, { isDataEmpty }) && Empty) { - return ( - - ) - } else { - return ( - - ) - } - } else if (loading) { - return - } else { - /** - * There really shouldn't be an `else` here, but like any piece of software, GraphQL clients have bugs. - * If there's no `error` and there's no `data` and we're not `loading`, something's wrong. Most likely with the cache. - * - * @see {@link https://github.com/redwoodjs/redwood/issues/2473#issuecomment-971864604} - */ - console.warn( - `If you're using Apollo Client, check for its debug logs here in the console, which may help explain the error.` - ) - throw new Error( - 'Cannot render Cell: reached an unexpected state where the query succeeded but `data` is `null`. If this happened in Storybook, your query could be missing fields; otherwise this is most likely a GraphQL caching bug. Note that adding an `id` field to all the fields on your query may fix the issue.' - ) - } - } - - NamedCell.displayName = displayName - - return (props: CellProps) => { - if (RWJS_ENV.RWJS_EXP_STREAMING_SSR) { - /** @TODO (STREAMING): Want to review full Cell lifecycle with Dom and Kris - * - * There's complexity here that I'm 70% sure I'm not capturing - * See notes below about queryResult. How can we refactor createCell so that it's available? - * Keep in mind we may need the ability to switch between useQuery and useSuspenseQuery - * - */ - - const FailureComponent = (fprops: any) => { - if (!Failure) { - return ( - <> -

Cell rendering failure. No Error component supplied.

-
{fprops.error}
- - ) - } - - // @TODO (STREAMING) query-result not available here, because it comes from inside NamedCell - // How do we pass refetch, etc. in? - return - } - - return ( - - }> - - - - ) - } - - return - } -} diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index 019e25db6fbb..30aeafefb305 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -14,16 +14,17 @@ export { useSubscription, } from './components/GraphQLHooksProvider' -export * from './components/CellCacheContext' +export * from './components/cell/CellCacheContext' + +export { createCell } from './components/cell/createCell' export { - createCell, CellProps, CellFailureProps, CellLoadingProps, CellSuccessProps, CellSuccessData, -} from './components/createCell' +} from './components/cell/cellTypes' export * from './graphql'