@pyreon/styler 0.18.0 → 0.20.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 +43 -2
- package/lib/index.js +115 -4
- package/package.json +6 -5
- package/src/__tests__/inject-rules.browser.test.ts +40 -0
- package/src/__tests__/memory-growth.test.ts +68 -0
- package/src/__tests__/static-styler-resolve-cost.test.ts +160 -0
- package/src/manifest.ts +332 -0
- package/src/sheet.ts +149 -4
- package/src/tests/manifest-snapshot.test.ts +51 -0
package/lib/index.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import * as _$_pyreon_core0 from "@pyreon/core";
|
|
2
1
|
import { ComponentFn, VNode, VNodeChild } from "@pyreon/core";
|
|
3
2
|
|
|
4
3
|
//#region src/ThemeProvider.d.ts
|
|
@@ -16,7 +15,7 @@ type Theme = DefaultTheme & Record<string, unknown>;
|
|
|
16
15
|
* - String-equality memoization (same CSS class = no DOM update)
|
|
17
16
|
* - Untracked resolve (no exponential cascade)
|
|
18
17
|
*/
|
|
19
|
-
declare const ThemeContext:
|
|
18
|
+
declare const ThemeContext: import("@pyreon/core").ReactiveContext<Theme>;
|
|
20
19
|
/**
|
|
21
20
|
* Read the current theme. Returns the theme value (calls the accessor).
|
|
22
21
|
* Inside a reactive scope (computed/effect), this tracks theme changes.
|
|
@@ -155,6 +154,8 @@ interface StyleSheetOptions {
|
|
|
155
154
|
declare class StyleSheet {
|
|
156
155
|
private cache;
|
|
157
156
|
private insertCache;
|
|
157
|
+
private icKeysByClass;
|
|
158
|
+
private domRules;
|
|
158
159
|
private sheet;
|
|
159
160
|
private ssrBuffer;
|
|
160
161
|
private isSSR;
|
|
@@ -167,6 +168,19 @@ declare class StyleSheet {
|
|
|
167
168
|
private extractClassName;
|
|
168
169
|
/** Parse existing rules from SSR-rendered <style> tag into cache. */
|
|
169
170
|
private hydrateFromTag;
|
|
171
|
+
/** Record that `icKey` resolves to `cacheKey` (for lockstep eviction). */
|
|
172
|
+
private trackIcKey;
|
|
173
|
+
/** Record a top-level CSSRule this `cacheKey` inserted into the sheet. */
|
|
174
|
+
private trackDomRule;
|
|
175
|
+
/**
|
|
176
|
+
* Evict the given cache keys across ALL three storage layers:
|
|
177
|
+
* the `cache` Map, the cssText-keyed `insertCache` Map, and the live
|
|
178
|
+
* DOM rules. Without the latter two, `maxCacheSize` bounded only the
|
|
179
|
+
* smallest of the three — `insertCache` keys (full CSS text) and the
|
|
180
|
+
* `<style>` tag's `cssRules` grew unbounded for the app's lifetime,
|
|
181
|
+
* which is the actual memory leak this method exists to prevent.
|
|
182
|
+
*/
|
|
183
|
+
private evictKeys;
|
|
170
184
|
/** Evict oldest entries when cache exceeds max size. */
|
|
171
185
|
private evictIfNeeded;
|
|
172
186
|
/**
|
|
@@ -201,6 +215,33 @@ declare class StyleSheet {
|
|
|
201
215
|
insertGlobal(cssText: string): void;
|
|
202
216
|
/** Returns collected CSS for SSR as a complete `<style>` tag string. */
|
|
203
217
|
getStyleTag(): string;
|
|
218
|
+
/**
|
|
219
|
+
* Returns the collected SSR rules as a raw array (one entry per
|
|
220
|
+
* top-level rule, already `@layer`-wrapped + class-prefixed exactly as
|
|
221
|
+
* `insert()` produced them). Used by the compile-time rocketstyle
|
|
222
|
+
* collapse resolver: it renders a component under SSR, reads the rules
|
|
223
|
+
* here, and the build emits an idempotent `injectRules()` call so the
|
|
224
|
+
* collapsed `_tpl()` site is self-sufficient (no prior runtime mount
|
|
225
|
+
* needed to populate the sheet). A copy — callers must not mutate the
|
|
226
|
+
* internal buffer.
|
|
227
|
+
*/
|
|
228
|
+
getStyleRules(): readonly string[];
|
|
229
|
+
private injectedBundles;
|
|
230
|
+
/**
|
|
231
|
+
* Inject pre-resolved CSS rule text (from `getStyleRules()` captured at
|
|
232
|
+
* build time by the rocketstyle-collapse resolver) directly into the
|
|
233
|
+
* live sheet. Unlike `insert()` this does NOT re-hash — the class names
|
|
234
|
+
* are already baked into `rules` and into the collapsed `_tpl()` HTML;
|
|
235
|
+
* re-hashing would produce a different class and break the contract.
|
|
236
|
+
* Idempotent by `key` (the resolver's FNV hash of the bundle).
|
|
237
|
+
*/
|
|
238
|
+
injectRules(rules: readonly string[], key: string): void;
|
|
239
|
+
/**
|
|
240
|
+
* Test-only: live `cssRules.length` (0 in SSR). Mirrors runtime-dom's
|
|
241
|
+
* `_tplCacheSize()` test-only-accessor convention; lets injectRules /
|
|
242
|
+
* eviction tests assert without reaching into the private sheet.
|
|
243
|
+
*/
|
|
244
|
+
ruleCountForTest(): number;
|
|
204
245
|
/** Returns collected CSS rules as a raw string (useful for streaming SSR). */
|
|
205
246
|
getStyles(): string;
|
|
206
247
|
/** Check if any buffered SSR rules use @layer wrapping. */
|
package/lib/index.js
CHANGED
|
@@ -9,6 +9,8 @@ const _countSink$2 = globalThis;
|
|
|
9
9
|
* deferred until a styled component renders (or until explicitly resolved).
|
|
10
10
|
*/
|
|
11
11
|
var CSSResult = class {
|
|
12
|
+
strings;
|
|
13
|
+
values;
|
|
12
14
|
constructor(strings, values) {
|
|
13
15
|
this.strings = strings;
|
|
14
16
|
this.values = values;
|
|
@@ -434,6 +436,8 @@ const DEFAULT_MAX_CACHE_SIZE = 1e4;
|
|
|
434
436
|
var StyleSheet = class {
|
|
435
437
|
cache = /* @__PURE__ */ new Map();
|
|
436
438
|
insertCache = /* @__PURE__ */ new Map();
|
|
439
|
+
icKeysByClass = /* @__PURE__ */ new Map();
|
|
440
|
+
domRules = /* @__PURE__ */ new Map();
|
|
437
441
|
sheet = null;
|
|
438
442
|
ssrBuffer = [];
|
|
439
443
|
isSSR;
|
|
@@ -447,6 +451,7 @@ var StyleSheet = class {
|
|
|
447
451
|
if (!this.isSSR) this.mount();
|
|
448
452
|
}
|
|
449
453
|
mount() {
|
|
454
|
+
if (this.isSSR) return;
|
|
450
455
|
const existing = document.querySelector(`style[${ATTR}]`);
|
|
451
456
|
if (existing) {
|
|
452
457
|
this.sheet = existing.sheet ?? null;
|
|
@@ -487,16 +492,67 @@ var StyleSheet = class {
|
|
|
487
492
|
}
|
|
488
493
|
}
|
|
489
494
|
}
|
|
495
|
+
/** Record that `icKey` resolves to `cacheKey` (for lockstep eviction). */
|
|
496
|
+
trackIcKey(cacheKey, icKey) {
|
|
497
|
+
let s = this.icKeysByClass.get(cacheKey);
|
|
498
|
+
if (!s) {
|
|
499
|
+
s = /* @__PURE__ */ new Set();
|
|
500
|
+
this.icKeysByClass.set(cacheKey, s);
|
|
501
|
+
}
|
|
502
|
+
s.add(icKey);
|
|
503
|
+
}
|
|
504
|
+
/** Record a top-level CSSRule this `cacheKey` inserted into the sheet. */
|
|
505
|
+
trackDomRule(cacheKey, ref) {
|
|
506
|
+
if (!ref) return;
|
|
507
|
+
let a = this.domRules.get(cacheKey);
|
|
508
|
+
if (!a) {
|
|
509
|
+
a = [];
|
|
510
|
+
this.domRules.set(cacheKey, a);
|
|
511
|
+
}
|
|
512
|
+
a.push(ref);
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Evict the given cache keys across ALL three storage layers:
|
|
516
|
+
* the `cache` Map, the cssText-keyed `insertCache` Map, and the live
|
|
517
|
+
* DOM rules. Without the latter two, `maxCacheSize` bounded only the
|
|
518
|
+
* smallest of the three — `insertCache` keys (full CSS text) and the
|
|
519
|
+
* `<style>` tag's `cssRules` grew unbounded for the app's lifetime,
|
|
520
|
+
* which is the actual memory leak this method exists to prevent.
|
|
521
|
+
*/
|
|
522
|
+
evictKeys(keys) {
|
|
523
|
+
const ruleRefs = /* @__PURE__ */ new Set();
|
|
524
|
+
for (const key of keys) {
|
|
525
|
+
this.cache.delete(key);
|
|
526
|
+
const ics = this.icKeysByClass.get(key);
|
|
527
|
+
if (ics) {
|
|
528
|
+
for (const ic of ics) this.insertCache.delete(ic);
|
|
529
|
+
this.icKeysByClass.delete(key);
|
|
530
|
+
}
|
|
531
|
+
const refs = this.domRules.get(key);
|
|
532
|
+
if (refs) {
|
|
533
|
+
for (const r of refs) ruleRefs.add(r);
|
|
534
|
+
this.domRules.delete(key);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
if (this.sheet && ruleRefs.size > 0) for (let i = this.sheet.cssRules.length - 1; i >= 0; i--) {
|
|
538
|
+
const r = this.sheet.cssRules[i];
|
|
539
|
+
if (r && ruleRefs.has(r)) try {
|
|
540
|
+
this.sheet.deleteRule(i);
|
|
541
|
+
} catch {}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
490
544
|
/** Evict oldest entries when cache exceeds max size. */
|
|
491
545
|
evictIfNeeded() {
|
|
492
546
|
if (this.cache.size <= this.maxCacheSize) return;
|
|
493
547
|
const toDelete = Math.floor(this.maxCacheSize * .1);
|
|
548
|
+
const evicted = [];
|
|
494
549
|
let count = 0;
|
|
495
550
|
for (const key of this.cache.keys()) {
|
|
496
551
|
if (count >= toDelete) break;
|
|
497
|
-
|
|
552
|
+
evicted.push(key);
|
|
498
553
|
count++;
|
|
499
554
|
}
|
|
555
|
+
this.evictKeys(evicted);
|
|
500
556
|
}
|
|
501
557
|
/**
|
|
502
558
|
* Extract nested at-rules (@media, @supports, @container) from CSS text
|
|
@@ -577,6 +633,7 @@ var StyleSheet = class {
|
|
|
577
633
|
const className = `${PREFIX}-${hash(cssText)}`;
|
|
578
634
|
if (this.cache.has(className)) {
|
|
579
635
|
this.insertCache.set(icKey, className);
|
|
636
|
+
this.trackIcKey(className, icKey);
|
|
580
637
|
return className;
|
|
581
638
|
}
|
|
582
639
|
this.evictIfNeeded();
|
|
@@ -590,11 +647,13 @@ var StyleSheet = class {
|
|
|
590
647
|
const finalRules = layerName ? rules.map((r) => `@layer ${layerName}{${r}}`) : rules;
|
|
591
648
|
if (this.isSSR) for (const rule of finalRules) this.ssrBuffer.push(rule);
|
|
592
649
|
else if (this.sheet) for (const rule of finalRules) try {
|
|
593
|
-
this.sheet.insertRule(rule, this.sheet.cssRules.length);
|
|
650
|
+
const at = this.sheet.insertRule(rule, this.sheet.cssRules.length);
|
|
651
|
+
this.trackDomRule(className, this.sheet.cssRules[at]);
|
|
594
652
|
} catch (_e) {
|
|
595
653
|
if (__DEV__) console.warn("[styler] Failed to insert CSS rule:", rule, _e);
|
|
596
654
|
}
|
|
597
655
|
this.insertCache.set(icKey, className);
|
|
656
|
+
this.trackIcKey(className, icKey);
|
|
598
657
|
return className;
|
|
599
658
|
}
|
|
600
659
|
/** Insert a @keyframes rule. Deduplicates by animation name. */
|
|
@@ -605,7 +664,8 @@ var StyleSheet = class {
|
|
|
605
664
|
const rule = `@keyframes ${name}{${body}}`;
|
|
606
665
|
if (this.isSSR) this.ssrBuffer.push(rule);
|
|
607
666
|
else if (this.sheet) try {
|
|
608
|
-
this.sheet.insertRule(rule, this.sheet.cssRules.length);
|
|
667
|
+
const at = this.sheet.insertRule(rule, this.sheet.cssRules.length);
|
|
668
|
+
this.trackDomRule(name, this.sheet.cssRules[at]);
|
|
609
669
|
} catch (_e) {
|
|
610
670
|
if (__DEV__) console.warn("[styler] Failed to insert @keyframes rule:", rule, _e);
|
|
611
671
|
}
|
|
@@ -642,7 +702,8 @@ var StyleSheet = class {
|
|
|
642
702
|
else if (this.sheet) {
|
|
643
703
|
const rules = this.splitRules(cssText);
|
|
644
704
|
for (const rule of rules) try {
|
|
645
|
-
this.sheet.insertRule(rule, this.sheet.cssRules.length);
|
|
705
|
+
const at = this.sheet.insertRule(rule, this.sheet.cssRules.length);
|
|
706
|
+
this.trackDomRule(key, this.sheet.cssRules[at]);
|
|
646
707
|
} catch (_e) {
|
|
647
708
|
if (__DEV__) console.warn("[styler] Failed to insert global CSS rule:", rule, _e);
|
|
648
709
|
}
|
|
@@ -653,6 +714,50 @@ var StyleSheet = class {
|
|
|
653
714
|
if (this.ssrBuffer.length === 0) return `<style ${ATTR}=""></style>`;
|
|
654
715
|
return `<style ${ATTR}="">${((this.hasLayeredRules() ? "@layer elements, rocketstyle;" : this.layer ? `@layer ${this.layer};` : "") + this.ssrBuffer.join("")).replace(/<\/style/gi, "<\\/style")}</style>`;
|
|
655
716
|
}
|
|
717
|
+
/**
|
|
718
|
+
* Returns the collected SSR rules as a raw array (one entry per
|
|
719
|
+
* top-level rule, already `@layer`-wrapped + class-prefixed exactly as
|
|
720
|
+
* `insert()` produced them). Used by the compile-time rocketstyle
|
|
721
|
+
* collapse resolver: it renders a component under SSR, reads the rules
|
|
722
|
+
* here, and the build emits an idempotent `injectRules()` call so the
|
|
723
|
+
* collapsed `_tpl()` site is self-sufficient (no prior runtime mount
|
|
724
|
+
* needed to populate the sheet). A copy — callers must not mutate the
|
|
725
|
+
* internal buffer.
|
|
726
|
+
*/
|
|
727
|
+
getStyleRules() {
|
|
728
|
+
return this.ssrBuffer.slice();
|
|
729
|
+
}
|
|
730
|
+
injectedBundles = /* @__PURE__ */ new Set();
|
|
731
|
+
/**
|
|
732
|
+
* Inject pre-resolved CSS rule text (from `getStyleRules()` captured at
|
|
733
|
+
* build time by the rocketstyle-collapse resolver) directly into the
|
|
734
|
+
* live sheet. Unlike `insert()` this does NOT re-hash — the class names
|
|
735
|
+
* are already baked into `rules` and into the collapsed `_tpl()` HTML;
|
|
736
|
+
* re-hashing would produce a different class and break the contract.
|
|
737
|
+
* Idempotent by `key` (the resolver's FNV hash of the bundle).
|
|
738
|
+
*/
|
|
739
|
+
injectRules(rules, key) {
|
|
740
|
+
if (this.injectedBundles.has(key)) return;
|
|
741
|
+
this.injectedBundles.add(key);
|
|
742
|
+
if (this.isSSR) {
|
|
743
|
+
for (const rule of rules) this.ssrBuffer.push(rule);
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
if (!this.sheet) return;
|
|
747
|
+
for (const rule of rules) try {
|
|
748
|
+
this.sheet.insertRule(rule, this.sheet.cssRules.length);
|
|
749
|
+
} catch (_e) {
|
|
750
|
+
if (__DEV__) console.warn("[styler] injectRules: failed to insert collapsed rule:", rule, _e);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Test-only: live `cssRules.length` (0 in SSR). Mirrors runtime-dom's
|
|
755
|
+
* `_tplCacheSize()` test-only-accessor convention; lets injectRules /
|
|
756
|
+
* eviction tests assert without reaching into the private sheet.
|
|
757
|
+
*/
|
|
758
|
+
ruleCountForTest() {
|
|
759
|
+
return this.sheet?.cssRules.length ?? 0;
|
|
760
|
+
}
|
|
656
761
|
/** Returns collected CSS rules as a raw string (useful for streaming SSR). */
|
|
657
762
|
getStyles() {
|
|
658
763
|
if (this.ssrBuffer.length === 0) return "";
|
|
@@ -667,11 +772,15 @@ var StyleSheet = class {
|
|
|
667
772
|
this.ssrBuffer = [];
|
|
668
773
|
this.cache.clear();
|
|
669
774
|
this.insertCache.clear();
|
|
775
|
+
this.icKeysByClass.clear();
|
|
776
|
+
this.domRules.clear();
|
|
670
777
|
}
|
|
671
778
|
/** Clear the dedup cache. Useful for HMR / dev-time reloads. */
|
|
672
779
|
clearCache() {
|
|
673
780
|
this.cache.clear();
|
|
674
781
|
this.insertCache.clear();
|
|
782
|
+
this.icKeysByClass.clear();
|
|
783
|
+
this.domRules.clear();
|
|
675
784
|
clearNormCache();
|
|
676
785
|
}
|
|
677
786
|
/**
|
|
@@ -687,6 +796,8 @@ var StyleSheet = class {
|
|
|
687
796
|
clearAll() {
|
|
688
797
|
this.cache.clear();
|
|
689
798
|
this.insertCache.clear();
|
|
799
|
+
this.icKeysByClass.clear();
|
|
800
|
+
this.domRules.clear();
|
|
690
801
|
clearNormCache();
|
|
691
802
|
this.ssrBuffer = [];
|
|
692
803
|
if (this.sheet) while (this.sheet.cssRules.length > 0) this.sheet.deleteRule(0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/styler",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Lightweight CSS-in-JS engine for Pyreon",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -42,8 +42,9 @@
|
|
|
42
42
|
"typecheck": "tsc --noEmit"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
-
"@pyreon/
|
|
46
|
-
"@pyreon/
|
|
45
|
+
"@pyreon/manifest": "0.13.1",
|
|
46
|
+
"@pyreon/test-utils": "^0.13.7",
|
|
47
|
+
"@pyreon/typescript": "^0.20.0",
|
|
47
48
|
"@vitest/browser-playwright": "^4.1.4",
|
|
48
49
|
"@vitus-labs/tools-rolldown": "^2.3.0"
|
|
49
50
|
},
|
|
@@ -51,7 +52,7 @@
|
|
|
51
52
|
"node": ">= 22"
|
|
52
53
|
},
|
|
53
54
|
"dependencies": {
|
|
54
|
-
"@pyreon/core": "^0.
|
|
55
|
-
"@pyreon/reactivity": "^0.
|
|
55
|
+
"@pyreon/core": "^0.20.0",
|
|
56
|
+
"@pyreon/reactivity": "^0.20.0"
|
|
56
57
|
}
|
|
57
58
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { createSheet } from '../sheet'
|
|
3
|
+
|
|
4
|
+
// Layer 1 of the P0 rocketstyle-collapse slice: the styler injects
|
|
5
|
+
// pre-resolved rule text (captured at build time by the collapse
|
|
6
|
+
// resolver) into the LIVE sheet, idempotently, WITHOUT re-hashing — the
|
|
7
|
+
// class names are already baked into the rules AND into the collapsed
|
|
8
|
+
// _tpl() HTML, so a re-hash would break the contract.
|
|
9
|
+
|
|
10
|
+
describe('StyleSheet.injectRules (real browser)', () => {
|
|
11
|
+
it('inserts pre-resolved rules into the live sheet verbatim (no re-hash)', () => {
|
|
12
|
+
const s = createSheet()
|
|
13
|
+
const before = s.ruleCountForTest()
|
|
14
|
+
s.injectRules(['.pyr-abc123{color:rgb(1,2,3)}'], 'k1')
|
|
15
|
+
expect(s.ruleCountForTest()).toBe(before + 1)
|
|
16
|
+
// Class name is preserved exactly — proves no re-hash happened.
|
|
17
|
+
const probe = document.createElement('div')
|
|
18
|
+
probe.className = 'pyr-abc123'
|
|
19
|
+
document.body.appendChild(probe)
|
|
20
|
+
expect(getComputedStyle(probe).color).toBe('rgb(1, 2, 3)')
|
|
21
|
+
probe.remove()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('is idempotent by key — re-injecting the same bundle adds nothing', () => {
|
|
25
|
+
const s = createSheet()
|
|
26
|
+
s.injectRules(['.pyr-dup{color:red}', '.pyr-dup2{color:blue}'], 'dup')
|
|
27
|
+
const afterFirst = s.ruleCountForTest()
|
|
28
|
+
s.injectRules(['.pyr-dup{color:red}', '.pyr-dup2{color:blue}'], 'dup')
|
|
29
|
+
s.injectRules(['.pyr-dup{color:red}', '.pyr-dup2{color:blue}'], 'dup')
|
|
30
|
+
expect(s.ruleCountForTest()).toBe(afterFirst)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('distinct keys inject independently', () => {
|
|
34
|
+
const s = createSheet()
|
|
35
|
+
const before = s.ruleCountForTest()
|
|
36
|
+
s.injectRules(['.pyr-x{margin:1px}'], 'kx')
|
|
37
|
+
s.injectRules(['.pyr-y{margin:2px}'], 'ky')
|
|
38
|
+
expect(s.ruleCountForTest()).toBe(before + 2)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -76,6 +76,74 @@ describe('memory growth', () => {
|
|
|
76
76
|
})
|
|
77
77
|
})
|
|
78
78
|
|
|
79
|
+
// Regression: `evictIfNeeded()` historically trimmed ONLY `this.cache`.
|
|
80
|
+
// `insertCache` (keyed by full CSS text — the large keys) and the live
|
|
81
|
+
// `<style>` tag's `cssRules` were never evicted, so `maxCacheSize`
|
|
82
|
+
// bounded the smallest of the three layers while the two memory-heavy
|
|
83
|
+
// ones grew for the process lifetime. These tests inspect the two
|
|
84
|
+
// previously-unbounded layers directly. Bisect-verified.
|
|
85
|
+
describe('lockstep eviction bounds insertCache + DOM cssRules', () => {
|
|
86
|
+
beforeEach(() => {
|
|
87
|
+
document.querySelectorAll('style[data-pyreon-styler]').forEach((el) => {
|
|
88
|
+
el.remove()
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('insertCache stays bounded under N >> maxCacheSize unique inserts', () => {
|
|
93
|
+
const maxSize = 50
|
|
94
|
+
const s = createSheet({ maxCacheSize: maxSize })
|
|
95
|
+
const N = maxSize * 6
|
|
96
|
+
|
|
97
|
+
for (let i = 0; i < N; i++) s.insert(`prop-${i}: value-${i};`)
|
|
98
|
+
|
|
99
|
+
const ic = (s as unknown as { insertCache: Map<string, string> }).insertCache
|
|
100
|
+
// Pre-fix: ic.size === N (insertCache never evicted). Post-fix it
|
|
101
|
+
// tracks `cache`, which trims oldest 10% on each overflow.
|
|
102
|
+
expect(s.cacheSize).toBeLessThanOrEqual(maxSize * 1.5)
|
|
103
|
+
expect(ic.size).toBeLessThanOrEqual(maxSize * 1.5)
|
|
104
|
+
expect(ic.size).toBeLessThan(N)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('live DOM cssRules count does not grow unbounded', () => {
|
|
108
|
+
const maxSize = 30
|
|
109
|
+
const s = createSheet({ maxCacheSize: maxSize })
|
|
110
|
+
|
|
111
|
+
for (let i = 0; i < maxSize; i++) s.insert(`a-${i}: ${i};`)
|
|
112
|
+
const el = document.querySelector('style[data-pyreon-styler]') as HTMLStyleElement
|
|
113
|
+
expect(el?.sheet).toBeTruthy()
|
|
114
|
+
|
|
115
|
+
for (let i = 0; i < maxSize * 5; i++) s.insert(`b-${i}: ${i};`)
|
|
116
|
+
|
|
117
|
+
const after = el.sheet!.cssRules.length
|
|
118
|
+
// Pre-fix: `after` ≈ maxSize*6 (+@layer decl) — every unique insert
|
|
119
|
+
// appended a rule, none ever deleted. Post-fix: eviction calls
|
|
120
|
+
// deleteRule in lockstep, so the live rule count stays within
|
|
121
|
+
// ~1.5× maxSize no matter how many uniques flowed through.
|
|
122
|
+
expect(after).toBeLessThanOrEqual(maxSize * 1.5 + 2)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('dedup still works after eviction cycles', () => {
|
|
126
|
+
const maxSize = 20
|
|
127
|
+
const s = createSheet({ maxCacheSize: maxSize })
|
|
128
|
+
|
|
129
|
+
const recent: string[] = []
|
|
130
|
+
for (let i = 0; i < 5; i++) recent.push(s.insert(`keep-${i}: v;`))
|
|
131
|
+
// Overflow to force eviction of older entries (not `recent`).
|
|
132
|
+
for (let i = 0; i < maxSize * 3; i++) s.insert(`churn-${i}: v;`)
|
|
133
|
+
// Recent entries: re-inserting yields the SAME deterministic
|
|
134
|
+
// className and exactly one live DOM rule each.
|
|
135
|
+
for (let i = 0; i < 5; i++) expect(s.insert(`keep-${i}: v;`)).toBe(recent[i])
|
|
136
|
+
|
|
137
|
+
const el = document.querySelector('style[data-pyreon-styler]') as HTMLStyleElement
|
|
138
|
+
let keepRules = 0
|
|
139
|
+
for (let i = 0; i < el.sheet!.cssRules.length; i++) {
|
|
140
|
+
const r = el.sheet!.cssRules[i]
|
|
141
|
+
if (r && r.cssText.includes('keep-0')) keepRules++
|
|
142
|
+
}
|
|
143
|
+
expect(keepRules).toBe(1)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
79
147
|
describe('SSR mode memory', () => {
|
|
80
148
|
let originalDocument: typeof document
|
|
81
149
|
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Measurement gate: per-mount cost of a fully-static `styled()` component.
|
|
3
|
+
*
|
|
4
|
+
* BACKGROUND. "Proposed compiler win #2 — static styler extraction" theorised
|
|
5
|
+
* that a fully-static styled component (`styled('div')`\`color: red\``, no
|
|
6
|
+
* function interpolations) wastes a per-mount `styler.resolve`, and that a
|
|
7
|
+
* bounded runtime memo slice could be carved out of the (multi-week,
|
|
8
|
+
* roadmap-scale) compile-time extraction effort. This gate MEASURES that
|
|
9
|
+
* premise with the real `styler.resolve` / `styler.staticVNode.hit` /
|
|
10
|
+
* `styler.sheet.insert*` perf counters and DISPROVES it for the runtime
|
|
11
|
+
* layer: the static path is already optimal.
|
|
12
|
+
*
|
|
13
|
+
* WHAT THE COUNTERS PROVE (the contrast IS the proof — self-discriminating,
|
|
14
|
+
* no fake fix to revert; same shape as the static-text baking gate):
|
|
15
|
+
*
|
|
16
|
+
* Fully-static `styled('div')`\`color: red\`` (values.length === 0):
|
|
17
|
+
* - `createStyledComponent` takes `raw = strings[0]` — `resolve()` is
|
|
18
|
+
* NEVER called → `styler.resolve` === 0 for the lifetime.
|
|
19
|
+
* - `sheet.insert` fires EXACTLY ONCE at component-creation time
|
|
20
|
+
* (definition, not mount) → `styler.sheet.insert` === 1.
|
|
21
|
+
* - Every mount with no extra props returns the pre-built
|
|
22
|
+
* `cachedEmptyVNode` → `styler.staticVNode.hit` === N, with ZERO
|
|
23
|
+
* additional resolve / sheet work per mount.
|
|
24
|
+
*
|
|
25
|
+
* Contrast — function-interpolated `styled('div')`\`color: ${p => p.c}\``
|
|
26
|
+
* with NO `$rocketstyle` / `$element` identity (the only shape that DOES
|
|
27
|
+
* re-resolve per call): `styler.resolve` === N. This is CORRECT, not waste
|
|
28
|
+
* — the CSS genuinely depends on per-call props; and real-app shapes
|
|
29
|
+
* (rocketstyle dimensions, Element `$element` interning) already hit
|
|
30
|
+
* `classCache` / `elClassCache` so they collapse to ~0 (proven elsewhere:
|
|
31
|
+
* PR #344 dimension memo, the `$element` interning gate). So #2's runtime
|
|
32
|
+
* layer needs no fix; the remaining #2 surface is purely compile-time /
|
|
33
|
+
* bundle (don't ship the styled wrapper + sheet.insert for provably-static
|
|
34
|
+
* CSS at all) — that is the roadmap item, deliberately NOT half-built here.
|
|
35
|
+
*
|
|
36
|
+
* Bisect note: there is intentionally no fix to revert. The discriminating
|
|
37
|
+
* assertion is `static.resolve === 0 && dynamic.resolve === N` in the SAME
|
|
38
|
+
* run — if the static path ever regressed to per-mount resolve, the static
|
|
39
|
+
* block's `toBe(0)` fails while the dynamic block still passes, pinpointing
|
|
40
|
+
* the regression to the static fast path specifically.
|
|
41
|
+
*/
|
|
42
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
43
|
+
import { sheet } from '../sheet'
|
|
44
|
+
import { styled } from '../styled'
|
|
45
|
+
|
|
46
|
+
type Sink = { __pyreon_count__?: (name: string, n?: number) => void }
|
|
47
|
+
const g = globalThis as Sink
|
|
48
|
+
|
|
49
|
+
let counts: Map<string, number>
|
|
50
|
+
|
|
51
|
+
const get = (name: string): number => counts.get(name) ?? 0
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
counts = new Map()
|
|
55
|
+
g.__pyreon_count__ = (name: string, n = 1) => {
|
|
56
|
+
counts.set(name, (counts.get(name) ?? 0) + n)
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
delete g.__pyreon_count__
|
|
62
|
+
// clearAll (not reset) — fires onSheetClear so styled.tsx's
|
|
63
|
+
// staticComponentCache / _hotCache reset between cases; otherwise the
|
|
64
|
+
// single-entry hot cache leaks a prior case's component.
|
|
65
|
+
sheet.clearAll()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const N = 100
|
|
69
|
+
|
|
70
|
+
describe('static styled() per-mount cost (measurement gate)', () => {
|
|
71
|
+
it('a fully-static component resolves ZERO times and inserts ONCE, regardless of mount count', () => {
|
|
72
|
+
// Distinct source-location template literal → its own
|
|
73
|
+
// TemplateStringsArray identity (independent of every other case).
|
|
74
|
+
const Comp = styled('div')`
|
|
75
|
+
color: red;
|
|
76
|
+
padding: 4px;
|
|
77
|
+
`
|
|
78
|
+
|
|
79
|
+
// The sheet.insert at *definition* time already happened above. Snapshot
|
|
80
|
+
// AFTER definition so the per-mount measurement is isolated from the
|
|
81
|
+
// one-time creation cost.
|
|
82
|
+
const insertsAtDefinition = get('styler.sheet.insert')
|
|
83
|
+
expect(insertsAtDefinition).toBe(1) // exactly one — at creation, not mount
|
|
84
|
+
expect(get('styler.resolve')).toBe(0) // values.length===0 → raw=strings[0], no resolve()
|
|
85
|
+
|
|
86
|
+
for (let i = 0; i < N; i++) Comp({})
|
|
87
|
+
|
|
88
|
+
// THE PROOF: N mounts added ZERO resolves and ZERO new sheet inserts.
|
|
89
|
+
expect(get('styler.resolve')).toBe(0)
|
|
90
|
+
expect(get('styler.sheet.insert')).toBe(1) // still just the creation insert
|
|
91
|
+
expect(get('styler.sheet.insert.hit')).toBe(0) // never re-inserted
|
|
92
|
+
// Every mount took the pre-built cachedEmptyVNode fast path.
|
|
93
|
+
expect(get('styler.staticVNode.hit')).toBe(N)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('two distinct static components: 2 inserts total, 0 resolves, N/2 hits each', () => {
|
|
97
|
+
const A = styled('div')`
|
|
98
|
+
color: blue;
|
|
99
|
+
`
|
|
100
|
+
const B = styled('span')`
|
|
101
|
+
color: green;
|
|
102
|
+
`
|
|
103
|
+
expect(get('styler.sheet.insert')).toBe(2) // one per definition
|
|
104
|
+
expect(get('styler.resolve')).toBe(0)
|
|
105
|
+
|
|
106
|
+
for (let i = 0; i < N / 2; i++) {
|
|
107
|
+
A({})
|
|
108
|
+
B({})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
expect(get('styler.resolve')).toBe(0)
|
|
112
|
+
expect(get('styler.sheet.insert')).toBe(2)
|
|
113
|
+
expect(get('styler.staticVNode.hit')).toBe(N) // N/2 + N/2
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('CONTRAST — a function-interpolated styled with no rocketstyle/$element identity DOES resolve per call (correct, not waste)', () => {
|
|
117
|
+
const Dyn = styled('div')<{ c: string }>`
|
|
118
|
+
color: ${(p) => p.c};
|
|
119
|
+
`
|
|
120
|
+
// No object $rocketstyle / $rocketstate / $element on rawProps → neither
|
|
121
|
+
// classCache nor elClassCache can fire; doResolve() runs every call.
|
|
122
|
+
// Same prop value each call → cssText identical → sheet dedups (hit).
|
|
123
|
+
for (let i = 0; i < N; i++) Dyn({ c: 'red' })
|
|
124
|
+
|
|
125
|
+
expect(get('styler.resolve')).toBe(N) // genuinely per-call (CSS depends on props)
|
|
126
|
+
expect(get('styler.staticVNode.hit')).toBe(0) // never the static fast path
|
|
127
|
+
// `styler.sheet.insert` counts every insert() CALL (one per mount here);
|
|
128
|
+
// `.hit` is the dedup subset — identical cssText each call, so all but
|
|
129
|
+
// the first hit the insertCache and inject NO new DOM rule. The dynamic
|
|
130
|
+
// path still pays the resolve() + the insert() call-overhead per mount;
|
|
131
|
+
// only the actual rule injection is deduped. That call-overhead is the
|
|
132
|
+
// residual the rocketstyle/$element identity caches (classCache /
|
|
133
|
+
// elClassCache) eliminate in real-app shapes — proven elsewhere.
|
|
134
|
+
expect(get('styler.sheet.insert')).toBe(N) // one insert() call per mount
|
|
135
|
+
expect(get('styler.sheet.insert.hit')).toBe(N - 1) // first builds cache, rest dedup
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('SELF-DISCRIMINATING — static.resolve===0 AND dynamic.resolve===N in one run', () => {
|
|
139
|
+
const Static = styled('div')`
|
|
140
|
+
margin: 8px;
|
|
141
|
+
`
|
|
142
|
+
counts = new Map() // isolate from the definition insert
|
|
143
|
+
for (let i = 0; i < N; i++) Static({})
|
|
144
|
+
const staticResolve = get('styler.resolve')
|
|
145
|
+
|
|
146
|
+
const Dyn = styled('div')<{ c: string }>`
|
|
147
|
+
color: ${(p) => p.c};
|
|
148
|
+
`
|
|
149
|
+
counts = new Map()
|
|
150
|
+
for (let i = 0; i < N; i++) Dyn({ c: 'blue' })
|
|
151
|
+
const dynResolve = get('styler.resolve')
|
|
152
|
+
|
|
153
|
+
// The discriminator: if the static fast path ever regressed to
|
|
154
|
+
// per-mount resolve, this is the assertion that pinpoints it — the
|
|
155
|
+
// static side moves off 0 while the dynamic side stays at N.
|
|
156
|
+
expect(staticResolve).toBe(0)
|
|
157
|
+
expect(dynResolve).toBe(N)
|
|
158
|
+
expect(dynResolve - staticResolve).toBe(N)
|
|
159
|
+
})
|
|
160
|
+
})
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { defineManifest } from '@pyreon/manifest'
|
|
2
|
+
|
|
3
|
+
export default defineManifest({
|
|
4
|
+
name: '@pyreon/styler',
|
|
5
|
+
title: 'CSS-in-JS',
|
|
6
|
+
tagline:
|
|
7
|
+
'CSS-in-JS — styled() / css / keyframes / createGlobalStyle, reactive theming, FNV-1a-deduped StyleSheet (SSR-safe)',
|
|
8
|
+
description:
|
|
9
|
+
"Pyreon's CSS-in-JS engine. `styled('div')` is a tagged template that returns a `ComponentFn` injecting a generated class; `css` is a tagged template returning a LAZY `CSSResult` resolved on use (not a string); `keyframes` returns the generated animation-name string. Tagged-template interpolations receive the component's `props` (and the theme) so styles can be signal-driven — function interpolations flip the component onto the dynamic resolve path (`isDynamic`). A singleton `StyleSheet` with FNV-1a hashing dedupes and supports SSR; `createSheet()` makes an isolated instance. Theme is delivered through a REACTIVE context — `useTheme()` snapshots at call time, `useThemeAccessor()` returns the raw `() => Theme` accessor for tracking inside effects so whole-theme swaps re-resolve without remounting.",
|
|
10
|
+
category: 'browser',
|
|
11
|
+
features: [
|
|
12
|
+
"styled('div')`...` / styled(Component)`...` / styled.div`...` (Proxy) — component factory with `as` polymorphism + $-transient props",
|
|
13
|
+
'css`...` — lazy CSSResult, resolved on use (NOT a string)',
|
|
14
|
+
'keyframes`...` — returns the generated @keyframes animation-name string',
|
|
15
|
+
'createGlobalStyle`...` — returns a ComponentFn that injects global CSS when mounted',
|
|
16
|
+
'useCSS(template, props?, boost?) — resolve a CSSResult to a class name inside a component',
|
|
17
|
+
'Reactive theming — useTheme() snapshot vs useThemeAccessor() accessor; ThemeProvider merges nested',
|
|
18
|
+
'Singleton StyleSheet (FNV-1a dedup, SSR) + createSheet() for isolated instances',
|
|
19
|
+
'buildProps / filterProps — $-transient + shouldForwardProp DOM prop forwarding (descriptor-preserving)',
|
|
20
|
+
],
|
|
21
|
+
api: [
|
|
22
|
+
{
|
|
23
|
+
name: 'styled',
|
|
24
|
+
kind: 'function',
|
|
25
|
+
signature:
|
|
26
|
+
'styled: ((tag: Tag, options?: StyledOptions) => TagTemplateFn) & { div: TagTemplateFn; span: TagTemplateFn; /* …all HTML tags via Proxy */ }',
|
|
27
|
+
summary:
|
|
28
|
+
"Component factory. `styled('div')`, `styled(MyComp)`, and `styled.div` (Proxy sugar) are all tagged templates returning a `ComponentFn` that injects a generated class. Tagged-template interpolations are called with the live `props` object (theme included), so a function interpolation reading `p.theme.color` / signal-driven values works and puts the component on the dynamic resolve path. Supports the polymorphic `as` prop and `$`-prefixed TRANSIENT props (consumed by styles, NOT forwarded to the DOM). Per-definition caching keys generated classes so repeat mounts skip re-resolution.",
|
|
29
|
+
example: `import { styled } from "@pyreon/styler"
|
|
30
|
+
|
|
31
|
+
const Button = styled("button")\`
|
|
32
|
+
background: \${(p) => p.theme.colors.primary};
|
|
33
|
+
padding: \${(p) => (p.$compact ? "4px" : "12px")};
|
|
34
|
+
\`
|
|
35
|
+
// <Button $compact onClick={...}>Go</Button> — $compact not forwarded to <button>`,
|
|
36
|
+
mistakes: [
|
|
37
|
+
"Expecting `$`-prefixed props to reach the DOM — they are transient by design (consumed by the template, stripped before forwarding). Use a non-`$` name if the attribute must land on the element",
|
|
38
|
+
'Destructuring `props` in the interpolation (`${({ theme }) => …}`) and being surprised it does not update on a whole-theme swap — read `props.theme` lazily; the theme context is reactive and the styled resolver re-runs on swap',
|
|
39
|
+
'Passing a resolved value where a function interpolation is needed for reactivity — `${signal()}` snapshots once at definition; use `${() => signal()}` (or `${(p) => p.x}`) to stay on the dynamic path',
|
|
40
|
+
'Using `styled.div` and expecting a different identity per call — the Proxy returns the same tag template fn shape; per-definition caches key on the template, not the call site',
|
|
41
|
+
],
|
|
42
|
+
seeAlso: ['css', 'useCSS', 'useTheme'],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'css',
|
|
46
|
+
kind: 'function',
|
|
47
|
+
signature:
|
|
48
|
+
'css(strings: TemplateStringsArray, ...values: Interpolation[]): CSSResult',
|
|
49
|
+
summary:
|
|
50
|
+
'Tagged-template that returns a LAZY `CSSResult` — it is NOT a class name or a CSS string until resolved by `styled()`, `useCSS()`, or composition into another template. Compose reusable fragments with it (assign a `css` result to `const base`, then interpolate `base` inside a `styled` template). Resolution is deferred so it can read the props/theme of the consuming component at use time.',
|
|
51
|
+
example: `import { css, useCSS } from "@pyreon/styler"
|
|
52
|
+
|
|
53
|
+
const card = css\`border: 1px solid #ddd; padding: 16px;\`
|
|
54
|
+
function Card(props) {
|
|
55
|
+
const cls = useCSS(card)
|
|
56
|
+
return <div class={cls}>{props.children}</div>
|
|
57
|
+
}`,
|
|
58
|
+
mistakes: [
|
|
59
|
+
'Treating the `css` tagged-template return value as a string / class name — it is a lazy `CSSResult`; interpolating it into text (e.g. `class={card}`) renders `[object Object]`. Resolve via `useCSS` or embed in a `styled` template',
|
|
60
|
+
'Reading props/theme at `css` call time — the template is resolved later; put dynamic bits in function interpolations so they read the LIVE props at use',
|
|
61
|
+
],
|
|
62
|
+
seeAlso: ['styled', 'useCSS', 'keyframes'],
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'keyframes',
|
|
66
|
+
kind: 'function',
|
|
67
|
+
signature:
|
|
68
|
+
'keyframes(strings: TemplateStringsArray, ...values: Interpolation[]): KeyframesResult',
|
|
69
|
+
summary:
|
|
70
|
+
'Tagged-template returning a `KeyframesResult` whose string form is the GENERATED, content-hashed `@keyframes` animation NAME. Reference it inside a `css` / `styled` template as the `animation-name` value; the `@keyframes` rule is injected (deduped via FNV-1a) on first use.',
|
|
71
|
+
example: `import { keyframes, styled } from "@pyreon/styler"
|
|
72
|
+
|
|
73
|
+
const spin = keyframes\`from { transform: rotate(0) } to { transform: rotate(360deg) }\`
|
|
74
|
+
const Spinner = styled("div")\`animation: \${spin} 1s linear infinite;\``,
|
|
75
|
+
mistakes: [
|
|
76
|
+
'Expecting a CSS class — `keyframes` yields an animation-NAME token, used as the `animation` / `animation-name` value, not a class applied to an element',
|
|
77
|
+
'Defining `keyframes` inside the render body per mount — define once at module scope so the hashed rule is injected once and reused',
|
|
78
|
+
],
|
|
79
|
+
seeAlso: ['css', 'styled'],
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
name: 'createGlobalStyle',
|
|
83
|
+
kind: 'function',
|
|
84
|
+
signature:
|
|
85
|
+
'createGlobalStyle(strings: TemplateStringsArray, ...values: Interpolation[]): ComponentFn',
|
|
86
|
+
summary:
|
|
87
|
+
'Returns a `ComponentFn` that injects GLOBAL CSS (resets, `:root` tokens, body styles) when MOUNTED — it is not a side-effecting call. Render the returned component once near the app root; unmounting removes the global rule. Function interpolations make the global block dynamic (re-resolves on prop/theme change).',
|
|
88
|
+
example: `import { createGlobalStyle } from "@pyreon/styler"
|
|
89
|
+
|
|
90
|
+
const GlobalReset = createGlobalStyle\`
|
|
91
|
+
*, *::before, *::after { box-sizing: border-box }
|
|
92
|
+
body { margin: 0; font-family: \${(p) => p.theme.fonts.body}; }
|
|
93
|
+
\`
|
|
94
|
+
// render <GlobalReset /> once at the app root`,
|
|
95
|
+
mistakes: [
|
|
96
|
+
'Calling `createGlobalStyle` (the tagged template) and expecting the CSS to inject — nothing happens until the returned component is RENDERED. Mount `<GlobalReset />` once near the root',
|
|
97
|
+
'Mounting it in many components — duplicates the global rule lifetime management; mount exactly once',
|
|
98
|
+
],
|
|
99
|
+
seeAlso: ['styled', 'css'],
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: 'useCSS',
|
|
103
|
+
kind: 'hook',
|
|
104
|
+
signature:
|
|
105
|
+
'useCSS(template: CSSResult, props?: Record<string, any>, boost?: boolean): string',
|
|
106
|
+
summary:
|
|
107
|
+
'Resolves a `CSSResult` (from the `css` tagged template) to an injected class-name string inside a component. Pass `props` so function interpolations in the template read live values; `boost` opts into a faster cache path for hot, stable templates. The returned class is deduped/hashed by the active `StyleSheet`.',
|
|
108
|
+
example: `import { css, useCSS } from "@pyreon/styler"
|
|
109
|
+
|
|
110
|
+
const box = css\`color: \${(p) => p.danger ? "red" : "inherit"};\`
|
|
111
|
+
function Box(props) {
|
|
112
|
+
return <div class={useCSS(box, props)}>{props.children}</div>
|
|
113
|
+
}`,
|
|
114
|
+
mistakes: [
|
|
115
|
+
'Forgetting to pass `props` when the template has function interpolations — they then resolve against an empty object and the dynamic values are lost',
|
|
116
|
+
'Calling `useCSS` outside a component setup — it depends on the active sheet/theme context like any hook',
|
|
117
|
+
],
|
|
118
|
+
seeAlso: ['css', 'styled'],
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: 'useTheme',
|
|
122
|
+
kind: 'hook',
|
|
123
|
+
signature: 'useTheme<T extends object = Theme>(): T',
|
|
124
|
+
summary:
|
|
125
|
+
'Returns the current theme as a SNAPSHOT at call time. `ThemeContext` is a REACTIVE context — `useTheme()` reads it once, so the returned object is static unless the read happens inside a reactive scope. For values that must track whole-theme swaps inside an `effect` / `computed`, use `useThemeAccessor()` instead.',
|
|
126
|
+
example: `import { useTheme } from "@pyreon/styler"
|
|
127
|
+
|
|
128
|
+
function Badge() {
|
|
129
|
+
const t = useTheme()
|
|
130
|
+
return <span style={{ color: t.colors.primary }}>{/* … */}</span>
|
|
131
|
+
}`,
|
|
132
|
+
mistakes: [
|
|
133
|
+
'Destructuring `const { colors } = useTheme()` and expecting it to update on a user-preference theme swap — the snapshot is captured once. Use `useThemeAccessor()` and read inside the reactive scope, or rely on `styled` templates (their resolver tracks the theme)',
|
|
134
|
+
'Calling `useTheme()` at module scope — it must run during component setup where the context is available',
|
|
135
|
+
],
|
|
136
|
+
seeAlso: ['useThemeAccessor', 'ThemeProvider', 'styled'],
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: 'useThemeAccessor',
|
|
140
|
+
kind: 'hook',
|
|
141
|
+
signature: 'useThemeAccessor<T extends object = Theme>(): () => T',
|
|
142
|
+
summary:
|
|
143
|
+
'Returns the raw `() => T` theme accessor (not a snapshot). Call it inside an `effect` / `computed` / JSX thunk so the read TRACKS the reactive theme context — whole-theme swaps (user-preference themes) then re-run the consumer without a remount. This is the escape hatch `styled()` itself uses internally.',
|
|
144
|
+
example: `import { useThemeAccessor } from "@pyreon/styler"
|
|
145
|
+
import { effect } from "@pyreon/reactivity"
|
|
146
|
+
|
|
147
|
+
const theme = useThemeAccessor()
|
|
148
|
+
effect(() => applyChartPalette(theme().colors)) // re-runs on theme swap`,
|
|
149
|
+
mistakes: [
|
|
150
|
+
'Calling the accessor once at setup and caching the result — that defeats the point; call it INSIDE the reactive scope every time so the dependency is tracked',
|
|
151
|
+
'Reaching for this when a `styled` template would do — the template resolver already tracks the theme; use the accessor only for imperative/non-CSS theme reads',
|
|
152
|
+
],
|
|
153
|
+
seeAlso: ['useTheme', 'ThemeProvider'],
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
name: 'ThemeProvider',
|
|
157
|
+
kind: 'component',
|
|
158
|
+
signature:
|
|
159
|
+
'ThemeProvider(props: { theme: Theme | ((parent: Theme) => Theme); children?: VNodeChild }): VNodeChild',
|
|
160
|
+
summary:
|
|
161
|
+
'Provides a theme to the reactive `ThemeContext`. Nested providers compose — a function `theme` receives the parent theme so subtrees can extend rather than replace. Because the context is reactive, swapping the `theme` prop re-resolves every `styled` / `useCSS` consumer below without remounting the tree. Marked `nativeCompat` so it works inside `@pyreon/{react,preact,vue,solid}-compat` apps.',
|
|
162
|
+
example: `import { ThemeProvider } from "@pyreon/styler"
|
|
163
|
+
|
|
164
|
+
<ThemeProvider theme={{ colors: { primary: "#06f" } }}>
|
|
165
|
+
<App />
|
|
166
|
+
</ThemeProvider>`,
|
|
167
|
+
mistakes: [
|
|
168
|
+
'Replacing the whole theme in a nested provider when you meant to extend — pass `theme={(parent) => ({ ...parent, colors: { ...parent.colors, accent: "#0a0" } })}`',
|
|
169
|
+
'Expecting most apps to mount this directly — `<PyreonUI>` wraps it; use `ThemeProvider` standalone only outside the `@pyreon/ui-core` provider',
|
|
170
|
+
],
|
|
171
|
+
seeAlso: ['useTheme', 'useThemeAccessor', 'ThemeContext'],
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
name: 'ThemeContext',
|
|
175
|
+
kind: 'constant',
|
|
176
|
+
signature: 'ThemeContext: ReactiveContext<Theme>',
|
|
177
|
+
summary:
|
|
178
|
+
'The reactive context backing the theme. Created via `createReactiveContext<Theme>` — `useContext(ThemeContext)` returns a `() => Theme` accessor (which is what `useTheme()` / `useThemeAccessor()` wrap). Exposed for advanced consumers building their own theme-aware primitives; prefer the hooks for app code.',
|
|
179
|
+
example: `import { ThemeContext } from "@pyreon/styler"
|
|
180
|
+
import { useContext } from "@pyreon/core"
|
|
181
|
+
|
|
182
|
+
const themeAccessor = useContext(ThemeContext) // () => Theme`,
|
|
183
|
+
mistakes: [
|
|
184
|
+
'Treating `useContext(ThemeContext)` as the theme object — it is the ACCESSOR `() => Theme` (reactive context). Call it to read',
|
|
185
|
+
],
|
|
186
|
+
seeAlso: ['useTheme', 'useThemeAccessor', 'ThemeProvider'],
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
name: 'createSheet',
|
|
190
|
+
kind: 'function',
|
|
191
|
+
signature: 'createSheet(options?: StyleSheetOptions): StyleSheet',
|
|
192
|
+
summary:
|
|
193
|
+
'Creates an ISOLATED `StyleSheet` instance (its own FNV-1a dedup cache + rule registry) instead of the shared singleton `sheet`. Use for shadow-DOM roots, multi-window/iframe rendering, or test isolation where one request/realm must not share the global dedup cache. Most apps never need this — the singleton is correct for a single document.',
|
|
194
|
+
example: `import { createSheet } from "@pyreon/styler"
|
|
195
|
+
|
|
196
|
+
const shadowSheet = createSheet({ /* StyleSheetOptions */ })`,
|
|
197
|
+
mistakes: [
|
|
198
|
+
'Creating a fresh sheet per render — defeats dedup; create once per realm/root and reuse',
|
|
199
|
+
'Mixing the singleton and an isolated sheet for the same DOM — classes from one will not be deduped against the other; pick one per document root',
|
|
200
|
+
],
|
|
201
|
+
seeAlso: ['StyleSheet', 'sheet'],
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
name: 'StyleSheet',
|
|
205
|
+
kind: 'class',
|
|
206
|
+
signature: 'class StyleSheet { constructor(options?: StyleSheetOptions) }',
|
|
207
|
+
summary:
|
|
208
|
+
'The CSS injection engine: FNV-1a content hashing, a dedup cache (identical CSS → one rule), and SSR support (collect rules to a string on the server, hydrate on the client). `sheet` is the process singleton; `createSheet()` wraps `new StyleSheet()`. Direct instantiation is for custom integrations (server frameworks collecting critical CSS, test harnesses).',
|
|
209
|
+
example: `import { StyleSheet } from "@pyreon/styler"
|
|
210
|
+
|
|
211
|
+
const s = new StyleSheet({ /* options */ })`,
|
|
212
|
+
mistakes: [
|
|
213
|
+
'Instantiating `new StyleSheet()` in app code — use the exported `sheet` singleton (or `createSheet()` for explicit isolation); a stray instance will not be where `styled()` injects',
|
|
214
|
+
],
|
|
215
|
+
seeAlso: ['createSheet', 'sheet'],
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
name: 'sheet',
|
|
219
|
+
kind: 'constant',
|
|
220
|
+
signature: 'sheet: StyleSheet',
|
|
221
|
+
summary:
|
|
222
|
+
'The process-wide singleton `StyleSheet` that `styled()` / `css` / `keyframes` / `createGlobalStyle` inject into by default. Read it for SSR critical-CSS extraction or debugging the rule registry; do not mutate it directly.',
|
|
223
|
+
example: `import { sheet } from "@pyreon/styler"
|
|
224
|
+
// SSR: render the app, then read the collected rules off \`sheet\` for the <head>`,
|
|
225
|
+
seeAlso: ['StyleSheet', 'createSheet'],
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
name: 'resolve',
|
|
229
|
+
kind: 'function',
|
|
230
|
+
signature:
|
|
231
|
+
'resolve(strings: TemplateStringsArray, values: Interpolation[], props: Record<string, any>): string',
|
|
232
|
+
summary:
|
|
233
|
+
'Low-level: resolve a tagged-template (strings + interpolations) against a `props` object into a final CSS string (function interpolations invoked with `props`). The engine `styled()` / `useCSS` build on. Direct use is for custom CSS-in-JS layered on top of styler; app code should prefer `styled` / `css`.',
|
|
234
|
+
example: `import { resolve } from "@pyreon/styler"
|
|
235
|
+
|
|
236
|
+
const cssText = resolve(strings, values, { theme, $compact: true })`,
|
|
237
|
+
seeAlso: ['normalizeCSS', 'resolveValue', 'styled'],
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
name: 'normalizeCSS',
|
|
241
|
+
kind: 'function',
|
|
242
|
+
signature: 'normalizeCSS(css: string): string',
|
|
243
|
+
summary:
|
|
244
|
+
'Normalizes a raw CSS string (whitespace/format canonicalization) so identical-intent CSS hashes to the same FNV-1a key and dedupes. Memoized via an internal cache — call `clearNormCache()` to drop it (tests / long-lived processes).',
|
|
245
|
+
example: `import { normalizeCSS } from "@pyreon/styler"
|
|
246
|
+
|
|
247
|
+
normalizeCSS("color: red ;") // canonical form, dedup-stable`,
|
|
248
|
+
seeAlso: ['clearNormCache', 'resolve'],
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: 'resolveValue',
|
|
252
|
+
kind: 'function',
|
|
253
|
+
signature:
|
|
254
|
+
'resolveValue(value: Interpolation, props: Record<string, any>): string',
|
|
255
|
+
summary:
|
|
256
|
+
'Resolves a SINGLE interpolation against `props`: invokes function interpolations with `props`, flattens nested `CSSResult` / `KeyframesResult`, and stringifies the result. The per-interpolation primitive `resolve()` loops over.',
|
|
257
|
+
example: `import { resolveValue } from "@pyreon/styler"
|
|
258
|
+
|
|
259
|
+
resolveValue((p) => p.theme.colors.primary, { theme })`,
|
|
260
|
+
seeAlso: ['resolve', 'isDynamic'],
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
name: 'clearNormCache',
|
|
264
|
+
kind: 'function',
|
|
265
|
+
signature: 'clearNormCache(): void',
|
|
266
|
+
summary:
|
|
267
|
+
'Clears the `normalizeCSS` memo cache. Needed in test suites that assert on injection counts / sheet contents across cases, and in long-lived processes that churn unique CSS and want to bound the cache. No effect on already-injected rules.',
|
|
268
|
+
example: `import { clearNormCache } from "@pyreon/styler"
|
|
269
|
+
|
|
270
|
+
afterEach(() => clearNormCache())`,
|
|
271
|
+
seeAlso: ['normalizeCSS'],
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
name: 'buildProps',
|
|
275
|
+
kind: 'function',
|
|
276
|
+
signature:
|
|
277
|
+
'buildProps(rawProps: Record<string, any>, generatedCls: string, isDOM: boolean, customFilter?: (prop: string) => boolean): Record<string, any>',
|
|
278
|
+
summary:
|
|
279
|
+
"Builds the final prop object forwarded to the rendered element: merges the generated class, drops `$`-transient props, and (for DOM targets) filters non-DOM attributes — `customFilter` overrides per-component. **Copies DESCRIPTORS, not values**, so compiler-emitted reactive (`_rp` getter) props survive forwarding instead of collapsing to a static snapshot.",
|
|
280
|
+
example: `import { buildProps } from "@pyreon/styler"
|
|
281
|
+
|
|
282
|
+
const forwarded = buildProps(rawProps, "sc-abc123", true)`,
|
|
283
|
+
mistakes: [
|
|
284
|
+
'Re-implementing prop forwarding with `result[key] = source[key]` — that fires getters and freezes reactive props to a one-time value. styler uses descriptor copy specifically to preserve the `_rp` getter contract; any custom forwarder must do the same',
|
|
285
|
+
'Passing `isDOM: true` for a component target — DOM-attr filtering will strip props the wrapped component legitimately needs',
|
|
286
|
+
],
|
|
287
|
+
seeAlso: ['filterProps', 'styled'],
|
|
288
|
+
},
|
|
289
|
+
{
|
|
290
|
+
name: 'filterProps',
|
|
291
|
+
kind: 'function',
|
|
292
|
+
signature:
|
|
293
|
+
'filterProps(props: Record<string, unknown>): Record<string, unknown>',
|
|
294
|
+
summary:
|
|
295
|
+
'Returns a copy of `props` with `$`-transient and known non-DOM props removed — the DOM-safety filter `buildProps` applies for element targets. Exposed for consumers doing their own forwarding who still want the styler allowlist semantics. Descriptor-preserving, same reactive-prop rationale as `buildProps`.',
|
|
296
|
+
example: `import { filterProps } from "@pyreon/styler"
|
|
297
|
+
|
|
298
|
+
const domSafe = filterProps(props)`,
|
|
299
|
+
seeAlso: ['buildProps'],
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
name: 'isDynamic',
|
|
303
|
+
kind: 'function',
|
|
304
|
+
signature: 'isDynamic(v: Interpolation): boolean',
|
|
305
|
+
summary:
|
|
306
|
+
'True when an interpolation is a function (signal accessor / props reader) — i.e. the styled component must take the DYNAMIC resolve path (re-resolve per prop/theme change) rather than the static cached path. Used internally to decide the resolver branch; exported for tooling that mirrors that decision.',
|
|
307
|
+
example: `import { isDynamic } from "@pyreon/styler"
|
|
308
|
+
|
|
309
|
+
isDynamic((p) => p.color) // true → dynamic path
|
|
310
|
+
isDynamic("12px") // false → static, cached`,
|
|
311
|
+
seeAlso: ['resolve', 'styled'],
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
gotchas: [
|
|
315
|
+
{
|
|
316
|
+
label: 'css / keyframes return lazy values, not strings',
|
|
317
|
+
note: 'The `css` tagged template yields a `CSSResult` (resolved on use); `keyframes` stringifies to an animation NAME; `createGlobalStyle` returns a `ComponentFn` that must be MOUNTED. None of them inject CSS at call time — only on resolution/mount.',
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
label: 'Theme context is reactive',
|
|
321
|
+
note: '`useTheme()` is a snapshot; `useThemeAccessor()` is the tracking accessor. `styled` / `useCSS` templates already track the theme through their resolver, so whole-theme swaps re-resolve CSS + swap class names WITHOUT remounting the VNode. Destructuring `useTheme()` and reading outside a reactive scope freezes the value.',
|
|
322
|
+
},
|
|
323
|
+
{
|
|
324
|
+
label: 'Prop forwarding copies descriptors',
|
|
325
|
+
note: '`buildProps` / `filterProps` copy property DESCRIPTORS (not values) so compiler-emitted `_rp` getter props keep their reactive subscription end-to-end. Any custom prop-forwarding wrapper layered on styler MUST do the same — plain `result[k] = src[k]` silently collapses signal-driven props to a one-time snapshot.',
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
label: 'Singleton sheet by default',
|
|
329
|
+
note: 'All injection goes through the `sheet` singleton (FNV-1a dedup, SSR). `createSheet()` / `new StyleSheet()` are only for isolated realms (shadow DOM, iframes, test isolation) — mixing sheets for one document breaks dedup.',
|
|
330
|
+
},
|
|
331
|
+
],
|
|
332
|
+
})
|
package/src/sheet.ts
CHANGED
|
@@ -32,6 +32,16 @@ export interface StyleSheetOptions {
|
|
|
32
32
|
export class StyleSheet {
|
|
33
33
|
private cache = new Map<string, string>()
|
|
34
34
|
private insertCache = new Map<string, string>()
|
|
35
|
+
// Reverse index: cache key (className / keyframe name / global key) →
|
|
36
|
+
// the insertCache keys that resolve to it. Lets eviction drop the
|
|
37
|
+
// (large) cssText-keyed insertCache entries in lockstep with `cache`,
|
|
38
|
+
// instead of letting them grow unbounded for the process lifetime.
|
|
39
|
+
private icKeysByClass = new Map<string, Set<string>>()
|
|
40
|
+
// Reverse index: cache key → the top-level CSSRule objects it inserted
|
|
41
|
+
// into the live sheet. Object references survive `deleteRule()`
|
|
42
|
+
// reindexing (only the numeric index shifts), so eviction can locate
|
|
43
|
+
// and remove the exact DOM rules without fragile index bookkeeping.
|
|
44
|
+
private domRules = new Map<string, CSSRule[]>()
|
|
35
45
|
private sheet: CSSStyleSheet | null = null
|
|
36
46
|
private ssrBuffer: string[] = []
|
|
37
47
|
private isSSR: boolean
|
|
@@ -47,6 +57,10 @@ export class StyleSheet {
|
|
|
47
57
|
}
|
|
48
58
|
|
|
49
59
|
private mount() {
|
|
60
|
+
// SSR guard: the constructor only calls mount() when !this.isSSR, but
|
|
61
|
+
// keep the guard in-method so it's self-evidently SSR-safe regardless
|
|
62
|
+
// of caller (matches `this.isSSR = typeof document === 'undefined'`).
|
|
63
|
+
if (this.isSSR) return
|
|
50
64
|
// Reuse existing <style> tag from SSR hydration
|
|
51
65
|
const existing = document.querySelector(`style[${ATTR}]`) as HTMLStyleElement | null
|
|
52
66
|
|
|
@@ -118,18 +132,80 @@ export class StyleSheet {
|
|
|
118
132
|
}
|
|
119
133
|
}
|
|
120
134
|
|
|
135
|
+
/** Record that `icKey` resolves to `cacheKey` (for lockstep eviction). */
|
|
136
|
+
private trackIcKey(cacheKey: string, icKey: string): void {
|
|
137
|
+
let s = this.icKeysByClass.get(cacheKey)
|
|
138
|
+
if (!s) {
|
|
139
|
+
s = new Set()
|
|
140
|
+
this.icKeysByClass.set(cacheKey, s)
|
|
141
|
+
}
|
|
142
|
+
s.add(icKey)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Record a top-level CSSRule this `cacheKey` inserted into the sheet. */
|
|
146
|
+
private trackDomRule(cacheKey: string, ref: CSSRule | null | undefined): void {
|
|
147
|
+
if (!ref) return
|
|
148
|
+
let a = this.domRules.get(cacheKey)
|
|
149
|
+
if (!a) {
|
|
150
|
+
a = []
|
|
151
|
+
this.domRules.set(cacheKey, a)
|
|
152
|
+
}
|
|
153
|
+
a.push(ref)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Evict the given cache keys across ALL three storage layers:
|
|
158
|
+
* the `cache` Map, the cssText-keyed `insertCache` Map, and the live
|
|
159
|
+
* DOM rules. Without the latter two, `maxCacheSize` bounded only the
|
|
160
|
+
* smallest of the three — `insertCache` keys (full CSS text) and the
|
|
161
|
+
* `<style>` tag's `cssRules` grew unbounded for the app's lifetime,
|
|
162
|
+
* which is the actual memory leak this method exists to prevent.
|
|
163
|
+
*/
|
|
164
|
+
private evictKeys(keys: string[]): void {
|
|
165
|
+
const ruleRefs = new Set<CSSRule>()
|
|
166
|
+
for (const key of keys) {
|
|
167
|
+
this.cache.delete(key)
|
|
168
|
+
const ics = this.icKeysByClass.get(key)
|
|
169
|
+
if (ics) {
|
|
170
|
+
for (const ic of ics) this.insertCache.delete(ic)
|
|
171
|
+
this.icKeysByClass.delete(key)
|
|
172
|
+
}
|
|
173
|
+
const refs = this.domRules.get(key)
|
|
174
|
+
if (refs) {
|
|
175
|
+
for (const r of refs) ruleRefs.add(r)
|
|
176
|
+
this.domRules.delete(key)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (this.sheet && ruleRefs.size > 0) {
|
|
180
|
+
// Descending walk: deleting at i never shifts a not-yet-visited
|
|
181
|
+
// lower index, so identity matching stays correct mid-loop.
|
|
182
|
+
for (let i = this.sheet.cssRules.length - 1; i >= 0; i--) {
|
|
183
|
+
const r = this.sheet.cssRules[i]
|
|
184
|
+
if (r && ruleRefs.has(r)) {
|
|
185
|
+
try {
|
|
186
|
+
this.sheet.deleteRule(i)
|
|
187
|
+
} catch {
|
|
188
|
+
// Rule already gone (e.g. external clearAll) — ignore.
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
121
195
|
/** Evict oldest entries when cache exceeds max size. */
|
|
122
196
|
private evictIfNeeded() {
|
|
123
197
|
if (this.cache.size <= this.maxCacheSize) return
|
|
124
198
|
|
|
125
199
|
// Map iteration order is insertion order — delete oldest 10%
|
|
126
200
|
const toDelete = Math.floor(this.maxCacheSize * 0.1)
|
|
201
|
+
const evicted: string[] = []
|
|
127
202
|
let count = 0
|
|
128
203
|
for (const key of this.cache.keys()) {
|
|
129
204
|
if (count >= toDelete) break
|
|
130
|
-
|
|
205
|
+
evicted.push(key)
|
|
131
206
|
count++
|
|
132
207
|
}
|
|
208
|
+
this.evictKeys(evicted)
|
|
133
209
|
}
|
|
134
210
|
|
|
135
211
|
/**
|
|
@@ -226,6 +302,7 @@ export class StyleSheet {
|
|
|
226
302
|
|
|
227
303
|
if (this.cache.has(className)) {
|
|
228
304
|
this.insertCache.set(icKey, className)
|
|
305
|
+
this.trackIcKey(className, icKey)
|
|
229
306
|
return className
|
|
230
307
|
}
|
|
231
308
|
|
|
@@ -254,7 +331,8 @@ export class StyleSheet {
|
|
|
254
331
|
} else if (this.sheet) {
|
|
255
332
|
for (const rule of finalRules) {
|
|
256
333
|
try {
|
|
257
|
-
this.sheet.insertRule(rule, this.sheet.cssRules.length)
|
|
334
|
+
const at = this.sheet.insertRule(rule, this.sheet.cssRules.length)
|
|
335
|
+
this.trackDomRule(className, this.sheet.cssRules[at])
|
|
258
336
|
} catch (_e) {
|
|
259
337
|
if (__DEV__) {
|
|
260
338
|
// oxlint-disable-next-line no-console
|
|
@@ -265,6 +343,7 @@ export class StyleSheet {
|
|
|
265
343
|
}
|
|
266
344
|
|
|
267
345
|
this.insertCache.set(icKey, className)
|
|
346
|
+
this.trackIcKey(className, icKey)
|
|
268
347
|
return className
|
|
269
348
|
}
|
|
270
349
|
|
|
@@ -281,7 +360,8 @@ export class StyleSheet {
|
|
|
281
360
|
this.ssrBuffer.push(rule)
|
|
282
361
|
} else if (this.sheet) {
|
|
283
362
|
try {
|
|
284
|
-
this.sheet.insertRule(rule, this.sheet.cssRules.length)
|
|
363
|
+
const at = this.sheet.insertRule(rule, this.sheet.cssRules.length)
|
|
364
|
+
this.trackDomRule(name, this.sheet.cssRules[at])
|
|
285
365
|
} catch (_e) {
|
|
286
366
|
if (__DEV__) {
|
|
287
367
|
// oxlint-disable-next-line no-console
|
|
@@ -332,7 +412,8 @@ export class StyleSheet {
|
|
|
332
412
|
const rules = this.splitRules(cssText)
|
|
333
413
|
for (const rule of rules) {
|
|
334
414
|
try {
|
|
335
|
-
this.sheet.insertRule(rule, this.sheet.cssRules.length)
|
|
415
|
+
const at = this.sheet.insertRule(rule, this.sheet.cssRules.length)
|
|
416
|
+
this.trackDomRule(key, this.sheet.cssRules[at])
|
|
336
417
|
} catch (_e) {
|
|
337
418
|
if (__DEV__) {
|
|
338
419
|
// oxlint-disable-next-line no-console
|
|
@@ -358,6 +439,64 @@ export class StyleSheet {
|
|
|
358
439
|
return `<style ${ATTR}="">${css}</style>`
|
|
359
440
|
}
|
|
360
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Returns the collected SSR rules as a raw array (one entry per
|
|
444
|
+
* top-level rule, already `@layer`-wrapped + class-prefixed exactly as
|
|
445
|
+
* `insert()` produced them). Used by the compile-time rocketstyle
|
|
446
|
+
* collapse resolver: it renders a component under SSR, reads the rules
|
|
447
|
+
* here, and the build emits an idempotent `injectRules()` call so the
|
|
448
|
+
* collapsed `_tpl()` site is self-sufficient (no prior runtime mount
|
|
449
|
+
* needed to populate the sheet). A copy — callers must not mutate the
|
|
450
|
+
* internal buffer.
|
|
451
|
+
*/
|
|
452
|
+
getStyleRules(): readonly string[] {
|
|
453
|
+
return this.ssrBuffer.slice()
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Idempotency guard for injectRules — keyed by the FNV hash the
|
|
457
|
+
// collapse resolver computes over the rule set. A second injection of
|
|
458
|
+
// the same resolved bundle (e.g. the module re-evaluated under HMR, or
|
|
459
|
+
// two collapsed call sites resolving to the same dimension combo) is a
|
|
460
|
+
// no-op instead of duplicate live `cssRules`.
|
|
461
|
+
private injectedBundles = new Set<string>()
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Inject pre-resolved CSS rule text (from `getStyleRules()` captured at
|
|
465
|
+
* build time by the rocketstyle-collapse resolver) directly into the
|
|
466
|
+
* live sheet. Unlike `insert()` this does NOT re-hash — the class names
|
|
467
|
+
* are already baked into `rules` and into the collapsed `_tpl()` HTML;
|
|
468
|
+
* re-hashing would produce a different class and break the contract.
|
|
469
|
+
* Idempotent by `key` (the resolver's FNV hash of the bundle).
|
|
470
|
+
*/
|
|
471
|
+
injectRules(rules: readonly string[], key: string): void {
|
|
472
|
+
if (this.injectedBundles.has(key)) return
|
|
473
|
+
this.injectedBundles.add(key)
|
|
474
|
+
if (this.isSSR) {
|
|
475
|
+
for (const rule of rules) this.ssrBuffer.push(rule)
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
if (!this.sheet) return
|
|
479
|
+
for (const rule of rules) {
|
|
480
|
+
try {
|
|
481
|
+
this.sheet.insertRule(rule, this.sheet.cssRules.length)
|
|
482
|
+
} catch (_e) {
|
|
483
|
+
if (__DEV__) {
|
|
484
|
+
// oxlint-disable-next-line no-console
|
|
485
|
+
console.warn('[styler] injectRules: failed to insert collapsed rule:', rule, _e)
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Test-only: live `cssRules.length` (0 in SSR). Mirrors runtime-dom's
|
|
493
|
+
* `_tplCacheSize()` test-only-accessor convention; lets injectRules /
|
|
494
|
+
* eviction tests assert without reaching into the private sheet.
|
|
495
|
+
*/
|
|
496
|
+
ruleCountForTest(): number {
|
|
497
|
+
return this.sheet?.cssRules.length ?? 0
|
|
498
|
+
}
|
|
499
|
+
|
|
361
500
|
/** Returns collected CSS rules as a raw string (useful for streaming SSR). */
|
|
362
501
|
getStyles(): string {
|
|
363
502
|
if (this.ssrBuffer.length === 0) return ''
|
|
@@ -379,12 +518,16 @@ export class StyleSheet {
|
|
|
379
518
|
this.ssrBuffer = []
|
|
380
519
|
this.cache.clear()
|
|
381
520
|
this.insertCache.clear()
|
|
521
|
+
this.icKeysByClass.clear()
|
|
522
|
+
this.domRules.clear()
|
|
382
523
|
}
|
|
383
524
|
|
|
384
525
|
/** Clear the dedup cache. Useful for HMR / dev-time reloads. */
|
|
385
526
|
clearCache(): void {
|
|
386
527
|
this.cache.clear()
|
|
387
528
|
this.insertCache.clear()
|
|
529
|
+
this.icKeysByClass.clear()
|
|
530
|
+
this.domRules.clear()
|
|
388
531
|
clearNormCache()
|
|
389
532
|
}
|
|
390
533
|
|
|
@@ -401,6 +544,8 @@ export class StyleSheet {
|
|
|
401
544
|
clearAll(): void {
|
|
402
545
|
this.cache.clear()
|
|
403
546
|
this.insertCache.clear()
|
|
547
|
+
this.icKeysByClass.clear()
|
|
548
|
+
this.domRules.clear()
|
|
404
549
|
clearNormCache()
|
|
405
550
|
this.ssrBuffer = []
|
|
406
551
|
if (this.sheet) {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import {
|
|
2
|
+
renderApiReferenceEntries,
|
|
3
|
+
renderLlmsFullSection,
|
|
4
|
+
renderLlmsTxtLine,
|
|
5
|
+
} from '@pyreon/manifest'
|
|
6
|
+
import manifest from '../manifest'
|
|
7
|
+
|
|
8
|
+
describe('gen-docs — styler snapshot', () => {
|
|
9
|
+
it('renders a llms.txt bullet starting with the package prefix', () => {
|
|
10
|
+
const line = renderLlmsTxtLine(manifest)
|
|
11
|
+
expect(line.startsWith('- @pyreon/styler —')).toBe(true)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('renders a llms-full.txt section with the right header', () => {
|
|
15
|
+
const section = renderLlmsFullSection(manifest)
|
|
16
|
+
expect(section.startsWith('## @pyreon/styler —')).toBe(true)
|
|
17
|
+
expect(section).toContain('```typescript')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('renders MCP api-reference entries for every api[] item', () => {
|
|
21
|
+
const record = renderApiReferenceEntries(manifest)
|
|
22
|
+
expect(Object.keys(record).sort()).toEqual([
|
|
23
|
+
'styler/StyleSheet',
|
|
24
|
+
'styler/ThemeContext',
|
|
25
|
+
'styler/ThemeProvider',
|
|
26
|
+
'styler/buildProps',
|
|
27
|
+
'styler/clearNormCache',
|
|
28
|
+
'styler/createGlobalStyle',
|
|
29
|
+
'styler/createSheet',
|
|
30
|
+
'styler/css',
|
|
31
|
+
'styler/filterProps',
|
|
32
|
+
'styler/isDynamic',
|
|
33
|
+
'styler/keyframes',
|
|
34
|
+
'styler/normalizeCSS',
|
|
35
|
+
'styler/resolve',
|
|
36
|
+
'styler/resolveValue',
|
|
37
|
+
'styler/sheet',
|
|
38
|
+
'styler/styled',
|
|
39
|
+
'styler/useCSS',
|
|
40
|
+
'styler/useTheme',
|
|
41
|
+
'styler/useThemeAccessor',
|
|
42
|
+
])
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('carries the CSS-in-JS foot-gun catalog into MCP mistakes for flagship APIs', () => {
|
|
46
|
+
const r = renderApiReferenceEntries(manifest)
|
|
47
|
+
expect(r['styler/styled']?.mistakes).toContain('transient')
|
|
48
|
+
expect(r['styler/css']?.mistakes).toContain('lazy')
|
|
49
|
+
expect(r['styler/useTheme']?.mistakes).toContain('snapshot')
|
|
50
|
+
})
|
|
51
|
+
})
|