Skip to content
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

RFC: makeStyles() API changes #17076

Merged
merged 19 commits into from Mar 3, 2021
Merged
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
195 changes: 195 additions & 0 deletions rfcs/convergence/make-overrides.md
@@ -0,0 +1,195 @@
# RFC: Replace `makeStyles()` implementation / API updates

<!--
An RFC can be anything. A question, a suggestion, a plan. The purpose of this template is to give some structure to help folks write successful RFCs. However, don't feel constrained by this template; use your best judgement.

Tips for writing a successful RFC:

- Simple plain words that make your point, fancy words obfuscate
- Try to stay concise, but don't gloss over important details
- Try to write a neutral problem statement, not one that motivates your desired solution
- Remember, "Writing is thinking". It's natural to realize new ideas while writing your proposal
-->

---

@layershifter

## Summary

This RFC proposes to replace existing `makeStyles()` implementation with a new one, i.e. proposes API changes.

## Background

In [microsoft/fluentui#16082](https://github.com/microsoft/fluentui/pull/16082) we have implemented a version of `makeStyles()` that uses atomic classes. Recent work on `makeOverrides()` shows significant performance improvements [microsoft/fluentui#16923](https://github.com/microsoft/fluentui/pull/16923) (around 15%) by changes in the implementation design.

To avoid confusion, the names `makeOverrides()` and `makeStyles` are both used in this RFC. This RFC also proposes the replacement of `makeStyles` with the `makeOverrides` implementation.

## Problem statement

`makeStyles()` implementation has two problems that will cause issues at scale: matchers and missing slots. The snippet below highlights these issues:

```tsx
import { ax, makeStyles } from "@fluentui/react-make-styles";

const useRootStyles = makeStyles<TSelectors>([
[null, { color: "red" }],
[
s => s.primary /* <- a matcher function, will be executed on each render */,
{ color: "blue" }
]
]);

const useIconStyles = makeStyles<TSelectors>([
/* styles for each slot are defined separately */
[null, { background: "black" }],
[s => s.primary, { background: "white" }]
]);

function Component() {
const rootClasses = useRootStyles();

// React hooks cannot be called conditionally, https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level
// styles *must* be rendered even if they are not used in markup
const iconClasses = useIconStyles();

return (
<div className={ax(rootClasses, props.className)}>
{props.icon && <div className={ax(iconClasses, props.icon.className)} />}
</div>
);
}
```

To compute classnames we need to understand what styles should be applied and thus execute matchers on each render as memoization of user's input is more expensive.

This moves us to a next issue: there is no way to define styles for multiple slots/components with a single call of the `makeStyles()`. Each slot will require a separate call of `makeStyles()`. Since these calls are represented by React hooks we can't bail out even if these slots are not rendered.

_Side note:_

> Initially we have been focused on implementation of atomic CSS and merging style definitions. As a source for inspirations we have used [Facebook stylex talk](https://www.youtube.com/watch?v=9JZHodNR184):
>
> - there was nothing like [`ax()`](https://github.com/microsoft/fluentui/pull/16411) function to merge atomic classes
> - there was no contextual RTL support (parts of app can be rendered with different text directions)

## Detailed Design or Proposal

To solve these issues we made a step back to the original API of `makeStyles()` (current iteration is a second version) and as we introduced `ax()` to merge atomic classnames we explored different solutions.

Proposed API solve the problem in a "vandal" way 🪓 Matchers are moved to user's scope thus we can have all definitions in a single `makeStyles()` call => we have a single React hook. See a modified snippet below:
layershifter marked this conversation as resolved.
Show resolved Hide resolved

```tsx
import { ax, makeOverrides } from "@fluentui/react-make-styles";

const useStyles = makeStyles({
Copy link
Member

Choose a reason for hiding this comment

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

I can't find it in the spec anymore, but when I last looked at it, the theming object was passed to each individual 'slot'. Can it just be an argument to a callback for makeOverrides first argument?

I.e.

const useStyles = makeOverrides((theme: ITheme) => {
  root: ...,
  other: ...
}));

instead of

const useStyles = makeOverrides({
  root: (theme: ITheme) => { ... },
  other: (theme: ITheme) => { ... }
});

Saves some lambda creation at runtime and in my opinion is a bit more intuitive.

Copy link
Member Author

Choose a reason for hiding this comment

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

Can it just be an argument to a callback for makeOverrides first argument?

We are considering this option, but there are few open questions.

1. IE11 should have decent performance if there will be a decision to include it into supported browsers, in this case build step can split styles to runtime dependent:

// ⚠️ not real code, just shows an idea
const useStyles = makeStyles([
  [null, theme => ({ color: "red", background: theme.colors.red })]
]);
// =>

const useStyles = makeStyles([
  [null, { color: "red" }],
  [null, theme => ({ background: theme.colors.red })] // only "theme" usages are inside this closure
]);

2. Our work related to tokens is not finished yet, we may decide to go with CSS variables (and some magic shim for IE11, if required). But, the open question is why we should have theme and closures at all?

const useStyles = makeStyles([
  // current option, simpler implementation for IE11
  [null, theme => ({ background: theme.colors.red })],
  // "vars" can be an object will all CSS variables that are in theme to enforce type safety
  [null, { background: vars.colors.red }],
  // can we just use CSS variables?
  [null, { background: "--theme-colors-red" }]
]);

Saves some lambda creation at runtime and in my opinion is a bit more intuitive.

Once we will have build time transforms, there will be no closures for modern browsers at all in runtime 🐱 Currently this work is done on an initial render, for example:

const useLabelStyles = makeStyles<AvatarState>([
  [
    null,
    tokens => ({
      color: tokens.alias.color.neutral.neutralForeground3,
      background: tokens.alias.color.neutral.neutralBackground6
    })
  ]
]);

Will produce this CSS:

CSS variables output

/* 👍 no matchers, no need to execute on each render */
root: { color: "red" },
rootPrimary: { color: "blue" },

/* 👍 styles for each slot are defined together (not a requirement) */
icon: { background: "black" },
iconPrimary: { background: "white" }
});

function Component() {
/* 👍 a single call of React hook */
const classes = useStyles();

return (
<div
className={ax(
classes.root /* The concept of matching is replaced with selective classname concat */,
props.primary && classes.rootPrimary,
props.className
)}
>
{props.icon && (
<div
className={ax(
classes.icon,
props.primary && classes.iconPrimary,
props.icon.className
)}
/>
)}
</div>
);
}
```

### Pros and Cons

These changes result in performance improvements in `makeOveriddes`:

- matcher functions are no longer executed on each render in styling functions
- styles are executed and merged only when slots are present
- less React hooks to call for slots

Surprisingly, this also moves us closer to [CSS Modules](https://github.com/css-modules/css-modules). The same snippet with CSS Modules, for example:
JustSlone marked this conversation as resolved.
Show resolved Hide resolved

```tsx
import cx from "classnames";
import * as classes from "./Component.css"; // the same styles can be written in CSS

function Component() {
return (
<div
className={cx(
classes.root,
props.primary && classes.rootPrimary,
props.className
)}
>
{props.icon && (
<div
className={cx(
classes.icon,
props.primary && classes.iconPrimary,
props.icon.className
)}
/>
)}
</div>
);
}
```

In [microsoft/fluentui#16923](https://github.com/microsoft/fluentui/pull/16923) `Avatar`, from `@fluentui/react-avatar` (check `packages/react-avatar/src/components/Avatar/useAvatarStyles.ts`), was used to validate performance improvements and explore potential issues. It is uncertain if/how these issues are critical, but there are two things that should be mentioned:

```tsx
const useStyles = makeOverrides({
/* ... Complex apps/components can contain many definitions ... */

rootShape20: { width: '20px', height: '20px' },
rootShape24: { width: '24px', height: '24px' },
rootShape28: { width: '28px', height: '28px' },
})
Copy link
Collaborator

@JustSlone JustSlone Feb 19, 2021

Choose a reason for hiding this comment

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

[non-blocking] I do quite like this pattern of defining styles ahead of time, then conditionally applying them below. We should measure, but my intuition says that this will have good opportunity for optimization and gets us closer to static styles.

I also find this more intuitive than the matcher approach 👍 I think this will be easier for devs to understand and other teams to use in their products.

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 also find this more intuitive than the matcher approach 👍 I think this will be easier for devs to understand and other teams to use in their products.

There is one interesting difference:

  • with matchers (i.e. makeStyles()) you can just execute a function and get classes back
  • with makeOverrides() you need to execute component's code to understand applied styles

I am considering this an implementation difference that should not block us from moving forward.

Copy link
Member

@khmakoto khmakoto Feb 24, 2021

Choose a reason for hiding this comment

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

Question: What prevents us from abstracting the component code into a separate function so that we can, like you said, just execute a function and get classes back?

Copy link
Member Author

Choose a reason for hiding this comment

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

Question: What prevents us from abstracting the component code into a separate function so that we can, like you said, just execute a function and get classes back?

function getRootClasses(state, classes) {
  return ax(classes.root, state.primary && classes.primary, /* etc. */)
}

Do you mean something like this?


export const useAvatarStyles = (state: AvatarState): AvatarState => {
const classes = useStyles();

state.className = ax(
classes.root,

// 👎 Matchers have been moved to ax() calls, it looks a bit verbose
// (in previous implementation matchers have been close to styles)
// 👎 It might be tricky find proper names to express definition names
// (we can end with "rootPrimaryCirclularGhostEtc.")
Copy link
Member

Choose a reason for hiding this comment

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

Not blocking by any means for this PR but we might want to find a convention for how we name things.

For example, we could go with [slot] [alphabetically ordered states].

So, for the example above that would be rootCircularGhostPrimary.

Just food for thought 😄.

layershifter marked this conversation as resolved.
Show resolved Hide resolved

state.size === 20 && classes.rootShape20,
state.size === 24 && classes.rootShape24,
/* ... many selectors ... */
state.size === 128 && classes.rootShape128
);

return state;
};
```

layershifter marked this conversation as resolved.
Show resolved Hide resolved
## Discarded Solutions

NA

## Open Issues

NA