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'