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

feat: native css styling #498

Merged
merged 44 commits into from May 16, 2023
Merged

Conversation

marklawlor
Copy link
Contributor

@marklawlor marklawlor commented Apr 16, 2023

Motivation

Allow native applications to be styled via .css and .module.css files.

This PR implements a css extractor and runtime for View and Text components. It supports:

  • A minimal set of CSS properties
  • px / % / vw / vh / rem units (the common Tailwind CSS units)
  • Inline CSS variables
  • variable inheritance
  • Basic media queries
  • Basic container queries
  • prefers-color-scheme media queries
  • className support
  • Tailwind CSS support
  • Opt in inheritance
  • Transitions (feature flagged)
  • Animations (feature flagged)

Execution

This is quite a large PR, so I'll break it down into sections. The actual implementation is quite small (the native runtime is <800 LOC) with the majority of this PR being the test project, integration tests & project boiler plate.

The general flow is

  • A Metro transform converts CSS stylesheet into RN objects
  • The style objects are passed to a new SheetSheet.create
    • This is not important. It just stores metadata to shortcut rendering
  • A jsx transformation detects if a component is using styles that we need to be dynamically calculated (e.g. media queries)
  • If so, the component is wrapped in CSSInteropWrapper
  • CSSInteropWrapper can calculate these dynamic values (referred from here onwards as flattening) and using signals, create a dependency graph.

CSS transfomer

The metro transformer has been split into webCssTransformer and nativeCssTransformer

if (options.platform === "web") {
return webCssTransform(config, projectRoot, filename, data, options);
} else {
return nativeCssTransform(config, projectRoot, filename, data, options);
}
webCssTransformer contains the original logic.

The nativeCssTransformer converts the CSS to RN style objects and collects metadata (e.g. is this style dynamic). The parsed styles and metadata are passed to a new StyleSheet class that extends the original React Native StyleSheet.

if (matchCssModule(filename)) {
return worker.transform(
config,
projectRoot,
filename,
Buffer.from(
`module.exports = require("@expo/styling").StyleSheet.create(${JSON.stringify(
nativeStyles
)});`
),
options
);
} else {
return worker.transform(
config,
projectRoot,
filename,
Buffer.from(
`require("@expo/styling").StyleSheet.register(${JSON.stringify(
nativeStyles
)});`
),
options
);

It exposed two different functions create & register. create works like the original create and the styles are returned. register instead "registers" the styles as globals (to be used in className). Both of these two functions remove the metadata and store it in a Weakmap.

register: (options: StyleSheetRegisterOptions) => {
if (options.declarations) {
for (const [name, styles] of Object.entries(options.declarations)) {
globalStyles.set(name, tagStyles(styles));
}
}
for (const subscription of subscriptions) {
subscription();
}
},

jsxImportSource

A new jsxImportSource is implemented. As this runs on every jsx transform the primary goal here is to reduce overhead. The View and Text components are tagged with a function signalling that we will transform their styles. All other components are ignored, and processed as normal.

export function render(
jsx: Function,
type: any,
props: Record<string | number, unknown>,
key: string
) {
if (!props.__skipCssInterop && typeof type.cssInterop === "function") {
return type.cssInterop(jsx, type, props, key);
} else {
return jsx(type, props, key);
}
}

For tagged components, two things happen

  • className props are converted to styles (from the global register) and merged with the style prop
  • the style prop is check if any of its style objects are present in the metadata WeakMap

Most styles are static (eg padding: 30), so the function early exits and renders as normal.
If the component contains a dynamic style, the it is wrapped in a CSSInteropWrapper component.

classNameToStyle(props);
/*
* Most styles are static so the CSSInteropWrapper is not needed
*/
if (!areStylesDynamic(props.style)) {
return jsx(type, props, key);
}
return jsx(CSSInteropWrapper, props, key);

CSSInteropWrapper

This component creates a Signal computation and flattens the style prop into a single object. As the styles are being flattened, property values will be computed as subscribed to. If a value changes (e.g. the viewport width) the component will render.

const [, rerender] = React.useReducer((acc) => acc + 1, 0);
/* eslint-disable react-hooks/rules-of-hooks -- __styleKeys is consistent an immutable */
for (const key of __styleKeys) {
/*
* Create a computation that will flatten the style object. Any signals read while the computation
* is running will be subscribed to.
*/
const computation = React.useMemo(
() => createComputation(() => flattenStyle($props[key])),
[props[key]]
);
useEffect(() => computation.subscribe(rerender), [computation]);
props[key] = computation.snapshot();
}
/* eslint-enable react-hooks/rules-of-hooks */
return <Component {...props} ref={ref} __skipCssInterop />;

Style flattening

Style flattening is the process of reducing a style prop into a single style object. As it passes over values, it will either calculate the value or set the value as a getter function so it can be computed at a later time.

Defferred computing is require as some values cannot be calculated until after the entire style has been flattened (e.g. color: rgb(255, 0, 0, var(--text-opacity)) requires the value of var(--text-opacity) to be known.

During this flattening process, styles may also have associated media queries. These styles will only pass if the media query passes. The computation will also subscribe to any dependencies of the media query.

for (const [key, value] of Object.entries(styles)) {
// Variables are prefixed with `--` and should not be flattened
if (key.startsWith("--")) {
flatStyleMeta.variables ??= {};
// Skip already set variables
if (key in flatStyleMeta.variables) continue;
const getterOrValue = extractValue(value, flatStyle, flatStyleMeta);
if (typeof getterOrValue === "function") {
Object.defineProperty(flatStyleMeta.variables, key, {
enumerable: true,
get() {
return getterOrValue();
},
});
} else {
flatStyleMeta.variables[key] = getterOrValue;
}
} else {
// Skip already set keys
if (key in flatStyle) continue;
// Skip failed media queries
if (styleMeta.media && !styleMeta.media.every(testMediaQuery)) {
continue;
}
// Non runtime styles can be set directly
if (!styleMeta.runtimeStyleProps?.has(key)) {
(flatStyle as any)[key] = value;
continue;
}
const getterOrValue = extractValue(value, flatStyle, flatStyleMeta);
if (typeof getterOrValue === "function") {
Object.defineProperty(flatStyle, key, {
configurable: true,
enumerable: true,
get() {
return getterOrValue();
},
});
} else {
flatStyle[key as keyof Style] = getterOrValue;
}
}
}
return flatStyle;

Test Plan

Unit / integration tests.

For this new feature to work, it needs all the systems mentioned above to work in unison. Because of this I've focused on integration testing over unit testing.

A typically integration test takes the form of

// Create a special component that we can test
const A = createMockComponent();

// Reset the styling globals
afterEach(() => {
  StyleSheet.__reset();
});

test("width", () => {
  // Easily copy/paste CSS for testings
  registerCSS(`
.my-class { color: blue; }
@media (width: 500px) {
  .my-class { color: red; }
}`);

  render(<A className="my-class" />);

  // Use a custom matcher to easily asset the components styles.
  expect(A).styleToEqual({
    color: "rgba(0, 0, 255, 1)",
  });
  
  // You can change globals within act
  act(() => {
    vw.__set(500);
  });
 
  // And assert that components rerendered
  expect(A).styleToEqual({
    color: "rgba(255, 0, 0, 1)",
  });
});

Test project

A new Tailwind based test project has been created in /apps

@jgornick
Copy link

Hey @marklawlor!

Previous NativeWind consumer here. I'm super excited to see that you're enhancing the implementation of cross-platform styles (i.e., class names) here in Expo.

With moving to Expo though, how much of the Expo ecosystem is going to be required to utilize @expo/styling?

Our use-case is to build and register a Tailwind-based utility StyleSheet and have our components utilize those utility styles (i.e., class names). A large chunk of our utility styles are driven by variables for the purpose of being able to switch between light and dark mode. Because our utility styles were variable driven, it was required to wrap our components to watch for changes (e.g., NativeWind.styled). We had a path forward with NativeWind 3, but was wondering if this type of use-case would still work with the introduction of @expo/styling?

Any information you can provide would be greatly appreciated! Thanks!

  • Joe

@marklawlor marklawlor force-pushed the marklawlor/react-native-css-styles branch from ed230d9 to 3d347a1 Compare April 26, 2023 23:52
@marklawlor marklawlor force-pushed the marklawlor/react-native-css-styles branch from aca3d30 to bd482db Compare May 1, 2023 07:06
@marklawlor
Copy link
Contributor Author

@jgornick This package works quite differently to NativeWind, so some concepts don't carry across. The biggest difference that NativeWind immediately converts className->style (the className is not passed down). This package instead only styles leaf nodes (eg View/Text). For example if you put a className on a <Pressable />, it will pass the className to its internal <View /> which is then styled.

This greatly improves performance as dynamic styles no longer cause a re-render high in the tree.

The styled() API allowed native developers to have a similar experience as the web and worked around the className issue. But since this library solved that problem, its no longer needed and you can use the same patterns as the web (eg classnames/clsx/cva).

But I also don't want to break everything, so I will release a NativeWInd 4 with a compatible styled()

@marklawlor marklawlor force-pushed the marklawlor/react-native-css-styles branch from bd482db to c87edc7 Compare May 10, 2023 07:48
@marklawlor marklawlor merged commit 8ac822a into main May 16, 2023
6 checks passed
@marklawlor marklawlor deleted the marklawlor/react-native-css-styles branch May 16, 2023 23:15
@implicit-invocation
Copy link

Is this feature ready to use? Do we have any documentation on this? I tried to degit the apps/tailwind repo and ran into Property 'className' does not exist on type 'IntrinsicAttributes & IntrinsicClassAttributes<View> & Readonly<ViewProps>'. typescript error.

@sannajammeh
Copy link

@jgornick This package works quite differently to NativeWind, so some concepts don't carry across. The biggest difference that NativeWind immediately converts className->style (the className is not passed down). This package instead only styles leaf nodes (eg View/Text). For example if you put a className on a <Pressable />, it will pass the className to its internal <View /> which is then styled.

This greatly improves performance as dynamic styles no longer cause a re-render high in the tree.

The styled() API allowed native developers to have a similar experience as the web and worked around the className issue. But since this library solved that problem, its no longer needed and you can use the same patterns as the web (eg classnames/clsx/cva).

But I also don't want to break everything, so I will release a NativeWInd 4 with a compatible styled()

Just to confirm as the docs seem to state something different. Does this mean we can actually use global css in native?
The docs state its web only.

@karlhorky
Copy link
Contributor

@EvanBacon I would also have the same question - is it possible to use CSS and CSS Modules in Expo also with the native parts? Really looking forward to this possibility! 🙌

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.

None yet

6 participants