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

Make RelayRenderer isomorphic #589

Closed
denvned opened this issue Nov 12, 2015 · 34 comments
Closed

Make RelayRenderer isomorphic #589

denvned opened this issue Nov 12, 2015 · 34 comments

Comments

@denvned
Copy link
Contributor

denvned commented Nov 12, 2015

I could make a PR based on this: https://github.com/denvned/isomorphic-relay/blob/v0.2.1/src/IsomorphicRenderer.js

Currently, _runQueries is an async operation, so it should not be called during server side rendering. That's why I moved it to componentDidMount, which is not called on the server.

isDataReady checks if the data has been already fetched (current implementation is not so important, and might be replaced). If the data is available, initialize with a preloaded state, otherwise with an empty state.

What do you think?

@josephsavona
Copy link
Contributor

Great work on isomorphic-relay! It is very similar to the API we've been exploring internally, and we'd love to have support for this in Relay proper:

// SERVER
Relay.injectNetworkLayer(new RelayServerNetworkLayer(...));
Relay.prepare(route, component, ({props, data, error /*, ...*/ }) => {
  if (error) {
    // stuff...
  }
  React.renderToString(
    <YourPageTemplate 
      data={data}>  // opaque data to be passed as JSON to the client
      <RelayRenderer {...props}>
    </YourPageTemplate>
  );
});

// CLIENT
Relay.injectNetworkLayer(new RelayClientNetworkLayer(data)); // pass in the data from the server
ReactDOM.render(
  <RelayRenderer route={...} component={...} />,
  ...
);

It would be relatively simple to change RelayRenderer to support synchronously rendering the component when its props contained data created by Relay.prepare. Basically: if those props are present, render synchronously, otherwise fetch data.

Any interest in submitting a PR? :-)

@denvned
Copy link
Contributor Author

denvned commented Nov 12, 2015

Thanks! Sure, I'll be glad to submit a PR.

How about triggering synchronous rendering in RelayRenderer by passing it a ReadyState received from Relay.prepare?

I think, the first render on the client should also be synchronous, otherwise server side rendered DOM will not match DOM after the first render on the client (it will be just <noscript data-reactid=...></noscript>), so React will not reuse it, also there will be flickering at page load. That's why, I think that data on the client should also be prepared before the first render.

@josephsavona
Copy link
Contributor

Good point, injecting the data from the server into the client's network layer means we can't synchronously render on the client. It might be reasonable to adjust the above such that Relay.prepare resolves with {props, data, error}, to be used on the client as:

// SERVER
// note: use the normal network layer
Relay.prepare(route, component, ({props, data, error}) => {/* see above */});

// CLIENT
// note: use the normal network layer
// `data` is intentionally opaque: it contains the serialized queries executed on the server
// and their corresponding response payloads (i.e. the inputs to `RelayStoreData.handleQueryPayload`).
Relay.injectPreparedResponse(data); 
// Rendering will be synchronous so long as the client uses identical variables as the server
ReactDOM.render(
  <RelayRenderer route={...} component={...} {...props} /> 
  ...
);

@denvned
Copy link
Contributor Author

denvned commented Nov 12, 2015

In isomorphic-relay, on the client I use storePreloadedData to inject prepared data. It uses DliteFetchModeConstants.FETCH_MODE_PRELOAD and RelayPendingQueryTracker.resolvePreloadQuery as suggested here.

I like the symetry of storePreloadedData to corresponding loadAndStoreData, which is used on the server.

queryResults produced by loadAndStoreData on the server and consumed by storePreloadedData on the client, is an object with the keys corresponding to the keys in querySet, and the values are response payloads corresponding to these queries.

What about incorporating a similar implementation?

@denvned
Copy link
Contributor Author

denvned commented Nov 12, 2015

The README contains info about how storePreloadedData and loadAndStoreData are used.

@josephsavona
Copy link
Contributor

Yeah, isomorphic-relay's design is very, very similar to what we're considering. It's primarily a name change:

  • Relay.prepare corresponds to loadAndStoreData
  • Relay.injectPreparedData corresponds to storePreloadedData

But the implementation can also be simplified. injectPreparedData can use internal methods (RelayStoreData#handleQueryPayload), which means there's no need for a special network layer on the client. Also note that a single query from the route may ultimately execute as multiple queries, so it's important to record queries and results at the right point.

Below is a more complete API specification - we'd happily accept contributions along these lines!

/**
  *  Part 1: Add `prepare` - this should intercept calls to `handleQueryPayload`, 
  * record the queries and payloads, and make them available as a `PreparedData` 
  * array in the callback. The main challenge here is determining a suitable injection 
  * point, an injected network layer would be the easiest place, but ideally `prepare` 
  * would not require one.
  */
Relay.prepare(
  route: Relay.Route, 
  component: Component, 
  preparedStateChange: PrepareStateChange
): void;

/**
  * Part 2: Add `injectPreparedData` which accepts the queries and payloads from the
  * server and writes them into the store. (use `RelayStoreData#handleQueryPayload`).
  */
Relay.injectPreparedData(data: PreparedData): void

/**
  * Part 3: Make `RelayRenderer` render synchronously if passed the `props` from the
  *  `prepare` callback.
  */
<RelayRenderer {...prepareState.props} />

// Types for the above:
type PreparedData = Array<{
  query: ConcreteQuery;
  result: ?Object;
}>;
type PrepareState = {
  props: Object;
  data: PreparedData;
  error: ?Error;
};
type PrepareStateChange = (prepareState: PrepareState) => void;

@josephsavona
Copy link
Contributor

cc @yungsters

@denvned
Copy link
Contributor Author

denvned commented Nov 13, 2015

Sorry for delays. I wish I were in the same time-zone as you.

But the implementation can also be simplified. injectPreparedData can use internal methods (RelayStoreData#handleQueryPayload), which means there's no need for a special network layer on the client.

storePreloadedData do not require any special network layer on the client already. But probably yes, calling RelayStoreData#handleQueryPayload directly could simplify it, i.e. make it synchronous.

Also note that a single query from the route may ultimately execute as multiple queries

Do you just mean that Component-route pair can resolve to multiple RelayQuery.Roots? Yes, I am aware of that. We can get all of these RelayQuery.Root's with a call to Relay.getQueries(Component, route), right?

The main challenge here is determining a suitable injection point, an injected network layer would be the easiest place, but ideally prepare would not require one.

Yeah, isomorphic-relay intercepts calls to NetworkLayer#sendQueries (it supports any network layer supplied by the user, including Relay.DefaultNetworkLayer).

But probably a better place to intercept queries is right in the RelayDataStore#handleQueryPayload, we could supply an interceptor by calling something like RelayDataStore#injectQueryPayloadInterceptor((query, payload) => {...}). Is that ok?

@denvned
Copy link
Contributor Author

denvned commented Nov 13, 2015

Seems like that to recreate a RelayQuery on the client we need not only ConcreteQuery, but also a route name and variables. So, PreparedData should probably be extended:

type PreparedData = Array<{
  query: ConcreteQuery;
  routeName: string;
  variables: Variables;
  result: ?Object;
}>;

Then the implementation of injectPreparedData might be:

const storeData = RelayStoreData.getDefaultInstance();

function injectPreparedData(data) {
    data.forEach(([{query, routeName, variables, result}]) => {
        const rootQuery = RelayQuery.Root.create(
            query,
            RelayMetaRoute.get(routeName),
            variables
        );
        storeData.handleQueryPayload(rootQuery, result);
    });
}

It mostly works, but there is a problem: when I tried to JSON.stringify a ConcreteQuery on the server to send it to the client, it throwed TypeError: Converting circular structure to JSON...

@josephsavona
Copy link
Contributor

Do you just mean that Component-route pair can resolve to multiple RelayQuery.Roots? Yes, I am aware of that. We can get all of these RelayQuery.Root's with a call to Relay.getQueries(Component, route), right?

More than that: plural root fields (nodes[ids: ["a", "b"])) may be split into multiple singular root fields, and any top-level query may be split into an arbitrary number of required query + split deferred queries (not supported by the OSS network layer yet, but we use this internally).

But probably a better place to intercept queries is right in the RelayDataStore#handleQueryPayload, we could supply an interceptor by calling something like RelayDataStore#injectQueryPayloadInterceptor

This seems reasonable for an initial implementation, and we can discuss details more on the PR.

Seems like that to recreate a RelayQuery on the client we need not only ConcreteQuery, but also a route name and variables.

Yeah it seems that way, but it actually isn't necessary. RelayQueryRoot objects can be serialized to plain objects/arrays via the toGraphQL.Query(relayQuery) function. This function creates a description of the query in which any non-matching route-conditional fragments have been removed, and in which all variables are inlined. The return value is a plain object compatible with the ConcreteQuery flow type. On the client this can be revived with fromGraphQL.Query(concreteQuery), and the outward shape/values of the query will match the original.

@denvned
Copy link
Contributor Author

denvned commented Nov 13, 2015

More than that: plural root fields (nodes[ids: ["a", "b"])) may be split into multiple singular root fields, and any top-level query may be split into an arbitrary number of required query + split deferred queries (not supported by the OSS network layer yet, but we use this internally).

Now I see. I have found that deferred queries are split here: https://github.com/facebook/relay/blob/v0.5.0/src/legacy/store/GraphQLQueryRunner.js#L162-L170
But where are plural root fields split?

And as far as I understand, we have to match intercepted split queries with the original ones returned by Relay.getQueries for given Component and route. Does a splitted query store any reference to the original query?

P.S.
Does it make sense to execute deferred queries on the server? Or they should be ignored somehow?

@josephsavona
Copy link
Contributor

diffRelayQuery also splits plural queries here.

And as far as I understand, we have to match intercepted split queries with the original ones returned by Relay.getQueries

There's no need to match up the queries and results from the server with those on the client. So long as the variables used on the client match those on the server (i.e. you're not relying on the browser environment, time, etc), the client will generate identical queries to the server. That means the client will be able to fulfill its queries synchronously from the cache (so long as the user calls injectPreparedData).

p.s. Does it make sense to execute deferred queries on the server?

That's a good question - this should be configurable by the product as it can have a significant impact on initial load time. In the case that the server does not prepare deferred data, the client will be able to render without it (it doesn't wait for deferred data) and then send requests for the additional deferred data.

@denvned
Copy link
Contributor Author

denvned commented Nov 13, 2015

There's no need to match up the queries and results from the server with those on the client.

Sorry, I was not clear. I meant matching on the server. It will serve multiple requests in parallel (Relay.prepare is asynchronous), so how will it know which intercepted query results send in response to which requests? We know that the querySet returned by Relay.getQueries belong to the current request, but what about split queries?

@josephsavona
Copy link
Contributor

The server just has to record the (query, response) pairs written to handleQueryPayload, it doesn't have to match anything up to the queryset.

@denvned
Copy link
Contributor Author

denvned commented Nov 13, 2015

But how will we know to which HTTP-request this intercepted (query, response) pair belong?

@denvned
Copy link
Contributor Author

denvned commented Nov 13, 2015

Probably, intercepting in RelayDataStore#handleQueryPayload is not so good idea...

@josephsavona
Copy link
Contributor

But how will we know to which HTTP-request this intercepted (query, response) pair belong?

This isn't necessary. If the server records all pairs of (query, response) that are written to the store and injects this data on the client, then the client won't even need to make network requests. Intercepting handleQueryPayload seems reasonable here.

@denvned
Copy link
Contributor Author

denvned commented Nov 14, 2015

then the client won't even need to make network requests.

And this is how isomorphic-relay already works. But I was talking about server side only.

Consider a hypothetical implementation of Relay.prepare:

const queryPayloadSubscribers = new Set();
const store = RelayStoreData.getDefaultInstance();

store.injectQueryPayloadInterceptor((query, payload) => {
  queryPayloadSubscribers.forEach(subscriber => {
    subscriber(query, payload);
  });
});

function prepare(route, component, preparedStateChange) {
  const querySet = getRelayQueries(component, route);
  const data = [];

  queryPayloadSubscribers.add(queryPayloadSubscriber);

  RelayStore.forceFetch(querySet, ({aborted, done, error, stale}) => {
    if (aborted) {
      error = new Error('Aborted');
    }
    if (done && !stale || error) {
      queryPayloadSubscribers.delete(queryPayloadSubscriber);

      preparedStateChange({props: {...}, data, error});
    }
  });

  function queryPayloadSubscriber(query, payload) {
    if (isQueryBelongToThisRequest(query)) {
      data.push({query: toGraphQL.Query(query), result: payload});
    }
  }
}

Note that multiple requests might be processed concurrently because Relay.prepare is asynchronous, so queryPayloadSubscriber might receive (query, payload) pairs belonging to other requests. The question is how could isQueryBelongToThisRequest(query) be implemented?

In isomorphic-relay I just check if the query is in the querySet returned by Relay.getQueries, and it works well when deferred query support is not enabled in the network layer. BTW, looks like diffRelayQuery is not called in the force fetch mode, so there is no split of plural queries in that mode, is that correct?

@josephsavona
Copy link
Contributor

Ohhh - you're assuming one global Relay context shared amongst multiple HTTP requests, and trying to distinguish which query/payload goes with which. I'm assuming that we can have a unique Relay context per HTTP request - see #558 which is moving along rapidly.

denvned added a commit to denvned/isomorphic-relay that referenced this issue Nov 15, 2015
@denvned
Copy link
Contributor Author

denvned commented Nov 15, 2015

Local Relay context would be a blessing. I have been able to use some enhancements from #558 in isomorphic-relay already, but with some hacks: https://github.com/denvned/isomorphic-relay/blob/v0.3.0/src/prepareData.js Probably, we can consider that as a prototype of Relay.prepare.

And https://github.com/denvned/isomorphic-relay/blob/v0.3.0/src/injectPreparedData.js as a prototype of Relay.injectPreparedData.

@denvned
Copy link
Contributor Author

denvned commented Nov 17, 2015

Working on the PR now. I hope to make it ready this week.

@denvned
Copy link
Contributor Author

denvned commented Nov 20, 2015

It would be relatively simple to change RelayRenderer to support synchronously rendering the component when its props contained data created by Relay.prepare. Basically: if those props are present, render synchronously, otherwise fetch data.

I thought a lot about this recently, and came to the conclusion that passing special properties to RelayRenderer to trigger synchronous rendering should be avoided. The problems are:

  • The properties should be passed to the RelayRenderer not only on the server, but also on the client. If the properties are produced by Relay.prepare on the server, then the user have to implement not only transfer of the data, but also the props to the client. This is already complicated. And we must consider that there might be multiple instances of RelayRenderer on the page.
  • If user passed the props to the RelayRenderer to trigger synchronous render on the server or the client, but the data required to perform render is not actually loaded to the store (as a result of an user or technical error), then the behaviour is undefined. That is too much error-prone and unpredictable.

Instead, we could simply make RelayRenderer to check if the data is already in the store when it mounts, and if data is ready then render, otherwise send the queries later, from the componentDidMount method, which is not called on the server, to insure that we don't asynchronously send queries on the server.

The question is how we will implement it.

GraphQLQueryRunner can be used to check if the data is already cached in the store. But the problem is that, while it checks the cache synchronously, it calls the callback with the result only on the next tick (using resolveImmediate(...)) anyway. So, currently, GraphQLQueryRunner can not be used to check the cache synchronously.

Also, in the server-side rendering mode, RelayRenderer should be able to check the cache, but not send queries at all. GraphQLQueryRunner do not allow that currently.

But, it is not too hard to modify GraphQLQueryRunner to make it possible to use it to check the cache synchronously, and make sending the queries an optional operation.

I've already experimented with it by making GraphQLQueryRunner a consumable object instead of a contextual singleton. So, instead of storeData.getQueryRunner(querySet, callback), it can be used as:

// `RelayStoreData#createQueryRunner(querySet)` just returns
// `new GraphQLQueryRunner(this, querySet)`.
// The constructor of `GraphQLQueryRunner` prepare queries (i.e.
// diffs and splits them), and calculate the initial ready state based
// on availability of the data in the store cache.
const queryRunner = storeData.createQueryRunner(querySet);

// Synchronously get the initial ready state to check the cache:
const readyState = queryRunner.getReadyState();

// Perform initial render
...

// If we still need to send the queries to the GraphQL server
// asynchronously:
queryRunner.run(callback);

Is it OK if I will work on the PR along these lines?

@josephsavona
Copy link
Contributor

Instead, we could simply make RelayRenderer to check if the data is already in the store when it mounts,

This is conceptually simple, but RelayRenderer checks the cache asynchronously (after mount) explicitly because that operation can be expensive and block rendering.

But, it is not too hard to modify GraphQLQueryRunner to make it possible to use it to check the cache synchronously, and make sending the queries an optional operation.

There are existing functions in Relay for synchronously checking if the results of a query are cached - there's no need to refactor the QueryRunner API for this.

Is it OK if I will work on the PR along these lines?

RelayRenderer and GraphQLQueryRunner are part of the critical path for application startup and we can't support PRs making major changes to these APIs. We need to find a solution that requires minimal changes to RelayRenderer and has near-zero impact on its initialization in the case that the user is not bootstrapping server props on the client.

@denvned
Copy link
Contributor Author

denvned commented Nov 20, 2015

RelayRenderer checks the cache asynchronously (after mount)

That is not quite true. Currently, RelayRenderer runs queries before rendering, from the constructor, and GraphQLQueryRunner checks cache by performing diffQueries synchronously, and I believe that this is the most expensive operation. So, if cache check could block rendering, then it does that already.

(I am aware, that there are also asynchronous calls to checkRelayQueryData, but I believe that this operation is not as expensive as diffQueries, furthermore, it actually redundant when diffQueries have been already performed, i.e. it makes sense only for force fetch mode. And there is an asynchronous call to storeData.readFromDiskCache when disk cache manager is enabled, but it is not usable for synchronous cache checking anyway.)

There are existing functions in Relay for synchronously checking if the results of a query are cached - there's no need to refactor the QueryRunner API for this.

Yes, but there is a problem. If we check cache aside from GraphQLQueryRunner, and after that run queries using GraphQLQueryRunner, then cache check is performed twice - by us, and by GraphQLQueryRunner. And as you said, cache check could be expensive.

We need to find a solution that requires minimal changes to RelayRenderer and has near-zero impact on its initialization in the case that the user is not bootstrapping server props on the client.

Fortunately, changes to RelayRenderer that I propose don't change its API at all, and are backward compatible with the existing behaviour except that they make synchronous rendering possible when data is cached. Also these changes have near-zero impact on its initialization, because as I said above, currently, cache is already synchronously checked before rendering (it just doesn't have a chance to do initial render using that cached data, because onReadyStateChange callback is always called by GraphQLQueryRunner asynchronously, i.e. after the initial render).

Also, if any changes to the API of GraphQLQueryRunner are banned, then I could just minimally extend it to add support for synchronous cache check while maintaining full backward compatibility with the existing API.

@denvned
Copy link
Contributor Author

denvned commented Nov 22, 2015

Here is my work on isomorphic RelayRenderer: https://github.com/denvned/relay/commits/isomorphic-renderer

It uses #625 as a base.

@josephsavona as you suggested I added to RelayRenderer a property that triggers synchronous render, and I didn't touch GraphQLQueryRunner. The property is a boolean named prepared. If the property is set, RelayRenderer initializes its state by checking the store cache. If all the data is already in the store and the force fetch mode is not used, then it doesn't send any queries. If only deferred data is missing, then it renders with available required data, and sends queries to fetch the remaining deferred data.

So, it is enough to set the prepared property to switch on the isomorphic mode in RelayRenderer:

<RelayRenderer
  Component={Component}
  queryConfig={queryConfig}
  prepared={true}
/>

But you should examine the source to see the details. It looks pretty solid for me.

Is that good for a PR?

TODO: tests for the new mode.

@willopez
Copy link

willopez commented Dec 3, 2015

Hi @josephsavona, I am currently working on a project that will need this feature; and wondering if the above mentioned PR is a feasible solution? or what needs to be done to make progress on this feature? and thanks @denvned for your work 👍

@denvned
Copy link
Contributor Author

denvned commented Dec 30, 2015

Yeah it seems that way, but it actually isn't necessary. RelayQueryRoot objects can be serialized to plain objects/arrays via the toGraphQL.Query(relayQuery) function.

@josephsavona , @yungsters Looks like it is not possible anymore because of a26c8b4. Is it possible to revert that commit back? It will be hard to implement Relay.prepare, as discussed above, without toGraphQL. Also isomorphic-relay, which many people use, already actively uses toGraphQL.

@yungsters
Copy link
Contributor

@denvned Sorry about that. I have a revision awaiting internal review that will bring this back.

@nodkz
Copy link
Contributor

nodkz commented Dec 30, 2015

@yungsters please add a note about toGraphQL method, that it used for isomorphic apps, for debugging and logging. Its removing should be prevented in future.

@denvned
Copy link
Contributor Author

denvned commented Dec 31, 2015

@yungsters Thanks for bringing toGraphQL back!

ghost pushed a commit that referenced this issue Feb 19, 2016
Summary:...to make sure `RelayRenderer` does not run queries during synchronous server-side rendering.

In the `RelayRenderer` tests, I had to replace `ShallowRenderer.render` with the traditional `ReactDOM.render`, because `ShallowRenderer.render` does not invoke some component lifecycle methods including `componentDidMount` that now run queries. But I did not change the logic of the tests, as **visible behaviour of `RelayRenderer` was fully retained.**

Added a test to check that `RelayRender` does not run queries on the server.

This PR is a forerunner of the PR that will make `RelayRenderer` isomorphic (see #589). That next PR will be pretty small and easily comprehensible, because this PR already did much of the necessary dirty work.
Closes #625

Reviewed By: josephsavona

Differential Revision: D2727748

fb-gh-sync-id: 202cc772d15661532afd5eee6ae647bb03af9dcc
shipit-source-id: 202cc772d15661532afd5eee6ae647bb03af9dcc
@josephsavona
Copy link
Contributor

This has been implemented as Relay.ReadyStateRenderer. @denvned can you confirm that there's nothing left to do here?

@denvned
Copy link
Contributor Author

denvned commented May 13, 2016

@josephsavona In isomorphic-relay I have managed to implement IsomorphicRelayRenderer that does not use Relay private API anymore, thanks to Relay.ReadyStateRenderer. So, yeah, this specific issue is probably solved.

But some other parts of isomorphic-relay still have to use few Relay internals, namely toGraphQL.Query, fromGraphQL.Query and RelayStoreData#handleQueryPayload. Any ideas how we would replace these with public API? Would GraphMode help with this?

@josephsavona
Copy link
Contributor

But some other parts of isomorphic-relay still have to use few Relay internals, namely toGraphQL.Query, fromGraphQL.Query and RelayStoreData#handleQueryPayload

@denvned thanks for confirming. We should add public methods for serializing/deserializing queries (the to/fromGraph stuff). handlQueryPayload is now accessible via RelayEnvironment#getStoreData()#handleQueryPayload.

@denvned
Copy link
Contributor Author

denvned commented May 13, 2016

handlQueryPayload is now accessible via RelayEnvironment#getStoreData()#handleQueryPayload.

I know, but is RelayStoreData a public API? getStoreData() is still marked with @internal in the sources.

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

5 participants