@pyreon/rocketstyle 0.22.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/README.md +117 -199
- package/lib/index.js +1 -1
- package/package.json +9 -9
- package/src/__tests__/memo-cap.test.ts +174 -0
- package/src/rocketstyle.ts +15 -3
package/README.md
CHANGED
|
@@ -1,36 +1,22 @@
|
|
|
1
1
|
# @pyreon/rocketstyle
|
|
2
2
|
|
|
3
|
-
Multi-dimensional styling
|
|
3
|
+
Multi-dimensional component styling — states, sizes, variants, themes, light/dark, all cached.
|
|
4
4
|
|
|
5
|
-
Organize
|
|
5
|
+
`@pyreon/rocketstyle` is the styling layer Pyreon's UI system builds on. Organize styles by named DIMENSIONS — `state` (`primary` / `danger` / `success`), `size` (`sm` / `md` / `lg`), `variant`, plus any custom dimension you declare — instead of flat boolean props. Each dimension is a chainable method (`.states({...})`, `.sizes({...})`); per-dimension values are themed via `.theme()` callbacks that receive `(theme, mode, css)`, with light/dark mode threaded through and pseudo-states (`hover` / `focus` / `pressed` / `active` / `disabled`) auto-detected. Built on `@pyreon/attrs` + `@pyreon/styler`. Per-definition WeakMap caches make per-mount cost near zero for same-definition components — verified 73% reduction in `styler.resolve` calls on real-app benchmarks.
|
|
6
6
|
|
|
7
|
-
##
|
|
8
|
-
|
|
9
|
-
- **Dimension-based theming** — define style variations as named dimensions (states, sizes, variants)
|
|
10
|
-
- **Immutable chaining** — `.attrs()`, `.theme()`, `.states()`, `.sizes()`, `.styles()` and more
|
|
11
|
-
- **Boolean shorthand** — `Button({ primary: true, lg: true })` instead of `Button({ state: 'primary', size: 'lg' })`
|
|
12
|
-
- **Pseudo-state detection** — hover, focus, pressed tracked via signals and context
|
|
13
|
-
- **Light/dark mode** — theme callbacks receive a mode parameter
|
|
14
|
-
- **Provider/Consumer** — propagate parent state to children through context
|
|
15
|
-
- **Multi-tier WeakMap caching** — dimension maps, reserved keys, omit Sets, and theme results cached per component definition (shared across all instances). Per-mount allocations near zero for same-definition components
|
|
16
|
-
- **TypeScript inference** — dimension values and prop types inferred through the chain
|
|
17
|
-
|
|
18
|
-
## Installation
|
|
7
|
+
## Install
|
|
19
8
|
|
|
20
9
|
```bash
|
|
21
|
-
bun add @pyreon/rocketstyle
|
|
10
|
+
bun add @pyreon/rocketstyle @pyreon/core @pyreon/reactivity @pyreon/ui-core @pyreon/styler
|
|
22
11
|
```
|
|
23
12
|
|
|
24
|
-
## Quick
|
|
13
|
+
## Quick start
|
|
25
14
|
|
|
26
|
-
```
|
|
15
|
+
```tsx
|
|
27
16
|
import rocketstyle from '@pyreon/rocketstyle'
|
|
28
17
|
import { Element } from '@pyreon/elements'
|
|
29
18
|
|
|
30
|
-
const Button = rocketstyle()({
|
|
31
|
-
name: 'Button',
|
|
32
|
-
component: Element,
|
|
33
|
-
})
|
|
19
|
+
const Button = rocketstyle()({ name: 'Button', component: Element })
|
|
34
20
|
.attrs({ tag: 'button' })
|
|
35
21
|
.theme({
|
|
36
22
|
fontSize: 16,
|
|
@@ -39,238 +25,178 @@ const Button = rocketstyle()({
|
|
|
39
25
|
borderRadius: 4,
|
|
40
26
|
color: '#fff',
|
|
41
27
|
backgroundColor: '#0d6efd',
|
|
42
|
-
hover: {
|
|
43
|
-
backgroundColor: '#0b5ed7',
|
|
44
|
-
},
|
|
28
|
+
hover: { backgroundColor: '#0b5ed7' },
|
|
45
29
|
})
|
|
46
30
|
.states({
|
|
47
|
-
primary: {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
},
|
|
51
|
-
danger: {
|
|
52
|
-
backgroundColor: '#dc3545',
|
|
53
|
-
hover: { backgroundColor: '#bb2d3b' },
|
|
54
|
-
},
|
|
55
|
-
success: {
|
|
56
|
-
backgroundColor: '#198754',
|
|
57
|
-
hover: { backgroundColor: '#157347' },
|
|
58
|
-
},
|
|
31
|
+
primary: { backgroundColor: '#0d6efd', hover: { backgroundColor: '#0b5ed7' } },
|
|
32
|
+
danger: { backgroundColor: '#dc3545', hover: { backgroundColor: '#bb2d3b' } },
|
|
33
|
+
success: { backgroundColor: '#198754', hover: { backgroundColor: '#157347' } },
|
|
59
34
|
})
|
|
60
35
|
.sizes({
|
|
61
36
|
sm: { fontSize: 14, paddingX: 12, paddingY: 6 },
|
|
62
37
|
md: { fontSize: 16, paddingX: 16, paddingY: 8 },
|
|
63
38
|
lg: { fontSize: 18, paddingX: 20, paddingY: 10 },
|
|
64
39
|
})
|
|
65
|
-
```
|
|
66
40
|
|
|
67
|
-
|
|
68
|
-
// Named props
|
|
69
|
-
Button({ state: 'danger', size: 'lg', label: 'Delete' })
|
|
70
|
-
|
|
71
|
-
// Boolean shorthand (when useBooleans is enabled)
|
|
72
|
-
Button({ danger: true, lg: true, label: 'Delete' })
|
|
41
|
+
<Button state="danger" size="lg">Delete</Button>
|
|
73
42
|
```
|
|
74
43
|
|
|
75
|
-
## Core
|
|
44
|
+
## Core concepts
|
|
76
45
|
|
|
77
46
|
### Dimensions
|
|
78
47
|
|
|
79
|
-
A dimension is a named axis of style variation.
|
|
48
|
+
A dimension is a named axis of style variation. Defaults ship four:
|
|
80
49
|
|
|
81
|
-
| Dimension | Prop name | Multi | Example |
|
|
82
|
-
| ---------- | --------- |
|
|
83
|
-
| `states` | `state` | no
|
|
84
|
-
| `sizes` | `size` | no
|
|
85
|
-
| `variants` | `variant` | no
|
|
86
|
-
| `multiple` | — | yes
|
|
50
|
+
| Dimension | Prop name | Multi? | Example |
|
|
51
|
+
| ---------- | --------- | ------ | ------------------------------ |
|
|
52
|
+
| `states` | `state` | no | `primary`, `danger`, `success` |
|
|
53
|
+
| `sizes` | `size` | no | `sm`, `md`, `lg` |
|
|
54
|
+
| `variants` | `variant` | no | `outlined`, `filled` |
|
|
55
|
+
| `multiple` | — | yes | `rounded`, `shadow` |
|
|
87
56
|
|
|
88
|
-
Each dimension creates a chain method
|
|
57
|
+
Each declared dimension creates a chain method AND a corresponding prop on the component. Multi-dimensions accept multiple active values at once.
|
|
89
58
|
|
|
90
|
-
|
|
59
|
+
### Default: `useBooleans: false` (string prop values)
|
|
91
60
|
|
|
92
|
-
|
|
61
|
+
```tsx
|
|
62
|
+
<Button state="primary" size="lg">Save</Button>
|
|
63
|
+
```
|
|
93
64
|
|
|
94
|
-
|
|
65
|
+
Boolean shorthand (`<Button primary lg>Save</Button>`) is opt-in via `rocketstyle({ useBooleans: true })`. **Important**: before April 2026 the type default was `true` but the runtime was `false` — boolean props typechecked but were silently dropped at runtime. Fixed in `rocketstyle/init.ts`; new code should not rely on the historical behaviour.
|
|
95
66
|
|
|
96
|
-
|
|
67
|
+
### Theme + pseudo-states
|
|
97
68
|
|
|
98
69
|
```ts
|
|
99
70
|
.theme({
|
|
100
71
|
color: '#333',
|
|
101
72
|
fontSize: 16,
|
|
102
|
-
hover:
|
|
103
|
-
focus:
|
|
104
|
-
active:
|
|
73
|
+
hover: { color: '#000' },
|
|
74
|
+
focus: { outline: '2px solid blue' },
|
|
75
|
+
active: { transform: 'scale(0.98)' },
|
|
76
|
+
disabled: { opacity: 0.5 },
|
|
105
77
|
})
|
|
106
78
|
```
|
|
107
79
|
|
|
108
|
-
|
|
80
|
+
Pseudo-state keys nest directly. Bases (`@pyreon/elements`) generate `:hover` / `:focus-visible` / `:active` / `:disabled` CSS from the nested objects. `:hover` is unconditional — applied to EVERY component with hover theme; only `cursor: pointer` is gated on `onClick` / `href`.
|
|
109
81
|
|
|
110
|
-
|
|
82
|
+
### Styles callback
|
|
111
83
|
|
|
112
84
|
```ts
|
|
113
85
|
.styles((css) => css`
|
|
86
|
+
cursor: pointer;
|
|
87
|
+
border: none;
|
|
88
|
+
transition: all 0.2s;
|
|
89
|
+
|
|
114
90
|
${({ $rocketstyle, $rocketstate }) => {
|
|
115
|
-
// $rocketstyle — computed theme
|
|
91
|
+
// $rocketstyle — computed theme (base + active dimension values merged)
|
|
116
92
|
// $rocketstate — { hover, focus, pressed, active, disabled, pseudo }
|
|
117
|
-
return css
|
|
93
|
+
return /* css string */
|
|
118
94
|
}}
|
|
119
95
|
`)
|
|
120
96
|
```
|
|
121
97
|
|
|
98
|
+
`$rocketstyle` is identity-cached — same dimension-prop combo produces the same object identity, which lets the styler's `classCache` skip resolve work entirely on cache hits.
|
|
99
|
+
|
|
122
100
|
## API
|
|
123
101
|
|
|
124
|
-
### rocketstyle(options?)
|
|
102
|
+
### `rocketstyle(options?)({ name, component })`
|
|
125
103
|
|
|
126
104
|
Factory initializer. Returns a function that accepts component configuration.
|
|
127
105
|
|
|
128
106
|
```ts
|
|
129
107
|
const factory = rocketstyle({
|
|
130
|
-
dimensions: {
|
|
131
|
-
/* custom dimensions */
|
|
132
|
-
},
|
|
108
|
+
dimensions: { /* custom dimensions */ },
|
|
133
109
|
useBooleans: true,
|
|
134
110
|
})
|
|
135
111
|
|
|
136
|
-
const
|
|
137
|
-
name: 'ComponentName',
|
|
138
|
-
component: BaseComponent,
|
|
139
|
-
})
|
|
112
|
+
const Button = factory({ name: 'Button', component: Element })
|
|
140
113
|
```
|
|
141
114
|
|
|
142
|
-
###
|
|
143
|
-
|
|
144
|
-
Same API as `@pyreon/attrs`. Define default props with optional priority and filter.
|
|
115
|
+
### `.attrs(props | callback, options?)`
|
|
145
116
|
|
|
146
|
-
|
|
147
|
-
Button.attrs({ tag: 'button', role: 'button' })
|
|
148
|
-
Button.attrs((props) => ({ 'aria-label': props.label }))
|
|
149
|
-
```
|
|
117
|
+
Same as `@pyreon/attrs` — accumulate defaults, supports callback / priority / filter.
|
|
150
118
|
|
|
151
|
-
###
|
|
119
|
+
### `.theme(values | callback)`
|
|
152
120
|
|
|
153
|
-
Base theme
|
|
121
|
+
Base theme applied to every instance.
|
|
154
122
|
|
|
155
123
|
```ts
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
fontSize: 16,
|
|
159
|
-
color: '#fff',
|
|
160
|
-
hover: { opacity: 0.9 },
|
|
161
|
-
})
|
|
162
|
-
|
|
163
|
-
// Callback form — receives the theme context and mode
|
|
164
|
-
Button.theme((theme, mode, css) => ({
|
|
124
|
+
.theme({ fontSize: 16, color: '#fff', hover: { opacity: 0.9 } })
|
|
125
|
+
.theme((theme, mode, css) => ({
|
|
165
126
|
fontSize: 16,
|
|
166
127
|
color: mode === 'dark' ? '#fff' : '#333',
|
|
167
128
|
}))
|
|
168
129
|
```
|
|
169
130
|
|
|
170
|
-
###
|
|
131
|
+
### `.states()` / `.sizes()` / `.variants()` / `.multiple()`
|
|
171
132
|
|
|
172
|
-
Define
|
|
133
|
+
Define per-dimension values.
|
|
173
134
|
|
|
174
135
|
```ts
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
})
|
|
179
|
-
|
|
180
|
-
Button.sizes({
|
|
181
|
-
sm: { fontSize: 14, paddingX: 8 },
|
|
182
|
-
lg: { fontSize: 18, paddingX: 20 },
|
|
183
|
-
})
|
|
136
|
+
.states({ primary: { backgroundColor: '#0d6efd' }, danger: { backgroundColor: '#dc3545' } })
|
|
137
|
+
.sizes({ sm: { fontSize: 14, paddingX: 8 }, lg: { fontSize: 18, paddingX: 20 } })
|
|
138
|
+
.multiple({ rounded: { borderRadius: 999 }, shadow: { boxShadow: '0 2px 8px rgba(0,0,0,0.15)' } })
|
|
184
139
|
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
rounded: { borderRadius: 999 },
|
|
188
|
-
shadow: { boxShadow: '0 2px 8px rgba(0,0,0,0.15)' },
|
|
189
|
-
})
|
|
190
|
-
```
|
|
191
|
-
|
|
192
|
-
Dimension methods also accept callbacks:
|
|
193
|
-
|
|
194
|
-
```ts
|
|
195
|
-
Button.states((theme, mode, css) => ({
|
|
140
|
+
// Callback form — receives (theme, mode, css)
|
|
141
|
+
.states((theme) => ({
|
|
196
142
|
primary: { backgroundColor: theme.colors?.primary ?? '#0d6efd' },
|
|
197
143
|
}))
|
|
198
144
|
```
|
|
199
145
|
|
|
200
|
-
###
|
|
146
|
+
### `.styles(callback)`
|
|
201
147
|
|
|
202
|
-
|
|
148
|
+
CSS template using `@pyreon/styler`'s `css` tagged template.
|
|
203
149
|
|
|
204
|
-
|
|
205
|
-
Button.styles(
|
|
206
|
-
(css) => css`
|
|
207
|
-
cursor: pointer;
|
|
208
|
-
border: none;
|
|
209
|
-
transition: all 0.2s;
|
|
210
|
-
|
|
211
|
-
${({ $rocketstyle }) => makeItResponsive({ theme: $rocketstyle, styles, css })}
|
|
212
|
-
`,
|
|
213
|
-
)
|
|
214
|
-
```
|
|
215
|
-
|
|
216
|
-
### .config(options)
|
|
217
|
-
|
|
218
|
-
Reconfigure the component.
|
|
150
|
+
### `.config(options)`
|
|
219
151
|
|
|
220
152
|
```ts
|
|
221
153
|
Button.config({
|
|
222
|
-
name: 'PrimaryButton',
|
|
223
|
-
component: NewBase,
|
|
224
|
-
provider: true,
|
|
225
|
-
consumer: (ctx) =>
|
|
226
|
-
inversed: true,
|
|
227
|
-
DEBUG: true,
|
|
154
|
+
name: 'PrimaryButton',
|
|
155
|
+
component: NewBase, // swap base — resets prop chains
|
|
156
|
+
provider: true, // make this a context provider for children
|
|
157
|
+
consumer: (ctx) => …, // consume parent component context
|
|
158
|
+
inversed: true, // invert theme mode for subtree
|
|
159
|
+
DEBUG: true,
|
|
228
160
|
})
|
|
229
161
|
```
|
|
230
162
|
|
|
231
|
-
###
|
|
232
|
-
|
|
233
|
-
Same API as `@pyreon/attrs`:
|
|
234
|
-
|
|
235
|
-
```ts
|
|
236
|
-
Button.compose({ withTracking: trackingHoc })
|
|
237
|
-
Button.statics({ category: 'action' })
|
|
163
|
+
### `.compose(hocs)` / `.statics(metadata)`
|
|
238
164
|
|
|
239
|
-
|
|
240
|
-
```
|
|
165
|
+
Same API as `@pyreon/attrs`.
|
|
241
166
|
|
|
242
|
-
### isRocketComponent(value)
|
|
167
|
+
### `isRocketComponent(value)` / `resolveTheme(value)`
|
|
243
168
|
|
|
244
|
-
Runtime
|
|
169
|
+
Runtime guard and theme accessor for use inside styled-component interpolations:
|
|
245
170
|
|
|
246
171
|
```ts
|
|
247
|
-
import { isRocketComponent } from '@pyreon/rocketstyle'
|
|
172
|
+
import { isRocketComponent, resolveTheme } from '@pyreon/rocketstyle'
|
|
173
|
+
|
|
174
|
+
isRocketComponent(Button) // true
|
|
248
175
|
|
|
249
|
-
|
|
176
|
+
styled(Component)`
|
|
177
|
+
color: ${(props) => resolveTheme(props.$rocketstyle).color};
|
|
178
|
+
`
|
|
250
179
|
```
|
|
251
180
|
|
|
252
|
-
|
|
181
|
+
`resolveTheme` handles both function-accessor (reactive) and plain-object `$rocketstyle` shapes.
|
|
253
182
|
|
|
254
|
-
|
|
183
|
+
## Custom dimensions
|
|
255
184
|
|
|
256
185
|
```ts
|
|
257
186
|
const rocketButton = rocketstyle({
|
|
258
187
|
dimensions: {
|
|
259
|
-
intent: 'intent',
|
|
260
|
-
size:
|
|
261
|
-
appearance: {
|
|
262
|
-
propName: 'appearance',
|
|
263
|
-
multi: true, // allows multiple values
|
|
264
|
-
},
|
|
188
|
+
intent: 'intent', // prop: intent="primary"
|
|
189
|
+
size: 'size',
|
|
190
|
+
appearance: { propName: 'appearance', multi: true },
|
|
265
191
|
},
|
|
266
192
|
})
|
|
267
193
|
```
|
|
268
194
|
|
|
269
|
-
|
|
195
|
+
Creates `.intent()`, `.size()`, `.appearance()` chain methods.
|
|
270
196
|
|
|
271
|
-
|
|
197
|
+
### Transform dimensions
|
|
272
198
|
|
|
273
|
-
Mark
|
|
199
|
+
Mark `transform: true` to make a dimension receive the accumulated theme from all prior dimensions — ideal for modifiers like `outlined` that derive from the active state.
|
|
274
200
|
|
|
275
201
|
```ts
|
|
276
202
|
const rocketButton = rocketstyle({
|
|
@@ -282,51 +208,44 @@ const rocketButton = rocketstyle({
|
|
|
282
208
|
|
|
283
209
|
const Button = rocketButton({ name: 'Button', component: Element })
|
|
284
210
|
.theme({ backgroundColor: '#0d6efd', color: '#fff' })
|
|
285
|
-
.states({
|
|
286
|
-
danger: { backgroundColor: '#dc3545', color: '#fff' },
|
|
287
|
-
})
|
|
211
|
+
.states({ danger: { backgroundColor: '#dc3545', color: '#fff' } })
|
|
288
212
|
.modifiers({
|
|
289
213
|
outlined: (theme) => ({
|
|
290
|
-
color: theme.backgroundColor,
|
|
214
|
+
color: theme.backgroundColor, // receives merged theme from prior dimensions
|
|
291
215
|
backgroundColor: 'transparent',
|
|
292
216
|
}),
|
|
293
217
|
})
|
|
294
218
|
|
|
295
|
-
|
|
296
|
-
Button({ state: 'danger', modifier: 'outlined' })
|
|
219
|
+
<Button state="danger" modifier="outlined" /> // outlined sees danger's red, becomes red-on-transparent
|
|
297
220
|
```
|
|
298
221
|
|
|
299
|
-
## Provider / Consumer
|
|
300
|
-
|
|
301
|
-
Propagate parent component state to children through Pyreon's context system.
|
|
222
|
+
## Provider / Consumer — parent-child state propagation
|
|
302
223
|
|
|
303
224
|
```ts
|
|
304
225
|
// Parent provides its state
|
|
305
226
|
const ButtonGroup = Button.config({ provider: true })
|
|
306
227
|
|
|
307
228
|
// Child consumes parent state
|
|
308
|
-
const ButtonIcon = rocketstyle()({
|
|
309
|
-
name: 'ButtonIcon',
|
|
310
|
-
component: Element,
|
|
311
|
-
})
|
|
229
|
+
const ButtonIcon = rocketstyle()({ name: 'ButtonIcon', component: Element })
|
|
312
230
|
.config({
|
|
313
|
-
consumer: (ctx) =>
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
})),
|
|
231
|
+
consumer: (ctx) => ctx(({ pseudo }) => ({
|
|
232
|
+
state: pseudo.hover ? 'active' : 'default',
|
|
233
|
+
})),
|
|
317
234
|
})
|
|
318
235
|
.states({
|
|
319
236
|
default: { color: '#666' },
|
|
320
|
-
active:
|
|
237
|
+
active: { color: '#fff' },
|
|
321
238
|
})
|
|
322
239
|
|
|
323
|
-
|
|
324
|
-
|
|
240
|
+
<ButtonGroup state="primary">
|
|
241
|
+
<ButtonIcon />
|
|
242
|
+
Label
|
|
243
|
+
</ButtonGroup>
|
|
325
244
|
```
|
|
326
245
|
|
|
327
|
-
## Light /
|
|
246
|
+
## Light / dark mode
|
|
328
247
|
|
|
329
|
-
Theme callbacks receive
|
|
248
|
+
Theme + dimension callbacks receive `(theme, mode, css)`. `mode === 'light' | 'dark'`. Use `inversed: true` on `.config()` to flip the mode for a subtree.
|
|
330
249
|
|
|
331
250
|
```ts
|
|
332
251
|
Button.theme((theme, mode) => ({
|
|
@@ -335,32 +254,31 @@ Button.theme((theme, mode) => ({
|
|
|
335
254
|
}))
|
|
336
255
|
```
|
|
337
256
|
|
|
338
|
-
Use `inversed: true` in `.config()` to flip the mode for a component subtree.
|
|
339
|
-
|
|
340
257
|
## Performance
|
|
341
258
|
|
|
342
|
-
|
|
259
|
+
Per-definition WeakMap caches keep per-mount cost flat as instance count grows:
|
|
260
|
+
|
|
261
|
+
- **`_dimensionsCache`** — `getDimensionsMap` result keyed on dimension-themes identity
|
|
262
|
+
- **`_reservedKeysCache`** — `Object.keys(reservedPropNames)` keyed on keywords identity
|
|
263
|
+
- **`_omitSetCache`** — pre-built `Set<string>` for `omit()` (avoids per-mount Set allocation)
|
|
264
|
+
- **`LocalThemeManager`** — WeakMap tiers for baseTheme, dimensionThemes, and per-mode resolved themes
|
|
265
|
+
- **`_rsMemo`** — dimension-prop memo keyed by `mode|dimensionPropTuple|pseudoState`, LRU-bounded at 32 entries per theme. Hit returns identity-stable `{ rocketstyle, rocketstate }` so the downstream styler `classCache` skips resolve entirely. Real-app E2 benchmark: 200 Buttons × 5 runs, baseline dropped from 8.80ms to 4.80ms (-45%); per-Button `styler.resolve` from 22 to 6 (-73%).
|
|
343
266
|
|
|
344
|
-
|
|
345
|
-
- `_dimensionsCache` — `getDimensionsMap` result keyed on dimension-themes identity
|
|
346
|
-
- `_reservedKeysCache` — `Object.keys(reservedPropNames)` keyed on keywords identity
|
|
347
|
-
- `_omitSetCache` — pre-built `Set<string>` for `omit()` (avoids per-mount Set construction)
|
|
348
|
-
- `ALL_PSEUDO_KEYS` / `STATIC_OMIT_KEYS` — merged key arrays computed once
|
|
349
|
-
- **Theme cache** (`LocalThemeManager`): `WeakMap` tiers for baseTheme, dimensionThemes, and per-mode resolved themes
|
|
350
|
-
- **getTheme in-place merge**: dimension slices merged directly onto `finalTheme` instead of allocating a new target per `merge()` call
|
|
351
|
-
- **Frozen `EMPTY_PSEUDO`**: shared frozen `{}` for pseudo-state defaults instead of 6 allocations per call
|
|
352
|
-
- **Dev guard**: uses `__DEV__` (`import.meta.env.DEV`) — tree-shaken to zero bytes in production
|
|
267
|
+
Real apps MUST mount one shared `<PyreonUI>` provider for the memo to span instances — each provider mount creates a fresh `enrichedTheme` via `computed()`, which produces a different WeakMap key.
|
|
353
268
|
|
|
354
269
|
For a 150-component page with 8 dimensions each: ~1,350 Set allocations, ~300 array spreads, and ~150 map rebuilds eliminated vs naive implementation.
|
|
355
270
|
|
|
356
|
-
##
|
|
271
|
+
## Gotchas
|
|
272
|
+
|
|
273
|
+
- **`.config({ component: NewBase })` resets `attrs` / `priorityAttrs` / `filterAttrs` / `compose` chains** — they were tailored to the previous component's prop shape. `theme` / `styles` / dimension chains are preserved. Re-chain shared attrs explicitly if you swap the base.
|
|
274
|
+
- **`useBooleans: false` is the default** (since April 2026 alignment fix). String props are the idiomatic surface.
|
|
275
|
+
- **Cache keys are downstream of normalization.** Under `useBooleans: true`, the memo correctly keys by the resolved dimension (not the raw boolean prop) — otherwise every boolean variant would collide on the first cached entry.
|
|
276
|
+
- **Dimension props don't accept function accessors directly.** `state={() => signal()}` is wrong — write `state={signal()}` and let the compiler emit reactive `_rp()` wrapping. Caught by the Reactivity Lens.
|
|
277
|
+
- **`provider: true` + `consumer:` on the same component** is a legal but rare shape. Most apps separate the two for clarity.
|
|
278
|
+
|
|
279
|
+
## Documentation
|
|
357
280
|
|
|
358
|
-
|
|
359
|
-
| ------------------ | ------- |
|
|
360
|
-
| @pyreon/core | \* |
|
|
361
|
-
| @pyreon/reactivity | \* |
|
|
362
|
-
| @pyreon/ui-core | \* |
|
|
363
|
-
| @pyreon/styler | \* |
|
|
281
|
+
Full docs: [docs.pyreon.dev/docs/rocketstyle](https://docs.pyreon.dev/docs/rocketstyle) (or `docs/docs/rocketstyle.md` in this repo).
|
|
364
282
|
|
|
365
283
|
## License
|
|
366
284
|
|
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 =
|
|
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.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Multi-dimensional style composition for Pyreon components",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -42,19 +42,19 @@
|
|
|
42
42
|
"typecheck": "tsc --noEmit"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
-
"@pyreon/test-utils": "^0.13.
|
|
46
|
-
"@pyreon/typescript": "^0.
|
|
47
|
-
"@pyreon/ui-core": "^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
|
-
"@vitus-labs/tools-rolldown": "^2.
|
|
49
|
+
"@vitus-labs/tools-rolldown": "^2.4.0"
|
|
50
50
|
},
|
|
51
51
|
"engines": {
|
|
52
52
|
"node": ">= 22"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@pyreon/core": "^0.
|
|
56
|
-
"@pyreon/reactivity": "^0.
|
|
57
|
-
"@pyreon/styler": "^0.
|
|
58
|
-
"@pyreon/ui-core": "^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
|
+
})
|
package/src/rocketstyle.ts
CHANGED
|
@@ -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).
|
|
160
|
-
// covers
|
|
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 =
|
|
175
|
+
const RS_MEMO_CAP = 128
|
|
164
176
|
|
|
165
177
|
// --------------------------------------------------------
|
|
166
178
|
// COMPOSE - high-order components
|