facebook / relay

Relay is a JavaScript framework for building data-driven React applications.
https://relay.dev
MIT License
18.28k stars 1.81k forks source link

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

Open taion opened 7 years ago

taion commented 7 years ago

This is sort of a follow-up to https://github.com/facebook/relay/issues/1687 and https://github.com/facebook/relay/pull/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 https://github.com/facebook/relay/pull/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 commented 7 years ago

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 commented 7 years ago

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 commented 7 years ago

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 commented 7 years ago

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 commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

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

chadfurman commented 6 years ago

@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 commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

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

taion commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

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

johanobergman commented 6 years ago

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 commented 6 years ago

See https://github.com/4Catalyzer/found-relay/tree/master/examples/todomvc-modern-universal.

chadfurman commented 6 years ago

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 commented 6 years ago

@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 commented 6 years ago

@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 commented 6 years ago

@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 commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

also you can do:

then in your bundle use document.currentScript

graingert commented 6 years ago

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

mike-marcacci commented 6 years ago

@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.

graingert commented 6 years ago

With relay you shouldn't be waterfalling at all: each top level container should be able to load the entire state from the URL

On 15 Feb 2018 23:12, "Mike Marcacci" notifications@github.com wrote:

@petesaia https://github.com/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.

— You are receiving this because you commented. Reply to this email directly, view it on GitHub https://github.com/facebook/relay/issues/1881#issuecomment-366093533, or mute the thread https://github.com/notifications/unsubscribe-auth/AAZQTCk_ryjeSKgjXb_6BH-euEkIJuU3ks5tVLnPgaJpZM4N3WlE .

taion commented 6 years ago

If you're doing nested routes, and you have a query renderer per route, you're going to end up with waterfalls unless you're using something like Found Relay.

Same deal as with react-router-relay back when it was Relay Classic.

psaia commented 6 years ago

@mike-marcacci Understood. For what it's worth, I was able to change the timeout from 200 to 1ms (and updated the gist) and I get a full render. This number will entirely depend on your app. E.g. if you have a timeout for 5 seconds for something to show, you'd have to wait 5 seconds for that thing to render.

mike-marcacci commented 6 years ago

@graingert ya, I know that ideally this wouldn’t be the case, but we have multiple independent (but concurrent) QueryRenderers which are only instantiates after an authentication session is loaded (via a top-level QueryRenderer). There’s probably a better way to do this, which I’ll hopefully get to eventually :) But my point was that this situation can be pretty easily supported by calling renderToString in a loop.

mike-marcacci commented 6 years ago

So, I learned the hard way that relay does the smart thing and processes/applies results asynchronously, such that it's impossible to use a network finish event as an indication that its results have been applied to the store or propagated to the DOM. While delaying to the next event loop with setImmediate worked in my initial tests, it caused a race with production-size queries.

To get something working correctly and worry about performance later, I've switched to running the app in full jsdom window. All QueryRenders in the app were swapped out for a custom TrackedQueryRenderer which actually waits for the results in its render function. This probably isn't the most robust or performant way to do this, but it:

  1. lets your queries run in the QueryRender
  2. supports concurrent QueryRenders
  3. supports waterfall QueryRenders if necessary
  4. doesn't have any race conditions
  5. rehydrates on the client without additional requests (using experimental dataFrom="STORE_THEN_NETWORK")

https://gist.github.com/mike-marcacci/5a6f84570d534dd36aa3e7f17d2b5237

robrichard commented 6 years ago

@mike-marcacci have you tried using the SSR pattern here: https://github.com/robrichard/relay-modern-isomorphic-example. You would not need to use jsdom and can use the standard React renderToString method.

You could potentially remove the query-lookup-renderer requirement by using dataFrom="STORE_THEN_NETWORK" now.

taion commented 6 years ago

The easiest approach right now is probably still just caching the requests, instead of going via the store.

A bit finicky though. Even with STORE_THEN_NETWORK you might still want something for request caching just to plumb through that initial request, though.

mike-marcacci commented 6 years ago

@robrichard I definitely checked out that example and found it helpful.

The real disadvantage of the approach in your example, though, is that queries need to be manually hoisted and run outside the QueryRenderer where they end up running on the client. While this is great in your example with a single root query, it's wildly unpractical in a large app with many different QueryRenderers defined at different places within the react component tree. The "single root query" is an awesome pattern, but there are whole classes of apps where this is totally undesirable. React, GraphQL, and Relay shine in these complex scenarios, so a general-purpose SSR strategy definitely needs to take these into account.

Also, I added jsdom mostly due to other dependancies which expected a real window environment. I had been mocking these dependencies and using webpack to resolve the mocks for the server bundle and the real libs for the client. However, this became quite burdensome and jsdom removed most of these complications. I would prefer using renderToString, but... I guess I'll just worry about that when the first DOS attack comes along. 🤷🏻‍♂️

@taion I definitely agree with your comment about STORE_THEN_NETWORK being insufficient, and I'm not 100% sure where in relay I want such functionality to exist. While it's certainly possible to orchestrate everything at the QueryRenderer as I did, I think some thoughtful events and hooks on the environment would go a long way towards making SSR, rehydration, etc far more user friendly.

grydstedt commented 6 years ago

@mike-marcacci were you able to come up with something? Agree that hoisting up queries to the top level branches isn't realistic for big applications.

mike-marcacci commented 6 years ago

Hey @grydstedt, I'm using the approach in my gist in production, and it's working extremely well. It does re-request all queries on the client, but uses the cache immediately. After seeing the approach react is taking for async rendering, it's become clear that we'll see first-class support for this kind of thing very soon. In the meantime, my approach is working great for us!

taion commented 6 years ago

@grydstedt

I don't think it's that bad to hoist up queries. The approach worked fine with react-router-relay with Relay Classic, and we still take the same approach using Found Relay these days.

Depending on your exact requirements, it's pretty straightforward to use either @robrichard's store-caching approach or request caching per https://github.com/4Catalyzer/found-relay/blob/v0.3.0-alpha.11/examples/todomvc-modern-universal/src/fetcher.js.

They both "just work" in a pure Node environment, though.

grydstedt commented 6 years ago

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 commented 6 years ago

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 commented 6 years ago

@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 commented 6 years ago

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 commented 6 years ago

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

koistya commented 5 years ago

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:

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 commented 4 years ago

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.

wasd171 commented 4 years ago

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

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

sibelius commented 4 years ago

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

taion commented 4 years ago

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

sibelius commented 4 years ago

can you try the new hooks version https://github.com/facebook/relay/commit/b83aace7a95f5fd82cbb30d1f6888bcc4767eeb5?