@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.
@@ -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
  })
@@ -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
 
@@ -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
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Minimal process type — just enough for `process.env.NODE_ENV` checks.
3
+ * Avoids requiring @types/node in consumers that import pyreon source
4
+ * via the `"bun"` export condition.
5
+ */
6
+ declare var process: { env: { NODE_ENV?: string } }
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 (process.env.NODE_ENV !== 'production') {
76
+ if (__DEV__) {
77
77
  validateInit(name, component, dimensions)
78
78
  }
79
79
 
@@ -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
- const { keysMap: dimensions, keywords: reservedPropNames } = getDimensionsMap({
144
- themes: initialDimensionThemes,
145
- useBooleans: options.useBooleans,
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
- const RESERVED_STYLING_PROPS_KEYS = Object.keys(reservedPropNames)
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
- // $rocketstyle as a FUNCTION ACCESSOR — fully reactive.
152
- // Re-evaluates when THEME, MODE, or dimension props change.
153
- // Props are resolved fresh each call so reactive prop accessors
154
- // (signals, getters) produce updated dimension values.
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 $rocketstyleAccessor = () => {
157
- // Read theme + mode LAZILY via the getter-backed themeAttrs object.
158
- // Both reads are tracked when this accessor runs inside a reactive
159
- // scope (styler's effect), so theme swap / mode toggle re-runs the
160
- // surrounding resolver and swaps the generated class.
161
- const theme = themeAttrs.theme // reactive: tracks theme signal
162
- const mode = themeAttrs.mode // reactive: tracks mode signal
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 (!baseThemeHelper.has(theme)) {
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 (!dimHelper.has(theme)) {
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 rocketstate = _calculateStylingAttrs({
181
- props: pickStyledAttrs(props as Record<string, unknown>, reservedPropNames),
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 (!modeBaseHelper.has(baseTheme)) {
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 (!modeDimHelper.has(themes)) {
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
- return getTheme({
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
- // Silence "unused" warnings for initialBaseTheme / initialDimensionThemes
208
- // they're eagerly populated into ThemeManager caches so the first accessor
209
- // call hits cache, but not referenced directly.
210
- void initialBaseTheme
211
- void initialDimensionThemes
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
- // Static mergeProps for final prop filtering (non-dimension props)
241
- // --------------------------------------------------
242
- const { pseudo: _pseudo, ...mergeProps } = {
243
- ...localCtx,
244
- ...props,
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
- const finalProps: Record<string, any> = {
251
- ...omit(mergeProps, [
252
- ...RESERVED_STYLING_PROPS_KEYS,
253
- ...PSEUDO_KEYS,
254
- ...options.filterAttrs,
255
- ]),
256
- ...(options.passProps ? pick(mergeProps, options.passProps) : {}),
257
- ref: props.ref,
258
- // Function accessors — DynamicStyled wraps them in a computed() so
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
- // development debugging
267
- if (process.env.NODE_ENV !== 'production') {
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> = {}
@@ -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
  }
@@ -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
- // object with possible options
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 propsKeys = Object.keys(props)
101
+ for (const key in result) {
102
+ if (result[key]) continue // already assigned
104
103
 
105
- Object.entries(result).forEach(([key, value]) => {
104
+ const dimensionMap = dimensions[key] as Record<string, unknown>
106
105
  const isMultiKey = multiKeys?.[key]
106
+ let newDimensionValue: string | string[] | undefined
107
107
 
108
- // when value in result is not assigned yet
109
- if (!value) {
110
- let newDimensionValue: string | string[] | undefined
111
- const keywordSet = new Set(Object.keys(dimensions[key] as Record<string, unknown>))
112
-
113
- if (isMultiKey) {
114
- newDimensionValue = propsKeys.filter((propKey) => keywordSet.has(propKey))
115
- } else {
116
- // iterate backwards to guarantee the last one will have
117
- // a priority over previous ones
118
- for (let i = propsKeys.length - 1; i >= 0; i--) {
119
- const k = propsKeys[i] as string
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