I'm looking to build an equivalent to react-router-relay or Found Relay for use with Relay Modern, to handle fetching multiple "root" queries in parallel, despite those queries corresponding to nested routes and corresponding components.
I don't think the approach I've taken for either library quite works here. They're respectively:
primeCache, but Relay Modern doesn't do caching in all casesThe approach I plan to explore is pull out the logic up to environment.streamQuery and environment.subscribe in <QueryRenderer> into a separate function, and just hand off the snapshot to something like a "dumb query renderer". This seems analogous to the divide between <Relay.Renderer> and <Relay.ReadyStateRenderer> in Relay Classic.
The next-best option I could think of was to render a bunch of <QueryRenderer>s in parallel at the root level, then portal through the data via context or something, but that seemed pretty gross. Also, pulling the initial snapshot logic out of React components makes for a nicer server rendering API.
Given that, does the approach I've outlined above sound reasonable? If so, would it make sense for this "dumb query renderer" to live in Relay core? I believe it'd be something that could be factored out of the current <QueryRenderer>, and that equivalent split between <Relay.Renderer> and <Relay.ReadyStateRenderer> did exist in Relay Classic.
Great question! I can imagine a bunch of different ways to go here. First some context, then suggestions.
Aggregate a bunch of queries configs into a single query config, but Relay Modern doesn't have query configs
Query configs (routes, really) were primarily necessary in Relay classic to work around the fact that legacy, FB-internal GraphQL didn't support multiple root fields. Relay Modern is built around the OSS GraphQL spec, so query configs are replaced with just queries and variables. Static queries also means that it isn't really possible to aggregate multiple queries together at runtime beyond possibly batching multiple queries into a single HTTP request at the network layer, if your server supports unwrapping them and execute multiple queries.
Use primeCache, but Relay Modern doesn't do caching in all cases
Relay Modern doesn't do caching at all by default. However all the tools are there. The main approaches are request/response caching at the network layer (we actually have a RelayQueryResponseCache class that should probably be exported) and to add new QueryRenderer-style APIs that use lookup and friends to try reading from local data first.
Routing and server rendering are a great examples of the types of functionality we wanted to enable by making the react-relay and relay-runtime separate modules. Long term it may make sense to incorporate some of your suggestions into QueryRenderer itself and split it along the lines of the classic RelayRenderer/ReadyStateRenderer. As a first step though, I'd encourage you to write this as a standalone API that uses functions from relay-runtime, perhaps based on QueryRenderer, share it here, and then we can discuss next steps!
This would be a great test to make sure that the runtime is documented (you'll have to look at the source, I suggest RelayStaticEnvironment, RelayCore, and the architecture docs) and provides all the necessary features.
Right I'm just going to copy-paste bits from <QueryRenderer> first. I think that's all the code I need. I don't need to introduce any concept of caching, I don't think – I just need to pass down the snapshot to something like a <QuerySnapshotRenderer>, unless I'm missing something.
I'm trying to write something very similar for server side rendering. I forked QueryRenderer to create a similar component that does environment.lookup to see if the data is already in the environment before rendering. I was able to get this to work for the server render and for the initial client render, but the component does not re-render when the store is updated on mutation.
This is what I added to QueryRenderer: https://github.com/robrichard/relay-examples/blob/ssr-example/todo-modern-server-side-rendering/js/components/ReactRelayLookupRenderer.js#L48-L63
I just need to pass down the snapshot to something like a
, unless I'm missing something.
@taion Yeah that should work
@robrichard Hmm that looks like it should work. I would check with componentWillReceiveProps() or some other lifecycle hook is perhaps getting executed and wiping out the subscription? Also, lookup(selector) will always return a snapshot for which the data property is non-null so your conditional may not be entering the right branch. lookup() returns whatever happens to be in the store; the fact that it returns a non-null result doesn't mean that exact query has been fetched before. The strategy @taion described is probably more robust: explicitly fetch the data, call lookup() and then pass a snapshot.
So a minimal snapshot renderer would look like this? Before rendering you would need to manually fetch the query from the environment, lookup the snapshot, and subscribe to updates if necessary? Is there anything missing here?
class RelaySnapshotRenderer extends Component {
render() {
const {snapshot, render} = this.props;
return render({props: snapshot.data});
}
getChildContext() {
return {
relay: {
environment: this.props.environment,
variables: this.props.snapshot.variables
}
};
}
}
RelaySnapshotRenderer.childContextTypes = {
relay: PropTypes.object.isRequired
};
I think you'd want to handle calling environment.subscribe as well, at least on the client. I'm really looking at the code in <QueryRenderer> that happens logically after snapshot is first bound.
cc @taion re 1c585b2
I guess if we want something like the old forceFetch behavior where an initial render shows the stale data, but then a subsequent re-render shows updated data once available, we'll have to code that up ourselves?
@taion Yup! With check that's now possible:
if (check()) {
// lookup() and render
}
fetch() // and then re-lookup and render when complete
For anyone else that comes across this issue, this is the component I'm using now. @taion @josephsavona let me know if you see any issues with this approach.
import {Component} from 'react';
import PropTypes from 'prop-types';
class RelaySnapshotRenderer extends Component {
constructor(props) {
super(props);
this.state = {
data: props.snapshot.data
};
this.onChange = this.onChange.bind(this);
}
getChildContext() {
return {
relay: {
environment: this.props.environment,
variables: this.props.snapshot.variables
}
};
}
componentDidMount() {
const {snapshot, environment} = this.props;
this.subscription = environment.subscribe(snapshot, this.onChange);
}
componentWillUnmount() {
this.subscription.dispose();
this.subscription = null;
}
onChange(snapshot) {
this.setState({
data: snapshot.data
});
}
render() {
const {render} = this.props;
const {data} = this.state;
return render({props: data});
}
}
RelaySnapshotRenderer.childContextTypes = {
relay: PropTypes.object.isRequired
};
RelaySnapshotRenderer.propTypes = {
environment: PropTypes.object.isRequired,
snapshot: PropTypes.object.isRequired,
render: PropTypes.func.isRequired
};
export default RelaySnapshotRenderer;
@robrichard That looks about right. One very important missing detail: the component needs to implement componentWillReceiveProps, discarding the previous subscription if either the snapshot or environment prop changes.
Given that this component itself subscribers for changes, it might make more sense for this to component to accept a selector: Selector prop instead of a snapshot. As is, the type signature allows the parent to also observe the value, whereas a selector makes it clear that the component itself will do the subscription.
@josephsavona does it make sense to add a lookup={true} prop on QueryRenderer to run check() and lookup() before attempting to fetch data? It seems otherwise we'll need to re-implement a lot of the logic that's currently in QueryRenderer, to properly handle subscriptions and edge cases regarding environment and variables changing?
@josephsavona I opened a PR (https://github.com/facebook/relay/issues/1760) to support running check and lookup in QueryRenderer. Let me know whether or not you think this is the right approach.
@taion would this also solve your use case?
Potentially – I was thinking of passing the initial snapshot into QueryRenderer directly, but quite possibly this approach is better.
Yup, that's basically how it's meant to be used! One thing to note is that you should clear the cache whenever a mutation is executed since mutations could invalidate cached results (check the type/kind property of the operation object, can't remember offhand which).
As for the global TTL: the cache-wide TTL setting is for simplicity. You could certainly build your own cache and do something else. I think the answer long-term is fragment level TTL: a query is only reexecuted if any fragment data is missing/stale. For now, global TTL is a quick way to ensure you don't re-execute a query twice in a short time interval.
There's a separate issue open for generating persisted queries, on mobile so can't find the link just now.
@unirey The basic approach you're describing for an alternate take on QueryRenderer makes sense. Their is one potential blocker, though: GraphQL does not allow clients to understand that query Foo { node(id: "xyz") { ... } } means "start from record xyz". So if you navigate from a list view (and have fetched record xyz as one of the items) to the detail view, Relay (and any other client) will see a root field that it has not yet fetched (node(id: xyz)) and return undefined. This is something we'd like to address long-term, but there isn't a good solution for it today.
Note that Relay Classic treated the node root field specially; in Relay Modern we've tried to make as few assumptions as possible about the schema and not have magic behavior for certain fields/arguments.
Why would the <QueryRenderer> get unmounted entirely? Or rather, in your routing tree, the <QueryRenderer> that interacts with the same data should stay at similar positions, no? I'm thinking something like your navigation/sidebar elements that don't really move around in your component tree much.
Re GC:
@unirey
No, it will be more or less exactly what's described here – prefetch the snapshots, then inject into dumb/lookup-based query renderers.
I'm just starting with the code now, but it should look virtually the same as react-router-relay or Found Relay, and internally will work pretty similar to how Found Relay works.
I ended up moving almost all of the logic out of my "dumb" <QueryRenderer> equivalent to make it simpler to manage the lifecycle for multiple routes.
I ended up with just: https://github.com/4Catalyzer/found-relay/blob/v0.3.0-alpha.1/src/modern/ReadyStateRenderer.js.
Well, the thing I ended up with is so simple that I don't really need anything here. I'm going to close this out for now.
@josephsavona how and where do I use the retain() method, I am kind of new here and I am using React Native with independent QueryRenderers, when I pop a view the previous caché is being emptied, I want to know how can I retain my cache
@IkerArb can you file a new issue and describe the issue further?
I got a bit lost here. Not sure now how should we put a simple caching in place?
@unirey s comment was interesting, but how do we achieve to have persistence then?
I've decided to render my graphql responses to static JSON files & serve those locally (rather than caching in relay modern).
Hope this helps.
I decided to use operation.hash (it is being generated by babel-relay-plugin and it is stored inside __generated__ files) so its only changed whenever you change a query/fragment. I'm not sure how to reset cache when a mutation is committed(haven't got there yet) but for caching requests it's working so far. What do you think @josephsavona ?
@ErrorPro Makes sense to me 👍
You need both the hash and the variables, BTW.
@taion https://github.com/facebook/relay/blob/master/packages/relay-runtime/network/RelayQueryResponseCache.js#L67
That's obvious, I use it like this. Relay merges queryID and variables both when creates a cache key:
const queryID = operation.hash;
const cachedData = cache.get(queryID, variables);
if (cachedData !== null) return cachedData;
And when a mutation is committed I just clear all the cache cache.clear(). I do not really like it now, because I flush all the cache but there's no way to understand which queries you need to refetch. There's actually, instead of using hash use name and remove only cache for certain query, but this approach also has downsides. I ended up with this approach:
const queryID = operation.name;
const cachedData = cache.get(queryID, variables);
if (cachedData !== null) return cachedData;
//after a mutation is commited
const { data: dataInCache } = cache.get('myQueryName', myQueryVars);
cache.set('myQueryName', myQueryVars, updateCache(dataInCache, responseFromMutation);
Problems:
props.match.params)Any thoughts?
Most helpful comment
I got a bit lost here. Not sure now how should we put a simple caching in place?
@unirey s comment was interesting, but how do we achieve to have persistence then?