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

[WIP] styled-sheet #2357

Open
wants to merge 23 commits into
base: master
from

Conversation

Projects
None yet
3 participants
@mxstbr
Copy link
Member

mxstbr commented Jan 28, 2019

Summary

This is a complete rewrite and modernisation of the core StyleSheet to speed up SSR performance and be much smaller bundle size-wise.

Background

In styled-components v3 and below we had to clone styles (injectGlobal, keyframes) from the global "master" StyleSheet to the request-specific StyleSheet (new ServerStyleSheet) for every server-side rendering request. That is an expensive operation and a big bottleneck for SSR performance.

With the new createGlobalStyle component and lazy injection of keyframes in v4 all styles injection happens at render-time, i.e. on every request. That means we do not have to clone any styles anymore! If we rewrite our StyleSheet to avoid cloning we should be able to get a big speedup in server-side rendering performance, as well as removing a lot of complexity and thus reducing the bundle size!

Click for a more detailed technical explanation of the proposed changes

This was taken from @philpl's comment in #2120.

Current StyleSheet — deferred injection

Our current model keeps order of first injection per component. When new rules are then injected they're appended to the first rule the component injected. However, we care about order of creation for components instead, so we want to inject a placeholder as soon as the component is created. Now, because we don't want to immediately inject rules we have deferred injection to "fake insert" as first rules. That's when .sc-a {} is also injected.

When we clone a StyleSheet (typically master to a ServerStyleSheet) the master has all the deferred rules and the "child" has all the rest. Since we want to preserve the order we've established we copy all of that information over by "cloning".

Proposed system — injection by indices

We can capture the creation order of components with a simple counter. If we then get to render we already have a StyleSheet from context with fallback to master (completely universal, yay). In render we can then instruct the StyleSheet to inject the component's rendered rules and also give it the creation index.

The StyleSheet keeps a list of all creation indices and the number of rules that have been inserted for them. (This means that all these numbers added up are equal to the number of inserted rules)

We can then take the creation index and walk the complete list of creation indices until either the next index is greater or the current one is equal. All of the number of rules in the list up until the matching index can then be added up. What we get is the injection index, which can be passed to sheet.insertRule 🎉

Babel-less SSR

Another problem we tried to solve is Babel-less SSR. How can we ensure consistent classes, ordering, and unique names without the Babel plugin?

The problem we need to solve is that there has to be a “single consistent moment” between the result of SSR and the rehydration where the client renders the same result. What we're trying to achieve is consistency between the two. We can't use our “best guess displayName” since it's ordered by creation, which might be different on the server and the client.

Essentially we can keep another counter for the render order. Since in SSR the rendered tree is enforced to be the same on the client we can count upwards for rendered StyledComponents. The first time we encounter a component while rendering we can give it a number to use as its componentId instead of a “best guess“ name.

Concretely

  • Each component has an order index that determines the injection order for a group of CSS rules (rule group)
  • Each component injects any number of CSS rules into its rule group
  • Hence the rule groups must stay ordered and grouped
  • rule groups can also grow when more CSS rules are injected into it

Component Creation

This is the moment when the user creates a StyledComponent.

  • An order index is being assigned to the component
  • The order index is created by incrementing a global counter

Rule injection

This is when a StyledComponent is being rendered

  • An amount of CSS rules have been processed and returned by stylis
  • The component calls RuleSheet#insertRules using CSS rules and its order index
  • The index of the underlying CSSStyleSheet#insertRule needs to be determined
  • This can be determined by summing up the sizes of all rule groups for which their order index <= the passed order index

When an optimisation on the above operation is made we thus need to take into account:

  • How do we determine the order index relative to all others efficiently?
  • How do we sum up the number of rules left of the current rule group efficiently?

To be answered

SSR

To restore the correct ordering the output CSS needs to be rehydrated. This is basically a process
of marshalling (during SSR) and unmarshalling (on the client).

The order index of all components must be restored. This can be done by having a mapping
of order index to a component ID. This component ID can be replaced with the render order index
which means that the "final" order index can only be determined during rendering.

Subsequently when a component is unknown its new order index must be larger than the largest order index
that SSR has generated.

Apart from this process everything remains the same.

Summary

  1. SSR maps each component ID to an order index (Rehydration retrieves this information)
  2. If the SSR result is "spread out" due to streaming, all rules are migrated to a new style tag in the head
  3. An incremental order index is assigned to each created StyledComponent
  4. During rendering we use the component ID or capture the render order index as a fallback
  5. If the component ID matches to a rehydrated order index it replaces the component's own order index (see 3.)
  6. CSS rules are generated
  7. The order index is used to insert the new rules into the rule group (See Rule injection)

As an optimisation the CSS rules' hash (which is also their className) can be looked up in a Map to avoid
duplicate injection.

Glossary

  • order index: This is a number that assigns a priority order to a rule group. Rules in such a group are meant to be injected in ascending order of their index.
  • rule group: This is a group of CSS rules that can never be separated and can grow as components append new rules to it.
  • component ID: This is a unique identifier for a component that is important to match information to it between SSR and rehydration.
  • render order index: This is a number that signifies in which order a given component rendered to all other tracked components, which can be used to replace component IDs for SSR.

TODO

  • Step 1: Build the new StyleSheet in a new styled-sheet package
    • Create new package in the monorepo
    • Build first implementation as test bed
    • Figure out API
    • Move to classes-as-order-markers instead of comments-as-order-markers
    • Add tons of unit tests
  • Step 2: Integrate the new styled-sheet into the core library
    • Rip out old StyleSheet
    • Add orderIndex to StyledComponent.js that counts up for each created component
    • Add same orderIndex to keyframes and createGlobalStyles
    • Output the orderIndex in the styledComponentId comment during SSR
    • Implement SSR rehydration based on the orderIndex in the comment
    • Replace styledComponentId with renderIndex
  • Step 3: Publish alpha release
    • Test in production apps (Spectrum)
  • Step 4: Publish public beta release
    • Announce and get people to submit bugs
  • Step 5: Release styled-components v5
  • Step 6: Implement rehydration from <link> tags for "static" style extraction

If you want to get involved and help out with this please comment below! We can use all the hands we can get, especially to write tests and to figure out the API.

Ref issue #2120

mxstbr and others added some commits Jan 22, 2019

Rename StyleSheet to RuleGroupTag
I'd suspect that this won't be the highest abstraction
if we want to replace ComponentStyle.
Replace RuleGroupTag.toString with getGroup
The component names should not leak into our rule group
abstraction. We might have to take a hit on perf later
on to keep the names separate and call indexOfGroup
multiple times, but that's worth it if the abstraction
is clean and easy to understand.
Remove createRuleGroup and size dynamically
We want to be able to create as many RuleGroupTags
as we want and decide separately what the order
index we'll inject will be.
Hence we'll resize the rulesPerGroup array as
needed.
@mxstbr
Copy link
Member Author

mxstbr left a comment

Took a quick look at the integration, that looks very nice, especially since there aren't too many changes so far 😅

Does it work? Why aren't tests running?

/* prepend style html to chunk */
const html = sheet.toHTML();
// Force Sheet to "forget" about previously emitted CSS
sheet.reset();

This comment was marked as resolved.

@mxstbr

mxstbr Jan 29, 2019

Author Member

Since there should be a new sheet on every request this seems like unnecessary work, shouldn't the entire sheet be garbage collected once the request is done?

This comment was marked as resolved.

@kitten

kitten Jan 29, 2019

Member

This is the code inbetween chunks on the SSR stream. We need to ensure that previously emitted CSS is not emitted again. Since there's no "useful" information in a GroupedTag apart from just CSS, the Stylesheet can just throw it away and create a new one

This comment was marked as resolved.

@mxstbr

mxstbr Jan 29, 2019

Author Member

I see, I see, that makes sense!

mxstbr added some commits Jan 29, 2019

@mxstbr

This comment has been minimized.

Copy link
Member Author

mxstbr commented Jan 29, 2019

I just added my first (working) test for the Sheet, yay!

it('should inject some CSS', () => {
const group = GroupRegistry.registerRuleGroup('name');
const sheet = new Sheet(undefined, true);
sheet.inject(group, 'key', ['.class { color: blue; background: red; }']);
expect(sheet.toString()).toMatchInlineSnapshot(`
"/*sc-1:name:key*/
.class { color: blue; background: red; }
"
`);
});

Why do I have to call GroupRegistry.registerRuleGroup('name')? That seems like a weird API. Maybe something like this would be clearer instead?!

const group = new Group('name');
sheet.inject(group, 'key', []);

I just did sheet.inject(0, 'key', rules) for a while expecting it to work. sheet.inject() should throw an error if the group passed as the first arg isn't registered, I just stumbled on that for a couple minutes!

It would also be nice if sheet.inject(group: number) was instead typed as sheet.inject(group: GroupIndex) which is just an alias to number. I thought sheet.inject(0) would work since it's typed as number.


Also another random idea, what about making groups the core injection primitive, to make it clear that you always have to inject into a group? Something like the following:

const sheet = new Sheet();
const group = new Group(sheet, name);
group.inject('key', rules);

That seems nicer than the implicit GroupRegistry. The GroupRegistry doesn't seem to be bound to a single sheet, at least from an API perspective—it's just a random function call. Or what about tying the Group to the Sheet?

const sheet = new Sheet();
const group = new sheet.Group(name);
group.inject('key', rules);
// or
sheet.inject(group, 'key', rules);

Why would these work/not work?

@kitten

This comment has been minimized.

Copy link
Member

kitten commented on 576f31b Jan 29, 2019

Oh that explains the sandbox issue 😂😂😂

@mxstbr

This comment has been minimized.

Copy link
Member Author

mxstbr commented Jan 29, 2019

For posterity so we do not forget: @kitten pointed that out that we could move to classes-as-order-markers instead of comments-as-order-markers, which would make it possible to rehydrate from a <link> tag. That would be useful for static pre-rendering a la Gatsby and react-static.

@probablyup

This comment has been minimized.

Copy link
Contributor

probablyup commented Jan 29, 2019

@mxstbr do a little benchmark around usage of new - it's a known perf impactor :/

@kitten

This comment has been minimized.

Copy link
Member

kitten commented Jan 31, 2019

@probablyup we'll revert the decision around Group and go back to the GroupRegistry singleton. That being said there's less allocations now, even including this change which only happens while the StyledComponent and ComponentStyle are being created :) during updates there's shouldn't ever be an allocation in styled-sheet

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