Skip to content

Commit 5465bff

Browse files
authored
Size shaves (#620)
* perf: shave bytes from core and global addon Four perf-gated source changes that drop -12B gzipped on dist/goober.cjs and -10B gzipped on dist/goober.modern.js, plus -2B on each of the global addon bundles. Modern build is now safely under the 1300B filesize cap (was 3B over). Changes: - src/core/parse.js: `/^--/.test(key)` -> `key[1] == '-'` for the CSS custom-property check. Behaviorally identical (custom props are the only keys with '-' as the second char; vendor prefixes start with '-name-' so position 1 is alphanumeric). Cheaper than regex.test. - src/core/hash.js: `global && cache.g ? cache.g : null` -> just `global && cache.g`. The downstream `update` does a truthy check on this value, so null/undefined/false are equivalent. Bonus: this consistently shows +4.6 to +6.3% on the css:tagged hit hot path across reruns (V8 specializes the simpler shape better). - src/styled.js: `/ *go\\d+/` -> `/go\\d/` for the existing-className detection. The leading-space anchor was unnecessary; classNames are space-tokenized, so any goober class match is sufficient. - global/src/index.js: convert `function GlobalStyles(props) { fn(props); return null; }` to `(props) => (fn(props), null)`. Identical semantics; ~15B raw / 2B gz smaller after minification. Test impact: - src/core/__tests__/hash.test.js: 5 assertions updated from `null` to `undefined` to match the new cssToReplace value. update() treats both as falsy in its ternary, so no observable behavior change. Verified: - npm run test-unit-core: 12 suites / 93 tests pass. - npm run test-ts: types clean. - npm run test-perf: goober still fastest vs styled-components 5.2.1 and emotion 11.0.0 on object/tagged/array suites. - AB compare bench across 3 reruns of 5 driver iterations: no regression beyond noise floor on render or css:object hit; consistent +4.8% win on css:tagged hit (cjs and modern). * test: add parse-path microbench and A/B compare harness Adds focused benchmarks that complement the existing perf.cjs (which compares against styled-components and emotion). These exercise just the parse / cache hot path and support pair-wise A/B comparison between two dist builds in the same Benchmark.js Suite to eliminate cross-run system drift. - benchmarks/perf-parse.cjs: cache-hit benches for object / tagged / big variants, plus direct astish, parse, and toHash microbenches imported from src/. Standalone — useful for spotting regressions in the parse path without React render overhead. - benchmarks/perf-parse.modern.cjs: mirror running against dist/goober.modern.js via dynamic import. - benchmarks/perf.compare.cjs: A/B render + css-hit bench. Loads two dist directories side-by-side and alternates tests in one Suite, so any system noise hits both versions equally. Used to validate that size shaves don't regress runtime. - benchmarks/perf.compare.modern.cjs: A/B variant for the modern dist. - benchmarks/run-ab.cjs: driver that runs an A/B bench file N times and emits aggregated medians + ranges as JSON. Useful for CI perf-gating in future PRs. .gitignore: ignore benchmarks/results/ output dir. These were the harness that drove the byte-shave investigation and remain available for follow-up size or perf work.
1 parent da31b8b commit 5465bff

11 files changed

Lines changed: 577 additions & 20 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@ dist
33
debug
44
sandbox
55
coverage
6-
.cache
6+
.cache
7+
benchmarks/results

benchmarks/perf-parse.cjs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
const Benchmark = require('benchmark');
2+
3+
// Parse-path benchmarks.
4+
// Strategy:
5+
// 1. css:hit paths (object & tagged) — cache hit, exercises hash lookup
6+
// 2. astish + parse called directly on src/ via ESM dynamic import to
7+
// measure the parse hot path without cache pollution
8+
const goober = require('../dist/goober.cjs');
9+
goober.setup(() => null);
10+
11+
const hitObj = {
12+
color: 'red',
13+
padding: '8px 16px',
14+
fontSize: 14,
15+
opacity: 0.5,
16+
'&:hover': { color: 'peachpuff' }
17+
};
18+
const hitObjBig = {
19+
color: 'peachpuff',
20+
background: 'dodgerblue',
21+
padding: '4px 8px',
22+
borderRadius: 4,
23+
fontSize: 14,
24+
transition: 'all 200ms ease',
25+
'@media (min-width: 640px)': { fontSize: 16, padding: '8px 16px' },
26+
'@media (min-width: 1024px)': { fontSize: 18, padding: '12px 24px' },
27+
'&:hover': {
28+
background: 'peachpuff',
29+
color: 'dodgerblue',
30+
transform: 'translateY(-1px)'
31+
},
32+
'&.is-active,&.is-pinned': { fontWeight: 600, textDecoration: 'underline' },
33+
'.child': { opacity: 0.8, marginLeft: 8 }
34+
};
35+
goober.css(hitObj);
36+
goober.css(hitObjBig);
37+
goober.css`color: red; padding: 4px;`;
38+
39+
const taggedBig = `
40+
color: var(--c, peachpuff);
41+
background: dodgerblue;
42+
padding: 4px 8px;
43+
border: 1px solid red;
44+
border-radius: 4px;
45+
font-size: 14px;
46+
line-height: 1.4;
47+
transition: all 200ms ease;
48+
transform: translateZ(0);
49+
will-change: transform;
50+
51+
@media (min-width: 640px) {
52+
font-size: 16px;
53+
padding: 8px 16px;
54+
}
55+
56+
&:hover {
57+
background: peachpuff;
58+
color: dodgerblue;
59+
transform: translateY(-1px);
60+
}
61+
62+
&.is-active,
63+
&.is-pinned {
64+
font-weight: 600;
65+
text-decoration: underline;
66+
}
67+
68+
.child:has(input, select) {
69+
color: red;
70+
}
71+
`;
72+
// Pre-warm: tagged big using a stable tag function so V8 reuses the array
73+
function taggedBigCall() {
74+
return goober.css`${taggedBig}`;
75+
}
76+
taggedBigCall();
77+
78+
(async () => {
79+
// Pull internals directly from source for parse-path microbench.
80+
const astishMod = await import('../src/core/astish.js');
81+
const parseMod = await import('../src/core/parse.js');
82+
const toHashMod = await import('../src/core/to-hash.js');
83+
84+
const { astish } = astishMod;
85+
const { parse } = parseMod;
86+
const { toHash } = toHashMod;
87+
88+
const obj = {
89+
color: 'peachpuff',
90+
background: 'dodgerblue',
91+
padding: '4px 8px',
92+
borderRadius: 4,
93+
fontSize: 14,
94+
transition: 'all 200ms ease',
95+
'@media (min-width: 640px)': { fontSize: 16, padding: '8px 16px' },
96+
'@media (min-width: 1024px)': { fontSize: 18, padding: '12px 24px' },
97+
'&:hover': {
98+
background: 'peachpuff',
99+
color: 'dodgerblue',
100+
transform: 'translateY(-1px)'
101+
},
102+
'&.is-active,&.is-pinned': {
103+
fontWeight: 600,
104+
textDecoration: 'underline'
105+
},
106+
'.child': { opacity: 0.8, marginLeft: 8 }
107+
};
108+
const longStr = 'background:red;color:black;padding:4px 8px;border:1px solid blue;font-size:14px;';
109+
110+
const suite = new Benchmark.Suite('PARSE!');
111+
suite
112+
.add('css:object hit', function () {
113+
goober.css(hitObj);
114+
})
115+
.add('css:object big hit', function () {
116+
goober.css(hitObjBig);
117+
})
118+
.add('css:tagged hit', function () {
119+
goober.css`color: red; padding: 4px;`;
120+
})
121+
.add('css:tagged big hit', function () {
122+
taggedBigCall();
123+
})
124+
.add('astish:big tagged', function () {
125+
astish(taggedBig);
126+
})
127+
.add('parse:big object', function () {
128+
parse(obj, '.x');
129+
})
130+
.add('toHash:long', function () {
131+
toHash(longStr);
132+
})
133+
.on('start', function (e) {
134+
console.log('\nStarting:', e.currentTarget.name);
135+
})
136+
.on('error', (e) => console.log(e))
137+
.on('cycle', function (event) {
138+
console.log('▸', String(event.target));
139+
})
140+
.on('complete', function () {
141+
const fastest = this.filter('fastest').map('name')[0];
142+
console.log('\nFastest is: ' + fastest);
143+
})
144+
.run({ async: true });
145+
})();

benchmarks/perf-parse.modern.cjs

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
const Benchmark = require('benchmark');
2+
3+
let goober;
4+
5+
const hitObj = {
6+
color: 'red',
7+
padding: '8px 16px',
8+
fontSize: 14,
9+
opacity: 0.5,
10+
'&:hover': { color: 'peachpuff' }
11+
};
12+
const hitObjBig = {
13+
color: 'peachpuff',
14+
background: 'dodgerblue',
15+
padding: '4px 8px',
16+
borderRadius: 4,
17+
fontSize: 14,
18+
transition: 'all 200ms ease',
19+
'@media (min-width: 640px)': { fontSize: 16, padding: '8px 16px' },
20+
'@media (min-width: 1024px)': { fontSize: 18, padding: '12px 24px' },
21+
'&:hover': {
22+
background: 'peachpuff',
23+
color: 'dodgerblue',
24+
transform: 'translateY(-1px)'
25+
},
26+
'&.is-active,&.is-pinned': { fontWeight: 600, textDecoration: 'underline' },
27+
'.child': { opacity: 0.8, marginLeft: 8 }
28+
};
29+
30+
const taggedBig = `
31+
color: var(--c, peachpuff);
32+
background: dodgerblue;
33+
padding: 4px 8px;
34+
border: 1px solid red;
35+
border-radius: 4px;
36+
font-size: 14px;
37+
line-height: 1.4;
38+
transition: all 200ms ease;
39+
transform: translateZ(0);
40+
will-change: transform;
41+
42+
@media (min-width: 640px) {
43+
font-size: 16px;
44+
padding: 8px 16px;
45+
}
46+
47+
&:hover {
48+
background: peachpuff;
49+
color: dodgerblue;
50+
transform: translateY(-1px);
51+
}
52+
53+
&.is-active,
54+
&.is-pinned {
55+
font-weight: 600;
56+
text-decoration: underline;
57+
}
58+
59+
.child:has(input, select) {
60+
color: red;
61+
}
62+
`;
63+
64+
(async () => {
65+
const ns = await import('../dist/goober.modern.js');
66+
goober = {
67+
styled: ns.styled,
68+
css: ns.css,
69+
setup: ns.setup,
70+
keyframes: ns.keyframes,
71+
glob: ns.glob,
72+
extractCss: ns.extractCss
73+
};
74+
goober.setup(() => null);
75+
76+
goober.css(hitObj);
77+
goober.css(hitObjBig);
78+
goober.css`color: red; padding: 4px;`;
79+
function taggedBigCall() {
80+
return goober.css`${taggedBig}`;
81+
}
82+
taggedBigCall();
83+
84+
const suite = new Benchmark.Suite('PARSE-modern!');
85+
suite
86+
.add('css:object hit', function () {
87+
goober.css(hitObj);
88+
})
89+
.add('css:object big hit', function () {
90+
goober.css(hitObjBig);
91+
})
92+
.add('css:tagged hit', function () {
93+
goober.css`color: red; padding: 4px;`;
94+
})
95+
.add('css:tagged big hit', function () {
96+
taggedBigCall();
97+
})
98+
.on('start', function (e) {
99+
console.log('\nStarting:', e.currentTarget.name);
100+
})
101+
.on('error', (e) => console.log(e))
102+
.on('cycle', function (event) {
103+
console.log('▸', String(event.target));
104+
})
105+
.on('complete', function () {
106+
const fastest = this.filter('fastest').map('name')[0];
107+
console.log('\nFastest is: ' + fastest);
108+
})
109+
.run({ async: true });
110+
})();

benchmarks/perf.compare.cjs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Side-by-side A/B bench: loads two dist files (baseline & candidate) and
2+
// alternates between them in the same suite to eliminate cross-run drift.
3+
//
4+
// Usage: node benchmarks/perf.compare.cjs <baseline-dir> <candidate-dir>
5+
//
6+
// Each "dir" must contain goober.cjs.
7+
const Benchmark = require('benchmark');
8+
const path = require('path');
9+
10+
const baselineDir = path.resolve(process.argv[2] || './dist-baseline');
11+
const candidateDir = path.resolve(process.argv[3] || './dist');
12+
13+
const A = require(path.join(baselineDir, 'goober.cjs'));
14+
const B = require(path.join(candidateDir, 'goober.cjs'));
15+
16+
const react = require('react');
17+
const { renderToString: render } = require('react-dom/server');
18+
19+
A.setup(react.createElement);
20+
B.setup(react.createElement);
21+
22+
const inner = (props) => ({
23+
opacity: props.counter > 0.5 ? 1 : 0,
24+
'@media (min-width: 1px)': { rule: 'all' },
25+
'&:hover': { another: 1, display: 'space' }
26+
});
27+
const tagged = `
28+
opacity: ${(props) => (props.counter > 0.5 ? 1 : 0)};
29+
@media (min-width: 1px) { rule: all; }
30+
&:hover { another: 1; display: space; }
31+
`;
32+
const arr = (props) => [
33+
{
34+
opacity: 0,
35+
'@media (min-width: 1px)': { rule: 'all' },
36+
'&:hover': { another: 1, display: 'space' }
37+
},
38+
props.counter > 0.5 && { opacity: 1 }
39+
];
40+
41+
const A_obj = A.styled('div')(inner);
42+
const A_tagged = A.styled('div')`${tagged}`;
43+
const A_array = A.styled('div')(arr);
44+
45+
const B_obj = B.styled('div')(inner);
46+
const B_tagged = B.styled('div')`${tagged}`;
47+
const B_array = B.styled('div')(arr);
48+
49+
function r(Foo) {
50+
render(react.createElement(Foo, { counter: Math.random() }));
51+
}
52+
53+
// Hit-path for parse benchmarks
54+
const hitObj = {
55+
color: 'red',
56+
padding: '8px 16px',
57+
fontSize: 14,
58+
opacity: 0.5,
59+
'&:hover': { color: 'peachpuff' }
60+
};
61+
A.css(hitObj);
62+
B.css(hitObj);
63+
A.css`color: red; padding: 4px;`;
64+
B.css`color: red; padding: 4px;`;
65+
66+
const suite = new Benchmark.Suite('AB');
67+
suite
68+
.add('A:render:object', () => r(A_obj))
69+
.add('B:render:object', () => r(B_obj))
70+
.add('A:render:tagged', () => r(A_tagged))
71+
.add('B:render:tagged', () => r(B_tagged))
72+
.add('A:render:array', () => r(A_array))
73+
.add('B:render:array', () => r(B_array))
74+
.add('A:css:object hit', () => A.css(hitObj))
75+
.add('B:css:object hit', () => B.css(hitObj))
76+
.add('A:css:tagged hit', () => A.css`color: red; padding: 4px;`)
77+
.add('B:css:tagged hit', () => B.css`color: red; padding: 4px;`)
78+
.on('cycle', (e) => console.log('▸', String(e.target)))
79+
.on('complete', function () {
80+
// Pair-wise comparison
81+
const results = {};
82+
this.forEach((b) => {
83+
const m = b.name.match(/^([AB]):(.+)$/);
84+
const [, ab, name] = m;
85+
if (!results[name]) results[name] = {};
86+
results[name][ab] = { ops: b.hz, rme: b.stats.rme };
87+
});
88+
console.log('\n--- A vs B ---');
89+
const json = { tests: [] };
90+
for (const [name, { A: a, B: b }] of Object.entries(results)) {
91+
const delta = ((b.ops - a.ops) / a.ops) * 100;
92+
const noise = Math.max(a.rme, b.rme);
93+
console.log(
94+
name.padEnd(22) +
95+
Math.round(a.ops).toLocaleString().padStart(13) +
96+
' -> ' +
97+
Math.round(b.ops).toLocaleString().padStart(13) +
98+
' Δ=' +
99+
delta.toFixed(2).padStart(6) +
100+
'% ±' +
101+
noise.toFixed(2).padStart(4) +
102+
'%'
103+
);
104+
json.tests.push({
105+
name,
106+
a: Math.round(a.ops),
107+
b: Math.round(b.ops),
108+
delta: +delta.toFixed(2),
109+
noise: +noise.toFixed(2)
110+
});
111+
}
112+
console.log('\nJSON:' + JSON.stringify(json));
113+
})
114+
.run();

0 commit comments

Comments
 (0)