Skip to content

Conversation

@Lucretiel
Copy link
Contributor

  • Moved the function-or-object check on the reducers parameter to outside the closure function
  • Removed the use of magic strings
  • Removed the mutation of the passed-in reducers argument

- Moved the function-or-object check on the `reducers` parameter to outside the closure function
- Removed the use of magic strings
- Removed the mutation of the passed-in `reducers` argument
: type;

const nextReducer = isFunction(reducers) ? reducers : reducers.next;
const throwReducer = isFunction(reducers) ? reducers : reducers.throw;
Copy link
Contributor

Choose a reason for hiding this comment

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

What do you think about using multiple assignment to DRY things up here?

In the Babel REPL with our .babelrc:

let foo
let bar
const o = { foo: 'baz', bar: 'qux' }

if (flag) {
  [ foo, bar ] = [ o.foo, o.bar ] 
} else {
  [ foo, bar ] = [ o, o ] 
}

// flag === true => baz qux
// flag === false =>  {"foo":"baz","bar":"qux"} {"foo":"baz","bar":"qux"}
console.log(foo, bar) 

so we could be more DRY here with

let nextReducer = throwReducer = reducers;

if (!isFunction(reducers)) {
  [ nextReducer, throwReducer ] = [ reducers.next, reducers.throw ]
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh that's perfect. I was actually wishing I could do that but I couldn't work out a clean way to do it... I didn't know you could do [a, b] = ... in addition to {a, b} = ... Will update, probably with:

const [nextReducer, throwReducer] = isFunction(reducers) ?
    [reducers, reducers] :
    [reducers.next, reducers.throw]

const reducer = action.error === true ? throwReducer : nextReducer;

return isFunction(reducer)
? reducer(state, action)
Copy link
Contributor

@yangmillstheory yangmillstheory Jun 30, 2016

Choose a reason for hiding this comment

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

I was thinking this might not be necessary with your changes, but I guess either reducers.throw or reducers.next might not be functions.

I was wondering if we should fail earlier if !isFunction(reducers) and reducers is not an object with function properties next and throw (not necessarily suggesting it be implemented in this PR), since that's the documented API. It would eliminate the need for more downstream checks.

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 can add that; this PR actually makes that more natural anyway. Check to make sure that nextReducer and throwReducer !== undefined, and throw otherwise?

Copy link
Contributor Author

@Lucretiel Lucretiel Jun 30, 2016

Choose a reason for hiding this comment

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

We should assume that doing something like this is an error, right?

const reducer = handleAction(myAction, {
    next: ...
    // No throw reducer
})

Copy link
Contributor

@yangmillstheory yangmillstheory Jun 30, 2016

Choose a reason for hiding this comment

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

I think this is a case of a breaking change, but it's also the library doing what the documentation says

If a single reducer is passed, it is used to handle both normal actions and failed actions. (A failed action is analogous to a rejected promise.) You can use this form if you know a certain type of action will never fail, like the increment example above.

Otherwise, you can specify separate reducers for next() and throw(). This API is inspired by the ES6 generator interface.

So it seems it doesn't make sense to not pass throw and just pass next in the object form, since the former case would handle that. This would break any code that was passing just next and not throw.

For your implementation, why not do an isFunction check (for both next and throw) instead of checking undefined?

Also, you could ensure that you can dot into reducers first before checking the types of reducers.throw and reducers.next. This would have thrown errors previously, so it's not a breaking change, we'd just be being more explicit about why it failed here.

Copy link
Contributor

Choose a reason for hiding this comment

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

Another positive effect of this change would be that handleAction would fail earlier at the definition level, and not the invocation level. Failing earlier is 👍 .

Copy link
Contributor Author

@Lucretiel Lucretiel Jun 30, 2016

Choose a reason for hiding this comment

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

Added a patch; you can tell me what you think. We're definitely into a place where my grasp of precise javascript semantics is pretty fuzzy.

As for the breaking change thing– I had the same question about a python library a long time ago. It turns out that the documentation is really the only source of truth; changing behavior that is undocumented, or errors thrown because of use the library incorrectly or in an undocumented way, isn't considered making a breaking change; it's either a bug fix or a new feature, depending on the change. I'll leave it up to you guys whether you think it counts in this case.

See also the question I asked here: semver/semver#248. Basically, I was worried that adding new arguments to a python function would constitute a breaking change, because people who were calling the function with those arguments in a prior version and seeing a TypeError wouldn't see that anymore.

Copy link
Contributor Author

@Lucretiel Lucretiel Jun 30, 2016

Choose a reason for hiding this comment

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

Note that these checks would make a use case of factoring out...

That reminds me– are we trying to keep this project minimally dependent on others? Because lodash has good implementations of both isFunction and ownKeys we could use, and has modular, one-function libraries we can depend on to minimize how much gets pulled in.

Copy link
Contributor

@yangmillstheory yangmillstheory Jun 30, 2016

Choose a reason for hiding this comment

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

Yeah, it would seem that it's explicitly trying to not take dependencies. There's also https://github.com/acdlite/redux-actions/blob/master/src/createAction.js#L1 which is included in _.

IMO we could quite easily factor out isFunction though, especially since it's already in the codebase. I think this would be more preferable than taking a dependency (which is a choice that I'm not qualified to encourage here).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's fine; it runs against my personal grain, but it's a perfectly valid standpoint. Doing that refactoring seems outside the scope of this PR, though, so we'll do it after this is resolved.

Copy link
Contributor

Choose a reason for hiding this comment

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

On my fork, I've started using lodash micro-packages.

Since I'm making faster progress there, I'm considering renaming it and publishing to NPM.

@yangmillstheory
Copy link
Contributor

👍 nice improvement of hygiene. ping @timche

@Lucretiel Lucretiel mentioned this pull request Jun 30, 2016

// Otherwise, assume an action map was passed
const reducer = reducers[handlerKey];
const reducer = action.error === true ? throwReducer : nextReducer;
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 a quick note: I double checked the FSA spec, and confirmed that this === true behavior is in fact the most correct (as opposed to doing something like action.error ? throwReducer : nextReducer):

If error has any other value besides true, including undefined and null, the action MUST NOT be interpreted as an error.

So 👍 to whoever got that right.

Copy link
Contributor

Choose a reason for hiding this comment

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

Cool, I was going to ask why we weren't checking truthiness, but that settles that.

if (nextReducer === undefined || throwReducer === undefined) {
throw TypeError("reducers argument must be either a function or an object of shape {next, throw}");
throw new TypeError(
'reducers argument must be either a function or an object of shape {next, throw}'
Copy link
Contributor

@yangmillstheory yangmillstheory Jun 30, 2016

Choose a reason for hiding this comment

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

reducers must be a reducer function or an object with shape {next, throw} where 'next' and 'throw' are reducer functions

I know you were broken with ESLint for long lines, so you could use string concatenation:

throw new TypeError(
  'reducers must be a reducer function or an object with ' + 
  'shape {next, throw} where "next" and "throw" are reducer functions'
)

@Lucretiel
Copy link
Contributor Author

Lucretiel commented Jun 30, 2016

Hmmm. So the tests that are failing are, in fact, using the partial object style:

it('uses `next()` if action does not represent an error', () => {
  const reducer = handleAction(type, {
    next: (state, action) => ({
      counter: state.counter + action.payload
    })
  });
  expect(reducer(prevState, { type, payload: 7 }))
    .to.deep.equal({
      counter: 10
    });
});

The documentation makes no mention of this style being valid, and in practice I can't think of any reasonable behavior for it. Silently skipping actions that are (or aren't) errors seems like an accident waiting to happen, and we already have a syntax for a unified reducer. It's my belief, therefore, that this test and its ilk are buggy, and there should at least be a placeholder reducer added to make it pass. However, I'll await advice from the rest of you before making this change.

If you agree and I fix this test, I'll add an extra pair of tests that ensure that it does throw when either key is missing.

@yangmillstheory
Copy link
Contributor

Looked at the Travis failures and I agree that placeholder reducers (https://github.com/acdlite/redux-actions/blob/master/src/createAction.js#L1) should be added to those tests. Thinking of tests as a form of API documentation, this makes even more sense. Does adding them make the tests pass?

@Lucretiel
Copy link
Contributor Author

Lucretiel commented Jun 30, 2016

Oh, dear. It actually appears that, based on the tests (thought not the written documentation), the defined behavior is in fact to do nothing if no function is attached to either the next or throw keys:

https://github.com/acdlite/redux-actions/blob/master/src/__tests__/handleAction-test.js#L87-L92

There is no similar test in place for passing a null as the reducer argument itself. This, in fact, was changed from the prior behavior, which DID raise an exception if a function wasn't passed in: e659159#diff-13523acaa823765ceef717519f2dcc3dL75

It's not clear to me if this change was made as an explicit API change, or if it was just changed with some subtle javascript thing. Pinging @acdlite for his input, as he wrote the change.

It's one thing for it to be a subtle side effect; it's another thing entirely if there's an explicitly described test describing the behavior. At the time, the addition of this (and possibly other) changes were seen as only needing a minor version bump. Semver itself leaves it up to our interpretation what counts as the "documented public API." It's still my preference that this behavior be changed to raise as error, especially because that error would be raised from handleAction and not from the returned action creator. It's also my preference that, because the written documentation in the README does say either way, we consider this only a patch change, or perhaps a new minor feature because of the added type checking. But I'll defer to the wisdom of the rest of you on that.


// If function is passed instead of map, use as reducer
if (isFunction(reducers)) {
reducers.next = reducers.throw = reducers;
Copy link
Contributor

@yangmillstheory yangmillstheory Jul 1, 2016

Choose a reason for hiding this comment

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

FYI, on my fork with stricter ESLint rules, this fails with

/Users/VictorAlvarez/code/private/redux-actions/src/handleAction.js
  18:7   error  Assignment to property of function parameter 'reducers'  no-param-reassign
  18:23  error  Assignment to property of function parameter 'reducers'  no-param-reassign

If this rule were enabled here, your change would pass it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah. That was the original problem I saw that compelled me to make this fix– mutating/assigning to the input parameter seemed like a VERY bad idea.

Lucretiel added 4 commits July 1, 2016 13:29
Removed improper type check
Added earlier type check on reducers
Update README with docs about optional reducers.
@Lucretiel
Copy link
Contributor Author

So, after sleeping on it, I decided that in this case it's best to defer to the behavior explicitly described by the tests. I also decided that only specifying one of next or throw could make sense in certain situations, like when you want to ignore errors. With this in mind, I reverted the type check, and added some extra supporting code to further do as much preprocessing of the reducers as possible, minimizing the work done by the actual returned reducer. I also added some extra docs to the README about the optional reducers.

@yangmillstheory
Copy link
Contributor

Nice. Does the case of passing in a reducer function as opposed to an object handle the case of wanting to ignore errors?

@Lucretiel
Copy link
Contributor Author

Lucretiel commented Jul 1, 2016

Not by itself, no. As per the docs:

If a single reducer is passed, it is used to handle both normal actions and failed actions.

If you pass a single reducer function, instead of an object, the function is invoked for all actions of that type, whether or not they are errors.

@Lucretiel
Copy link
Contributor Author

Keep in mind that that's the typical case anyway. Most users won't ever have a need to create or handle error actions. I myself am already not a fan of async or thunk action creators, because they don't give you a useful handle to the in-progress action that you can cancel or do something else with.

@yangmillstheory
Copy link
Contributor

yangmillstheory commented Jul 1, 2016

Yes, I've not found many uses for this myself, yet.

What I've used it for, e.g., is force logging a user out if log in or log out fails, for some reason.

After reading #97 (comment) again, I completely agree with the reasoning behind allow any defined/undefined combination of next and throw to be defaulted.

}

function coerceReducer(reducer) {
return isFunction(reducer) ? reducer : state => state;
Copy link
Contributor

Choose a reason for hiding this comment

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

This isn't a blocking concern, but I still wish we were more intentional about the argument type passed in.

null and undefined seem reasonable to coerce to a default, but 1, {}, [], true, false seems like a misuse of the API that we should throw TypeError on.

IMO, the most ideal situation would be to not conflate null and undefined, and just pick one to coerce, preferably undefined, so we could use default argument syntax like https://github.com/yangmillstheory/redux-standard-actions/blob/master/src/actionReducer.js#L27.

@yangmillstheory
Copy link
Contributor

yangmillstheory commented Jul 13, 2016

@timche could you take a look?

@Lucretiel do you mind if I push some minor formatting changes to your branch? (I know you've really been patient with this, so whatever I can do to take some of the extra work off your hands would be great).

Thanks for your patience. I'd love to get this small PR (that's generated some good useful discussion) in soon!

Made the type checking of the reducers more strict, requiring either a function or `undefined`.
Replaced strings with templates
Fixed trailing whitespace
@@ -1,20 +1,32 @@
function isFunction(val) {
return typeof val === 'function';
Copy link
Contributor

Choose a reason for hiding this comment

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

Sorry, #97 (comment) wasn't meant to suggest this change - it's more of a longer-term vision for this library. You can revert this commit if you like.

(Also, you can use ES6 string interpolation below.)

@yangmillstheory
Copy link
Contributor

This has gotten a bit stale and has got tons of discussion, let's move this over to #109.

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.

2 participants