@pyreon/styler 0.11.0 → 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.
Files changed (36) hide show
  1. package/package.json +12 -10
  2. package/src/ThemeProvider.ts +37 -0
  3. package/src/__tests__/ThemeProvider.test.ts +67 -0
  4. package/src/__tests__/benchmark.bench.ts +189 -0
  5. package/src/__tests__/composition-chain.test.ts +489 -0
  6. package/src/__tests__/css.test.ts +70 -0
  7. package/src/__tests__/forward.test.ts +282 -0
  8. package/src/__tests__/globalStyle.test.ts +72 -0
  9. package/src/__tests__/hash.test.ts +70 -0
  10. package/src/__tests__/hybrid-injection.test.ts +205 -0
  11. package/src/__tests__/index.ts +14 -0
  12. package/src/__tests__/insertion-effect.test.ts +106 -0
  13. package/src/__tests__/integration.test.ts +149 -0
  14. package/src/__tests__/keyframes.test.ts +68 -0
  15. package/src/__tests__/memory-growth.test.ts +152 -0
  16. package/src/__tests__/p3-features.test.ts +258 -0
  17. package/src/__tests__/resolve.test.ts +249 -0
  18. package/src/__tests__/shared.test.ts +73 -0
  19. package/src/__tests__/sheet-advanced.test.ts +669 -0
  20. package/src/__tests__/sheet-split-atrules.test.ts +411 -0
  21. package/src/__tests__/sheet.test.ts +164 -0
  22. package/src/__tests__/styled-ssr.test.ts +67 -0
  23. package/src/__tests__/styled.test.ts +303 -0
  24. package/src/__tests__/theme.test.ts +33 -0
  25. package/src/__tests__/useCSS.test.ts +142 -0
  26. package/src/css.ts +13 -0
  27. package/src/forward.ts +276 -0
  28. package/src/globalStyle.ts +48 -0
  29. package/src/hash.ts +30 -0
  30. package/src/index.ts +15 -0
  31. package/src/keyframes.ts +36 -0
  32. package/src/resolve.ts +172 -0
  33. package/src/shared.ts +12 -0
  34. package/src/sheet.ts +387 -0
  35. package/src/styled.tsx +277 -0
  36. 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
+ })