@pyreon/styler 0.16.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.d.ts CHANGED
@@ -99,6 +99,14 @@ declare const filterProps: (props: Record<string, unknown>) => Record<string, un
99
99
  * Build final props for a styled component in a single pass.
100
100
  * Combines className merging, ref injection, and prop filtering into one
101
101
  * allocation and one iteration.
102
+ *
103
+ * Copies own property DESCRIPTORS rather than values for forwarded
104
+ * props — getter-shaped reactive props (compiler-emitted `_rp(() =>
105
+ * signal())` converted to getters by `makeReactiveProps`) survive the
106
+ * copy with their reactive subscription intact. A bare `result[key] =
107
+ * rawProps[key]` fires the getter at setup time and stores a static
108
+ * value, breaking signal-driven reactivity for any consumer that reads
109
+ * `props.x` in a reactive scope downstream.
102
110
  */
103
111
  declare const buildProps: (rawProps: Record<string, any>, generatedCls: string, isDOM: boolean, customFilter?: (prop: string) => boolean) => Record<string, any>;
104
112
  //#endregion
@@ -147,6 +155,8 @@ interface StyleSheetOptions {
147
155
  declare class StyleSheet {
148
156
  private cache;
149
157
  private insertCache;
158
+ private icKeysByClass;
159
+ private domRules;
150
160
  private sheet;
151
161
  private ssrBuffer;
152
162
  private isSSR;
@@ -159,6 +169,19 @@ declare class StyleSheet {
159
169
  private extractClassName;
160
170
  /** Parse existing rules from SSR-rendered <style> tag into cache. */
161
171
  private hydrateFromTag;
172
+ /** Record that `icKey` resolves to `cacheKey` (for lockstep eviction). */
173
+ private trackIcKey;
174
+ /** Record a top-level CSSRule this `cacheKey` inserted into the sheet. */
175
+ private trackDomRule;
176
+ /**
177
+ * Evict the given cache keys across ALL three storage layers:
178
+ * the `cache` Map, the cssText-keyed `insertCache` Map, and the live
179
+ * DOM rules. Without the latter two, `maxCacheSize` bounded only the
180
+ * smallest of the three — `insertCache` keys (full CSS text) and the
181
+ * `<style>` tag's `cssRules` grew unbounded for the app's lifetime,
182
+ * which is the actual memory leak this method exists to prevent.
183
+ */
184
+ private evictKeys;
162
185
  /** Evict oldest entries when cache exceeds max size. */
163
186
  private evictIfNeeded;
164
187
  /**
package/lib/index.js CHANGED
@@ -331,24 +331,36 @@ const filterProps = (props) => {
331
331
  * Build final props for a styled component in a single pass.
332
332
  * Combines className merging, ref injection, and prop filtering into one
333
333
  * allocation and one iteration.
334
+ *
335
+ * Copies own property DESCRIPTORS rather than values for forwarded
336
+ * props — getter-shaped reactive props (compiler-emitted `_rp(() =>
337
+ * signal())` converted to getters by `makeReactiveProps`) survive the
338
+ * copy with their reactive subscription intact. A bare `result[key] =
339
+ * rawProps[key]` fires the getter at setup time and stores a static
340
+ * value, breaking signal-driven reactivity for any consumer that reads
341
+ * `props.x` in a reactive scope downstream.
334
342
  */
335
343
  const buildProps = (rawProps, generatedCls, isDOM, customFilter) => {
336
344
  const result = {};
337
345
  const userCls = rawProps.class || rawProps.className;
338
346
  if (generatedCls) result.class = userCls ? `${generatedCls} ${userCls}` : generatedCls;
339
347
  else if (userCls) result.class = userCls;
348
+ const copyDescriptor = (key) => {
349
+ const d = Object.getOwnPropertyDescriptor(rawProps, key);
350
+ if (d) Object.defineProperty(result, key, d);
351
+ };
340
352
  if (!isDOM) {
341
353
  for (const key in rawProps) {
342
354
  if (key === "as" || key === "className" || key === "class") continue;
343
355
  if (key.charCodeAt(0) === 36) continue;
344
- result[key] = rawProps[key];
356
+ copyDescriptor(key);
345
357
  }
346
358
  return result;
347
359
  }
348
360
  if (customFilter) {
349
361
  for (const key in rawProps) {
350
362
  if (key === "as" || key === "className" || key === "class") continue;
351
- if (customFilter(key)) result[key] = rawProps[key];
363
+ if (customFilter(key)) copyDescriptor(key);
352
364
  }
353
365
  return result;
354
366
  }
@@ -356,10 +368,10 @@ const buildProps = (rawProps, generatedCls, isDOM, customFilter) => {
356
368
  if (key === "as" || key === "className" || key === "class") continue;
357
369
  if (key.charCodeAt(0) === 36) continue;
358
370
  if (key.startsWith("data-") || key.startsWith("aria-")) {
359
- result[key] = rawProps[key];
371
+ copyDescriptor(key);
360
372
  continue;
361
373
  }
362
- if (HTML_PROPS.has(key)) result[key] = rawProps[key];
374
+ if (HTML_PROPS.has(key)) copyDescriptor(key);
363
375
  }
364
376
  return result;
365
377
  };
@@ -422,6 +434,8 @@ const DEFAULT_MAX_CACHE_SIZE = 1e4;
422
434
  var StyleSheet = class {
423
435
  cache = /* @__PURE__ */ new Map();
424
436
  insertCache = /* @__PURE__ */ new Map();
437
+ icKeysByClass = /* @__PURE__ */ new Map();
438
+ domRules = /* @__PURE__ */ new Map();
425
439
  sheet = null;
426
440
  ssrBuffer = [];
427
441
  isSSR;
@@ -435,6 +449,7 @@ var StyleSheet = class {
435
449
  if (!this.isSSR) this.mount();
436
450
  }
437
451
  mount() {
452
+ if (this.isSSR) return;
438
453
  const existing = document.querySelector(`style[${ATTR}]`);
439
454
  if (existing) {
440
455
  this.sheet = existing.sheet ?? null;
@@ -475,16 +490,67 @@ var StyleSheet = class {
475
490
  }
476
491
  }
477
492
  }
493
+ /** Record that `icKey` resolves to `cacheKey` (for lockstep eviction). */
494
+ trackIcKey(cacheKey, icKey) {
495
+ let s = this.icKeysByClass.get(cacheKey);
496
+ if (!s) {
497
+ s = /* @__PURE__ */ new Set();
498
+ this.icKeysByClass.set(cacheKey, s);
499
+ }
500
+ s.add(icKey);
501
+ }
502
+ /** Record a top-level CSSRule this `cacheKey` inserted into the sheet. */
503
+ trackDomRule(cacheKey, ref) {
504
+ if (!ref) return;
505
+ let a = this.domRules.get(cacheKey);
506
+ if (!a) {
507
+ a = [];
508
+ this.domRules.set(cacheKey, a);
509
+ }
510
+ a.push(ref);
511
+ }
512
+ /**
513
+ * Evict the given cache keys across ALL three storage layers:
514
+ * the `cache` Map, the cssText-keyed `insertCache` Map, and the live
515
+ * DOM rules. Without the latter two, `maxCacheSize` bounded only the
516
+ * smallest of the three — `insertCache` keys (full CSS text) and the
517
+ * `<style>` tag's `cssRules` grew unbounded for the app's lifetime,
518
+ * which is the actual memory leak this method exists to prevent.
519
+ */
520
+ evictKeys(keys) {
521
+ const ruleRefs = /* @__PURE__ */ new Set();
522
+ for (const key of keys) {
523
+ this.cache.delete(key);
524
+ const ics = this.icKeysByClass.get(key);
525
+ if (ics) {
526
+ for (const ic of ics) this.insertCache.delete(ic);
527
+ this.icKeysByClass.delete(key);
528
+ }
529
+ const refs = this.domRules.get(key);
530
+ if (refs) {
531
+ for (const r of refs) ruleRefs.add(r);
532
+ this.domRules.delete(key);
533
+ }
534
+ }
535
+ if (this.sheet && ruleRefs.size > 0) for (let i = this.sheet.cssRules.length - 1; i >= 0; i--) {
536
+ const r = this.sheet.cssRules[i];
537
+ if (r && ruleRefs.has(r)) try {
538
+ this.sheet.deleteRule(i);
539
+ } catch {}
540
+ }
541
+ }
478
542
  /** Evict oldest entries when cache exceeds max size. */
479
543
  evictIfNeeded() {
480
544
  if (this.cache.size <= this.maxCacheSize) return;
481
545
  const toDelete = Math.floor(this.maxCacheSize * .1);
546
+ const evicted = [];
482
547
  let count = 0;
483
548
  for (const key of this.cache.keys()) {
484
549
  if (count >= toDelete) break;
485
- this.cache.delete(key);
550
+ evicted.push(key);
486
551
  count++;
487
552
  }
553
+ this.evictKeys(evicted);
488
554
  }
489
555
  /**
490
556
  * Extract nested at-rules (@media, @supports, @container) from CSS text
@@ -565,6 +631,7 @@ var StyleSheet = class {
565
631
  const className = `${PREFIX}-${hash(cssText)}`;
566
632
  if (this.cache.has(className)) {
567
633
  this.insertCache.set(icKey, className);
634
+ this.trackIcKey(className, icKey);
568
635
  return className;
569
636
  }
570
637
  this.evictIfNeeded();
@@ -578,11 +645,13 @@ var StyleSheet = class {
578
645
  const finalRules = layerName ? rules.map((r) => `@layer ${layerName}{${r}}`) : rules;
579
646
  if (this.isSSR) for (const rule of finalRules) this.ssrBuffer.push(rule);
580
647
  else if (this.sheet) for (const rule of finalRules) try {
581
- this.sheet.insertRule(rule, this.sheet.cssRules.length);
648
+ const at = this.sheet.insertRule(rule, this.sheet.cssRules.length);
649
+ this.trackDomRule(className, this.sheet.cssRules[at]);
582
650
  } catch (_e) {
583
651
  if (__DEV__) console.warn("[styler] Failed to insert CSS rule:", rule, _e);
584
652
  }
585
653
  this.insertCache.set(icKey, className);
654
+ this.trackIcKey(className, icKey);
586
655
  return className;
587
656
  }
588
657
  /** Insert a @keyframes rule. Deduplicates by animation name. */
@@ -593,7 +662,8 @@ var StyleSheet = class {
593
662
  const rule = `@keyframes ${name}{${body}}`;
594
663
  if (this.isSSR) this.ssrBuffer.push(rule);
595
664
  else if (this.sheet) try {
596
- this.sheet.insertRule(rule, this.sheet.cssRules.length);
665
+ const at = this.sheet.insertRule(rule, this.sheet.cssRules.length);
666
+ this.trackDomRule(name, this.sheet.cssRules[at]);
597
667
  } catch (_e) {
598
668
  if (__DEV__) console.warn("[styler] Failed to insert @keyframes rule:", rule, _e);
599
669
  }
@@ -630,7 +700,8 @@ var StyleSheet = class {
630
700
  else if (this.sheet) {
631
701
  const rules = this.splitRules(cssText);
632
702
  for (const rule of rules) try {
633
- this.sheet.insertRule(rule, this.sheet.cssRules.length);
703
+ const at = this.sheet.insertRule(rule, this.sheet.cssRules.length);
704
+ this.trackDomRule(key, this.sheet.cssRules[at]);
634
705
  } catch (_e) {
635
706
  if (__DEV__) console.warn("[styler] Failed to insert global CSS rule:", rule, _e);
636
707
  }
@@ -655,11 +726,15 @@ var StyleSheet = class {
655
726
  this.ssrBuffer = [];
656
727
  this.cache.clear();
657
728
  this.insertCache.clear();
729
+ this.icKeysByClass.clear();
730
+ this.domRules.clear();
658
731
  }
659
732
  /** Clear the dedup cache. Useful for HMR / dev-time reloads. */
660
733
  clearCache() {
661
734
  this.cache.clear();
662
735
  this.insertCache.clear();
736
+ this.icKeysByClass.clear();
737
+ this.domRules.clear();
663
738
  clearNormCache();
664
739
  }
665
740
  /**
@@ -675,6 +750,8 @@ var StyleSheet = class {
675
750
  clearAll() {
676
751
  this.cache.clear();
677
752
  this.insertCache.clear();
753
+ this.icKeysByClass.clear();
754
+ this.domRules.clear();
678
755
  clearNormCache();
679
756
  this.ssrBuffer = [];
680
757
  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.16.0",
3
+ "version": "0.19.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/test-utils": "^0.13.3",
46
- "@pyreon/typescript": "^0.16.0",
45
+ "@pyreon/manifest": "0.13.1",
46
+ "@pyreon/test-utils": "^0.13.6",
47
+ "@pyreon/typescript": "^0.19.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.16.0",
55
- "@pyreon/reactivity": "^0.16.0"
55
+ "@pyreon/core": "^0.19.0",
56
+ "@pyreon/reactivity": "^0.19.0"
56
57
  }
57
58
  }
@@ -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
 
package/src/forward.ts CHANGED
@@ -225,6 +225,14 @@ export const filterProps = (props: Record<string, unknown>): Record<string, unkn
225
225
  * Build final props for a styled component in a single pass.
226
226
  * Combines className merging, ref injection, and prop filtering into one
227
227
  * allocation and one iteration.
228
+ *
229
+ * Copies own property DESCRIPTORS rather than values for forwarded
230
+ * props — getter-shaped reactive props (compiler-emitted `_rp(() =>
231
+ * signal())` converted to getters by `makeReactiveProps`) survive the
232
+ * copy with their reactive subscription intact. A bare `result[key] =
233
+ * rawProps[key]` fires the getter at setup time and stores a static
234
+ * value, breaking signal-driven reactivity for any consumer that reads
235
+ * `props.x` in a reactive scope downstream.
228
236
  */
229
237
  export const buildProps = (
230
238
  rawProps: Record<string, any>,
@@ -234,7 +242,11 @@ export const buildProps = (
234
242
  ): Record<string, any> => {
235
243
  const result: Record<string, any> = {}
236
244
 
237
- // Merge generated + user className
245
+ // Merge generated + user className. Reading `rawProps.class` /
246
+ // `.className` synchronously is fine — `class` is consumed at this
247
+ // boundary (merged with the generated class), never forwarded
248
+ // reactively. The string we write is consumed by the DOM at apply
249
+ // time, not stored as a getter.
238
250
  const userCls = rawProps.class || rawProps.className
239
251
  if (generatedCls) {
240
252
  result.class = userCls ? `${generatedCls} ${userCls}` : generatedCls
@@ -242,12 +254,19 @@ export const buildProps = (
242
254
  result.class = userCls
243
255
  }
244
256
 
257
+ // Helper: copy a prop's OWN descriptor (preserves getters) into result.
258
+ // Falls back to a no-op if the source has no own descriptor for the key.
259
+ const copyDescriptor = (key: string): void => {
260
+ const d = Object.getOwnPropertyDescriptor(rawProps, key)
261
+ if (d) Object.defineProperty(result, key, d)
262
+ }
263
+
245
264
  // Component target — forward all props except as/className/class and $-prefixed
246
265
  if (!isDOM) {
247
266
  for (const key in rawProps) {
248
267
  if (key === 'as' || key === 'className' || key === 'class') continue
249
268
  if (key.charCodeAt(0) === 36) continue // $-prefixed transient
250
- result[key] = rawProps[key]
269
+ copyDescriptor(key)
251
270
  }
252
271
  return result
253
272
  }
@@ -256,7 +275,7 @@ export const buildProps = (
256
275
  if (customFilter) {
257
276
  for (const key in rawProps) {
258
277
  if (key === 'as' || key === 'className' || key === 'class') continue
259
- if (customFilter(key)) result[key] = rawProps[key]
278
+ if (customFilter(key)) copyDescriptor(key)
260
279
  }
261
280
  return result
262
281
  }
@@ -266,10 +285,10 @@ export const buildProps = (
266
285
  if (key === 'as' || key === 'className' || key === 'class') continue
267
286
  if (key.charCodeAt(0) === 36) continue // $-prefixed transient
268
287
  if (key.startsWith('data-') || key.startsWith('aria-')) {
269
- result[key] = rawProps[key]
288
+ copyDescriptor(key)
270
289
  continue
271
290
  }
272
- if (HTML_PROPS.has(key)) result[key] = rawProps[key]
291
+ if (HTML_PROPS.has(key)) copyDescriptor(key)
273
292
  }
274
293
  return result
275
294
  }
@@ -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
- this.cache.delete(key)
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
@@ -379,12 +460,16 @@ export class StyleSheet {
379
460
  this.ssrBuffer = []
380
461
  this.cache.clear()
381
462
  this.insertCache.clear()
463
+ this.icKeysByClass.clear()
464
+ this.domRules.clear()
382
465
  }
383
466
 
384
467
  /** Clear the dedup cache. Useful for HMR / dev-time reloads. */
385
468
  clearCache(): void {
386
469
  this.cache.clear()
387
470
  this.insertCache.clear()
471
+ this.icKeysByClass.clear()
472
+ this.domRules.clear()
388
473
  clearNormCache()
389
474
  }
390
475
 
@@ -401,6 +486,8 @@ export class StyleSheet {
401
486
  clearAll(): void {
402
487
  this.cache.clear()
403
488
  this.insertCache.clear()
489
+ this.icKeysByClass.clear()
490
+ this.domRules.clear()
404
491
  clearNormCache()
405
492
  this.ssrBuffer = []
406
493
  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
+ })