Skip to content

Commit

Permalink
Optimization of hot paths (#3970)
Browse files Browse the repository at this point in the history
* Keep foldedComponentIds as a string

Seems the only use just joins the contents on every read, so we can do it on write instead.

* Build className string manually

Items are added to the array only to be filtered out again and then the remaining ones get joined. This performs better.

* Use Set for domElements

More accurately represents the collection and gives O(1) lookup performance.

* Read value once

* Remove unnecessary casting & array check

`flatten` always returns an array, and `join` doesn't seem to care what type it is.

* Simplify objToCssArray

* Make names a local variable

It's not accessed outside of this function and is always reset at the start.

* Build string directly instead of joining array elements

* Add util function for joining string arrays

`[...].join()` for an array of strings only becomes more performant with many thousands of items.
With 10 strings, this function is ~1.5x faster in Chrome and ~3x faster in Firefox.

* Optimize hyphenateStyleName

Just replacing regex with manual iteration.
This could be several times faster if we checked for the "ms-" prefix within the loop, but it makes the function pretty unreadable.
  • Loading branch information
benbryant0 committed Mar 23, 2023
1 parent 2b4f6cb commit 4d4e63c
Show file tree
Hide file tree
Showing 13 changed files with 110 additions and 58 deletions.
3 changes: 2 additions & 1 deletion packages/styled-components/src/constructors/keyframes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Keyframes from '../models/Keyframes';
import { Interpolation, Styles } from '../types';
import generateComponentId from '../utils/generateComponentId';
import { joinStringArray } from '../utils/joinStrings';
import css from './css';

export default function keyframes<Props extends object = object>(
Expand All @@ -18,7 +19,7 @@ export default function keyframes<Props extends object = object>(
);
}

const rules = (css(strings, ...interpolations) as string[]).join('');
const rules = joinStringArray(css(strings, ...interpolations) as string[]);
const name = generateComponentId(rules);
return new Keyframes(name, rules);
}
30 changes: 13 additions & 17 deletions packages/styled-components/src/models/ComponentStyle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import flatten from '../utils/flatten';
import generateName from '../utils/generateAlphabeticName';
import { hash, phash } from '../utils/hash';
import isStaticRules from '../utils/isStaticRules';
import { joinStringArray, joinStrings } from '../utils/joinStrings';

const SEED = hash(SC_VERSION);

Expand All @@ -16,12 +17,10 @@ export default class ComponentStyle {
baseStyle: ComponentStyle | null | undefined;
componentId: string;
isStatic: boolean;
names: string[];
rules: RuleSet<any>;
staticRulesId: string;

constructor(rules: RuleSet<any>, componentId: string, baseStyle?: ComponentStyle) {
this.names = [];
this.rules = rules;
this.staticRulesId = '';
this.isStatic =
Expand All @@ -42,28 +41,26 @@ export default class ComponentStyle {
styleSheet: StyleSheet,
stylis: Stringifier
): string {
this.names.length = 0;

if (this.baseStyle) {
this.names.push(this.baseStyle.generateAndInjectStyles(executionContext, styleSheet, stylis));
}
let names = this.baseStyle
? this.baseStyle.generateAndInjectStyles(executionContext, styleSheet, stylis)
: '';

// force dynamic classnames if user-supplied stylis plugins are in use
if (this.isStatic && !stylis.hash) {
if (this.staticRulesId && styleSheet.hasNameForId(this.componentId, this.staticRulesId)) {
this.names.push(this.staticRulesId);
names = joinStrings(names, this.staticRulesId);
} else {
const cssStatic = (
const cssStatic = joinStringArray(
flatten(this.rules, executionContext, styleSheet, stylis) as string[]
).join('');
);
const name = generateName(phash(this.baseHash, cssStatic) >>> 0);

if (!styleSheet.hasNameForId(this.componentId, name)) {
const cssStaticFormatted = stylis(cssStatic, `.${name}`, undefined, this.componentId);
styleSheet.insertRules(this.componentId, name, cssStaticFormatted);
}

this.names.push(name);
names = joinStrings(names, name);
this.staticRulesId = name;
}
} else {
Expand All @@ -78,10 +75,9 @@ export default class ComponentStyle {

if (process.env.NODE_ENV !== 'production') dynamicHash = phash(dynamicHash, partRule);
} else if (partRule) {
const partChunk = flatten(partRule, executionContext, styleSheet, stylis) as
| string
| string[];
const partString = Array.isArray(partChunk) ? partChunk.join('') : partChunk;
const partString = joinStringArray(
flatten(partRule, executionContext, styleSheet, stylis) as string[]
);
dynamicHash = phash(dynamicHash, partString);
css += partString;
}
Expand All @@ -98,10 +94,10 @@ export default class ComponentStyle {
);
}

this.names.push(name);
names = joinStrings(names, name);
}
}

return this.names.join(' ');
return names;
}
}
7 changes: 5 additions & 2 deletions packages/styled-components/src/models/GlobalStyle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import StyleSheet from '../sheet';
import { ExecutionContext, FlattenerResult, RuleSet, Stringifier } from '../types';
import flatten from '../utils/flatten';
import isStaticRules from '../utils/isStaticRules';
import { joinStringArray } from '../utils/joinStrings';

export default class GlobalStyle<Props extends object> {
componentId: string;
Expand All @@ -24,8 +25,10 @@ export default class GlobalStyle<Props extends object> {
styleSheet: StyleSheet,
stylis: Stringifier
): void {
const flatCSS = flatten(this.rules, executionContext, styleSheet, stylis) as string[];
const css = stylis(flatCSS.join(''), '');
const flatCSS = joinStringArray(
flatten(this.rules, executionContext, styleSheet, stylis) as string[]
);
const css = stylis(flatCSS, '');
const id = this.componentId + instance;

// NOTE: We use the id as a name as well, since these rules never change
Expand Down
3 changes: 2 additions & 1 deletion packages/styled-components/src/models/InlineStyle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from '../types';
import flatten from '../utils/flatten';
import generateComponentId from '../utils/generateComponentId';
import { joinStringArray } from '../utils/joinStrings';

let generated: Dict<any> = {};

Expand All @@ -32,7 +33,7 @@ export default function makeInlineStyleClass<Props extends object>(styleSheet: S

generateStyleObject(executionContext: ExecutionContext & Props) {
// keyframes, functions, and component selectors are not allowed for React Native
const flatCSS = (flatten(this.rules, executionContext) as string[]).join('');
const flatCSS = joinStringArray(flatten(this.rules, executionContext) as string[]);
const hash = generateComponentId(flatCSS);

if (!generated[hash]) {
Expand Down
3 changes: 2 additions & 1 deletion packages/styled-components/src/models/ServerStyleSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Readable } from 'stream';
import { IS_BROWSER, SC_ATTR, SC_ATTR_VERSION, SC_VERSION } from '../constants';
import StyleSheet from '../sheet';
import styledError from '../utils/error';
import { joinStringArray } from '../utils/joinStrings';
import getNonce from '../utils/nonce';
import { StyleSheetManager } from './StyleSheetManager';

Expand All @@ -28,7 +29,7 @@ export default class ServerStyleSheet {
`${SC_ATTR}="true"`,
`${SC_ATTR_VERSION}="${SC_VERSION}"`,
];
const htmlAttr = attrs.filter(Boolean).join(' ');
const htmlAttr = joinStringArray(attrs.filter(Boolean) as string[], ' ');

return `<style ${htmlAttr}>${css}</style>`;
};
Expand Down
21 changes: 13 additions & 8 deletions packages/styled-components/src/models/StyledComponent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import hoist from '../utils/hoist';
import isFunction from '../utils/isFunction';
import isStyledComponent from '../utils/isStyledComponent';
import isTag from '../utils/isTag';
import joinStrings from '../utils/joinStrings';
import { joinStrings } from '../utils/joinStrings';
import merge from '../utils/mixinDeep';
import ComponentStyle from './ComponentStyle';
import { useStyleSheetContext } from './StyleSheetManager';
Expand Down Expand Up @@ -143,16 +143,21 @@ function useStyledComponentImpl<Target extends WebTarget, Props extends Executio
forwardedComponent.warnTooManyClasses(generatedClassName);
}

let classString = joinStrings(foldedComponentIds, styledComponentId);
if (generatedClassName) {
classString += ' ' + generatedClassName;
}
if (context.className) {
classString += ' ' + context.className;
}

propsForElement[
// handle custom elements which React doesn't properly alias
isTag(elementToBeCreated) &&
domElements.indexOf(elementToBeCreated as Extract<typeof domElements, string>) === -1
!domElements.has(elementToBeCreated as Extract<typeof domElements, string>)
? 'class'
: 'className'
] = foldedComponentIds
.concat(styledComponentId, generatedClassName, context.className)
.filter(Boolean)
.join(' ');
] = classString;

propsForElement.ref = forwardedRef;

Expand Down Expand Up @@ -241,8 +246,8 @@ function createStyledComponent<
// this static is used to preserve the cascade of static classes for component selector
// purposes; this is especially important with usage of the css prop
WrappedStyledComponent.foldedComponentIds = isTargetStyledComp
? styledComponentTarget.foldedComponentIds.concat(styledComponentTarget.styledComponentId)
: (EMPTY_ARRAY as string[]);
? joinStrings(styledComponentTarget.foldedComponentIds, styledComponentTarget.styledComponentId)
: '';

WrappedStyledComponent.styledComponentId = styledComponentId;

Expand Down
8 changes: 5 additions & 3 deletions packages/styled-components/src/test/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { mainSheet } from '../models/StyleSheetManager';
import { resetGroupIds } from '../sheet/GroupIDAllocator';
import { rehydrateSheet } from '../sheet/Rehydration';
import styledError from '../utils/error';
import { joinStringArray } from '../utils/joinStrings';

/* Ignore hashing, just return class names sequentially as .a .b .c etc */
let mockIndex = 0;
Expand Down Expand Up @@ -68,9 +69,10 @@ export const stripWhitespace = (str: string) =>
.replace(/\s+/g, ' ');

export const getCSS = (scope: Document | HTMLElement) =>
Array.from(scope.querySelectorAll('style'))
.map(tag => tag.innerHTML)
.join('\n')
joinStringArray(
Array.from(scope.querySelectorAll('style')).map(tag => tag.innerHTML),
'\n'
)
.replace(/ {/g, '{')
.replace(/:\s+/g, ':')
.replace(/:\s+;/g, ':;');
Expand Down
2 changes: 1 addition & 1 deletion packages/styled-components/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export interface IStyledStatics<R extends Runtime, OuterProps extends object>
extends CommonStatics<R, OuterProps> {
componentStyle: R extends 'web' ? ComponentStyle : never;
// this is here because we want the uppermost displayName retained in a folding scenario
foldedComponentIds: R extends 'web' ? Array<string> : never;
foldedComponentIds: R extends 'web' ? string : never;
inlineStyle: R extends 'native' ? InstanceType<IInlineStyleConstructor<OuterProps>> : never;
target: StyledTarget<R>;
styledComponentId: R extends 'web' ? string : never;
Expand Down
4 changes: 2 additions & 2 deletions packages/styled-components/src/utils/domElements.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Thanks to ReactDOMFactories for this handy list!

export default [
export default new Set([
'a',
'abbr',
'address',
Expand Down Expand Up @@ -136,4 +136,4 @@ export default [
'svg',
'text',
'tspan',
] as const;
] as const);
18 changes: 10 additions & 8 deletions packages/styled-components/src/utils/flatten.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,22 +24,24 @@ import isStyledComponent from './isStyledComponent';
const isFalsish = (chunk: any): chunk is undefined | null | false | '' =>
chunk === undefined || chunk === null || chunk === false || chunk === '';

export const objToCssArray = (obj: Dict<any>, prevKey?: string): string[] => {
export const objToCssArray = (obj: Dict<any>): string[] => {
const rules = [];

for (const key in obj) {
if (!obj.hasOwnProperty(key) || isFalsish(obj[key])) continue;
const val = obj[key];
if (!obj.hasOwnProperty(key) || isFalsish(val)) continue;

if ((Array.isArray(obj[key]) && obj[key].isCss) || isFunction(obj[key])) {
rules.push(`${hyphenate(key)}:`, obj[key], ';');
} else if (isPlainObject(obj[key])) {
rules.push(...objToCssArray(obj[key], key));
// @ts-expect-error Property 'isCss' does not exist on type 'any[]'
if ((Array.isArray(val) && val.isCss) || isFunction(val)) {
rules.push(`${hyphenate(key)}:`, val, ';');
} else if (isPlainObject(val)) {
rules.push(`${key} {`, ...objToCssArray(val), '}');
} else {
rules.push(`${hyphenate(key)}: ${addUnitIfNeeded(key, obj[key])};`);
rules.push(`${hyphenate(key)}: ${addUnitIfNeeded(key, val)};`);
}
}

return prevKey ? [`${prevKey} {`, ...rules, '}'] : rules;
return rules;
};

export default function flatten<Props extends object>(
Expand Down
31 changes: 19 additions & 12 deletions packages/styled-components/src/utils/hyphenateStyleName.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,4 @@
/**
* inlined version of
* https://github.com/facebook/fbjs/blob/master/packages/fbjs/src/core/hyphenateStyleName.js
*/
const uppercaseCheck = /[A-Z]/;
const uppercasePattern = /[A-Z]/g;
const msPattern = /^ms-/;
const prefixAndLowerCase = (char: string): string => `-${char.toLowerCase()}`;
const isUpper = (c: string) => c >= 'A' && c <= 'Z';

/**
* Hyphenates a camelcased CSS property name, for example:
Expand All @@ -20,8 +13,22 @@ const prefixAndLowerCase = (char: string): string => `-${char.toLowerCase()}`;
* As Modernizr suggests (http://modernizr.com/docs/#prefixed), an `ms` prefix
* is converted to `-ms-`.
*/
export default function hyphenateStyleName(string: string) {
return uppercaseCheck.test(string) && !string.startsWith('--')
? string.replace(uppercasePattern, prefixAndLowerCase).replace(msPattern, '-ms-')
: string;
export default function hyphenateStyleName(string: string): string {
let output = '';

for (let i = 0; i < string.length; i++) {
const c = string[i];
// Check for CSS variable prefix
if (i === 1 && c === '-' && string[0] === '-') {
return string;
}

if (isUpper(c)) {
output += '-' + c.toLowerCase();
} else {
output += c;
}
}

return output.startsWith('ms-') ? '-' + output : output;
}
14 changes: 13 additions & 1 deletion packages/styled-components/src/utils/joinStrings.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
/**
* Convenience function for joining strings to form className chains
*/
export default function joinStrings(a: string | undefined, b: string): string {
export function joinStrings(a: string | undefined, b: string): string {
return a && b ? `${a} ${b}` : a || b;
}

export function joinStringArray(arr: string[], sep?: string): string {
if (arr.length === 0) {
return '';
}

let result = arr[0];
for (let i = 1; i < arr.length; i++) {
result += sep ? sep + arr[i] : arr[i];
}
return result;
}
24 changes: 23 additions & 1 deletion packages/styled-components/src/utils/test/joinStrings.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import joinStrings from '../joinStrings';
import { joinStringArray, joinStrings } from '../joinStrings';

describe('joinStrings(string?, string?)', () => {
it('joins the two strings with a space between', () => {
Expand All @@ -15,3 +15,25 @@ describe('joinStrings(string?, string?)', () => {
expect(joinStrings('', 'b')).toBe('b');
});
});

describe('joinStringArray(string[], string?)', () => {
it('joins the strings with the separator between', () => {
expect(joinStringArray(['a', 'b'], ' ')).toBe('a b');
expect(joinStringArray(['a ', 'b'], ' ')).toBe('a b');
expect(joinStringArray(['a ', ' b'], ' ')).toBe('a b');
});

it('joins the strings with no separator when separator is falsy', () => {
expect(joinStringArray(['a', 'b'])).toBe('ab');
expect(joinStringArray(['a', 'b'], '')).toBe('ab');
});

it('returns the string unmodified if only one in array', () => {
expect(joinStringArray(['a'])).toBe('a');
expect(joinStringArray(['a'], ' ')).toBe('a');
});

it('returns an empty string for an empty array', () => {
expect(joinStringArray([])).toBe('');
});
});

0 comments on commit 4d4e63c

Please sign in to comment.