@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
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
|
|
161
|
-
|
|
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
|
-
|
|
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
|
|
357
|
+
const r = renderStyles(breakpointTheme);
|
|
184
358
|
return media[item]`
|
|
185
|
-
|
|
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
|
|
2095
|
+
else for (const inner of Object.values(d.keys)) addKey(inner);
|
|
2096
|
+
if (d.id) addKey(d.id);
|
|
1915
2097
|
}
|
|
1916
2098
|
/**
|
|
1917
|
-
*
|
|
1918
|
-
*
|
|
1919
|
-
*
|
|
1920
|
-
*
|
|
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 (
|
|
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 (
|
|
1944
|
-
|
|
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 (
|
|
1948
|
-
if (
|
|
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 (
|
|
1951
|
-
|
|
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
|
-
|
|
1955
|
-
${
|
|
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.
|
|
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.
|
|
46
|
-
"@pyreon/typescript": "^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": "^
|
|
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
|
+
})
|