@pyreon/rocketstyle 0.13.1 → 0.15.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 +17 -1
- package/lib/index.d.ts +16 -2
- package/lib/index.js +151 -82
- package/package.json +10 -9
- package/src/__tests__/context.test.ts +15 -15
- package/src/__tests__/createLocalProvider.test.ts +33 -1
- package/src/__tests__/native-marker.test.ts +9 -0
- package/src/__tests__/rocketstyle.browser.test.tsx +36 -0
- package/src/constants/index.ts +7 -0
- package/src/context/context.ts +5 -1
- package/src/env.d.ts +6 -0
- package/src/init.ts +2 -2
- package/src/rocketstyle.ts +230 -77
- package/src/types/rocketstyle.ts +15 -0
- package/src/utils/attrs.ts +25 -29
- package/src/utils/theme.ts +47 -44
- package/lib/index.d.ts.map +0 -1
- package/lib/index.js.map +0 -1
|
@@ -167,4 +167,40 @@ describe('@pyreon/rocketstyle in real browser', () => {
|
|
|
167
167
|
light.unmount()
|
|
168
168
|
dark.unmount()
|
|
169
169
|
})
|
|
170
|
+
|
|
171
|
+
it('multiple instances share definition-scoped caches (no per-mount rebuild)', () => {
|
|
172
|
+
// Verifies the perf optimization: getDimensionsMap, reservedPropNames keys,
|
|
173
|
+
// and omit Sets are cached at definition time (WeakMap), not rebuilt per mount.
|
|
174
|
+
// 10 instances of the same component with different state props must all render
|
|
175
|
+
// correctly — proving the caches handle varied prop combinations.
|
|
176
|
+
const Box: any = rocketstyle()({ name: 'CacheBox', component: Base })
|
|
177
|
+
.styles(
|
|
178
|
+
(css: any) => css`
|
|
179
|
+
color: ${({ $rocketstyle }: any) => $rocketstyle.color};
|
|
180
|
+
`,
|
|
181
|
+
)
|
|
182
|
+
.theme({ color: 'rgb(100, 100, 100)' })
|
|
183
|
+
.states({
|
|
184
|
+
primary: { color: 'rgb(0, 100, 200)' },
|
|
185
|
+
danger: { color: 'rgb(200, 50, 50)' },
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
const instances = Array.from({ length: 10 }, (_, i) => {
|
|
189
|
+
const state = i % 3 === 0 ? 'primary' : i % 3 === 1 ? 'danger' : undefined
|
|
190
|
+
return mountInBrowser(h(Box, { id: `c${i}`, ...(state ? { state } : {}) }))
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
// Check a subset — primary, danger, and default all resolve correctly
|
|
194
|
+
expect(
|
|
195
|
+
getComputedStyle(instances[0]!.container.querySelector('#c0')!).color,
|
|
196
|
+
).toBe('rgb(0, 100, 200)') // primary
|
|
197
|
+
expect(
|
|
198
|
+
getComputedStyle(instances[1]!.container.querySelector('#c1')!).color,
|
|
199
|
+
).toBe('rgb(200, 50, 50)') // danger
|
|
200
|
+
expect(
|
|
201
|
+
getComputedStyle(instances[2]!.container.querySelector('#c2')!).color,
|
|
202
|
+
).toBe('rgb(100, 100, 100)') // default
|
|
203
|
+
|
|
204
|
+
for (const inst of instances) inst.unmount()
|
|
205
|
+
})
|
|
170
206
|
})
|
package/src/constants/index.ts
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
// `import.meta.env.DEV` is provided by Vite/Rolldown at build time and
|
|
2
|
+
// literal-replaced so prod bundles tree-shake the dev branch to zero bytes.
|
|
3
|
+
// Typed through a narrowing interface so downstream packages don't need
|
|
4
|
+
// `vite/client` in their tsconfigs to type-check this file transitively.
|
|
5
|
+
/** Tree-shakeable dev-mode flag. `true` in dev, `false` (dead code eliminated) in prod. */
|
|
6
|
+
export const __DEV__: boolean = process.env.NODE_ENV !== 'production'
|
|
7
|
+
|
|
1
8
|
/** Default theme mode used when no mode is provided via context. */
|
|
2
9
|
export const MODE_DEFAULT = 'light'
|
|
3
10
|
|
package/src/context/context.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { VNodeChild } from '@pyreon/core'
|
|
2
|
-
import { useContext } from '@pyreon/core'
|
|
2
|
+
import { nativeCompat, useContext } from '@pyreon/core'
|
|
3
3
|
import { Provider as CoreProvider, context } from '@pyreon/ui-core'
|
|
4
4
|
import { MODE_DEFAULT, THEME_MODES_INVERSED } from '../constants'
|
|
5
5
|
|
|
@@ -49,6 +49,10 @@ const Provider = ({ provider = CoreProvider, inversed, ...props }: TProvider): V
|
|
|
49
49
|
return result ?? null
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// Mark as native — reads useContext() and delegates to CoreProvider, both
|
|
53
|
+
// of which need Pyreon's setup frame.
|
|
54
|
+
nativeCompat(Provider)
|
|
55
|
+
|
|
52
56
|
export { context }
|
|
53
57
|
|
|
54
58
|
export default Provider
|
package/src/env.d.ts
ADDED
package/src/init.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isEmpty } from '@pyreon/ui-core'
|
|
2
|
-
import { ALL_RESERVED_KEYS } from './constants'
|
|
2
|
+
import { ALL_RESERVED_KEYS, __DEV__ } from './constants'
|
|
3
3
|
import defaultDimensions from './constants/defaultDimensions'
|
|
4
4
|
import rocketComponent from './rocketstyle'
|
|
5
5
|
import type { DefaultDimensions, Dimensions } from './types/dimensions'
|
|
@@ -73,7 +73,7 @@ const validateInit = (name: string, component: unknown, dimensions: Dimensions)
|
|
|
73
73
|
|
|
74
74
|
const rocketstyle = (({ dimensions = defaultDimensions, useBooleans = false } = {}) =>
|
|
75
75
|
({ name, component }: { name: string; component: any }) => {
|
|
76
|
-
if (
|
|
76
|
+
if (__DEV__) {
|
|
77
77
|
validateInit(name, component, dimensions)
|
|
78
78
|
}
|
|
79
79
|
|
package/src/rocketstyle.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { compose, config, hoistNonReactStatics, omit, pick, render } from '@pyreon/ui-core'
|
|
2
2
|
import { LocalThemeManager } from './cache'
|
|
3
|
-
import { CONFIG_KEYS, PSEUDO_KEYS, PSEUDO_META_KEYS, STYLING_KEYS } from './constants'
|
|
3
|
+
import { CONFIG_KEYS, PSEUDO_KEYS, PSEUDO_META_KEYS, STYLING_KEYS, __DEV__ } from './constants'
|
|
4
4
|
import createLocalProvider from './context/createLocalProvider'
|
|
5
5
|
import { useLocalContext } from './context/localContext'
|
|
6
6
|
import { rocketstyleAttrsHoc } from './hoc'
|
|
@@ -17,6 +17,9 @@ import { createStaticsChainingEnhancers, createStaticsEnhancers } from './utils/
|
|
|
17
17
|
import { calculateStyles } from './utils/styles'
|
|
18
18
|
import { getDimensionThemes, getTheme, getThemeByMode, getThemeFromChain } from './utils/theme'
|
|
19
19
|
|
|
20
|
+
// Dev-time counter sink — see packages/internals/perf-harness for contract.
|
|
21
|
+
const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
|
|
22
|
+
|
|
20
23
|
/**
|
|
21
24
|
* Core rocketstyle component factory. Creates a fully-featured Pyreon component
|
|
22
25
|
* that integrates theme management (with light/dark mode support), multi-tier
|
|
@@ -87,6 +90,49 @@ const rocketComponent: RocketComponent = (options) => {
|
|
|
87
90
|
// --------------------------------------------------------
|
|
88
91
|
const ThemeManager = new LocalThemeManager()
|
|
89
92
|
|
|
93
|
+
// ── Per-definition caches (shared across all instances) ──────────────
|
|
94
|
+
// getDimensionsMap + Object.keys(reservedPropNames) are theme-independent
|
|
95
|
+
// (dimension structure comes from .sizes()/.states()/.variants() chain,
|
|
96
|
+
// not from runtime theme values). Cache them so 50 instances of the same
|
|
97
|
+
// component definition skip the rebuild entirely.
|
|
98
|
+
const _dimensionsCache = new WeakMap<
|
|
99
|
+
object,
|
|
100
|
+
{ keysMap: Record<string, unknown>; keywords: Record<string, true | undefined> }
|
|
101
|
+
>()
|
|
102
|
+
const _reservedKeysCache = new WeakMap<object, string[]>()
|
|
103
|
+
|
|
104
|
+
// Pre-compute merged key arrays once per definition (not per mount)
|
|
105
|
+
const ALL_PSEUDO_KEYS = [...PSEUDO_KEYS, ...PSEUDO_META_KEYS]
|
|
106
|
+
// Static portion of omit keys — PSEUDO_KEYS + filterAttrs + 'pseudo' are definition-scoped.
|
|
107
|
+
// RESERVED_STYLING_PROPS_KEYS is dimension-dependent but also cached per definition.
|
|
108
|
+
// 'pseudo' is included here so we can skip the destructuring spread of mergeProps.
|
|
109
|
+
const STATIC_OMIT_KEYS = ['pseudo', ...PSEUDO_KEYS, ...(options.filterAttrs ?? [])]
|
|
110
|
+
// Pre-built Set for omit() — avoids per-call Set allocation. Built once the
|
|
111
|
+
// dimension-dependent reserved keys are known (first mount), then reused.
|
|
112
|
+
const _omitSetCache = new WeakMap<string[], Set<string>>()
|
|
113
|
+
|
|
114
|
+
// ── Dimension-prop memo (per-definition) ─────────────────────────────
|
|
115
|
+
// Keyed on theme identity → Map<keyString, { rocketstyle, rocketstate }>.
|
|
116
|
+
// The accessors below build a key from (mode, dimension prop tuple,
|
|
117
|
+
// pseudo state tuple) and look up here. On hit they return the SAME
|
|
118
|
+
// object identities for both `$rocketstyle` and `$rocketstate`, which
|
|
119
|
+
// lets the styler's existing `classCache` (keyed on those identities)
|
|
120
|
+
// skip the entire CSS resolve pipeline. On miss they compute fresh
|
|
121
|
+
// and store the result.
|
|
122
|
+
//
|
|
123
|
+
// Why this matters: B-FINDING.md (PR #342) showed every Button mount
|
|
124
|
+
// fires 22 styler.resolve calls even when the styler-sheet cache hits
|
|
125
|
+
// — the cache catches at the LAST step (insert dedup), but the resolve
|
|
126
|
+
// pipeline still runs to compute the hash. Stable accessor identities
|
|
127
|
+
// mean the styler's classCache hits earlier and the resolves don't run.
|
|
128
|
+
//
|
|
129
|
+
// LRU bound prevents unbounded growth from prop-tuple churn (e.g. a
|
|
130
|
+
// table where every cell has a unique state). 32 entries per theme
|
|
131
|
+
// covers ~99% of unique combos in real apps.
|
|
132
|
+
type RsMemoEntry = { readonly rocketstyle: object; readonly rocketstate: object }
|
|
133
|
+
const _rsMemo = new WeakMap<object, Map<string, RsMemoEntry>>()
|
|
134
|
+
const RS_MEMO_CAP = 32
|
|
135
|
+
|
|
90
136
|
// --------------------------------------------------------
|
|
91
137
|
// COMPOSE - high-order components
|
|
92
138
|
// --------------------------------------------------------
|
|
@@ -140,131 +186,218 @@ const rocketComponent: RocketComponent = (options) => {
|
|
|
140
186
|
return helper.get(initialTheme)
|
|
141
187
|
})()
|
|
142
188
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
189
|
+
// Cache getDimensionsMap per dimension-themes identity — all instances
|
|
190
|
+
// of the same component definition share the same dimension structure.
|
|
191
|
+
let dimResult = _dimensionsCache.get(initialDimensionThemes as object)
|
|
192
|
+
if (dimResult) {
|
|
193
|
+
if (process.env.NODE_ENV !== 'production')
|
|
194
|
+
_countSink.__pyreon_count__?.('rocketstyle.dimensionsMap.hit')
|
|
195
|
+
} else {
|
|
196
|
+
dimResult = getDimensionsMap({
|
|
197
|
+
themes: initialDimensionThemes,
|
|
198
|
+
useBooleans: options.useBooleans,
|
|
199
|
+
})
|
|
200
|
+
_dimensionsCache.set(initialDimensionThemes as object, dimResult)
|
|
201
|
+
}
|
|
202
|
+
const { keysMap: dimensions, keywords: reservedPropNames } = dimResult
|
|
147
203
|
|
|
148
|
-
|
|
204
|
+
// Cache Object.keys() result — same dimension structure = same keys
|
|
205
|
+
let RESERVED_STYLING_PROPS_KEYS = _reservedKeysCache.get(reservedPropNames as object)
|
|
206
|
+
if (!RESERVED_STYLING_PROPS_KEYS) {
|
|
207
|
+
RESERVED_STYLING_PROPS_KEYS = Object.keys(reservedPropNames)
|
|
208
|
+
_reservedKeysCache.set(reservedPropNames as object, RESERVED_STYLING_PROPS_KEYS)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Silence "unused" warnings for initialBaseTheme / initialDimensionThemes —
|
|
212
|
+
// they're eagerly populated into ThemeManager caches so the first accessor
|
|
213
|
+
// call hits cache, but not referenced directly.
|
|
214
|
+
void initialBaseTheme
|
|
215
|
+
void initialDimensionThemes
|
|
216
|
+
|
|
217
|
+
// Capture pseudo from localCtx once at setup — pseudo properties are
|
|
218
|
+
// getters (from createLocalProvider) that read signals lazily.
|
|
219
|
+
// Passing them through preserves reactivity without subscribing here.
|
|
220
|
+
const localPseudo = localCtx?.pseudo
|
|
149
221
|
|
|
150
222
|
// --------------------------------------------------
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
// (
|
|
223
|
+
// Shared accessor resolver.
|
|
224
|
+
//
|
|
225
|
+
// Both `$rocketstyleAccessor` and `$rocketstateAccessor` derive from the
|
|
226
|
+
// same input set (theme, mode, dimension props, pseudo state). Folding
|
|
227
|
+
// them into one resolver lets the dimension-prop memo return the SAME
|
|
228
|
+
// object identities for both — which is what the styler's `classCache`
|
|
229
|
+
// (keyed on `(rocketstyle, rocketstate)` identity) needs to skip the
|
|
230
|
+
// resolve pipeline on cache hit.
|
|
231
|
+
//
|
|
232
|
+
// Reactive contract: this runs inside the styler's `computed()` (one per
|
|
233
|
+
// mounted instance). All signal reads — theme, mode, dimension props,
|
|
234
|
+
// pseudo getters from localCtx — are TRACKED, so any change re-runs the
|
|
235
|
+
// computed which re-resolves the entry. Same key → cached entry; new key
|
|
236
|
+
// → fresh computation, stored under LRU cap.
|
|
155
237
|
// --------------------------------------------------
|
|
156
|
-
const
|
|
157
|
-
// Read theme + mode
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
238
|
+
const _resolveRsEntry = (): RsMemoEntry => {
|
|
239
|
+
// Read reactive inputs (tracks theme + mode signals)
|
|
240
|
+
const theme = themeAttrs.theme
|
|
241
|
+
const mode = themeAttrs.mode
|
|
242
|
+
|
|
243
|
+
// Build key: mode | dimensionProps | pseudoState. Reading dimension
|
|
244
|
+
// props + pseudo signals here tracks them in the surrounding computed
|
|
245
|
+
// so any change re-runs us with a different key.
|
|
246
|
+
let key = mode as string
|
|
247
|
+
const propsRec = props as Record<string, unknown>
|
|
248
|
+
for (const dimName in dimensions) {
|
|
249
|
+
const v = propsRec[dimName]
|
|
250
|
+
// String/number/boolean serialize directly. Anything else (including
|
|
251
|
+
// undefined / objects) gets a typeof tag so we don't collide.
|
|
252
|
+
key +=
|
|
253
|
+
'|' +
|
|
254
|
+
(typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'
|
|
255
|
+
? String(v)
|
|
256
|
+
: v === undefined
|
|
257
|
+
? ''
|
|
258
|
+
: '~' + typeof v)
|
|
259
|
+
}
|
|
260
|
+
for (const k of ALL_PSEUDO_KEYS) {
|
|
261
|
+
const propV = propsRec[k]
|
|
262
|
+
const localV = localPseudo?.[k as keyof typeof localPseudo]
|
|
263
|
+
const v = propV !== undefined ? propV : localV
|
|
264
|
+
key += '|' + (v === undefined ? '' : v ? '1' : '0')
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Cache lookup
|
|
268
|
+
let themeMemo = _rsMemo.get(theme as object)
|
|
269
|
+
if (!themeMemo) {
|
|
270
|
+
themeMemo = new Map()
|
|
271
|
+
_rsMemo.set(theme as object, themeMemo)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const cached = themeMemo.get(key)
|
|
275
|
+
if (cached) {
|
|
276
|
+
if (process.env.NODE_ENV !== 'production')
|
|
277
|
+
_countSink.__pyreon_count__?.('rocketstyle.dimensionMemo.hit')
|
|
278
|
+
// LRU touch: move to end so eviction targets oldest unused entry
|
|
279
|
+
themeMemo.delete(key)
|
|
280
|
+
themeMemo.set(key, cached)
|
|
281
|
+
return cached
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Miss: compute fresh. Counter measures actual theme resolutions
|
|
285
|
+
// (not accessor invocations) — see COUNTERS.md.
|
|
286
|
+
if (process.env.NODE_ENV !== 'production')
|
|
287
|
+
_countSink.__pyreon_count__?.('rocketstyle.getTheme')
|
|
163
288
|
|
|
164
289
|
// Resolve base + dimension themes for the CURRENT theme. WeakMap
|
|
165
290
|
// keyed on theme identity — stable-theme renders hit cache in O(1),
|
|
166
291
|
// theme swaps fall through to recompute (once per new theme).
|
|
167
292
|
const baseThemeHelper = ThemeManager.baseTheme
|
|
168
|
-
if (
|
|
293
|
+
if (baseThemeHelper.has(theme)) {
|
|
294
|
+
if (process.env.NODE_ENV !== 'production')
|
|
295
|
+
_countSink.__pyreon_count__?.('rocketstyle.localThemeManager.hit')
|
|
296
|
+
} else {
|
|
169
297
|
baseThemeHelper.set(theme, getThemeFromChain(options.theme, theme))
|
|
170
298
|
}
|
|
171
299
|
const baseTheme = baseThemeHelper.get(theme)
|
|
172
300
|
|
|
173
301
|
const dimHelper = ThemeManager.dimensionsThemes
|
|
174
|
-
if (
|
|
302
|
+
if (dimHelper.has(theme)) {
|
|
303
|
+
if (process.env.NODE_ENV !== 'production')
|
|
304
|
+
_countSink.__pyreon_count__?.('rocketstyle.localThemeManager.hit')
|
|
305
|
+
} else {
|
|
175
306
|
dimHelper.set(theme, getDimensionThemes(theme, options))
|
|
176
307
|
}
|
|
177
308
|
const themes = dimHelper.get(theme)
|
|
178
309
|
|
|
179
310
|
// Resolve active dimensions from props (not localCtx which has pseudo getters)
|
|
180
|
-
const
|
|
181
|
-
props: pickStyledAttrs(
|
|
311
|
+
const rocketstateRaw = _calculateStylingAttrs({
|
|
312
|
+
props: pickStyledAttrs(propsRec, reservedPropNames),
|
|
182
313
|
dimensions,
|
|
183
314
|
})
|
|
184
315
|
|
|
185
316
|
// Resolve mode-specific theme
|
|
186
317
|
const modeBaseHelper = ThemeManager.modeBaseTheme[mode]
|
|
187
|
-
if (
|
|
318
|
+
if (modeBaseHelper.has(baseTheme)) {
|
|
319
|
+
if (process.env.NODE_ENV !== 'production')
|
|
320
|
+
_countSink.__pyreon_count__?.('rocketstyle.localThemeManager.hit')
|
|
321
|
+
} else {
|
|
188
322
|
modeBaseHelper.set(baseTheme, getThemeByMode(baseTheme, mode))
|
|
189
323
|
}
|
|
190
324
|
const currentModeBaseTheme = modeBaseHelper.get(baseTheme)
|
|
191
325
|
|
|
192
326
|
const modeDimHelper = ThemeManager.modeDimensionTheme[mode]
|
|
193
|
-
if (
|
|
327
|
+
if (modeDimHelper.has(themes)) {
|
|
328
|
+
if (process.env.NODE_ENV !== 'production')
|
|
329
|
+
_countSink.__pyreon_count__?.('rocketstyle.localThemeManager.hit')
|
|
330
|
+
} else {
|
|
194
331
|
modeDimHelper.set(themes, getThemeByMode(themes, mode))
|
|
195
332
|
}
|
|
196
333
|
const currentModeThemes = modeDimHelper.get(themes)
|
|
197
334
|
|
|
198
|
-
|
|
199
|
-
rocketstate,
|
|
335
|
+
const rocketstyle = getTheme({
|
|
336
|
+
rocketstate: rocketstateRaw,
|
|
200
337
|
themes: currentModeThemes,
|
|
201
338
|
baseTheme: currentModeBaseTheme,
|
|
202
339
|
transformKeys: options.transformKeys,
|
|
203
340
|
appTheme: theme,
|
|
204
341
|
})
|
|
205
|
-
}
|
|
206
342
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
// --------------------------------------------------
|
|
214
|
-
// $rocketstate as a FUNCTION ACCESSOR — reactive on prop changes.
|
|
215
|
-
// Re-evaluates active dimensions + pseudo state from current props.
|
|
216
|
-
// --------------------------------------------------
|
|
217
|
-
// Capture pseudo from localCtx once at setup — pseudo properties are
|
|
218
|
-
// getters (from createLocalProvider) that read signals lazily.
|
|
219
|
-
// Passing them through preserves reactivity without subscribing here.
|
|
220
|
-
const localPseudo = localCtx?.pseudo
|
|
221
|
-
|
|
222
|
-
const $rocketstateAccessor = () => {
|
|
223
|
-
const rocketstate = _calculateStylingAttrs({
|
|
224
|
-
props: pickStyledAttrs(props as Record<string, unknown>, reservedPropNames),
|
|
225
|
-
dimensions,
|
|
226
|
-
})
|
|
227
|
-
|
|
228
|
-
// Read pseudo props fresh each call — props may have reactive getters
|
|
229
|
-
// from _rp() wrapping. Reading inside the accessor (which runs in an
|
|
230
|
-
// effect) ensures changes to pseudo props like active={isDark()} are tracked.
|
|
231
|
-
const propPseudo = pick(props, [...PSEUDO_KEYS, ...PSEUDO_META_KEYS])
|
|
232
|
-
|
|
233
|
-
return {
|
|
234
|
-
...rocketstate,
|
|
343
|
+
// $rocketstate carries dimension state + pseudo flags so the styler
|
|
344
|
+
// emits matching pseudo selectors (`:hover`, `:focus`, etc.).
|
|
345
|
+
const propPseudo = pick(propsRec, ALL_PSEUDO_KEYS)
|
|
346
|
+
const rocketstate = {
|
|
347
|
+
...rocketstateRaw,
|
|
235
348
|
pseudo: { ...localPseudo, ...propPseudo },
|
|
236
349
|
}
|
|
237
|
-
}
|
|
238
350
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
351
|
+
// LRU eviction at cap — drop the oldest (first-inserted) entry.
|
|
352
|
+
if (themeMemo.size >= RS_MEMO_CAP) {
|
|
353
|
+
const oldestKey = themeMemo.keys().next().value
|
|
354
|
+
if (oldestKey !== undefined) themeMemo.delete(oldestKey)
|
|
355
|
+
}
|
|
356
|
+
const entry: RsMemoEntry = { rocketstyle, rocketstate }
|
|
357
|
+
themeMemo.set(key, entry)
|
|
358
|
+
return entry
|
|
245
359
|
}
|
|
246
360
|
|
|
361
|
+
const $rocketstyleAccessor = () => _resolveRsEntry().rocketstyle
|
|
362
|
+
const $rocketstateAccessor = () => _resolveRsEntry().rocketstate
|
|
363
|
+
|
|
247
364
|
// --------------------------------------------------
|
|
248
365
|
// final props passed to WrappedComponent
|
|
249
366
|
// --------------------------------------------------
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
// mode/dimension changes produce a new CSS class reactively. The
|
|
260
|
-
// computed tracks only these two accessors; the resolve itself runs
|
|
261
|
-
// untracked to prevent exponential cascade from theme deep-reads.
|
|
262
|
-
$rocketstyle: $rocketstyleAccessor,
|
|
263
|
-
$rocketstate: $rocketstateAccessor,
|
|
367
|
+
// Cache a pre-built Set for omit() — avoids building a new Set from
|
|
368
|
+
// the key array on every mount. Same dimension structure = same Set.
|
|
369
|
+
let omitSet = _omitSetCache.get(RESERVED_STYLING_PROPS_KEYS)
|
|
370
|
+
if (omitSet) {
|
|
371
|
+
if (process.env.NODE_ENV !== 'production')
|
|
372
|
+
_countSink.__pyreon_count__?.('rocketstyle.omitSet.hit')
|
|
373
|
+
} else {
|
|
374
|
+
omitSet = new Set([...RESERVED_STYLING_PROPS_KEYS, ...STATIC_OMIT_KEYS])
|
|
375
|
+
_omitSetCache.set(RESERVED_STYLING_PROPS_KEYS, omitSet)
|
|
264
376
|
}
|
|
265
377
|
|
|
266
|
-
//
|
|
267
|
-
|
|
378
|
+
// Merge localCtx + props without an intermediate spread object.
|
|
379
|
+
// omit() handles 'pseudo' removal (included in STATIC_OMIT_KEYS).
|
|
380
|
+
const mergeProps = localCtx ? { ...localCtx, ...props } : props
|
|
381
|
+
|
|
382
|
+
// omit() already returns a fresh object — assign directly onto it
|
|
383
|
+
// instead of spreading into another {} (saves one object allocation).
|
|
384
|
+
const finalProps = omit(mergeProps as Record<string, unknown>, omitSet) as Record<string, any>
|
|
385
|
+
|
|
386
|
+
if (options.passProps) {
|
|
387
|
+
const passed = pick(mergeProps, options.passProps)
|
|
388
|
+
for (const k in passed) finalProps[k] = passed[k]
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
finalProps.ref = props.ref
|
|
392
|
+
// Function accessors — DynamicStyled wraps them in a computed() so
|
|
393
|
+
// mode/dimension changes produce a new CSS class reactively. The
|
|
394
|
+
// computed tracks only these two accessors; the resolve itself runs
|
|
395
|
+
// untracked to prevent exponential cascade from theme deep-reads.
|
|
396
|
+
finalProps.$rocketstyle = $rocketstyleAccessor
|
|
397
|
+
finalProps.$rocketstate = $rocketstateAccessor
|
|
398
|
+
|
|
399
|
+
// development debugging — tree-shaken in production via import.meta.env.DEV
|
|
400
|
+
if (__DEV__) {
|
|
268
401
|
finalProps['data-rocketstyle'] = componentName
|
|
269
402
|
|
|
270
403
|
if (options.DEBUG) {
|
|
@@ -326,6 +459,26 @@ const rocketComponent: RocketComponent = (options) => {
|
|
|
326
459
|
options: options.statics,
|
|
327
460
|
})
|
|
328
461
|
|
|
462
|
+
// ─── Hoisted attrs chain (T3.1) ──────────────────────────────────────
|
|
463
|
+
//
|
|
464
|
+
// Expose the accumulated `.attrs()` callback chain on the component so
|
|
465
|
+
// external inspectors (notably `extractDocumentTree` from
|
|
466
|
+
// `@pyreon/connector-document`) can compute the post-attrs props
|
|
467
|
+
// without invoking the full component. The previous Path B workaround
|
|
468
|
+
// had to run the entire styled wrapper — JSX tree creation, dimension
|
|
469
|
+
// resolution, the lot — just to read `_documentProps` off the result.
|
|
470
|
+
//
|
|
471
|
+
// Typed surface: `RocketStyleComponent.__rs_attrs` is a `readonly
|
|
472
|
+
// ReadonlyArray<(props) => Record<string, unknown>>`. Empty when
|
|
473
|
+
// no `.attrs()` was ever called. `chain.reduce(Object.assign, {})`
|
|
474
|
+
// produces the post-attrs result for a given props bag.
|
|
475
|
+
//
|
|
476
|
+
// The `readonly` modifier guards external CONSUMERS — internal
|
|
477
|
+
// assignment from the factory itself is the only legitimate write,
|
|
478
|
+
// hence the cast. Do not drop the readonly on the type.
|
|
479
|
+
;(FinalComponent as unknown as { __rs_attrs: typeof options.attrs }).__rs_attrs =
|
|
480
|
+
options.attrs ?? []
|
|
481
|
+
|
|
329
482
|
Object.assign(FinalComponent, {
|
|
330
483
|
attrs: (attrs: any, { priority, filter }: any = {}) => {
|
|
331
484
|
const result: Record<string, any> = {}
|
package/src/types/rocketstyle.ts
CHANGED
|
@@ -153,4 +153,19 @@ export interface IRocketStyleComponent<
|
|
|
153
153
|
|
|
154
154
|
IS_ROCKETSTYLE: true
|
|
155
155
|
displayName: string
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The accumulated `.attrs()` callback chain — hoisted at component-creation
|
|
159
|
+
* time so external inspectors (notably `extractDocumentTree` from
|
|
160
|
+
* `@pyreon/connector-document`) can compute post-attrs props without
|
|
161
|
+
* invoking the full styled wrapper. Each callback maps user props to a
|
|
162
|
+
* partial props object; `chain.reduce(Object.assign, {})` produces the
|
|
163
|
+
* post-attrs result.
|
|
164
|
+
*
|
|
165
|
+
* Stable contract — consumers can rely on this property being present on
|
|
166
|
+
* every rocketstyle-wrapped component. Empty array when no `.attrs()`
|
|
167
|
+
* was ever called on the chain. Treat as read-only; mutating breaks
|
|
168
|
+
* `extractDocumentTree` and any other inspector consuming the hoist.
|
|
169
|
+
*/
|
|
170
|
+
readonly __rs_attrs: ReadonlyArray<(props: Record<string, unknown>) => Record<string, unknown>>
|
|
156
171
|
}
|
package/src/utils/attrs.ts
CHANGED
|
@@ -80,53 +80,49 @@ export const calculateStylingAttrs: CalculateStylingAttrs =
|
|
|
80
80
|
const result: Record<string, any> = {}
|
|
81
81
|
|
|
82
82
|
// (1) find dimension keys values & initialize
|
|
83
|
-
|
|
84
|
-
Object.keys(dimensions).forEach((item) => {
|
|
83
|
+
for (const item in dimensions) {
|
|
85
84
|
const pickedProp = props[item]
|
|
86
85
|
const t = typeof pickedProp
|
|
87
86
|
|
|
88
|
-
// if the property is multi key, allow assign array as well
|
|
89
87
|
if (multiKeys?.[item] && Array.isArray(pickedProp)) {
|
|
90
88
|
result[item] = pickedProp
|
|
91
|
-
}
|
|
92
|
-
// assign when it's only a string or number otherwise it's considered
|
|
93
|
-
// as invalid param
|
|
94
|
-
else if (t === 'string' || t === 'number') {
|
|
89
|
+
} else if (t === 'string' || t === 'number') {
|
|
95
90
|
result[item] = pickedProp
|
|
96
91
|
} else {
|
|
97
92
|
result[item] = undefined
|
|
98
93
|
}
|
|
99
|
-
}
|
|
94
|
+
}
|
|
100
95
|
|
|
101
96
|
// (2) if booleans are being used let's find the rest
|
|
97
|
+
// Use `in` operator on the dimension map instead of allocating
|
|
98
|
+
// a new Set per dimension — the map is already an object with
|
|
99
|
+
// the keywords as keys.
|
|
102
100
|
if (useBooleans) {
|
|
103
|
-
const
|
|
101
|
+
for (const key in result) {
|
|
102
|
+
if (result[key]) continue // already assigned
|
|
104
103
|
|
|
105
|
-
|
|
104
|
+
const dimensionMap = dimensions[key] as Record<string, unknown>
|
|
106
105
|
const isMultiKey = multiKeys?.[key]
|
|
106
|
+
let newDimensionValue: string | string[] | undefined
|
|
107
107
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (keywordSet.has(k) && props[k]) {
|
|
121
|
-
newDimensionValue = k
|
|
122
|
-
break
|
|
123
|
-
}
|
|
108
|
+
if (isMultiKey) {
|
|
109
|
+
const matches: string[] = []
|
|
110
|
+
for (const propKey in props) {
|
|
111
|
+
if (propKey in dimensionMap) matches.push(propKey)
|
|
112
|
+
}
|
|
113
|
+
newDimensionValue = matches.length > 0 ? matches : undefined
|
|
114
|
+
} else {
|
|
115
|
+
// Iterate props to find last matching keyword
|
|
116
|
+
// (last wins for priority)
|
|
117
|
+
for (const k in props) {
|
|
118
|
+
if (k in dimensionMap && props[k]) {
|
|
119
|
+
newDimensionValue = k
|
|
124
120
|
}
|
|
125
121
|
}
|
|
126
|
-
|
|
127
|
-
result[key] = newDimensionValue
|
|
128
122
|
}
|
|
129
|
-
|
|
123
|
+
|
|
124
|
+
result[key] = newDimensionValue
|
|
125
|
+
}
|
|
130
126
|
}
|
|
131
127
|
|
|
132
128
|
return result
|