Skip to content
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

How do we do server-side rendering with Relay Modern? #1881

Open
taion opened this issue Jun 12, 2017 · 61 comments
Open

How do we do server-side rendering with Relay Modern? #1881

taion opened this issue Jun 12, 2017 · 61 comments

Comments

@taion
Copy link
Contributor

taion commented Jun 12, 2017

This is sort of a follow-up to #1687 and #1760.

What is a recommended pattern for handling server-side rendering in a simple request-response paradigm with Relay Modern?

RecordStore seems to expose capabilities for serializing and deserializing its contents, and I can use a preloaded RecordStore when building a new Environment, but this is a bit deceptive, since without #1760, my <QueryRenderer>s will ignore the preloaded records... but this capability must be there for a reason?

But thinking about this more, I'm not even sure the approach in #1760 is correct. For a streaming network layer, it seems like it would be wrong to omit sending out the request just because I have preloaded data – instead I'd just want the server to e.g. not send me the initial payload (because I already have it).

Something like the query response cache seems more promising, but the bundled one doesn't really expose hooks for exporting its contents, plus a key/value cache with a TTL isn't exactly the right model here anyway.

Am I missing something?

@taion
Copy link
Contributor Author

taion commented Jun 12, 2017

To get by for now, I'm using this code: https://github.com/taion/relay-todomvc/blob/found-modern-universal/src/fetcher.js

Essentially a one-time use response cache, that uses the order of the requests (which for these purposes will be deterministic) as the cache keys, and expunges records from the cache as they're consumed.

Is this the right sort of approach?

@andiwinata
Copy link

Yeah, I'm interested to know how to do it properly.

Right now I'm sort of following this but in a bit different way.

Basically I'm making request to graphql server using environment._network.fetch(query, variable) (seems really hacky accessing private variable) where query is created by generated relay (graphql `query {...fragment}`), and then proceed rendering to client when all the data fetched...

I'm not even sure if I'm using relay properly with those cache system, refetch and pagination later on. I'm also not using createFragmentContainer or QueryRenderer, since I feel they are optional. I wish there were more documentation about relay modern :/

@TimoRuetten
Copy link

Did you found a good way to solve the SSR problem with Modern Relay ? Would be awesome when facebook would be also interested in SSR for Relay and could do some documentation on how to solve it.

@taion
Copy link
Contributor Author

taion commented Jun 30, 2017

I think something like what I have for my Relay-TodoMVC example is basically correct. It just needs to be hardened to deal with errors and such.

Just checking if there's any feedback on anything I'm missing, though.

@chadfurman
Copy link

So let me get this straight.

You're querying the GQL endpoint on the server, dumping the responses into an array that you serialize to the client, and then when the client loads and goes to execute a GQ, first it dumps all payloads into the Relay Cache on the client and then continues to try a normal "fetch" which wouldn't actually need to hit the API if the payloads from the server give all the data needed for the query on the client...

Right?

@taion
Copy link
Contributor Author

taion commented Aug 10, 2017

You should follow the pattern in https://github.com/4Catalyzer/found-relay/blob/master/examples/todomvc-modern-universal/src/fetcher.js for now. Note the caching is at the level of the network requests, not the Relay data.

@steida
Copy link

steida commented Aug 10, 2017

Or check https://github.com/este/este

@chadfurman
Copy link

@taion I really like the pattern there and I am asking these questions to assist in my attempt to follow the pattern of your fetcher.

The ServerFetcher builds an array of payloads that gets returned to the server.js file:
https://github.com/4Catalyzer/found-relay/blob/master/examples/todomvc-modern-universal/src/fetcher.js#L36

which prints the payloads to the page for the client-side JavaScript:
https://github.com/4Catalyzer/found-relay/blob/master/examples/todomvc-modern-universal/src/server.js#L79

The client-side JS initializes a ClientFetcher with the payload and the API URL and then immediately creates a resolver with the fetcher:
https://github.com/4Catalyzer/found-relay/blob/master/examples/todomvc-modern-universal/src/client.js#L18-19

So my question is how are the payloads here being accounted for: https://github.com/4Catalyzer/found-relay/blob/master/examples/todomvc-modern-universal/src/fetcher.js#L52 ?

Is there a loop somewhere that calls fetch until the payload list is empty, and then continues with other queries on the page?

How would I do this with React Router 4? https://github.com/4Catalyzer/found-relay/blob/master/examples/todomvc-modern-universal/src/client.js#L21 is assuming I'm using a farce router -- the main change that is necessary is to account for the resolver in RR4. Any advice in this regard?

@taion
Copy link
Contributor Author

taion commented Aug 10, 2017

The ClientFetcher takes advantage of how requests go out in the same order. It takes the initial responses from the server, and just returns those as the responses for the first few requests from the client.

I would recommend not using RRv4 with Relay if you're using routes with nested requests, as it's not certain the same guarantees will apply, and you'll potentially have issues with request waterfalls. See https://facebook.github.io/relay/docs/routing.html#nested-routes for more details.

@chadfurman
Copy link

At the moment I'm not experiencing request waterfalls, as my server renders one giant request per route. Request waterfalls are something I will watch out for as I continue to build out the SPA, and if I encounter such a situation I will switch to using found.

Thank you so much, @taion, as I now understand how this works. I might cheat a little bit and add something to the ClientFetcher container which just "fetches" all the payloads when init'd. From there, all subsequent queries should hit the cache and get what they need without any concern for ordering. Yes?

@chadfurman
Copy link

oh, and what do you mean by, "Note the caching is at the level of the network requests, not the Relay data." ?

@taion
Copy link
Contributor Author

taion commented Aug 10, 2017

As in the fetcher handles the caching... so really it's your network layer that's handling it, and it only knows about requests, not about the schema per se.

@chadfurman
Copy link

Ahh I see, so the same request generates the same response, and it's the request->resposne pair that's cached as opposed to some form of data tree with merges / diffs.

Did you ever have any luck with the QueryRenderer @taion ? Currently, I have one request per route; however, this only triggers on the server. The front-end I plan to use FragmentContainers on, which should trigger appropriate queries as they render in? Will I still need a QueryRenderer somewhere? I've had a heck of a time getting the QueryRenderer to work with SSR...

@chadfurman
Copy link

I'm reading #1687, #1760, and https://github.com/robrichard/relay-query-lookup-renderer right now to try and get caught up with everything discussed so far.

@johanobergman
Copy link

I was able to get server rendering working by using slightly modified versions of the query-lookup-renderer and getDataFromTree from react-apollo. Basically I added a hook to the queryrenderer to get a promise for its query, and walk the render tree waiting for each queryrenderer to finish before continuing. This saves the data in the relay store instead of at the network layer, and has worked pretty well for me.

@taion How do you solve the initial render, as the network layer is asynchronous?

@taion
Copy link
Contributor Author

taion commented Aug 11, 2017

@chadfurman
Copy link

chadfurman commented Aug 12, 2017

Okay I did manage to get this working to an extent using the QueryLookupRenderer -- but I end up stuck with a stagnant cache after page load. I have it temporarily triggering page reloads when the data changes, and alternatively plan to not use the cache at all; however, a long-term solution would be ideal.

Note that I went with the QueryLookupRenderer only because my current codebase is in React Router 4. I believe found-relay to be my eventual goal, and with it perhaps the simpler version of the network caching from above will be sufficient. But one thing at a time.

@johanobergman
Copy link

@chadfurman Make sure the QueryRenderer only looks up from the cache on initial load. If you're having trouble with stagnant cache after mutations, I believe there's a bug in query-lookup-renderer. I had to make sure to subscribe to the snapshot that's looked up:

const snapshot = environment.lookup(operation.fragment);
this._rootSubscription = environment.subscribe(snapshot, this._onChange);

in the QueryRenderer constructor.

@chadfurman
Copy link

@johanobergman my problem was that my QueryRenderer was using an environment defined in global scope, so when my router loaded the next page, it wasn't using the new environment with the JWT embedded in the network layer, and thus was getting a null profile every time. When I made it use environment that gets rebuilt when the JWT is set, then with that environment the QueryRenderer pulled the right profile on navigation.

@st0ffern
Copy link

st0ffern commented Dec 4, 2017

@taion i think one of the main things about SSR is SEO. Sending relay payload to the client and then hydrate there works for a user but not a crawler. right?

What is bad about doing this?
https://github.com/yusinto/relay-modern-ssr/blob/master/src/server/server.js#L56-L86

@taion
Copy link
Contributor Author

taion commented Dec 4, 2017

Hydration is to get the site working again on the client. For pure SSR, it’s just the markup you care about. You hydrate to pick things up on the client, not for the initial render.

@simenbrekken
Copy link

Here's an example utilizing Next.js which should be pretty easy to follow: https://github.com/zeit/next.js/tree/canary/examples/with-relay-modern

@taion
Copy link
Contributor Author

taion commented Dec 5, 2017

That's not a very good implementation... you're not caching the response results at all, so in fact you block on the client-side render until the initial fetch succeeds.

Hydrating the store in this method: https://github.com/zeit/next.js/blob/canary/examples/with-relay-modern/lib/createRelayEnvironment.js#L30 is basically a no-op, as I noted in the OP.

In other words, as far as I know, you still need something like https://github.com/4Catalyzer/found-relay/blob/v0.3.0-alpha.9/examples/todomvc-modern-universal/src/fetcher.js to cache the responses, unless you want to block interaction on the client-side until that data fetch succeeds, which would be a pointless delay to interactivity.

@chadfurman
Copy link

chadfurman commented Dec 5, 2017

My main concern with fetcher.js is XSS -- I'm not sure how to get the server's payloads rendered to the client.

JSON.stringify alone is not enough, as you can see in the redux docs:

        <script>
          // WARNING: See the following for security issues around embedding JSON in HTML:
          // http://redux.js.org/docs/recipes/ServerRendering.html#security-considerations
          window.__PRELOADED_STATE__ = ${JSON.stringify(preloadedState).replace(/</g, '\\u003c')}
        </script>

Maybe I'll use yahoo serialize? https://medium.com/node-security/the-most-common-xss-vulnerability-in-react-js-applications-2bdffbcc1fa0

@taion
Copy link
Contributor Author

taion commented Dec 5, 2017

Yes – that's exactly what I do in https://github.com/4Catalyzer/found-relay/blob/v0.3.0-alpha.9/examples/todomvc-modern-universal/src/server.js#L79 (though serialize-javascript per that example is a better option than the Yahoo serialize library, which adds a lot more overhead than is necessary).

The point of this issue is that, ATM, the only options are to either do this bit with the requests, or to use the lookup query renderer. The former is a bit annoying, but the latter has some edge cases since you're bypassing the normal Relay request flow.

@graingert
Copy link
Contributor

also you can do:

<script src="path/to/bundle.json" data-data="${escape(JSON.stringify(preloadedState))}"></script>

then in your bundle use document.currentScript

@graingert
Copy link
Contributor

I didn't mean window.escape, just some html escape function

@mike-marcacci
Copy link
Contributor

@petesaia that’s an interesting approach, but adds a 200ms race to each SSR request. My approach uses renderToString to trigger relay requests. When all requests are finished our relay network handler triggers another renderToString. If this causes more relay requests, the cycle repeats. We clamp this at 3 iterations right now, but don’t have a situation that requires more than 2. I’ve been curious about the performance between my strategy and one the uses jsdom, but haven’t had time to test. I’ll post a gist up when I get a chance.

Our reasons are SEO and initial page load; while we employ several cache strategies (CDN, service worker, app cache, etc) our app bundle is quite large and takes a while to download and parse. This makes for a really frustrating first interaction, especially on spotty mobile networks, which is an important target for us.

Additionally, time to first meaningful render is used as a factor when ranking search results for mobile.

@grydstedt
Copy link

grydstedt commented Mar 8, 2018

Thanks @mike-marcacci! Yeah, @taion, I'm starting to consider going that route. With the request caching is there some worry with the request order, not sure how deterministic it is?

@st0ffern
Copy link

st0ffern commented Mar 8, 2018

When setting up a server i think that this issue, and other issues such as serving multiple apps, authenticated react apps, relay integration and so on should be handled by a complete server package. And you should be able to edit it all after your needs.

I have done this here: https://github.com/velop-io/server

@taion would you like to look into it hand take part as a collaborator?

@taion
Copy link
Contributor Author

taion commented Mar 8, 2018

@grydstedt The order in which requests get sent out is deterministic, so the request caching approach is fine as long as the requests are the same. It's the same as how it worked with isomorphic-relay-router.

@Stoffern I don't think there's much specific to the server here. For example, something like https://github.com/4Catalyzer/found-relay/blob/v0.3.0-alpha.11/examples/todomvc-modern-universal/src/server.js just looks like a standard React "server rendering" example.

@nodkz
Copy link
Contributor

nodkz commented Mar 15, 2018

Guys, you may test a beta version of my SSR middleware for react-relay-network-modern
https://github.com/nodkz/react-relay-network-modern-ssr

API will be changed a little bit, but this is working solution which I use in my app.

@damassi
Copy link
Contributor

damassi commented Apr 26, 2018

@nodkz - awesome! gonna take this for a spin right now

@koistya
Copy link

koistya commented Jul 9, 2019

I have been using this Create React App + Relay + GraphQL.js + SSR template for all of my consultancy projects during the last few years, and it works super well. Check it out:

https://github.com/kriasoft/react-firebase-starter (✮ 4k)

A couple of notable things that it does:

  • Adds server-side rendering and GraphQL API support (src/server/api.js) to Create React App without a need to create some extra projects.
  • It uses GraphQL.js schema directly during SSR (no interop, see src/server/relay.js)
  • Hydrates Relay response payload and inject it into HTML during SSR (src/server/ssr.js) + re-hydration on the client during the initial (first) render.
  • No more than one API call to the server on each screen display (transition).
  • When the user transitions to a new page (screen) it loads missing JS chunks and data in parallel.

Basically, declare your routes somewhat similar to this (they can have any shape/form you like):

const routes = [
  {
    path: '/products/:orderBy(featured|newest)?',
    query: graphql`
      query ProductsQuery($first: number, $orderBy: ProductsOrederBy) {
        products(first: $first, orderBy: $orderBy) {
          ...ProductList_products
        }
      }
    `,
    variables: ctx => ({ first: 50, orderBy: ctx.params.orderBy }),
    components: () => [import(...), import(...)], // .js chunks for the route
    render(components, data, ctx) {
      return {
        title: '...',
        body: <ProductList productsRef={data.products} />,
      };
    }
  },
  ...
];

Tell the router what needs to happen when a route is matched to the provided URL (src/router.js).

import UniversalRouter from 'universal-router';
import { createOperationDescriptor, getRequest, fetchQuery } from 'relay-runtime';
import routes from './routes';

export default new UniversalRouter(routes, {
  resolveRoute(ctx) {
    const { route, params, relay } = ctx;
    ...
    let dataPromise = null;
    let componentsPromises = ....;

    if (route.query) {
      const request = getRequest(route.query);
      const operation = createOperationDescriptor(request, variables);
      const lookup = relay.lookup(operation.fragment, operation);
      const response = fetchQuery(relay, route.query, variables);
      dataPromise = lookup.isMissingData ? response : lookup.data;
    }

    return Promise.all([...componentsPromsies, dataPromise]).then(...);
  }
}

Then bootstrap the router when application starts (src/index.js):

import { createBrowserHistory } from 'history';
import router from './router';

function render(location) {
  router.resolve(location).then(...).catch(...);
}

const history = createBrowserHistory();

history.listen(render);
render(history.location);
``

@stan-sack
Copy link

Has anyone been able to use a refetch container or pagination container with https://github.com/zeit/next.js/tree/canary/examples/with-relay-modern. I can't work out how to do anything other than a basic query.

@sibelius sibelius added the docs label Aug 28, 2019
@wasd171
Copy link
Contributor

wasd171 commented Sep 3, 2019

Unfortunately I am unable to use createFragmentContainer, as referred in relay-tools/react-relay-network-modern-ssr#16 . I've tried the following options:

  • using network cache – does not work nicely with Next.js since even with the cache containing the value for query the QueryRenderer returns the props: null when just mounted
  • passing the response received through fetchQuery directly to the corresponding components – works per se, but I get a lot of errors from createFragmentContainer that it expects a different shape (since the response doesn't contain fields like __fragmentOwner)
  • recreating the store with pre-filled records – even with dataFrom = "STORE_THEN_NETWORK" it returns props: null

I hope that someone had more success and is willing to share the experience!

@sibelius
Copy link
Contributor

sibelius commented Sep 3, 2019

can you try to use the community hooks version https://github.com/relay-tools/relay-hooks?

@taion
Copy link
Contributor Author

taion commented Sep 3, 2019

This seems likely to be specific to the setup with Next. Are you properly setting up the query renderer context?

@sibelius
Copy link
Contributor

sibelius commented Sep 3, 2019

can you try the new hooks version b83aace?

@wasd171
Copy link
Contributor

wasd171 commented Sep 4, 2019

After playing around I was able to get it working (non-hooks version) in Next.js with the following technique:

On the server I do:

  • create an environment
  • await fetchQuery
  • get records as const records = environment.getStore().getSource()
  • stringify them and pass as the output of App.getInitialProps (didn't work if passed just like an object!)
  • in App.render do JSON.parse of the stringified records and create const source = new RecordSource(records)
  • pass the environment to the QueryRenderer that has dataFrom = "STORE_THEN_NETWORK"

This would provide props that are not null on the client
However! Props would remain null on the server
In order to solve that I took a piece of code from https://github.com/relay-tools/relay-query-lookup-renderer/blob/a9a4ed636a862ff48d8aaf0719aa29c3dfaf86aa/src/index.js#L113

await fetchQuery(
	environment,
	initialProps.gqlQuery,
	initialProps.gqlVariables
)

const records = environment.getStore().getSource()
stringifiedRecords = JSON.stringify(records)

const operation = createOperationDescriptor(
	getRequest(initialProps.gqlQuery), 
	initialProps.gqlVariables
)
const snapshot = environment.lookup(operation.fragment, operation)
initialProps.gqlResponse = snapshot.data

And the QueryRenderer looks like the following:

<QueryRenderer<feedQuery>
	environment={environment}
	query={gqlQuery}
	variables={gqlVariables}
	dataFrom="STORE_THEN_NETWORK"
	render={({ props, error }) => {
		const data = props || gqlResponse // gqlResponse comes from snapshot.data

                console.log('props', !!props)
		console.log('response', !!gqlResponse)

		if (error) {
			return null
		} else if (data) {
			return <Feed data={data} />
		} else {
			return null
		}
	}}
/>

I get the following logs:

  • server
props false
response true
  • client
props true
response true

Changing dataFrom to "NETWORK_ONLY" results in the same output for the client and server:

props false
response true

@robrichard
Copy link
Contributor

You can try something like this, rendering ReactRelayContext.Provider instead of rendering a <QueryRenderer>.

// on server fetch data
const data = await fetchQuery(
    environment,
    initialProps.gqlQuery,
    initialProps.gqlVariables
)

// on client
const operation = createOperationDescriptor(
	getRequest(initialProps.gqlQuery), 
	initialProps.gqlVariables
)
const data = environment.lookup(operation.fragment, operation).data;

// server and client
<ReactRelayContext.Provider value={{environment}}>
    <Feed data={data} />
</ReactRelayContext.Provider>

@wasd171
Copy link
Contributor

wasd171 commented Sep 4, 2019

@robrichard this works like a charm!

@snrbrnjna
Copy link

pretty sure, that the problems here are solved with this PR #2883

@matthieu-foucault
Copy link

@wasd171 any chance you could share a working example?

@maraisr
Copy link
Contributor

maraisr commented Nov 6, 2019

@wasd171 how did you get this not to blow out of control over time, or is the source, store and environment re-created per request?

const records = environment.getStore().getSource()
stringifiedRecords = JSON.stringify(records)

@sibelius
Copy link
Contributor

@robrichard do you have a wrapper component that handle this behavior different on server vs on client?

@sibelius
Copy link
Contributor

I've wrote a blog post how I make Relay work well with SSR

https://dev.to/sibelius/adding-server-side-rendering-to-a-relay-production-app-30oc
https://medium.com/@sibelius/adding-server-side-rendering-to-a-relay-production-app-8df64495aebf

anybody wanna take a stab to add this to the official docs?

@stale
Copy link

stale bot commented Dec 25, 2020

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

@stale stale bot added the wontfix label Dec 25, 2020
@koistya
Copy link

koistya commented Feb 22, 2021

Here is yet another example of how to hydrate/dehydrate Relay store at CDN edge locations using Cloudflare Workers.

kriasoft/graphql-starter-kit#277 See web/main.ts, web/core/router.ts, web/proxy/index.ts, web/proxy/transform.ts.

@stale stale bot removed the wontfix label Feb 22, 2021
@stale
Copy link

stale bot commented Jan 9, 2022

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

@stale stale bot added the wontfix label Jan 9, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests