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

Remove singletons in favor of React context (hooks-based implementation)(backward compatible with all versions of react) #221

Merged
merged 40 commits into from
Aug 26, 2019

Conversation

noratarano
Copy link
Collaborator

@noratarano noratarano commented Aug 13, 2019

Summary

Replace the withStyles implementation with one that uses React.Context, not singletons. We fall back to the singletons if no react-with-styles themes or interfaces are provided through context. The ThemedStyleSheet API is still supported as we now detect Hooks/Context and conditionally use the old hooks-based implementation and the new one.

Usage:

// The same as before!
const ComponentWithStyles = withStyles(theme => styles, options)(Component);
// Before
ThemedStyleSheet.registerTheme(Theme);
ThemedStyleSheet.registerInterface(Theme);

// After!
<WithStylesContext.Provider 
  value={{ 
    stylesInterface: AphroditeInterface, 
    stylesTheme: AirbnbTheme, 
  }}
>
  <App />
</WithStylesContext.Provider>

This change also introduces a few notable differences in the way that withStyles works:

  • Because the theme is no longer available outside of the React tree, every instance of a component will “create” styles from the theme, as opposed to the one time per withStyles call that the other component was doing before.

Updates to the README are captured here: #223

Details

Singletons don't scale, and we're feeling that at Airbnb. In an effort to modernize our repos and scale our infrastructure, we're moving away from this singleton implementation and relying on the latest React context API to register themes and interfaces. Because we have so many systems sharing this library (react-dates, rheostat, and internal libs), including ones that require the singleton implementation (ahem hypernova) this implementation falls back to the singleton implementation (through ThemedStyleSheet) to ensure that bundles that don't set theming can continue to work until the process is complete.

Lastly, while this is a breaking change, consumers of this lib can still access the old version of withStyles depending on their version of React. Those with React <= 16.8 will get the old implementation. Those with the new React will get the new one based on hooks.

Deprecation process that I'm envisioning:

  1. Update this library to rely on context and fall back to ThemedStyleSheet (this PR)
  2. Update all Airbnb production and development code to provide the interface and theme through context using WithStylesContext.Provider. If some aren't captured, we fall back to the previous implementation.
  3. Once the migration of the airbnb repos are complete, completely deprecate ThemedStyleSheet and the deprecated/withStyles HOC.

Before making useStyles available:

  • Make sure stylesFn is static (doesn't change)
  • Make sure stylesFn doesn't depend on props (cache created results?)
  • useDirection or DirectionContext so that useStyles can be used alone

Future performance improvements:

  • Cache created styles in some styles cache so that we don't have to create styles for every instance of the component

Testing

I wrote a lot of tests for this. Please take a look at all the assertions.

I also tested this implementation on the internal design system storybook, rheostat and react-dates (with both the Aphrodite and CSS interfaces). 👍

Reviewers

@lencioni @ljharb @goatslacker @brucewpaul @TaeKimJR @majapw @indiesquidge


// Exported until we deprecate this API completely
export { get as _getTheme };

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is so we can fall back to these singletons from the contextual implementation.

@noratarano noratarano requested review from lencioni, ljharb, brucewpaul, goatslacker and majapw and removed request for lencioni August 13, 2019 19:56
@@ -0,0 +1,220 @@
/* eslint react/forbid-foreign-prop-types: off */
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This is the old withStyles moved to deprecated/

@noratarano
Copy link
Collaborator Author

noratarano commented Aug 13, 2019

Sounds like there may be an issue with our implementation of page slots. I'm taking a look and will ping again once I confirmed this implementation is OK. Apologies!

ljharb
ljharb previously requested changes Aug 13, 2019
Copy link
Collaborator

@ljharb ljharb left a comment

Choose a reason for hiding this comment

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

Using hooks means it's a breaking change, and we'll need to bump the peer dep to react 16.8 or higher.

Is there a reason we can't use traditional context?


const StylesThemeContext = createContext();

export default StylesThemeContext;
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Make into a single context.

resolve,
flush,
}),
[create, resolve, flush],
Copy link
Collaborator Author

@noratarano noratarano Aug 14, 2019

Choose a reason for hiding this comment

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

Avoid diffing so many things. Only diff the values that matter once, so no need to diff create/resolve/flush separately.

Copy link

Choose a reason for hiding this comment

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

The values returned by useCallback are memoized already, and I'm not sure we need to re-memo them again. In fact, useCallback could be implemented with useMemo.

Should be able to:

return { create, resolve, flush }

Or

return [create, resolve, flush];

@noratarano
Copy link
Collaborator Author

noratarano commented Aug 14, 2019

@ljharb This is intended to be a breaking change. This API is just much easier to maintain and read through. There's no particular reason other than maintainability for this, and Airbnb is using React 16.8, and I believe we're comfortable with this breaking change.

Can you share why it's not recommended to introduce this restriction?

@ljharb
Copy link
Collaborator

ljharb commented Aug 14, 2019

@noratarano the X is just for updating the peer dep; the rest is just my opinion.

Public open source projects aren't just for Airbnb, and while it's great that Airbnb is now on 16.8 (congrats!), making it a requirement excludes some portion of the internet from being able to use it - as of a month or two ago, it'd have excluded Airbnb too. In general, it's best to aim for maximal backwards compatibility, so that this project can belong anywhere.

If the API is identically achievable and maintainable using legacy context - even if it's slightly less fun to work with - it seems to me like a moral imperative to maximize how many people can use the project, not to optimize for the experience of the < 5 developers who will touch this repo.

All that said, if the peer dep is bumped, i'll remove my X, and I'll trust yall to make the decisions you feel best.

Copy link

@ahuth ahuth left a comment

Choose a reason for hiding this comment

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

Looks good so far! Left a couple comments regarding hooks, and will leave some more comments

// Fallback
const registeredInterface = _getInterface();
if (!fallbackInterface) {
setFallbackInterface(registeredInterface);
Copy link

@ahuth ahuth Aug 14, 2019

Choose a reason for hiding this comment

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

If _getInterface is a simple getter, would there be any issues with executing it each time the hooks run?

const stylesInterface = useContext(StylesInterfaceContext);

if (stylesInterface) {
  return stylesInterface;
}

// Fallback
return _getInterface();

Or, if we don't want to run it every time the hook is called, we could avoid the setState (and its re-render) with a ref:

const stylesInterface = useContext(StylesInterfaceContext);
const fallbackRef = useRef();

if (stylesInterface) {
  return stylesInterface;
}

if (!fallbackRef.current) {
  fallbackRef.current = _getInterface();
}

return fallbackRef.current;

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

OOoh! Interesting. 👍

resolve,
flush,
}),
[create, resolve, flush],
Copy link

Choose a reason for hiding this comment

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

The values returned by useCallback are memoized already, and I'm not sure we need to re-memo them again. In fact, useCallback could be implemented with useMemo.

Should be able to:

return { create, resolve, flush }

Or

return [create, resolve, flush];

// Calculate and cache the styles definition for this combination of global state values. This
// value will only be recalculated if the create function changes, which in turn will only
// change if any of the global state we depend on changes.
const styles = useMemo(() => create(stylesFn), [create]);
Copy link

Choose a reason for hiding this comment

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

👍

package.json Show resolved Hide resolved
@noratarano noratarano force-pushed the nora--remove-singletons branch from 6ef2ed4 to f25e134 Compare August 15, 2019 18:57
@noratarano noratarano requested review from ahuth and TaeKimJR August 22, 2019 00:32
@@ -0,0 +1,218 @@
import React from 'react';
Copy link
Member

Choose a reason for hiding this comment

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

Is this file completely untouched? I.e., you just moved what was previous at src/withStyles.jsx into this deprecated folder? Or did you change other things around as well?

@@ -0,0 +1,555 @@
import React from 'react';
Copy link
Member

Choose a reason for hiding this comment

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

Same question here: is this file untouched other than the move from test/withStyles_test.jsx → test/deprecated/withStyles_test.jsx?

@@ -1,555 +1,619 @@
/* eslint-disable no-unused-vars */

Copy link
Member

Choose a reason for hiding this comment

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

Why this tho?

Copy link
Collaborator Author

@noratarano noratarano Aug 23, 2019

Choose a reason for hiding this comment

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

Without disabling this I can't do:

describe('foo', () => {
  let myUsedVar;
  
  beforeEach(() => {
    myUsedVar = imUsingIt();
  });

  it('uses this var', () => {
    console.log(myUsedVar);
  });
});

This fails. 😢

Copy link
Member

Choose a reason for hiding this comment

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

Ah. Well that's a silly failure, but also Just Another Reason™ to avoid setup functions like beforeEact et al that share state, especially given that people can nest these types of things and make it super hard in a long test suite to figure out what is actually going on (I experienced this often on the Host product code with tests with nested blocks and setup helpers). I don't believe tests are were we should be keeping things DRY, and would much rather see each test verbosely express everything it needs.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good point. I'll definitely keep that in mind going forward ^_^

@noratarano noratarano changed the title Remove singletons in favor of React context (hooks-based implementation) Remove singletons in favor of React context (hooks-based implementation)(backward compatible) Aug 22, 2019
@noratarano noratarano changed the title Remove singletons in favor of React context (hooks-based implementation)(backward compatible) Remove singletons in favor of React context (hooks-based implementation)(backward compatible with all versions of react) Aug 23, 2019
@noratarano noratarano force-pushed the nora--remove-singletons branch from d733f77 to 9b447c6 Compare August 23, 2019 00:31
@noratarano noratarano force-pushed the nora--remove-singletons branch from 713e189 to 58ce44b Compare August 23, 2019 03:02
Copy link
Collaborator

@ljharb ljharb left a comment

Choose a reason for hiding this comment

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

Awesome, this is looking great!

@@ -55,12 +56,16 @@
"chai": "^4.2.0",
"enzyme": "^3.10.0",
"enzyme-adapter-react-16": "^1.14.0",
Copy link
Collaborator

Choose a reason for hiding this comment

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

this should be removed, since the helper is being used now


return {
Provider: () => {
throw new ReferenceError('WithStylesContext requires React 16.6 or later');
Copy link
Collaborator

Choose a reason for hiding this comment

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

createContext works in 16.3+, i believe

src/useStyles.js Show resolved Hide resolved
let { stylesInterface, stylesTheme: theme } = useContext(WithStylesContext);

// Fallback to the singleton implementation
stylesInterface = stylesInterface || _getInterface();
Copy link
Collaborator

Choose a reason for hiding this comment

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

does _getInterface() make it available for future useContext calls, or should this somehow be persisted?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

No it doesn't... I see what you mean. 🤔

import ifReact from 'enzyme-adapter-react-helper/build/ifReact';

export function describeIfReact(version, ...rest) {
return ifReact(version, describe, describe.skip)(...rest);
Copy link
Collaborator

Choose a reason for hiding this comment

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

nice

test/withStylesWithHooks_test.jsx Outdated Show resolved Hide resolved
test/withStylesWithHooks_test.jsx Outdated Show resolved Hide resolved
@noratarano noratarano force-pushed the nora--remove-singletons branch from f65ff92 to 1c783f6 Compare August 23, 2019 21:14
@noratarano
Copy link
Collaborator Author

Merging this :) Will update README or make any additional changes in separate PRs before publishing the next version.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants