@pyreon/styler 0.24.4 → 0.24.6

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.
Files changed (47) hide show
  1. package/package.json +5 -7
  2. package/src/ThemeProvider.ts +0 -65
  3. package/src/__tests__/ThemeProvider.test.ts +0 -67
  4. package/src/__tests__/benchmark.bench.ts +0 -200
  5. package/src/__tests__/composition-chain.test.ts +0 -537
  6. package/src/__tests__/css.test.ts +0 -70
  7. package/src/__tests__/dev-gate-treeshake.test.ts +0 -85
  8. package/src/__tests__/forward.test.ts +0 -282
  9. package/src/__tests__/globalStyle.test.ts +0 -72
  10. package/src/__tests__/hash.test.ts +0 -70
  11. package/src/__tests__/hybrid-injection.test.ts +0 -225
  12. package/src/__tests__/index.ts +0 -14
  13. package/src/__tests__/inject-rules.browser.test.ts +0 -40
  14. package/src/__tests__/insertion-effect.test.ts +0 -119
  15. package/src/__tests__/integration-dom.test.ts +0 -58
  16. package/src/__tests__/integration.test.ts +0 -179
  17. package/src/__tests__/keyframes.test.ts +0 -68
  18. package/src/__tests__/memory-growth.test.ts +0 -220
  19. package/src/__tests__/native-marker.test.ts +0 -9
  20. package/src/__tests__/p3-features.test.ts +0 -316
  21. package/src/__tests__/resolve-cache.test.ts +0 -94
  22. package/src/__tests__/resolve.test.ts +0 -308
  23. package/src/__tests__/shared.test.ts +0 -133
  24. package/src/__tests__/sheet-advanced.test.ts +0 -659
  25. package/src/__tests__/sheet-split-atrules.test.ts +0 -410
  26. package/src/__tests__/sheet.test.ts +0 -250
  27. package/src/__tests__/static-styler-resolve-cost.test.ts +0 -160
  28. package/src/__tests__/styled-reactive.test.ts +0 -74
  29. package/src/__tests__/styled-ssr.test.ts +0 -75
  30. package/src/__tests__/styled.test.ts +0 -511
  31. package/src/__tests__/styler.browser.test.tsx +0 -194
  32. package/src/__tests__/theme.test.ts +0 -33
  33. package/src/__tests__/useCSS.test.ts +0 -172
  34. package/src/css.ts +0 -13
  35. package/src/env.d.ts +0 -6
  36. package/src/forward.ts +0 -308
  37. package/src/globalStyle.ts +0 -53
  38. package/src/hash.ts +0 -28
  39. package/src/index.ts +0 -15
  40. package/src/keyframes.ts +0 -36
  41. package/src/manifest.ts +0 -332
  42. package/src/resolve.ts +0 -225
  43. package/src/shared.ts +0 -22
  44. package/src/sheet.ts +0 -635
  45. package/src/styled.tsx +0 -503
  46. package/src/tests/manifest-snapshot.test.ts +0 -51
  47. package/src/useCSS.ts +0 -20
package/src/sheet.ts DELETED
@@ -1,635 +0,0 @@
1
- /**
2
- * StyleSheet manager. Handles CSS rule injection, hash-based deduplication,
3
- * SSR buffering, client-side hydration, bounded cache, and @layer support.
4
- *
5
- * Media queries (@media), @supports, and @container blocks nested inside
6
- * component CSS are automatically extracted into separate top-level rules.
7
- */
8
- import { hash } from './hash'
9
- import { clearNormCache } from './resolve'
10
-
11
- // Dev-time counter sink — see styler/resolve.ts for the contract.
12
- const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
13
-
14
- // Dev-mode gate. `import.meta.env.DEV` is literal-replaced by Vite at build
15
- // time and tree-shakes to zero bytes in prod. The previous
16
- // `process.env.NODE_ENV !== 'production'` form was dead code in real Vite
17
- // browser bundles (Vite does not polyfill `process`), so insertRule failures
18
- // were silently swallowed in production — masking malformed CSS bugs.
19
- const __DEV__ = process.env.NODE_ENV !== 'production'
20
-
21
- const PREFIX = 'pyr'
22
- const ATTR = 'data-pyreon-styler'
23
- const DEFAULT_MAX_CACHE_SIZE = 10000
24
-
25
- export interface StyleSheetOptions {
26
- /** Maximum number of cached rules before eviction (default: 10000). */
27
- maxCacheSize?: number
28
- /** CSS @layer name to wrap scoped rules in. */
29
- layer?: string
30
- }
31
-
32
- export class StyleSheet {
33
- private cache = new Map<string, string>()
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[]>()
45
- private sheet: CSSStyleSheet | null = null
46
- private ssrBuffer: string[] = []
47
- private isSSR: boolean
48
- private maxCacheSize: number
49
- private layer: string | undefined
50
- private supportsLayer = false
51
-
52
- constructor(options: StyleSheetOptions = {}) {
53
- this.maxCacheSize = options.maxCacheSize ?? DEFAULT_MAX_CACHE_SIZE
54
- this.layer = options.layer
55
- this.isSSR = typeof document === 'undefined'
56
- if (!this.isSSR) this.mount()
57
- }
58
-
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
64
- // Reuse existing <style> tag from SSR hydration
65
- const existing = document.querySelector(`style[${ATTR}]`) as HTMLStyleElement | null
66
-
67
- if (existing) {
68
- this.sheet = existing.sheet ?? null
69
- this.hydrateFromTag(existing)
70
- } else {
71
- const el = document.createElement('style')
72
- el.setAttribute(ATTR, '')
73
- document.head.appendChild(el)
74
- this.sheet = el.sheet ?? null
75
- }
76
-
77
- // Inject CSS @layer ordering for the framework's cascade.
78
- //
79
- // Two layers: `elements` (base layout primitives) < `rocketstyle`
80
- // (themed component styles). The explicit ordering declaration
81
- // ensures rocketstyle theme styles always override element base
82
- // styles regardless of source order, while media queries within
83
- // each layer still work correctly (media conditions are evaluated
84
- // within each layer independently).
85
- //
86
- // Previously this used a single `@layer pyreon` which put
87
- // rocketstyle and elements in the same layer, relying on source
88
- // order. That broke when Elements were rendered WITHOUT a layer
89
- // (unlayered CSS always wins over layered CSS per the cascade
90
- // spec), making rocketstyle themes unable to override element
91
- // base styles.
92
- if (this.sheet) {
93
- try {
94
- this.sheet.insertRule('@layer elements, rocketstyle;', 0)
95
- this.supportsLayer = true
96
- } catch {
97
- // @layer not supported — falls back to source order
98
- }
99
- }
100
- }
101
-
102
- /** Extract className from a selector like ".pyr-abc" or ".pyr-abc.pyr-abc" → "pyr-abc" */
103
- private extractClassName(selectorText: string): string | null {
104
- if (selectorText[0] !== '.') return null
105
- const dotIdx = selectorText.indexOf('.', 1)
106
- return dotIdx > 0 ? selectorText.slice(1, dotIdx) : selectorText.slice(1)
107
- }
108
-
109
- /** Parse existing rules from SSR-rendered <style> tag into cache. */
110
- private hydrateFromTag(el: HTMLStyleElement) {
111
- const sheet = el.sheet
112
- if (!sheet) return
113
-
114
- for (let i = 0; i < sheet.cssRules.length; i++) {
115
- const rule = sheet.cssRules[i]
116
-
117
- if (rule instanceof CSSStyleRule) {
118
- const className = this.extractClassName(rule.selectorText)
119
- if (className) this.cache.set(className, className)
120
- }
121
-
122
- // Handle split @media rules that wrap our selectors
123
- if (typeof CSSMediaRule !== 'undefined' && rule instanceof CSSMediaRule) {
124
- for (let j = 0; j < rule.cssRules.length; j++) {
125
- const inner = rule.cssRules[j]
126
- if (inner instanceof CSSStyleRule) {
127
- const className = this.extractClassName(inner.selectorText)
128
- if (className) this.cache.set(className, className)
129
- }
130
- }
131
- }
132
- }
133
- }
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
-
195
- /** Evict oldest entries when cache exceeds max size. */
196
- private evictIfNeeded() {
197
- if (this.cache.size <= this.maxCacheSize) return
198
-
199
- // Map iteration order is insertion order — delete oldest 10%
200
- const toDelete = Math.floor(this.maxCacheSize * 0.1)
201
- const evicted: string[] = []
202
- let count = 0
203
- for (const key of this.cache.keys()) {
204
- if (count >= toDelete) break
205
- evicted.push(key)
206
- count++
207
- }
208
- this.evictKeys(evicted)
209
- }
210
-
211
- /**
212
- * Extract nested at-rules (@media, @supports, @container) from CSS text
213
- * and wrap their content in the given selector as separate top-level rules.
214
- */
215
- private splitAtRules(cssText: string, selector: string): { base: string; atRules: string[] } {
216
- // Fast path: no at-rules to split
217
- if (cssText.indexOf('@') === -1) return { base: cssText, atRules: [] }
218
-
219
- const atRules: string[] = []
220
- const baseParts: string[] = []
221
- const len = cssText.length
222
- let depth = 0
223
- let atStart = -1
224
- let lastBase = 0
225
-
226
- // `charCodeAt(i)` returns a primitive int; `cssText[i]` allocates a
227
- // fresh 1-char string in V8 per iteration. On long stylesheets with
228
- // at-rule blocks the per-char allocation dominates. Ported from
229
- // vitus-labs `c483cabc`.
230
- for (let i = 0; i < len; i++) {
231
- const ch = cssText.charCodeAt(i)
232
-
233
- if (ch === 123 /* { */) {
234
- depth++
235
- } else if (ch === 125 /* } */) {
236
- depth--
237
- if (depth === 0 && atStart >= 0) {
238
- // End of a tracked at-rule block — extract and wrap with selector
239
- const openBrace = cssText.indexOf('{', atStart)
240
- const atPrefix = cssText.slice(atStart, openBrace).trim()
241
- const innerCSS = cssText.slice(openBrace + 1, i).trim()
242
- if (innerCSS) {
243
- atRules.push(`${atPrefix}{${selector}{${innerCSS}}}`)
244
- }
245
- atStart = -1
246
- lastBase = i + 1
247
- }
248
- } else if (depth === 0 && ch === 64 /* @ */ && atStart < 0) {
249
- // Check if this starts a splittable at-rule (not @keyframes, @font-face, etc.)
250
- const remaining = cssText.slice(i, i + 20)
251
- if (/^@(?:media|supports|container)\b/.test(remaining)) {
252
- // Save any base CSS that precedes this at-rule
253
- const baseBefore = cssText.slice(lastBase, i).trim()
254
- if (baseBefore) baseParts.push(baseBefore)
255
- atStart = i
256
- }
257
- }
258
- }
259
-
260
- // Collect remaining base CSS after the last at-rule
261
- if (lastBase < cssText.length && atStart < 0) {
262
- const remaining = cssText.slice(lastBase).trim()
263
- if (remaining) baseParts.push(remaining)
264
- }
265
-
266
- // If no at-rules were found, return original unchanged
267
- if (atRules.length === 0) return { base: cssText, atRules: [] }
268
-
269
- return { base: baseParts.join(' '), atRules }
270
- }
271
-
272
- /**
273
- * Compute a className from CSS text without injecting (pure function).
274
- */
275
- getClassName(cssText: string): string {
276
- const cached = this.insertCache.get(cssText)
277
- if (cached) return cached
278
- const h = hash(cssText)
279
- return `${PREFIX}-${h}`
280
- }
281
-
282
- /**
283
- * Insert CSS rules for a component. Returns the class name (deterministic, hash-based).
284
- * Deduplicates: same CSS text always produces the same class name and
285
- * the rules are only injected once.
286
- *
287
- * @param cssText - CSS declarations to insert
288
- * @param _unused - Reserved for backward compatibility (was `boost`)
289
- * @param insertLayer - CSS @layer to wrap this rule in (e.g. 'rocketstyle').
290
- * Used by rocketstyle to ensure wrapper styles override inner component styles
291
- * via @layer order (base < rocketstyle) instead of specificity hacks.
292
- */
293
- insert(cssText: string, _unused = false, insertLayer?: string): string {
294
- if (process.env.NODE_ENV !== 'production')
295
- _countSink.__pyreon_count__?.('styler.sheet.insert')
296
- // Fast path: skip hash computation on repeated insertions of same CSS text
297
- const icKey = insertLayer ? `${cssText}\0L:${insertLayer}` : cssText
298
- const icHit = this.insertCache.get(icKey)
299
- if (icHit) {
300
- if (process.env.NODE_ENV !== 'production')
301
- _countSink.__pyreon_count__?.('styler.sheet.insert.hit')
302
- return icHit
303
- }
304
-
305
- const h = hash(cssText)
306
- const className = `${PREFIX}-${h}`
307
-
308
- if (this.cache.has(className)) {
309
- this.insertCache.set(icKey, className)
310
- this.trackIcKey(className, icKey)
311
- return className
312
- }
313
-
314
- this.evictIfNeeded()
315
- this.cache.set(className, className)
316
-
317
- const selector = `.${className}`
318
-
319
- // Split nested at-rules into separate top-level rules
320
- const { base, atRules } = this.splitAtRules(cssText, selector)
321
-
322
- const rules: string[] = []
323
- if (base) rules.push(`${selector}{${base}}`)
324
- rules.push(...atRules)
325
-
326
- // Apply @layer wrapping — per-insert layer takes precedence over sheet-level layer.
327
- // In SSR, always apply layers (output goes to real browsers).
328
- // In client, skip if @layer isn't supported (e.g. happy-dom in tests).
329
- const layerName = this.isSSR || this.supportsLayer ? (insertLayer ?? this.layer) : undefined
330
- const finalRules = layerName ? rules.map((r) => `@layer ${layerName}{${r}}`) : rules
331
-
332
- if (this.isSSR) {
333
- for (const rule of finalRules) {
334
- this.ssrBuffer.push(rule)
335
- }
336
- } else if (this.sheet) {
337
- for (const rule of finalRules) {
338
- try {
339
- const at = this.sheet.insertRule(rule, this.sheet.cssRules.length)
340
- this.trackDomRule(className, this.sheet.cssRules[at])
341
- } catch (_e) {
342
- if (__DEV__) {
343
- // oxlint-disable-next-line no-console
344
- console.warn('[styler] Failed to insert CSS rule:', rule, _e)
345
- }
346
- }
347
- }
348
- }
349
-
350
- this.insertCache.set(icKey, className)
351
- this.trackIcKey(className, icKey)
352
- return className
353
- }
354
-
355
- /** Insert a @keyframes rule. Deduplicates by animation name. */
356
- insertKeyframes(name: string, body: string): void {
357
- if (this.cache.has(name)) return
358
-
359
- this.evictIfNeeded()
360
- this.cache.set(name, name)
361
-
362
- const rule = `@keyframes ${name}{${body}}`
363
-
364
- if (this.isSSR) {
365
- this.ssrBuffer.push(rule)
366
- } else if (this.sheet) {
367
- try {
368
- const at = this.sheet.insertRule(rule, this.sheet.cssRules.length)
369
- this.trackDomRule(name, this.sheet.cssRules[at])
370
- } catch (_e) {
371
- if (__DEV__) {
372
- // oxlint-disable-next-line no-console
373
- console.warn('[styler] Failed to insert @keyframes rule:', rule, _e)
374
- }
375
- }
376
- }
377
- }
378
-
379
- /**
380
- * Split CSS text into individual top-level rules.
381
- * CSSStyleSheet.insertRule() only accepts one rule at a time.
382
- */
383
- private splitRules(cssText: string): string[] {
384
- const rules: string[] = []
385
- const len = cssText.length
386
- let depth = 0
387
- let start = 0
388
-
389
- // `charCodeAt(i)` returns a primitive int; `cssText[i]` allocates a
390
- // fresh 1-char string per iteration. Ported from vitus-labs `c483cabc`.
391
- for (let i = 0; i < len; i++) {
392
- const ch = cssText.charCodeAt(i)
393
- if (ch === 123 /* { */) depth++
394
- else if (ch === 125 /* } */) {
395
- depth--
396
- if (depth === 0) {
397
- const rule = cssText.slice(start, i + 1).trim()
398
- if (rule) rules.push(rule)
399
- start = i + 1
400
- }
401
- }
402
- }
403
-
404
- return rules
405
- }
406
-
407
- /** Insert global CSS rules (no wrapper selector). Deduplicates by hash. */
408
- insertGlobal(cssText: string): void {
409
- const h = hash(cssText)
410
- const key = `global-${h}`
411
-
412
- if (this.cache.has(key)) return
413
-
414
- this.evictIfNeeded()
415
- this.cache.set(key, key)
416
-
417
- if (this.isSSR) {
418
- this.ssrBuffer.push(cssText)
419
- } else if (this.sheet) {
420
- const rules = this.splitRules(cssText)
421
- for (const rule of rules) {
422
- try {
423
- const at = this.sheet.insertRule(rule, this.sheet.cssRules.length)
424
- this.trackDomRule(key, this.sheet.cssRules[at])
425
- } catch (_e) {
426
- if (__DEV__) {
427
- // oxlint-disable-next-line no-console
428
- console.warn('[styler] Failed to insert global CSS rule:', rule, _e)
429
- }
430
- }
431
- }
432
- }
433
- }
434
-
435
- /** Returns collected CSS for SSR as a complete `<style>` tag string. */
436
- getStyleTag(): string {
437
- if (this.ssrBuffer.length === 0) return `<style ${ATTR}=""></style>`
438
- // Emit the layer ordering declaration for SSR output so the cascade
439
- // is correct when the browser parses the SSR HTML. On the client side
440
- // this ordering is injected via insertRule in mount().
441
- const layerDecl = this.hasLayeredRules()
442
- ? '@layer elements, rocketstyle;'
443
- : this.layer
444
- ? `@layer ${this.layer};`
445
- : ''
446
- const css = (layerDecl + this.ssrBuffer.join('')).replace(/<\/style/gi, '<\\/style')
447
- return `<style ${ATTR}="">${css}</style>`
448
- }
449
-
450
- /**
451
- * Returns the collected SSR rules as a raw array (one entry per
452
- * top-level rule, already `@layer`-wrapped + class-prefixed exactly as
453
- * `insert()` produced them). Used by the compile-time rocketstyle
454
- * collapse resolver: it renders a component under SSR, reads the rules
455
- * here, and the build emits an idempotent `injectRules()` call so the
456
- * collapsed `_tpl()` site is self-sufficient (no prior runtime mount
457
- * needed to populate the sheet). A copy — callers must not mutate the
458
- * internal buffer.
459
- */
460
- getStyleRules(): readonly string[] {
461
- return this.ssrBuffer.slice()
462
- }
463
-
464
- // Idempotency guard for injectRules — keyed by the FNV hash the
465
- // collapse resolver computes over the rule set. A second injection of
466
- // the same resolved bundle (e.g. the module re-evaluated under HMR, or
467
- // two collapsed call sites resolving to the same dimension combo) is a
468
- // no-op instead of duplicate live `cssRules`.
469
- private injectedBundles = new Set<string>()
470
-
471
- /**
472
- * Inject pre-resolved CSS rule text (from `getStyleRules()` captured at
473
- * build time by the rocketstyle-collapse resolver) directly into the
474
- * live sheet. Unlike `insert()` this does NOT re-hash — the class names
475
- * are already baked into `rules` and into the collapsed `_tpl()` HTML;
476
- * re-hashing would produce a different class and break the contract.
477
- * Idempotent by `key` (the resolver's FNV hash of the bundle).
478
- */
479
- injectRules(rules: readonly string[], key: string): void {
480
- if (this.injectedBundles.has(key)) return
481
- this.injectedBundles.add(key)
482
- if (this.isSSR) {
483
- for (const rule of rules) this.ssrBuffer.push(rule)
484
- return
485
- }
486
- if (!this.sheet) return
487
- for (const rule of rules) {
488
- try {
489
- this.sheet.insertRule(rule, this.sheet.cssRules.length)
490
- } catch (_e) {
491
- if (__DEV__) {
492
- // oxlint-disable-next-line no-console
493
- console.warn('[styler] injectRules: failed to insert collapsed rule:', rule, _e)
494
- }
495
- }
496
- }
497
- }
498
-
499
- /**
500
- * Test-only: live `cssRules.length` (0 in SSR). Mirrors runtime-dom's
501
- * `_tplCacheSize()` test-only-accessor convention; lets injectRules /
502
- * eviction tests assert without reaching into the private sheet.
503
- */
504
- ruleCountForTest(): number {
505
- return this.sheet?.cssRules.length ?? 0
506
- }
507
-
508
- /** Returns collected CSS rules as a raw string (useful for streaming SSR). */
509
- getStyles(): string {
510
- if (this.ssrBuffer.length === 0) return ''
511
- const layerDecl = this.hasLayeredRules()
512
- ? '@layer elements, rocketstyle;'
513
- : this.layer
514
- ? `@layer ${this.layer};`
515
- : ''
516
- return layerDecl + this.ssrBuffer.join('')
517
- }
518
-
519
- /** Check if any buffered SSR rules use @layer wrapping. */
520
- private hasLayeredRules(): boolean {
521
- return this.ssrBuffer.some((r) => r.startsWith('@layer '))
522
- }
523
-
524
- /** Reset SSR buffer and cache (call between server requests). */
525
- reset(): void {
526
- this.ssrBuffer = []
527
- this.cache.clear()
528
- this.insertCache.clear()
529
- this.icKeysByClass.clear()
530
- this.domRules.clear()
531
- }
532
-
533
- /** Clear the dedup cache. Useful for HMR / dev-time reloads. */
534
- clearCache(): void {
535
- this.cache.clear()
536
- this.insertCache.clear()
537
- this.icKeysByClass.clear()
538
- this.domRules.clear()
539
- clearNormCache()
540
- }
541
-
542
- /**
543
- * Full cleanup: clear cache and remove all CSS rules from the DOM.
544
- * Intended for HMR / dev-time reloads where stale styles must be purged.
545
- *
546
- * Also fires `onSheetClear` subscribers so downstream caches (e.g.
547
- * `styled.tsx`'s static-component cache) reset alongside the sheet.
548
- * Without this, stale `StaticStyled` ComponentFn references survive HMR
549
- * and continue to apply CSS class names that were just deleted from
550
- * the DOM — observable as missing styles after every hot reload.
551
- */
552
- clearAll(): void {
553
- this.cache.clear()
554
- this.insertCache.clear()
555
- this.icKeysByClass.clear()
556
- this.domRules.clear()
557
- clearNormCache()
558
- this.ssrBuffer = []
559
- if (this.sheet) {
560
- while (this.sheet.cssRules.length > 0) {
561
- this.sheet.deleteRule(0)
562
- }
563
- }
564
- fireSheetClearSubscribers()
565
- }
566
-
567
- /**
568
- * Compute className and full CSS rule text without injecting.
569
- */
570
- prepare(cssText: string): { className: string; rules: string } {
571
- const h = hash(cssText)
572
- const className = `${PREFIX}-${h}`
573
- const selector = `.${className}`
574
- const { base, atRules } = this.splitAtRules(cssText, selector)
575
-
576
- const allRules: string[] = []
577
- if (base) allRules.push(`${selector}{${base}}`)
578
- allRules.push(...atRules)
579
-
580
- const finalRules = this.layer ? allRules.map((r) => `@layer ${this.layer}{${r}}`) : allRules
581
-
582
- return { className, rules: finalRules.join('') }
583
- }
584
-
585
- /** Check if a className is already in the cache. O(1) Map lookup. */
586
- has(className: string): boolean {
587
- return this.cache.has(className)
588
- }
589
-
590
- /** Current number of cached rules. */
591
- get cacheSize(): number {
592
- return this.cache.size
593
- }
594
- }
595
-
596
- /** Default singleton sheet for client-side use.
597
- * No default layer — each consumer specifies their own:
598
- * Elements use `{ layer: 'elements' }`
599
- * Rocketstyle uses `{ layer: 'rocketstyle' }`
600
- * The layer ordering `@layer elements, rocketstyle` is injected
601
- * in mount() so rocketstyle always overrides elements.
602
- */
603
- export const sheet = new StyleSheet()
604
-
605
- /**
606
- * Factory for creating isolated StyleSheet instances.
607
- * Use in SSR to get per-request isolation.
608
- */
609
- export const createSheet = (options?: StyleSheetOptions): StyleSheet => new StyleSheet(options)
610
-
611
- // ─── onSheetClear subscriber registry ─────────────────────────────────────
612
- //
613
- // Used by `styled.tsx` to reset its static-component cache when the
614
- // singleton sheet is cleared via `clearAll()`. Module-level Set so the
615
- // subscription survives between calls; ports the vitus-labs pattern from
616
- // `connector-styler/sheet.ts:onClear`. Scoped to the singleton sheet —
617
- // per-instance sheets created via `createSheet()` don't fire the hook.
618
- const _sheetClearSubscribers = new Set<() => void>()
619
-
620
- const fireSheetClearSubscribers = (): void => {
621
- for (const cb of _sheetClearSubscribers) cb()
622
- }
623
-
624
- /**
625
- * Subscribe to `sheet.clearAll()`. Fires after the sheet has been
626
- * fully cleared, so subscribers can drop downstream caches that depend
627
- * on the sheet's class names being live in the DOM.
628
- *
629
- * Returns a disposer for symmetry; in practice subscribers register
630
- * once at module load and never unsubscribe.
631
- */
632
- export const onSheetClear = (callback: () => void): (() => void) => {
633
- _sheetClearSubscribers.add(callback)
634
- return () => _sheetClearSubscribers.delete(callback)
635
- }