@pyreon/rocketstyle 0.23.0 → 0.24.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.js CHANGED
@@ -543,7 +543,7 @@ const rocketComponent = (options) => {
543
543
  ];
544
544
  const _omitSetCache = /* @__PURE__ */ new WeakMap();
545
545
  const _rsMemo = /* @__PURE__ */ new WeakMap();
546
- const RS_MEMO_CAP = 32;
546
+ const RS_MEMO_CAP = 128;
547
547
  const hocsFuncs = [rocketStyleHOC(options), ...calculateHocsFuncs(options.compose)];
548
548
  const EnhancedComponent = (props) => {
549
549
  const localCtx = useLocalContext(options.consumer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/rocketstyle",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Multi-dimensional style composition for Pyreon components",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,9 +42,9 @@
42
42
  "typecheck": "tsc --noEmit"
43
43
  },
44
44
  "devDependencies": {
45
- "@pyreon/test-utils": "^0.13.10",
46
- "@pyreon/typescript": "^0.23.0",
47
- "@pyreon/ui-core": "^0.23.0",
45
+ "@pyreon/test-utils": "^0.13.11",
46
+ "@pyreon/typescript": "^0.24.0",
47
+ "@pyreon/ui-core": "^0.24.0",
48
48
  "@vitest/browser-playwright": "^4.1.4",
49
49
  "@vitus-labs/tools-rolldown": "^2.4.0"
50
50
  },
@@ -52,9 +52,9 @@
52
52
  "node": ">= 22"
53
53
  },
54
54
  "dependencies": {
55
- "@pyreon/core": "^0.23.0",
56
- "@pyreon/reactivity": "^0.23.0",
57
- "@pyreon/styler": "^0.23.0",
58
- "@pyreon/ui-core": "^0.23.0"
55
+ "@pyreon/core": "^0.24.0",
56
+ "@pyreon/reactivity": "^0.24.0",
57
+ "@pyreon/styler": "^0.24.0",
58
+ "@pyreon/ui-core": "^0.24.0"
59
59
  }
60
60
  }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Regression: `_rsMemo` LRU cap was 32, which thrashed on real-world
3
+ * high-cardinality workloads (data tables with state derived from row
4
+ * data, design systems with many tokens × axes, dashboards rendering many
5
+ * small interactive components). The rs-precompute spike (closed PR #761,
6
+ * branch `spike/rocketstyle-precompute`) bisect-verified that a 60-unique-
7
+ * tuple Button mount loop had 45% cache-miss rate at cap=32 (888 out of
8
+ * 2000 lookups were cold resolves) and 46% wall-clock regression vs the
9
+ * cap-fits-workload case.
10
+ *
11
+ * Fix: raise `RS_MEMO_CAP` from 32 to 128. Memory cost ~12KB per
12
+ * definition per theme (128 × ~100 bytes) — negligible vs the wall-clock
13
+ * win.
14
+ *
15
+ * This test locks the cap behavior via the counter contract: with N=64
16
+ * unique state variants (above the OLD cap of 32, below the NEW cap of
17
+ * 128), a two-pass cold-then-warm render loop must have ZERO cold
18
+ * resolves on the warm pass. Pre-fix (cap=32): the warm pass would
19
+ * re-cold-resolve any tuples the first pass had evicted (the oldest 32
20
+ * of the 64). Post-fix (cap=128): all 64 fit, second pass is fully
21
+ * cached.
22
+ *
23
+ * Bisect verification (run manually before merging):
24
+ * 1. Revert `RS_MEMO_CAP` to 32 in rocketstyle.ts
25
+ * 2. Run this test — both warm-pass assertions fail with "expected N to be 0"
26
+ * 3. Restore cap to 128
27
+ * 4. Run test — both pass
28
+ */
29
+
30
+ import { initTestConfig, withThemeContext } from '@pyreon/test-utils'
31
+ import rocketstyle from '../init'
32
+
33
+ let cleanup: () => void
34
+ beforeAll(() => {
35
+ cleanup = initTestConfig()
36
+ })
37
+ afterAll(() => cleanup())
38
+
39
+ // Lightweight counter sink — rocketstyle emits via `globalThis.__pyreon_count__`
40
+ // without an import dep on @pyreon/perf-harness. We install our own sink to
41
+ // observe the cold-resolve count.
42
+ interface CounterGlobal {
43
+ __pyreon_count__?: ((name: string) => void) | undefined
44
+ }
45
+ function installCounter(): { snapshot: () => Record<string, number>; reset: () => void; uninstall: () => void } {
46
+ const counts: Record<string, number> = {}
47
+ const prev = (globalThis as CounterGlobal).__pyreon_count__
48
+ ;(globalThis as CounterGlobal).__pyreon_count__ = (name: string) => {
49
+ counts[name] = (counts[name] ?? 0) + 1
50
+ }
51
+ return {
52
+ snapshot: () => ({ ...counts }),
53
+ reset: () => {
54
+ for (const k of Object.keys(counts)) delete counts[k]
55
+ },
56
+ uninstall: () => {
57
+ ;(globalThis as CounterGlobal).__pyreon_count__ = prev
58
+ },
59
+ }
60
+ }
61
+
62
+ // Capture the rocketstyle theme accessor's resolved value so a render
63
+ // actually invokes `_resolveRsEntry` (the function that emits the
64
+ // `rocketstyle.getTheme` counter on cache miss and
65
+ // `rocketstyle.dimensionMemo.hit` on cache hit).
66
+ const ThemeCapture: any = ({ $rocketstyle, $rocketstate, ...rest }: any) => ({
67
+ type: 'div',
68
+ props: rest,
69
+ $rocketstyle: typeof $rocketstyle === 'function' ? $rocketstyle() : $rocketstyle,
70
+ $rocketstate: typeof $rocketstate === 'function' ? $rocketstate() : $rocketstate,
71
+ })
72
+ ThemeCapture.displayName = 'ThemeCapture'
73
+
74
+ // Build N state variants in a single rocketstyle component. Each render
75
+ // with a different `state` prop produces a different memo key.
76
+ function makeHighCardinalityComponent(N: number): any {
77
+ const states: Record<string, { color: string }> = {}
78
+ for (let i = 0; i < N; i++) {
79
+ states[`s${i}`] = { color: `rgb(${i % 256}, 0, 0)` }
80
+ }
81
+ return rocketstyle()({
82
+ name: `HighCard${N}`,
83
+ component: ThemeCapture,
84
+ }).states(() => states)
85
+ }
86
+
87
+ describe('rocketstyle — _rsMemo LRU cap (regression PR #762)', () => {
88
+ it('warm pass over 64 unique tuples has ZERO cold resolves (cap >= 64)', () => {
89
+ const N = 64
90
+ const Comp: any = makeHighCardinalityComponent(N)
91
+ const counter = installCounter()
92
+
93
+ try {
94
+ // Cold pass — fills the memo with N entries.
95
+ for (let i = 0; i < N; i++) {
96
+ withThemeContext(() => Comp({ state: `s${i}` }))
97
+ }
98
+ const afterCold = counter.snapshot()
99
+ const coldGetTheme = afterCold['rocketstyle.getTheme'] ?? 0
100
+ // Sanity: cold pass must have ~N resolves (one per unique state).
101
+ expect(coldGetTheme).toBeGreaterThanOrEqual(N)
102
+
103
+ // Warm pass — same N tuples, in same order. With cap >= N, every
104
+ // lookup hits cache. Pre-fix (cap=32, N=64): the cold pass filled
105
+ // the memo and evicted the oldest 32 entries (entries 0..31),
106
+ // leaving entries 32..63 cached. The warm pass would re-cold-
107
+ // resolve entries 0..31 → 32 cold resolves. Post-fix (cap=128):
108
+ // 0 cold resolves on the warm pass.
109
+ counter.reset()
110
+ for (let i = 0; i < N; i++) {
111
+ withThemeContext(() => Comp({ state: `s${i}` }))
112
+ }
113
+ const afterWarm = counter.snapshot()
114
+ const warmColdGetTheme = afterWarm['rocketstyle.getTheme'] ?? 0
115
+ expect(warmColdGetTheme).toBe(0)
116
+ } finally {
117
+ counter.uninstall()
118
+ }
119
+ })
120
+
121
+ it('warm pass over 100 unique tuples has ZERO cold resolves (cap = 128)', () => {
122
+ // Second probe at the cap's effective boundary: 100 tuples is comfortably
123
+ // within the cap=128 limit. If the cap were anything less than 100,
124
+ // this would fail. The 64-vs-100 split lets a future bump (e.g. to 256)
125
+ // be detected at the boundary without rewriting tests.
126
+ const N = 100
127
+ const Comp: any = makeHighCardinalityComponent(N)
128
+ const counter = installCounter()
129
+
130
+ try {
131
+ for (let i = 0; i < N; i++) {
132
+ withThemeContext(() => Comp({ state: `s${i}` }))
133
+ }
134
+ const afterCold = counter.snapshot()
135
+ expect(afterCold['rocketstyle.getTheme'] ?? 0).toBeGreaterThanOrEqual(N)
136
+
137
+ counter.reset()
138
+ for (let i = 0; i < N; i++) {
139
+ withThemeContext(() => Comp({ state: `s${i}` }))
140
+ }
141
+ const afterWarm = counter.snapshot()
142
+ expect(afterWarm['rocketstyle.getTheme'] ?? 0).toBe(0)
143
+ } finally {
144
+ counter.uninstall()
145
+ }
146
+ })
147
+
148
+ it('cap still bounds growth — workload of 200 tuples DOES evict (cap < 200)', () => {
149
+ // Control: at workload > cap, the LRU SHOULD evict. This guards against
150
+ // an accidental "remove the cap entirely" change that would let the
151
+ // memo grow unbounded.
152
+ const N = 200
153
+ const Comp: any = makeHighCardinalityComponent(N)
154
+ const counter = installCounter()
155
+
156
+ try {
157
+ for (let i = 0; i < N; i++) {
158
+ withThemeContext(() => Comp({ state: `s${i}` }))
159
+ }
160
+ counter.reset()
161
+ // Re-render in same order: with cap=128, the cold pass kept entries
162
+ // 72..199 (last 128 of the 200), so the warm pass will re-resolve
163
+ // entries 0..71 → 72 cold resolves. Some non-zero number must
164
+ // appear; the exact value depends on insertion order.
165
+ for (let i = 0; i < N; i++) {
166
+ withThemeContext(() => Comp({ state: `s${i}` }))
167
+ }
168
+ const afterWarm = counter.snapshot()
169
+ expect(afterWarm['rocketstyle.getTheme'] ?? 0).toBeGreaterThan(0)
170
+ } finally {
171
+ counter.uninstall()
172
+ }
173
+ })
174
+ })
@@ -156,11 +156,23 @@ const rocketComponent: RocketComponent = (options) => {
156
156
  // mean the styler's classCache hits earlier and the resolves don't run.
157
157
  //
158
158
  // LRU bound prevents unbounded growth from prop-tuple churn (e.g. a
159
- // table where every cell has a unique state). 32 entries per theme
160
- // covers ~99% of unique combos in real apps.
159
+ // table where every cell has a unique state). 128 entries per theme
160
+ // covers the E2 perf-dashboard reference workload AND high-cardinality
161
+ // surfaces (data tables, design systems with many tokens crossed with
162
+ // size/variant axes, dashboards rendering many small interactive
163
+ // components). The previous cap of 32 was sized for the reference
164
+ // workload only and thrashed at higher cardinalities — measured 45%
165
+ // cache-miss rate (888/2000 lookups) on a 60-unique-tuple Button
166
+ // mount loop, 46% wall-clock regression vs the cap-fits-workload
167
+ // case. The rs-precompute spike (closed PR #761, results live on
168
+ // `spike/rocketstyle-precompute`) bisect-verified that raising the cap
169
+ // 32 → 128 zeroes the cold-resolves counter for that 60-tuple
170
+ // workload at zero implementation cost. Memory: ~12KB per definition
171
+ // per theme at 128 entries × ~100 bytes per entry — negligible vs
172
+ // the 46% runtime win.
161
173
  type RsMemoEntry = { readonly rocketstyle: object; readonly rocketstate: object }
162
174
  const _rsMemo = new WeakMap<object, Map<string, RsMemoEntry>>()
163
- const RS_MEMO_CAP = 32
175
+ const RS_MEMO_CAP = 128
164
176
 
165
177
  // --------------------------------------------------------
166
178
  // COMPOSE - high-order components