Relay: Implement RelayStore.reset()

Created on 3 Sep 2015  Â·  56Comments  Â·  Source: facebook/relay

The motivating use case here is clearing the cache on logout. We already have an internal task for this (t6995165) but I'm creating this issue for external visibility.

For now, the workaround is to do a full page refresh on logout. The long-term plan here is #559 - instead of resetting the store, applications will be create new instances of RelayEnvironment when necessary.

enhancement help wanted

Most helpful comment

This is how I'm doing it:

util.js

export let currentRelay = {
  reset: function () {
    let env = new Relay.Environment();
    env.injectNetworkLayer(appReplayNetworkLayer);
    env.injectTaskScheduler(InteractionManager.runAfterInteractions);
    currentRelay.store = env;
  },
  store: null
};

currentRelay.reset();

index.ios.js

import {currentRelay} from './util';

...

class App extends React.Component {

  ...

  doReset() {
    currentRelay.reset();
    this.forceUpdate();
  }

  doMutation() {
    currentRelay.store.commitUpdate(new MyMutation({}));
  }

}

use Relay.Renderer directly or create your own wrapper than passes environment={currentRelay.store}. see RelayRootContainer source

All 56 comments

This will be useful for the prototyping tools (#240). Right now, it's possible to paint yourself into a corner where you have cached results for a given field/call combination, but you've changed the underlying resolve method in the schema tab.

Great, need this :)

Things that need to be touched just from a quick peek at the code:

  • In RelayStoreData.js: _records, _queuedRecords must be reset to {}, _recordsStore and _queuedStore must be reinitialized with the empty record-sets. _cachedRecords should stay as is, I assume. _queryTracker must be reinitialized. RelayStoreGarbageCollector should also be reinitialized if needed.
  • In RelayStore.js: queuedStore can no longer be cached.

The more interesting part: How should RelayStoreData announce this change to the rest of the system? Should Relay go back into a fetching-state after a call to reset?

@clentfort Yeah, the tricky part isn't so much the resetting as it is deciding what to do if components are still mounted.

@josephsavona: If we change some stuff we could use the infrastructure that is available for the garbage collector and simply error-out if any data is still subscribed to.
We could change the RelayStoreQueryResolver to emit events when new subscriptions are created/disposed, this would enable us to count the number of active subscriptions and not allow reset if there are any.
This would even allow us to decouple some things (i.e. we could make RelayStoreQueryResolver unaware of the garbage collector)!

Is there any work around for this that could be used on react-native? A page refresh isn't really an option there. :)

@skevy The workaround for now in React Native is the brute-force equivalent of a page refresh - tear down the current JS context and start a new one.

Here's what needs to change to make this happen:

  • Add Relay.Store.reset(): Implement an invariant check that there are no rendered Relay containers, no pending queries, and no mutations - only allow reset if this check passes.
  • Find every module that calls RelayStoreData.getDefaultInstance() and holds onto a reference to the result. Instead of caching the result in module scope, always call getDefaultInstance() when the instance is about to be used.
  • tests
  • documentation

I started a rough version of this at https://github.com/josephsavona/relay/commit/7fdb68df465df8df32bfe63b57a9463fc70d6824 - feel free to use this as the basis of the above implementation. It implements the invariant check I mentioned above, but all the new methods need tests.

cc @skevy @devknoll @taion @fson anybody interested? ;-)

Would it be reasonable for the RelayRootContainer to manage the
RelayStoreData instance?

My concern is that only exposing this low-level Relay.Store.reset() API, we
are leaving all the complexity of orchestrating the reset (tearing down the
components, resetting, rendering the components again) to the developer.

This could become quite complex to do, especially if data is fetched both before
and after the reset. For example resetting data on login/logout:

class App extends Component {
  state = { loginState: 'SIGNED_OUT' };
  handleLogin = () => {
    login().then(() => {
      this.setState({ loginState: 'TRANSITION' }, () => {
        Relay.Store.reset();
        this.setState({ loginState: 'SIGNED_IN' });
      });
    })
  };
  handleLogout = () => {
    logout().then(() => {
      this.setState({ loginState: 'TRANSITION' }, () => {
        Relay.Store.reset();
        this.setState({ loginState: 'SIGNED_OUT' });
      });
    })
  };
  render() {
    switch (this.state.loginState) {
      case 'SIGNED_OUT':
        return (
          <Relay.RootContainer
            Component={Login}
            renderFetched={(data) =>
              <Login {...data} onLogin={this.handleLogin} />
            } />
        );
      case 'TRANSITION':
        return null;
      case 'SIGNED_IN':
        return (
          <Relay.RootContainer
            Component={Profile}
            renderFetched={(data) =>
              <Profile {...data} onLogout={this.handleLogout} />
            } />
        );
    }
}

If the data would be tied to the lifecycle of RootContainer, one could simply
use the key property in the RootContainer to force it to be re-mounted when
the login state changes. Are there any disadvantages to that approach?

@fson

I like the idea of not having that global singleton Relay store.

Ignoring implementation difficulties for now, there are a couple of practical API considerations.

  • For things like modals and overlays and other dynamic content with data dependencies, when not associating a route to them, it's probably easiest to just set up a new RootContainer - this RootContainer ideally should share the same store as the top-level RootContainer; perhaps RootContainers can export the store as context and children RootContainers can try to use those first

    • The context approach requires things that use portals to use unstable_renderSubtreeIntoContainer, though... ideally anybody doing this is using something like react-overlays that deals with this for them

  • Same as above, but taking the naive routing approach @cpojer covers at https://medium.com/@cpojer/relay-and-routing-36b5439bad9, instead of using react-router-relay or something equivalent
  • Same as above, but for mocking .defer support with extra RootContainers
  • @skevy can comment on this, but I believe naive approaches with navigator on RN lead to multiple sibling RootContainers that would not be able to share state with this approach, and that actually keeping everything under a single top-level RootContainer requires quite a lot of work

@fson Interesting idea. For background, we've found lots of use cases for having multiple <RelayRootContainer> instances that share data within a single application. For example, on React Native each screen within a navigation stack typically has its own root container, and if you're integrating Relay into an existing app you might have root containers for each small UI component that you convert to use Relay.

That said, you're absolutely right that a more complete solution is to integrate RelayRootContainer into the reset() lifecycle. Perhaps:

  • Allow reset() to be called while queries are pending and simply abort them.
  • If a reset occurs, RelayRootContainer immediately resets itself as it if was just rendered: it reissues queries for the route and shows the renderLoading indicator.
  • What to do about pending mutations is less clear.

I like the idea of not having that global singleton Relay store.

@taion We agree. Earlier versions of Relay had _way_ more global state, and we've been steadily refactoring to move this state into instance objects. The eventual goal is that you could configure a RelayContext-style object and pass it into the root container, and all query fetching, mutations, etc would then go through that context. This would obviously also help for server rendering.

@josephsavona

What about (optionally) separating that out from Relay.RootContainer entirely? The idea would be something like having a RelayContext component that exports the context via getChildContext.

Then each Relay.RootContainer could just try to get its relay context via its getContext (and optionally create a new one if there isn't one available already, to simplify normal use cases where you just have a single RootContainer up top).

This would work well for the standard RN use case, because you could just wrap the navigator in a RelayContext component and have the RootContainers share data. You'd still have to deal with unstable_renderSubtreeIntoContainer with dynamic modals rendered into portals for the web, but that's a much more minor issue.

The benefit of doing this would be that it might be possible to avoid an explicit imperative reset method, and just re-mount the RelayContext component, as @fson said.

@taion Yes! That's basically the long-term vision. However, there's still a bunch of work needed to get there. The approach I outlined for Relay.Store.reset() is more meant as a useful stopgap until we have true RelayContexts.

It would also be great to have the ability to invalidate certain entries of the cache. E.g. if we know that certain data can be updated from other sources or other users it would be great to be able to say, for example, after 10min invalidate this part of the cache and if the user re-visits that page requery the invalidated parts that are needed to display the current components.

This issue will largely be addressed by #558, which makes all Relay state contextual (implements the RelayContext idea discussed above).

@Globegitter - Yeah, that would be cool. The API could be Relay.Store.invalidate(query), and the implementation would move any records or fields referenced by the query from the "fresh" data (RelayStoreData#_records) to "stale" data (RelayStoreData#_cachedRecords). This would cause Relay to refetch the information, without disrupting existing components that may be displaying the data.

Instead of (or perhaps in addition to) invalidating it'd be nice to be able
to set expiration times. If relay is a cache it'd be nice to have normal
cache APIs available :-)
On Fri, Nov 13, 2015 at 10:51 AM Joseph Savona [email protected]
wrote:

This issue will largely be addressed by #558
https://github.com/facebook/relay/issues/558, which makes all Relay
state contextual (implements the RelayContext idea discussed above).

@Globegitter https://github.com/Globegitter - Yeah, that would be cool.
The API could be Relay.Store.invalidate(query), and the implementation
would move any records or fields referenced by the query from the "fresh"
data (RelayStoreData#_records) to "stale" data (
RelayStoreData#_cachedRecords). This would cause Relay to refetch the
information, without disrupting existing components that may be displaying
the data.

—
Reply to this email directly or view it on GitHub
https://github.com/facebook/relay/issues/233#issuecomment-156521834.

@KyleAMathews Yup, we're looking into this. However, in early experiments storing the metadata required to record expiration times had a non-trivial impact on product performance.

@KyleAMathews @josephsavona yep being able to set expiration times would be great as well. But yeah getting performance right is of course important as well ;)

Could expiration exist outside of core? Seems like if we exposed Relay.Store.invalidate then it should be fairly trivial to build on top of that.

I think it'd be really nice to keep the core fairly simple and break out relay-addons similar to React if we need to :+1:

Yeah I can definitely see supporting an expire command being
complicated/slow. Also perhaps not advisable as given the multi-faceted
nature of intertwined nature of graphql queries, an expire command could be
pretty blunt. Some views might not want its data invalidated from
underneath it. An invalidate command keeps the control on the view layer
which seems the right trade off for engineers/product owners to think about
things. Basically its a poor man's subscription :-)

A related question, could you invalidate on a timer while the view is
active and have the data refreshed underneath it?
On Fri, Nov 13, 2015 at 4:55 PM Gerald Monaco [email protected]
wrote:

Could expiration exist outside of core? Seems like if we exposed
Relay.Store.invalidate then it should be fairly trivial to build on top
of that.

I think it'd be really nice to keep the core fairly simple and break out
relay-addons similar to React if we need to [image: :+1:]

—
Reply to this email directly or view it on GitHub
https://github.com/facebook/relay/issues/233#issuecomment-156581999.

A related question, could you invalidate on a timer while the view is active and have the data refreshed underneath it?

@KyleAMathews This is trivial to do today: use forceFetch. For example, to poll for updates on a query, use:

const query = Relay.createQuery(Relay.QL`query { ... }`, {var: 'foo'});
Relay.Store.forceFetch({query}, readyState => { ... });

Could expiration exist outside of core?

@devknoll Good question. It isn't possible today without hijacking some internal methods. It could be an interesting experiment to try building it and see what hooks you need. Again though, the main consideration is perf.

:+1: I've been very happy with using forceFetch for this pattern. It's much nicer than Flux equivalents since it's tied to the specific view getting refreshed.

@josephsavona I know that you guys have been working hard on making Relay state contextual, and it seems like you've made a lot of progress.

Is it in a place yet that would make implementing this easier?

I still want to log out of my app :)

I'm curious about this too. What's left to be done here, and is there anywhere I can help?

We've made a lot of progress on contextualizing Relay state, but we're not all the way there yet. This is something we're actively exploring and plan to implement when it's feasible. In general we encourage community contributions, but this particular change affects the core of Relay and it will be difficult for us to review or accept PRs.

But what about all of those PRs from @devknoll? :stuck_out_tongue_winking_eye:

@taion that contributed to the "made a lot of progress" part ;-)

@taion to clarify, the recent work around contextualizing state was focused on moving the remaining pieces of state into RelayStoreData. This was relatively straightforward in terms of outward API impact and performance. The remaining changes have a much more nuanced impact on both of these aspects - we'll try to do the work in the open as much as possible to get community feedback.

@josephsavona , you've written earlier:

@skevy The workaround for now in React Native is the brute-force equivalent of a page refresh - tear down the current JS context and start a new one.

Do you have any examples or directions on how to do this?

I'm not familiar enough with the current React Native APIs - this is a good question for stack overflow.

@marcoreat I've posted a stack overflow question about this, but have yet to receive an answer.

I think I've finally figured this out. All I did was create my own native module that calls the bridge reload function as found within the developer menu. You can find the dev menu code here.

@bmcmahen Can you explain how to call reload method. I tried creating a native module importing RCTBridgeModule.h and tried calling [self.bridge reload] but gives me compilation error.

Yeah, what @nickhudkins has there is pretty much what I did.

Ohk sorry I had just imported RCTBridgeModule and not RCTBridge. Thanks for reply.

@josephsavona hello

You mention a "forceFetch" method but I don't see it documented here is it normal? https://facebook.github.io/relay/docs/api-reference-relay-store.html

thanks I wasn't looking at the good page!

@KyleAMathews This is trivial to do today: use forceFetch. For example, to poll for updates on a query, >use:

const query = Relay.createQuery(Relay.QLquery { ... }, {var: 'foo'}); Relay.Store.forceFetch({query}, readyState => { ... });

Is there way to make it work as fatQuery, so I can refetch everything that is being tracked on some particular connection?

Reason why I just don't call forceFetch inside component that has full query is simply because I need to do it somewhere else.
Something like this:

const query = Relay.createQuery(Relay.QL`query {
    viewer {
        fonts @relay(pattern:true)
    }
}`,{});
Relay.Store.forceFetch({query});

Kind of a related question how you guys handle after user logging in orther than full page reloading? Some views need to be added or updated

@tuananhtd that's a great question for Stack Overflow :-)

I don't see any reason for this to be open anymore. Based on the discussion in https://github.com/facebook/relay/pull/898#issuecomment-191816193 that concluded that this feature isn't needed now when the new environment api has landed (https://github.com/facebook/relay/issues/558).

@edvinerikson The question of how to reset the Relay store still comes up occasionally, so having this issue is a useful way for users to find the current status. I'll update the description to make the plan more clear.

How do you emulate reset() using the Environment API?

This is how I'm doing it:

util.js

export let currentRelay = {
  reset: function () {
    let env = new Relay.Environment();
    env.injectNetworkLayer(appReplayNetworkLayer);
    env.injectTaskScheduler(InteractionManager.runAfterInteractions);
    currentRelay.store = env;
  },
  store: null
};

currentRelay.reset();

index.ios.js

import {currentRelay} from './util';

...

class App extends React.Component {

  ...

  doReset() {
    currentRelay.reset();
    this.forceUpdate();
  }

  doMutation() {
    currentRelay.store.commitUpdate(new MyMutation({}));
  }

}

use Relay.Renderer directly or create your own wrapper than passes environment={currentRelay.store}. see RelayRootContainer source

I'm closing as this is now possible in the release branch. Stores cannot be reset, instead you can create a new instance of Relay.Environment and pass that to <Relay.Renderer environment={...} />.

At the moment, using custom environments won't work with mutations. The patch is in the repo at https://github.com/facebook/relay/commit/b44fcb2f69c69ae32a9a691949d65f11dfb09fa7 , but it hasn't been released, so you'll have to manually patch it in order to get custom environments to work with mutations. Once you're patched, you can do this.props.relay.commitUpdate(new MyMutation({ ...inputs }))

If you don't use the patched version, Mutations won't find cached objects correctly given in the inputs

Further reading: https://github.com/facebook/relay/issues/1136

@joenoon Have you got an example app which uses this to reset the environment? ^

@jamesone actually what I do now is a bit different. I haven't tried changing the entire environment out in months, but I remember having problems getting it to work right in my app. Sounds like it might have been addressed since then.

Not sure if this will address your needs, but what I've been doing that seems to work pretty good for me is I have a simple mutation that by default has a fatQuery that will force the entire viewer to refresh. So when the app returns from the background I just issue this mutation, and anything open in the app will refresh with current data:

import Relay from 'react-relay';

export let ActiveMutation = class ActiveMutation extends Relay.Mutation {

  getMutation() {
    return Relay.QL`mutation {active}`;
  }

  getVariables() {
    const {fatQuery,...props} = this.props;
    return props;
  }

  getFatQuery() {
    if (this.props.fatQuery) return this.props.fatQuery;
    return Relay.QL`
      fragment on ActivePayload {
        viewer
      }
    `;
  }

  getConfigs() {
    return [
      {
        type: 'FIELDS_CHANGE',
        fieldIDs: {
          viewer: 'viewer',
        },
      },
    ];
  }

}

On the server side its just a mutation with this:

  ...
  outputFields: {
    viewer: {
      type: viewerType,
      resolve: () => ({})
    },
  },
  ...

Why isn't there a command Relay.reset() which simply wipes EVERYTHING from the app?

Because it's much cleaner to just instantiate a new Relay.Environment() instead of trying to make sure you've cleaned everything on the global singleton Relay.Store.

And https://github.com/facebook/relay/issues/233#issuecomment-220143451 no longer applies.

So just do the right thing and make a new environment.

If it's for logout, just do window.location.reload()

Was this page helpful?
0 / 5 - 0 ratings