@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 CHANGED
@@ -106,6 +106,20 @@ type MakeItResponsive = ({
106
106
  theme?: Theme$1;
107
107
  [prop: string]: any;
108
108
  }) => any;
109
+ /**
110
+ * Core responsive engine used by every styled component in the system.
111
+ *
112
+ * Returns a styled-components interpolation function that:
113
+ * 1. Reads the component's theme prop (via `key` or direct `theme`)
114
+ * 2. Without breakpoints → renders plain CSS
115
+ * 3. With breakpoints → normalizes, transforms (property-per-breakpoint →
116
+ * breakpoint-per-property), optimizes (deduplicates identical breakpoints),
117
+ * deltas the per-breakpoint output against the mobile-first cascade
118
+ * (drops re-emitted unchanged declarations), and wraps each non-empty
119
+ * breakpoint's deltas in the appropriate `@media` query. Falls back to
120
+ * the unoptimized path if any breakpoint's render result can't be
121
+ * cleanly stringified.
122
+ */
109
123
  declare const makeItResponsive: MakeItResponsive;
110
124
  //#endregion
111
125
  //#region src/responsive/normalizeTheme.d.ts
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { provide } from "@pyreon/core";
1
+ import { nativeCompat, provide } from "@pyreon/core";
2
2
  import { ThemeContext } from "@pyreon/styler";
3
3
  import { Provider as Provider$1, config, context, isEmpty, set } from "@pyreon/ui-core";
4
4
 
@@ -70,6 +70,127 @@ const normalizeTheme = ({ theme, breakpoints }) => {
70
70
  return result;
71
71
  };
72
72
 
73
+ //#endregion
74
+ //#region src/responsive/optimizeBreakpointDeltas.ts
75
+ /** Parse a CSS string into top-level declarations and opaque blocks. */
76
+ const parse = (css) => {
77
+ const entries = [];
78
+ const len = css.length;
79
+ let depth = 0;
80
+ let parenDepth = 0;
81
+ let quote = 0;
82
+ let segmentStart = 0;
83
+ const pushSegment = (rawSegment) => {
84
+ const trimmed = rawSegment.trim();
85
+ if (!trimmed) return;
86
+ const text = trimmed.endsWith(";") ? trimmed.slice(0, -1) : trimmed;
87
+ const colonIdx = text.indexOf(":");
88
+ if (colonIdx <= 0) {
89
+ entries.push({
90
+ kind: "block",
91
+ raw: `${text};`
92
+ });
93
+ return;
94
+ }
95
+ const prop = text.slice(0, colonIdx).trim();
96
+ const value = text.slice(colonIdx + 1).trim();
97
+ if (!prop || !value) {
98
+ entries.push({
99
+ kind: "block",
100
+ raw: `${text};`
101
+ });
102
+ return;
103
+ }
104
+ entries.push({
105
+ kind: "decl",
106
+ prop,
107
+ value,
108
+ raw: `${prop}: ${value};`
109
+ });
110
+ };
111
+ for (let i = 0; i < len; i++) {
112
+ const code = css.charCodeAt(i);
113
+ if (quote !== 0) {
114
+ if (code === 92) i++;
115
+ else if (code === quote) quote = 0;
116
+ continue;
117
+ }
118
+ if (code === 34 || code === 39) {
119
+ quote = code;
120
+ continue;
121
+ }
122
+ if (code === 40) {
123
+ parenDepth++;
124
+ continue;
125
+ }
126
+ if (code === 41) {
127
+ if (parenDepth > 0) parenDepth--;
128
+ continue;
129
+ }
130
+ if (parenDepth > 0) continue;
131
+ if (code === 123) {
132
+ depth++;
133
+ continue;
134
+ }
135
+ if (code === 125) {
136
+ depth--;
137
+ if (depth === 0) {
138
+ const raw = css.slice(segmentStart, i + 1).trim();
139
+ if (raw) entries.push({
140
+ kind: "block",
141
+ raw
142
+ });
143
+ segmentStart = i + 1;
144
+ }
145
+ continue;
146
+ }
147
+ if (depth === 0 && code === 59) {
148
+ pushSegment(css.slice(segmentStart, i));
149
+ segmentStart = i + 1;
150
+ }
151
+ }
152
+ if (segmentStart < len) {
153
+ const trailing = css.slice(segmentStart).trim();
154
+ if (trailing) if (depth > 0) entries.push({
155
+ kind: "block",
156
+ raw: trailing
157
+ });
158
+ else pushSegment(trailing);
159
+ }
160
+ return entries;
161
+ };
162
+ /**
163
+ * Apply the mobile-first cascade diff. The first entry passes through
164
+ * unchanged; subsequent entries are pruned to the delta vs. the running
165
+ * cascade (declarations by prop, blocks by exact text match).
166
+ */
167
+ const optimizeBreakpointDeltas = (cssStrings) => {
168
+ if (cssStrings.length <= 1) return cssStrings;
169
+ const cascadeDecl = /* @__PURE__ */ new Map();
170
+ const cascadeBlocks = /* @__PURE__ */ new Set();
171
+ const out = new Array(cssStrings.length);
172
+ for (let i = 0; i < cssStrings.length; i++) {
173
+ const css = cssStrings[i];
174
+ if (!css) {
175
+ out[i] = "";
176
+ continue;
177
+ }
178
+ const entries = parse(css);
179
+ const kept = [];
180
+ for (const e of entries) if (e.kind === "decl") {
181
+ if (cascadeDecl.get(e.prop) !== e.value) {
182
+ kept.push(e.raw);
183
+ cascadeDecl.set(e.prop, e.value);
184
+ }
185
+ } else if (!cascadeBlocks.has(e.raw)) {
186
+ kept.push(e.raw);
187
+ cascadeBlocks.add(e.raw);
188
+ }
189
+ out[i] = kept.join(" ");
190
+ }
191
+ return out;
192
+ };
193
+
73
194
  //#endregion
74
195
  //#region src/responsive/optimizeTheme.ts
75
196
  const shallowEqual = (a, b) => {
@@ -141,7 +262,39 @@ const transformTheme = ({ theme, breakpoints }) => {
141
262
 
142
263
  //#endregion
143
264
  //#region src/responsive/makeItResponsive.ts
265
+ /**
266
+ * Coerce a styles-callback result to a CSS string for delta optimization.
267
+ * Returns null when the engine's result type can't be stringified cleanly
268
+ * (e.g. styled-components / Emotion objects whose default toString() yields
269
+ * "[object Object]") — caller falls back to the unoptimized path.
270
+ *
271
+ * Styler's CSSResult provides toString() that resolves with empty props,
272
+ * so any function interpolation that needs render-time props must come from
273
+ * the styles-callback closure (theme is destructured at call time, not
274
+ * resolved later). Verified across the project's styles callbacks.
275
+ */
276
+ const stringifyResult = (result) => {
277
+ if (result == null) return "";
278
+ if (typeof result === "string") return result;
279
+ if (typeof result === "object" && "strings" in result && "values" in result) return String(result);
280
+ const text = String(result);
281
+ return text.includes("[object ") ? null : text;
282
+ };
144
283
  const themeCache = /* @__PURE__ */ new WeakMap();
284
+ /**
285
+ * Core responsive engine used by every styled component in the system.
286
+ *
287
+ * Returns a styled-components interpolation function that:
288
+ * 1. Reads the component's theme prop (via `key` or direct `theme`)
289
+ * 2. Without breakpoints → renders plain CSS
290
+ * 3. With breakpoints → normalizes, transforms (property-per-breakpoint →
291
+ * breakpoint-per-property), optimizes (deduplicates identical breakpoints),
292
+ * deltas the per-breakpoint output against the mobile-first cascade
293
+ * (drops re-emitted unchanged declarations), and wraps each non-empty
294
+ * breakpoint's deltas in the appropriate `@media` query. Falls back to
295
+ * the unoptimized path if any breakpoint's render result can't be
296
+ * cleanly stringified.
297
+ */
145
298
  const makeItResponsive = ({ theme: customTheme, key = "", css, styles, normalize = true }) => ({ theme = {}, ...props }) => {
146
299
  const internalTheme = customTheme || props[key];
147
300
  if (isEmpty(internalTheme)) return "";
@@ -157,34 +310,61 @@ const makeItResponsive = ({ theme: customTheme, key = "", css, styles, normalize
157
310
  `;
158
311
  const { media, sortedBreakpoints } = __PYREON__;
159
312
  let optimizedTheme;
160
- const cached = themeCache.get(internalTheme);
161
- if (cached && cached.breakpoints === sortedBreakpoints) optimizedTheme = cached.optimized;
313
+ const entry = themeCache.get(internalTheme);
314
+ const breakpointsMatch = entry?.breakpoints === sortedBreakpoints;
315
+ if (entry && breakpointsMatch && entry.rendered) {
316
+ const memoized = entry.rendered.get(theme);
317
+ if (memoized) return memoized;
318
+ }
319
+ if (entry && breakpointsMatch) optimizedTheme = entry.optimized;
162
320
  else {
163
321
  let helperTheme = internalTheme;
164
322
  if (normalize) helperTheme = normalizeTheme({
165
323
  theme: internalTheme,
166
- breakpoints: sortedBreakpoints
324
+ breakpoints: sortedBreakpoints ?? []
167
325
  });
168
326
  optimizedTheme = optimizeTheme({
169
327
  theme: transformTheme({
170
328
  theme: helperTheme,
171
- breakpoints: sortedBreakpoints
329
+ breakpoints: sortedBreakpoints ?? []
172
330
  }),
173
- breakpoints: sortedBreakpoints
331
+ breakpoints: sortedBreakpoints ?? []
174
332
  });
175
333
  themeCache.set(internalTheme, {
176
334
  breakpoints: sortedBreakpoints,
177
- optimized: optimizedTheme
335
+ optimized: optimizedTheme,
336
+ rendered: entry?.rendered
178
337
  });
179
338
  }
180
- return sortedBreakpoints.map((item) => {
339
+ const bps = sortedBreakpoints ?? [];
340
+ const renderedTexts = bps.map((item) => {
341
+ const breakpointTheme = optimizedTheme[item];
342
+ if (!breakpointTheme || !media) return "";
343
+ return stringifyResult(renderStyles(breakpointTheme));
344
+ });
345
+ const canOptimize = renderedTexts.every((t) => t !== null);
346
+ let result;
347
+ if (canOptimize) {
348
+ const deltas = optimizeBreakpointDeltas(renderedTexts);
349
+ result = bps.map((item, i) => {
350
+ const cssText = deltas[i];
351
+ if (!cssText || !media) return "";
352
+ return media[item]`${cssText}`;
353
+ });
354
+ } else result = bps.map((item) => {
181
355
  const breakpointTheme = optimizedTheme[item];
182
356
  if (!breakpointTheme || !media) return "";
183
- const result = renderStyles(breakpointTheme);
357
+ const r = renderStyles(breakpointTheme);
184
358
  return media[item]`
185
- ${result};
186
- `;
359
+ ${r};
360
+ `;
187
361
  });
362
+ const cacheEntry = themeCache.get(internalTheme);
363
+ if (cacheEntry) {
364
+ if (!cacheEntry.rendered) cacheEntry.rendered = /* @__PURE__ */ new WeakMap();
365
+ cacheEntry.rendered.set(theme, result);
366
+ }
367
+ return result;
188
368
  };
189
369
 
190
370
  //#endregion
@@ -245,6 +425,7 @@ function Provider(props) {
245
425
  children
246
426
  });
247
427
  }
428
+ nativeCompat(Provider);
248
429
 
249
430
  //#endregion
250
431
  //#region src/styles/alignContent.ts
@@ -1911,13 +2092,20 @@ for (let i = 0; i < propertyMap.length; i++) {
1911
2092
  };
1912
2093
  if (d.key) addKey(d.key);
1913
2094
  if (d.keys) if (Array.isArray(d.keys)) for (const k of d.keys) addKey(k);
1914
- else for (const k of Object.values(d.keys)) addKey(k);
2095
+ else for (const inner of Object.values(d.keys)) addKey(inner);
2096
+ if (d.id) addKey(d.id);
1915
2097
  }
1916
2098
  /**
1917
- * Data-driven style processor. Uses the pre-built key→index lookup to
1918
- * iterate ONLY the descriptors whose theme keys are present in the
1919
- * incoming theme object. Falls back to full scan only if the lookup
1920
- * produces zero matches (defensive shouldn't happen in practice).
2099
+ * Convert a normalized theme object (Record<key, value>) into a CSS template
2100
+ * by walking the property map. Each entry in propertyMap describes a single
2101
+ * CSS property its kind (simple / convert / convert_fallback / edge /
2102
+ * border_radius), the input theme key(s) to read, and the output CSS name.
2103
+ *
2104
+ * Returns a `css` tagged template literal so makeItResponsive can embed the
2105
+ * result inside the responsive breakpoint structure. Each call returns a
2106
+ * FRESH array — the result CSSResult holds onto that array by reference,
2107
+ * and reusing one module-level array across calls would clobber an earlier
2108
+ * CSSResult's data when the next styles() call clears the shared array.
1921
2109
  *
1922
2110
  * IMPORTANT: the return MUST be wrapped in `css\`...\`` — NOT a plain
1923
2111
  * string join. makeItResponsive embeds this result in another template
@@ -1926,36 +2114,33 @@ for (let i = 0; i < propertyMap.length; i++) {
1926
2114
  * pseudo-selectors, and @layer wrapping.
1927
2115
  */
1928
2116
  const _seen = /* @__PURE__ */ new Set();
1929
- const _fragments = [];
1930
2117
  const styles = ({ theme: t, css, rootSize }) => {
1931
- if (import.meta.env?.DEV === true) _countSink.__pyreon_count__?.("unistyle.styles");
2118
+ if (process.env.NODE_ENV !== "production") _countSink.__pyreon_count__?.("unistyle.styles");
1932
2119
  const calc = (...params) => values(params, rootSize);
1933
2120
  const shorthand = edge(rootSize);
1934
2121
  const borderRadiusFn = borderRadius(rootSize);
2122
+ const fragments = [];
1935
2123
  _seen.clear();
1936
- _fragments.length = 0;
1937
2124
  for (const key of Object.keys(t)) {
1938
2125
  const indices = keyToIndices.get(key);
1939
2126
  if (!indices) continue;
1940
2127
  for (const idx of indices) {
1941
2128
  if (_seen.has(idx)) continue;
1942
2129
  _seen.add(idx);
1943
- if (import.meta.env?.DEV === true) _countSink.__pyreon_count__?.("unistyle.descriptor");
1944
- _fragments.push(processDescriptor(propertyMap[idx], t, css, calc, shorthand, borderRadiusFn));
2130
+ if (process.env.NODE_ENV !== "production") _countSink.__pyreon_count__?.("unistyle.descriptor");
2131
+ fragments.push(processDescriptor(propertyMap[idx], t, css, calc, shorthand, borderRadiusFn));
1945
2132
  }
1946
2133
  }
1947
- if (_fragments.length === 0 && Object.keys(t).length > 0) {
1948
- if (import.meta.env?.DEV === true) _countSink.__pyreon_count__?.("unistyle.descriptor.fallback-scan");
2134
+ if (fragments.length === 0 && Object.keys(t).length > 0) {
2135
+ if (process.env.NODE_ENV !== "production") _countSink.__pyreon_count__?.("unistyle.descriptor.fallback-scan");
1949
2136
  for (const d of propertyMap) {
1950
- if (import.meta.env?.DEV === true) _countSink.__pyreon_count__?.("unistyle.descriptor");
1951
- _fragments.push(processDescriptor(d, t, css, calc, shorthand, borderRadiusFn));
2137
+ if (process.env.NODE_ENV !== "production") _countSink.__pyreon_count__?.("unistyle.descriptor");
2138
+ fragments.push(processDescriptor(d, t, css, calc, shorthand, borderRadiusFn));
1952
2139
  }
1953
2140
  }
1954
- const result = css`
1955
- ${_fragments}
2141
+ return css`
2142
+ ${fragments}
1956
2143
  `;
1957
- _fragments.length = 0;
1958
- return result;
1959
2144
  };
1960
2145
 
1961
2146
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/unistyle",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "Responsive theming and breakpoint utilities for Pyreon",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -10,6 +10,7 @@
10
10
  },
11
11
  "files": [
12
12
  "lib",
13
+ "!lib/**/*.map",
13
14
  "!lib/analysis",
14
15
  "README.md",
15
16
  "LICENSE",
@@ -42,18 +43,18 @@
42
43
  },
43
44
  "devDependencies": {
44
45
  "@pyreon/manifest": "0.13.1",
45
- "@pyreon/test-utils": "^0.13.2",
46
- "@pyreon/typescript": "^0.14.0",
46
+ "@pyreon/test-utils": "^0.13.3",
47
+ "@pyreon/typescript": "^0.16.0",
47
48
  "@vitest/browser-playwright": "^4.1.4",
48
- "@vitus-labs/tools-rolldown": "^1.15.3"
49
- },
50
- "peerDependencies": {
51
- "@pyreon/core": "^0.14.0",
52
- "@pyreon/reactivity": "^0.14.0",
53
- "@pyreon/styler": "^0.14.0",
54
- "@pyreon/ui-core": "^0.14.0"
49
+ "@vitus-labs/tools-rolldown": "^2.3.0"
55
50
  },
56
51
  "engines": {
57
52
  "node": ">= 22"
53
+ },
54
+ "dependencies": {
55
+ "@pyreon/core": "^0.16.0",
56
+ "@pyreon/reactivity": "^0.16.0",
57
+ "@pyreon/styler": "^0.16.0",
58
+ "@pyreon/ui-core": "^0.16.0"
58
59
  }
59
60
  }
@@ -168,4 +168,264 @@ describe('makeItResponsive', () => {
168
168
 
169
169
  expect(Array.isArray(result)).toBe(true)
170
170
  })
171
+
172
+ describe('delta optimization (mirrors vitus-labs)', () => {
173
+ it('strips re-emitted unchanged declarations across breakpoints', () => {
174
+ // mockStyles emits `color: red; padding: 0;` at xs and the same color
175
+ // with a different padding at sm. The delta optimizer should drop
176
+ // `color: red` from sm because it's already cascaded from xs via
177
+ // `@media (min-width: …)`.
178
+ const sortedBreakpoints = ['xs', 'sm']
179
+ const captured: Record<string, string> = {}
180
+ const media: Record<string, (s: TemplateStringsArray, ...v: any[]) => string> = {
181
+ xs: (s, ...vals) => {
182
+ let out = ''
183
+ for (let i = 0; i < s.length; i++) {
184
+ out += s[i]
185
+ if (i < vals.length) out += String(vals[i])
186
+ }
187
+ captured.xs = out
188
+ return out
189
+ },
190
+ sm: (s, ...vals) => {
191
+ let out = ''
192
+ for (let i = 0; i < s.length; i++) {
193
+ out += s[i]
194
+ if (i < vals.length) out += String(vals[i])
195
+ }
196
+ captured.sm = out
197
+ return out
198
+ },
199
+ }
200
+
201
+ const responsive = makeItResponsive({
202
+ theme: { color: { xs: 'red', sm: 'red' }, padding: { xs: '0', sm: '1rem' } },
203
+ css: mockCss,
204
+ styles: mockStyles,
205
+ normalize: true,
206
+ })
207
+
208
+ responsive({
209
+ theme: {
210
+ breakpoints: { xs: 0, sm: 576 },
211
+ __PYREON__: { sortedBreakpoints, media },
212
+ },
213
+ })
214
+
215
+ // xs sees full output
216
+ expect(captured.xs).toContain('color: red;')
217
+ expect(captured.xs).toContain('padding: 0;')
218
+ // sm sees only the delta — color is in cascade already
219
+ expect(captured.sm).toContain('padding: 1rem;')
220
+ expect(captured.sm).not.toContain('color:')
221
+ })
222
+
223
+ it('skips the media-template call entirely when a breakpoint has no deltas', () => {
224
+ const sortedBreakpoints = ['xs', 'sm']
225
+ let xsCalls = 0
226
+ let smCalls = 0
227
+ const media: Record<string, (s: TemplateStringsArray, ...v: any[]) => string> = {
228
+ xs: (s, ...vals) => {
229
+ xsCalls++
230
+ let out = ''
231
+ for (let i = 0; i < s.length; i++) {
232
+ out += s[i]
233
+ if (i < vals.length) out += String(vals[i])
234
+ }
235
+ return out
236
+ },
237
+ sm: (s, ...vals) => {
238
+ smCalls++
239
+ let out = ''
240
+ for (let i = 0; i < s.length; i++) {
241
+ out += s[i]
242
+ if (i < vals.length) out += String(vals[i])
243
+ }
244
+ return out
245
+ },
246
+ }
247
+
248
+ const responsive = makeItResponsive({
249
+ // Identical values at both breakpoints — sm produces zero deltas.
250
+ theme: { color: { xs: 'red', sm: 'red' } },
251
+ css: mockCss,
252
+ styles: mockStyles,
253
+ })
254
+
255
+ const result = responsive({
256
+ theme: {
257
+ breakpoints: { xs: 0, sm: 576 },
258
+ __PYREON__: { sortedBreakpoints, media },
259
+ },
260
+ })
261
+
262
+ // xs renders (has content); sm produces no @media wrapper at all
263
+ expect(xsCalls).toBe(1)
264
+ expect(smCalls).toBe(0)
265
+ // sm slot is the empty-string sentinel
266
+ expect((result as unknown[])[1]).toBe('')
267
+ })
268
+ })
269
+
270
+ describe('stringify fallback (mirrors vitus-labs)', () => {
271
+ // Where the two paths diverge observably: `styles` is invoked
272
+ // ONCE per breakpoint in the optimized path (for stringification),
273
+ // and TWICE per breakpoint in the fallback path (once for
274
+ // stringify which returns null, then again to re-render against
275
+ // the engine for the @media wrapper). With 2 breakpoints that's
276
+ // 2 calls vs 4 calls — clean observable distinction.
277
+
278
+ it('takes the unoptimized path when styles result has [object Foo] toString', () => {
279
+ // Foreign-engine result whose default toString is `[object ForeignResult]`.
280
+ // stringifyResult returns null → canOptimize=false → fallback path.
281
+ class ForeignResult {
282
+ constructor(public payload: string) {}
283
+ // Default toString → "[object ForeignResult]"
284
+ }
285
+
286
+ const sortedBreakpoints = ['xs', 'sm']
287
+ const media: Record<string, (s: TemplateStringsArray, ...v: any[]) => string> = {
288
+ xs: mockCss,
289
+ sm: mockCss,
290
+ }
291
+
292
+ let stylesCalls = 0
293
+ const stylesReturningForeign = ({ theme }: { theme: Record<string, unknown> }) => {
294
+ stylesCalls++
295
+ return new ForeignResult(JSON.stringify(theme))
296
+ }
297
+
298
+ // Distinct values per breakpoint so optimizeTheme keeps both keys
299
+ // (identical values get deduplicated upstream and never reach the
300
+ // optimization-vs-fallback split).
301
+ const responsive = makeItResponsive({
302
+ theme: { color: { xs: 'red', sm: 'blue' } },
303
+ css: mockCss,
304
+ styles: stylesReturningForeign as any,
305
+ })
306
+
307
+ responsive({
308
+ theme: {
309
+ breakpoints: { xs: 0, sm: 576 },
310
+ __PYREON__: { sortedBreakpoints, media },
311
+ },
312
+ })
313
+
314
+ // Fallback signature: stringify pass + re-render pass = 2 calls per bp
315
+ expect(stylesCalls).toBe(4)
316
+ })
317
+
318
+ it('takes the optimized path for plain-string styles results', () => {
319
+ const sortedBreakpoints = ['xs', 'sm']
320
+ const media: Record<string, (s: TemplateStringsArray, ...v: any[]) => string> = {
321
+ xs: mockCss,
322
+ sm: mockCss,
323
+ }
324
+
325
+ let stylesCalls = 0
326
+ const stylesCountingMock = (args: { theme: Record<string, unknown> }) => {
327
+ stylesCalls++
328
+ return mockStyles(args)
329
+ }
330
+
331
+ const responsive = makeItResponsive({
332
+ theme: { color: { xs: 'red', sm: 'blue' } },
333
+ css: mockCss,
334
+ styles: stylesCountingMock,
335
+ })
336
+
337
+ responsive({
338
+ theme: {
339
+ breakpoints: { xs: 0, sm: 576 },
340
+ __PYREON__: { sortedBreakpoints, media },
341
+ },
342
+ })
343
+
344
+ // Optimized signature: stringify pass only = 1 call per bp
345
+ expect(stylesCalls).toBe(2)
346
+ })
347
+ })
348
+
349
+ describe('render-output cache (mirrors vitus-labs)', () => {
350
+ it('returns the same rendered output by reference when called twice with stable theme + internal-theme refs', () => {
351
+ const sortedBreakpoints = ['xs', 'sm']
352
+ let xsCalls = 0
353
+ const media: Record<string, (s: TemplateStringsArray, ...v: any[]) => string> = {
354
+ xs: (s, ...vals) => {
355
+ xsCalls++
356
+ let out = ''
357
+ for (let i = 0; i < s.length; i++) {
358
+ out += s[i]
359
+ if (i < vals.length) out += String(vals[i])
360
+ }
361
+ return out
362
+ },
363
+ sm: mockCss,
364
+ }
365
+
366
+ const themeObj = { color: { xs: 'red', sm: 'blue' } }
367
+ const globalTheme = {
368
+ breakpoints: { xs: 0, sm: 576 },
369
+ __PYREON__: { sortedBreakpoints, media },
370
+ }
371
+
372
+ const responsive = makeItResponsive({
373
+ theme: themeObj,
374
+ css: mockCss,
375
+ styles: mockStyles,
376
+ })
377
+
378
+ const result1 = responsive({ theme: globalTheme })
379
+ const xsCallsAfterFirst = xsCalls
380
+ const result2 = responsive({ theme: globalTheme })
381
+
382
+ // Same identity means the rendered cache hit (no re-rendering)
383
+ expect(result2).toBe(result1)
384
+ // Render cache hit means the media template was NOT called again
385
+ expect(xsCalls).toBe(xsCallsAfterFirst)
386
+ })
387
+
388
+ it('re-renders when the outer theme reference changes (e.g. provider value swap)', () => {
389
+ const sortedBreakpoints = ['xs', 'sm']
390
+ let xsCalls = 0
391
+ const media: Record<string, (s: TemplateStringsArray, ...v: any[]) => string> = {
392
+ xs: (s, ...vals) => {
393
+ xsCalls++
394
+ let out = ''
395
+ for (let i = 0; i < s.length; i++) {
396
+ out += s[i]
397
+ if (i < vals.length) out += String(vals[i])
398
+ }
399
+ return out
400
+ },
401
+ sm: mockCss,
402
+ }
403
+
404
+ const themeObj = { color: { xs: 'red', sm: 'blue' } }
405
+
406
+ const responsive = makeItResponsive({
407
+ theme: themeObj,
408
+ css: mockCss,
409
+ styles: mockStyles,
410
+ })
411
+
412
+ // Two distinct outer-theme objects with the same content
413
+ const globalA = {
414
+ breakpoints: { xs: 0, sm: 576 },
415
+ __PYREON__: { sortedBreakpoints, media },
416
+ }
417
+ const globalB = {
418
+ breakpoints: { xs: 0, sm: 576 },
419
+ __PYREON__: { sortedBreakpoints, media },
420
+ }
421
+
422
+ responsive({ theme: globalA })
423
+ const callsAfterA = xsCalls
424
+ responsive({ theme: globalB })
425
+
426
+ // New outer theme object → no render cache hit → media template re-runs
427
+ expect(xsCalls).toBeGreaterThan(callsAfterA)
428
+ })
429
+
430
+ })
171
431
  })
@@ -0,0 +1,9 @@
1
+ import { isNativeCompat } from '@pyreon/core'
2
+ import { describe, expect, it } from 'vitest'
3
+ import UnistyleProvider from '../context'
4
+
5
+ describe('native-compat marker — @pyreon/unistyle', () => {
6
+ it('Provider is marked native', () => {
7
+ expect(isNativeCompat(UnistyleProvider)).toBe(true)
8
+ })
9
+ })