Skip to content

Commit 8ba39d6

Browse files
committed
chore: add batching test
1 parent 9b3e291 commit 8ba39d6

File tree

2 files changed

+312
-0
lines changed

2 files changed

+312
-0
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`tasty batching snapshot: demonstrates batching in CSS output 1`] = `
4+
":is(.t7, .t8):hover {cursor: pointer; opacity: 0.8;}
5+
:is(.t2, .t7) {--current-color-rgb: var(--red-color-rgb); --current-color: var(--red-color, red); color: var(--red-color); display: block;}
6+
:is(.t8) {--current-color-rgb: var(--blue-color-rgb); --current-color: var(--blue-color, blue); color: var(--blue-color); display: flex;}"
7+
`;

src/tasty/batching.test.tsx

Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
import { render } from '@testing-library/react';
2+
3+
import { Block } from '../components/Block';
4+
5+
import { injector } from './injector';
6+
import { tasty } from './tasty';
7+
8+
describe('tasty batching', () => {
9+
it('batches identical selector+declarations across classes', () => {
10+
const A = tasty(Block, {
11+
styles: {
12+
cursor: {
13+
'[disabled]': 'default',
14+
},
15+
},
16+
});
17+
const B = tasty(Block, {
18+
styles: {
19+
cursor: {
20+
'[disabled]': 'default',
21+
},
22+
// Make classes different overall but keep the [disabled] rule identical
23+
color: '#text',
24+
},
25+
});
26+
27+
const { container, rerender } = render(
28+
<div>
29+
<A qa="a" />
30+
<B qa="b" />
31+
</div>,
32+
);
33+
34+
const aEl = container.querySelector('[data-qa="a"]') as Element;
35+
const bEl = container.querySelector('[data-qa="b"]') as Element;
36+
const aClass = (aEl.getAttribute('class') || '')
37+
.split(/\s+/)
38+
.find((c) => /^t\d+$/.test(c)) as string;
39+
const bClass = (bEl.getAttribute('class') || '')
40+
.split(/\s+/)
41+
.find((c) => /^t\d+$/.test(c)) as string;
42+
43+
expect(aClass).toBeTruthy();
44+
expect(bClass).toBeTruthy();
45+
46+
const css = injector.instance.getCssTextForClasses([aClass, bClass]);
47+
// Expect combined :is selector with both classes and doubled specificity
48+
const classesPattern = `:is\\((?:\\.${aClass}|\\.${bClass})(?:, \\.(?:${aClass}|${bClass}))*\\)\\[disabled\\]`;
49+
const singleIsPattern = new RegExp(classesPattern);
50+
expect(css).toMatch(singleIsPattern);
51+
52+
// Now remove B and expect the rule to still contain both classes until cleanup
53+
rerender(
54+
<div>
55+
<A qa="a" />
56+
</div>,
57+
);
58+
59+
// With the new unmarking approach, both classes remain in CSS until bulk cleanup
60+
const cssAfter = injector.instance.getCssTextForClasses([aClass, bClass]);
61+
const stillBothPattern = new RegExp(
62+
`:is\\(\\.${aClass}, \\.${bClass}\\)\\[disabled\\]`,
63+
);
64+
expect(cssAfter).toMatch(stillBothPattern);
65+
66+
// Force cleanup to see the rule shrink
67+
injector.instance.forceBulkCleanup();
68+
const cssAfterCleanup = injector.instance.getCssTextForClasses([aClass]);
69+
const singleAfterPattern = new RegExp(
70+
`:is\\(\\.${aClass}\\)\\[disabled\\]`,
71+
);
72+
expect(cssAfterCleanup).toMatch(singleAfterPattern);
73+
});
74+
75+
it('batches identical atomic rules across different components', () => {
76+
// Two different components that share one identical atomic rule
77+
const ComponentA = tasty(Block, {
78+
styles: {
79+
color: '#red',
80+
ButtonIcon: {
81+
maxWidth: 'initial',
82+
minWidth: 'var(--font-size)',
83+
width: 'auto',
84+
},
85+
},
86+
});
87+
const ComponentB = tasty(Block, {
88+
styles: {
89+
color: '#blue', // different from A
90+
padding: '1x', // different from A
91+
ButtonIcon: {
92+
maxWidth: 'initial', // same as A
93+
minWidth: 'var(--font-size)', // same as A
94+
width: 'auto', // same as A
95+
},
96+
},
97+
});
98+
99+
const { container } = render(
100+
<div>
101+
<ComponentA qa="comp-a" />
102+
<ComponentB qa="comp-b" />
103+
</div>,
104+
);
105+
106+
const aEl = container.querySelector('[data-qa="comp-a"]') as Element;
107+
const bEl = container.querySelector('[data-qa="comp-b"]') as Element;
108+
const aClass = (aEl.getAttribute('class') || '')
109+
.split(/\s+/)
110+
.find((c) => /^t\d+$/.test(c)) as string;
111+
const bClass = (bEl.getAttribute('class') || '')
112+
.split(/\s+/)
113+
.find((c) => /^t\d+$/.test(c)) as string;
114+
115+
expect(aClass).toBeTruthy();
116+
expect(bClass).toBeTruthy();
117+
expect(aClass).not.toBe(bClass); // Should be different classes
118+
119+
const css = injector.instance.getCssText();
120+
121+
// Should have a batched ButtonIcon rule with both classes
122+
const buttonIconPattern = new RegExp(
123+
`:is\\(\\.${aClass}, \\.${bClass}\\)\\s*\\[data-element="ButtonIcon"\\]`,
124+
);
125+
expect(css).toMatch(buttonIconPattern);
126+
127+
// Verify both classes participate in the same batched group
128+
const aBatches = (injector.instance as any).sheetManager
129+
.getRegistry(document)
130+
.classToBatchedKeys.get(aClass);
131+
const bBatches = (injector.instance as any).sheetManager
132+
.getRegistry(document)
133+
.classToBatchedKeys.get(bClass);
134+
135+
expect(aBatches).toBeTruthy();
136+
expect(bBatches).toBeTruthy();
137+
138+
// Find shared batch key for ButtonIcon rule
139+
const sharedKeys = [...(aBatches || [])].filter(
140+
(key) => (bBatches || new Set()).has(key) && key.includes('ButtonIcon'),
141+
);
142+
expect(sharedKeys.length).toBeGreaterThan(0);
143+
144+
// Verify the actual CSS contains batched selectors
145+
const allCSS = injector.instance.getCssText();
146+
147+
// Should contain a batched selector with both classes for the ButtonIcon rule
148+
const batchedButtonIconPattern = new RegExp(
149+
`:is\\(\\.${aClass}, \\.${bClass}\\)\\s*\\[data-element="ButtonIcon"\\]`,
150+
's',
151+
);
152+
expect(allCSS).toMatch(batchedButtonIconPattern);
153+
154+
// The ButtonIcon styles should only appear once in the CSS (batched, not duplicated)
155+
const buttonIconRuleMatches = (
156+
allCSS.match(/\[data-element="ButtonIcon"\]/g) || []
157+
).length;
158+
expect(buttonIconRuleMatches).toBe(1);
159+
});
160+
161+
it('prevents duplicate identical CSS rules when same component rendered multiple times', () => {
162+
const Component = tasty({
163+
styles: {
164+
display: 'block',
165+
color: '#red',
166+
padding: '1x',
167+
},
168+
});
169+
170+
// Render the same component multiple times
171+
const { container: containerA } = render(<Component qa="comp-a" />);
172+
const { container: containerB } = render(<Component qa="comp-b" />);
173+
const { container: containerC } = render(<Component qa="comp-c" />);
174+
175+
const aEl = containerA.querySelector('[data-qa="comp-a"]') as Element;
176+
const bEl = containerB.querySelector('[data-qa="comp-b"]') as Element;
177+
const cEl = containerC.querySelector('[data-qa="comp-c"]') as Element;
178+
179+
const aClass = (aEl.getAttribute('class') || '')
180+
.split(/\s+/)
181+
.find((c) => /^t\d+$/.test(c)) as string;
182+
const bClass = (bEl.getAttribute('class') || '')
183+
.split(/\s+/)
184+
.find((c) => /^t\d+$/.test(c)) as string;
185+
const cClass = (cEl.getAttribute('class') || '')
186+
.split(/\s+/)
187+
.find((c) => /^t\d+$/.test(c)) as string;
188+
189+
// All should have the same class since they're identical
190+
expect(aClass).toBe(bClass);
191+
expect(bClass).toBe(cClass);
192+
193+
const allCSS = injector.instance.getCssText();
194+
195+
// Check that we have batched rules properly
196+
const classRuleMatches = (
197+
allCSS.match(new RegExp(`:is\\(\\.${aClass}\\)`, 'g')) || []
198+
).length;
199+
200+
// Should have one rule per distinct style declaration, but they should be batched
201+
// The exact count might vary based on how styles are grouped, so let's just verify no excessive duplication
202+
expect(classRuleMatches).toBeGreaterThan(0);
203+
expect(classRuleMatches).toBeLessThan(6); // Should not have excessive duplication
204+
205+
// More importantly, verify the CSS contains the expected class
206+
expect(allCSS).toContain(`:is(.${aClass})`);
207+
expect(allCSS).toContain('var(--red-color)');
208+
});
209+
210+
it('truly batches multiple classes with identical individual rules', () => {
211+
// Create components with complex styles where only some individual rules are identical
212+
const ComponentA = tasty({
213+
styles: {
214+
display: 'block',
215+
color: '#red',
216+
padding: '1x',
217+
// Add a nested rule that could be batched
218+
'&:hover': {
219+
cursor: 'pointer',
220+
},
221+
},
222+
});
223+
224+
const ComponentB = tasty({
225+
styles: {
226+
display: 'flex', // Different from A
227+
color: '#blue', // Different from A
228+
margin: '1x', // Different from A
229+
// Same hover rule as A - this should be batched
230+
'&:hover': {
231+
cursor: 'pointer',
232+
},
233+
},
234+
});
235+
236+
const { container } = render(
237+
<div>
238+
<ComponentA qa="comp-a" />
239+
<ComponentB qa="comp-b" />
240+
</div>,
241+
);
242+
243+
const aEl = container.querySelector('[data-qa="comp-a"]') as Element;
244+
const bEl = container.querySelector('[data-qa="comp-b"]') as Element;
245+
246+
const aClass = (aEl.getAttribute('class') || '')
247+
.split(/\s+/)
248+
.find((c) => /^t\d+$/.test(c)) as string;
249+
const bClass = (bEl.getAttribute('class') || '')
250+
.split(/\s+/)
251+
.find((c) => /^t\d+$/.test(c)) as string;
252+
253+
expect(aClass).toBeTruthy();
254+
expect(bClass).toBeTruthy();
255+
expect(aClass).not.toBe(bClass); // Must be different classes
256+
257+
const allCSS = injector.instance.getCssText();
258+
259+
// Look for batched hover rule with both classes
260+
const batchedHoverPattern = new RegExp(
261+
`:is\\(\\.${aClass}, \\.${bClass}\\):hover[^{]*{[^}]*cursor:\\s*pointer`,
262+
's',
263+
);
264+
expect(allCSS).toMatch(batchedHoverPattern);
265+
266+
// Should only have one occurrence of "cursor: pointer"
267+
const cursorPointerMatches = (allCSS.match(/cursor:\s*pointer/g) || [])
268+
.length;
269+
expect(cursorPointerMatches).toBe(1);
270+
});
271+
272+
it('snapshot: demonstrates batching in CSS output', () => {
273+
const ComponentA = tasty({
274+
styles: {
275+
display: 'block',
276+
color: '#red',
277+
'&:hover': {
278+
cursor: 'pointer',
279+
opacity: '0.8',
280+
},
281+
},
282+
});
283+
284+
const ComponentB = tasty({
285+
styles: {
286+
display: 'flex',
287+
color: '#blue',
288+
// Same hover rule as A - should be batched
289+
'&:hover': {
290+
cursor: 'pointer',
291+
opacity: '0.8',
292+
},
293+
},
294+
});
295+
296+
const { container } = render(
297+
<div>
298+
<ComponentA qa="comp-a" />
299+
<ComponentB qa="comp-b" />
300+
</div>,
301+
);
302+
303+
expect(container.firstChild).toMatchTastySnapshot();
304+
});
305+
});

0 commit comments

Comments
 (0)