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

Use redux-saga #2195

Merged
merged 1 commit into from
May 25, 2017
Merged

Use redux-saga #2195

merged 1 commit into from
May 25, 2017

Conversation

tofumatt
Copy link
Contributor

@tofumatt tofumatt commented Mar 27, 2017

Adds support for redux-saga and server-side rendering of sagas.

This patch also gets around redux-saga not blocking on request by simply rendering placeholder content for the components it renders, which I think is a nicer solution anyway and something we should do more of in the future. It's more manual but creates a much nicer feel and I think will make the client-side jankiness we loathe much less of an issue.

Closes mozilla/addons#10293.

I think I've cleaned this up enough it's ready for r?


Screenshot of the new loading indicator:

may-05-2017 14-40-30

@kumar303
Copy link
Contributor

Were you able to find out if redux-saga supports an isomorphic render on the server? We should figure that out first before going too far with it.

@tofumatt
Copy link
Contributor Author

tofumatt commented Mar 28, 2017 via email

@tofumatt
Copy link
Contributor Author

END issue is here: redux-saga/redux-saga#255

So it's implemented and people use it, apparently the docs aren't perfect but good enough, heh.

@tofumatt
Copy link
Contributor Author

The test failures here are totally confounding me. Any idea what's going on?

@kumar303
Copy link
Contributor

The test failures here are totally confounding me. Any idea what's going on?

The error doesn't make a whole lot of sense -- I guess you don't get it locally? One thing that comes to mind is that we need to support async functions in babel somehow. If it's working for you locally already then maybe it's set up. We use the async and await keywords on web-ext but perhaps that's a different approach? For that we have to specify regenerator: true and we use the regenerator-runtime. Does addons-frontend need something like that?

@tofumatt
Copy link
Contributor Author

tofumatt commented Apr 17, 2017 via email

@tofumatt tofumatt force-pushed the redux-saga-2150 branch 2 times, most recently from 452fbac to d5347a5 Compare April 27, 2017 15:41
@tofumatt
Copy link
Contributor Author

tofumatt commented May 3, 2017

Basically the only issue I've hit that isn't easily solvable is that redux-async-connect sends its END_GLOBAL_LOAD event before the saga has finished to the loading bar won't display. There doesn't seem to be a straightforward way around this but I'll look further. This should be ready for review soon and fortunately changing something over to a saga is pretty easy so I should be able to do it for search in another PR and get the loading bar working then.

render() {
const { addonType, categories, error, loading, i18n } = this.props;

if (loading) {
return (
<div className="Categories">
<p>{i18n.gettext('Loading...')}</p>
<span className="visually-hidden">{i18n.gettext('Loading…')}</span>
<ul className="Categories-list"
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think some of this could be refactored so the categories were just an array of <LoadingText /> elements and if loading === true they are displayed in <span> instead of <a> tags. I can refactor this to duplicate less markup I think.

error: PropTypes.bool,
loading: PropTypes.bool.isRequired,
i18n: PropTypes.object.isRequired,
}

componentWillMount() {
const { addonType, categories, clientApp, dispatch } = this.props;
if (!Object.values(categories).length) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I would say this is the big difference for redux-saga: our components are now expected to load/work without data and then dispatch their own actions to get said data if that's the case. For this simple case of categories it was easy and I ended up reloading them from state because they nearly never change and it made sense. For things like search I suspect there'd be more effort involved though in reality it will mostly be shuffling around existing code. But I think this component now makes a lot more sense to look at: if no categories exist (basically only happens on first load) it fetches them and shows a placeholder until then. The mechanism for loading is in the saga, yes, but I think this is a bit easier to follow.

componentWillMount() {
const { addonType, categories, clientApp, dispatch } = this.props;
if (!Object.values(categories).length) {
dispatch(showLoading());
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This should have been in the saga, my bad. Will move it.

@tofumatt tofumatt changed the title WIP: Use redux-saga Use redux-saga May 5, 2017
Copy link
Contributor

@kumar303 kumar303 left a comment

Choose a reason for hiding this comment

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

I'm pretty excited about sagas! I had some change requests, mostly minor but a few major ones.

@@ -32,7 +32,15 @@ export default (
<IndexRoute component={Home} />
<Route path="addon/:slug/" component={DetailPage} />
<Route path="addon/:addonSlug/reviews/" component={AddonReviewList} />
<Route path=":visibleAddonType/categories/" component={CategoryList} />
{/* These user routes are to make the proxy serve each URL from */}
Copy link
Contributor

Choose a reason for hiding this comment

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

This looks like maybe it got added back when resolving a conflict? All of these routes got removed in a611f0a#diff-c798d928b4d830910f653f823fa95de4L32

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Indeed. I messed up resolving a conflict and should have kept these removed. Will fix!

import { getApi } from './utils';


// worker Saga: will be fired on every CATEGORIES_FETCH action.
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think this comment is necessary because the call to yield takeEvery(CATEGORIES_FETCH, fetchCategories) is well documented and also quite intuitive.

src/amo/store.js Outdated
);

store.runSaga = sagaMiddleware.run;
Copy link
Contributor

Choose a reason for hiding this comment

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

Assigning this to store.runSaga is risky and I don't think it's totally necessary. It's risky because the store API is out of our control; it's defined by Redux. It could also trip someone up if they're staring at store.runSaga() and trying to find it in the Redux documentation.

I think you can do it by returning { store, sagaMiddleware } from createStore() and using it directly when you need it:

const { sagaMiddleware, store } = createStore();
// ...
sagaMiddleware.run(rootSagas);

You will have to update all existing calls from...

const store = createStore();

to...

const { store } = createStore();

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ten four. There are a few examples of this usage and it was quick and easy, but you're right. I'll go through and change it to export both items, it'll be a bit noisier in the diff.

return {
type: CATEGORIES_GET,
type: CATEGORIES_FETCH,
payload: { loading: true },
Copy link
Contributor

Choose a reason for hiding this comment

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

👍 for removing loading from all of these action payloads. You can remove this one too because the reducer already sets loading: true. Dispatching an action with an empty payload is fine.

componentWillMount() {
const { addonType, categories, clientApp, dispatch } = this.props;
if (!Object.values(categories).length) {
dispatch(categoriesFetch({ addonType, clientApp }));
Copy link
Contributor

Choose a reason for hiding this comment

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

When I comment this out, all the tests still pass which means it's not covered. It should have test coverage to make sure that categories are fetched when the component first renders. The way we would typically do this is to use mapDispatchToProps() to provide something like this.props.fetchCategories() and then that can be replaced with a sinon stub to make sure it gets called on first render.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah thanks, good catch. It works when testing in the browser too (I spent a lot of time looking at it when doing the loading text placeholder) so I knew it worked and didn't give it much thought. 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

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

vertical-align: middle;
}

@for $percentage from 1 through 100 {
Copy link
Contributor

Choose a reason for hiding this comment

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

This looks like it will add 300 lines to our CSS bundle because of the duplicated rules. Can that be avoided? Could it use a CSS transition or something instead? Or could it do a gradual fade-in like a fake progress indicator?

Copy link
Contributor

Choose a reason for hiding this comment

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

If this is controlled only by client-side code then setting style props directly is the answer for this sort of thing, since react should poke the style properties directly and does not cause CSP issues. See also https://github.com/mozilla/addons-frontend/issues/2321

The only caveat to using style props is for server rendering because the initial server render would use inline-styles and they would be blocked by CSP.

Copy link
Contributor Author

@tofumatt tofumatt May 9, 2017

Choose a reason for hiding this comment

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

In this particular case, at least for now, that would work as that loading text is only seen during client-side loading. Though that could change in the future if we wanted certain stuff that loaded only on the client-side.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think I'll go ahead with a client-side only solution because this loading animation is client-side only. I'll document that fact though, just in case it confuses anyone.

it('maps state to props', () => {
const props = mapStateToProps({
api: { clientApp: 'android', lang: 'pt' },
categories: {
Copy link
Contributor

Choose a reason for hiding this comment

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

could you dispatch an action to produce this state instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Definitely. This is largely old code just moved but I'll refactor, thanks 👍

describe('mapStateToProps', () => {
it('maps state to props', () => {
const props = mapStateToProps({
api: { clientApp: 'android', lang: 'pt' },
Copy link
Contributor

Choose a reason for hiding this comment

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

you can dispach setClient and setLang to produce this state

it('should get Api from state then make API request to categories', () => {
const fetchCategoriesGenerator = fetchCategories();

let next = fetchCategoriesGenerator.next();
Copy link
Contributor

Choose a reason for hiding this comment

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

Oof, I know you are just following the docs but I think this testing approach is very fragile and not very helpful. I agree with all the same problems raised in this issue: redux-saga/redux-saga#518

I did some digging and this library looks the most promising as a solution. Do you want to try rewriting the tests with that to see how it feels?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Totally. I saw that library too and indeed even made a mistake during a refactor forgetting to re-assign next to the next result of the generator.

I'll give it a go with that lib.

it('sets the query', () => {
assert.deepEqual(action.payload, params);
});
// it('sets the query', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Should this be removed or put back?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oops, should be removed! Thanks.

@tofumatt
Copy link
Contributor Author

Okay! I think I fixed up everything, ready for another r?

@kumar303 kumar303 self-requested a review May 23, 2017 16:17
Copy link
Contributor

@kumar303 kumar303 left a comment

Choose a reason for hiding this comment

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

Everything looks great, just some minor cleanup requests

ref={(ref) => { this.categories = ref; }}
>
{categories.map((category) => (
<li className="Categories-list-item" key={category.slug}>
Copy link
Contributor

Choose a reason for hiding this comment

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

When the array is filled with zeroes the key will be 0.slug which is undefined. I don't know if that's a big deal or not?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Probably not a huge deal but that's a good point. I'll grab the index and put that in there if category.slug is undefined.

Copy link
Contributor

Choose a reason for hiding this comment

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

Since it just needs to be a unique key, you could use the index for both cases

let store;

try {
cookie.plugToRequest(req, res);

store = createStore();
const _store = createStore();
Copy link
Contributor

Choose a reason for hiding this comment

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

It's a bit weird but this should technically work since the variables are already in scope:

{ sagaMiddleware, store } = createStore();

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was getting syntax errors when I did that.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, ok. In that case, renaming _store to storeData would read better to me.

// Next action is showing the loading bar.
assert.deepEqual(calledActions[1], showLoading());

// Next action is loading the categories returned by the API.
Copy link
Contributor

Choose a reason for hiding this comment

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

This comment is stale

mockApi.verify();
});

it('should yield takeEvery() for the main generator', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this test really necessary? It doesn't seem so useful to me.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It ensures we response to all category actions instead of just one. Though actually because of how the categories API works it might be better to just have one request... I'll see.

Copy link
Contributor

Choose a reason for hiding this comment

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

Okay, I think I see what you were going for. I think it would be better to test the actual logic with an approach more like this:

sagaTester.dispatch(actions.categoriesFetch());
sagaTester.dispatch(actions.categoriesFetch()); // dispatch another
assert.equal(sagaTester.numCalled(actions.categoriesLoad({ result })), 2);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just to note the actual API for numCalled is to just pass the action type and not the entire { type, payload } object (as would result from this). I blindly copied this then wondered why it didn't work for about five minutes, hah!

It's actually:

    sagaTester.dispatch(actions.categoriesFetch());
    sagaTester.dispatch(actions.categoriesFetch());

    assert.equal(sagaTester.numCalled(CATEGORIES_LOAD), 2);

.withArgs({
api: { ...initialState.api },
})
.throws(error);
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 already have a test that verifies how the API is called, I think you could simplify this to:

mockApi.throws(error);

Also, you won't need to call mockApi.verify().

Copy link
Contributor Author

Choose a reason for hiding this comment

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

mockApi.throws(error); seems to cause an error because mockApi.throws is not a function. I can trim it down to mockApi.expects('categories').throws(error);



describe('amo rootSagas', () => {
it('should get Api from state then make API request to categories', () => {
Copy link
Contributor

@kumar303 kumar303 May 24, 2017

Choose a reason for hiding this comment

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

is this descripition accurate?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oops, no, holdover from when the test was different.

const sagaGenerator = rootSagas();

assert.deepEqual(sagaGenerator.next().value, [
fork(categoriesSaga),
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm, this test doesn't seem so useful. It looks like it will be hard to maintain, especially as sagas grow. Maybe you could just call sagaGenerator.next() to make sure no errors are thrown?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We do essentially the same thing for reducers right now... though we might be able to use the redux saga testing utils to make it better. I forgot to use them in this test. I'll see about that and failing that do your thing, yeah.

Copy link
Contributor

Choose a reason for hiding this comment

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

I know, the current test we have for registering reducers feels a bit overkill for me. I get how we want to add coverage to this code but other than that it feels like a duplication of effort.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fair enough actually and I think doing rootSagas().next() does not throw is fine. I did that and coverage remained the same; I agree that's a better and more maintainable test. 👍

it('should return API state', () => {
const state = { api: signedInApiState, somethingElse: true };

assert.deepEqual(getApi(state), signedInApiState);
Copy link
Contributor

Choose a reason for hiding this comment

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

This would be a better test if it used the SagaTester. I don't know how hard that is but the redux-saga-tester docs have some good examples of adding a saga function to the tester.

[ADDON_TYPE_LANG]: [],
[ADDON_TYPE_OPENSEARCH]: [],
[ADDON_TYPE_THEME]: [],
},
Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of hard coding this list, I think it would be better to call emptyCategoryList() directly. I don't think you gain much by hard coding all the values.

[ADDON_TYPE_LANG]: [],
[ADDON_TYPE_OPENSEARCH]: [],
[ADDON_TYPE_THEME]: [],
},
Copy link
Contributor

Choose a reason for hiding this comment

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

same comment here about emptyCategoryList()

@tofumatt tofumatt requested a review from kumar303 May 25, 2017 01:45
@tofumatt
Copy link
Contributor Author

Okay, I think I addressed all the comments and tightened up the tests. I realised as well I was using signedInApiState which while I know technically isn't too bad to use, is still raw state and I want to get away from using it so I used the store and dispatched the actions. Yay!

@kumar303
Copy link
Contributor

I was using signedInApiState ... and I want to get away from using it so I used the store and dispatched the actions

Good call! That was a bad habit I introduced as a baby step to DRY'ing up the state-based tests. We should fix that everywhere one day.

Copy link
Contributor

@kumar303 kumar303 left a comment

Choose a reason for hiding this comment

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

highfive

@tofumatt tofumatt merged commit 1bc558d into master May 25, 2017
@tofumatt tofumatt deleted the redux-saga-2150 branch May 25, 2017 15:30
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.

Try redux-saga
3 participants