@pyreon/rocketstyle 0.15.0 → 0.18.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/src/index.ts CHANGED
@@ -4,7 +4,7 @@ import type { Rocketstyle } from './init'
4
4
  import rocketstyle from './init'
5
5
  import type { IsRocketComponent } from './isRocketComponent'
6
6
  import isRocketComponent from './isRocketComponent'
7
- import type { AttrsCb } from './types/attrs'
7
+ import type { AttrsCb, AttrsHelpers } from './types/attrs'
8
8
  import type {
9
9
  ConfigAttrs,
10
10
  ConsumerCb,
@@ -37,6 +37,7 @@ import type { ComponentFn, ElementType, ExtractProps, MergeTypes, TObj } from '.
37
37
 
38
38
  export type {
39
39
  AttrsCb,
40
+ AttrsHelpers,
40
41
  ComponentFn,
41
42
  ComposeParam,
42
43
  ConfigAttrs,
@@ -9,7 +9,12 @@ import type { Configuration, ExtendedConfiguration } from './types/configuration
9
9
  import type { RocketComponent } from './types/rocketComponent'
10
10
  import type { InnerComponentProps, RocketStyleComponent } from './types/rocketstyle'
11
11
  import type { ComponentFn } from './types/utils'
12
- import { calculateChainOptions, calculateStylingAttrs, pickStyledAttrs } from './utils/attrs'
12
+ import {
13
+ calculateChainOptions,
14
+ calculateStylingAttrs,
15
+ mergeDescriptors,
16
+ pickStyledAttrs,
17
+ } from './utils/attrs'
13
18
  import { chainOptions, chainOrOptions, chainReservedKeyOptions } from './utils/chaining'
14
19
  import { calculateHocsFuncs } from './utils/compose'
15
20
  import { getDimensionsMap } from './utils/dimensions'
@@ -38,18 +43,42 @@ type CloneAndEnhance = (
38
43
  opts: Partial<ExtendedConfiguration>,
39
44
  ) => ReturnType<typeof rocketComponent>
40
45
 
41
- /** Clones the current configuration and merges new options, returning a fresh rocketComponent. */
42
- const cloneAndEnhance: CloneAndEnhance = (defaultOpts, opts) =>
43
- rocketComponent({
46
+ /**
47
+ * Clones the current configuration and merges new options, returning a fresh
48
+ * rocketComponent.
49
+ *
50
+ * Component-swap reset: when `opts.component` is set AND differs from the
51
+ * current `defaultOpts.component`, the prior `attrs`, `priorityAttrs`,
52
+ * `filterAttrs`, and `compose` chains are dropped — they were tailored to the
53
+ * previous component's prop shape, and applying them to a different component
54
+ * silently leaks invalid props through to the DOM (e.g. `disabled` on an
55
+ * `<a>`). Callers who want to preserve them must re-chain explicitly:
56
+ *
57
+ * const NewBtn = Button.config({ component: 'a' }).attrs(sharedAttrs)
58
+ */
59
+ const cloneAndEnhance: CloneAndEnhance = (defaultOpts, opts) => {
60
+ const componentChanged =
61
+ opts.component != null && opts.component !== defaultOpts.component
62
+
63
+ return rocketComponent({
44
64
  ...defaultOpts,
45
- attrs: chainOptions(opts.attrs, defaultOpts.attrs),
46
- filterAttrs: [...(defaultOpts.filterAttrs ?? []), ...(opts.filterAttrs ?? [])],
47
- priorityAttrs: chainOptions(opts.priorityAttrs, defaultOpts.priorityAttrs),
65
+ attrs: componentChanged
66
+ ? chainOptions(opts.attrs, [])
67
+ : chainOptions(opts.attrs, defaultOpts.attrs),
68
+ filterAttrs: componentChanged
69
+ ? [...(opts.filterAttrs ?? [])]
70
+ : [...(defaultOpts.filterAttrs ?? []), ...(opts.filterAttrs ?? [])],
71
+ priorityAttrs: componentChanged
72
+ ? chainOptions(opts.priorityAttrs, [])
73
+ : chainOptions(opts.priorityAttrs, defaultOpts.priorityAttrs),
48
74
  statics: { ...defaultOpts.statics, ...opts.statics },
49
- compose: { ...defaultOpts.compose, ...opts.compose },
75
+ compose: componentChanged
76
+ ? { ...opts.compose }
77
+ : { ...defaultOpts.compose, ...opts.compose },
50
78
  ...chainOrOptions(CONFIG_KEYS, opts, defaultOpts),
51
79
  ...chainReservedKeyOptions([...defaultOpts.dimensionKeys, ...STYLING_KEYS], opts, defaultOpts),
52
80
  } as Parameters<typeof rocketComponent>[0])
81
+ }
53
82
 
54
83
  // --------------------------------------------------------
55
84
  // rocketComponent
@@ -239,23 +268,46 @@ const rocketComponent: RocketComponent = (options) => {
239
268
  // Read reactive inputs (tracks theme + mode signals)
240
269
  const theme = themeAttrs.theme
241
270
  const mode = themeAttrs.mode
271
+ const propsRec = props as Record<string, unknown>
272
+
273
+ // Resolve active dimensions FIRST so the cache key uses the RESOLVED
274
+ // dimension values, not the raw prop names. Under `useBooleans: true`
275
+ // the user writes `<X primary />` / `<X secondary />` — both map to
276
+ // `state="primary"` / `state="secondary"` after _calculateStylingAttrs
277
+ // resolves the boolean shorthand. Keying off `propsRec[dimName]` would
278
+ // read `undefined` for both (the dimension prop itself was never set)
279
+ // and collide every variant onto the first cached entry. Reading
280
+ // `rocketstateRaw[dimName]` gives the resolved string and partitions
281
+ // them correctly.
282
+ // Resolved from props (not localCtx which has pseudo getters).
283
+ const rocketstateRaw = _calculateStylingAttrs({
284
+ props: pickStyledAttrs(propsRec, reservedPropNames),
285
+ dimensions,
286
+ })
242
287
 
243
- // Build key: mode | dimensionProps | pseudoState. Reading dimension
288
+ // Build key: mode | dimensionValues | pseudoState. Reading dimension
244
289
  // props + pseudo signals here tracks them in the surrounding computed
245
290
  // so any change re-runs us with a different key.
246
291
  let key = mode as string
247
- const propsRec = props as Record<string, unknown>
248
292
  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)
293
+ const v = rocketstateRaw[dimName]
294
+ // Multi-key dimensions (e.g. variant={['primary', 'rounded']}) are
295
+ // arrays. Sort + join so equivalent sets hash identically; without
296
+ // this both `['a','b']` and `['b','a']` would produce different keys.
297
+ if (Array.isArray(v)) {
298
+ key +=
299
+ '|' + (v.length === 0 ? '' : (v as unknown[]).slice().sort().join(','))
300
+ } else {
301
+ // String/number/boolean serialize directly. Anything else (including
302
+ // undefined / objects) gets a typeof tag so we don't collide.
303
+ key +=
304
+ '|' +
305
+ (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'
306
+ ? String(v)
307
+ : v === undefined
308
+ ? ''
309
+ : '~' + typeof v)
310
+ }
259
311
  }
260
312
  for (const k of ALL_PSEUDO_KEYS) {
261
313
  const propV = propsRec[k]
@@ -307,12 +359,6 @@ const rocketComponent: RocketComponent = (options) => {
307
359
  }
308
360
  const themes = dimHelper.get(theme)
309
361
 
310
- // Resolve active dimensions from props (not localCtx which has pseudo getters)
311
- const rocketstateRaw = _calculateStylingAttrs({
312
- props: pickStyledAttrs(propsRec, reservedPropNames),
313
- dimensions,
314
- })
315
-
316
362
  // Resolve mode-specific theme
317
363
  const modeBaseHelper = ThemeManager.modeBaseTheme[mode]
318
364
  if (modeBaseHelper.has(baseTheme)) {
@@ -375,30 +421,68 @@ const rocketComponent: RocketComponent = (options) => {
375
421
  _omitSetCache.set(RESERVED_STYLING_PROPS_KEYS, omitSet)
376
422
  }
377
423
 
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
424
+ // Merge localCtx + props via descriptor-copy so reactive getter
425
+ // props on `props` (compiler-emitted `_rp(() => signal())` wrappers
426
+ // converted to getters by `makeReactiveProps`) survive the merge.
427
+ // A plain `{ ...localCtx, ...props }` spread would fire every getter
428
+ // and collapse to static values, defeating reactivity for any
429
+ // downstream JSX accessor reading `props.x`.
430
+ const mergeProps = localCtx ? mergeDescriptors(localCtx, props) : props
381
431
 
382
- // omit() already returns a fresh object assign directly onto it
383
- // instead of spreading into another {} (saves one object allocation).
432
+ // omit() preserves descriptors (since ui-core's omit was updated to
433
+ // copy descriptors), so reactive getters carry through to finalProps.
384
434
  const finalProps = omit(mergeProps as Record<string, unknown>, omitSet) as Record<string, any>
385
435
 
386
436
  if (options.passProps) {
387
437
  const passed = pick(mergeProps, options.passProps)
388
- for (const k in passed) finalProps[k] = passed[k]
438
+ // Copy descriptors so any reactive getters in passProps survive.
439
+ // Plain `finalProps[k] = passed[k]` would fire getters at setup time
440
+ // AND silently fail when finalProps[k] is already a getter-only
441
+ // descriptor (assignment to a getter-only property is a no-op in
442
+ // non-strict mode, throws in strict mode).
443
+ const passedDescriptors = Object.getOwnPropertyDescriptors(passed)
444
+ for (const k of Object.keys(passedDescriptors)) {
445
+ Object.defineProperty(finalProps, k, passedDescriptors[k]!)
446
+ }
389
447
  }
390
448
 
391
- finalProps.ref = props.ref
449
+ // Use defineProperty for these last writes too — if props.ref or
450
+ // an existing finalProps slot happened to carry a getter-only
451
+ // descriptor, plain assignment would silently fail. defineProperty
452
+ // explicitly replaces the descriptor regardless of shape.
453
+ const refDescriptor = Object.getOwnPropertyDescriptor(props, 'ref')
454
+ if (refDescriptor) {
455
+ Object.defineProperty(finalProps, 'ref', refDescriptor)
456
+ }
392
457
  // Function accessors — DynamicStyled wraps them in a computed() so
393
458
  // mode/dimension changes produce a new CSS class reactively. The
394
459
  // computed tracks only these two accessors; the resolve itself runs
395
460
  // untracked to prevent exponential cascade from theme deep-reads.
396
- finalProps.$rocketstyle = $rocketstyleAccessor
397
- finalProps.$rocketstate = $rocketstateAccessor
461
+ Object.defineProperty(finalProps, '$rocketstyle', {
462
+ value: $rocketstyleAccessor,
463
+ writable: true,
464
+ enumerable: true,
465
+ configurable: true,
466
+ })
467
+ Object.defineProperty(finalProps, '$rocketstate', {
468
+ value: $rocketstateAccessor,
469
+ writable: true,
470
+ enumerable: true,
471
+ configurable: true,
472
+ })
398
473
 
399
474
  // development debugging — tree-shaken in production via import.meta.env.DEV
400
475
  if (__DEV__) {
401
- finalProps['data-rocketstyle'] = componentName
476
+ // defineProperty rather than `=` to be safe against any preserved
477
+ // descriptor in this slot (defense-in-depth — `data-rocketstyle`
478
+ // is unlikely to be passed as a user prop, but the writes above
479
+ // use defineProperty for the same reason).
480
+ Object.defineProperty(finalProps, 'data-rocketstyle', {
481
+ value: componentName,
482
+ writable: true,
483
+ enumerable: true,
484
+ configurable: true,
485
+ })
402
486
 
403
487
  if (options.DEBUG) {
404
488
  const debugPayload = {
@@ -529,8 +613,8 @@ const rocketComponent: RocketComponent = (options) => {
529
613
  {
530
614
  render,
531
615
  mode,
532
- isDark: mode === 'light',
533
- isLight: mode === 'dark',
616
+ isDark: mode === 'dark',
617
+ isLight: mode === 'light',
534
618
  },
535
619
  ]),
536
620
  })
@@ -1,13 +1,23 @@
1
1
  import type { render } from '@pyreon/ui-core'
2
2
  import type { ThemeModeKeys } from './theme'
3
3
 
4
- export type AttrsCb<A, T> = (
5
- props: Partial<A>,
6
- theme: T,
7
- helpers: {
8
- mode?: ThemeModeKeys
9
- isDark?: boolean
10
- isLight?: boolean
11
- createElement: typeof render
12
- },
13
- ) => Partial<A>
4
+ /** Helpers object passed as the 3rd arg to every `.attrs(callback)`. */
5
+ export type AttrsHelpers = {
6
+ mode?: ThemeModeKeys
7
+ isDark?: boolean
8
+ isLight?: boolean
9
+ createElement: typeof render
10
+ }
11
+
12
+ /**
13
+ * Callback signature for `.attrs((props, theme, helpers) => …)`.
14
+ *
15
+ * `Partial<A>` on the return is for the strict-typing form when callers
16
+ * pass `AttrsCb<DFP, Theme<T>>` directly. In the rocketstyle `.attrs()`
17
+ * callback overload itself we use a different shape that decouples the
18
+ * props arg (narrow, full DFP) from the return type (loose — only the
19
+ * user's explicit `<P>` generic is checked, with `Record<string,
20
+ * unknown>` allowing runtime extras like `_documentProps`). See
21
+ * `IRocketStyleComponent.attrs` for the call-site shape.
22
+ */
23
+ export type AttrsCb<A, T> = (props: Partial<A>, theme: T, helpers: AttrsHelpers) => Partial<A>
@@ -1,5 +1,5 @@
1
1
  import type { VNodeChild } from '@pyreon/core'
2
- import type { AttrsCb } from './attrs'
2
+ import type { AttrsHelpers } from './attrs'
3
3
  import type { ConfigAttrs } from './config'
4
4
  import type { DefaultProps } from './configuration'
5
5
  import type {
@@ -76,7 +76,27 @@ export interface IRocketStyleComponent<
76
76
  // dimension key props
77
77
  DKP extends TDKP = TDKP,
78
78
  // calculated final props
79
- DFP = MergeTypes<[OA, EA, DefaultProps, ExtractDimensionProps<D, DKP, UB>]>,
79
+ //
80
+ // `OA extends infer O` distributes over OA's union branches — future-proofs
81
+ // against the overload-aware `ExtractProps` (follow-up PR); for today's
82
+ // single-typed OA the distribution collapses to a single branch.
83
+ //
84
+ // **OA-overlapping EA keys are widened**. When `.attrs({ tag: 'a' })` sets
85
+ // a default for `tag` on a wrapper whose OA has `tag: HTMLTags`, we strip
86
+ // `tag` from OA and re-add it as `Partial<Pick<O, 'tag'>>` — taking O's
87
+ // WIDER type (`HTMLTags`), not EA's narrow literal `'a'`. The runtime
88
+ // default is `'a'`; the JSX call site still accepts any `HTMLTags` value
89
+ // (so `<Btn tag="span" />` is valid). `Partial<Omit<EA, keyof O>>` handles
90
+ // net-new EA-only keys (no OA conflict) and marks them optional too —
91
+ // every `.attrs()` value is semantically a default, never required of the
92
+ // consumer. Mirrors vitus-labs/ui-system PR #225.
93
+ DFP = OA extends infer O
94
+ ? Omit<O, keyof EA & keyof O> &
95
+ Partial<Pick<O, keyof EA & keyof O>> &
96
+ MergeTypes<
97
+ [Partial<Omit<EA, keyof O>>, DefaultProps, ExtractDimensionProps<D, DKP, UB>]
98
+ >
99
+ : never,
80
100
  > {
81
101
  // The component is callable — Pyreon components are plain functions
82
102
  (props: DFP): VNodeChild
@@ -95,23 +115,52 @@ export interface IRocketStyleComponent<
95
115
  : RocketStyleComponent<OA, EA, T, CSS, S, HOC, D, UB, DKP>
96
116
 
97
117
  // ATTRS chaining method
98
- attrs: <P extends TObj | unknown = unknown>(
99
- param: P extends TObj
100
- ?
101
- | Partial<DFP & P>
102
- | ((
103
- props: Partial<DFP & P>,
104
- theme: Theme<T>,
105
- helpers: any,
106
- ) => Partial<P> & Record<string, unknown>)
107
- : Partial<DFP> | AttrsCb<DFP, Theme<T>>,
108
- config?: Partial<{
109
- priority: boolean
110
- filter: P extends TObj ? Partial<keyof (EA & P)>[] : Partial<keyof EA>[]
111
- }>,
112
- ) => P extends TObj
113
- ? RocketStyleComponent<OA, MergeTypes<[EA, P]>, T, CSS, S, HOC, D, UB, DKP>
114
- : RocketStyleComponent<OA, EA, T, CSS, S, HOC, D, UB, DKP>
118
+ //
119
+ // Two overloads. TS resolves in declaration order; the callback form is
120
+ // listed FIRST so a function argument matches it before falling through to
121
+ // the object form. Mirrors vitus-labs/ui-system PR #227 Issue 2 (callback
122
+ // overload split).
123
+ //
124
+ // **Callback overload — asymmetric props/return**: the `props` arg is
125
+ // typed strictly as `Partial<DFP & P>` (so reading `props.x` narrows
126
+ // against the wrapped component's full surface), while the RETURN is
127
+ // `Partial<P> & Record<string, unknown>` — only the user's explicit `<P>`
128
+ // generic is checked, with a wildcard for runtime extras. This preserves
129
+ // Pyreon's documented convention where `.attrs(callback)` returns can
130
+ // carry runtime-only fields (`_documentProps` markers for the document-
131
+ // export pipeline, `tag: 'a'` overrides on Text-based components where
132
+ // the rendered DOM tag is outside the component's narrow `tag` union,
133
+ // etc.). The runtime is loose by design `.attrs(cb, { filter })`
134
+ // strips keys before forwarding to the DOM. Consumers wanting strict
135
+ // typed extras pass `<P>` with the precise keys they expect.
136
+ //
137
+ // **Object overload — `P & Partial<NoInfer<DFP>>`**: TS infers P from
138
+ // the param's keys. `NoInfer<DFP>` (TS 5.4+) prevents DFP from
139
+ // contributing to P inference. Combined with DFP widening above, this
140
+ // makes `.attrs({ tag: 'a' })` keys optional at the JSX call site —
141
+ // every `.attrs()` value is a default, not a required prop. Mirrors
142
+ // vitus-labs PR #225.
143
+ attrs: {
144
+ <P extends TObj = {}>(
145
+ param: (
146
+ props: Partial<DFP & P>,
147
+ theme: Theme<T>,
148
+ helpers: AttrsHelpers,
149
+ ) => Partial<P> & Record<string, unknown>,
150
+ config?: Partial<{
151
+ priority: boolean
152
+ filter: (keyof MergeTypes<[EA, P]>)[]
153
+ }>,
154
+ ): RocketStyleComponent<OA, MergeTypes<[EA, P]>, T, CSS, S, HOC, D, UB, DKP>
155
+
156
+ <P extends TObj = {}>(
157
+ param: P & Partial<NoInfer<DFP>>,
158
+ config?: Partial<{
159
+ priority: boolean
160
+ filter: (keyof MergeTypes<[EA, P]>)[]
161
+ }>,
162
+ ): RocketStyleComponent<OA, MergeTypes<[EA, P]>, T, CSS, S, HOC, D, UB, DKP>
163
+ }
115
164
 
116
165
  // THEME chaining method
117
166
  theme: <P extends TObj = TObj>(
@@ -4,7 +4,7 @@ import type { TObj } from './utils'
4
4
 
5
5
  export interface StylesDefault {}
6
6
 
7
- export type Styles<S = unknown> = StylesDefault
7
+ export type Styles<_S = unknown> = StylesDefault
8
8
 
9
9
  export type Css = typeof config.css
10
10
  export type Style = ReturnType<Css>
@@ -51,5 +51,48 @@ export type Spread<A extends readonly [...any]> = A extends [infer L, ...infer R
51
51
  export type MergeTypes<A extends readonly [...any]> = ExtractNullableKeys<Spread<A>>
52
52
 
53
53
  // ─── ExtractProps ─────────────────────────────────────────────
54
- export type ExtractProps<TComponentOrTProps> =
55
- TComponentOrTProps extends ComponentFn<infer TProps> ? TProps : TComponentOrTProps
54
+ /**
55
+ * Extracts the props type from a Pyreon component function — or passes
56
+ * through the input unchanged when it's already a props type.
57
+ *
58
+ * Multi-overload aware: matches up to 4 call signatures and produces the
59
+ * UNION of their first-argument types. A single-overload function still
60
+ * works (the union of 4 copies of the same props type dedupes back to
61
+ * the single shape).
62
+ *
63
+ * **Why this shape**. `T extends (props: infer P) => any ? P : never` only
64
+ * captures the LAST overload of a multi-overload function — TS's overload-
65
+ * resolution-against-conditional-types semantics. Iterator / List / Element
66
+ * are 3-overload primitives where the LAST overload (`ChildrenProps`) is the
67
+ * loosest; without overload-aware extraction, `ExtractProps<Iterator>`
68
+ * returned just `ChildrenProps` and lost both `SimpleProps<T>` and
69
+ * `ObjectProps<T>` — wrapping Iterator through `rocketstyle()` /
70
+ * `attrs()` silently downgraded the public prop surface.
71
+ *
72
+ * The pattern-match shape `T extends { (props: infer P1, ...args: any): any;
73
+ * (props: infer P2, ...args: any): any; ... }` is the canonical TS trick
74
+ * for extracting overload sets — see also `Parameters<T>` semantics.
75
+ *
76
+ * Mirrors vitus-labs PR #222.
77
+ */
78
+ export type ExtractProps<TComponentOrTProps> = TComponentOrTProps extends {
79
+ (props: infer P1, ...args: any): any
80
+ (props: infer P2, ...args: any): any
81
+ (props: infer P3, ...args: any): any
82
+ (props: infer P4, ...args: any): any
83
+ }
84
+ ? P1 | P2 | P3 | P4
85
+ : TComponentOrTProps extends {
86
+ (props: infer P1, ...args: any): any
87
+ (props: infer P2, ...args: any): any
88
+ (props: infer P3, ...args: any): any
89
+ }
90
+ ? P1 | P2 | P3
91
+ : TComponentOrTProps extends {
92
+ (props: infer P1, ...args: any): any
93
+ (props: infer P2, ...args: any): any
94
+ }
95
+ ? P1 | P2
96
+ : TComponentOrTProps extends ComponentFn<infer TProps>
97
+ ? TProps
98
+ : TComponentOrTProps
@@ -3,13 +3,60 @@ import type { MultiKeys } from '../types/dimensions'
3
3
  // --------------------------------------------------------
4
4
  // remove undefined props
5
5
  // --------------------------------------------------------
6
- /** Strips keys with `undefined` values so they don't shadow default props during merging. */
6
+ /**
7
+ * Strips keys with `undefined` values so they don't shadow default props during merging.
8
+ *
9
+ * Copies own property DESCRIPTORS rather than values so that reactive
10
+ * getter-shaped props (compiler-emitted `_rp(() => signal())` converted
11
+ * to getters by `makeReactiveProps`) survive the pipeline with their
12
+ * subscription intact. Reading `props[key]` here would fire the getter
13
+ * at HOC setup time (outside any tracking scope) and collapse the prop
14
+ * to a static value — every downstream JSX accessor that reads
15
+ * `props.x` would see the captured-once value, not the live signal.
16
+ *
17
+ * For getter descriptors we keep the descriptor as-is (the
18
+ * undefined-filter doesn't apply — we can't peek into the getter
19
+ * without firing it). For data descriptors we drop entries whose
20
+ * value is `undefined` to preserve the original merge semantics.
21
+ */
7
22
  type RemoveUndefinedProps = <T extends Record<string, any>>(props: T) => Partial<T>
8
23
 
9
24
  export const removeUndefinedProps: RemoveUndefinedProps = (props) => {
10
25
  const result: Partial<typeof props> = {}
11
- for (const key in props) {
12
- if (props[key] !== undefined) result[key] = props[key]
26
+ const descriptors = Object.getOwnPropertyDescriptors(props)
27
+ for (const key of Object.keys(descriptors)) {
28
+ const d = descriptors[key]!
29
+ if (d.get || d.value !== undefined) {
30
+ Object.defineProperty(result, key, d)
31
+ }
32
+ }
33
+ return result
34
+ }
35
+
36
+ // --------------------------------------------------------
37
+ // merge descriptors
38
+ // --------------------------------------------------------
39
+ /**
40
+ * Like `Object.assign(target, ...sources)` but copies own property
41
+ * DESCRIPTORS instead of reading + writing values. Later sources
42
+ * override earlier ones (same semantics as spread / Object.assign).
43
+ *
44
+ * Required for reactive-prop preservation through the rocketstyle
45
+ * pipeline: a plain `{ ...A, ...B }` spread fires every getter on A
46
+ * and B and stores the resolved value, breaking the reactive
47
+ * subscription. This helper copies descriptors so getters survive
48
+ * the merge.
49
+ */
50
+ export const mergeDescriptors = (
51
+ ...sources: ReadonlyArray<Record<string, any> | null | undefined>
52
+ ): Record<string, any> => {
53
+ const result: Record<string, any> = {}
54
+ for (const source of sources) {
55
+ if (!source) continue
56
+ const descriptors = Object.getOwnPropertyDescriptors(source)
57
+ for (const key of Object.keys(descriptors)) {
58
+ Object.defineProperty(result, key, descriptors[key]!)
59
+ }
13
60
  }
14
61
  return result
15
62
  }