@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,489 @@
1
+ /**
2
+ * Integration tests verifying that styler correctly resolves the full
3
+ * CSS composition chain used by rocketstyle + unistyle + makeItResponsive.
4
+ *
5
+ * This tests the EXACT patterns used in production to identify where
6
+ * CSS properties like `position: absolute` might get lost.
7
+ */
8
+
9
+ import type { VNode } from "@pyreon/core"
10
+ import { h } from "@pyreon/core"
11
+ import { describe, expect, it } from "vitest"
12
+ import { css } from "../css"
13
+ import { normalizeCSS, resolve, resolveValue } from "../resolve"
14
+ import { createSheet } from "../sheet"
15
+ import { styled } from "../styled"
16
+
17
+ // =====================================================================
18
+ // LAYER 1: resolve() with nested CSSResults — the raw resolution chain
19
+ // =====================================================================
20
+
21
+ describe("resolve composition chain", () => {
22
+ describe("CSSResult nesting (css-in-css)", () => {
23
+ it("resolves nested css`...` calls", () => {
24
+ const inner = css`color: red;`
25
+ const outer = css`${inner} font-size: 16px;`
26
+ const result = normalizeCSS(resolve(outer.strings, outer.values, {}))
27
+ expect(result).toContain("color: red;")
28
+ expect(result).toContain("font-size: 16px;")
29
+ })
30
+
31
+ it("resolves deeply nested css calls (3 levels)", () => {
32
+ const level3 = css`position: absolute;`
33
+ const level2 = css`${level3} display: flex;`
34
+ const level1 = css`${level2} color: blue;`
35
+ const result = normalizeCSS(resolve(level1.strings, level1.values, {}))
36
+ expect(result).toContain("position: absolute;")
37
+ expect(result).toContain("display: flex;")
38
+ expect(result).toContain("color: blue;")
39
+ })
40
+
41
+ it("resolves array of CSS strings (processDescriptor fragments)", () => {
42
+ // This mimics unistyle/styles/index.ts: fragments array from propertyMap
43
+ const fragments = [
44
+ "",
45
+ "",
46
+ "position: absolute;",
47
+ "",
48
+ "display: flex;",
49
+ "",
50
+ "height: 2.5rem;",
51
+ "",
52
+ ]
53
+ const result = css`${fragments}`
54
+ const resolved = normalizeCSS(resolve(result.strings, result.values, {}))
55
+ expect(resolved).toContain("position: absolute;")
56
+ expect(resolved).toContain("display: flex;")
57
+ expect(resolved).toContain("height: 2.5rem;")
58
+ })
59
+
60
+ it("resolves CSSResult wrapping an array of fragments", () => {
61
+ // styles() returns css`${fragments}` where fragments is an array
62
+ const fragments = ["position: absolute;", "", "color: red;"]
63
+ const stylesResult = css`${fragments}`
64
+ // makeItResponsive wraps: css`${renderStyles(theme)}`
65
+ const mirResult = css`${stylesResult}`
66
+ const resolved = normalizeCSS(resolve(mirResult.strings, mirResult.values, {}))
67
+ expect(resolved).toContain("position: absolute;")
68
+ expect(resolved).toContain("color: red;")
69
+ })
70
+ })
71
+
72
+ describe("function interpolations (styled component render path)", () => {
73
+ it("resolves function that returns a CSSResult", () => {
74
+ const fn = (props: any) => css`color: ${props.color};`
75
+ const template = css`${fn}`
76
+ const result = normalizeCSS(resolve(template.strings, template.values, { color: "red" }))
77
+ expect(result).toContain("color: red;")
78
+ })
79
+
80
+ it("resolves function that returns an array (makeItResponsive responsive path)", () => {
81
+ // makeItResponsive returns an array when breakpoints exist
82
+ const fn = () => [
83
+ css`position: absolute;`,
84
+ css`@media (min-width: 36em) { font-size: 2rem; }`,
85
+ ]
86
+ const template = css`${fn}`
87
+ const result = normalizeCSS(resolve(template.strings, template.values, {}))
88
+ expect(result).toContain("position: absolute;")
89
+ expect(result).toContain("@media")
90
+ expect(result).toContain("font-size: 2rem;")
91
+ })
92
+
93
+ it("resolves function returning CSSResult containing another function", () => {
94
+ // This is the exact rocketstyle pattern:
95
+ // .styles((css) => css`${({$rocketstyle}) => { ... return css`${baseTheme};` }}`)
96
+ const innerFn = (props: any) => {
97
+ const theme = props.$rocketstyle
98
+ const fragments = [
99
+ theme.position ? `position: ${theme.position};` : "",
100
+ theme.display ? `display: ${theme.display};` : "",
101
+ ]
102
+ return css`${fragments}`
103
+ }
104
+
105
+ const outerResult = css`
106
+ font-weight: 500;
107
+ ${innerFn};
108
+ `
109
+
110
+ const resolved = normalizeCSS(
111
+ resolve(outerResult.strings, outerResult.values, {
112
+ $rocketstyle: { position: "absolute", display: "flex" },
113
+ }),
114
+ )
115
+ expect(resolved).toContain("font-weight: 500;")
116
+ expect(resolved).toContain("position: absolute;")
117
+ expect(resolved).toContain("display: flex;")
118
+ })
119
+
120
+ it("resolves the full rocketstyle+unistyle chain pattern", () => {
121
+ // Simulates the full chain:
122
+ // 1. processDescriptor generates CSS string fragments
123
+ // 2. styles() wraps them in css`${fragments}`
124
+ // 3. makeItResponsive wraps in css`${renderStyles(theme)}`
125
+ // 4. For responsive: media wrapper adds @media
126
+ // 5. Returns array of breakpoint results
127
+ // 6. .styles() callback wraps everything
128
+
129
+ const unistyleStyles = ({
130
+ theme: t,
131
+ css: cssFn,
132
+ }: {
133
+ theme: Record<string, any>
134
+ css: typeof css
135
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: complex logic is inherent to this function
136
+ }) => {
137
+ const fragments = [
138
+ t.position ? `position: ${t.position};` : "",
139
+ t.display ? `display: ${t.display};` : "",
140
+ t.height ? `height: ${t.height}rem;` : "",
141
+ t.fontSize ? `font-size: ${t.fontSize}rem;` : "",
142
+ t.backgroundColor ? `background-color: ${t.backgroundColor};` : "",
143
+ t.color ? `color: ${t.color};` : "",
144
+ ]
145
+ return cssFn`${fragments}`
146
+ }
147
+
148
+ // Simulate makeItResponsive for non-responsive case
149
+ const makeItResponsiveNonBP = (config: {
150
+ theme: Record<string, any>
151
+ styles: typeof unistyleStyles
152
+ css: typeof css
153
+ }) => {
154
+ return () => {
155
+ const renderStyles = (t: Record<string, any>) =>
156
+ config.styles({ theme: t, css: config.css })
157
+ return config.css`
158
+ ${renderStyles(config.theme)}
159
+ `
160
+ }
161
+ }
162
+
163
+ // Simulate .styles() callback
164
+ const stylesCb = (cssFn: typeof css) => {
165
+ return cssFn`
166
+ font-weight: 500;
167
+ ${(props: any) => {
168
+ const rocketTheme = props.$rocketstyle
169
+ const baseTheme = makeItResponsiveNonBP({
170
+ theme: rocketTheme,
171
+ styles: unistyleStyles,
172
+ css: cssFn,
173
+ })
174
+ return cssFn`${baseTheme};`
175
+ }};
176
+ `
177
+ }
178
+
179
+ // This is what calculateStyles produces
180
+ const stylesArray = [stylesCb(css)]
181
+
182
+ // This is the styled component template resolution
183
+ const templateStrings = Object.assign(["\n ", ";\n"], {
184
+ raw: ["\n ", ";\n"],
185
+ }) as unknown as TemplateStringsArray
186
+
187
+ const resolved = normalizeCSS(
188
+ resolve(templateStrings, [stylesArray], {
189
+ $rocketstyle: {
190
+ position: "absolute",
191
+ display: "flex",
192
+ height: 2.5,
193
+ backgroundColor: "#0070f3",
194
+ color: "#fff",
195
+ },
196
+ }),
197
+ )
198
+
199
+ expect(resolved).toContain("position: absolute;")
200
+ expect(resolved).toContain("display: flex;")
201
+ expect(resolved).toContain("height: 2.5rem;")
202
+ expect(resolved).toContain("background-color: #0070f3;")
203
+ expect(resolved).toContain("color: #fff;")
204
+ expect(resolved).toContain("font-weight: 500;")
205
+ })
206
+
207
+ it("resolves the full chain with responsive breakpoints", () => {
208
+ const unistyleStyles = ({
209
+ theme: t,
210
+ css: cssFn,
211
+ }: {
212
+ theme: Record<string, any>
213
+ css: typeof css
214
+ }) => {
215
+ const fragments = [
216
+ t.position ? `position: ${t.position};` : "",
217
+ t.height ? `height: ${t.height}rem;` : "",
218
+ ]
219
+ return cssFn`${fragments}`
220
+ }
221
+
222
+ // Simulate createMediaQueries
223
+ const createMedia = (cssFn: typeof css, bps: Record<string, number>, rs: number) => {
224
+ const media: Record<string, (...args: any[]) => any> = {}
225
+ for (const [key, value] of Object.entries(bps)) {
226
+ if (value === 0) {
227
+ media[key] = (...args: any[]) => (cssFn as any)(...args)
228
+ } else {
229
+ const emSize = value / rs
230
+ media[key] = (...args: any[]) => cssFn`
231
+ @media only screen and (min-width: ${emSize}em) {
232
+ ${(cssFn as any)(...args)};
233
+ }
234
+ `
235
+ }
236
+ }
237
+ return media
238
+ }
239
+
240
+ const breakpoints = { xs: 0, md: 768 }
241
+ const rootSize = 16
242
+ const media = createMedia(css, breakpoints, rootSize)
243
+ const sortedBreakpoints = ["xs", "md"]
244
+
245
+ // Simulate makeItResponsive with responsive path
246
+ const makeItResponsiveResp = (config: {
247
+ theme: Record<string, any>
248
+ styles: typeof unistyleStyles
249
+ css: typeof css
250
+ }) => {
251
+ return (_props: any) => {
252
+ const renderStyles = (t: Record<string, any>) =>
253
+ config.styles({ theme: t, css: config.css })
254
+
255
+ // After normalizeTheme + transformTheme + optimizeTheme:
256
+ // position: 'absolute' -> only first breakpoint
257
+ // height: { xs: 2.5, md: 5 } -> different per breakpoint
258
+ const optimizedTheme: Record<string, Record<string, any>> = {
259
+ xs: { position: "absolute", height: 2.5 },
260
+ md: { height: 5 },
261
+ }
262
+
263
+ return sortedBreakpoints.map((item) => {
264
+ const breakpointTheme = optimizedTheme[item]
265
+ if (!breakpointTheme || !media) return ""
266
+ const result = renderStyles(breakpointTheme)
267
+ return (media as Record<string, any>)[item]`
268
+ ${result};
269
+ `
270
+ })
271
+ }
272
+ }
273
+
274
+ const stylesCb = (cssFn: typeof css) => {
275
+ return cssFn`
276
+ ${(props: any) => {
277
+ const rocketTheme = props.$rocketstyle
278
+ const baseTheme = makeItResponsiveResp({
279
+ theme: rocketTheme,
280
+ styles: unistyleStyles,
281
+ css: cssFn,
282
+ })
283
+ return cssFn`${baseTheme};`
284
+ }};
285
+ `
286
+ }
287
+
288
+ const stylesArray = [stylesCb(css)]
289
+
290
+ const templateStrings = Object.assign(["\n ", ";\n"], {
291
+ raw: ["\n ", ";\n"],
292
+ }) as unknown as TemplateStringsArray
293
+
294
+ const resolved = normalizeCSS(
295
+ resolve(templateStrings, [stylesArray], {
296
+ $rocketstyle: { position: "absolute", height: { xs: 2.5, md: 5 } },
297
+ }),
298
+ )
299
+
300
+ // Base breakpoint (xs) should have position + height
301
+ expect(resolved).toContain("position: absolute;")
302
+ expect(resolved).toContain("height: 2.5rem;")
303
+
304
+ // md breakpoint should be in @media
305
+ expect(resolved).toContain("@media")
306
+ expect(resolved).toContain("height: 5rem;")
307
+ })
308
+ })
309
+ })
310
+
311
+ // =====================================================================
312
+ // LAYER 2: styled component rendering — verify CSS injection + className
313
+ // =====================================================================
314
+
315
+ describe("styled component composition", () => {
316
+ it("handles array of functions as single interpolation (calculateStyles pattern)", () => {
317
+ // This is EXACTLY what rocketstyle does:
318
+ // styled(component, { boost: true })`${calculateStyles(styles)};`
319
+ // calculateStyles returns an array of function results
320
+
321
+ const fn1 = (props: any) => `position: ${props.$rocketstyle?.position ?? "static"};`
322
+ const fn2 = (props: any) => `color: ${props.$rocketstyle?.color ?? "inherit"};`
323
+
324
+ const Comp = styled("div")`
325
+ ${[fn1, fn2]};
326
+ `
327
+
328
+ const vnode = Comp({ $rocketstyle: { position: "absolute", color: "red" } }) as VNode
329
+ expect(vnode.props.class).toMatch(/^pyr-/)
330
+ })
331
+
332
+ it("handles function returning css`...` with nested function returning array", () => {
333
+ // This mimics the full .styles() -> makeItResponsive -> unistyle chain
334
+ const innerFn = (props: any) => {
335
+ const t = props.$rocketstyle
336
+ return [t.position ? `position: ${t.position};` : "", t.color ? `color: ${t.color};` : ""]
337
+ }
338
+
339
+ const outerCssResult = css`
340
+ font-weight: bold;
341
+ ${innerFn};
342
+ `
343
+
344
+ const Comp = styled("div")`
345
+ ${[outerCssResult]};
346
+ `
347
+
348
+ const vnode = Comp({ $rocketstyle: { position: "absolute", color: "blue" } }) as VNode
349
+ expect(vnode.props.class).toMatch(/^pyr-/)
350
+
351
+ // Verify the CSS resolves correctly
352
+ const cssText = normalizeCSS(
353
+ resolve(outerCssResult.strings, outerCssResult.values, {
354
+ $rocketstyle: { position: "absolute", color: "blue" },
355
+ }),
356
+ )
357
+ expect(cssText).toContain("position: absolute;")
358
+ expect(cssText).toContain("color: blue;")
359
+ expect(cssText).toContain("font-weight: bold;")
360
+ })
361
+
362
+ it("handles css result wrapping a makeItResponsive-like function", () => {
363
+ // makeItResponsive returns a FUNCTION
364
+ // This function is used as interpolation in css`${baseTheme};`
365
+ // That css result is used as interpolation in css`${fn};`
366
+ // That css result is in an array from calculateStyles
367
+
368
+ const makeItResponsiveLike = (theme: Record<string, any>) => (_props: any) => {
369
+ const fragments = Object.entries(theme).map(([k, v]) => `${k}: ${v};`)
370
+ return css`${fragments}`
371
+ }
372
+
373
+ const styleCallback = css`
374
+ font-weight: 500;
375
+ ${(props: any) => {
376
+ const baseTheme = makeItResponsiveLike(props.$rocketstyle)
377
+ return css`${baseTheme};`
378
+ }};
379
+ `
380
+
381
+ const Comp = styled("div")`
382
+ ${[styleCallback]};
383
+ `
384
+
385
+ const vnode = Comp({ $rocketstyle: { position: "absolute", display: "flex" } }) as VNode
386
+ expect(vnode.props.class).toMatch(/^pyr-/)
387
+
388
+ // Resolve manually to verify CSS content
389
+ const cssText = normalizeCSS(
390
+ resolve(styleCallback.strings, styleCallback.values, {
391
+ $rocketstyle: { position: "absolute", display: "flex" },
392
+ }),
393
+ )
394
+ expect(cssText).toContain("position: absolute;")
395
+ expect(cssText).toContain("display: flex;")
396
+ })
397
+
398
+ it("wrapping a component: outer styled inherits inner className", () => {
399
+ // Inner is a Pyreon component wrapped by rocketstyle's styled()
400
+ const Inner = (props: { class?: string; $rocketstyle?: any; "data-testid"?: string }) =>
401
+ h("div", { class: props.class, "data-testid": "inner" })
402
+
403
+ const Outer = styled(Inner)`
404
+ ${(props: any) => {
405
+ const t = props.$rocketstyle || {}
406
+ return `position: ${t.position || "static"};`
407
+ }};
408
+ `
409
+
410
+ const vnode = Outer({ $rocketstyle: { position: "absolute" } }) as VNode
411
+ // Outer renders Inner, passing className
412
+ expect(vnode.props.class).toMatch(/^pyr-/)
413
+ })
414
+
415
+ it("CSS output contains all properties from composition chain", () => {
416
+ const fragments = [
417
+ "position: absolute;",
418
+ "",
419
+ "display: flex;",
420
+ "height: 2.5rem;",
421
+ "",
422
+ "background-color: #0070f3;",
423
+ ]
424
+ const cssText = normalizeCSS(resolve(css`${fragments}`.strings, css`${fragments}`.values, {}))
425
+
426
+ // Verify the resolved CSS text contains all declarations
427
+ expect(cssText).toContain("position: absolute;")
428
+ expect(cssText).toContain("display: flex;")
429
+ expect(cssText).toContain("height: 2.5rem;")
430
+ expect(cssText).toContain("background-color: #0070f3;")
431
+
432
+ // Verify it can be inserted into a sheet
433
+ const s = createSheet()
434
+ const className = s.insert(cssText)
435
+ expect(className).toMatch(/^pyr-/)
436
+ })
437
+
438
+ it("handles the exact rocketstyle pattern with ThemeProvider context", () => {
439
+ // Full pattern: styled component -> function interpolation
440
+ // -> css result -> function -> css result -> array fragments
441
+ // Note: In VNode-level testing, we verify resolve output directly
442
+ // since ThemeProvider requires runtime context.
443
+
444
+ const innerFn = (props: any) => {
445
+ const t = props.$rocketstyle || {}
446
+ const fragments = [
447
+ t.position ? `position: ${t.position};` : "",
448
+ t.color ? `color: ${t.color};` : "",
449
+ t.fontSize ? `font-size: ${t.fontSize};` : "",
450
+ ]
451
+ return css`${fragments}`
452
+ }
453
+
454
+ const Comp = styled("div")`
455
+ ${(props: any) => {
456
+ const t = props.$rocketstyle || {}
457
+ const fragments = [
458
+ t.position ? `position: ${t.position};` : "",
459
+ t.color ? `color: ${t.color};` : "",
460
+ t.fontSize ? `font-size: ${t.fontSize};` : "",
461
+ ]
462
+ return css`${fragments}`
463
+ }};
464
+ `
465
+
466
+ const vnode = Comp({
467
+ $rocketstyle: {
468
+ position: "absolute",
469
+ color: "#fff",
470
+ fontSize: "14px",
471
+ },
472
+ }) as VNode
473
+ expect(vnode.props.class).toMatch(/^pyr-/)
474
+
475
+ // Also verify the CSS text resolves correctly
476
+ const resolved = normalizeCSS(
477
+ resolveValue(innerFn, {
478
+ $rocketstyle: {
479
+ position: "absolute",
480
+ color: "#fff",
481
+ fontSize: "14px",
482
+ },
483
+ }),
484
+ )
485
+ expect(resolved).toContain("position: absolute;")
486
+ expect(resolved).toContain("color: #fff;")
487
+ expect(resolved).toContain("font-size: 14px;")
488
+ })
489
+ })
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { css } from "../css"
3
+ import { CSSResult } from "../resolve"
4
+
5
+ describe("css", () => {
6
+ it("returns a CSSResult instance", () => {
7
+ const result = css`color: red;`
8
+ expect(result).toBeInstanceOf(CSSResult)
9
+ })
10
+
11
+ it("captures template strings", () => {
12
+ const result = css`color: red;`
13
+ expect(result.strings[0]).toBe("color: red;")
14
+ })
15
+
16
+ it("captures interpolation values", () => {
17
+ const color = "blue"
18
+ const result = css`color: ${color};`
19
+ expect(result.values).toEqual(["blue"])
20
+ })
21
+
22
+ it("captures function interpolations without calling them", () => {
23
+ const fn = () => "red"
24
+ const result = css`color: ${fn};`
25
+ expect(result.values[0]).toBe(fn)
26
+ expect(typeof result.values[0]).toBe("function")
27
+ })
28
+
29
+ it("works when called as a regular function", () => {
30
+ const strings = Object.assign(["color: ", ";"], {
31
+ raw: ["color: ", ";"],
32
+ }) as TemplateStringsArray
33
+ const result = css(strings, "red")
34
+ expect(result).toBeInstanceOf(CSSResult)
35
+ expect(result.values).toEqual(["red"])
36
+ })
37
+
38
+ it("supports nesting css results", () => {
39
+ const inner = css`color: red;`
40
+ const outer = css`${inner} display: flex;`
41
+ expect(outer.values[0]).toBeInstanceOf(CSSResult)
42
+ })
43
+
44
+ it("handles multiple interpolations", () => {
45
+ const result = css`color: ${"red"}; font-size: ${16}px;`
46
+ expect(result.values).toEqual(["red", 16])
47
+ expect(result.strings.length).toBe(3)
48
+ })
49
+
50
+ it("handles null/undefined/boolean interpolations lazily", () => {
51
+ const n = null
52
+ const u = undefined
53
+ const f = false
54
+ const t = true
55
+ const result = css`a${n}b${u}c${f}d${t}e`
56
+ expect(result.values).toEqual([null, undefined, false, true])
57
+ })
58
+
59
+ it("captures numeric interpolations", () => {
60
+ const result = css`flex: ${1};`
61
+ expect(result.values[0]).toBe(1)
62
+ })
63
+
64
+ it("empty template returns CSSResult with one empty string", () => {
65
+ const result = css``
66
+ expect(result).toBeInstanceOf(CSSResult)
67
+ expect(result.strings.length).toBe(1)
68
+ expect(result.values.length).toBe(0)
69
+ })
70
+ })