@pyreon/unistyle 0.15.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 +191 -10
- package/package.json +9 -9
- package/src/__tests__/makeItResponsive.test.ts +260 -0
- package/src/__tests__/optimizeBreakpointDeltas.test.ts +124 -0
- package/src/__tests__/special-keys.test.ts +120 -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 +5 -0
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
|
@@ -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) => {
|
|
181
341
|
const breakpointTheme = optimizedTheme[item];
|
|
182
342
|
if (!breakpointTheme || !media) return "";
|
|
183
|
-
|
|
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) => {
|
|
355
|
+
const breakpointTheme = optimizedTheme[item];
|
|
356
|
+
if (!breakpointTheme || !media) return "";
|
|
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
|
|
@@ -1913,6 +2093,7 @@ for (let i = 0; i < propertyMap.length; i++) {
|
|
|
1913
2093
|
if (d.key) addKey(d.key);
|
|
1914
2094
|
if (d.keys) if (Array.isArray(d.keys)) for (const k of d.keys) addKey(k);
|
|
1915
2095
|
else for (const inner of Object.values(d.keys)) addKey(inner);
|
|
2096
|
+
if (d.id) addKey(d.id);
|
|
1916
2097
|
}
|
|
1917
2098
|
/**
|
|
1918
2099
|
* Convert a normalized theme object (Record<key, value>) into a CSS template
|
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": {
|
|
@@ -43,18 +43,18 @@
|
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@pyreon/manifest": "0.13.1",
|
|
46
|
-
"@pyreon/test-utils": "^0.13.
|
|
47
|
-
"@pyreon/typescript": "^0.
|
|
46
|
+
"@pyreon/test-utils": "^0.13.3",
|
|
47
|
+
"@pyreon/typescript": "^0.16.0",
|
|
48
48
|
"@vitest/browser-playwright": "^4.1.4",
|
|
49
49
|
"@vitus-labs/tools-rolldown": "^2.3.0"
|
|
50
50
|
},
|
|
51
|
-
"peerDependencies": {
|
|
52
|
-
"@pyreon/core": "^0.15.0",
|
|
53
|
-
"@pyreon/reactivity": "^0.15.0",
|
|
54
|
-
"@pyreon/styler": "^0.15.0",
|
|
55
|
-
"@pyreon/ui-core": "^0.15.0"
|
|
56
|
-
},
|
|
57
51
|
"engines": {
|
|
58
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"
|
|
59
59
|
}
|
|
60
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,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
|
+
})
|
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
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mobile-first cascade optimizer.
|
|
3
|
+
*
|
|
4
|
+
* Given an ordered array of CSS strings (one per breakpoint, smallest first),
|
|
5
|
+
* returns a parallel array where each non-base breakpoint contains only the
|
|
6
|
+
* declarations that DIFFER from the cumulative cascade so far. This relies on
|
|
7
|
+
* mobile-first `@media (min-width: …)` semantics: properties set at smaller
|
|
8
|
+
* breakpoints inherit at larger ones, so re-emitting an unchanged property is
|
|
9
|
+
* pure byte waste.
|
|
10
|
+
*
|
|
11
|
+
* Example:
|
|
12
|
+
* ["color: red; padding: 0;", "color: red; padding: 1rem;"]
|
|
13
|
+
* → ["color: red; padding: 0;", "padding: 1rem;"]
|
|
14
|
+
*
|
|
15
|
+
* Top-level declarations are diffed by `prop:value`. Selector blocks
|
|
16
|
+
* (`&:hover { … }`, `@supports { … }`) are treated as opaque and deduped by
|
|
17
|
+
* exact text. Anything inside parens or quoted strings is skipped over so
|
|
18
|
+
* `linear-gradient(red 0%, blue 100%)` and `content: ";"` parse correctly.
|
|
19
|
+
*
|
|
20
|
+
* Limitations:
|
|
21
|
+
* - shorthand/longhand interaction is not modeled. If breakpoint A sets
|
|
22
|
+
* `padding: 1rem` and breakpoint B sets `padding-top: 0`, both are kept
|
|
23
|
+
* (they have different `prop` keys). If A sets `padding-top: 1rem` and B
|
|
24
|
+
* sets `padding: 1rem`, B's `padding` is emitted because the cascade map
|
|
25
|
+
* has no entry for `padding`. This is correct: B's shorthand RESETS sides
|
|
26
|
+
* A didn't touch, so dropping it would change behaviour.
|
|
27
|
+
* - Nested blocks are deduped only by exact textual match. Two equivalent
|
|
28
|
+
* blocks with different whitespace would both be emitted.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
interface DeclEntry {
|
|
32
|
+
kind: 'decl'
|
|
33
|
+
prop: string
|
|
34
|
+
value: string
|
|
35
|
+
raw: string // canonical "prop: value;" form
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface BlockEntry {
|
|
39
|
+
kind: 'block'
|
|
40
|
+
raw: string // entire "selector { body }" or stray block
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type Entry = DeclEntry | BlockEntry
|
|
44
|
+
|
|
45
|
+
/** Parse a CSS string into top-level declarations and opaque blocks. */
|
|
46
|
+
const parse = (css: string): Entry[] => {
|
|
47
|
+
const entries: Entry[] = []
|
|
48
|
+
const len = css.length
|
|
49
|
+
|
|
50
|
+
let depth = 0
|
|
51
|
+
let parenDepth = 0
|
|
52
|
+
let quote = 0 // charCode of active quote (0 if none)
|
|
53
|
+
let segmentStart = 0
|
|
54
|
+
|
|
55
|
+
const pushSegment = (rawSegment: string) => {
|
|
56
|
+
const trimmed = rawSegment.trim()
|
|
57
|
+
if (!trimmed) return
|
|
58
|
+
// pushSegment is only reached for segments that ended with a top-level
|
|
59
|
+
// ";" — full "selector { ... }" blocks are captured separately by the
|
|
60
|
+
// brace walker, so this path always sees declarations (or malformed
|
|
61
|
+
// declaration-shaped fragments).
|
|
62
|
+
const text = trimmed.endsWith(';') ? trimmed.slice(0, -1) : trimmed
|
|
63
|
+
const colonIdx = text.indexOf(':')
|
|
64
|
+
if (colonIdx <= 0) {
|
|
65
|
+
// No ":" or starts with ":" → not a parseable declaration; keep raw
|
|
66
|
+
entries.push({ kind: 'block', raw: `${text};` })
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
const prop = text.slice(0, colonIdx).trim()
|
|
70
|
+
const value = text.slice(colonIdx + 1).trim()
|
|
71
|
+
if (!prop || !value) {
|
|
72
|
+
entries.push({ kind: 'block', raw: `${text};` })
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
entries.push({
|
|
76
|
+
kind: 'decl',
|
|
77
|
+
prop,
|
|
78
|
+
value,
|
|
79
|
+
raw: `${prop}: ${value};`,
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (let i = 0; i < len; i++) {
|
|
84
|
+
const code = css.charCodeAt(i)
|
|
85
|
+
|
|
86
|
+
// Inside a quoted string — skip until matching quote (ignoring escapes)
|
|
87
|
+
if (quote !== 0) {
|
|
88
|
+
if (code === 92 /* \ */) {
|
|
89
|
+
i++ // skip the next character
|
|
90
|
+
} else if (code === quote) {
|
|
91
|
+
quote = 0
|
|
92
|
+
}
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Quote start
|
|
97
|
+
if (code === 34 /* " */ || code === 39 /* ' */) {
|
|
98
|
+
quote = code
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Parens — content (e.g. linear-gradient args) shouldn't be interpreted
|
|
103
|
+
if (code === 40 /* ( */) {
|
|
104
|
+
parenDepth++
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
if (code === 41 /* ) */) {
|
|
108
|
+
if (parenDepth > 0) parenDepth--
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
if (parenDepth > 0) continue
|
|
112
|
+
|
|
113
|
+
if (code === 123 /* { */) {
|
|
114
|
+
depth++
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
if (code === 125 /* } */) {
|
|
118
|
+
depth--
|
|
119
|
+
if (depth === 0) {
|
|
120
|
+
// End of a top-level block — capture from segmentStart..i (inclusive)
|
|
121
|
+
const raw = css.slice(segmentStart, i + 1).trim()
|
|
122
|
+
if (raw) entries.push({ kind: 'block', raw })
|
|
123
|
+
segmentStart = i + 1
|
|
124
|
+
}
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (depth === 0 && code === 59 /* ; */) {
|
|
129
|
+
pushSegment(css.slice(segmentStart, i))
|
|
130
|
+
segmentStart = i + 1
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Trailing segment (no terminating semicolon)
|
|
135
|
+
if (segmentStart < len) {
|
|
136
|
+
const trailing = css.slice(segmentStart).trim()
|
|
137
|
+
if (trailing) {
|
|
138
|
+
if (depth > 0) {
|
|
139
|
+
// Unbalanced braces — keep the rest as opaque so output isn't lossy
|
|
140
|
+
entries.push({ kind: 'block', raw: trailing })
|
|
141
|
+
} else {
|
|
142
|
+
pushSegment(trailing)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return entries
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Apply the mobile-first cascade diff. The first entry passes through
|
|
152
|
+
* unchanged; subsequent entries are pruned to the delta vs. the running
|
|
153
|
+
* cascade (declarations by prop, blocks by exact text match).
|
|
154
|
+
*/
|
|
155
|
+
export const optimizeBreakpointDeltas = (cssStrings: string[]): string[] => {
|
|
156
|
+
if (cssStrings.length <= 1) return cssStrings
|
|
157
|
+
|
|
158
|
+
const cascadeDecl = new Map<string, string>()
|
|
159
|
+
const cascadeBlocks = new Set<string>()
|
|
160
|
+
const out: string[] = new Array(cssStrings.length)
|
|
161
|
+
|
|
162
|
+
for (let i = 0; i < cssStrings.length; i++) {
|
|
163
|
+
const css = cssStrings[i]
|
|
164
|
+
if (!css) {
|
|
165
|
+
out[i] = ''
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const entries = parse(css)
|
|
170
|
+
const kept: string[] = []
|
|
171
|
+
|
|
172
|
+
for (const e of entries) {
|
|
173
|
+
if (e.kind === 'decl') {
|
|
174
|
+
if (cascadeDecl.get(e.prop) !== e.value) {
|
|
175
|
+
kept.push(e.raw)
|
|
176
|
+
cascadeDecl.set(e.prop, e.value)
|
|
177
|
+
}
|
|
178
|
+
} else if (!cascadeBlocks.has(e.raw)) {
|
|
179
|
+
kept.push(e.raw)
|
|
180
|
+
cascadeBlocks.add(e.raw)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
out[i] = kept.join(' ')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return out
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export default optimizeBreakpointDeltas
|
|
@@ -53,6 +53,11 @@ for (let i = 0; i < propertyMap.length; i++) {
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
|
+
// kind: 'special' descriptors carry only `id` (no key/keys). Index by id so
|
|
57
|
+
// the fast path resolves them when paired with non-special keys; otherwise
|
|
58
|
+
// fragments.length > 0 from non-special hits skips the fallback full-scan
|
|
59
|
+
// and the special is silently dropped.
|
|
60
|
+
if (d.id) addKey(d.id)
|
|
56
61
|
}
|
|
57
62
|
|
|
58
63
|
/**
|