-
Notifications
You must be signed in to change notification settings - Fork 398
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
Use redux-saga #2195
Conversation
Were you able to find out if |
Yeah, they have an issue about it and have a special END action with example usage that allows server side rendering. I'll actually need to add that before this patch is considered complete.
So we should be able to do server side rendering with saga.
|
So it's implemented and people use it, apparently the docs aren't perfect but good enough, heh. |
17c67d3
to
3dd15de
Compare
3dd15de
to
51947b4
Compare
51947b4
to
1fe9386
Compare
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 |
Yeah it’s not failing locally at all, and this used to pass on Travis… but recently it seems a tonne of branches are failing, including this one once pushed again.
I don’t think I have anything special set up locally that would cause it to fail on travis; all my changes are checked in.
|
452fbac
to
d5347a5
Compare
Basically the only issue I've hit that isn't easily solvable is that |
33d9ed3
to
3537eba
Compare
src/amo/components/Categories.js
Outdated
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" |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
src/amo/components/Categories.js
Outdated
componentWillMount() { | ||
const { addonType, categories, clientApp, dispatch } = this.props; | ||
if (!Object.values(categories).length) { | ||
dispatch(showLoading()); |
There was a problem hiding this comment.
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.
There was a problem hiding this 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.
src/amo/routes.js
Outdated
@@ -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 */} |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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!
src/amo/sagas/categories.js
Outdated
import { getApi } from './utils'; | ||
|
||
|
||
// worker Saga: will be fired on every CATEGORIES_FETCH action. |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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();
There was a problem hiding this comment.
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.
src/core/actions/categories.js
Outdated
return { | ||
type: CATEGORIES_GET, | ||
type: CATEGORIES_FETCH, | ||
payload: { loading: true }, |
There was a problem hiding this comment.
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 })); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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. 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I ended up just passing in props directly, see: https://github.com/mozilla/addons-frontend/pull/2195/files#diff-eaa60fc7f4b8c5473dadb4f134abbe3eR85
vertical-align: middle; | ||
} | ||
|
||
@for $percentage from 1 through 100 { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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: { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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' }, |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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', () => { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
0d7adb2
to
0ab05db
Compare
Okay! I think I fixed up everything, ready for another r? |
There was a problem hiding this 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
src/amo/components/Categories.js
Outdated
ref={(ref) => { this.categories = ref; }} | ||
> | ||
{categories.map((category) => ( | ||
<li className="Categories-list-item" key={category.slug}> |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
src/core/server/base.js
Outdated
let store; | ||
|
||
try { | ||
cookie.plugToRequest(req, res); | ||
|
||
store = createStore(); | ||
const _store = createStore(); |
There was a problem hiding this comment.
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();
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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', () => { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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);
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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()
.
There was a problem hiding this comment.
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);
tests/client/amo/sagas/testIndex.js
Outdated
|
||
|
||
describe('amo rootSagas', () => { | ||
it('should get Api from state then make API request to categories', () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is this descripition accurate?
There was a problem hiding this comment.
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.
tests/client/amo/sagas/testIndex.js
Outdated
const sagaGenerator = rootSagas(); | ||
|
||
assert.deepEqual(sagaGenerator.next().value, [ | ||
fork(categoriesSaga), |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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. 👍
tests/client/amo/sagas/testUtils.js
Outdated
it('should return API state', () => { | ||
const state = { api: signedInApiState, somethingElse: true }; | ||
|
||
assert.deepEqual(getApi(state), signedInApiState); |
There was a problem hiding this comment.
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]: [], | ||
}, |
There was a problem hiding this comment.
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]: [], | ||
}, |
There was a problem hiding this comment.
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()
Okay, I think I addressed all the comments and tightened up the tests. I realised as well I was using |
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. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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: