-
-
Notifications
You must be signed in to change notification settings - Fork 654
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
per-account 4/n: Sort out thunk actions #5066
Conversation
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.
Thanks! See a few comments below.
src/react-redux.js
Outdated
// Odd that $ReadOnly has any effect here -- the BoundedDiff just inside it | ||
// has one of its own. But without it, Flow complains that C appears in | ||
// input/output position at ElementConfig<C>, incompatible with our marking | ||
// it as contravariant (so "input positions" only) with `-C`. Either way, | ||
// that sure looks like a perfectly good input/contravariant position: it | ||
// goes through the top of two BoundedDiffs, then an ElementConfig, making a | ||
// path +, +, -, which multiplies to -. | ||
// | ||
// Meanwhile `-SP` is correct too: it goes through the top of one | ||
// BoundedDiff and the bottom of another, for a path +, -, multiplying to -. |
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 is a little tricky! 🤔 Thanks for writing it down. Maybe we can talk in the office sometime, to help me understand it better?
I'm not sure how to apply what I know about variance to understand why we have the contravariance sigils -
in OwnProps<-C, -SP>
.
I think that's because I'm prepared to look at that type declaration and use it to evaluate whether OwnProps<C1, SP1>
<: OwnProps<C2 SP2>
for a given C1
, SP1
, C2
, and SP2
—but then I ask myself "What's a use for evaluating that?", and I can't think of one, and I can't find a flow like that in the code. Is there one?
Then I guess I'm kind of stuck there. It's also not yet very intuitive to me what an "input position" or "output position" is (though I've seen terms like these used several times before), and I don't know what "making a path +, +, -, which multiplies to -" means. Is that last bit borrowing from category theory, or something?
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.
No, not category theory. I wouldn't so casually draw on that here, I hope 😉 That sentence is pretty shorthandy, though.
Here's the code for reference:
export type OwnProps<-C, -SP, -D> = $ReadOnly<
BoundedDiff<BoundedDiff<$Exact<ElementConfig<C>>, SP>, {| +dispatch: D |}>,
>;
Then what that thought ending in "multiplies to -" expands to is:
- That place where the type parameter
C
gets used in the definition ofOwnProps
is:- as the first argument to
ElementConfig
- … the result of which is used as the argument to
$Exact
- … the result of which is used as the first argument to
BoundedDiff
- … the result of which is used as the first argument to (another)
BoundedDiff
- … the result of which is used as the argument to
$ReadOnly
- … the result of which is the definition.
- as the first argument to
- The variance of each of those type-operators in their respective type parameters is:
- contravariant, aka input, written
-
:ElementConfig<-C>
(this isn't obvious, more on it below) - (… gloss over
$Exact
) - covariant, aka output, written
+
:BoundedDiff<+U, -L>
, and this is being passed as that first parameterU
- covariant, aka output, written
+
:BoundedDiff<+U, -L>
, and this is again being passed as that first parameterU
- (… gloss over
$ReadOnly
)
- contravariant, aka input, written
- So the sequence of variances is -, +, +, reading outward, from where
C
is used out to the definition.- Or +, +, -, reading inward from the definition to that use of
C
, which is the direction I used in that comment.
- Or +, +, -, reading inward from the definition to that use of
- And the mathematical signs -, +, + multiply together to make -. (For example, the numbers -1 * +1 * +1 multiply to -1.)
- Which is just shorthand for: if you flip something backward an odd number of times, it ends up backward. Whereas if you flip it an even number of times, it ends up facing forward again.
- And contravariance means that a subtyping relationship between parameters produces a subtyping in the opposite direction between results: S <: T means
F<T>
<:F<S>
. Whereas covariance means it produces a subtyping in the same direction:F<S>
<:F<T>
.
Perhaps that's helpful? Also happy to discuss in the office tomorrow, where we'll have a whiteboard too.
Now, why do I say ElementConfig
is contravariant?
… Well, maybe I shouldn't, actually -- I just tried an isolated test case, without all the other stuff around it that I was figuring was getting things confused here, and Flow still says it's an input/output position (aka invariant):
type F<C> = ElementConfig<C>; // ok
type Fout<+C> = ElementConfig<C>; // error
type Fin<-C> = ElementConfig<C>; // error
Here's the same test case on flow.org/try.
I think of it as roughly contravariant because a React component taking props of type Props
is basically like a function Props => React.Node
. In fact, if it's a function component, it's exactly that. And for P1 => React.Node
to be a subtype of P2 => React.Node
, it's necessary and sufficient that P2 be a subtype of P1.
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 don't actually need the -C
here, so I'll take it out given that Flow believes ElementConfig
shouldn't be contravariant.
Also it turns out that while Flow does accept treating $Exact
as covariant, that's actually unsound -- you can write coerce
with it:
type E<+T> = $Exact<T>; // accepted; uh-oh
// We can use that to cast any type to any other, with Flow
// raising not a peep:
function coerce<A, B>(x: A): B {
// Make an object that has a property `f` returning `x`…
const yA: E<{ f: () => A, ... }> = { f: () => x };
// … and pass it off as type {||}, using the supposed covariance of E.
const y: E<{ ... }> = yA;
// Now spread that object to sneak its `f` property into
// an object that supposedly has `f` of a different type.
const ythrow = { f: () => { throw null } };
const yB: { f: () => B } = { ...ythrow, ...y };
// Flow accepts this because it thinks `y` has no properties,
// so that `yB` gets its `f` from `ythrow`. But in fact it's from `y`.
// Now call that `f`. The value we actually get is `x`,
// but Flow thinks it has whatever type we chose.
return yB.f();
}
const a: number = coerce('a');
a.toFixed(); // crash! but no Flow error anywhere
So that leaves me less eager to gloss over $Exact
and treat it as covariant, either 😛
@@ -0,0 +1,59 @@ | |||
// @flow strict-local |
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.
react-redux types: Propagate dispatch type properly, with BoundedDiff
Hmm, in this bit—
In the old version with `$Diff`, Flow wouldn't complain if a
component expected a `dispatch` prop with too specific a type.
—I think "too specific a type" could be made a little clearer. Is there a universal standard for how specific the type of a dispatch
prop should be (I think not)? Or is it more context-dependent, like "…if a component expected a dispatch
prop with too specific a type, i.e., a type that was more specific than what a connect
call was trying to provide for that prop"?
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 it more context-dependent, like "…if a component expected a
dispatch
prop with too specific a type, i.e., a type that was more specific than what aconnect
call was trying to provide for that prop"?
Yes, exactly -- I mean "too specific" in that its expectations are too specific for what it's actually going to get.
When the type is the same as the one that's actually being provided, then the expectations are the same on both sides and that's great.
When the type that's being expected is a supertype of the one being provided, then that's still just fine: S subtype T means S encodes all the same expectations that T does, and possibly some more expectations too. So the code providing the value is promising more than the recipient happens to care about, which is fine.
But if the type that's expected isn't a supertype of the one provided, then that means it encodes some other expectations that aren't in the provided type. The code receiving the value will be believing it can rely on some assumptions that weren't actually promised by the code providing it, so the value provided might not actually satisfy those assumptions. For example if a React component expects { +a: number, +b: string, ... }
for some prop, but the caller only thinks it's providing { +b: string, +c: boolean, ... }
for that prop, then the actual value passed as the prop may not have a property a
, and the child component could crash when it takes that property and tries to use it as a number
.
I'll reword a bit to make this more explicit.
23bb9b3
to
c76ebe1
Compare
Thanks for the review! Merging, after revisions discussed above. Take a look and see if it's clearer, and we can always revise the comments further. Also happy to discuss covariance and contravariance when we're next in the office together. |
This function has a bit of logic that makes sense only in a global context (asking whether there even is an account to have state for), but then the bulk of it is per-account. One group of its callers is firmly per-account, in the initial fetch in fetchActions.js. So those would like this function to be per-account. But then its other caller is withHaveServerDataGate. It's possible that should also be per-account, but I'm not sure of that -- in particular I'm not sure we only use it in contexts where there is an account at all. Or possibly that should be the case but it would take some refactoring to make it so. For the present, then, I'd rather defer that question by not changing its behavior; I want to just keep it making all the same checks it currently does. To accommodate both sides of that, we split this into a global and a per-account version, and have each caller use the appropriate one. This means those per-account actions in fetchActions.js are now calling the per-account version of this selector.
This is really a per-account thunk action, which we'll soon be giving only a per-account state. So this bit of logic where it's assuming that that's also secretly a global state will need to be marked like this.
These helpers' callers are per-account thunk actions. We'll need this when we make plain ThunkAction stop getting a GlobalState.
In the old version with `$Diff`, Flow wouldn't complain if a component expected a `dispatch` prop with too specific a type for what it was actually going to get: that is, if the type it expected was a subtype of the type actually provided by `connect`, encoding more-specific expectations about the value than `connect` was actually promising to meet. It actually *would* complain if the component didn't have specific *enough* expectations of the type. That is, it'd accept calling `connect` on a component expecting a `dispatch: empty` -- something `connect` couldn't possibly give it -- but reject doing so with `dispatch: mixed`, which should be perfectly fine. Effectively, by using `$Diff` here, we seem to have gotten the comparison backward. (And Flow is unable to check our work in doing so, because the whole reason we have this file is that the react-redux libdef is too lax.) That hasn't really been a practical issue to date, because the only reasonable type for something called `dispatch` in our codebase has been `Dispatch`. But we're soon going to have two flavors of that type, one for acting on a particular account's data and one for acting on all accounts' or purely account-independent data, so the distinction will become an important one. Happily, our `BoundedDiff` solves this problem.
… type This component discards the `dispatch` prop it gets, so it doesn't really care about its type and can say it merely expects a `mixed`. This will let us change the `dispatch` type that gets passed here without having to make a meaningless edit at this line. Previously we couldn't write `mixed` here because of a bug in our `OwnProps` type, which is used in our `connect`. We've just fixed that by using our `BoundedDiff` in place of `$Diff`.
We're about to have two different "dispatch" types. This will let us use the same `OwnProps` with reference to either of them.
For now the plain Dispatch still provides a GlobalState (which in turn can still for now be used as a PerAccountState). With these separate names we'll be able to start distinguishing thunk actions that really are meant to be global from those that are per-account. Then we'll be able to tighten the plain ThunkAction type (and Dispatch, which is tied to it) to provide only a PerAccountState.
This handy feature of redux-thunk lets us provide a way for per-account thunk actions to get access to data that's legitimate for them to have but lives outside of a PerAccountState. We leave a fixme in the redux-mock-store libdef, with a TODO comment so we'll know to come back to it later in this series of PRs. The libdef already had an imprecise type here, and it's for a module only used in tests so it's not too critical if there's a type error somewhere that it ends up concealing.
This marks as global all our global thunk actions and some of their callers. The remaining callers are a couple of Redux components. For now, they still invoke these thunk actions with a plain `Dispatch`, and so we rely for now on the fact that we haven't yet made that distinct from `GlobalDispatch` -- the former still provides a `GlobalState` to its actions. In the next commit, we'll make it possible to mark those components as global so that they get a `GlobalDispatch`, and then we can tighten `Dispatch`.
This marks as global all the places we get a `dispatch` which is meant to be global. For now the distinction between `connectGlobal` and plain `connect` is only about the `dispatch` prop, not the state provided to the `mapStateToProps` callback; so plain `connect` still provides a GlobalState. We'll tighten that part separately later.
This confirms that we've now explicitly marked global all the places that we need a GlobalDispatch. Anywhere that's still getting a plain Dispatch -- which means the bulk of our thunk actions and Redux components -- has to actually behave as per-account, modulo any explicit fixmes. (And we've only added one of those, a call to assumeSecretlyGlobalState in eventActions.js.)
c76ebe1
to
8f4347a
Compare
This is the next PR in the series after #5030, #5016, #5017, and #5023, produced from the branch described at #5006 (comment) (and after the interlude in #5043, #5053, and #5054.)
From the foreshadowing last time:
This PR handles thunk actions. It also causes React components to get a
dispatch: Dispatch
that provides a PerAccountState to the dispatched thunk actions, rather than a GlobalState.Coming next, we'll convert React components to get a PerAccountState themselves. After that, we'll start distinguishing plain actions: just like other areas of our code before the start of this PR series, most of them implicitly refer to the active account while others don't, and we'll start tracking which is which so that we can later reinterpret the former group as referring to whichever specific account their caller had in mind.