@pyreon/unistyle 0.14.0 → 0.16.0
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/lib/index.d.ts +14 -0
- package/lib/index.js +214 -29
- package/package.json +11 -10
- package/src/__tests__/makeItResponsive.test.ts +260 -0
- package/src/__tests__/native-marker.test.ts +9 -0
- package/src/__tests__/optimizeBreakpointDeltas.test.ts +124 -0
- package/src/__tests__/special-keys.test.ts +120 -0
- package/src/__tests__/styles.test.ts +59 -0
- package/src/context.tsx +5 -1
- package/src/env.d.ts +6 -0
- package/src/responsive/index.ts +1 -0
- package/src/responsive/makeItResponsive.ts +123 -18
- package/src/responsive/optimizeBreakpointDeltas.ts +190 -0
- package/src/styles/styles/index.ts +37 -27
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { optimizeBreakpointDeltas } from '../responsive'
|
|
3
|
+
|
|
4
|
+
describe('optimizeBreakpointDeltas', () => {
|
|
5
|
+
describe('cascade pruning', () => {
|
|
6
|
+
it('returns input unchanged when there is one or fewer breakpoints', () => {
|
|
7
|
+
expect(optimizeBreakpointDeltas([])).toEqual([])
|
|
8
|
+
expect(optimizeBreakpointDeltas(['color: red;'])).toEqual(['color: red;'])
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('strips re-emitted unchanged declarations from later breakpoints', () => {
|
|
12
|
+
const out = optimizeBreakpointDeltas([
|
|
13
|
+
'color: red; padding: 0;',
|
|
14
|
+
'color: red; padding: 1rem;',
|
|
15
|
+
])
|
|
16
|
+
expect(out[0]).toBe('color: red; padding: 0;')
|
|
17
|
+
// `color: red` was already in the cascade — only padding survives
|
|
18
|
+
expect(out[1]).toBe('padding: 1rem;')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('keeps changed declarations across multiple breakpoints', () => {
|
|
22
|
+
const out = optimizeBreakpointDeltas([
|
|
23
|
+
'color: red; font-size: 12px;',
|
|
24
|
+
'color: blue; font-size: 12px;',
|
|
25
|
+
'color: blue; font-size: 16px;',
|
|
26
|
+
])
|
|
27
|
+
expect(out[0]).toBe('color: red; font-size: 12px;')
|
|
28
|
+
expect(out[1]).toBe('color: blue;')
|
|
29
|
+
expect(out[2]).toBe('font-size: 16px;')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('emits empty string when a later breakpoint adds no deltas', () => {
|
|
33
|
+
const out = optimizeBreakpointDeltas([
|
|
34
|
+
'color: red; padding: 0;',
|
|
35
|
+
'color: red; padding: 0;',
|
|
36
|
+
])
|
|
37
|
+
expect(out[0]).toBe('color: red; padding: 0;')
|
|
38
|
+
expect(out[1]).toBe('')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('passes through empty / null breakpoints unchanged', () => {
|
|
42
|
+
const out = optimizeBreakpointDeltas(['color: red;', '', 'color: blue;'])
|
|
43
|
+
expect(out[0]).toBe('color: red;')
|
|
44
|
+
expect(out[1]).toBe('')
|
|
45
|
+
expect(out[2]).toBe('color: blue;')
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('parser edge cases', () => {
|
|
50
|
+
it('skips colons inside parens (linear-gradient args)', () => {
|
|
51
|
+
const out = optimizeBreakpointDeltas([
|
|
52
|
+
'background: linear-gradient(red 0%, blue 100%);',
|
|
53
|
+
'background: linear-gradient(red 0%, blue 100%);',
|
|
54
|
+
])
|
|
55
|
+
expect(out[0]).toBe('background: linear-gradient(red 0%, blue 100%);')
|
|
56
|
+
// Same value cascades — delta is empty
|
|
57
|
+
expect(out[1]).toBe('')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('skips semicolons inside quoted strings (content: ";")', () => {
|
|
61
|
+
const out = optimizeBreakpointDeltas([
|
|
62
|
+
`content: ";"; color: red;`,
|
|
63
|
+
`content: ";"; color: blue;`,
|
|
64
|
+
])
|
|
65
|
+
// Both declarations parsed correctly on bp1; bp2 only color delta
|
|
66
|
+
expect(out[0]).toContain(`content: ";";`)
|
|
67
|
+
expect(out[0]).toContain('color: red;')
|
|
68
|
+
expect(out[1]).toBe('color: blue;')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('treats nested selector blocks as opaque, deduped by exact text', () => {
|
|
72
|
+
const out = optimizeBreakpointDeltas([
|
|
73
|
+
'&:hover { color: red; } padding: 0;',
|
|
74
|
+
'&:hover { color: red; } padding: 1rem;',
|
|
75
|
+
])
|
|
76
|
+
// The hover block dedupes; padding delta survives
|
|
77
|
+
expect(out[1]).not.toContain('&:hover')
|
|
78
|
+
expect(out[1]).toContain('padding: 1rem;')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('keeps differently-shaped nested blocks across breakpoints', () => {
|
|
82
|
+
const out = optimizeBreakpointDeltas([
|
|
83
|
+
'&:hover { color: red; }',
|
|
84
|
+
'&:hover { color: blue; }',
|
|
85
|
+
])
|
|
86
|
+
expect(out[0]).toContain('&:hover { color: red; }')
|
|
87
|
+
// Different inner text → not deduped
|
|
88
|
+
expect(out[1]).toContain('&:hover { color: blue; }')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('handles trailing declarations with no terminating semicolon', () => {
|
|
92
|
+
const out = optimizeBreakpointDeltas(['color: red', 'color: blue'])
|
|
93
|
+
expect(out[0]).toBe('color: red;')
|
|
94
|
+
expect(out[1]).toBe('color: blue;')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('preserves @supports / @media-style nested blocks as opaque blocks', () => {
|
|
98
|
+
const out = optimizeBreakpointDeltas([
|
|
99
|
+
'@supports (display: grid) { display: grid; }',
|
|
100
|
+
'@supports (display: grid) { display: grid; } color: red;',
|
|
101
|
+
])
|
|
102
|
+
expect(out[0]).toBe('@supports (display: grid) { display: grid; }')
|
|
103
|
+
// @supports block dedupes; color is new
|
|
104
|
+
expect(out[1]).toBe('color: red;')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('keeps shorthand and longhand decls separately (no shorthand modeling)', () => {
|
|
108
|
+
const out = optimizeBreakpointDeltas([
|
|
109
|
+
'padding: 1rem;',
|
|
110
|
+
'padding-top: 0;',
|
|
111
|
+
])
|
|
112
|
+
// Different `prop` keys → both retained
|
|
113
|
+
expect(out[0]).toBe('padding: 1rem;')
|
|
114
|
+
expect(out[1]).toBe('padding-top: 0;')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('keeps malformed declaration-shaped fragments without losing them', () => {
|
|
118
|
+
const out = optimizeBreakpointDeltas([':abc;', ':abc;'])
|
|
119
|
+
// No prop name (starts with `:`) → kept as opaque block; deduped on bp2
|
|
120
|
+
expect(out[0]).toBe(':abc;')
|
|
121
|
+
expect(out[1]).toBe('')
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
})
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import styles from '../styles/styles/index'
|
|
3
|
+
|
|
4
|
+
const mockCss = (strings: TemplateStringsArray, ...vals: any[]) => {
|
|
5
|
+
let r = ''
|
|
6
|
+
for (let i = 0; i < strings.length; i++) {
|
|
7
|
+
r += strings[i]
|
|
8
|
+
if (i < vals.length) r += String(vals[i])
|
|
9
|
+
}
|
|
10
|
+
return r
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Regression: kind: 'special' descriptors (`fullScreen`, `hideEmpty`,
|
|
14
|
+
// `clearFix`, `extendCss`, `backgroundImage`, `animation`) only carry an
|
|
15
|
+
// `id` field — no `key` / `keys`. The keyToIndices builder used to walk
|
|
16
|
+
// only `d.key` / `d.keys`, so special descriptors were never indexed.
|
|
17
|
+
//
|
|
18
|
+
// In single-special-property themes the bug was masked by the fallback
|
|
19
|
+
// path (`if (fragments.length === 0 && Object.keys(t).length > 0)` triggers
|
|
20
|
+
// a full-scan that hits processSpecial). The moment ANY non-special key is
|
|
21
|
+
// also present in the theme — the real-world shape, e.g. `<Overlay>` with
|
|
22
|
+
// `{ fullScreen: true, background: 'rgba(0,0,0,0.5)' }` — the fast path
|
|
23
|
+
// processes `background`, fragments.length === 1, fallback skipped, the
|
|
24
|
+
// special is silently dropped.
|
|
25
|
+
//
|
|
26
|
+
// Fix: index `d.id` alongside `d.key` / `d.keys` so the fast path resolves
|
|
27
|
+
// special descriptors directly.
|
|
28
|
+
describe('kind: special descriptors paired with non-special properties', () => {
|
|
29
|
+
it('fullScreen + background → both render', () => {
|
|
30
|
+
const result = styles({
|
|
31
|
+
theme: { fullScreen: true, background: 'rgba(0,0,0,0.5)' },
|
|
32
|
+
css: mockCss,
|
|
33
|
+
rootSize: 16,
|
|
34
|
+
})
|
|
35
|
+
const output = String(result)
|
|
36
|
+
expect(output).toContain('position: fixed;')
|
|
37
|
+
expect(output).toContain('top: 0;')
|
|
38
|
+
expect(output).toContain('background: rgba(0,0,0,0.5);')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('hideEmpty + color → both render', () => {
|
|
42
|
+
const result = styles({
|
|
43
|
+
theme: { hideEmpty: true, color: 'red' },
|
|
44
|
+
css: mockCss,
|
|
45
|
+
rootSize: 16,
|
|
46
|
+
})
|
|
47
|
+
const normalized = String(result).replace(/\s+/g, ' ')
|
|
48
|
+
expect(normalized).toContain('&:empty { display: none; }')
|
|
49
|
+
expect(normalized).toContain('color: red;')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('clearFix + padding → both render', () => {
|
|
53
|
+
const result = styles({
|
|
54
|
+
theme: { clearFix: true, padding: 8 },
|
|
55
|
+
css: mockCss,
|
|
56
|
+
rootSize: 16,
|
|
57
|
+
})
|
|
58
|
+
const normalized = String(result).replace(/\s+/g, ' ')
|
|
59
|
+
expect(normalized).toContain("&::after { clear: both; content: ''; display: table; }")
|
|
60
|
+
expect(normalized).toContain('padding:')
|
|
61
|
+
expect(normalized).toContain('0.5rem')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('extendCss + color → both render', () => {
|
|
65
|
+
const result = styles({
|
|
66
|
+
theme: { extendCss: 'border: 1px solid red;', color: 'blue' },
|
|
67
|
+
css: mockCss,
|
|
68
|
+
rootSize: 16,
|
|
69
|
+
})
|
|
70
|
+
const output = String(result)
|
|
71
|
+
expect(output).toContain('border: 1px solid red;')
|
|
72
|
+
expect(output).toContain('color: blue;')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('backgroundImage + color → both render', () => {
|
|
76
|
+
const result = styles({
|
|
77
|
+
theme: { backgroundImage: 'https://example.com/img.png', color: 'green' },
|
|
78
|
+
css: mockCss,
|
|
79
|
+
rootSize: 16,
|
|
80
|
+
})
|
|
81
|
+
const output = String(result)
|
|
82
|
+
expect(output).toContain('background-image: url(https://example.com/img.png);')
|
|
83
|
+
expect(output).toContain('color: green;')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('animation + color → both render', () => {
|
|
87
|
+
const result = styles({
|
|
88
|
+
theme: { animation: 'fadeIn 1s ease-in', color: 'purple' },
|
|
89
|
+
css: mockCss,
|
|
90
|
+
rootSize: 16,
|
|
91
|
+
})
|
|
92
|
+
const output = String(result)
|
|
93
|
+
expect(output).toContain('animation:')
|
|
94
|
+
expect(output).toContain('fadeIn 1s ease-in')
|
|
95
|
+
expect(output).toContain('color: purple;')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('multiple specials + non-specials → all render', () => {
|
|
99
|
+
const result = styles({
|
|
100
|
+
theme: {
|
|
101
|
+
fullScreen: true,
|
|
102
|
+
hideEmpty: true,
|
|
103
|
+
clearFix: true,
|
|
104
|
+
extendCss: 'outline: 2px dashed orange;',
|
|
105
|
+
color: 'red',
|
|
106
|
+
padding: 16,
|
|
107
|
+
},
|
|
108
|
+
css: mockCss,
|
|
109
|
+
rootSize: 16,
|
|
110
|
+
})
|
|
111
|
+
const normalized = String(result).replace(/\s+/g, ' ')
|
|
112
|
+
expect(normalized).toContain('position: fixed;')
|
|
113
|
+
expect(normalized).toContain('&:empty { display: none; }')
|
|
114
|
+
expect(normalized).toContain("&::after { clear: both; content: ''; display: table; }")
|
|
115
|
+
expect(normalized).toContain('outline: 2px dashed orange;')
|
|
116
|
+
expect(normalized).toContain('color: red;')
|
|
117
|
+
expect(normalized).toContain('padding:')
|
|
118
|
+
expect(normalized).toContain('1rem')
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -212,3 +212,62 @@ describe('Tier 1: performance characteristics', () => {
|
|
|
212
212
|
// the indexed path should always find matches.
|
|
213
213
|
})
|
|
214
214
|
})
|
|
215
|
+
|
|
216
|
+
// Regression test for PR #283's `_fragments` reuse — the module-level array
|
|
217
|
+
// was captured by reference inside the returned CSSResult's values, so the
|
|
218
|
+
// next styles() call would clear the previous result's data before its
|
|
219
|
+
// consumer ever resolved it.
|
|
220
|
+
//
|
|
221
|
+
// Pre-fix: r1.values[0] (the fragments array) was the SAME reference as the
|
|
222
|
+
// module-level array; the second styles() call ran `_fragments.length = 0`
|
|
223
|
+
// and wiped r1's fragments to []. Post-fix: each call gets its own array.
|
|
224
|
+
//
|
|
225
|
+
// Real-app symptom this caused: rocketstyle dimension themes (state="primary"
|
|
226
|
+
// → blue background) produced empty CSS because element.ts calls
|
|
227
|
+
// makeItResponsive 5 times (base/hover/focus/active/disabled), each calling
|
|
228
|
+
// styles() under the hood. Only the LAST one kept its data; the rest
|
|
229
|
+
// resolved empty. See `packages/ui/components/src/bases/element.ts`.
|
|
230
|
+
describe('regression: CSSResult ownership of fragments array (PR #283 follow-up)', () => {
|
|
231
|
+
// Lazy-capturing mock: stores strings + values without resolving, mimicking
|
|
232
|
+
// the real CSSResult contract where consumers resolve later.
|
|
233
|
+
const lazyCss = (strings: TemplateStringsArray, ...values: unknown[]) => ({
|
|
234
|
+
strings,
|
|
235
|
+
values,
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('first result retains its fragments after a second styles() call', () => {
|
|
239
|
+
const r1 = styles({
|
|
240
|
+
theme: { color: 'red', fontSize: 14 },
|
|
241
|
+
css: lazyCss as never,
|
|
242
|
+
rootSize: 16,
|
|
243
|
+
})
|
|
244
|
+
const r1Fragments = (r1 as { values: unknown[] }).values[0]
|
|
245
|
+
const r1LenBefore = Array.isArray(r1Fragments) ? r1Fragments.length : -1
|
|
246
|
+
expect(r1LenBefore).toBeGreaterThan(0)
|
|
247
|
+
|
|
248
|
+
// Second call — pre-fix this cleared r1's array via shared module-level
|
|
249
|
+
// reference. Post-fix: each call owns its array.
|
|
250
|
+
styles({
|
|
251
|
+
theme: { backgroundColor: 'blue', padding: 8 },
|
|
252
|
+
css: lazyCss as never,
|
|
253
|
+
rootSize: 16,
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
const r1FragmentsAfter = (r1 as { values: unknown[] }).values[0]
|
|
257
|
+
const r1LenAfter = Array.isArray(r1FragmentsAfter) ? r1FragmentsAfter.length : -1
|
|
258
|
+
expect(r1LenAfter).toBe(r1LenBefore)
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
it('two results from sequential calls have INDEPENDENT fragments arrays', () => {
|
|
262
|
+
const r1 = styles({ theme: { color: 'red' }, css: lazyCss as never, rootSize: 16 })
|
|
263
|
+
const r2 = styles({ theme: { backgroundColor: 'blue' }, css: lazyCss as never, rootSize: 16 })
|
|
264
|
+
|
|
265
|
+
const r1Fragments = (r1 as { values: unknown[] }).values[0]
|
|
266
|
+
const r2Fragments = (r2 as { values: unknown[] }).values[0]
|
|
267
|
+
// Different array identities — r1 is not r2.
|
|
268
|
+
expect(r1Fragments).not.toBe(r2Fragments)
|
|
269
|
+
// Both populated.
|
|
270
|
+
expect(Array.isArray(r1Fragments) && r1Fragments.length).toBeGreaterThan(0)
|
|
271
|
+
expect(Array.isArray(r2Fragments) && r2Fragments.length).toBeGreaterThan(0)
|
|
272
|
+
})
|
|
273
|
+
})
|
package/src/context.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { VNode } from '@pyreon/core'
|
|
2
|
-
import { provide } from '@pyreon/core'
|
|
2
|
+
import { nativeCompat, provide } from '@pyreon/core'
|
|
3
3
|
import { ThemeContext } from '@pyreon/styler'
|
|
4
4
|
import { Provider as CoreProvider, context } from '@pyreon/ui-core'
|
|
5
5
|
import type { PyreonTheme } from './enrichTheme'
|
|
@@ -35,6 +35,10 @@ function Provider(props: TProvider): VNode | null {
|
|
|
35
35
|
return CoreProvider({ theme: enrichedTheme, children }) as VNode | null
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// Mark as native — invoked by PyreonUI internally; needs Pyreon's setup
|
|
39
|
+
// frame for provide(ThemeContext, ...) to reach descendants.
|
|
40
|
+
nativeCompat(Provider)
|
|
41
|
+
|
|
38
42
|
export { context }
|
|
39
43
|
|
|
40
44
|
export default Provider
|
package/src/env.d.ts
ADDED
package/src/responsive/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ export type { MakeItResponsive, MakeItResponsiveStyles } from './makeItResponsiv
|
|
|
6
6
|
export { default as makeItResponsive } from './makeItResponsive'
|
|
7
7
|
export type { NormalizeTheme } from './normalizeTheme'
|
|
8
8
|
export { default as normalizeTheme } from './normalizeTheme'
|
|
9
|
+
export { default as optimizeBreakpointDeltas } from './optimizeBreakpointDeltas'
|
|
9
10
|
export type { OptimizeTheme } from './optimizeTheme'
|
|
10
11
|
export { default as optimizeTheme } from './optimizeTheme'
|
|
11
12
|
export type { SortBreakpoints } from './sortBreakpoints'
|
|
@@ -1,12 +1,40 @@
|
|
|
1
1
|
import { isEmpty } from '@pyreon/ui-core'
|
|
2
2
|
import type createMediaQueries from './createMediaQueries'
|
|
3
3
|
import normalizeTheme from './normalizeTheme'
|
|
4
|
+
import optimizeBreakpointDeltas from './optimizeBreakpointDeltas'
|
|
4
5
|
import optimizeTheme from './optimizeTheme'
|
|
5
6
|
import type sortBreakpoints from './sortBreakpoints'
|
|
6
7
|
import transformTheme from './transformTheme'
|
|
7
8
|
|
|
8
9
|
type Css = (strings: TemplateStringsArray, ...values: any[]) => any
|
|
9
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Coerce a styles-callback result to a CSS string for delta optimization.
|
|
13
|
+
* Returns null when the engine's result type can't be stringified cleanly
|
|
14
|
+
* (e.g. styled-components / Emotion objects whose default toString() yields
|
|
15
|
+
* "[object Object]") — caller falls back to the unoptimized path.
|
|
16
|
+
*
|
|
17
|
+
* Styler's CSSResult provides toString() that resolves with empty props,
|
|
18
|
+
* so any function interpolation that needs render-time props must come from
|
|
19
|
+
* the styles-callback closure (theme is destructured at call time, not
|
|
20
|
+
* resolved later). Verified across the project's styles callbacks.
|
|
21
|
+
*/
|
|
22
|
+
const stringifyResult = (result: unknown): string | null => {
|
|
23
|
+
if (result == null) return ''
|
|
24
|
+
if (typeof result === 'string') return result
|
|
25
|
+
// CSSResult duck-type fast path: has `strings` (TemplateStringsArray) and
|
|
26
|
+
// `values`. We know its toString() resolves to clean CSS, so we can skip
|
|
27
|
+
// the "[object Foo]" validation for the common path.
|
|
28
|
+
if (typeof result === 'object' && 'strings' in result && 'values' in result) {
|
|
29
|
+
return String(result)
|
|
30
|
+
}
|
|
31
|
+
// Foreign engine result — coerce and validate. Default
|
|
32
|
+
// Object.prototype.toString → "[object Foo]" → bail out so caller can fall
|
|
33
|
+
// back to the unoptimized path.
|
|
34
|
+
const text = String(result)
|
|
35
|
+
return text.includes('[object ') ? null : text
|
|
36
|
+
}
|
|
37
|
+
|
|
10
38
|
type CustomTheme = Record<string, unknown>
|
|
11
39
|
|
|
12
40
|
type Theme = Partial<{
|
|
@@ -45,16 +73,45 @@ export type MakeItResponsive = ({
|
|
|
45
73
|
normalize?: boolean
|
|
46
74
|
}) => (props: { theme?: Theme; [prop: string]: any }) => any
|
|
47
75
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Per-internal-theme cache:
|
|
78
|
+
* - `optimized`: the per-breakpoint theme object (`{ xs: {...}, md: {...} }`)
|
|
79
|
+
* after `normalize → transform → optimize`. Reused as long as the same
|
|
80
|
+
* `sortedBreakpoints` reference is passed in.
|
|
81
|
+
* - `rendered`: memoized FINAL output (array of media-wrapped CSSResults),
|
|
82
|
+
* keyed by the outer `theme` reference. Hit when the same internal theme
|
|
83
|
+
* AND the same outer theme render again — which is the common case when
|
|
84
|
+
* the provider value is stable. Avoids re-running renderStyles +
|
|
85
|
+
* optimizeBreakpointDeltas on every parent re-render.
|
|
86
|
+
*/
|
|
87
|
+
interface ThemeCacheEntry {
|
|
88
|
+
breakpoints: unknown
|
|
89
|
+
optimized: Record<string, Record<string, unknown>>
|
|
90
|
+
rendered?: WeakMap<object, unknown[]> | undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const themeCache = new WeakMap<object, ThemeCacheEntry>()
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Core responsive engine used by every styled component in the system.
|
|
97
|
+
*
|
|
98
|
+
* Returns a styled-components interpolation function that:
|
|
99
|
+
* 1. Reads the component's theme prop (via `key` or direct `theme`)
|
|
100
|
+
* 2. Without breakpoints → renders plain CSS
|
|
101
|
+
* 3. With breakpoints → normalizes, transforms (property-per-breakpoint →
|
|
102
|
+
* breakpoint-per-property), optimizes (deduplicates identical breakpoints),
|
|
103
|
+
* deltas the per-breakpoint output against the mobile-first cascade
|
|
104
|
+
* (drops re-emitted unchanged declarations), and wraps each non-empty
|
|
105
|
+
* breakpoint's deltas in the appropriate `@media` query. Falls back to
|
|
106
|
+
* the unoptimized path if any breakpoint's render result can't be
|
|
107
|
+
* cleanly stringified.
|
|
108
|
+
*/
|
|
53
109
|
const makeItResponsive: MakeItResponsive =
|
|
54
110
|
({ theme: customTheme, key = '', css, styles, normalize = true }) =>
|
|
55
111
|
({ theme = {}, ...props }) => {
|
|
56
112
|
const internalTheme = customTheme || props[key]
|
|
57
113
|
|
|
114
|
+
// if no theme is defined, return empty object
|
|
58
115
|
if (isEmpty(internalTheme)) return ''
|
|
59
116
|
|
|
60
117
|
const { rootSize, breakpoints, __PYREON__, ...restTheme } = theme as Theme
|
|
@@ -62,6 +119,7 @@ const makeItResponsive: MakeItResponsive =
|
|
|
62
119
|
const renderStyles = (styleTheme: Record<string, unknown>): ReturnType<typeof styles> =>
|
|
63
120
|
styles({ theme: styleTheme, css, rootSize, globalTheme: restTheme })
|
|
64
121
|
|
|
122
|
+
// if there are no breakpoints, return just standard css
|
|
65
123
|
if (isEmpty(breakpoints) || isEmpty(__PYREON__)) {
|
|
66
124
|
return css`
|
|
67
125
|
${renderStyles(internalTheme)}
|
|
@@ -72,47 +130,94 @@ const makeItResponsive: MakeItResponsive =
|
|
|
72
130
|
const { media, sortedBreakpoints } = __PYREON__ as NonNullable<typeof __PYREON__>
|
|
73
131
|
|
|
74
132
|
let optimizedTheme: Record<string, Record<string, unknown>>
|
|
133
|
+
const entry = themeCache.get(internalTheme)
|
|
134
|
+
const breakpointsMatch = entry?.breakpoints === sortedBreakpoints
|
|
135
|
+
|
|
136
|
+
// Full-render cache: same internal theme + same outer theme → return
|
|
137
|
+
// the previous render's output verbatim. CSSResult instances are
|
|
138
|
+
// immutable so reusing them is safe.
|
|
139
|
+
if (entry && breakpointsMatch && entry.rendered) {
|
|
140
|
+
const memoized = entry.rendered.get(theme as object)
|
|
141
|
+
if (memoized) return memoized
|
|
142
|
+
}
|
|
75
143
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
optimizedTheme = cached.optimized
|
|
144
|
+
if (entry && breakpointsMatch) {
|
|
145
|
+
optimizedTheme = entry.optimized
|
|
79
146
|
} else {
|
|
80
147
|
let helperTheme = internalTheme
|
|
81
148
|
|
|
82
149
|
if (normalize) {
|
|
83
150
|
helperTheme = normalizeTheme({
|
|
84
151
|
theme: internalTheme,
|
|
85
|
-
breakpoints: sortedBreakpoints,
|
|
152
|
+
breakpoints: sortedBreakpoints ?? [],
|
|
86
153
|
})
|
|
87
154
|
}
|
|
88
155
|
|
|
89
156
|
const transformedTheme = transformTheme({
|
|
90
157
|
theme: helperTheme,
|
|
91
|
-
breakpoints: sortedBreakpoints,
|
|
158
|
+
breakpoints: sortedBreakpoints ?? [],
|
|
92
159
|
})
|
|
93
160
|
|
|
94
161
|
optimizedTheme = optimizeTheme({
|
|
95
162
|
theme: transformedTheme,
|
|
96
|
-
breakpoints: sortedBreakpoints,
|
|
163
|
+
breakpoints: sortedBreakpoints ?? [],
|
|
97
164
|
})
|
|
98
165
|
|
|
99
166
|
themeCache.set(internalTheme, {
|
|
100
167
|
breakpoints: sortedBreakpoints,
|
|
101
168
|
optimized: optimizedTheme,
|
|
169
|
+
// Preserve any pre-existing rendered cache when re-entering with a
|
|
170
|
+
// changed sortedBreakpoints reference — usually unreachable because
|
|
171
|
+
// breakpoints come from a stable provider value, but the explicit
|
|
172
|
+
// handling avoids a memory cliff in tests / HMR.
|
|
173
|
+
rendered: entry?.rendered,
|
|
102
174
|
})
|
|
103
175
|
}
|
|
104
176
|
|
|
105
|
-
|
|
106
|
-
const breakpointTheme = optimizedTheme[item]
|
|
177
|
+
const bps = sortedBreakpoints ?? []
|
|
107
178
|
|
|
179
|
+
// Resolve each per-breakpoint render to a string so the delta optimizer
|
|
180
|
+
// can diff at the property level. If any breakpoint's result can't be
|
|
181
|
+
// cleanly stringified (foreign engine result), fall back to the original
|
|
182
|
+
// unoptimized path that lets the engine resolve interpolations itself.
|
|
183
|
+
const renderedTexts: (string | null)[] = bps.map((item: string) => {
|
|
184
|
+
const breakpointTheme = optimizedTheme[item]
|
|
108
185
|
if (!breakpointTheme || !media) return ''
|
|
186
|
+
return stringifyResult(renderStyles(breakpointTheme))
|
|
187
|
+
})
|
|
109
188
|
|
|
110
|
-
|
|
189
|
+
const canOptimize = renderedTexts.every((t) => t !== null)
|
|
190
|
+
let result: unknown[]
|
|
191
|
+
if (canOptimize) {
|
|
192
|
+
const deltas = optimizeBreakpointDeltas(renderedTexts as string[])
|
|
193
|
+
result = bps.map((item: string, i: number) => {
|
|
194
|
+
const cssText = deltas[i]
|
|
195
|
+
if (!cssText || !media) return ''
|
|
196
|
+
return (media as Record<string, any>)[item]`${cssText}`
|
|
197
|
+
})
|
|
198
|
+
} else {
|
|
199
|
+
result = bps.map((item: string) => {
|
|
200
|
+
const breakpointTheme = optimizedTheme[item]
|
|
201
|
+
if (!breakpointTheme || !media) return ''
|
|
202
|
+
const r = renderStyles(breakpointTheme)
|
|
203
|
+
return (media as Record<string, any>)[item]`
|
|
204
|
+
${r};
|
|
205
|
+
`
|
|
206
|
+
})
|
|
207
|
+
}
|
|
111
208
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
209
|
+
// Memoize the final rendered output by outer theme reference. Stable
|
|
210
|
+
// theme + stable internal theme → future renders return immediately.
|
|
211
|
+
// Invariant: by this point themeCache always has an entry for
|
|
212
|
+
// internalTheme — earlier paths either hit the rendered-cache and
|
|
213
|
+
// returned, or wrote one via themeCache.set above.
|
|
214
|
+
const cacheEntry = themeCache.get(internalTheme)
|
|
215
|
+
if (cacheEntry) {
|
|
216
|
+
if (!cacheEntry.rendered) cacheEntry.rendered = new WeakMap()
|
|
217
|
+
cacheEntry.rendered.set(theme as object, result)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return result
|
|
116
221
|
}
|
|
117
222
|
|
|
118
223
|
export default makeItResponsive
|