@pyreon/styler 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.d.ts +28 -2
- package/lib/index.js +46 -0
- package/package.json +5 -5
- package/src/__tests__/inject-rules.browser.test.ts +40 -0
- package/src/__tests__/static-styler-resolve-cost.test.ts +160 -0
- package/src/sheet.ts +58 -0
package/lib/index.d.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import * as _$_pyreon_core0 from "@pyreon/core";
|
|
2
1
|
import { ComponentFn, VNode, VNodeChild } from "@pyreon/core";
|
|
3
2
|
|
|
4
3
|
//#region src/ThemeProvider.d.ts
|
|
@@ -16,7 +15,7 @@ type Theme = DefaultTheme & Record<string, unknown>;
|
|
|
16
15
|
* - String-equality memoization (same CSS class = no DOM update)
|
|
17
16
|
* - Untracked resolve (no exponential cascade)
|
|
18
17
|
*/
|
|
19
|
-
declare const ThemeContext:
|
|
18
|
+
declare const ThemeContext: import("@pyreon/core").ReactiveContext<Theme>;
|
|
20
19
|
/**
|
|
21
20
|
* Read the current theme. Returns the theme value (calls the accessor).
|
|
22
21
|
* Inside a reactive scope (computed/effect), this tracks theme changes.
|
|
@@ -216,6 +215,33 @@ declare class StyleSheet {
|
|
|
216
215
|
insertGlobal(cssText: string): void;
|
|
217
216
|
/** Returns collected CSS for SSR as a complete `<style>` tag string. */
|
|
218
217
|
getStyleTag(): string;
|
|
218
|
+
/**
|
|
219
|
+
* Returns the collected SSR rules as a raw array (one entry per
|
|
220
|
+
* top-level rule, already `@layer`-wrapped + class-prefixed exactly as
|
|
221
|
+
* `insert()` produced them). Used by the compile-time rocketstyle
|
|
222
|
+
* collapse resolver: it renders a component under SSR, reads the rules
|
|
223
|
+
* here, and the build emits an idempotent `injectRules()` call so the
|
|
224
|
+
* collapsed `_tpl()` site is self-sufficient (no prior runtime mount
|
|
225
|
+
* needed to populate the sheet). A copy — callers must not mutate the
|
|
226
|
+
* internal buffer.
|
|
227
|
+
*/
|
|
228
|
+
getStyleRules(): readonly string[];
|
|
229
|
+
private injectedBundles;
|
|
230
|
+
/**
|
|
231
|
+
* Inject pre-resolved CSS rule text (from `getStyleRules()` captured at
|
|
232
|
+
* build time by the rocketstyle-collapse resolver) directly into the
|
|
233
|
+
* live sheet. Unlike `insert()` this does NOT re-hash — the class names
|
|
234
|
+
* are already baked into `rules` and into the collapsed `_tpl()` HTML;
|
|
235
|
+
* re-hashing would produce a different class and break the contract.
|
|
236
|
+
* Idempotent by `key` (the resolver's FNV hash of the bundle).
|
|
237
|
+
*/
|
|
238
|
+
injectRules(rules: readonly string[], key: string): void;
|
|
239
|
+
/**
|
|
240
|
+
* Test-only: live `cssRules.length` (0 in SSR). Mirrors runtime-dom's
|
|
241
|
+
* `_tplCacheSize()` test-only-accessor convention; lets injectRules /
|
|
242
|
+
* eviction tests assert without reaching into the private sheet.
|
|
243
|
+
*/
|
|
244
|
+
ruleCountForTest(): number;
|
|
219
245
|
/** Returns collected CSS rules as a raw string (useful for streaming SSR). */
|
|
220
246
|
getStyles(): string;
|
|
221
247
|
/** Check if any buffered SSR rules use @layer wrapping. */
|
package/lib/index.js
CHANGED
|
@@ -9,6 +9,8 @@ const _countSink$2 = globalThis;
|
|
|
9
9
|
* deferred until a styled component renders (or until explicitly resolved).
|
|
10
10
|
*/
|
|
11
11
|
var CSSResult = class {
|
|
12
|
+
strings;
|
|
13
|
+
values;
|
|
12
14
|
constructor(strings, values) {
|
|
13
15
|
this.strings = strings;
|
|
14
16
|
this.values = values;
|
|
@@ -712,6 +714,50 @@ var StyleSheet = class {
|
|
|
712
714
|
if (this.ssrBuffer.length === 0) return `<style ${ATTR}=""></style>`;
|
|
713
715
|
return `<style ${ATTR}="">${((this.hasLayeredRules() ? "@layer elements, rocketstyle;" : this.layer ? `@layer ${this.layer};` : "") + this.ssrBuffer.join("")).replace(/<\/style/gi, "<\\/style")}</style>`;
|
|
714
716
|
}
|
|
717
|
+
/**
|
|
718
|
+
* Returns the collected SSR rules as a raw array (one entry per
|
|
719
|
+
* top-level rule, already `@layer`-wrapped + class-prefixed exactly as
|
|
720
|
+
* `insert()` produced them). Used by the compile-time rocketstyle
|
|
721
|
+
* collapse resolver: it renders a component under SSR, reads the rules
|
|
722
|
+
* here, and the build emits an idempotent `injectRules()` call so the
|
|
723
|
+
* collapsed `_tpl()` site is self-sufficient (no prior runtime mount
|
|
724
|
+
* needed to populate the sheet). A copy — callers must not mutate the
|
|
725
|
+
* internal buffer.
|
|
726
|
+
*/
|
|
727
|
+
getStyleRules() {
|
|
728
|
+
return this.ssrBuffer.slice();
|
|
729
|
+
}
|
|
730
|
+
injectedBundles = /* @__PURE__ */ new Set();
|
|
731
|
+
/**
|
|
732
|
+
* Inject pre-resolved CSS rule text (from `getStyleRules()` captured at
|
|
733
|
+
* build time by the rocketstyle-collapse resolver) directly into the
|
|
734
|
+
* live sheet. Unlike `insert()` this does NOT re-hash — the class names
|
|
735
|
+
* are already baked into `rules` and into the collapsed `_tpl()` HTML;
|
|
736
|
+
* re-hashing would produce a different class and break the contract.
|
|
737
|
+
* Idempotent by `key` (the resolver's FNV hash of the bundle).
|
|
738
|
+
*/
|
|
739
|
+
injectRules(rules, key) {
|
|
740
|
+
if (this.injectedBundles.has(key)) return;
|
|
741
|
+
this.injectedBundles.add(key);
|
|
742
|
+
if (this.isSSR) {
|
|
743
|
+
for (const rule of rules) this.ssrBuffer.push(rule);
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
if (!this.sheet) return;
|
|
747
|
+
for (const rule of rules) try {
|
|
748
|
+
this.sheet.insertRule(rule, this.sheet.cssRules.length);
|
|
749
|
+
} catch (_e) {
|
|
750
|
+
if (__DEV__) console.warn("[styler] injectRules: failed to insert collapsed rule:", rule, _e);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Test-only: live `cssRules.length` (0 in SSR). Mirrors runtime-dom's
|
|
755
|
+
* `_tplCacheSize()` test-only-accessor convention; lets injectRules /
|
|
756
|
+
* eviction tests assert without reaching into the private sheet.
|
|
757
|
+
*/
|
|
758
|
+
ruleCountForTest() {
|
|
759
|
+
return this.sheet?.cssRules.length ?? 0;
|
|
760
|
+
}
|
|
715
761
|
/** Returns collected CSS rules as a raw string (useful for streaming SSR). */
|
|
716
762
|
getStyles() {
|
|
717
763
|
if (this.ssrBuffer.length === 0) return "";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/styler",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Lightweight CSS-in-JS engine for Pyreon",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@pyreon/manifest": "0.13.1",
|
|
46
|
-
"@pyreon/test-utils": "^0.13.
|
|
47
|
-
"@pyreon/typescript": "^0.
|
|
46
|
+
"@pyreon/test-utils": "^0.13.7",
|
|
47
|
+
"@pyreon/typescript": "^0.20.0",
|
|
48
48
|
"@vitest/browser-playwright": "^4.1.4",
|
|
49
49
|
"@vitus-labs/tools-rolldown": "^2.3.0"
|
|
50
50
|
},
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"node": ">= 22"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@pyreon/core": "^0.
|
|
56
|
-
"@pyreon/reactivity": "^0.
|
|
55
|
+
"@pyreon/core": "^0.20.0",
|
|
56
|
+
"@pyreon/reactivity": "^0.20.0"
|
|
57
57
|
}
|
|
58
58
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { createSheet } from '../sheet'
|
|
3
|
+
|
|
4
|
+
// Layer 1 of the P0 rocketstyle-collapse slice: the styler injects
|
|
5
|
+
// pre-resolved rule text (captured at build time by the collapse
|
|
6
|
+
// resolver) into the LIVE sheet, idempotently, WITHOUT re-hashing — the
|
|
7
|
+
// class names are already baked into the rules AND into the collapsed
|
|
8
|
+
// _tpl() HTML, so a re-hash would break the contract.
|
|
9
|
+
|
|
10
|
+
describe('StyleSheet.injectRules (real browser)', () => {
|
|
11
|
+
it('inserts pre-resolved rules into the live sheet verbatim (no re-hash)', () => {
|
|
12
|
+
const s = createSheet()
|
|
13
|
+
const before = s.ruleCountForTest()
|
|
14
|
+
s.injectRules(['.pyr-abc123{color:rgb(1,2,3)}'], 'k1')
|
|
15
|
+
expect(s.ruleCountForTest()).toBe(before + 1)
|
|
16
|
+
// Class name is preserved exactly — proves no re-hash happened.
|
|
17
|
+
const probe = document.createElement('div')
|
|
18
|
+
probe.className = 'pyr-abc123'
|
|
19
|
+
document.body.appendChild(probe)
|
|
20
|
+
expect(getComputedStyle(probe).color).toBe('rgb(1, 2, 3)')
|
|
21
|
+
probe.remove()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('is idempotent by key — re-injecting the same bundle adds nothing', () => {
|
|
25
|
+
const s = createSheet()
|
|
26
|
+
s.injectRules(['.pyr-dup{color:red}', '.pyr-dup2{color:blue}'], 'dup')
|
|
27
|
+
const afterFirst = s.ruleCountForTest()
|
|
28
|
+
s.injectRules(['.pyr-dup{color:red}', '.pyr-dup2{color:blue}'], 'dup')
|
|
29
|
+
s.injectRules(['.pyr-dup{color:red}', '.pyr-dup2{color:blue}'], 'dup')
|
|
30
|
+
expect(s.ruleCountForTest()).toBe(afterFirst)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('distinct keys inject independently', () => {
|
|
34
|
+
const s = createSheet()
|
|
35
|
+
const before = s.ruleCountForTest()
|
|
36
|
+
s.injectRules(['.pyr-x{margin:1px}'], 'kx')
|
|
37
|
+
s.injectRules(['.pyr-y{margin:2px}'], 'ky')
|
|
38
|
+
expect(s.ruleCountForTest()).toBe(before + 2)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Measurement gate: per-mount cost of a fully-static `styled()` component.
|
|
3
|
+
*
|
|
4
|
+
* BACKGROUND. "Proposed compiler win #2 — static styler extraction" theorised
|
|
5
|
+
* that a fully-static styled component (`styled('div')`\`color: red\``, no
|
|
6
|
+
* function interpolations) wastes a per-mount `styler.resolve`, and that a
|
|
7
|
+
* bounded runtime memo slice could be carved out of the (multi-week,
|
|
8
|
+
* roadmap-scale) compile-time extraction effort. This gate MEASURES that
|
|
9
|
+
* premise with the real `styler.resolve` / `styler.staticVNode.hit` /
|
|
10
|
+
* `styler.sheet.insert*` perf counters and DISPROVES it for the runtime
|
|
11
|
+
* layer: the static path is already optimal.
|
|
12
|
+
*
|
|
13
|
+
* WHAT THE COUNTERS PROVE (the contrast IS the proof — self-discriminating,
|
|
14
|
+
* no fake fix to revert; same shape as the static-text baking gate):
|
|
15
|
+
*
|
|
16
|
+
* Fully-static `styled('div')`\`color: red\`` (values.length === 0):
|
|
17
|
+
* - `createStyledComponent` takes `raw = strings[0]` — `resolve()` is
|
|
18
|
+
* NEVER called → `styler.resolve` === 0 for the lifetime.
|
|
19
|
+
* - `sheet.insert` fires EXACTLY ONCE at component-creation time
|
|
20
|
+
* (definition, not mount) → `styler.sheet.insert` === 1.
|
|
21
|
+
* - Every mount with no extra props returns the pre-built
|
|
22
|
+
* `cachedEmptyVNode` → `styler.staticVNode.hit` === N, with ZERO
|
|
23
|
+
* additional resolve / sheet work per mount.
|
|
24
|
+
*
|
|
25
|
+
* Contrast — function-interpolated `styled('div')`\`color: ${p => p.c}\``
|
|
26
|
+
* with NO `$rocketstyle` / `$element` identity (the only shape that DOES
|
|
27
|
+
* re-resolve per call): `styler.resolve` === N. This is CORRECT, not waste
|
|
28
|
+
* — the CSS genuinely depends on per-call props; and real-app shapes
|
|
29
|
+
* (rocketstyle dimensions, Element `$element` interning) already hit
|
|
30
|
+
* `classCache` / `elClassCache` so they collapse to ~0 (proven elsewhere:
|
|
31
|
+
* PR #344 dimension memo, the `$element` interning gate). So #2's runtime
|
|
32
|
+
* layer needs no fix; the remaining #2 surface is purely compile-time /
|
|
33
|
+
* bundle (don't ship the styled wrapper + sheet.insert for provably-static
|
|
34
|
+
* CSS at all) — that is the roadmap item, deliberately NOT half-built here.
|
|
35
|
+
*
|
|
36
|
+
* Bisect note: there is intentionally no fix to revert. The discriminating
|
|
37
|
+
* assertion is `static.resolve === 0 && dynamic.resolve === N` in the SAME
|
|
38
|
+
* run — if the static path ever regressed to per-mount resolve, the static
|
|
39
|
+
* block's `toBe(0)` fails while the dynamic block still passes, pinpointing
|
|
40
|
+
* the regression to the static fast path specifically.
|
|
41
|
+
*/
|
|
42
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
43
|
+
import { sheet } from '../sheet'
|
|
44
|
+
import { styled } from '../styled'
|
|
45
|
+
|
|
46
|
+
type Sink = { __pyreon_count__?: (name: string, n?: number) => void }
|
|
47
|
+
const g = globalThis as Sink
|
|
48
|
+
|
|
49
|
+
let counts: Map<string, number>
|
|
50
|
+
|
|
51
|
+
const get = (name: string): number => counts.get(name) ?? 0
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
counts = new Map()
|
|
55
|
+
g.__pyreon_count__ = (name: string, n = 1) => {
|
|
56
|
+
counts.set(name, (counts.get(name) ?? 0) + n)
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
delete g.__pyreon_count__
|
|
62
|
+
// clearAll (not reset) — fires onSheetClear so styled.tsx's
|
|
63
|
+
// staticComponentCache / _hotCache reset between cases; otherwise the
|
|
64
|
+
// single-entry hot cache leaks a prior case's component.
|
|
65
|
+
sheet.clearAll()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const N = 100
|
|
69
|
+
|
|
70
|
+
describe('static styled() per-mount cost (measurement gate)', () => {
|
|
71
|
+
it('a fully-static component resolves ZERO times and inserts ONCE, regardless of mount count', () => {
|
|
72
|
+
// Distinct source-location template literal → its own
|
|
73
|
+
// TemplateStringsArray identity (independent of every other case).
|
|
74
|
+
const Comp = styled('div')`
|
|
75
|
+
color: red;
|
|
76
|
+
padding: 4px;
|
|
77
|
+
`
|
|
78
|
+
|
|
79
|
+
// The sheet.insert at *definition* time already happened above. Snapshot
|
|
80
|
+
// AFTER definition so the per-mount measurement is isolated from the
|
|
81
|
+
// one-time creation cost.
|
|
82
|
+
const insertsAtDefinition = get('styler.sheet.insert')
|
|
83
|
+
expect(insertsAtDefinition).toBe(1) // exactly one — at creation, not mount
|
|
84
|
+
expect(get('styler.resolve')).toBe(0) // values.length===0 → raw=strings[0], no resolve()
|
|
85
|
+
|
|
86
|
+
for (let i = 0; i < N; i++) Comp({})
|
|
87
|
+
|
|
88
|
+
// THE PROOF: N mounts added ZERO resolves and ZERO new sheet inserts.
|
|
89
|
+
expect(get('styler.resolve')).toBe(0)
|
|
90
|
+
expect(get('styler.sheet.insert')).toBe(1) // still just the creation insert
|
|
91
|
+
expect(get('styler.sheet.insert.hit')).toBe(0) // never re-inserted
|
|
92
|
+
// Every mount took the pre-built cachedEmptyVNode fast path.
|
|
93
|
+
expect(get('styler.staticVNode.hit')).toBe(N)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('two distinct static components: 2 inserts total, 0 resolves, N/2 hits each', () => {
|
|
97
|
+
const A = styled('div')`
|
|
98
|
+
color: blue;
|
|
99
|
+
`
|
|
100
|
+
const B = styled('span')`
|
|
101
|
+
color: green;
|
|
102
|
+
`
|
|
103
|
+
expect(get('styler.sheet.insert')).toBe(2) // one per definition
|
|
104
|
+
expect(get('styler.resolve')).toBe(0)
|
|
105
|
+
|
|
106
|
+
for (let i = 0; i < N / 2; i++) {
|
|
107
|
+
A({})
|
|
108
|
+
B({})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
expect(get('styler.resolve')).toBe(0)
|
|
112
|
+
expect(get('styler.sheet.insert')).toBe(2)
|
|
113
|
+
expect(get('styler.staticVNode.hit')).toBe(N) // N/2 + N/2
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('CONTRAST — a function-interpolated styled with no rocketstyle/$element identity DOES resolve per call (correct, not waste)', () => {
|
|
117
|
+
const Dyn = styled('div')<{ c: string }>`
|
|
118
|
+
color: ${(p) => p.c};
|
|
119
|
+
`
|
|
120
|
+
// No object $rocketstyle / $rocketstate / $element on rawProps → neither
|
|
121
|
+
// classCache nor elClassCache can fire; doResolve() runs every call.
|
|
122
|
+
// Same prop value each call → cssText identical → sheet dedups (hit).
|
|
123
|
+
for (let i = 0; i < N; i++) Dyn({ c: 'red' })
|
|
124
|
+
|
|
125
|
+
expect(get('styler.resolve')).toBe(N) // genuinely per-call (CSS depends on props)
|
|
126
|
+
expect(get('styler.staticVNode.hit')).toBe(0) // never the static fast path
|
|
127
|
+
// `styler.sheet.insert` counts every insert() CALL (one per mount here);
|
|
128
|
+
// `.hit` is the dedup subset — identical cssText each call, so all but
|
|
129
|
+
// the first hit the insertCache and inject NO new DOM rule. The dynamic
|
|
130
|
+
// path still pays the resolve() + the insert() call-overhead per mount;
|
|
131
|
+
// only the actual rule injection is deduped. That call-overhead is the
|
|
132
|
+
// residual the rocketstyle/$element identity caches (classCache /
|
|
133
|
+
// elClassCache) eliminate in real-app shapes — proven elsewhere.
|
|
134
|
+
expect(get('styler.sheet.insert')).toBe(N) // one insert() call per mount
|
|
135
|
+
expect(get('styler.sheet.insert.hit')).toBe(N - 1) // first builds cache, rest dedup
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('SELF-DISCRIMINATING — static.resolve===0 AND dynamic.resolve===N in one run', () => {
|
|
139
|
+
const Static = styled('div')`
|
|
140
|
+
margin: 8px;
|
|
141
|
+
`
|
|
142
|
+
counts = new Map() // isolate from the definition insert
|
|
143
|
+
for (let i = 0; i < N; i++) Static({})
|
|
144
|
+
const staticResolve = get('styler.resolve')
|
|
145
|
+
|
|
146
|
+
const Dyn = styled('div')<{ c: string }>`
|
|
147
|
+
color: ${(p) => p.c};
|
|
148
|
+
`
|
|
149
|
+
counts = new Map()
|
|
150
|
+
for (let i = 0; i < N; i++) Dyn({ c: 'blue' })
|
|
151
|
+
const dynResolve = get('styler.resolve')
|
|
152
|
+
|
|
153
|
+
// The discriminator: if the static fast path ever regressed to
|
|
154
|
+
// per-mount resolve, this is the assertion that pinpoints it — the
|
|
155
|
+
// static side moves off 0 while the dynamic side stays at N.
|
|
156
|
+
expect(staticResolve).toBe(0)
|
|
157
|
+
expect(dynResolve).toBe(N)
|
|
158
|
+
expect(dynResolve - staticResolve).toBe(N)
|
|
159
|
+
})
|
|
160
|
+
})
|
package/src/sheet.ts
CHANGED
|
@@ -439,6 +439,64 @@ export class StyleSheet {
|
|
|
439
439
|
return `<style ${ATTR}="">${css}</style>`
|
|
440
440
|
}
|
|
441
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Returns the collected SSR rules as a raw array (one entry per
|
|
444
|
+
* top-level rule, already `@layer`-wrapped + class-prefixed exactly as
|
|
445
|
+
* `insert()` produced them). Used by the compile-time rocketstyle
|
|
446
|
+
* collapse resolver: it renders a component under SSR, reads the rules
|
|
447
|
+
* here, and the build emits an idempotent `injectRules()` call so the
|
|
448
|
+
* collapsed `_tpl()` site is self-sufficient (no prior runtime mount
|
|
449
|
+
* needed to populate the sheet). A copy — callers must not mutate the
|
|
450
|
+
* internal buffer.
|
|
451
|
+
*/
|
|
452
|
+
getStyleRules(): readonly string[] {
|
|
453
|
+
return this.ssrBuffer.slice()
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Idempotency guard for injectRules — keyed by the FNV hash the
|
|
457
|
+
// collapse resolver computes over the rule set. A second injection of
|
|
458
|
+
// the same resolved bundle (e.g. the module re-evaluated under HMR, or
|
|
459
|
+
// two collapsed call sites resolving to the same dimension combo) is a
|
|
460
|
+
// no-op instead of duplicate live `cssRules`.
|
|
461
|
+
private injectedBundles = new Set<string>()
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Inject pre-resolved CSS rule text (from `getStyleRules()` captured at
|
|
465
|
+
* build time by the rocketstyle-collapse resolver) directly into the
|
|
466
|
+
* live sheet. Unlike `insert()` this does NOT re-hash — the class names
|
|
467
|
+
* are already baked into `rules` and into the collapsed `_tpl()` HTML;
|
|
468
|
+
* re-hashing would produce a different class and break the contract.
|
|
469
|
+
* Idempotent by `key` (the resolver's FNV hash of the bundle).
|
|
470
|
+
*/
|
|
471
|
+
injectRules(rules: readonly string[], key: string): void {
|
|
472
|
+
if (this.injectedBundles.has(key)) return
|
|
473
|
+
this.injectedBundles.add(key)
|
|
474
|
+
if (this.isSSR) {
|
|
475
|
+
for (const rule of rules) this.ssrBuffer.push(rule)
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
if (!this.sheet) return
|
|
479
|
+
for (const rule of rules) {
|
|
480
|
+
try {
|
|
481
|
+
this.sheet.insertRule(rule, this.sheet.cssRules.length)
|
|
482
|
+
} catch (_e) {
|
|
483
|
+
if (__DEV__) {
|
|
484
|
+
// oxlint-disable-next-line no-console
|
|
485
|
+
console.warn('[styler] injectRules: failed to insert collapsed rule:', rule, _e)
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Test-only: live `cssRules.length` (0 in SSR). Mirrors runtime-dom's
|
|
493
|
+
* `_tplCacheSize()` test-only-accessor convention; lets injectRules /
|
|
494
|
+
* eviction tests assert without reaching into the private sheet.
|
|
495
|
+
*/
|
|
496
|
+
ruleCountForTest(): number {
|
|
497
|
+
return this.sheet?.cssRules.length ?? 0
|
|
498
|
+
}
|
|
499
|
+
|
|
442
500
|
/** Returns collected CSS rules as a raw string (useful for streaming SSR). */
|
|
443
501
|
getStyles(): string {
|
|
444
502
|
if (this.ssrBuffer.length === 0) return ''
|