Skip to content

Proposal for makeStyles() next#16082

Merged
layershifter merged 16 commits intomasterfrom
feat/make-styles-next
Dec 16, 2020
Merged

Proposal for makeStyles() next#16082
layershifter merged 16 commits intomasterfrom
feat/make-styles-next

Conversation

@layershifter
Copy link
Member

@layershifter layershifter commented Nov 27, 2020

This PR contains a new version of makeStyles that we are evaluating last weeks.

😕 Problems

Perf is a problem

Styles are computed for each part of each component, including duplicate calculations when there are multiple instances of the same component on the page. Even with caching it slow compared to native CSS.

Predictable style overrides

Current version of makeStyles() is heavily inspired by Material UI and depends on file evaluation order which makes complicated to use it with React Suspense, for example: https://codesandbox.io/s/kind-davinci-xvwxg

There is also no way to solve CSS specificity issues for styles overrides.

Improve Developer Experience

For Northstar: Style files are currently hard to read and manage due to their complexity. The complexity is caused by heavily nested conditionals and the use of abstractions (like helper functions). Styles also decoupled from components that caused an additional indirection.

📜 Key notes

The new iteration of makeStyles() splits the expensive part (processing styles, generating classnames) and the cheap part (merging classnames), expensive part can be done build time. Code also intentionally separated to highlight required part of makeStyles() and runtime. A production version of makeStyles() currently is less than 200 LoC.

It uses the similar side effect approach as in useCSS hook and Emotion (#14470) (passing down classnames but referrencing a dictionary when merging).

We use hash based atomic classnames, this makes deterministic (vs Fela is sequential = non-deterministic), it simplifies support server side rendering.

Tokens (theme, siteVariables) are injected as CSS variables, can easily fallback to runtime evaluation in IE 11 without any change required on component/overrides side. However, this will require bundling of runtime part.

📦 Current API proposal

Anything can change based on feedback, but current API is shown below:

const useStyles = makeStyles([
  [null, { color: 'red' }],
  [selectors => selectors.color === 'green', { color: 'green' }]
])

//

useStyles() // returns a classname for "color: red"
useStyles({ color: 'green' }}  // returns a classname for "color: green"

Why selectors and styles are separated?

It removes any conditions in styles and and allows easily transform them to CSS.

Why selectors are functions?

This allows to match styles in a single loop i.e. the best performance option that we have.

I also evaluated matchers approach (microsoft/fluent-ui-react#1301), but we need have to compare there two objects:

// ⚠️ not a real API

const useStyles = makeStyles([
  [null, { color: "red" }],
  [{ color: "green" }, { color: "green" }]
]);

Another idea that was not enough performant is to transform these matchers to bitmasks. It also was slower as we need to transform user's input masks which gives us a second loop 🐌:

const masks = {
  default: 0, // null
  colorgreen: 1
};

It also made code complicated for understanding and debugging.

🏁 Performance

Based on modified perf test from react-native-web. A deep mount of 50 components, contains styles overrides (Box overrides View).

Current version of makeStyles was also measured and it's fast as native styles.

Compare to Emotion

image

Compare to native CSS

image


Another perf test compares what we have now in Fluent UI N*.

  • ImageMinimal.perf.tsx renders 5k Image components, the simplest component with caching enabled in Fela
  • AvatarMinimal.perf.tsx renders 5k Avatar components (renders Status & Label), show what happens when caching breaks in Fela

no styles at all i.e. unstyled components

Example min avg median max
ImageMinimal.perf.tsx 125.88 133.48 133.56 144.96
ImageMinimal.perf.tsx 123.57 133.31 131.87 159.53
AvatarMinimal.perf.tsx 525.08 553.99 552.31 611.16
AvatarMinimal.perf.tsx 520.34 584.5 577.29 688.11
AvatarMinimal.perf.tsx 520.22 556.62 545.83 712.89

makeStyles: build time, CSS variables

Example min avg median max
ImageMinimal.perf.tsx 153.75 160.29 159.59 171.29
ImageMinimal.perf.tsx 152.88 161.48 161.86 169.87
AvatarMinimal.perf.tsx 683.33 778.88 713.31 1410.99
AvatarMinimal.perf.tsx 690.64 732.25 717.42 1041.57
AvatarMinimal.perf.tsx 691.82 760.57 738.08 1044.76

Current styling system in Northstar

Example min avg median max
ImageMinimal.perf.tsx 200.3 213.97 213.61 244.09
ImageMinimal.perf.tsx 205.85 214.07 213.58 228.09
AvatarMinimal.perf.tsx 1558.07 1778.27 1746.16 3147.47
AvatarMinimal.perf.tsx 1542.73 1801.55 1755.26 2795.56

Current PR ships only runtime part and some tests for it. Build time transform are next on the list. @fluentui/make-styles intentionally is private to avoid publishing.


const useButtonStyles = makeStyles([
[
null,
Copy link
Contributor

@ling1726 ling1726 Nov 30, 2020

Choose a reason for hiding this comment

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

To make the API a bit more intuitive would it make sense to create a builder kind of API ? either to build selector-styles pairs (decoupling building/making):

import { builder } from 'make-styles';

const rawStyles = builder
  .addDefault({...styles}) // no need to declare null selector
  .addSelector({text: false}, {...styles})

const useButtonStyles = makeStyles(rawStyles)

or makeStyles could use a builder pattern with a fluent API:

const useButtonStyles = makeStyles()
  .addDefault({...styles}) // your null selector
  .addSelector({text: false}, {...styles})
  .make();

const buttonStylesMaker = makeStyles()
  .addDefault({...styles}) // your null selector
  .addSelector({text: false}, {...styles});

const useButtonStyles = buttonStylesMaker.make();

Copy link
Contributor

Choose a reason for hiding this comment

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

This would also mean you could have project level style makers that could be reused ?

Copy link
Member Author

Choose a reason for hiding this comment

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

null selector probably should become {}

Copy link
Contributor

Choose a reason for hiding this comment

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

That's fair, do you think the above pattern is unnecessary in that case ?

Copy link
Member

@dzearing dzearing Dec 1, 2020

Choose a reason for hiding this comment

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

That's an interesting idea @ling1726 :) I could see how a builder could allow the hook to be a little more extendable and the api a little more explicit.

Slightly concerned deviating too far from css. It's already weird to be writing css in javascript for many people; deviating from normal css in js selectors by now having a matcher API might be another hurdle for adoption. Not dismissing the approach, though. Just need to chew on it a bit.

Regarding extendability of rules, in makeStyles I was considering something like extend as well:

Option 1, let extends property reference another hook that becomes injected:

const useButtonStyles = makeStyles( ... );
const useMyButtonStyles = makeStyles({
  extends: useButtonStyles,
  root: { ... },
  icon: { ... }
});

Option 2, let the hook have an extendable method:

const useMyButtonStyles = useButtonStyles.extend({
  root: ...,
  icon: ...
});

Option 3, pass a hook into makeStyles and have it recognize a style hook and extract the rules:

const useMyButtonStyles = makeStyles(useButtonStyles, { ... });

Copy link
Member

Choose a reason for hiding this comment

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

Realized that option 3 is closest to how StyleX works for extending existing classes:

image

Copy link
Member Author

Choose a reason for hiding this comment

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

It was covered in offline why we would prefer to use className as a plain property can be passed to any elements.


Another issue is related to overrides and their context, it's a real case in Avatar + Status.

const useStatusStyles = makeStyles();

function Status(props) {
  return (
    <div
      className={useStatusStyles({ status: props.status }, props.className)}
    />
  );
}
const useAvatarStatusStyles = makeStyles();

function Avatar(props) {
  // ⚠️ See usage
  return <Status className={useAvatarStatusStyles({ square: props.square })} />;
}

Avatar can be square, but Status cannot be. With the current implementation overrides will be resolved in Avatar and then passed to Status.

In "stylex":

  • they should be resolved in Status and it means that it somehow should know square
  • they should be resolved in Avatar and it means that it has the same issue as the existing solution in v0 where we have sometimes className and styles

@size-auditor
Copy link

size-auditor bot commented Nov 30, 2020

Asset size changes

Size Auditor did not detect a change in bundle size for any component!

Baseline commit: de2be83fbac3276be793cf311eaabfb337cc0c47 (build)

}),
],

[{ state: 'success' }, tokens => ({ backgroundColor: tokens.colorScheme.green.background })],
Copy link
Member

@dzearing dzearing Dec 1, 2020

Choose a reason for hiding this comment

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

Trying to pin down how this should work in extraction. Here's a scenario:

makeStyles([
  [ null, { background: 'red' } ],
  [ { primary: true }, { background: 'green' } ],
  [ { state: 'info' }, { background: 'blue' } ]
]);

In this case, we have 3 possible atomic css classes that will be hashed: let's refer to them as r, g, and b (though they'll be more like 123abc using a hash function.)

  • If we're in a default state, className should be r.
  • If primary is true, className should be g.
  • If state is info, className should be b.
  • If state is info and primary is true, state wins and className should be b.

My initial extraction expectation was that this would be extracted like so:

// extract this stylesheet: `.r{background:red}.g{background:green}.b{background:blue}`.

// code is transformed into this:

```jsx
makeStyles([
  [ null, 'r' ],
  [ { primary: true }: 'b' ],
  [ { state: 'info' }: 'g' ]
]);

But I believe that means that we will also have to inject some javascript for rehydrating the classname hash map. Without priming the hash map, the class names will be joined, resulting in specificity to control the result.

Am I understanding this correctly?

Copy link
Member Author

Choose a reason for hiding this comment

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

But I believe that means that we will also have to inject some javascript for rehydrating the classname hash map. Without priming the hash map, the class names will be joined, resulting in specificity to control the result.

Am I understanding this correctly?

A small correction, it will keep property name and we will perform a merge later based on it:

makeStyles([
  [null, { background: "r" }],
  [{ primary: true }, { background: "b" }],
  [{ state: "info" }, { background: "g" }]
]);

So for { primary: true } we will get:

[{ background: "r" }, { background: "b" }];

Once we will shallow merge:

const classes = Object.values(
  Object.assign({}, { background: "r" }, { background: "b" })
); // ["b"]

],

[
{ text: false },
Copy link
Member

@dzearing dzearing Dec 1, 2020

Choose a reason for hiding this comment

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

Is there a scenario where falsey matches are definitely needed? Can't the default state represent the default look and flags define variants off the default look?

And, if there is a scenario that requires them, how would we represent "enum doesn't equal value" scenarios?

Copy link
Member Author

Choose a reason for hiding this comment

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

Is there a scenario where falsey matches are definitely needed?

To be honest, we are not 100% sure about it. It might be useful or not, our existing styles have a lot of negative conditions. I would also prefer to use default values and override them

@khmakoto khmakoto mentioned this pull request Dec 8, 2020
2 tasks
@fabricteam
Copy link
Collaborator

fabricteam commented Dec 14, 2020

Perf Analysis

No significant results to display.

All results

Scenario Render type Master Ticks PR Ticks Iterations Status
Avatar mount 822 844 5000
BaseButtonCompat mount 923 919 5000
Breadcrumb mount 40027 40244 5000
Checkbox mount 1512 1578 5000
CheckboxBase mount 1311 1309 5000
ChoiceGroup mount 4926 4833 5000
ComboBox mount 966 976 1000
CommandBar mount 9753 9839 1000
ContextualMenu mount 5964 5900 1000
DefaultButtonCompat mount 1149 1149 5000
DetailsRow mount 3663 3733 5000
DetailsRowFast mount 3658 3681 5000
DetailsRowNoStyles mount 3516 3508 5000
Dialog mount 1449 1472 1000
DocumentCardTitle mount 1723 1709 1000
Dropdown mount 3359 3348 5000
FocusTrapZone mount 1817 1771 5000
FocusZone mount 1811 1814 5000
IconButtonCompat mount 1834 1818 5000
Label mount 330 325 5000
Layer mount 1823 1791 5000
Link mount 453 468 5000
MenuButtonCompat mount 1494 1489 5000
MessageBar mount 1921 1961 5000
Nav mount 3289 3271 1000
OverflowSet mount 1005 994 5000
Panel mount 1383 1411 1000
Persona mount 846 855 1000
Pivot mount 1377 1408 1000
PrimaryButtonCompat mount 1305 1289 5000
Rating mount 7708 7804 5000
SearchBox mount 1369 1329 5000
Shimmer mount 2579 2619 5000
Slider mount 1904 1884 5000
SpinButton mount 5033 5002 5000
Spinner mount 413 416 5000
SplitButtonCompat mount 3215 3198 5000
Stack mount 487 505 5000
StackWithIntrinsicChildren mount 1488 1464 5000
StackWithTextChildren mount 4603 4583 5000
SwatchColorPicker mount 10256 10185 5000
Tabs mount 1389 1404 1000
TagPicker mount 2810 2851 5000
TeachingBubble mount 11286 11254 5000
Text mount 410 406 5000
TextField mount 1402 1410 5000
ThemeProvider mount 2078 2078 5000
ThemeProvider virtual-rerender 626 626 5000
Toggle mount 803 793 5000
button mount 676 639 5000
buttonNative mount 106 107 5000

Perf Analysis (Fluent)

Perf comparison
Status Scenario Fluent TPI Fabric TPI Ratio Iterations Ticks
🎯 Avatar.Fluent 0.46 0.51 0.9:1 2000 922
🦄 Button.Fluent 0.12 0.2 0.6:1 5000 610
🔧 Checkbox.Fluent 0.64 0.35 1.83:1 1000 640
🎯 Dialog.Fluent 0.17 0.22 0.77:1 5000 831
🔧 Dropdown.Fluent 2.89 0.4 7.23:1 1000 2885
🔧 Icon.Fluent 0.15 0.06 2.5:1 5000 728
🦄 Image.Fluent 0.08 0.12 0.67:1 5000 422
🔧 Slider.Fluent 1.57 0.43 3.65:1 1000 1574
🔧 Text.Fluent 0.08 0.03 2.67:1 5000 384
🦄 Tooltip.Fluent 0.12 0.87 0.14:1 5000 589

🔧 Needs work     🎯 On target     🦄 Amazing

Perf tests with no regressions
Scenario Current PR Ticks Baseline Ticks Ratio
TableMinimalPerf.default 465 422 1.1:1
ButtonMinimalPerf.default 195 179 1.09:1
AttachmentMinimalPerf.default 186 173 1.08:1
HeaderMinimalPerf.default 422 393 1.07:1
RadioGroupMinimalPerf.default 492 461 1.07:1
AnimationMinimalPerf.default 438 415 1.06:1
AvatarMinimalPerf.default 519 489 1.06:1
HeaderSlotsPerf.default 894 841 1.06:1
ListNestedPerf.default 629 592 1.06:1
Tooltip.Fluent 589 558 1.06:1
ButtonUseCssPerf.default 874 835 1.05:1
ChatDuplicateMessagesPerf.default 432 413 1.05:1
IconMinimalPerf.default 733 701 1.05:1
BoxMinimalPerf.default 402 388 1.04:1
GridMinimalPerf.default 396 382 1.04:1
ListCommonPerf.default 708 682 1.04:1
PortalMinimalPerf.default 164 157 1.04:1
SkeletonMinimalPerf.default 468 448 1.04:1
TableManyItemsPerf.default 2356 2275 1.04:1
Slider.Fluent 1574 1517 1.04:1
CardMinimalPerf.default 614 598 1.03:1
CarouselMinimalPerf.default 478 465 1.03:1
DialogMinimalPerf.default 835 809 1.03:1
ImageMinimalPerf.default 435 421 1.03:1
MenuMinimalPerf.default 926 897 1.03:1
RefMinimalPerf.default 242 235 1.03:1
SegmentMinimalPerf.default 391 381 1.03:1
TextMinimalPerf.default 404 392 1.03:1
TooltipMinimalPerf.default 852 825 1.03:1
Button.Fluent 610 590 1.03:1
Dialog.Fluent 831 810 1.03:1
DividerMinimalPerf.default 416 407 1.02:1
ListMinimalPerf.default 529 517 1.02:1
ProviderMinimalPerf.default 1004 983 1.02:1
ReactionMinimalPerf.default 458 447 1.02:1
ToolbarMinimalPerf.default 996 979 1.02:1
AccordionMinimalPerf.default 172 171 1.01:1
ButtonUseCssNestingPerf.default 1109 1095 1.01:1
CheckboxMinimalPerf.default 2886 2858 1.01:1
DropdownManyItemsPerf.default 806 798 1.01:1
DropdownMinimalPerf.default 2910 2891 1.01:1
EmbedMinimalPerf.default 4132 4107 1.01:1
FormMinimalPerf.default 477 471 1.01:1
LabelMinimalPerf.default 450 446 1.01:1
LayoutMinimalPerf.default 444 440 1.01:1
LoaderMinimalPerf.default 748 741 1.01:1
SliderMinimalPerf.default 1571 1552 1.01:1
SplitButtonMinimalPerf.default 3851 3798 1.01:1
StatusMinimalPerf.default 772 767 1.01:1
CustomToolbarPrototype.default 3838 3805 1.01:1
Avatar.Fluent 922 913 1.01:1
AttachmentSlotsPerf.default 1156 1160 1:1
ButtonOverridesMissPerf.default 1692 1686 1:1
ChatMinimalPerf.default 642 642 1:1
DatepickerMinimalPerf.default 47420 47218 1:1
ItemLayoutMinimalPerf.default 1341 1342 1:1
ListWith60ListItems.default 953 950 1:1
ProviderMergeThemesPerf.default 1960 1964 1:1
TextAreaMinimalPerf.default 540 541 1:1
VideoMinimalPerf.default 693 691 1:1
Dropdown.Fluent 2885 2888 1:1
Text.Fluent 384 384 1:1
ButtonSlotsPerf.default 618 624 0.99:1
ChatWithPopoverPerf.default 468 475 0.99:1
InputMinimalPerf.default 1310 1321 0.99:1
MenuButtonMinimalPerf.default 1640 1652 0.99:1
TreeMinimalPerf.default 804 815 0.99:1
Image.Fluent 422 426 0.99:1
FlexMinimalPerf.default 324 330 0.98:1
Checkbox.Fluent 640 655 0.98:1
PopupMinimalPerf.default 691 714 0.97:1
TreeWith60ListItems.default 217 225 0.96:1
Icon.Fluent 728 760 0.96:1
AlertMinimalPerf.default 317 339 0.94:1

@codesandbox-ci
Copy link

codesandbox-ci bot commented Dec 14, 2020

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 954ca4c:

Sandbox Source
Fluent UI Button Configuration
codesandbox-react-template Configuration
codesandbox-react-northstar-template Configuration
crazy-khayyam-q3u7h PR

@layershifter layershifter changed the title Draft: makeStyles() next Proposal for makeStyles() next Dec 14, 2020
Copy link
Member

@khmakoto khmakoto left a comment

Choose a reason for hiding this comment

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

Left 2 suggestions but otherwise looks good to me!

layershifter and others added 2 commits December 15, 2020 13:30
Co-authored-by: Xu Gao <xugao0131@hotmail.com>
@layershifter layershifter merged commit e17a341 into master Dec 16, 2020
@layershifter layershifter deleted the feat/make-styles-next branch December 16, 2020 12:16
@msft-fluent-ui-bot
Copy link
Collaborator

🎉@fluentui/make-styles@v0.2.0 has been released which incorporates this pull request.:tada:

Handy links:

@miroslavstastny miroslavstastny mentioned this pull request Jan 5, 2021
11 tasks
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.

9 participants