@pyreon/styler 0.11.1 → 0.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -5
- package/src/ThemeProvider.ts +37 -0
- package/src/__tests__/ThemeProvider.test.ts +67 -0
- package/src/__tests__/benchmark.bench.ts +189 -0
- package/src/__tests__/composition-chain.test.ts +489 -0
- package/src/__tests__/css.test.ts +70 -0
- package/src/__tests__/forward.test.ts +282 -0
- package/src/__tests__/globalStyle.test.ts +72 -0
- package/src/__tests__/hash.test.ts +70 -0
- package/src/__tests__/hybrid-injection.test.ts +205 -0
- package/src/__tests__/index.ts +14 -0
- package/src/__tests__/insertion-effect.test.ts +106 -0
- package/src/__tests__/integration.test.ts +149 -0
- package/src/__tests__/keyframes.test.ts +68 -0
- package/src/__tests__/memory-growth.test.ts +152 -0
- package/src/__tests__/p3-features.test.ts +258 -0
- package/src/__tests__/resolve.test.ts +249 -0
- package/src/__tests__/shared.test.ts +73 -0
- package/src/__tests__/sheet-advanced.test.ts +669 -0
- package/src/__tests__/sheet-split-atrules.test.ts +411 -0
- package/src/__tests__/sheet.test.ts +164 -0
- package/src/__tests__/styled-ssr.test.ts +67 -0
- package/src/__tests__/styled.test.ts +303 -0
- package/src/__tests__/theme.test.ts +33 -0
- package/src/__tests__/useCSS.test.ts +142 -0
- package/src/css.ts +13 -0
- package/src/forward.ts +276 -0
- package/src/globalStyle.ts +48 -0
- package/src/hash.ts +30 -0
- package/src/index.ts +15 -0
- package/src/keyframes.ts +36 -0
- package/src/resolve.ts +172 -0
- package/src/shared.ts +12 -0
- package/src/sheet.ts +387 -0
- package/src/styled.tsx +277 -0
- package/src/useCSS.ts +20 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
|
2
|
+
import { hash } from "../hash"
|
|
3
|
+
import { createSheet, StyleSheet } from "../sheet"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tests for the @media/@supports/@container splitting behavior.
|
|
7
|
+
*
|
|
8
|
+
* When CSS text contains nested at-rules, sheet.insert() should split them
|
|
9
|
+
* into separate top-level rules rather than relying on CSS Nesting.
|
|
10
|
+
* This matches the approach used by styled-components and Emotion.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
describe("StyleSheet -- at-rule splitting", () => {
|
|
14
|
+
describe("SSR mode (splitAtRules internals via SSR output)", () => {
|
|
15
|
+
let originalDocument: typeof document
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
originalDocument = globalThis.document
|
|
19
|
+
// @ts-expect-error - intentionally deleting for SSR simulation
|
|
20
|
+
delete globalThis.document
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
globalThis.document = originalDocument
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("CSS without @media produces a single rule", () => {
|
|
28
|
+
const s = createSheet()
|
|
29
|
+
s.insert("color: red; font-size: 16px;")
|
|
30
|
+
const styles = s.getStyles()
|
|
31
|
+
|
|
32
|
+
// Should have exactly one rule: .pyr-xxx{color: red; font-size: 16px;}
|
|
33
|
+
expect(styles).toMatch(/^\.pyr-[0-9a-z]+\{color: red; font-size: 16px;\}$/)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("CSS with @media splits into base + media rules", () => {
|
|
37
|
+
const s = createSheet()
|
|
38
|
+
s.insert("color: red; @media (min-width: 768px){color: blue;}")
|
|
39
|
+
const styles = s.getStyles()
|
|
40
|
+
|
|
41
|
+
// Base rule: .pyr-xxx{color: red;}
|
|
42
|
+
expect(styles).toMatch(/\.pyr-[0-9a-z]+\{color: red;\}/)
|
|
43
|
+
// Media rule: @media (min-width: 768px){.pyr-xxx{color: blue;}}
|
|
44
|
+
expect(styles).toMatch(/@media \(min-width: 768px\)\{\.pyr-[0-9a-z]+\{color: blue;\}\}/)
|
|
45
|
+
// The base rule should NOT contain @media inside its braces
|
|
46
|
+
expect(styles).not.toMatch(/\.pyr-[0-9a-z]+\{[^}]*@media/)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("CSS with multiple @media produces multiple separate rules", () => {
|
|
50
|
+
const s = createSheet()
|
|
51
|
+
s.insert(
|
|
52
|
+
"position: absolute; bottom: -4.375rem; @media (min-width: 36em){right: -11.25rem;} @media (min-width: 48em){bottom: 0; height: 40rem;}",
|
|
53
|
+
)
|
|
54
|
+
const styles = s.getStyles()
|
|
55
|
+
|
|
56
|
+
// Base
|
|
57
|
+
expect(styles).toContain("position: absolute; bottom: -4.375rem;")
|
|
58
|
+
// Two separate media rules
|
|
59
|
+
expect(styles).toMatch(/@media \(min-width: 36em\)\{\.pyr-[0-9a-z]+\{right: -11.25rem;\}\}/)
|
|
60
|
+
expect(styles).toMatch(
|
|
61
|
+
/@media \(min-width: 48em\)\{\.pyr-[0-9a-z]+\{bottom: 0; height: 40rem;\}\}/,
|
|
62
|
+
)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("CSS with only @media (no base declarations) works correctly", () => {
|
|
66
|
+
const s = createSheet()
|
|
67
|
+
s.insert("@media (min-width: 768px){color: blue;} @media (min-width: 1024px){color: green;}")
|
|
68
|
+
const styles = s.getStyles()
|
|
69
|
+
|
|
70
|
+
// No base rule (or empty base)
|
|
71
|
+
expect(styles).not.toMatch(/\.pyr-[0-9a-z]+\{\}/)
|
|
72
|
+
// Both media rules present
|
|
73
|
+
expect(styles).toMatch(/@media \(min-width: 768px\)\{\.pyr-[0-9a-z]+\{color: blue;\}\}/)
|
|
74
|
+
expect(styles).toMatch(/@media \(min-width: 1024px\)\{\.pyr-[0-9a-z]+\{color: green;\}\}/)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("boost doubles selector in both base and media rules", () => {
|
|
78
|
+
const s = createSheet()
|
|
79
|
+
s.insert("color: red; @media (min-width: 768px){color: blue;}", true)
|
|
80
|
+
const styles = s.getStyles()
|
|
81
|
+
|
|
82
|
+
// Base: .pyr-xxx.pyr-xxx{color: red;}
|
|
83
|
+
expect(styles).toMatch(/\.pyr-[0-9a-z]+\.pyr-[0-9a-z]+\{color: red;\}/)
|
|
84
|
+
// Media: @media (...){.pyr-xxx.pyr-xxx{color: blue;}}
|
|
85
|
+
expect(styles).toMatch(
|
|
86
|
+
/@media \(min-width: 768px\)\{\.pyr-[0-9a-z]+\.pyr-[0-9a-z]+\{color: blue;\}\}/,
|
|
87
|
+
)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it("@supports blocks are also split out", () => {
|
|
91
|
+
const s = createSheet()
|
|
92
|
+
s.insert("display: flex; @supports (display: grid){display: grid;}")
|
|
93
|
+
const styles = s.getStyles()
|
|
94
|
+
|
|
95
|
+
expect(styles).toMatch(/\.pyr-[0-9a-z]+\{display: flex;\}/)
|
|
96
|
+
expect(styles).toMatch(/@supports \(display: grid\)\{\.pyr-[0-9a-z]+\{display: grid;\}\}/)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it("@container blocks are also split out", () => {
|
|
100
|
+
const s = createSheet()
|
|
101
|
+
s.insert("font-size: 1rem; @container (min-width: 400px){font-size: 1.25rem;}")
|
|
102
|
+
const styles = s.getStyles()
|
|
103
|
+
|
|
104
|
+
expect(styles).toMatch(/\.pyr-[0-9a-z]+\{font-size: 1rem;\}/)
|
|
105
|
+
expect(styles).toMatch(
|
|
106
|
+
/@container \(min-width: 400px\)\{\.pyr-[0-9a-z]+\{font-size: 1.25rem;\}\}/,
|
|
107
|
+
)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it("@layer wraps each split rule individually", () => {
|
|
111
|
+
const s = createSheet({ layer: "components" })
|
|
112
|
+
s.insert("color: red; @media (min-width: 768px){color: blue;}")
|
|
113
|
+
const styles = s.getStyles()
|
|
114
|
+
|
|
115
|
+
// Base wrapped in layer
|
|
116
|
+
expect(styles).toMatch(/@layer components\{\.pyr-[0-9a-z]+\{color: red;\}\}/)
|
|
117
|
+
// Media wrapped in layer
|
|
118
|
+
expect(styles).toMatch(
|
|
119
|
+
/@layer components\{@media \(min-width: 768px\)\{\.pyr-[0-9a-z]+\{color: blue;\}\}\}/,
|
|
120
|
+
)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("deduplicates CSS with @media (same CSS -> same className -> single insert)", () => {
|
|
124
|
+
const s = createSheet()
|
|
125
|
+
const cssStr = "color: red; @media (min-width: 768px){color: blue;}"
|
|
126
|
+
s.insert(cssStr)
|
|
127
|
+
s.insert(cssStr)
|
|
128
|
+
|
|
129
|
+
const styles = s.getStyles()
|
|
130
|
+
const baseMatches = styles.match(/\.pyr-[0-9a-z]+\{color: red;\}/g)
|
|
131
|
+
expect(baseMatches).toHaveLength(1)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("real-world example: position + responsive inset/height", () => {
|
|
135
|
+
const s = createSheet()
|
|
136
|
+
const cssStr =
|
|
137
|
+
"position: absolute; bottom: -4.375rem; right: -12.5rem; height: 28.75rem; " +
|
|
138
|
+
"@media only screen and (min-width: 36em){right: -11.25rem;} " +
|
|
139
|
+
"@media only screen and (min-width: 48em){bottom: 0; height: 40rem;} " +
|
|
140
|
+
"@media only screen and (min-width: 62em){right: -6.25rem;} " +
|
|
141
|
+
"@media only screen and (min-width: 100em){right: initial; left: 55%;}"
|
|
142
|
+
s.insert(cssStr, true)
|
|
143
|
+
const styles = s.getStyles()
|
|
144
|
+
|
|
145
|
+
// Base rule has position, bottom, right, height
|
|
146
|
+
expect(styles).toContain("position: absolute;")
|
|
147
|
+
expect(styles).toContain("bottom: -4.375rem;")
|
|
148
|
+
expect(styles).toContain("right: -12.5rem;")
|
|
149
|
+
expect(styles).toContain("height: 28.75rem;")
|
|
150
|
+
|
|
151
|
+
// Each media query is a separate top-level rule
|
|
152
|
+
expect(styles).toMatch(/@media only screen and \(min-width: 36em\)\{/)
|
|
153
|
+
expect(styles).toMatch(/@media only screen and \(min-width: 48em\)\{/)
|
|
154
|
+
expect(styles).toMatch(/@media only screen and \(min-width: 62em\)\{/)
|
|
155
|
+
expect(styles).toMatch(/@media only screen and \(min-width: 100em\)\{/)
|
|
156
|
+
|
|
157
|
+
// No nested @media inside a class selector
|
|
158
|
+
expect(styles).not.toMatch(/\.pyr-[0-9a-z]+\{[^}]*@media/)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it("getStyleTag contains all split rules", () => {
|
|
162
|
+
const s = createSheet()
|
|
163
|
+
s.insert("color: red; @media (min-width: 768px){color: blue;}")
|
|
164
|
+
const tag = s.getStyleTag()
|
|
165
|
+
|
|
166
|
+
expect(tag).toMatch(/^<style data-pyreon-styler="">.*<\/style>$/)
|
|
167
|
+
expect(tag).toContain("color: red;")
|
|
168
|
+
expect(tag).toContain("@media (min-width: 768px)")
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it("reset clears all split rules from SSR buffer and cache", () => {
|
|
172
|
+
const s = createSheet()
|
|
173
|
+
s.insert("color: red; @media (min-width: 768px){color: blue;}")
|
|
174
|
+
expect(s.getStyles()).not.toBe("")
|
|
175
|
+
|
|
176
|
+
s.reset()
|
|
177
|
+
expect(s.getStyles()).toBe("")
|
|
178
|
+
expect(s.cacheSize).toBe(0) // cache also cleared for SSR correctness
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe("DOM mode (insertRule verification)", () => {
|
|
183
|
+
beforeEach(() => {
|
|
184
|
+
for (const el of Array.from(document.querySelectorAll("style[data-pyreon-styler]")))
|
|
185
|
+
el.remove()
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it("inserts base + media as separate CSSRules", () => {
|
|
189
|
+
const s = createSheet()
|
|
190
|
+
s.insert("color: red; @media (min-width: 768px){color: blue;}")
|
|
191
|
+
|
|
192
|
+
// Find the style element
|
|
193
|
+
const styleEl = document.querySelector("style[data-pyreon-styler]") as HTMLStyleElement
|
|
194
|
+
expect(styleEl).not.toBeNull()
|
|
195
|
+
const sheet = styleEl.sheet
|
|
196
|
+
if (!sheet) throw new Error("expected sheet")
|
|
197
|
+
|
|
198
|
+
// Should have at least 2 rules: one CSSStyleRule + one CSSMediaRule
|
|
199
|
+
let hasStyleRule = false
|
|
200
|
+
let hasMediaRule = false
|
|
201
|
+
|
|
202
|
+
for (let i = 0; i < sheet.cssRules.length; i++) {
|
|
203
|
+
const rule = sheet.cssRules[i]
|
|
204
|
+
if (rule instanceof CSSStyleRule && rule.selectorText.startsWith(".pyr-")) {
|
|
205
|
+
hasStyleRule = true
|
|
206
|
+
}
|
|
207
|
+
if (rule instanceof CSSMediaRule) {
|
|
208
|
+
hasMediaRule = true
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
expect(hasStyleRule).toBe(true)
|
|
213
|
+
expect(hasMediaRule).toBe(true)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex logic is inherent to this function
|
|
217
|
+
it("boosted selector appears in both base and media rules", () => {
|
|
218
|
+
const s = createSheet()
|
|
219
|
+
const className = s.insert("color: red; @media (min-width: 768px){color: blue;}", true)
|
|
220
|
+
|
|
221
|
+
const styleEl = document.querySelector("style[data-pyreon-styler]") as HTMLStyleElement
|
|
222
|
+
const sheet = styleEl.sheet
|
|
223
|
+
if (!sheet) throw new Error("expected sheet")
|
|
224
|
+
const boostedSelector = `.${className}.${className}`
|
|
225
|
+
|
|
226
|
+
let baseFound = false
|
|
227
|
+
let mediaInnerFound = false
|
|
228
|
+
|
|
229
|
+
for (let i = 0; i < sheet.cssRules.length; i++) {
|
|
230
|
+
const rule = sheet.cssRules[i]
|
|
231
|
+
if (rule instanceof CSSStyleRule && rule.selectorText === boostedSelector) {
|
|
232
|
+
baseFound = true
|
|
233
|
+
}
|
|
234
|
+
if (rule instanceof CSSMediaRule) {
|
|
235
|
+
for (let j = 0; j < rule.cssRules.length; j++) {
|
|
236
|
+
const inner = rule.cssRules[j]
|
|
237
|
+
if (inner instanceof CSSStyleRule && inner.selectorText === boostedSelector) {
|
|
238
|
+
mediaInnerFound = true
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
expect(baseFound).toBe(true)
|
|
245
|
+
expect(mediaInnerFound).toBe(true)
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
describe("hydration with split rules", () => {
|
|
250
|
+
beforeEach(() => {
|
|
251
|
+
for (const el of Array.from(document.querySelectorAll("style[data-pyreon-styler]")))
|
|
252
|
+
el.remove()
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
it("hydrates className from CSSMediaRule inner selectors", () => {
|
|
256
|
+
// Simulate SSR: create a style tag with split rules
|
|
257
|
+
const el = document.createElement("style")
|
|
258
|
+
el.setAttribute("data-pyreon-styler", "")
|
|
259
|
+
document.head.appendChild(el)
|
|
260
|
+
|
|
261
|
+
const className = `pyr-${hash("color: red;")}`
|
|
262
|
+
|
|
263
|
+
// Insert rules that simulate what SSR produces
|
|
264
|
+
el.sheet?.insertRule(`.${className}{color: red;}`, 0)
|
|
265
|
+
el.sheet?.insertRule(`@media (min-width: 768px){.${className}{color: blue;}}`, 1)
|
|
266
|
+
|
|
267
|
+
// Create a new sheet that will hydrate from the tag
|
|
268
|
+
const s = new StyleSheet()
|
|
269
|
+
// The sheet should have hydrated the className
|
|
270
|
+
expect(s.has(className)).toBe(true)
|
|
271
|
+
// Subsequent insert is a no-op (deduped)
|
|
272
|
+
expect(s.cacheSize).toBeGreaterThanOrEqual(1)
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
it("hydrates className from boosted selectors in media rules", () => {
|
|
276
|
+
const el = document.createElement("style")
|
|
277
|
+
el.setAttribute("data-pyreon-styler", "")
|
|
278
|
+
document.head.appendChild(el)
|
|
279
|
+
|
|
280
|
+
const className = `pyr-${hash("font-size: 1rem;")}`
|
|
281
|
+
|
|
282
|
+
el.sheet?.insertRule(`.${className}.${className}{font-size: 1rem;}`, 0)
|
|
283
|
+
el.sheet?.insertRule(
|
|
284
|
+
`@media (min-width: 768px){.${className}.${className}{font-size: 1.5rem;}}`,
|
|
285
|
+
1,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
const s = new StyleSheet()
|
|
289
|
+
expect(s.has(className)).toBe(true)
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it("hydrates from media-only rules (no base style rule)", () => {
|
|
293
|
+
const el = document.createElement("style")
|
|
294
|
+
el.setAttribute("data-pyreon-styler", "")
|
|
295
|
+
document.head.appendChild(el)
|
|
296
|
+
|
|
297
|
+
const className = `pyr-${hash("responsive-only")}`
|
|
298
|
+
|
|
299
|
+
// Only a media rule, no base rule
|
|
300
|
+
el.sheet?.insertRule(`@media (min-width: 768px){.${className}{color: blue;}}`, 0)
|
|
301
|
+
|
|
302
|
+
const s = new StyleSheet()
|
|
303
|
+
expect(s.has(className)).toBe(true)
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
describe("edge cases", () => {
|
|
308
|
+
let originalDocument: typeof document
|
|
309
|
+
|
|
310
|
+
beforeEach(() => {
|
|
311
|
+
originalDocument = globalThis.document
|
|
312
|
+
// @ts-expect-error - intentionally deleting for SSR simulation
|
|
313
|
+
delete globalThis.document
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
afterEach(() => {
|
|
317
|
+
globalThis.document = originalDocument
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it("handles empty CSS text", () => {
|
|
321
|
+
const s = createSheet()
|
|
322
|
+
const cls = s.insert("")
|
|
323
|
+
expect(cls).toMatch(/^pyr-/)
|
|
324
|
+
expect(s.getStyles()).toBe("")
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it("handles CSS with @ in a value (not an at-rule)", () => {
|
|
328
|
+
const s = createSheet()
|
|
329
|
+
s.insert('content: "@media";')
|
|
330
|
+
// Should not be confused by @ in a string value
|
|
331
|
+
expect(s.getStyles()).toContain('content: "@media";')
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it("handles @keyframes reference in the CSS without splitting it", () => {
|
|
335
|
+
const s = createSheet()
|
|
336
|
+
s.insert("animation: fadeIn 0.3s;")
|
|
337
|
+
const styles = s.getStyles()
|
|
338
|
+
expect(styles).toContain("animation: fadeIn 0.3s;")
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it("preserves &:hover nesting in base CSS", () => {
|
|
342
|
+
const s = createSheet()
|
|
343
|
+
s.insert("color: red; &:hover{color: blue;}")
|
|
344
|
+
const styles = s.getStyles()
|
|
345
|
+
|
|
346
|
+
// The &:hover block should stay inside the base rule
|
|
347
|
+
expect(styles).toMatch(/\.pyr-[0-9a-z]+\{color: red; &:hover\{color: blue;\}\}/)
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it("preserves &:hover nesting alongside @media splitting", () => {
|
|
351
|
+
const s = createSheet()
|
|
352
|
+
s.insert("color: red; &:hover{color: blue;} @media (min-width: 768px){font-size: 2rem;}")
|
|
353
|
+
const styles = s.getStyles()
|
|
354
|
+
|
|
355
|
+
// Base rule has color + &:hover
|
|
356
|
+
expect(styles).toMatch(/\.pyr-[0-9a-z]+\{color: red; &:hover\{color: blue;\}\}/)
|
|
357
|
+
// Media rule is separate
|
|
358
|
+
expect(styles).toMatch(/@media \(min-width: 768px\)\{\.pyr-[0-9a-z]+\{font-size: 2rem;\}\}/)
|
|
359
|
+
})
|
|
360
|
+
|
|
361
|
+
it("handles consecutive @media blocks with no base CSS between them", () => {
|
|
362
|
+
const s = createSheet()
|
|
363
|
+
s.insert("@media (min-width: 768px){color: blue;} @media (min-width: 1024px){color: green;}")
|
|
364
|
+
const styles = s.getStyles()
|
|
365
|
+
|
|
366
|
+
expect(styles).toMatch(/@media \(min-width: 768px\)/)
|
|
367
|
+
expect(styles).toMatch(/@media \(min-width: 1024px\)/)
|
|
368
|
+
})
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
describe("performance characteristics", () => {
|
|
372
|
+
let originalDocument: typeof document
|
|
373
|
+
|
|
374
|
+
beforeEach(() => {
|
|
375
|
+
originalDocument = globalThis.document
|
|
376
|
+
// @ts-expect-error - intentionally deleting for SSR simulation
|
|
377
|
+
delete globalThis.document
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
afterEach(() => {
|
|
381
|
+
globalThis.document = originalDocument
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
it("fast path: no scanning when CSS has no @ character", () => {
|
|
385
|
+
const s = createSheet()
|
|
386
|
+
// Insert 1000 simple rules -- should not trigger any splitting logic
|
|
387
|
+
const start = performance.now()
|
|
388
|
+
for (let i = 0; i < 1000; i++) {
|
|
389
|
+
s.insert(`prop-${i}: val-${i};`)
|
|
390
|
+
}
|
|
391
|
+
const elapsed = performance.now() - start
|
|
392
|
+
|
|
393
|
+
expect(s.cacheSize).toBe(1000)
|
|
394
|
+
// Should complete quickly — 500ms is generous for CI runners
|
|
395
|
+
expect(elapsed).toBeLessThan(500)
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it("splitting adds minimal overhead for CSS with @media", () => {
|
|
399
|
+
const s = createSheet()
|
|
400
|
+
const start = performance.now()
|
|
401
|
+
for (let i = 0; i < 500; i++) {
|
|
402
|
+
s.insert(`color: color-${i}; @media (min-width: ${i}px){font-size: ${i}rem;}`)
|
|
403
|
+
}
|
|
404
|
+
const elapsed = performance.now() - start
|
|
405
|
+
|
|
406
|
+
expect(s.cacheSize).toBe(500)
|
|
407
|
+
// Should still be fast — 500ms is generous for CI runners
|
|
408
|
+
expect(elapsed).toBeLessThan(500)
|
|
409
|
+
})
|
|
410
|
+
})
|
|
411
|
+
})
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest"
|
|
2
|
+
import { hash } from "../hash"
|
|
3
|
+
import { sheet } from "../sheet"
|
|
4
|
+
|
|
5
|
+
describe("StyleSheet", () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
sheet.reset()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
sheet.reset()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe("insert", () => {
|
|
15
|
+
it("returns a class name with pyr- prefix", () => {
|
|
16
|
+
const className = sheet.insert("display: flex;")
|
|
17
|
+
expect(className).toMatch(/^pyr-[0-9a-z]+$/)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("same CSS text always returns same class name (dedup)", () => {
|
|
21
|
+
const cls1 = sheet.insert("color: red;")
|
|
22
|
+
const cls2 = sheet.insert("color: red;")
|
|
23
|
+
expect(cls1).toBe(cls2)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("different CSS text returns different class names", () => {
|
|
27
|
+
const cls1 = sheet.insert("color: red;")
|
|
28
|
+
const cls2 = sheet.insert("color: blue;")
|
|
29
|
+
expect(cls1).not.toBe(cls2)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it("class name matches hash of CSS text", () => {
|
|
33
|
+
const cssText = "display: flex;"
|
|
34
|
+
const className = sheet.insert(cssText)
|
|
35
|
+
expect(className).toBe(`pyr-${hash(cssText)}`)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("handles empty string CSS", () => {
|
|
39
|
+
const className = sheet.insert("")
|
|
40
|
+
expect(className).toMatch(/^pyr-[0-9a-z]+$/)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("supports boost mode (doubled selector)", () => {
|
|
44
|
+
const className = sheet.insert("color: red;", true)
|
|
45
|
+
expect(className).toMatch(/^pyr-[0-9a-z]+$/)
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe("cache eviction", () => {
|
|
50
|
+
it("evicts oldest entries when cache exceeds MAX_CACHE", () => {
|
|
51
|
+
for (let i = 0; i < 100; i++) {
|
|
52
|
+
sheet.insert(`unique-prop-${i}: value-${i};`)
|
|
53
|
+
}
|
|
54
|
+
const result = sheet.insert("color: red;")
|
|
55
|
+
expect(result).toMatch(/^pyr-/)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe("insertKeyframes", () => {
|
|
60
|
+
it("does not throw", () => {
|
|
61
|
+
expect(() => sheet.insertKeyframes("test-anim", "from{opacity:0}to{opacity:1}")).not.toThrow()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("deduplicates by name", () => {
|
|
65
|
+
sheet.insertKeyframes("test-anim", "from{opacity:0}to{opacity:1}")
|
|
66
|
+
// Second call with same name should not throw
|
|
67
|
+
expect(() => sheet.insertKeyframes("test-anim", "from{opacity:0}to{opacity:1}")).not.toThrow()
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe("insertGlobal", () => {
|
|
72
|
+
it("does not throw for valid CSS", () => {
|
|
73
|
+
expect(() => sheet.insertGlobal("body { margin: 0; }")).not.toThrow()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("handles multiple calls without error", () => {
|
|
77
|
+
sheet.insertGlobal("body { margin: 0; }")
|
|
78
|
+
sheet.insertGlobal("html { box-sizing: border-box; }")
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it("deduplicates same CSS", () => {
|
|
82
|
+
sheet.insertGlobal("body { margin: 0; }")
|
|
83
|
+
sheet.insertGlobal("body { margin: 0; }")
|
|
84
|
+
// No error, second is deduped
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe("getClassName", () => {
|
|
89
|
+
it("returns a className without injecting", () => {
|
|
90
|
+
const className = sheet.getClassName("color: red;")
|
|
91
|
+
expect(className).toMatch(/^pyr-[0-9a-z]+$/)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("returns same className as insert for same CSS", () => {
|
|
95
|
+
const cssText = "display: flex;"
|
|
96
|
+
const getResult = sheet.getClassName(cssText)
|
|
97
|
+
const insertResult = sheet.insert(cssText)
|
|
98
|
+
expect(getResult).toBe(insertResult)
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe("prepare", () => {
|
|
103
|
+
it("returns className and rules", () => {
|
|
104
|
+
const { className, rules } = sheet.prepare("color: red;")
|
|
105
|
+
expect(className).toMatch(/^pyr-[0-9a-z]+$/)
|
|
106
|
+
expect(rules).toContain(className)
|
|
107
|
+
expect(rules).toContain("color: red;")
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it("supports boost mode", () => {
|
|
111
|
+
const { className, rules } = sheet.prepare("color: red;", true)
|
|
112
|
+
// Boosted selector should have doubled class
|
|
113
|
+
expect(rules).toContain(`.${className}.${className}`)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
describe("SSR support", () => {
|
|
118
|
+
it("getStyleTag returns a string", () => {
|
|
119
|
+
const result = sheet.getStyleTag()
|
|
120
|
+
expect(typeof result).toBe("string")
|
|
121
|
+
expect(result).toContain("data-pyreon-styler")
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it("getStyles returns empty string when no rules inserted", () => {
|
|
125
|
+
const result = sheet.getStyles()
|
|
126
|
+
expect(result).toBe("")
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
describe("reset", () => {
|
|
131
|
+
it("clears cache so new inserts re-generate class names", () => {
|
|
132
|
+
const cls1 = sheet.insert("color: green;")
|
|
133
|
+
sheet.reset()
|
|
134
|
+
const cls2 = sheet.insert("color: green;")
|
|
135
|
+
expect(cls1).toBe(cls2)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe("clearCache and clearAll", () => {
|
|
140
|
+
it("clearCache clears the cache", () => {
|
|
141
|
+
sheet.insert("color: red;")
|
|
142
|
+
sheet.clearCache()
|
|
143
|
+
expect(sheet.cacheSize).toBe(0)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it("clearAll clears cache and SSR buffer", () => {
|
|
147
|
+
sheet.insert("color: red;")
|
|
148
|
+
sheet.clearAll()
|
|
149
|
+
expect(sheet.cacheSize).toBe(0)
|
|
150
|
+
expect(sheet.getStyles()).toBe("")
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe("has", () => {
|
|
155
|
+
it("returns true for cached classNames", () => {
|
|
156
|
+
const className = sheet.insert("color: red;")
|
|
157
|
+
expect(sheet.has(className)).toBe(true)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it("returns false for unknown classNames", () => {
|
|
161
|
+
expect(sheet.has("pyr-unknown")).toBe(false)
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SSR tests for styled() and createGlobalStyle(). Re-imports modules
|
|
5
|
+
* with `document` deleted so IS_SERVER evaluates to true.
|
|
6
|
+
*/
|
|
7
|
+
describe("styled -- SSR mode", () => {
|
|
8
|
+
let originalDocument: typeof document
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
vi.resetModules()
|
|
12
|
+
originalDocument = globalThis.document
|
|
13
|
+
// @ts-expect-error - intentionally deleting for SSR simulation
|
|
14
|
+
delete globalThis.document
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
globalThis.document = originalDocument
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("static: creates component with SSR injection path", async () => {
|
|
22
|
+
const { styled } = await import("../styled")
|
|
23
|
+
const Comp = styled("div")`color: red;`
|
|
24
|
+
expect((Comp as any).displayName).toBe("styled(div)")
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("static: empty CSS template in SSR", async () => {
|
|
28
|
+
const { styled } = await import("../styled")
|
|
29
|
+
const Comp = styled("div")``
|
|
30
|
+
expect((Comp as any).displayName).toBe("styled(div)")
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("static: boost option in SSR", async () => {
|
|
34
|
+
const { styled } = await import("../styled")
|
|
35
|
+
const Comp = styled("div", { boost: true })`color: blue;`
|
|
36
|
+
expect((Comp as any).displayName).toBe("styled(div)")
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("static: shouldForwardProp in SSR", async () => {
|
|
40
|
+
const { styled } = await import("../styled")
|
|
41
|
+
const Comp = styled("div", {
|
|
42
|
+
shouldForwardProp: (p) => p !== "color",
|
|
43
|
+
})`display: flex;`
|
|
44
|
+
expect((Comp as any).displayName).toBe("styled(div)")
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("static: styled.div shorthand in SSR", async () => {
|
|
48
|
+
const { styled } = await import("../styled")
|
|
49
|
+
const Comp = styled.div`color: green;`
|
|
50
|
+
expect((Comp as any).displayName).toBe("styled(div)")
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it("createGlobalStyle: static SSR path", async () => {
|
|
54
|
+
const { createGlobalStyle } = await import("../globalStyle")
|
|
55
|
+
const GlobalStyle = createGlobalStyle`body { margin: 0; }`
|
|
56
|
+
// Static path injects immediately and returns a function that produces null
|
|
57
|
+
const result = GlobalStyle({})
|
|
58
|
+
expect(result).toBeNull()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it("createGlobalStyle: empty CSS in SSR returns null", async () => {
|
|
62
|
+
const { createGlobalStyle } = await import("../globalStyle")
|
|
63
|
+
const GlobalStyle = createGlobalStyle``
|
|
64
|
+
const result = GlobalStyle({})
|
|
65
|
+
expect(result).toBeNull()
|
|
66
|
+
})
|
|
67
|
+
})
|