-
Notifications
You must be signed in to change notification settings - Fork 2k
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
SelectDropdown: cleanup code and migrate CSS to webpack #35086
Conversation
@jsnajdr It looks like your PR supersedes mine, so I can probably close it. What do you think? Looks like we were working on the same thing at the same time (although you went a heck of a lot further 😄) |
Here is how your PR affects size of JS and CSS bundles shipped to the user's browser: App Entrypoints (~49 bytes removed 📉 [gzipped])
Common code that is always downloaded and parsed every time the app is loaded, no matter which route is used. Legacy SCSS Stylesheet (~699 bytes removed 📉 [gzipped])
The monolithic CSS stylesheet that is downloaded on every app load. Sections (~13720 bytes removed 📉 [gzipped])
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 (~3612 bytes removed 📉 [gzipped])
React components that are loaded lazily, when a certain part of UI is displayed for the first time. 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. Generated by performance advisor bot at iscalypsofastyet.com. |
I wanted to just migrate the CSS, but then got absorbed by making a lot if improvements in 22 commits 😄 Looking at your PR, I'm doing a few things differently:
|
I think your changes are generally better.
The reason I did this was to avoid keeping around references for longer than they should exist. If option
Right, I tend to just write that type of safeguard mechanically without much thought. I think it's probably safe here, yes.
Yes, that's probably a better model than the |
I'm pretty sure we loose tree shaking when doing An alternative solution that removes the deep imports and keeps tree shaking working is just re-exporting them in the index file and importing them separately from the same path: import { DropdownLabel, DropdownSeparator } from 'components/select-dropdown'; Regarding EDIT: Looks like most of the event handlers are on child elements, so |
We already don't have any tree shaking for this component, because <SelectDropdown options = { [
{ label: 'Unclickable', isLabel: true }, // this will be a SelectDropdown.Label
null, // this will be a SelectDropdown.Separator
{ label: 'Real Option', value: 'opt' }, // this will be SelectDropdown.Item
] } /> Adding the subcomponents as static fields doesn't add any new dependencies: there already are there.
In our case though, the container is always rendered (no conditional rendering is there) and the |
1458b52
to
a6daba4
Compare
I just rebased the branch onto latest |
Ah, yeah! I missed that! I still wonder about consistency: instead of adopting I think I started checking all |
onSelect: () => {}, | ||
onToggle: () => {}, | ||
onSelect: noop, | ||
onToggle: noop, | ||
style: {}, | ||
}; | ||
|
||
static instances = 0; |
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.
Not something that should be fixed in this PR or any time soon, but I think it's worth noting. If we're ever going to render this component on the server, this can potentially break since this instances
will be shared between all clients accessing the same server instance.
I guess the best solution we have now is managing IDs with React Context, but this might break in the future with React Async mode. People have been discussing alternatives here: reactjs/rfcs#32
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.
That's a good point: the generated IDs will be different on server and client. Thanks for linking that React issue! As most other people who commented there, all we need the ID for is to link elements together with various ARIA attributes.
I think managing IDs with React Context would solve the issue even in Async mode. I don't immediately see how Async mode could break it. Initial rendering on server and client is always sync and only the later updates can be async, preempt and cancel each other etc.
I'm proposing the The goal is to improve the DX when importing the component. Some context: This convention is simply bad: import Dropdown from 'components/dropdown';
import DropdownItem from 'components/dropdown/item'; because it can't be easily migrated to an import from package: import Dropdown from '@automattic/calypso-ui/dropdown';
import DropdownItem from '@automattic/calypso-ui/dropdown/item'; When importing from package subdirectories like this, it's impossible to publish multiple versions of the bundle (CJS, ESM, ES:next, ...) because the We always need to import from the root package (where the import { Dropdown, DropdownItem } from '@automattic/calypso-ui';
<Dropdown>
<DropdownItem />
</Dropdown> or import { Dropdown } from '@automattic/calypso-ui';
<Dropdown>
<Dropdown.Item />
</Dropdown> I like the second option much better: I import only one thing from the package. Because conceptually it's one thing with several different parts rather than two different things. Does that make sense? |
It's possible! But it requires some additional build script to create proxy directories. Those directories include only a With this approach, users are able to import either from the root or from the public subdirectories without losing anything, as explained here: https://reakit.io/docs/bundle-size/ Ideally, build tools like Babel and Webpack should be smart enough and people shouldn't be worrying about things like that (maybe Prepack will save us in the future?). But, until this becomes a reality, importing from subdirectories is usually useful for libraries and apps without tree shaking support. Plus, from what I've seen, people tend to adopt a library more easily if they don't feel like they're including the whole thing when they only need a piece. Regarding importing separately or not, I used to prefer what you're suggesting ( It's up to the user if they want the convenience of importing everything as an object: import * as d from "reakit/Dialog";
// not pretty, but tradeoffs 🤷♂️
<d.DialogBackdrop />
<d.DialogDisclosure />
<d.Dialog /> Or if they want it separate: import { Dialog, DialogBackdrop } from "reakit";
import { Dialog, DialogBackdrop } from "reakit/Dialog";
import { Dialog } from "reakit/Dialog/Dialog";
import { DialogBackdrop } from "reakit/Dialog/DialogBackdrop"; |
Thanks, I didn't realize that! Every component can have its own If I only converted: import SelectDropdown from 'components/select-dropdown';
import SelectDropdownItem from 'components/select-dropdown/item'; to import { SelectDropdown, SelectDropdownItem } from 'components/select-dropdown'; in this PR and avoided the controversial The main objective of this PR is to migrate CSS, not to rearrange the imports. So I'm willing to give up that part completely if there's not 100% consensus that it's a good idea 🙂 |
Consider a simple component like this: class ReffyComponent extends Component {
inputRef = createRef();
componentDidMount() {
this.inputRef.current.focus();
}
render() {
return (
<div>
<input type="text" ref={ this.inputRef } />
</div>
);
}
} At the time when We know that because we know how React works. And I understand that TypeScript doesn't know that. But in that case TypeScript becomes a liability and doesn't add value. Is there any way how to tell TypeScript that guarding |
We can do <div>
{ loading ? 'Loading...' : <input type="text" ref={ this.inputRef } /> }
</div> But, guarding it could make it harder to find the error if this component is updated to something like that in the future, so I vote for keeping it as is. :) I think the PR is okay, I just wanted to kick off a discussion around creating a consistent pattern around modules/imports, but this can be discussed in other issue/PR. Are the failing checks on the PR a blocker? |
Yes, in that case, the ref is not always set. But TypeScript doesn't distinguish between the two cases and its advice is not very helpful. Also, when showing a placeholder like in your example, the lifecycle method is likely to be similar to this: componentDidMount() {
if ( this.props.loading ) {
// do some magic on the placeholder
} else {
this.inputRef.current.focus();
}
} I.e., there are several variables that change together as the system moves from one state to another. There are invariants that never change: // assertion that's always true in `componentDidMount`
expect( ( loading && ! inputRef ) || ( ! loading && inputRef ) ).toBeTruthy(); Here, TypeScript nudges us towards refactoring the component to something where the relationship is more clear, e.g., two different components.
OK, does that mean I can keep the
Yes, the e2e tests are consistently failing, so I likely broke something. I'll need to look into that. |
Yes. No need to change that. |
Use an array of refs to focusable items, set them with a callback, move the `refIndex` increment inline to calling expression.
`key` is needed only when passing array of children. In this case, the children are written as JSX and `key` doesn't need to be added on cloning and mapping 1:1.
Only `DropdownItem` needs a ref (it's focusable and the ref is used to move focus on up and down keyboard navigation) and an `onClick` handler. Separator and Label are neither focusable nor clickable.
Clicks on label and separator should not bubble to the parent element and cause the dropdown to close. This patch adds a missing handler to the separator component (label is already OK). Also adds an a11y role to specify that the element is not interactive despite having an `onClick` handler.
Can be done by assinging an instance property instead of full constructor. Also, `getInitialSelectedItem` already handles the case where `options` prop is not present or empty.
Closing the popup when receiving new props doesn't seem to make much sense. And the initial selected value is set only on initial mount and further changes of the prop are ignored. That's the common behavior of initial-ish props on uncontrolled components. For example, native `<input defaultValue="x" />` renders input box with "x" and doesn't change the value on further rerenders with a different prop.
…edText or Icon In the `getSelectedText` and `getSelectedIcon` getters, the currently selected value is always in `this.state.selected`. No need to default to the initial value. Also, use `_.get` instead of `_.result`, as `icon` and `label` are not functions.
…down._ convention
Some of them were quite awful, spying on internal method calls or mocking the whole component instance instead of testing on the real component and checking its state and JSDOM rendering.
a6daba4
to
dd17c67
Compare
Otherwise, the options element is clickable although it has `visibility: hidden` and captures clicks on controls that are underneath it.
Found the bug that was breaking the e2e tests (namely "Revert to Draft" in Classic Editor) and fixed it in c9b3c11. |
The remaining e2e failures are not related, let's merge 🤞 |
The
SelectDropdown
component accumulated a lot of cruft over the years, so I did a comprehensive cleanup as part of the CSS migration.Please review commit by commit, I tried to keep them very small and explain everything.
Similar to
SegmentedControl
in #35051, I put the subcomponents as static properties on the main component: