@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.
- package/package.json +5 -7
- package/src/ThemeProvider.ts +0 -65
- package/src/__tests__/ThemeProvider.test.ts +0 -67
- package/src/__tests__/benchmark.bench.ts +0 -200
- package/src/__tests__/composition-chain.test.ts +0 -537
- package/src/__tests__/css.test.ts +0 -70
- package/src/__tests__/dev-gate-treeshake.test.ts +0 -85
- package/src/__tests__/forward.test.ts +0 -282
- package/src/__tests__/globalStyle.test.ts +0 -72
- package/src/__tests__/hash.test.ts +0 -70
- package/src/__tests__/hybrid-injection.test.ts +0 -225
- package/src/__tests__/index.ts +0 -14
- package/src/__tests__/inject-rules.browser.test.ts +0 -40
- package/src/__tests__/insertion-effect.test.ts +0 -119
- package/src/__tests__/integration-dom.test.ts +0 -58
- package/src/__tests__/integration.test.ts +0 -179
- package/src/__tests__/keyframes.test.ts +0 -68
- package/src/__tests__/memory-growth.test.ts +0 -220
- package/src/__tests__/native-marker.test.ts +0 -9
- package/src/__tests__/p3-features.test.ts +0 -316
- package/src/__tests__/resolve-cache.test.ts +0 -94
- package/src/__tests__/resolve.test.ts +0 -308
- package/src/__tests__/shared.test.ts +0 -133
- package/src/__tests__/sheet-advanced.test.ts +0 -659
- package/src/__tests__/sheet-split-atrules.test.ts +0 -410
- package/src/__tests__/sheet.test.ts +0 -250
- package/src/__tests__/static-styler-resolve-cost.test.ts +0 -160
- package/src/__tests__/styled-reactive.test.ts +0 -74
- package/src/__tests__/styled-ssr.test.ts +0 -75
- package/src/__tests__/styled.test.ts +0 -511
- package/src/__tests__/styler.browser.test.tsx +0 -194
- package/src/__tests__/theme.test.ts +0 -33
- package/src/__tests__/useCSS.test.ts +0 -172
- package/src/css.ts +0 -13
- package/src/env.d.ts +0 -6
- package/src/forward.ts +0 -308
- package/src/globalStyle.ts +0 -53
- package/src/hash.ts +0 -28
- package/src/index.ts +0 -15
- package/src/keyframes.ts +0 -36
- package/src/manifest.ts +0 -332
- package/src/resolve.ts +0 -225
- package/src/shared.ts +0 -22
- package/src/sheet.ts +0 -635
- package/src/styled.tsx +0 -503
- package/src/tests/manifest-snapshot.test.ts +0 -51
- 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
|
-
}
|