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

Automatic unsubscribes: new query manager mixins for routes + components #20

Merged
merged 11 commits into from
Sep 5, 2017

Conversation

bgentry
Copy link
Member

@bgentry bgentry commented May 1, 2017

This is an attempt at solving #19.

These mixins are intended to replace most direct usage of the Apollo service. The query managers act as a proxy to the Apollo service, tracking all watched queries made in a route or component and automatically unsubscribing from them via the respective lifecycle hooks.

Additionally, the objects returned by watchQuery include an _apolloObservable property that gives direct access to the ObservableQuery returned by ApolloClient.watchQuery. This should make it easy to refetch queries or handle paginated result sets.

This is poorly tested so far! I'm posting it now for feedback. Here's what the proposed usage would be in a route:

import ApolloRouteQueryManager from 'myapp/mixins/apollo-route-query-manager';

export default Ember.Route.extend(ApolloRouteQueryManager, {
  model() {
    return Ember.RSVP.hash({
      episodes: this.apollo.watchQuery({ query: EpisodesQuery }, 'episodes'),
      heroes: this.apollo.watchQuery({ query: HeroesQuery }, 'heroes')
    });
  }

The this.apollo in the route is not actually the apollo service; it's a proxy object that tracks any queries performed in the route, automatically unsubscribing from them when resetController is called.

The usage for components is similar.

cc @viniciussbs

@bgentry bgentry force-pushed the automatic-unsubscribes branch 5 times, most recently from 62ea456 to 73cd107 Compare May 1, 2017 06:48
@bgentry
Copy link
Member Author

bgentry commented May 1, 2017

This is essentially "working" and I started changing out some of the apollo usage in my own app's routes and components. The ergonomics of actually using this feels great compared to the old way. The main drawback I've run into so far is that testing with this change feels a bit weird.

owner.register('object:apollo-query-manager', QueryManager, {
singleton: false,
});
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have no idea what I'm doing here, but I had to figure something out to make this work reasonably well in unit + integration tests for routes/components where the mixins were being used. Without this section, apollo was undefined in routes and components.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than having the query managers be container-managed, one alternative might be to introduce a simplecreateQueryManager method to the existing apollo service:

createQueryManager() {
  return QueryManager.create({ apollo: this });
}

This base mixin could then just rely on the service and instantiate a query manager on init as it's already doing, but you wouldn't have to deal with the custom registration.

Integration tests should just work, and unit tests could continue to just have needs: ['service:apollo'].

@@ -179,6 +203,23 @@ export default Service.extend({
);
},

managedWatchQuery(opts, resultKey, manager) {
let observable = this.client.watchQuery(opts);
manager.get('queries').pushObject(observable);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There should be explicit interfaces between the service and the manager being called here, such as addObservableQuery() and addSubscription()

@bgentry bgentry force-pushed the automatic-unsubscribes branch from 73cd107 to a9d74ac Compare May 1, 2017 23:25
@@ -0,0 +1 @@
export { default } from 'ember-apollo-client/mixins/component-query-manager';
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it a best practice to avoid putting mixins in app/ when users can just import them from ember-apollo-client/mixins/{name}?

@bgentry bgentry force-pushed the automatic-unsubscribes branch 2 times, most recently from f52b779 to 210aeaf Compare May 9, 2017 23:02
@@ -26,7 +27,7 @@ function newDataFunc(observable, resultKey, resolve) {
let mergedProps = { _apolloObservable: observable };

return ({ data }) => {
let dataToSend = isNone(resultKey) ? data : data[resultKey];
let dataToSend = isNone(resultKey) ? data : get(data, resultKey);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking about to send a PR with this change. With get, I guess I can get a nested property

return this.get('apollo').query({ query }, 'pretty.nested.property');

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you want to PR that separately (with a test) I'll happily merge it right away. No reason it needs to wait until I finalize this PR.

@viniciussbs
Copy link
Contributor

@bgentry I will start using this branch on my app tomorrow. I'll give you feedbacks.

@bgentry
Copy link
Member Author

bgentry commented May 17, 2017

@viniciussbs ok, I still plan to make changes due to @dfreeman's feedback but it does at least work for me. Just a little tougher to set up than I'd like.

@@ -21,6 +22,31 @@ const {

const { alias } = computed;

function newDataFunc(observable, resultKey, resolve) {
let obj;
let mergedProps = { _apolloObservable: observable };
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you are exposing the observable to be used, I think the property shouldn't be named as a private one. apolloObservable looks more appropriate to me.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My thought for keeping this private is that I wouldn't want the observable to show up in i.e. property iteration. Maybe a better, more explicit API would be to name this __apolloObservable__ to make it obvious that it's a private property, and then expose it via some accessor function:

import { getObservable } from 'ember-apollo-client';

export default Ember.Route.extend({
  model() {
    let result = this.apollo.query(...);
    let observable = getObservable(result);
    ...
  }
}

Thoughts on whether that's an improved design or if it's overkill? cc @viniciussbs @dfreeman

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd agree that it's odd to have the public documented solution for something start with "grab this property that starts with an underscore...". You could use defineProperty to explicitly make it nonenumerable, though it'll of course still show up in dev tools (albeit with a slightly faded-out key).

I personally like the getObservable approach a lot, since it abstracts away the exact details of how the access works and leaves you free to change it in the future.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kept the property named _apolloObservable, but now you're expected to get it as I documented in my proposal above. Is that sufficient, or should I make further changes (such as with defineProperty)?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this approach, too. The defineProperty one could be used in the future if we return something like a ApolloResult or something like that instead of a Ember.Object. The getObservable looks like the best shot right now.

@viniciussbs
Copy link
Contributor

Hi, @bgentry.

Inside a component I have a query to fetch resources that will be paginated and another query to fetch a single resource once. For the first query, I call this.apollo.watchQuery. Nice. But, for the seconde one, I can't call this.apollo.query - or this.apollo.queryOnce, using the addon name for the function. So I'm injecting the service - with another name to avoid collision - just to use queryOnce.

The problem here is having two different syntax to use Apollo. I have this.apollo.watchQuery and this.get('apolloService').queryOnce inside the same component. It would be nice to call this.apollo.query instead.

I've suggested query because the query manager is following the vanilla API.

@viniciussbs
Copy link
Contributor

Hi, @bgentry

Any thoughts about the things I've pointed? And are you planning a merge soon?

@bgentry
Copy link
Member Author

bgentry commented Jun 15, 2017

@viniciussbs yes to both. Planning to make changes based on the feedback. I might be able to get back to it early next week.

As for the query/queryOnce/watchQuery thing, I'm thinking the direction I should go is toward unifying those names w/ ApolloClient itself. It will probably require a series of changes, first deprecating query with a watchQuery alias, then doing the same with queryOnce -> query.

I'm trying to make this new query manager follow ApolloClient right from the start.

Also going to make the changes @dfreeman suggested.

Sorry for the delay, my startup has been busy and I haven't been able to touch the frontend in awhile.

@viniciussbs
Copy link
Contributor

@bgentry Nice.

As for the query/queryOnce/watchQuery thing, I'm thinking the direction I should go is toward unifying those names w/ ApolloClient itself. It will probably require a series of changes, first deprecating query with a watchQuery alias, then doing the same with queryOnce -> query.

The best choice in my opinion.

export default Mixin.create(BaseQueryManager, {
resetController() {
this._super(...arguments);
this.get('apollo').unsubscribeAll();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm facing a problem with this.

Let's say I'm on "/comments?status=approved" and then I navigate to "/posts":

  1. the function model inside app/routes/posts.js runs this.apollo.watchQuery to fetch posts;
  2. the query manager inside app/routes/posts.js keeps the active observable queries from this route;
  3. the function resetController inside app/routes/comments.js runs this.get('apollo').unsubscribeAll();
  4. the query manager inside app/routes/comments.js unsubscribe all observable query.

That's fine and working as expected. Now let's say I'm on "/comments?status=approved" and then I navigate to "/comments?status=pending":

  1. the function model inside app/routes/comments.js runs this.apollo.watchQuery to fetch pending comments;
  2. the query manager inside app/routes/comments.js keeps the active observable queries from this route;
  3. the function resetController inside app/routes/comments.js runs this.get('apollo').unsubscribeAll();
  4. the query manager inside app/routes/comments.js unsubscribe all observable query, including the one that was just pushed to the query manager.

The problem with this second scenario is noticed when I want to fetch more comments. The function fetchMore is called on the observable query that was previously unsubscribed and then Apollo tells me that the query does not exist.

I'm handle this using a copy of this mixin that only unsubscribe when we are exiting:

resetController(_controller, isExiting, _transition) {
  this._super(...arguments);
  if (isExiting) {
    this.get('apollo').unsubscribeAll();
  }
}

Copy link
Member Author

@bgentry bgentry Sep 1, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@viniciussbs ok, so I think I understand this scenario. When switching from /comments?status=approved to /comments?status=pending, you do in fact want for the "approved comments" subscription to be ended. However, resetController()/unsubscribeAll() isn't called until after the new model is loaded. Therefore both the "approved comments" and "pending comments" subscriptions are ended because they are both tracked at the time unsubscribeAll() is called.

Is there maybe another route hook I should be utilizing to mark when a transition has begun? That would potentially let me flag all subscriptions which existed prior to the new model data, such that I would only clear things that existed before.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@viniciussbs I just pushed up my progress thus far, including a new commit with a failing test case that I believe covers what you described. Can you please confirm before I write the fix?

My current plan is to utilize the beforeModel() route hook to tell the query manager to mark all pre-existing queries as stale, and then when resetController() gets called later, it should only cancel subscriptions that were marked as stale. Does that seem like the correct behavior and the best way to implement it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the late response, @bgentry

Yes, this is the scenario. I don't know a more appropriate hook for this case. Your plan looks like the ideal solution for me.

@viniciussbs
Copy link
Contributor

@bgentry Could you rebase against master? I would love to save my queries and mutations in external files.

@bgentry
Copy link
Member Author

bgentry commented Jul 29, 2017

@viniciussbs sorry for how long it's taken me to get back to this. Haven't been working on the front-end parts of my app much for the past couple months, and was also busy getting married :)

Will be taking this across the finish line soon.

@viniciussbs
Copy link
Contributor

@bgentry Oh, congratulations! Best wishes for you.

If you need any help, let me know.

@villander
Copy link
Contributor

@bgentry What's missing for this branch to be merge? I want to help

@bgentry bgentry force-pushed the automatic-unsubscribes branch from 210aeaf to bfcbe1f Compare August 31, 2017 21:08
@viniciussbs
Copy link
Contributor

@bgentry Great news! :-) If you need any help, let me know. And thank you for creating a new branch.

@bgentry bgentry force-pushed the automatic-unsubscribes branch from bfcbe1f to 9ffe7b5 Compare September 3, 2017 23:20
bgentry added a commit that referenced this pull request Sep 3, 2017
As noted[1] by @viniciussbs, there's a flaw in this implementation. When
`model()` is called twice on a route due to a query params change, all
watchQuery subscriptions are cancelled, including the brand new one with
the updated query params.

Really we should only be cancelling the old queries that existed prior
to the model reload.

[1]: #20 (comment)
@bgentry bgentry force-pushed the automatic-unsubscribes branch from cec7da6 to 0fd504a Compare September 3, 2017 23:56
bgentry added a commit that referenced this pull request Sep 3, 2017
As noted[1] by @viniciussbs, there's a flaw in this implementation. When
`model()` is called twice on a route due to a query params change, all
watchQuery subscriptions are cancelled, including the brand new one with
the updated query params.

Really we should only be cancelling the old queries that existed prior
to the model reload.

[1]: #20 (comment)
@bgentry
Copy link
Member Author

bgentry commented Sep 3, 2017

I pushed up all my progress so far. I think all issues are resolved aside from the issue raised by @viniciussbs about query params and multiple model() calls, which I've written a failing test case for. I also need to update all documentation and the readme to reflect these changes.

These mixins are intended to replace most direct usage of the Apollo
service. The query managers act as a proxy to the Apollo service,
tracking all watched queries made in a route or component and
automatically unsubscribing from them via the respective lifecycle
hooks.

Additionally, the objects returned by watchQuery include an
`_apolloObservable` property that gives direct access to the
ObservableQuery returned by ApolloClient.watchQuery. This should make it
easy to refetch queries or handle paginated result sets.
For better alignment with Apollo Client itself, this addon will move
towards these methods being named `query` for one-off queries and
`watchQuery` for watched queries.
As noted[1] by @viniciussbs, there's a flaw in this implementation. When
`model()` is called twice on a route due to a query params change, all
watchQuery subscriptions are cancelled, including the brand new one with
the updated query params.

Really we should only be cancelling the old queries that existed prior
to the model reload.

[1]: #20 (comment)
@bgentry bgentry force-pushed the automatic-unsubscribes branch from 0fd504a to 510345d Compare September 4, 2017 23:13
@bgentry
Copy link
Member Author

bgentry commented Sep 5, 2017

I realized I can't actually deprecate _apolloUnsubscribe just yet, as it's still the only mechanism for unsubscribing for data fetched outside a route or component (such as within a service). Going to just punt on that issue for now and remove the deprecation warning.

Rather than documenting and expecting users to use a private-ish key to
unsubscribe from a watchQuery, expose a function whose explicit purpose
is to get the observable. This makes it easier to change the
implementation in the future while keeping the same API.
When re-fetching a model (likely due to a change in query parameters),
we only want to end any old ("stale") subscriptions that existed prior
to the new model(s) being loaded. To do this, we mark preexisting
subscriptions as stale when `beforeModel` is called.

Additionally, we need to clear out *all* subscriptions when the route is
being torn down, so we special case this scenario in the
RouteQueryManager.
This is handled automatically now by the RouteQueryManager mixin.
@bgentry bgentry force-pushed the automatic-unsubscribes branch from 3a4e4ed to 77bae96 Compare September 5, 2017 00:37
@bgentry
Copy link
Member Author

bgentry commented Sep 5, 2017

Alright, I think that should cover everything! Last call for review or feedback. I've updated the readme to reflect new usage. I'll also be updating my own app to use this updated branch as a final check.

This will be released as v0.4.0 as it includes a couple of new deprecations, though nothing breaking unless you were already using an earlier version of this PR.

@bgentry bgentry force-pushed the automatic-unsubscribes branch from 77bae96 to c04de01 Compare September 5, 2017 00:56
@bgentry bgentry merged commit 458ed93 into master Sep 5, 2017
@bgentry bgentry deleted the automatic-unsubscribes branch September 5, 2017 05:08
@bgentry
Copy link
Member Author

bgentry commented Sep 5, 2017

Well, it took me just a bit too long to come back to this PR. Sorry for the delays, but it's now merged. Releasing v0.4.0 shortly!

@viniciussbs
Copy link
Contributor

Thank you for your efforts, @bgentry. I'll try to update to v0.4.0 today.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants