@pyreon/styler 0.19.0 → 0.21.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
@@ -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: _$_pyreon_core0.ReactiveContext<Theme>;
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.19.0",
3
+ "version": "0.21.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.6",
47
- "@pyreon/typescript": "^0.19.0",
46
+ "@pyreon/test-utils": "^0.13.8",
47
+ "@pyreon/typescript": "^0.21.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.19.0",
56
- "@pyreon/reactivity": "^0.19.0"
55
+ "@pyreon/core": "^0.21.0",
56
+ "@pyreon/reactivity": "^0.21.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 ''