Skip to content

Commit c321da7

Browse files
authored
optimising ax and improving its clarity (#1820)
* optimising ax and improving clarity * adding comment * adding changeset * updating comments * further improving language in comments * removing misleading comment * further clarity for jsdoc * trying out objects * fixing up logic * wip * more optimised string creation * updating ax * updating test and bundle size values * adding more comments and test cases
1 parent 50d10a7 commit c321da7

File tree

5 files changed

+127
-65
lines changed

5 files changed

+127
-65
lines changed

.changeset/nasty-jars-roll.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@compiled/react': patch
3+
---
4+
5+
Reducing bundle size and improving runtime performance of the `ax` runtime function.
6+
7+
```ts
8+
import { ax } from '@compiled/react/runtime';
9+
```

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@
136136
},
137137
{
138138
"path": "./packages/react/dist/browser/runtime/ax.js",
139-
"limit": "195B",
139+
"limit": "169B",
140140
"import": "ax"
141141
},
142142
{

packages/react/src/runtime/__perf__/ax.test.ts

Lines changed: 45 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,42 +3,61 @@ import { runBenchmark } from '@compiled/benchmark';
33
import { ax } from '../index';
44

55
describe('ax benchmark', () => {
6-
const arr = [
7-
'_19itglyw',
8-
'_2rko1l7b',
9-
'_ca0qftgi',
10-
'_u5f319bv',
11-
'_n3tdftgi',
12-
'_19bv19bv',
13-
'_bfhk1mzw',
14-
'_syazu67f',
15-
'_k48p1nn1',
16-
'_ect41kw7',
17-
'_1wybdlk8',
18-
'_irr3mlcl',
19-
'_1di6vctu',
20-
// `undefined` is an acceptable parameter so we want to include it in the test case.
21-
// Example: ax(['aaaabbbb', foo() && "aaaacccc"])
22-
undefined,
6+
const chunks: string[] = ['aaaa', 'bbbb', 'cccc', 'dddd', 'eeee', 'ffff', 'gggg'];
7+
const uniques: string[] = chunks.map((chunk) => `_${chunk}${chunk}`);
8+
const withClashes: string[] = [
9+
...Array.from({ length: 4 }, () => `_${chunks[0]}${chunks[0]}`),
10+
...Array.from({ length: 6 }, () => `_${chunks[0]}${chunks[1]}`),
11+
...Array.from({ length: 8 }, () => `_${chunks[0]}${chunks[2]}`),
2312
];
2413

25-
it('completes with ax() string as the fastest', async () => {
26-
// Remove undefined and join the strings
27-
const str = arr.slice(0, -1).join(' ');
14+
const getRandomRules = (() => {
15+
function randomChunk() {
16+
return chunks[Math.floor(Math.random() * chunks.length)];
17+
}
18+
19+
return function create(): string[] {
20+
return Array.from({ length: 20 }, () => `_${randomChunk()}${randomChunk()}`);
21+
};
22+
})();
2823

24+
it('completes with ax() string as the fastest', async () => {
2925
const benchmark = await runBenchmark('ax', [
3026
{
31-
name: 'ax() array',
32-
fn: () => ax(arr),
27+
name: 'ax() single',
28+
fn: () => ax(['_aaaabbbb']),
29+
},
30+
{
31+
name: 'ax() uniques (array)',
32+
fn: () => ax(uniques),
33+
},
34+
{
35+
name: 'ax() uniques (as a string)',
36+
fn: () => ax([uniques.join(' ')]),
37+
},
38+
{
39+
name: 'ax() clashes',
40+
fn: () => ax(withClashes),
41+
},
42+
{
43+
name: 'ax() clashes (as a string)',
44+
fn: () => ax([withClashes.join(' ')]),
45+
},
46+
{
47+
name: 'ax() random keys (no clashes)',
48+
fn: () => ax(getRandomRules()),
3349
},
3450
{
35-
name: 'ax() string',
36-
fn: () => ax([str, undefined]),
51+
name: 'ax() random keys (with clashes)',
52+
fn: () => {
53+
const random = getRandomRules();
54+
ax([...random, ...random, ...random]);
55+
},
3756
},
3857
]);
3958

4059
expect(benchmark).toMatchObject({
41-
fastest: ['ax() string'],
60+
fastest: ['ax() single'],
4261
});
43-
}, 30000);
62+
}, 90000);
4463
});

packages/react/src/runtime/__tests__/ax.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ describe('ax', () => {
66
it.each([
77
['should handle empty array', [], undefined],
88
['should handle array with undefined', [undefined], undefined],
9+
['should handle array with falsy values', [undefined, null, false as const, ''], undefined],
910
['should join single classes together', ['foo', 'bar'], 'foo bar'],
1011
['should join multi classes together', ['foo baz', 'bar'], 'foo baz bar'],
1112
['should remove undefined', ['foo', 'bar', undefined], 'foo bar'],
@@ -50,7 +51,8 @@ describe('ax', () => {
5051
['hello_there', 'hello_world', '_aaaabbbb'],
5152
'hello_there hello_world _aaaabbbb',
5253
],
53-
])('%s', (_, params, result) => {
54-
expect(result).toEqual(ax(params));
54+
['should remove duplicate custom class names', ['a', 'a'], 'a'],
55+
])('%s', (_, params, expected) => {
56+
expect(ax(params)).toEqual(expected);
5557
});
5658
});

packages/react/src/runtime/ax.ts

Lines changed: 68 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,96 @@
1-
const UNDERSCORE_UNICODE = 95;
2-
31
/**
42
* This length includes the underscore,
53
* e.g. `"_1s4A"` would be a valid atomic group hash.
64
*/
75
const ATOMIC_GROUP_LENGTH = 5;
86

97
/**
10-
* Joins classes together and ensures atomic declarations of a single group exist.
11-
* Atomic declarations take the form of `_{group}{value}` (always prefixed with an underscore),
12-
* where both `group` and `value` are hashes **four characters long**.
13-
* Class names can be of any length,
14-
* this function can take both atomic declarations and class names.
15-
*
16-
* Input:
8+
* Create a single string containing all the classnames provided, separated by a space (`" "`).
9+
* The result will only contain the _last_ atomic style classname for each atomic `group`.
1710
*
18-
* ```
19-
* ax(['_aaaabbbb', '_aaaacccc'])
11+
* ```ts
12+
* ax(['_aaaabbbb', '_aaaacccc']);
13+
* // output
14+
* '_aaaacccc'
2015
* ```
2116
*
22-
* Output:
17+
* Format of Atomic style classnames: `_{group}{value}` (`_\w{4}\w{4}`)
2318
*
24-
* ```
25-
* '_aaaacccc'
26-
* ```
19+
* `ax` will preserve any non atomic style classnames (eg `"border-red"`)
2720
*
28-
* @param classes
21+
* ```ts
22+
* ax(['_aaaabbbb', '_aaaacccc', 'border-red']);
23+
* // output
24+
* '_aaaacccc border-red'
25+
* ```
2926
*/
3027
export default function ax(classNames: (string | undefined | null | false)[]): string | undefined {
31-
if (classNames.length <= 1 && (!classNames[0] || classNames[0].indexOf(' ') === -1)) {
32-
// short circuit if there's no custom class names.
33-
return classNames[0] || undefined;
28+
// Shortcut: nothing to do
29+
if (!classNames.length) {
30+
return;
3431
}
3532

36-
const atomicGroups: Record<string, string> = {};
33+
// Shortcut: don't need to do anything if we only have a single classname
34+
if (
35+
classNames.length === 1 &&
36+
classNames[0] &&
37+
// checking to see if `classNames[0]` is a string that contains other classnames
38+
!classNames[0].includes(' ')
39+
) {
40+
return classNames[0];
41+
}
3742

38-
for (let i = 0; i < classNames.length; i++) {
39-
const cls = classNames[i];
40-
if (!cls) {
43+
// Using an object rather than a `Map` as it performed better in our benchmarks.
44+
// Would be happy to move to `Map` if it proved to be better under real conditions.
45+
const map: Record<string, string> = {};
46+
47+
// Note: using loops to minimize iterations over the collection
48+
for (const value of classNames) {
49+
// Exclude all falsy values, which leaves us with populated strings
50+
if (!value) {
4151
continue;
4252
}
4353

44-
const groups = cls.split(' ');
54+
// a `value` can contain multiple classnames
55+
const list = value.split(' ');
4556

46-
for (let x = 0; x < groups.length; x++) {
47-
const atomic = groups[x];
48-
const atomicGroupName = atomic.slice(
49-
0,
50-
atomic.charCodeAt(0) === UNDERSCORE_UNICODE ? ATOMIC_GROUP_LENGTH : undefined
51-
);
52-
atomicGroups[atomicGroupName] = atomic;
57+
for (const className of list) {
58+
/**
59+
* For atomic style classnames: the `key` is the `group`
60+
*
61+
* - Later atomic classnames with the same `group` will override earlier ones
62+
* (which is what we want).
63+
* - Assumes atomic classnames are the only things that start with `_`
64+
* - Could use a regex to ensure that atomic classnames are structured how we expect,
65+
* but did not add that for now as it did slow things down a bit.
66+
*
67+
* For other classnames: the `key` is the whole classname
68+
* - Okay to remove duplicates as doing so does not impact specificity
69+
*
70+
* */
71+
const key = className.startsWith('_') ? className.slice(0, ATOMIC_GROUP_LENGTH) : className;
72+
map[key] = className;
5373
}
5474
}
5575

56-
let str = '';
76+
/**
77+
* We are converting the `map` into a string.
78+
*
79+
* The simple way to do this would be `Object.values(map).join(' ')`.
80+
* However, the approach below performs 10%-20% better in benchmarks.
81+
*
82+
* For `ax()` it feels right to squeeze as much runtime performance out as we can.
83+
*/
84+
let result: string = '';
85+
for (const key in map) {
86+
result += map[key] + ' ';
87+
}
5788

58-
for (const key in atomicGroups) {
59-
const value = atomicGroups[key];
60-
str += value + ' ';
89+
// If we have an empty string, then our `map` was empty.
90+
if (!result) {
91+
return;
6192
}
6293

63-
return str.slice(0, -1);
94+
// remove last " " from the result (we added " " at the end of every value)
95+
return result.trimEnd();
6496
}

0 commit comments

Comments
 (0)