@pyreon/rocketstyle 0.16.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -239,7 +239,31 @@ const useThemeAttrs = ({ inversed }) => {
239
239
  //#region src/utils/attrs.ts
240
240
  const removeUndefinedProps = (props) => {
241
241
  const result = {};
242
- for (const key in props) if (props[key] !== void 0) result[key] = props[key];
242
+ const descriptors = Object.getOwnPropertyDescriptors(props);
243
+ for (const key of Object.keys(descriptors)) {
244
+ const d = descriptors[key];
245
+ if (d.get || d.value !== void 0) Object.defineProperty(result, key, d);
246
+ }
247
+ return result;
248
+ };
249
+ /**
250
+ * Like `Object.assign(target, ...sources)` but copies own property
251
+ * DESCRIPTORS instead of reading + writing values. Later sources
252
+ * override earlier ones (same semantics as spread / Object.assign).
253
+ *
254
+ * Required for reactive-prop preservation through the rocketstyle
255
+ * pipeline: a plain `{ ...A, ...B }` spread fires every getter on A
256
+ * and B and stores the resolved value, breaking the reactive
257
+ * subscription. This helper copies descriptors so getters survive
258
+ * the merge.
259
+ */
260
+ const mergeDescriptors = (...sources) => {
261
+ const result = {};
262
+ for (const source of sources) {
263
+ if (!source) continue;
264
+ const descriptors = Object.getOwnPropertyDescriptors(source);
265
+ for (const key of Object.keys(descriptors)) Object.defineProperty(result, key, descriptors[key]);
266
+ }
243
267
  return result;
244
268
  };
245
269
  /** Picks only the props whose keys exist in the dimension keywords lookup and have truthy values. */
@@ -301,15 +325,7 @@ const rocketStyleHOC = ({ inversed, attrs, priorityAttrs }) => {
301
325
  isLight: themeAttrs.isLight
302
326
  }];
303
327
  const prioritizedAttrs = calculatePriorityAttrs([filteredProps, ...callbackParams]);
304
- const finalAttrs = calculateAttrs([{
305
- ...prioritizedAttrs,
306
- ...filteredProps
307
- }, ...callbackParams]);
308
- return WrappedComponent({
309
- ...prioritizedAttrs,
310
- ...finalAttrs,
311
- ...filteredProps
312
- });
328
+ return WrappedComponent(mergeDescriptors(prioritizedAttrs, calculateAttrs([mergeDescriptors(prioritizedAttrs, filteredProps), ...callbackParams]), filteredProps));
313
329
  };
314
330
  return HOCComponent;
315
331
  };
@@ -647,20 +663,34 @@ const rocketComponent = (options) => {
647
663
  omitSet = new Set([...RESERVED_STYLING_PROPS_KEYS, ...STATIC_OMIT_KEYS]);
648
664
  _omitSetCache.set(RESERVED_STYLING_PROPS_KEYS, omitSet);
649
665
  }
650
- const mergeProps = localCtx ? {
651
- ...localCtx,
652
- ...props
653
- } : props;
666
+ const mergeProps = localCtx ? mergeDescriptors(localCtx, props) : props;
654
667
  const finalProps = omit(mergeProps, omitSet);
655
668
  if (options.passProps) {
656
669
  const passed = pick(mergeProps, options.passProps);
657
- for (const k in passed) finalProps[k] = passed[k];
670
+ const passedDescriptors = Object.getOwnPropertyDescriptors(passed);
671
+ for (const k of Object.keys(passedDescriptors)) Object.defineProperty(finalProps, k, passedDescriptors[k]);
658
672
  }
659
- finalProps.ref = props.ref;
660
- finalProps.$rocketstyle = $rocketstyleAccessor;
661
- finalProps.$rocketstate = $rocketstateAccessor;
673
+ const refDescriptor = Object.getOwnPropertyDescriptor(props, "ref");
674
+ if (refDescriptor) Object.defineProperty(finalProps, "ref", refDescriptor);
675
+ Object.defineProperty(finalProps, "$rocketstyle", {
676
+ value: $rocketstyleAccessor,
677
+ writable: true,
678
+ enumerable: true,
679
+ configurable: true
680
+ });
681
+ Object.defineProperty(finalProps, "$rocketstate", {
682
+ value: $rocketstateAccessor,
683
+ writable: true,
684
+ enumerable: true,
685
+ configurable: true
686
+ });
662
687
  if (__DEV__) {
663
- finalProps["data-rocketstyle"] = componentName;
688
+ Object.defineProperty(finalProps, "data-rocketstyle", {
689
+ value: componentName,
690
+ writable: true,
691
+ enumerable: true,
692
+ configurable: true
693
+ });
664
694
  if (options.DEBUG) {
665
695
  const debugPayload = {
666
696
  component: componentName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/rocketstyle",
3
- "version": "0.16.0",
3
+ "version": "0.19.0",
4
4
  "description": "Multi-dimensional style composition for Pyreon components",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,9 +42,9 @@
42
42
  "typecheck": "tsc --noEmit"
43
43
  },
44
44
  "devDependencies": {
45
- "@pyreon/test-utils": "^0.13.3",
46
- "@pyreon/typescript": "^0.16.0",
47
- "@pyreon/ui-core": "^0.16.0",
45
+ "@pyreon/test-utils": "^0.13.6",
46
+ "@pyreon/typescript": "^0.19.0",
47
+ "@pyreon/ui-core": "^0.19.0",
48
48
  "@vitest/browser-playwright": "^4.1.4",
49
49
  "@vitus-labs/tools-rolldown": "^2.3.0"
50
50
  },
@@ -52,9 +52,9 @@
52
52
  "node": ">= 22"
53
53
  },
54
54
  "dependencies": {
55
- "@pyreon/core": "^0.16.0",
56
- "@pyreon/reactivity": "^0.16.0",
57
- "@pyreon/styler": "^0.16.0",
58
- "@pyreon/ui-core": "^0.16.0"
55
+ "@pyreon/core": "^0.19.0",
56
+ "@pyreon/reactivity": "^0.19.0",
57
+ "@pyreon/styler": "^0.19.0",
58
+ "@pyreon/ui-core": "^0.19.0"
59
59
  }
60
60
  }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Reactive-prop preservation through the rocketstyle pipeline.
3
+ *
4
+ * Catches the bug class where rocketstyle's HOC + EnhancedComponent
5
+ * COPIED getter-shaped reactive props via value-read + value-write,
6
+ * collapsing the reactive subscription to a static value before the
7
+ * inner component ever saw it. Every downstream JSX accessor reading
8
+ * `props.x` would then see the captured-once value, not the live signal.
9
+ *
10
+ * The contract: `_rp`-branded thunks converted to getter properties by
11
+ * `makeReactiveProps` MUST flow through the rocketstyle pipeline with
12
+ * their getter descriptor intact. Two preservation points:
13
+ *
14
+ * 1. `removeUndefinedProps` — first call site in the attrs HOC
15
+ * 2. `mergeDescriptors` — replaces the `{ ...A, ...B }` spreads in
16
+ * the attrs HOC + EnhancedComponent's mergeProps
17
+ *
18
+ * If either is reverted to value-copying, these tests fail with the
19
+ * specific failure-mode the production bug exhibits: the consumer
20
+ * reads `props.href` and gets the resolved-once value rather than a
21
+ * getter that fires the underlying signal on every read.
22
+ *
23
+ * Bisect-verified per layer in the PR — see PR description.
24
+ */
25
+
26
+ import { mergeDescriptors, removeUndefinedProps } from '../utils/attrs'
27
+
28
+ describe('removeUndefinedProps — getter preservation', () => {
29
+ it('preserves a getter descriptor through the filter (live read)', () => {
30
+ let calls = 0
31
+ const source = {} as Record<string, unknown>
32
+ Object.defineProperty(source, 'href', {
33
+ get() {
34
+ calls++
35
+ return `https://example.com/page-${calls}`
36
+ },
37
+ enumerable: true,
38
+ configurable: true,
39
+ })
40
+
41
+ const filtered = removeUndefinedProps(source)
42
+
43
+ // Filter must NOT have fired the getter during the copy step —
44
+ // the live reactive read should happen at downstream consumption.
45
+ expect(calls).toBe(0)
46
+
47
+ // Each read fires the getter again, proving the descriptor flowed through.
48
+ expect(filtered.href).toBe('https://example.com/page-1')
49
+ expect(filtered.href).toBe('https://example.com/page-2')
50
+ expect(filtered.href).toBe('https://example.com/page-3')
51
+ })
52
+
53
+ it('still strips data properties with undefined values', () => {
54
+ const result = removeUndefinedProps({ a: 1, b: undefined, c: 'x' })
55
+ expect(result).toEqual({ a: 1, c: 'x' })
56
+ expect('b' in result).toBe(false)
57
+ })
58
+
59
+ it('preserves null / falsy non-undefined data values (existing contract)', () => {
60
+ const result = removeUndefinedProps({ a: null, b: 0, c: '', d: false })
61
+ expect(result).toEqual({ a: null, b: 0, c: '', d: false })
62
+ })
63
+
64
+ it('keeps a getter even though we cannot peek into it (undefined-filter inapplicable)', () => {
65
+ // A getter whose initial fire would return undefined still survives —
66
+ // we can't safely peek without firing the subscription. If the
67
+ // downstream consumer fires it and gets undefined, that's their
68
+ // semantic; rocketstyle stays out of the way.
69
+ let val: unknown = undefined
70
+ const source = {} as Record<string, unknown>
71
+ Object.defineProperty(source, 'href', {
72
+ get: () => val,
73
+ enumerable: true,
74
+ configurable: true,
75
+ })
76
+
77
+ const filtered = removeUndefinedProps(source)
78
+ expect('href' in filtered).toBe(true)
79
+ expect(filtered.href).toBeUndefined()
80
+
81
+ val = 'http://example.com'
82
+ expect(filtered.href).toBe('http://example.com')
83
+ })
84
+ })
85
+
86
+ describe('mergeDescriptors — getter preservation through merge', () => {
87
+ it('preserves getter descriptors from any source position (later wins)', () => {
88
+ const a = { plain: 'A.plain' } as Record<string, unknown>
89
+ let bCalls = 0
90
+ Object.defineProperty(a, 'shared', {
91
+ value: 'A.shared',
92
+ enumerable: true,
93
+ configurable: true,
94
+ writable: true,
95
+ })
96
+
97
+ const b = {} as Record<string, unknown>
98
+ Object.defineProperty(b, 'href', {
99
+ get: () => {
100
+ bCalls++
101
+ return `b-${bCalls}`
102
+ },
103
+ enumerable: true,
104
+ configurable: true,
105
+ })
106
+ Object.defineProperty(b, 'shared', {
107
+ get: () => 'B.shared',
108
+ enumerable: true,
109
+ configurable: true,
110
+ })
111
+
112
+ const merged = mergeDescriptors(a, b)
113
+
114
+ // Plain value from A survives.
115
+ expect(merged.plain).toBe('A.plain')
116
+
117
+ // Getter from B survives and is live (no fire at merge time).
118
+ expect(bCalls).toBe(0)
119
+ expect(merged.href).toBe('b-1')
120
+ expect(merged.href).toBe('b-2')
121
+
122
+ // Later source wins — B's getter for 'shared' replaced A's data value.
123
+ expect(merged.shared).toBe('B.shared')
124
+ })
125
+
126
+ it('skips null/undefined sources without breaking the chain', () => {
127
+ const a = { x: 1 }
128
+ const b = { y: 2 }
129
+ expect(mergeDescriptors(a, null, b, undefined)).toEqual({ x: 1, y: 2 })
130
+ })
131
+
132
+ it('returns empty object for no sources', () => {
133
+ expect(mergeDescriptors()).toEqual({})
134
+ })
135
+
136
+ it('a plain spread WOULD fire getters — descriptor merge does not (regression catcher)', () => {
137
+ // This test specifically catches the value-spread regression. If
138
+ // mergeDescriptors is replaced with `Object.assign(target, ...sources)`
139
+ // or `{ ...A, ...B }`, getter calls happen at merge time and this
140
+ // test fails with `expect(calls).toBe(0)` -> got >= 1.
141
+ let calls = 0
142
+ const source = {} as Record<string, unknown>
143
+ Object.defineProperty(source, 'href', {
144
+ get: () => {
145
+ calls++
146
+ return 'value'
147
+ },
148
+ enumerable: true,
149
+ configurable: true,
150
+ })
151
+
152
+ const merged = mergeDescriptors({}, source)
153
+ expect(calls).toBe(0)
154
+
155
+ // Sanity — descriptor IS there and works on read.
156
+ expect(merged.href).toBe('value')
157
+ expect(calls).toBe(1)
158
+ })
159
+ })
160
+
161
+ describe('end-to-end pipeline — getter survives the rocketstyle hop chain', () => {
162
+ it('reactive prop flows through removeUndefinedProps + mergeDescriptors without firing the getter', () => {
163
+ // Synthesises the exact pipeline shape in rocketstyleAttrsHoc.ts
164
+ // (removeUndefinedProps + mergeDescriptors twice). Confirms the
165
+ // combined transformation preserves reactivity end-to-end.
166
+ let calls = 0
167
+ const inputProps = {} as Record<string, unknown>
168
+ Object.defineProperty(inputProps, 'href', {
169
+ get: () => {
170
+ calls++
171
+ return `live-${calls}`
172
+ },
173
+ enumerable: true,
174
+ configurable: true,
175
+ })
176
+ inputProps.size = 'large' // plain value
177
+
178
+ // Step 1 (HOC): filter undefined, preserve getter.
179
+ const filtered = removeUndefinedProps(inputProps)
180
+ expect(calls).toBe(0)
181
+
182
+ // Step 2 (HOC): inner merge for attrs callback input.
183
+ const innerMerge = mergeDescriptors({ tag: 'a' }, filtered)
184
+ expect(calls).toBe(0)
185
+
186
+ // Step 3 (HOC): final merge before wrapped-component handoff.
187
+ const finalProps = mergeDescriptors({}, { tag: 'a' }, filtered)
188
+ expect(calls).toBe(0)
189
+
190
+ // Now the wrapped-component consumer reads — getter fires once per read.
191
+ expect(finalProps.href).toBe('live-1')
192
+ expect(finalProps.size).toBe('large')
193
+ expect(innerMerge.href).toBe('live-2')
194
+ })
195
+ })
@@ -424,4 +424,58 @@ describe('@pyreon/rocketstyle in real browser', () => {
424
424
 
425
425
  for (const inst of instances) inst.unmount()
426
426
  })
427
+
428
+ it('reactive prop flips reach the rendered DOM through the rocketstyle pipeline', async () => {
429
+ // The bug class this catches: rocketstyle's HOC + EnhancedComponent
430
+ // used to value-copy props (via spread + omit/pick + the
431
+ // value-iteration removeUndefinedProps), collapsing getter-shaped
432
+ // reactive props to a static value before the wrapped component
433
+ // saw them. Downstream JSX accessors then read the captured-once
434
+ // value, breaking signal-driven updates on every prop on every
435
+ // rocketstyle-wrapped component (the whole of @pyreon/ui-components,
436
+ // plus user-defined ones).
437
+ //
438
+ // Test mirrors the real-app shape: a signal feeds an `id` prop on a
439
+ // rocketstyle-wrapped Base; the wrapped component renders a child
440
+ // that reads `props.id` reactively. Flipping the signal must patch
441
+ // the DOM.
442
+ //
443
+ // We can't drive this through the user-side compiler in tests, so
444
+ // we manually `_rp`-brand the function + run `makeReactiveProps` —
445
+ // exactly what the compiler + mount pipeline do for a real consumer.
446
+ const { _rp } = await import('@pyreon/core')
447
+ const labelSig = signal('initial')
448
+
449
+ // Base renders the label prop as DOM text so we can assert reactivity
450
+ // observably in the rendered tree.
451
+ const ReactiveBase: ComponentFn<{ label?: string; children?: VNodeChild }> = (
452
+ props,
453
+ ) => h('div', { 'data-testid': 'reactive' }, () => props.label)
454
+ ;(ReactiveBase as ComponentFn & { displayName?: string }).displayName = 'ReactiveBase'
455
+
456
+ const Box: any = rocketstyle()({ name: 'ReactivePropBox', component: ReactiveBase })
457
+
458
+ // Brand the prop like the compiler does (`_rp(() => signal())`) —
459
+ // mount.ts's `makeReactiveProps` then converts the brand to a getter
460
+ // property. If the rocketstyle pipeline preserves the descriptor, the
461
+ // inner component sees a getter; if it value-copies (the broken
462
+ // shape this test catches), it sees the resolved-once string 'initial'
463
+ // and the DOM never updates on signal flip.
464
+ const rawProps = { label: _rp(() => labelSig()) }
465
+
466
+ const { container, unmount } = mountInBrowser(h(Box, rawProps))
467
+ const el = container.querySelector<HTMLElement>('[data-testid="reactive"]')!
468
+ expect(el.textContent).toBe('initial')
469
+
470
+ labelSig.set('updated')
471
+ // Microtask flush — Pyreon's renderEffect commits synchronously after batch.
472
+ await Promise.resolve()
473
+ expect(el.textContent).toBe('updated')
474
+
475
+ labelSig.set('third')
476
+ await Promise.resolve()
477
+ expect(el.textContent).toBe('third')
478
+
479
+ unmount()
480
+ })
427
481
  })
@@ -2,7 +2,11 @@ import { render } from '@pyreon/ui-core'
2
2
  import { useTheme } from '../hooks'
3
3
  import type { Configuration } from '../types/configuration'
4
4
  import type { ComponentFn } from '../types/utils'
5
- import { calculateChainOptions, removeUndefinedProps } from '../utils/attrs'
5
+ import {
6
+ calculateChainOptions,
7
+ mergeDescriptors,
8
+ removeUndefinedProps,
9
+ } from '../utils/attrs'
6
10
 
7
11
  export type RocketStyleHOC = ({
8
12
  inversed,
@@ -45,19 +49,21 @@ const rocketStyleHOC: RocketStyleHOC = ({ inversed, attrs, priorityAttrs }) => {
45
49
 
46
50
  const prioritizedAttrs = calculatePriorityAttrs([filteredProps, ...callbackParams])
47
51
 
52
+ // Merge via descriptor-copy so reactive getter props on
53
+ // filteredProps survive the chain. A `{...A, ...B}` spread
54
+ // would fire every getter on A and B and store the resolved
55
+ // value, breaking the reactive subscription downstream.
56
+ // Attrs callbacks legitimately read prop VALUES (e.g.
57
+ // `({ href }) => ({ tag: href ? 'a' : 'button' })`) — that's
58
+ // a one-shot read at setup time by design. The pipeline only
59
+ // needs to preserve reactivity for props the callbacks DON'T
60
+ // consume, which the descriptor-merge does.
48
61
  const finalAttrs = calculateAttrs([
49
- {
50
- ...prioritizedAttrs,
51
- ...filteredProps,
52
- },
62
+ mergeDescriptors(prioritizedAttrs, filteredProps),
53
63
  ...callbackParams,
54
64
  ])
55
65
 
56
- const finalProps = {
57
- ...prioritizedAttrs,
58
- ...finalAttrs,
59
- ...filteredProps,
60
- }
66
+ const finalProps = mergeDescriptors(prioritizedAttrs, finalAttrs, filteredProps)
61
67
 
62
68
  return WrappedComponent(finalProps)
63
69
  }
@@ -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'
@@ -416,30 +421,68 @@ const rocketComponent: RocketComponent = (options) => {
416
421
  _omitSetCache.set(RESERVED_STYLING_PROPS_KEYS, omitSet)
417
422
  }
418
423
 
419
- // Merge localCtx + props without an intermediate spread object.
420
- // omit() handles 'pseudo' removal (included in STATIC_OMIT_KEYS).
421
- 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
422
431
 
423
- // omit() already returns a fresh object assign directly onto it
424
- // 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.
425
434
  const finalProps = omit(mergeProps as Record<string, unknown>, omitSet) as Record<string, any>
426
435
 
427
436
  if (options.passProps) {
428
437
  const passed = pick(mergeProps, options.passProps)
429
- 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
+ }
430
447
  }
431
448
 
432
- 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
+ }
433
457
  // Function accessors — DynamicStyled wraps them in a computed() so
434
458
  // mode/dimension changes produce a new CSS class reactively. The
435
459
  // computed tracks only these two accessors; the resolve itself runs
436
460
  // untracked to prevent exponential cascade from theme deep-reads.
437
- finalProps.$rocketstyle = $rocketstyleAccessor
438
- 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
+ })
439
473
 
440
474
  // development debugging — tree-shaken in production via import.meta.env.DEV
441
475
  if (__DEV__) {
442
- 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
+ })
443
486
 
444
487
  if (options.DEBUG) {
445
488
  const debugPayload = {
@@ -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
  }