Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

Gutenboarding: data-store reducer function signatures don't need to list which actions they handle #38932

Merged
merged 3 commits into from
Feb 11, 2020

Conversation

p-jackson
Copy link
Member

@p-jackson p-jackson commented Jan 20, 2020

In order to get type checking and code completion, the data-store reducers have been explicitly listing each of the actions they handle in their type signature. I think this (1) doesn't scale well as more cases are added to the switch statement (2) makes the reducer's type too specific.

A reducer should be able to handle any of a store's actions without outside observers being aware any changes that are made. By listing them in the type they're effectively part of the function's API, and changing the implementation could be a breaking API change (loosely speaking).

Edit: and converting the reducers to function expressions is entirely optional. I just did it to drive home that adding TypeScript to reducers doesn't require you to jump through any extra hoops, write reducers in a way that's different from JS reducers, or declare somethings type before defining it.
But I don't mean to bikeshed on a stylistic thing 😅 I made the change in a single commit so it's easy to drop.

Changes proposed in this Pull Request

Reducer action types are still generated automatically from the action creators, but now it includes all actions.

  • Add a new ActionsDefinedInModule mapped type
  • Reducer function signatures include all actions for that store
  • Switch the reducers to use function expressions (optional)

@matticbot
Copy link
Contributor

@p-jackson p-jackson self-assigned this Jan 20, 2020
@p-jackson p-jackson added the [Goal] New Onboarding previously called Gutenboarding label Jan 20, 2020
@matticbot
Copy link
Contributor

matticbot commented Jan 20, 2020

Here is how your PR affects size of JS and CSS bundles shipped to the user's browser:

Webpack Runtime (~26828 bytes removed 📉 [gzipped])

name      parsed_size            gzip_size
manifest    -156233 B  (-91.8%)   -26828 B  (-86.5%)

Webpack runtime for loading modules. It is included in the HTML page as an inline script. Is downloaded and parsed every time the app is loaded.

App Entrypoints (~639276 bytes removed 📉 [gzipped])

name                   parsed_size            gzip_size
entry-jetpack-cloud     -1144190 B  (-74.3%)  -275596 B  (-73.3%)
entry-domains-landing    -446927 B  (-69.7%)  -123150 B  (-71.9%)
entry-gutenboarding      -282503 B  (-12.4%)   -80856 B  (-13.4%)
entry-main               -273244 B  (-16.7%)   -79837 B  (-19.9%)
entry-login              -273244 B  (-26.0%)   -79837 B  (-28.8%)

Common code that is always downloaded and parsed every time the app is loaded, no matter which route is used.

Sections (~11507 bytes removed 📉 [gzipped])

name                      parsed_size              gzip_size
domains                     +286141 B    (+43.2%)   +70322 B    (+46.0%)
purchases                   -210786 B    (-21.7%)   -51374 B    (-22.1%)
media                       +203460 B    (+56.5%)   +44995 B    (+47.0%)
earn                        -196774 B    (-43.3%)   -43049 B    (-40.0%)
mailing-lists               +123934 B  (+1858.1%)   +31126 B  (+1619.5%)
#                      +112297 B    (+76.8%)   +28267 B    (+76.9%)
import                      -101775 B    (-50.2%)   -25864 B    (-49.2%)
people                       -98464 B    (-28.1%)   -23733 B    (-27.0%)
marketing                    -93084 B    (-22.2%)   -29825 B    (-28.9%)
notification-settings        -84745 B    (-27.6%)   -19084 B    (-24.6%)
jetpack-connect              +84726 B    (+17.8%)   +21958 B    (+17.5%)
plans                        +78979 B    (+20.1%)   +19853 B    (+19.4%)
reader                       +77487 B    (+19.7%)   +17109 B    (+16.6%)
devdocs                      -54563 B    (-42.4%)   -14999 B    (-42.5%)
home                         +54256 B    (+19.8%)   +14491 B    (+20.2%)
pages                        -53937 B    (-22.9%)   -13209 B    (-20.9%)
domain-connect-authorize     +50443 B   (+401.6%)   +13913 B   (+386.5%)
google-my-business           +43706 B    (+17.0%)    +8208 B    (+11.6%)
email                        -43632 B    (-14.9%)    -8904 B    (-11.9%)
concierge                    -38347 B    (-10.0%)   -11499 B    (-12.7%)
stats                        -37646 B     (-3.7%)    -2790 B     (-1.1%)
gutenberg-editor             -34445 B     (-5.0%)    -9616 B     (-5.0%)
security                     +29950 B     (+7.3%)    +7020 B     (+6.6%)
migrate                      -29616 B    (-24.7%)    -6621 B    (-21.0%)
settings-writing             -29275 B     (-6.8%)    -7152 B     (-6.6%)
settings                     -29275 B     (-5.8%)    -7152 B     (-5.5%)
settings-security            -28590 B    (-11.0%)    -6489 B     (-9.5%)
settings-performance         -27618 B    (-13.6%)    -6144 B    (-11.3%)
settings-discussion          -27618 B    (-16.6%)    -6144 B    (-14.5%)
help                         -22382 B     (-4.8%)    -4883 B     (-4.2%)
checkout                     +21144 B     (+1.9%)    +2912 B     (+1.0%)
hosting                      +20717 B     (+8.8%)    +5332 B     (+8.5%)
me                           +20471 B     (+9.4%)    +3175 B     (+5.4%)
checklist                    +14234 B     (+5.2%)    +4017 B     (+5.6%)
happychat                    +13665 B     (+5.5%)    +3606 B     (+5.4%)
auth                         -12255 B    (-40.3%)    -3420 B    (-39.4%)
feature-upsell                -9660 B     (-6.8%)    +1043 B     (+3.1%)
hello-dolly                   -7640 B     (-8.2%)    -2812 B    (-10.9%)
comments                      -5970 B     (-1.3%)    -1939 B     (-1.9%)
themes                        +2616 B     (+0.8%)     -572 B     (-0.7%)
plugins                       +2616 B     (+0.6%)     -572 B     (-0.5%)
post-editor                   -1657 B     (-0.1%)    -1008 B     (-0.2%)

Sections contain code specific for a given set of routes. Is downloaded and parsed only when a particular route is navigated to.

Async-loaded Components (~12545 bytes added 📈 [gzipped])

name                                           parsed_size              gzip_size
async-load-#-steps-reader-landing            -42241 B    (-78.7%)   -11140 B    (-78.1%)
async-load-#-steps-clone-start               -32205 B    (-69.6%)    -9728 B    (-70.3%)
async-load-#-steps-launch-site               -30579 B    (-98.3%)    -8628 B    (-96.3%)
async-load-#-steps-plans                     +28983 B    (+23.3%)    +8178 B    (+25.2%)
async-load-#-steps-clone-point               +28193 B    (+22.9%)    +8857 B    (+33.7%)
async-load-#-steps-domains                   +26936 B    (+14.6%)    +7310 B    (+16.5%)
async-load-#-steps-passwordless              +24170 B  (+4509.3%)    +5751 B  (+1732.2%)
async-load-#-steps-plans-atomic-store        +23675 B    (+32.1%)    +4909 B    (+23.6%)
async-load-reader-site-stream                     -23630 B    (-49.1%)    -5340 B    (-44.0%)
async-load-#-steps-user                      +22446 B    (+25.1%)    +6357 B    (+27.7%)
async-load-reader-following-manage                +22270 B    (+25.1%)    +5007 B    (+20.7%)
async-load-#-steps-import-preview            -19845 B    (-48.4%)    -5049 B    (-44.2%)
async-load-#-steps-site-topic                +16946 B   (+145.5%)    +4775 B   (+141.7%)
async-load-reader-team-main                       -15414 B    (-97.7%)    -4514 B    (-94.5%)
async-load-#-steps-about                     +14607 B    (+45.4%)    +4256 B    (+50.4%)
async-load-reader-tag-stream-main                 +12529 B   (+384.9%)    +3430 B   (+254.3%)
async-load-#-steps-import-url-onboarding     +10626 B    (+51.9%)    +3217 B    (+56.0%)
async-load-#-steps-site-picker               -10326 B    (-54.0%)    -2868 B    (-52.7%)
async-load-reader-conversations-stream             -8826 B    (-65.7%)    -2637 B    (-61.1%)
async-load-#-steps-site-or-domain             +8571 B    (+81.4%)    +2278 B    (+71.9%)
async-load-#-steps-test-step                  -3732 B    (-31.9%)     -892 B    (-27.7%)
async-load-reader-search-stream                    -3616 B     (-4.1%)    -1403 B     (-6.0%)
async-load-#-steps-clone-destination          +2644 B    (+17.1%)    +1017 B    (+25.7%)
async-load-#-steps-site-title                 +2498 B    (+28.5%)     +527 B    (+20.5%)
async-load-#-steps-site-type                  -1926 B    (-14.1%)     -408 B    (-11.3%)
async-load-#-steps-clone-credentials          +1847 B     (+4.3%)      -22 B     (-0.2%)
async-load-quick-language-switcher                 -1385 B     (-4.5%)     +422 B     (+5.3%)
async-load-#-steps-clone-cloning              -1365 B     (-9.1%)     -543 B    (-12.0%)
async-load-#-steps-rewind-migrate              -913 B     (-3.6%)      +46 B     (+0.7%)
async-load-#-steps-import-url                  -693 B     (-3.3%)     -620 B     (-9.7%)

React components that are loaded lazily, when a certain part of UI is displayed for the first time.

Moment.js Locales (~18456 bytes added 📈 [gzipped])

name                    parsed_size              gzip_size
moment-locale-af           +42101 B  (+3160.7%)   +10784 B  (+1481.3%)
moment-locale-ms-my        +29190 B  (+2127.6%)    +7983 B  (+1105.7%)
moment-locale-ru            -3322 B    (-73.1%)     -893 B    (-55.6%)
moment-locale-sd            +3191 B   (+235.3%)     +851 B   (+112.7%)
moment-locale-ms            +2074 B   (+152.2%)     +556 B    (+77.3%)
moment-locale-ta            -1865 B    (-61.6%)     -533 B    (-45.8%)
moment-locale-uz            +1811 B   (+126.6%)     +725 B   (+102.1%)
moment-locale-tet           +1763 B   (+139.6%)     +476 B    (+69.1%)
moment-locale-br            +1702 B   (+109.9%)     +237 B    (+28.3%)
moment-locale-ne            -1352 B    (-52.5%)     -463 B    (-41.8%)
moment-locale-ka            -1341 B    (-48.7%)     -257 B    (-25.8%)
moment-locale-ar            -1331 B    (-50.0%)     -458 B    (-38.6%)
moment-locale-pa-in         -1275 B    (-51.8%)     -381 B    (-37.3%)
moment-locale-ko            +1272 B    (+85.8%)     +417 B    (+56.9%)
moment-locale-mr            -1252 B    (-36.4%)     -305 B    (-23.9%)
moment-locale-en-SG         +1232 B    (+98.4%)     +533 B    (+77.6%)
moment-locale-bn            -1227 B    (-49.6%)     -416 B    (-39.6%)
moment-locale-bg            +1219 B    (+70.5%)     +375 B    (+41.0%)
moment-locale-ar-dz         +1212 B    (+83.5%)     +500 B    (+72.9%)
moment-locale-be            -1191 B    (-40.4%)     -376 B    (-29.1%)
moment-locale-cv            +1189 B    (+70.2%)     +357 B    (+43.9%)
moment-locale-sq            +1170 B    (+90.6%)     +290 B    (+41.3%)
moment-locale-ar-ma         +1074 B    (+75.0%)     +429 B    (+62.9%)
moment-locale-ar-ly         -1074 B    (-42.9%)     -430 B    (-38.7%)
moment-locale-cs            -1059 B    (-36.7%)     -313 B    (-26.8%)
moment-locale-ss             +999 B    (+65.8%)     +286 B    (+35.5%)
moment-locale-kk             +994 B    (+56.4%)     +120 B    (+13.7%)
moment-locale-my             -965 B    (-45.1%)     -259 B    (-27.6%)
moment-locale-te             -915 B    (-40.0%)     -237 B    (-24.7%)
moment-locale-nb             +915 B    (+74.7%)     +293 B    (+45.4%)
moment-locale-el             -910 B    (-36.6%)     -402 B    (-33.0%)
moment-locale-ug-cn          -879 B    (-37.2%)     -221 B    (-21.7%)
moment-locale-uk             -876 B    (-27.0%)     -415 B    (-28.9%)
moment-locale-es             -853 B    (-38.9%)     -206 B    (-21.9%)
moment-locale-th             -844 B    (-40.1%)     -171 B    (-19.9%)
moment-locale-tl-ph          +835 B    (+68.6%)     +324 B    (+49.3%)
moment-locale-nn             +816 B    (+68.8%)     +299 B    (+46.6%)
moment-locale-id             +787 B    (+58.3%)     +248 B    (+34.5%)
moment-locale-fo             +777 B    (+64.5%)     +234 B    (+35.5%)
moment-locale-bo             -777 B    (-23.9%)      -23 B     (-2.1%)
moment-locale-pt             +760 B    (+58.1%)     +292 B    (+41.5%)
moment-locale-sr             -735 B    (-36.3%)     -249 B    (-26.2%)
moment-locale-si             -733 B    (-35.6%)     -155 B    (-18.0%)
moment-locale-et             +711 B    (+47.5%)     +177 B    (+23.0%)
moment-locale-fa             -661 B    (-32.4%)     -248 B    (-26.4%)
moment-locale-tr             +656 B    (+44.1%)      +92 B    (+11.5%)
moment-locale-ml             -655 B    (-27.2%)      -49 B     (-5.1%)
moment-locale-tzm-latn       +632 B    (+53.2%)      +87 B    (+15.4%)
moment-locale-it             +622 B    (+47.7%)     +179 B    (+24.9%)
moment-locale-ku             -608 B    (-29.1%)     -243 B    (-24.9%)
moment-locale-nl             +584 B    (+29.3%)     +171 B    (+18.2%)
moment-locale-is             -575 B    (-29.9%)     -179 B    (-20.0%)
moment-locale-mi             +567 B    (+38.4%)     +208 B    (+27.8%)
moment-locale-he             +565 B    (+29.1%)     +210 B    (+23.9%)
moment-locale-km             -560 B    (-24.1%)     -135 B    (-13.4%)
moment-locale-gu             -538 B    (-21.5%)     -127 B    (-11.7%)
moment-locale-ar-sa          -500 B    (-25.9%)     -239 B    (-26.0%)
moment-locale-ar-tn          +497 B    (+34.6%)     +241 B    (+35.4%)
moment-locale-sr-cyrl        -491 B    (-19.5%)     -139 B    (-12.7%)
moment-locale-bm             +484 B    (+38.8%)     +280 B    (+44.1%)
moment-locale-gom-latn       -448 B    (-22.8%)     -224 B    (-23.3%)
moment-locale-kn             -433 B    (-15.7%)     -140 B    (-12.2%)
moment-locale-hi             -431 B    (-18.2%)     -198 B    (-18.4%)
moment-locale-bs             -427 B    (-21.6%)      -40 B     (-4.6%)
moment-locale-lv             +425 B    (+22.4%)     +253 B    (+29.8%)
moment-locale-tzl            -417 B    (-25.5%)     -210 B    (-24.2%)
moment-locale-pl             +394 B    (+19.1%)      +27 B     (+2.7%)
moment-locale-vi             -375 B    (-24.5%)     -172 B    (-22.3%)
moment-locale-zh-tw          -355 B    (-20.2%)     -202 B    (-22.3%)
moment-locale-zh-cn          -328 B    (-18.6%)     -212 B    (-23.0%)
moment-locale-az             -323 B    (-18.4%)     -234 B    (-25.6%)
moment-locale-ky             +318 B    (+17.9%)      +84 B     (+9.4%)
moment-locale-sl             -312 B    (-12.7%)      -60 B     (-6.0%)
moment-locale-de             -295 B    (-20.2%)     -120 B    (-15.9%)
moment-locale-lt             -276 B    (-11.9%)     -290 B    (-26.3%)
moment-locale-cy             +274 B    (+19.3%)      +52 B     (+6.8%)
moment-locale-mk             -272 B    (-15.5%)     -161 B    (-17.7%)
moment-locale-da             +254 B    (+21.8%)     +128 B    (+20.2%)
moment-locale-hr             +246 B    (+11.6%)     +137 B    (+14.6%)
moment-locale-sv             +237 B    (+18.5%)     +106 B    (+15.2%)
moment-locale-tg             +234 B    (+11.4%)      -22 B     (-2.2%)
moment-locale-lo             -232 B    (-11.4%)     +130 B    (+16.0%)
moment-locale-ja             -221 B    (-14.4%)      -50 B     (-6.5%)
moment-locale-mn             +220 B    (+10.1%)      -11 B     (-1.1%)
moment-locale-uz-latn        +200 B    (+17.3%)     +132 B    (+22.0%)
moment-locale-mt             +188 B    (+16.0%)      +40 B     (+5.9%)
moment-locale-tzm            -186 B    (-10.2%)     +214 B    (+32.8%)
moment-locale-fr             -183 B    (-13.2%)      -67 B     (-9.2%)
moment-locale-ur             -168 B    (-12.4%)     -165 B    (-22.6%)
moment-locale-ca             +152 B     (+8.3%)      +20 B     (+2.3%)
moment-locale-me             -150 B     (-7.3%)     -108 B    (-11.3%)
moment-locale-gl             -142 B     (-9.3%)      +17 B     (+2.3%)
moment-locale-yo             +131 B     (+9.3%)     +147 B    (+20.9%)
moment-locale-sw             +121 B    (+10.4%)      +67 B    (+10.6%)
moment-locale-eu             +121 B     (+8.8%)      +76 B    (+11.0%)
moment-locale-hy-am          -119 B     (-5.6%)      -34 B     (-3.5%)
moment-locale-jv             +118 B     (+8.3%)      +33 B     (+4.5%)
moment-locale-dv             -112 B     (-7.1%)      -74 B     (-9.0%)
moment-locale-hu             +107 B     (+5.3%)       +8 B     (+0.9%)
moment-locale-ro              +91 B     (+7.4%)      -11 B     (-1.5%)
moment-locale-sk              -90 B     (-4.2%)      -70 B     (-7.5%)
moment-locale-eo              -83 B     (-6.2%)      -47 B     (-6.4%)
moment-locale-ga              +63 B     (+4.7%)      +34 B     (+4.6%)
moment-locale-fi              +56 B     (+2.8%)      +46 B     (+5.1%)
moment-locale-lb              -39 B     (-2.2%)      -50 B     (-5.3%)
moment-locale-en-nz           -39 B     (-3.1%)      -20 B     (-2.9%)
moment-locale-fy              -38 B     (-2.7%)      -45 B     (-5.9%)
moment-locale-tlh             -36 B     (-1.7%)      -31 B     (-3.5%)
moment-locale-fr-ca           +34 B     (+2.5%)      +14 B     (+2.0%)
moment-locale-en-il           +34 B     (+2.8%)      +19 B     (+2.8%)
moment-locale-se              +29 B     (+2.2%)      +47 B     (+6.6%)
moment-locale-gd              -29 B     (-2.1%)      -20 B     (-2.6%)
moment-locale-fr-ch           -19 B     (-1.4%)      -11 B     (-1.5%)
moment-locale-ar-kw           +19 B     (+1.3%)       +5 B     (+0.7%)
moment-locale-es-do           -17 B     (-0.8%)       -6 B     (-0.6%)
moment-locale-en-ca           +16 B     (+1.3%)      +11 B     (+1.6%)
moment-locale-en-gb           -12 B     (-1.0%)      -11 B     (-1.6%)
moment-locale-de-at           -11 B     (-0.7%)       -7 B     (-0.9%)

Locale data for moment.js. Unless you are upgrading the moment.js library, changes in these chunks are suspicious.

Legend

What is parsed and gzip size?

Parsed Size: Uncompressed size of the JS and CSS files. This much code needs to be parsed and stored in memory.
Gzip Size: Compressed size of the JS and CSS files. This much data needs to be downloaded over network.

Generated by performance advisor bot at iscalypsofastyet.com.

@p-jackson p-jackson changed the title Update/data store action types [Gutenboarding]: data-store reducer function signatures don't list which actions they handle Jan 20, 2020
@p-jackson p-jackson force-pushed the update/data-store-action-types branch from fc991c6 to fd10df9 Compare January 22, 2020 09:12
@p-jackson p-jackson force-pushed the update/data-store-action-types branch from fd10df9 to a86aad8 Compare January 23, 2020 23:00
@p-jackson p-jackson changed the title [Gutenboarding]: data-store reducer function signatures don't list which actions they handle Gutenboarding: data-store reducer function signatures don't list which actions they handle Jan 24, 2020
@p-jackson p-jackson changed the title Gutenboarding: data-store reducer function signatures don't list which actions they handle Gutenboarding: data-store reducer function signatures don't need to list which actions they handle Jan 24, 2020
@p-jackson p-jackson marked this pull request as ready for review January 24, 2020 00:33
@p-jackson p-jackson requested a review from a team as a code owner January 24, 2020 00:33
@p-jackson p-jackson added the [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. label Jan 24, 2020
@p-jackson p-jackson requested a review from a team January 24, 2020 00:33
@ramonjd
Copy link
Member

ramonjd commented Jan 24, 2020

@p-jackson

the data-store reducers have been explicitly listing each of the actions they handle in their type signature. I think this (1) doesn't scale well as more cases are added to the switch statement (2) makes the reducer's type too specific.

I like the motivation behind this PR, and think it makes 100% sense.

I don't feel confident reviewing it from a TS perspective yet, but it doesn't break the frontend and I can't see any compilation errors ☺️

@p-jackson p-jackson requested a review from a team January 24, 2020 03:33
@p-jackson
Copy link
Member Author

Thanks @ramonjd, my main worry was it didn't make sense. I was struggling to word my rationale 😅

Obviously hoping for some 👀 from @Automattic/luna, but maybe I'll throw a @Automattic/type-review in for good measure.

@andrewserong
Copy link
Member

Nice work @p-jackson, I also don't feel confident reviewing it from a TS perspective, but for me this also greatly improves the readability of the reducers as that top block of passing in generics to Reducer took quite a while for me to parse visually.

In practical terms, I think it also makes sense because won't each of the reducers be called with each of the actions, whether or not their type is specified in the reducer's signature? So I think this change feels a bit more accurate to the behaviour of reducers.

Edit: and converting the reducers to function expressions is entirely optional.

I'm quite partial to function definitions and function expressions when functions are declared at the top-level, so I like this, too!

type DomainSuggestion = import('@automattic/data-stores').DomainSuggestions.DomainSuggestion;
type Template = import('@automattic/data-stores').VerticalsTemplates.Template;

function domain( state: DomainSuggestion | undefined = undefined, action: OnboardAction ) {
Copy link
Member

Choose a reason for hiding this comment

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

Setting the state's default value to undefined is redundant.

Also, using the Reducer<> type has some benefits, as it adds # | undefined automatically to the state type:

const domain: Reducer< DomainSuggestion, OnboardAction > = ( state, action ) => {
  ...
}

I continue to be very confused about the OnboardAction type. I believe that the action type shouldn't be constrained in any way beyond requiring an object with type field. And that's what the import { Action } from 'redux' type specifies.

Redux will call the reducer with an init action like this one:

{
  type: "@@redux/INITj.3.e.w.g"
}

This action doesn't pass typecheck for OnboardingAction, and yet the reducer is guaranteed to be called with it and must react to it correctly.

On the other hand, I see exactly this kind of constraining the action type in the official docs, too:
https://redux.js.org/recipes/usage-with-typescript/#type-checking-reducers
Is it a bug, or am I just clueless about something?

Copy link
Member

Choose a reason for hiding this comment

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

The Reducer type is very strange and I've wondered about this myself. We claim that a Reducer handles a specific action type, but as you say it must handle all actions.

I'd speculate it's a tradeoff the authors decided to make because by convention —you can't use Redux if you don't understand the if ( action.type === 'MY_ACTION' ) { return 'new state' } else { return state } pattern—, reducers gracefully handle all action types.

If we expect this by convention and lie a little bit to the type system, we get better information in our reducer functions. A correctly typed reducer function that wants to deal with a specific action but has to deal with actions of { type: any } ends up being painful to work with. Consider that we can't even get a discriminated union because the basic action type { type: any } also satisfies a fully typed { type: 'MY_ACTION', foo: string, bar: number, baz: boolean }, so in our if ( action.type === 'MY_ACTION' ) { … } branch, we still don't know whether which type we have!

I think the following illustrates it:

import { Reducer } from 'redux'

interface ReducerCorrect<S, A> {
    // Note the `A | A_` here, our type effectively opens up to `{ type: any }` 😞
    <A_ extends { type: any; }>(state: S | undefined, action: A | A_): S;
}


type Action = { type: 'foo', bar: { baz: { quux: number } } }

// This is unsafe, but the experience is better
const reducerRedux: Reducer<number, Action> = (state = 0, action) => {
     // Whoa, that's going to error at runtime for sure!
    // Of course, anyone experienced with Redux won't write this…
    return action.bar.baz.quux;
}

// This is safe, but is a pain
const reducerCorrect: ReducerCorrect<number, Action> = (state = 0, action) => {
    if (action.type === 'MY_ACTION') {
        return action.bar.baz.quux; // We can't know this -- type error! 😭
    } 
    return state;
}

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 thoughts about typing tradeoffs echo @sirreal's. The global nature of Redux actions almost makes them untypable. Elm's pattern of using nested actions is a way of making all the types perfect, but global events are pretty handy :)

Setting the state's default value to undefined is redundant.

Good point, I'll clean that up while I'm here.

using the Reducer<> type has some benefits, as it adds # | undefined automatically to the state type

I actually like that not all state types are # | undefined. Yes the onboard store has a lot of optional pieces of state so it'd be a convenience here. But the domainSuggestion state is never undefined and I feel like a lot of reducers work this way e.g. a lot of reducers are just a single value type and have a default.

Copy link
Member

Choose a reason for hiding this comment

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

🤔After considering this a bit more and seeing some other context (p1579980641021900-slack-typescript), I'd change my speculative stance 🙂

The second type argument to Reducer is fine if left to the default. But, if it becomes the union of the type of all actions in your application, it should give excellent information and type safety.

Copy link
Member Author

Choose a reason for hiding this comment

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

Setting the state's default value to undefined is redundant.

I've pushed a fix for this.

The second type argument to Reducer is fine if left to the default. But, if it becomes the union of the type of all actions in your application, it should give excellent information and type safety.

@sirreal Just to clarify, does that mean you're happy with the changes here since it allows reducers to accept any of the actions for a given store? I thought that was your original (speculative) stance.

Copy link
Member

Choose a reason for hiding this comment

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

does that mean you're happy with the changes […]

I've been considering these changes and playing with the types but haven't found the time to leave a full review. I'd prefer to leave it for a while.

Copy link
Member

Choose a reason for hiding this comment

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

It seems that the TypeScript types for Redux reducers are buggy. When I created a toy example of a reducer that accepts only a constrained set of actions, it indeed fails to compile valid dispatches of unknown actions:

function reducer( state, action: HandledActionTypes ) {
  ...
}
reducer( undefined, { type: '@@INIT' } );
createStore( reducer ).dispatch( { type: '@@INIT' } );

Both these statements fail to compile and compiler reports an error:

Type '"@@INIT"' is not assignable to type '"ACTION_A" | "ACTION_B"'

The Redux repo already has a similar issue: reduxjs/redux#3580
It describes another funny consequence of the constrained action type: one can write a reducer that fails to handle unknown actions:

function reducer( state: MyState = initState, action: MyActions ): MyState {
  switch ( action.type ) {
    case MY_ACTION_A:
      return { ...state, ... };
    case MY_ACTION_B:
      return { ...state, ... };
  }
}

The switch statement exhausts all possible values of action.type for the MyActions type, so TypeScript doesn't mind that it returns undefined for all other action.types. It thinks it can never happen 🙂 But such a reducer breaks the Redux contract, and also fails to always return a value of type MyState.

I added a comment that describes our case: reduxjs/redux#3580 (comment)

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks for pointing out that Redux issue thread @jsnajdr, we're not the only one's grappling with how Redux and TS should work together.

I took a look at the solution Simplenote is using, and their typings suffer from the same issue i.e. it's possible to write a reducer with no type errors that doesn't deal with unknown action types.

This isn't ideal, but given it's something community hasn't figured out yet (the approach recommended by the docs has the issue) is this something where we just wait and see where Redux goes with this. This PR improves things a little at least by widening the action type.

@p-jackson p-jackson force-pushed the update/data-store-action-types branch from a86aad8 to 459a4ac Compare January 26, 2020 00:52
@sirreal sirreal force-pushed the update/data-store-action-types branch from 459a4ac to cd2dcbe Compare February 4, 2020 17:05
@sirreal

This comment has been minimized.

@sirreal
Copy link
Member

sirreal commented Feb 4, 2020

Rebased.

I accidentally posted the previous incomplete comment.

I've continued to have higher priority things get in the way of coming back to this. I was hoping to leave detailed feedback and propose some changes and improvements, but I've been unable to find the time.

In general I think there are opportunities for simplification around here. I'm not sure I like the ergonomics of the new mapped types. Needing to import the namespace import * as actions from './actions', then pass the typeof that namespace object to the mapped type doesn't seem ideal. It might make more sense to export an Action type from the actions module that is a union of all the possible actions.

There also seems to be opportunity for improvement with the type of the type property of actions. I'd like to remove the action-type enums, which don't make sense with a small set of action creators.

I started exploring and working on some ideas which I've shared in #39259, but I haven't found the time to finish it up.

@p-jackson
Copy link
Member Author

Thanks for commenting @sirreal, appreciate that you're trying to balance priorities :)

fwiw I like removing the enum for action types, I don't really feel adding the namespacing adds any value.

Interesting idea about removing the mapped type and explicitly defining a union of all action types. One advantage I see is that if some hypothetical action had a complicated enough type then we might want to define it explicitly as its own interface, instead of infer it from the action creator's return type, then it could easily be included in the union type.

@p-jackson p-jackson force-pushed the update/data-store-action-types branch 2 times, most recently from 584fab7 to 88c2331 Compare February 10, 2020 04:41
The extra namespacing the `ActionType` enums provided aren't worth the
extra typing. It makes the already long identifiers even longer, and you
already get auto-complete in reducers because TypeScript knows what
values are possible when you type `action.type ===` in a reducer.
@p-jackson p-jackson force-pushed the update/data-store-action-types branch from 88c2331 to dca75ac Compare February 10, 2020 05:02
@p-jackson
Copy link
Member Author

Rebased to include the new site store. I've updated to take some ideas from @sirreal's branch:

  • action types no longer use an enum
    • they don't add a lot of value, we already get autocomplete in reducers without them
    • identifiers are already long enough to read/type without the extra prefix
  • no longer use a mapped type for all possible actions

Copy link
Member

@sirreal sirreal left a comment

Choose a reason for hiding this comment

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

Overall I'm quite happy with the direction, thanks for the initiative!

As noted, I do see reason to continue using Reducer or add explicit return types to the reducer function declarations. What do you think?

I've tested and there don't seem to be any regressions. Approving with the note that I'd like to see the above change 🚀

type DomainSuggestion = import('@automattic/data-stores').DomainSuggestions.DomainSuggestion;
type Template = import('@automattic/data-stores').VerticalsTemplates.Template;

function domain( state: DomainSuggestion | undefined, action: OnboardAction ) {
Copy link
Member

Choose a reason for hiding this comment

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

I like using the Reducer type because it ensures we return a state type. I'd say it's a bit more cumbersome here using function declarations:

Suggested change
function domain( state: DomainSuggestion | undefined, action: OnboardAction ) {
function domain( state: DomainSuggestion | undefined, action: OnboardAction ): DomainSuggestion {

If we stay with const declarations and Reducer, we get the following which seems like a good place:

Suggested change
function domain( state: DomainSuggestion | undefined, action: OnboardAction ) {
const domain: Reducer<DomainSuggestion | undefined, OnboardAction> = ( state, action ) => {

If we don't return the same type of state, that's a bug types can help detect.

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've dropped the commit that converted the reducers to function expressons. Given all selectors and action creators are using the const way it doesn't make sense for the reducers to be any different.

I'd say it's a bit more cumbersome here using function declarations

Given that I think it's less cumbersome this way goes to show it must be a preference thing, so better to just go with the flow of what's already there.

Then again I did forget to specify the return types, so maybe you have a point 😅

Copy link
Member

Choose a reason for hiding this comment

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

I don't have much of a preference as long as we're getting well typed reducers (with return):

const r: Reducer< State, Action > = ( s = initialValue, a ) => s;
// or
function r_( s: State = initialValue, a: Action ): State { return s; }

Comment on lines 38 to 49
export function* createAccount( params: CreateAccountParams ) {
yield fetchNewUser();
try {
const newUser = yield {
type: ActionType.CREATE_ACCOUNT as const,
type: 'CREATE_ACCOUNT' as const,
params,
};
return receiveNewUser( newUser );
} catch ( err ) {
return receiveNewUserFailed( err );
}
}
Copy link
Member

@sirreal sirreal Feb 10, 2020

Choose a reason for hiding this comment

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

When working on this, this had me curious:

  • There's an untyped literal action object that's yielded in the middle. An observation, not necessarily an issue.
  • This is a generator, which uniquely types its yield and return types.

I'm not sure whether our reducers "see" yielded actions (presumably handled by controls?) or only returned actions. Maybe this is up to the handling control and not something we can generalize about.

Copy link
Member Author

Choose a reason for hiding this comment

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

Just tried this and was surprised to see that the reducer didn't see the 'CREATE_ACCOUNT' action 🤔

Copy link
Member

Choose a reason for hiding this comment

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

@p-jackson I ran into this when I was working on the Site data store, and it looks like the way the redux-routine middleware works is that if it matches a control against the name of an action, it runs the control but doesn't dispatch the action to pass it along to the reducer. For unhandled actions (actions that don't have a match in the controls), they do get dispatched by the middleware so that they hit the reducer as normal.

I still don't think I have a solid grasp of exactly how the middleware works, but in effect, it feels like controls are designed to intercept an action, and then in the control we can then dispatch actions that will hit the reducer. Because the reducer doesn't see the action that's listed in our controls, that's why we've got the additional fetchNewUser action that gets yielded before CREATE_ACCOUNT, otherwise from the reducer, we don't know that the action has started. It seems like useful behaviour for a middleware to me, but definitely non-obvious in its behaviour!

@p-jackson p-jackson force-pushed the update/data-store-action-types branch from dca75ac to 51f2df3 Compare February 11, 2020 02:41

type Template = VerticalsTemplates.Template;

export const setDomain = (
domain: import('@automattic/data-stores').DomainSuggestions.DomainSuggestion | undefined
) => ( {
type: ActionType.SET_DOMAIN as const,
type: 'SET_DOMAIN' as const,
Copy link
Member

Choose a reason for hiding this comment

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

This is just a question for my own curiosity (and wrapping my head around TS features), what's the value of adding as const on a string literal? Does this prevent the type key from being changed?

Copy link
Member Author

Choose a reason for hiding this comment

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

Without it the type prop would still be a string, not a string literal unfortunately.

Another interesting way of doing it is:

return <const>{
	type: 'SET_DOMAIN',
	domain,
};

Which not only makes typeof type the correct string literal, but it also marks all the props as readonly.

Hover your mouse over the types in this playground example to see it in action.

Copy link
Member

@andrewserong andrewserong Feb 11, 2020

Choose a reason for hiding this comment

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

Ah, gotcha! Thanks for the example, that makes it clear to me now :)

Copy link
Member

Choose a reason for hiding this comment

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

I'd like the reducers to all return { … } as const if anyone wants to take that task on 👍

Copy link
Member

@andrewserong andrewserong left a comment

Choose a reason for hiding this comment

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

This PR's looking great to me @p-jackson — thanks so much for sticking with it and rebasing against the Site data store, too, it makes the reducers much more readable to me while also better reflecting how the reducers function! 👍

@p-jackson p-jackson merged commit 0f8ff95 into master Feb 11, 2020
@matticbot matticbot removed the [Status] Needs Review The PR is ready for review. This also triggers e2e canary tests and wp-desktop tests automatically. label Feb 11, 2020
@sirreal sirreal deleted the update/data-store-action-types branch November 27, 2020 11:14
# for free to join this conversation on GitHub. Already have an account? # to comment
Labels
[Goal] New Onboarding previously called Gutenboarding
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants