@pyreon/rocketstyle 0.16.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/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
670
|
+
const passedDescriptors = Object.getOwnPropertyDescriptors(passed);
|
|
671
|
+
for (const k of Object.keys(passedDescriptors)) Object.defineProperty(finalProps, k, passedDescriptors[k]);
|
|
658
672
|
}
|
|
659
|
-
|
|
660
|
-
finalProps
|
|
661
|
-
finalProps
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.18.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.
|
|
46
|
-
"@pyreon/typescript": "^0.
|
|
47
|
-
"@pyreon/ui-core": "^0.
|
|
45
|
+
"@pyreon/test-utils": "^0.13.5",
|
|
46
|
+
"@pyreon/typescript": "^0.18.0",
|
|
47
|
+
"@pyreon/ui-core": "^0.18.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.
|
|
56
|
-
"@pyreon/reactivity": "^0.
|
|
57
|
-
"@pyreon/styler": "^0.
|
|
58
|
-
"@pyreon/ui-core": "^0.
|
|
55
|
+
"@pyreon/core": "^0.18.0",
|
|
56
|
+
"@pyreon/reactivity": "^0.18.0",
|
|
57
|
+
"@pyreon/styler": "^0.18.0",
|
|
58
|
+
"@pyreon/ui-core": "^0.18.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 {
|
|
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
|
}
|
package/src/rocketstyle.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
420
|
-
//
|
|
421
|
-
|
|
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()
|
|
424
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
438
|
-
|
|
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
|
-
|
|
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 = {
|
package/src/utils/attrs.ts
CHANGED
|
@@ -3,13 +3,60 @@ import type { MultiKeys } from '../types/dimensions'
|
|
|
3
3
|
// --------------------------------------------------------
|
|
4
4
|
// remove undefined props
|
|
5
5
|
// --------------------------------------------------------
|
|
6
|
-
/**
|
|
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
|
-
|
|
12
|
-
|
|
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
|
}
|