@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.
@@ -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
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Minimal process type — just enough for `process.env.NODE_ENV` checks.
3
+ * Avoids requiring @types/node in consumers that import pyreon source
4
+ * via the `"bun"` export condition.
5
+ */
6
+ declare var process: { env: { NODE_ENV?: string } }
@@ -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
- const themeCache = new WeakMap<
49
- object,
50
- { breakpoints: unknown; optimized: Record<string, Record<string, unknown>> }
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
- const cached = themeCache.get(internalTheme)
77
- if (cached && cached.breakpoints === sortedBreakpoints) {
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
- return sortedBreakpoints.map((item: string) => {
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
- const result = renderStyles(breakpointTheme)
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
- return (media as Record<string, any>)[item]`
113
- ${result};
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