-
Notifications
You must be signed in to change notification settings - Fork 2.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
RFC: React 18 SSR + Suspense Support #10231
Comments
For Hydration how about useQuery giving back an object that users need to add to DOM as serialized, namespaced data attribute. They would only need something like:
const { data, hydrationProps } = useQuery(...)
return <div {...hydrationProps}>...</div> Yes, this would be a manual work, but users can make sure it'll exist when it's needed, at the right place. Another option would be custom script tags used similar way (hydrationTags instead of hydrationProps), the user needs to render it. It'd work the same way Google Analytics's push. You have an array before the scripts are loaded, you can put it in . When DOM loads, it populates this array. Then One big benefit compared to data attributes that we don't need to rely on DOM at all, if the user renders the script tag we Apollo gives them, the data will be there in time. |
@wintercounter good ideas! I think we'll probably lean more toward your 2nd idea there. We'd like to avoid manual stuff as much as possible to eliminate the possibility of error (i.e. forgetting to spread While we don't yet have it flushed out, my guess is that we'll probably end up with some kind of function that you'll import server-side that will hook into |
Btw, another option for sync rendering without multi-pass is to wait for
async/await support which is in the pipeline now. Many of us don't need
streaming, especially if we can cache fully static pages ourselves...
…On Wed, Oct 26, 2022, 01:14 Jerel Miller ***@***.***> wrote:
@wintercounter <https://github.com/wintercounter> good ideas! I think
we'll probably lean more toward your 2nd idea there. We'd like to avoid
manual stuff as much as possible to eliminate the possibility of error
(i.e. forgetting to spread hydrationProps onto the div`). This discussion
<reactwg/react-18#114> seems to allude to
the idea of being able to write script tags during the stream, so I
definitely want to explore this more.
While we don't yet have it flushed out, my guess is that we'll probably
end up with some kind of function that you'll import server-side that will
hook into renderToPipeableStream
<https://reactjs.org/docs/react-dom-server.html#rendertopipeablestream>
that will handle this for you. We are looking at how libraries like Relay
and Next.js are handling this to get some inspiration.
—
Reply to this email directly, view it on GitHub
<#10231 (comment)>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/AAHLJQFDZMTGBKQWYDZY2A3WFBSTVANCNFSM6AAAAAAROMH6ZM>
.
You are receiving this because you were mentioned.Message ID:
***@***.***>
|
Hey all, just notifying about a discrepancy here in the comparison table, urql supports both client and ssr suspense for over a year now. The reason behind us advising I have put together a small example here where if you omit the script it streams everything in correctly but with the script still seems to be having a react warning which I will look at soon 😅 |
@JoviDeCroock Thanks for the info! I've updated the table above to reflect this. I was looking at the docs for info and all I could find on suspense was the section about SSR. Appreciate the confirmation that it works and the demo you put together! |
I don't think this is what you want to do. Two different invocations to const query1 = useQuery(...);
const query2 = useQuery(...);
return <div>{query1.field.result}{query2.field.result}</div>; Then the return value is something like |
@laverdet thats a really good callout. You're right, the proposal would not allow you to parallelize queries in the same component. I do agree your proposed behavior makes sense and that we should allow it. This seems to fall in line with an earlier suggestion. Thanks for the callout! Edit: I've added a section in the proposal above to call this behavior out explicitly and removed the statement about not rendering beyond the |
That can be overcome by allowing multiple queries in a single useQuery call.
const [q1, q2] = useQuery([{...}, {...}])
|
Regarding the opt-in process I also might recommend just making a new hook entirely. Suspense is such a radical departure from the existing model that I think trying to implement it with a toggle is a fool's errand. Also, from the consumer side the two models are completely reversed. Without suspense the component has to say "what do I look like when I am loading", but with suspense the consumer may or may not even get a chance to answer that question. About SSR and hydration I agree that sending down normalized cache [ The side-channel technique is described under "Injecting Into the SSR Stream" here: reactwg/react-18#114 Building the aforementioned side-channel is definitely out of the scope of Apollo but familiarity with the technique is good to have. I think that a sound implementation on the Apollo side would naturally support both the |
Found this as I was wondering if/when Apollo would be ready for use with NextJS 13. Exciting things ahead it seems!
I'm not sure if this is a best practice, but I wonder if this is too magic. Not all data is accessed synchronously during render. I can imaging something like const WaitlistButton = () => {
const userInfo = useQuery(...);
return <button onClick={() => {
fetch(`/api/waitlist?user-id=${userInfo.data.userId}`, { ... })
}>Join the waitlist</button>
/> (i.e., where the data isn't accessed until a callback is triggered). I don't believe Suspense works when a promise is thrown during an event handler. I wonder if instead we can do opt-in batching (like the Looking forward to this!!!!! |
@travigd great point and something I hadn't considered because this access pattern isn't a part of my workflow, but it is a totally valid one. I wonder if it makes sense to return a promise for suspense queries, and for |
A bit verbose but not the end of the world. |
I'm wondering if there are enough edge cases around suspending for queries to use this as an opportunity to make an intentional shift toward With this approach, we might decide not to have |
@laverdet @travigd these are really great points! That A few interesting things I noted in that This bit of code first stuck out to me: function Note({id, shouldIncludeAuthor}) {
const note = use(fetchNote(id));
let byline = null;
if (shouldIncludeAuthor) {
const author = use(fetchNoteAuthor(note.authorId));
byline = <h2>{author.displayName}</h2>;
}
return (
<div>
<h1>{note.title}</h1>
{byline}
<section>{note.body}</section>
</div>
);
} Particularly, this line of code: const author = use(fetchNoteAuthor(note.authorId)); This implies that promises are unwrapped before rendering can continue since one would assume that In fact, later in the proposal, this is stated:
I understand this to mean that the component would suspend immediately for pending promises. Given this proposal from the React team (understanding this could change between now and release), I wonder if we should do the same (which would revert this proposal to an earlier version). @travigd you make a great point about accessing the data in event handlers. My guess is this kind of data access happens more often than multiple Relay seems to solve this use case with a completely different hook called @laverdet you do bring up an interesting point about introducing a completely new hook and something I've also considered (perhaps I should call out in this proposal). A new hook could afford us some advantages such as:
This does come at the cost of a larger API footprint, but might be worth the tradeoff. With the addition of the upcoming This also comes at the cost of allowing suspense to be globally enabled ( Some other interesting points:
This is interesting. While it make sense this would be a hook-only API, I'd be curious how the React team plans to enforce linting rules and compiler optimizations for libraries like Apollo that would consider using
I really like this point and something we are striving for with our implementation of a suspense query. We want it to feel like a sequential function, despite the async nature of queries. |
About API footprint I think making it a separate hook would actually reduce the footprint. The API is not the sum of all exported functions but is actually the sum of the possible About waterfalls with |
I think it definitely makes sense to block rendering for this case because the event handler depends on that data to function correctly — what should happen if the user clicks and the data hasn't returned yet? To bind the handler to the rendered DOM node, the data must be present. If you truly don't want the query to block rendering, I'll ask again — aren't |
Thats a really good point @laverdet.
Generally I agree with you, but I think there is a use case for this if we do end up embracing the Again, I think this global option is moot if we introduce a new hook instead. Opting into suspense would be just using the new hook, so the global option doesn't make sense in this case. |
@alessbell do you have any thoughts on this? You've got a bit more expertise in |
@adamesque I'll post a couple thoughts about your question (@alessbell feel free to correct me where I'm wrong).
I'm not sure
As for |
@adamesque Thanks for all your feedback here. We appreciate it! I just had a few clarifying questions.
The Apollo Client team has been thinking about |
We'd want to use Suspense everywhere, and const ByLine = forMessage => <div>{forMessage.author.name}</div>;
const Message = message => {
return <div>
<Suspense fallback={<Loading />}><ByLine forMessage={message} /></Suspense>
<p>{message.content}</p>
</div>;
};
const MessageList = () => {
const { getMessages } = useSuspenseQuery(GetMessages);
return <ul>{
getMessages.map(message => <li key={message.id}><Message message={message} /></li>)
}</ul>;
} I'd expect I would think a sound implementation would work the same way during SSR. I think that some users will just opt to omit |
@laverdet Thank you for the excellent example here! Super helpful. 🙏 Just one clarifying question in this example, this component wouldn't be a |
@jpvajda and @jerelmiller I appreciate the thoughtful comments & questions!
So we don't use @laverdet The potential problem I see in your example is that the I might prefer to rewrite your example as // This component suspends
const ByLine = ({authorId}) => {
// sugar wrapping the useFragment API for this component
// (the contract is cumbersome for an example)
const {data: name} = useByLineFragment(authorId);
return <div>{name}</div>;
};
// This component doesn't suspend
const Message = message => {
return <div>
<Suspense fallback={<Loading />}><ByLine authorId={message.author.id} /></Suspense>
</div>;
};
// This component suspends
const MessageList = () => {
const {data: messages} = useSuspenseQuery(GetMessages);
return <ul>{
messages.map(message => <li key={message.id}><Message message={message} /></li>)
}</ul>;
}; I also think you might want to express MessageList more like this: const App = () => {
useBackgroundQuery(GetMessages);
return <MessageList />;
}
const MessageList = () => {
const { data: messages } = useMessageListFragment();
return <ul>{
messages.map(message => <li key={message.id}><Message message={message} /></li>)
}</ul>;
} |
Hi! It would be useful to have loading state in case when suspensePolicy set to initial. Because in this case on refetch we do not fall in Suspense#fallback. |
@SiarheiLazakovich I've been thinking something similar would be nice in case you want to show some kind of UI indication that a fetch is happening. I'm considering exporting the I'd like to avoid the |
What's the state of SSR support in the current implementation? I suppose it might be tricky if you don't "own" the stream (e.g. like Next.js owning it), and we don't have a built-in API for transferring the data for hydration on the client. If it's not supported yet, it would be good if the Hook could throw in the SSR environment to avoid people relying on it accidentally. Otherwise (if the data isn't being serialized to the client), it would be very fragile because it would attempt fetching from both environments. |
One solution would be if the hook would give the user a component to be rendered which would render the necessary data inline. It's a manual way of doing it, but I think I could live with that. Another option is the "old-school" query component. That actual can render anything necessary. |
@gaearon we are still in the early stages of the SSR story as we've been really focused on ensuring it works as intended with all the features of Suspense itself (i.e. with We don't yet have a solution for streaming cache updates to the client, so you're correct, its likely very fragile right now since it likely fetches in both environments (I say likely because I'm still experimenting with SSR on my own and don't have a definitive answer on the current behavior). My current understanding of the problem is written up in the "How do we hydrate the client cache with data fetched server-side?" section in the original post. My thinking was that we wouldn't own the stream, but rather write to it in some way. I have a lingering sense that there is probably something I've completely missed with the mental model of SSR and how its intended to work with a client like ours, so feedback here would be helpful if you have any. I will definitely consider throwing on the server, or at the very least, making this a bit more "opt in" to avoid communicating that we've got this fully figured out. Appreciate the feedback there. |
Yeah that looks right to me. The problem right now is we don't offer any way to inject data into the stream, and reactwg/react-18#114 suggests writing your own Transform Stream, which (I think) you can experiment with — but isn't possible if the underlying stream isn't exposed (such as with a framework). I might be wrong though. But this seems like the first thing to try. |
This seemed like the right first step, so appreciate the confirmation that this is a good thing to try.
This was also my worry. My thinking is that we can at least start with support for users that have access to the stream (such as those that have their own SSR implementation) and we get in touch with frameworks (or you/the React team) to figure out how to approach this in the framework space. At least this problem isn't unique to Apollo Client, but we can at least contribute the conversation where helpful. |
@gaearon asked a few comments ago "what is the state of SSR implementation". I don't mean to interrupt this awesome discussion, but just wanted to make sure others understand that working with Apollo can be done through Next.js server components just like any other external service (though limited I'm sure on what can be done). I wrote a simple helper method that I call in my RSC's that does a POST to the GraphQL server and then returns the data to apollo client for processing. That is, the helper function is this: https://gist.github.com/pkellner/cf1154a7bfb1873253702b11c8df5fb1 and then call it like this in my RSC's. import React from "react";
import { commonGlobalDataQuery } from "../../gql/common/auth";
import { getDataFromGql } from "@/lib/getDataFromGql";
import { initializeApollo } from "@/lib/apolloClient";
import {getCodeCampYear} from "@/lib/utils";
initializeApollo();
async function getData() {
return await getDataFromGql(commonGlobalDataQuery,{},"commonGlobalData");
}
export default async function HomeHeader() {
//const authInfoValue: AuthInfo = useContext(AuthInfoContext);
const {
data: { codeCampYears: codeCampYears, configDataList: configDataList },
} = await getData();
... My initializeApollo code is similar to what is in Next.js examples here: https://github.com/vercel/next.js/tree/89fffa694dc238eb40bd52809c8cbcbc39c2ec8b/examples/api-routes-apollo-server-and-client |
We're using a custom browser and server link which transmits query results over a transform stream. This is basically what is proposed in the RFC so I just want to confirm that the results are very impressive. The custom link approach is really nice because it gracefully handles |
@laverdet that's really good to hear! I was hoping we could create a custom link to handle this, so I'm really glad to hear this is an approach that has worked. Do you pass in the stream provided from @pkellner thanks for posting that! Might be a decent stop-gap until we have a more "official" solution there. |
@laverdet , this does depend on the alpha of nextjs 13 though. I'm hoping it releases for production soon. |
Yes, basically. There's an extra layer of indirection because we publish non-Apollo information over the transport stream as well, so it's abstracted away into what we call the side-channel. It's all integrated into our frontend code so sharing is a bit difficult. There's also a good deal of "opinion" baked in. For example, we have some tricks in place to ensure that the HTML markup is sent before the JSON query result which isn't super obvious. This is what the server link looks like. We use a schema link so the query operations are being dispatched directly in the frontend service. The browser link accepts the side channel payloads and injects the query events into the link as it receives them. Obviously this isn't super useful without the other underlying implementations but the approach is sound. server.ts import { ApolloLink } from "@apollo/client/link/core/ApolloLink.js";
import { SchemaLink } from "@apollo/client/link/schema/index.js";
import { Observable } from "@apollo/client/utilities/observables/Observable.js";
// [...]
export async function makeServerTransportLink(pageContext: ServerPageContext) {
const context = contextFromParams({ ... });
const link = new SchemaLink({ context, schema });
return makeServerSendLink(pageContext).concat(link);
}
function makeServerSendLink(context: ServerPageContext) {
return new ApolloLink((operation, forward) => {
const id = context.apolloQueryId++;
const key = makeOperationKey(operation.query, operation.variables);
context.publishSideChannelPayload(GQL_QUERY, null, [ id, key ]);
const observable = forward(operation);
return new Observable(observer => {
const subscription = observable.subscribe(
result => {
context.publishSideChannelPayload(GQL_NEXT, key, [ id, result.data ]);
observer.next(result);
},
error => {
context.publishSideChannelPayload(GQL_ERROR, key, [ id, error ]);
observer.error(error);
},
() => {
context.publishSideChannelPayload(GQL_DONE, key, id);
observer.complete();
});
return () => subscription.unsubscribe();
});
});
} |
Awesome. Thanks for sharing @laverdet! |
Hey all 👋 We've got an exciting update to share! We've released an experimental package with support for Apollo Client with Next 13 and React Server Components. Check out the announcement blog post here: https://www.apollographql.com/blog/announcement/frontend/using-apollo-client-with-next-js-13-releasing-an-official-library-to-support-the-app-router/ This is our first step toward greater SSR and RSC integration with React. If you'd like to provide feedback or understand more of the interals, @phryneas has created an RFC in that repo for public view/comment. We'd love any and all feedback you may have! Next.js was a logical first step for us as most of the activity and questions around SSR support came from those using Next.js. We aren't finished and plan to bring this integration to more frameworks and solutions. As an update on the Suspense front, we are nearing completion on our Suspense functionality. We'll be releasing a beta v3.8 version in the next couple weeks that delivers on our Suspense story. Stay tuned for more information as we we get ready for this stage. If you haven't tried out our alpha and would like to provide feedback and/or report bugs, please do so by creating an issue in this repository. Thanks! |
Hi everyone, a quick update on two new hooks for fetching/reading data with Suspense: API documentation and a general overview are available here. What do these hooks do? To quote the docs:
We're pretty excited about them. As always, you can try them out via |
Hey all 👋 Its been a few and I wanted to provide an update on progress. For those unaware, we recently started shipping beta versions of our next minor release v3.8. This is a huge milestone as this means we consider v3.8 feature complete 🎉. A few highlights from the betas that I wanted to call out related to Suspense:
import {
useSuspenseQuery,
useBackgroundQuery,
useReadQuery,
} from '@apollo/client'; This was an important change as it signifies that we consider these hooks stable.
We hope to start shipping a release candidate for v3.8 in the next week or two. We'd appreciate any and all feedback and/or bug reports to ensure stability in this release. It has certainly shaped up to be one of our biggest minor releases to date! For those interested in our progress, feel free to follow our v3.8 milestone. It's been a long journey, but we can't wait to get this into the hands of everyone! Thanks to all of you that have tried out the alphas/betas and provided feedback and bug reports. It has been tremendously helpful! |
Hey all 👋 Another exciting update to share! We released our first v3.8 release candidate version today 🎉! You can try this release out with the
As such, we've frozen changes to our v3.7.x version in anticipation of a stable release of v3.8 in the next couple weeks. I'll highlight a tweak we've made to the setup process in the release candidate version that I believe streamlines the Suspense feature a bit more. In previous prerelease versions, you'd need to create a import { ApolloClient, ApolloProvider, SuspenseCache } from '@apollo/client';
const suspenseCache = new SuspenseCache();
const client = new ApolloClient({ ... });
// ...
<ApolloProvider client={client} suspenseCache={suspenseCache} /> With the release candidate, we now transparently create the Suspense Cache behind-the-scenes the first time you use a Suspense-related feature. Now all you need is to import one of the new hooks and start using it! If you're using an -import { SuspenseCache } from '@apollo/client';
-const suspenseCache = new SuspenseCache();
-<ApolloProvider client={client} suspenseCache={suspenseCache} />;
+<ApolloProvider client={client} />; We've provided a deprecation error to make this transition obvious. As a result, the As of this release candidate, we have worked through all known Suspense-related issues and completed all the work planned for our next minor release. We will now be furiously writing and finishing up documentation for all features in this release. Unless we get newly reported bugs for this release candidate, this version will essentially become the v3.8 stable version. If you'd like to help us out, please try this version out and let us know if you find any bugs! We can't wait to get this out to you all 🎉. Thanks! |
I encountered a weird issue with I had two components, identical in the implementation, calling two different queries, both using Took a long time to try to figure out the difference between the two before I found out that the GraphQL server was taking less than 150ms to resolve the query of the working component, while on the other hand the other query was taking more than 1.5s for the GraphQL endpoint to send the response. It seems that at some point there is a timeout and it fallbacks to client-side rendering. Is that something we can prevent? |
@XavierLeTohic This sounds like you are making that request on the client and server simultaneously, which kinda defeats the purpose of SSR. |
@XavierLeTohic would you mind opening a separate issue for this? I'd like to keep this issue related to the suspense implementation + updates. Doing so would help us track this more effectively. Thanks! |
We're proud to announce the public release of Apollo Client 3.8 with Suspense integration 🎉!! Thanks so much to all of you that tried out the prereleases and provided feedback! For more information on this release, including the other features we released, please see the announcement post. Since we are now public with this release, I'm going to go ahead and close out this issue. We will continue to iterate on our Suspense integration in future versions so please keep an eye out for additional announcements. Feel free to open new issues for bug reports and reach out to us for any feedback you may have. Thanks! |
This issue has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs. |
This RFC replaces the previous conversations on React 18 SSR (#8365) and React Suspense (#9627).
Background
React 18 brings some new capabilities to SSR primarily with the help of React suspense. Among these is the ability to handle streaming HTML with the newly introduced
renderToPipeableStream
function. This relies onSuspense
to determine which fallback boundaries will be returned with the initial HTML chunk.You can find information about the architecture in this discussion along with a demo of this functionality created by the React team here. An upgrade guide is also provided to give an idea of how these APIs are used.
Technical guidance
Adding proper React 18 SSR support means adding support for React suspense (#9627). Once we build out our suspense solution, this should get us most of the way to SSR support. At this time, it's unclear to me what will be lacking in the SSR context once we implement SSR. We will be able to learn this once we start work on the feature.
Here are some ideas on how we might approach this from a high-level technical/API standpoint.
API
Suspense-enabled queries should be opt-in. Because React requires a
<Suspense />
boundary to properly work when a component suspends, there will need to be code modifications. Allowing developers to opt-in would allow the developer to add suspense support at their own pace.We want to enable the use of suspense by introducing a new hook called
useSuspenseQuery
:Using
useSuspenseQuery
will require use of a suspense cache to keep track of in-flight requests. This will be instantiated and passed to the<ApolloProvider />
component.Supported options
The new
useSuspenseQuery
hook aims to feel similar touseQuery
, so it should operate with a similar set of options. Below is the list ofuseQuery
options that we plan to support withuseSuspenseQuery
Existing options
useQuery
optionuseSuspenseQuery
optionuseSuspenseQuery
would "just work" with SSR so we'd like to avoid this option. We will re-evaluate with feedback over time.Notable changes in behavior from
useQuery
Using a suspense query will differ from
useQuery
in the following ways:With a suspense enabled query, a promise is thrown and the nearest
<Suspense />
boundary fallback is rendered. Because suspense now handles the loading state for us, we no longer need aloading
boolean returned in the result.data
should no longer beundefined
A pending promise in a suspense query will suspend the component (i.e. throw the promise), so we can guarantee that once the data is resolved successfully, we have non-null data. Using suspense will allow a developer to remove
!data
checks in render.Caveat
When using the
skip
option, or when you have used a differenterrorPolicy
than the default,data
may still beundefined
. This principle only applies to the default behavior.useSuspenseQuery
hooks in the same component will result in a request waterfallAs a best practice, we should avoid recommending the use of multiple
useSuspenseQuery
hooks in the same component.useSuspenseQuery
will suspend immediately, which means calls to otheruseSuspenseQuery
hooks in the same component won't run until previous calls have been resolved.Instead, we should recommend using separate components surrounded by a suspense boundary to fetch data in parallel.
As encouraged by some of the early suspense docs, rejected promises will result in errors, which means an error boundary should be used to capture the error state. If a
useSuspenseQuery
fulfills with a rejected promise, we throw that error. You can see an example of this behavior in a demo provided by Kent C. Dodds via an Egghead tutorial.Though we will throw as the default behavior, we want to enable users to have control over how errors are handled. We should respect the error policy set by the user.
none
ignore
all
useQuery
, this will allow you to get partial data results alongside the error via theerror
property returned byuseSuspenseQuery
getDataFromTree
With the new architecture, we no longer need our 2-pass rendering approach as React 18 SSR uses streaming HTML and suspense boundaries. We can no longer rely on this behavior since rendering is no longer synchronous while using
renderToPipeableStream
.We should consider deprecating this function (and the related
renderToStringWithData
) and encourage others to migrate torenderToPipeableStream
. Perhaps this is something we ultimately move to a separate bundle for backwards compatibility in a future major version of Apollo for those that need support for synchronous rendering.ssrMode
option inApolloClient
I don't have a lot of context for what
ssrMode
does under the hood, but this might be an opportunity to deprecate this flag in our options to theApolloClient
class. If we can pull this off, this gets us a step closer to allowing user to share an Apollo client between the server and the client.Working with the cache
How we return data from a suspense-enabled query depends on the fetch policy specified for the query. When using a fetch policy that reads from the cache, avoid suspending the component and return cached data if the data is already in the cache. For policies that avoid the cache, always suspend the component.
Fetch policies
Supported fetch policies
cache-first
cache-only
cache-and-network
network-only
no-cache
standby
Fetch policy behaviors
Building a promise cache
We will need a way to cache the promises thrown by
useQuery
when the hook is run. Apollo has the ability to deduplicate queries across components, which means it's possible more than one component by rely on the same promise to resolve. We need a way to associate queries with their promises so that if components are rendered, we can look them up and throw them if necessary to suspend the component. React 18 concurrency features also may determine that a component should be re-rendered at any time. We want to ensure any suspended component that attempts to be rendered is able to properly look up a pending promise and re-throw if necessary.We will need this to be a React-only feature, so adding this to something like
QueryManager
won't work since its part of core. Perhaps this is something we consider initializing inApolloProvider
.There is a RFC for a built-in suspense cache, but this is still a ways off. We will need to build our own until this is in place.
This is also particularly important if we want to enable the render-as-you-fetch pattern in Apollo.
Usage with
@defer
useSuspenseQuery
should aim to take advantage of the benefits@defer
provide, namely being able to render UI when the first chunk of data is returned. Because of this, we should avoid suspending for the entirety of the request, or we risk negating the benefits of the deferred query. Instead, we should only suspend until the first chunk of data is received, then rerender as subsequent chunks are loaded. We also plan to add support for suspense inuseFragment
to allow deferred chunks to suspend as they are being loaded.Roughly, this should work as follows:
Usage with fetch policies
If using a fetch policy that reads from the cache, we should still try and read the entirety of the query from the cache. If we do not have the data, or only have partial data, fetch and suspend like normal. Leverage the
returnPartialData
option if you'd like to avoid suspending when partial data is in the cache.Error handling
Error handling is trickier since the initial chunk could succeed while deferred chunks return with errors. I propose the following rules:
errorPolicy
set in options (see above for more detail on error policies).error
property returned fromuseSuspenseQuery
. Throwing the error will depend on theerrorPolicy
.none
(the default), discard any partial data results and throw the error.ignore
, discard all errors and return any data collected thus far (this might result in an incomplete query).all
, add all partial data results and collect all errors in theerror
property.SSR
The above implementation should get us most, if not all the way, there for SSR support. The one real outstanding question is how we populate the cache client-side once SSR completes so that we can avoid a refetch. See the
Outstanding Questions
section below for more information on this question.Other considerations
Render-as-you-fetch pattern
Up to this point, our Apollo client supports the fetch-on-render pattern, which might introduce request waterfalls depending on how an app is structured. With the introduction of Suspense, we should be able to enable the render-as-you-fetch pattern, which allows data to be loaded ahead of time so that we can begin to render a component as data is being fetched.
This is a pattern we should explore as its now the recommended approach since it allows us to show content to the user sooner and load data in parallel.
Outstanding questions
How do we hydrate the client cache with data fetched server-side?
Our current solution fetches all data in a 2-pass approach via
getDataFromTree
. Using this method, we are able to detect when all queries have resolved before we send the rendered HTML. Because we are using synchronous rendering APIs, we are able to detect when rendering is complete and send the markup complete with the extracted cache data on a global variable.React 18 makes this a lot trickier as the new architecture allows for the ability to stream HTML while the client begins to hydrate. Waiting for React to fully finish streaming the HTML in order to restore the client Apollo cache feels too late as its possible hydrated components may already begin to execute on the client.
On the flip side, React 18’s new
renderToPipeableStream
does include anonShellReady
callback but it appears this might fire too early. From the docs:I come across this discussion with some interesting comments about data hydration:
I believe this is something we will continue to learn about as we begin implementation.
@benjamn has proposed an interesting idea of potentially transmitting the whole query results produced via SSR rather than normalized cache results that would need to be serialized. Queries might then be able to use these query results immediately which would then make their way into the cache. This is something we should consider via an
ApolloLink
.What other hooks allow a component to suspend?
useLazyQuery
We have this example in our docs:
This implies that the general usage of
useLazyQuery
is in response to user interaction. Because of this, I'm inclined to say that we not add suspense support foruseLazyQuery
and let it operate as it does today to allow for a better user experience. If we decided to suspend the component, this would result in the already displayed UI being unmounted and the suspense fallback displayed instead. This seems to be more in line with howstartTransition
works within suspense as it avoids rendering the suspense fallback when possible.That being said, I wonder if we should consider using
startTransition
to allow React to determine whether to suspend or not (if possible). Per the docs:Its unclear to me how exactly this works if the same hook allows the component to suspend, but would be interesting to explore nonetheless.
useMutation
This is a write operation and therefore should not suspend a component. Mutations are typically used in response to user interaction anyways. This behavior would be consistent with a library like Relay which only uses suspense for queries.
Existing SSR support
Our current solution uses an exported function called
getDataFromTree
that allows us to use a 2-pass rendering approach. This relies on React synchronous rendering viarenderToStaticMarkup
and attempts to wait until all data is fetched in the React tree before resolving (renderToString
usage is also available viarenderToStringWithData
)This is NOT affected in React 18 and will continue to work, though it is not the recommended solution. In React 17, any use of
Suspense
withrenderToString
orrenderToStaticMarkup
would result in an error. In React 18, this changed and these functions now support very limitedSuspense
support.You can see a the existing functionality working with React 18 in a demo I put together.
Communication
React 18 SSR suspense support (and broad data-fetching support) is still very experimental. There are no formal guidelines yet given by the React team, so adding support means we will need to be reactive to breaking changes within React itself. Adding suspense to Apollo however might encourage the React team to move this functionality out of beta.
Once we go live with this functionality, assuming the React team hasn't introduced formal guidelines, we will need a way to communicate this to our broader audience. Below are some references to how other libraries do this:
I particularly like the way React Query states this:
Support for suspense in other libraries
This shows the current landscape of client suspense + SSR suspense support in other libraries. In this context, client suspense means it takes advantage of
<Suspense />
boundaries for data fetching and SSR suspense means that it supports React 18 SSR features (i.e. streaming HTML viarenderToPipeableStream
with<Suspense />
boundaries)urql
react-ssr-prepass
is currently advised, but also works withrenderToPipeableStream
. Demoreact-ssr-prepass
for a 2-pass rendering approachmicro-graphql-react
References
The text was updated successfully, but these errors were encountered: