@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/lib/index.d.ts +65 -10
- package/lib/index.js +91 -45
- package/package.json +10 -10
- package/src/__tests__/attrs-overloads.test.ts +97 -0
- package/src/__tests__/cache-key-boolean-collision.test.ts +54 -0
- package/src/__tests__/reactive-props-preservation.test.ts +195 -0
- package/src/__tests__/rocketstyle.browser.test.tsx +275 -0
- package/src/__tests__/rocketstyleIntegration.test.ts +134 -0
- package/src/hoc/rocketstyleAttrsHoc.ts +16 -10
- package/src/index.ts +2 -1
- package/src/rocketstyle.ts +122 -38
- package/src/types/attrs.ts +20 -10
- package/src/types/rocketstyle.ts +68 -19
- package/src/types/styles.ts +1 -1
- package/src/types/utils.ts +45 -2
- package/src/utils/attrs.ts +50 -3
|
@@ -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
|
+
})
|
|
@@ -95,6 +95,152 @@ describe('@pyreon/rocketstyle in real browser', () => {
|
|
|
95
95
|
unmount()
|
|
96
96
|
})
|
|
97
97
|
|
|
98
|
+
// Bug 3 audit: derivation chain matching the real bokisch.com bug shape.
|
|
99
|
+
//
|
|
100
|
+
// Library defines a base `Text` component with `.styles(({css}) => css\`color:
|
|
101
|
+
// ${$rocketstyle.color}\`)` — the .styles() callback reads $rocketstyle.color.
|
|
102
|
+
// Library also defines `.theme({color: 'black'})` as the default.
|
|
103
|
+
//
|
|
104
|
+
// Consumer derives a sub-component: `Text.theme((t, m) => ({color: m('red',
|
|
105
|
+
// 'blue')}))` — adds NEW .theme() but does NOT call .styles() again. The
|
|
106
|
+
// derivation should INHERIT the base's .styles() and have it consume the
|
|
107
|
+
// new .theme() values, with mode toggling re-rendering correctly.
|
|
108
|
+
//
|
|
109
|
+
// The user's report: components in this chain keep stale colors on
|
|
110
|
+
// theme toggle. Test verifies whether the derivation chain actually
|
|
111
|
+
// wires up reactively.
|
|
112
|
+
it('Bug 3 repro: rocketstyle derivation chain — inherited .styles() + new .theme() reacts to mode', async () => {
|
|
113
|
+
const modeSig = signal<'light' | 'dark'>('light')
|
|
114
|
+
|
|
115
|
+
// Library base — has .styles() that reads $rocketstyle.color.
|
|
116
|
+
const TextBase: any = rocketstyle()({
|
|
117
|
+
name: 'TextBase',
|
|
118
|
+
component: Base,
|
|
119
|
+
})
|
|
120
|
+
.styles(
|
|
121
|
+
(css: any) => css`
|
|
122
|
+
color: ${({ $rocketstyle }: any) => $rocketstyle.color};
|
|
123
|
+
`,
|
|
124
|
+
)
|
|
125
|
+
.theme({ color: 'rgb(0, 128, 0)' }) // green default
|
|
126
|
+
|
|
127
|
+
// Consumer derivation — only adds .theme(), no .styles() override.
|
|
128
|
+
const Text: any = TextBase
|
|
129
|
+
.theme((_t: any, m: any) => ({
|
|
130
|
+
color: m('rgb(255, 0, 0)', 'rgb(0, 0, 255)'),
|
|
131
|
+
}))
|
|
132
|
+
|
|
133
|
+
const { container, unmount } = mountInBrowser(
|
|
134
|
+
h(PyreonUI, { theme: {}, mode: modeSig }, h(Text, { id: 'derive' })),
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
const el = container.querySelector<HTMLElement>('#derive')!
|
|
138
|
+
const initialColor = getComputedStyle(el).color
|
|
139
|
+
|
|
140
|
+
modeSig.set('dark')
|
|
141
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
142
|
+
await new Promise((r) => requestAnimationFrame(() => r(undefined)))
|
|
143
|
+
|
|
144
|
+
const darkColor = getComputedStyle(el).color
|
|
145
|
+
|
|
146
|
+
// Diagnostic: log both colors for clarity, regardless of pass/fail
|
|
147
|
+
// (helps triage whether the bug is "no .theme() applied at all" vs
|
|
148
|
+
// "applied but doesn't react to mode toggle").
|
|
149
|
+
if (initialColor === 'rgb(255, 0, 0)' && darkColor === 'rgb(0, 0, 255)') {
|
|
150
|
+
// Works correctly — derivation chain wires up reactively
|
|
151
|
+
expect(darkColor).toBe('rgb(0, 0, 255)')
|
|
152
|
+
} else {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`[bug-3-repro] derivation chain failed. initial=${initialColor}, dark=${darkColor}. ` +
|
|
155
|
+
`Expected initial=rgb(255, 0, 0), dark=rgb(0, 0, 255). ` +
|
|
156
|
+
`Possible causes: (a) .theme() override silently dropped, ` +
|
|
157
|
+
`(b) .styles() not inherited from base, ` +
|
|
158
|
+
`(c) mode toggle doesn't propagate.`,
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
unmount()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
// Bug 3 audit (continued): derivation chain WITH dimension props.
|
|
165
|
+
// Real consumer pattern: `<Text base paragraph centered>` uses
|
|
166
|
+
// dimension-prop values from .sizes()/.variants(). Tests that mode
|
|
167
|
+
// toggle still propagates when dimension props are active.
|
|
168
|
+
it('Bug 3 repro: derivation + dimension props + mode toggle', async () => {
|
|
169
|
+
const modeSig = signal<'light' | 'dark'>('light')
|
|
170
|
+
|
|
171
|
+
const TextBase: any = rocketstyle()({ name: 'TextBaseDim', component: Base })
|
|
172
|
+
.styles(
|
|
173
|
+
(css: any) => css`
|
|
174
|
+
color: ${({ $rocketstyle }: any) => $rocketstyle.color};
|
|
175
|
+
font-size: ${({ $rocketstyle }: any) => $rocketstyle.fontSize};
|
|
176
|
+
`,
|
|
177
|
+
)
|
|
178
|
+
.theme({ color: 'rgb(0, 0, 0)', fontSize: '14px' })
|
|
179
|
+
.sizes({
|
|
180
|
+
small: { fontSize: '12px' },
|
|
181
|
+
large: { fontSize: '20px' },
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// Consumer derivation: theme override that depends on mode
|
|
185
|
+
const Text: any = TextBase.theme((_t: any, m: any) => ({
|
|
186
|
+
color: m('rgb(255, 0, 0)', 'rgb(0, 0, 255)'),
|
|
187
|
+
}))
|
|
188
|
+
|
|
189
|
+
const { container, unmount } = mountInBrowser(
|
|
190
|
+
h(PyreonUI, { theme: {}, mode: modeSig },
|
|
191
|
+
h(Text, { id: 'dim', size: 'large' }),
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
const el = container.querySelector<HTMLElement>('#dim')!
|
|
195
|
+
expect(getComputedStyle(el).color).toBe('rgb(255, 0, 0)')
|
|
196
|
+
expect(getComputedStyle(el).fontSize).toBe('20px')
|
|
197
|
+
|
|
198
|
+
modeSig.set('dark')
|
|
199
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
200
|
+
await new Promise((r) => requestAnimationFrame(() => r(undefined)))
|
|
201
|
+
|
|
202
|
+
expect(getComputedStyle(el).color).toBe('rgb(0, 0, 255)')
|
|
203
|
+
expect(getComputedStyle(el).fontSize).toBe('20px') // dimension unchanged
|
|
204
|
+
unmount()
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
// Bug 3 audit (continued): DOUBLE derivation — Text → TextStyled → consumer.
|
|
208
|
+
// Tests whether mode reactivity survives a multi-level chain.
|
|
209
|
+
it('Bug 3 repro: double-derivation chain still reacts to mode', async () => {
|
|
210
|
+
const modeSig = signal<'light' | 'dark'>('light')
|
|
211
|
+
|
|
212
|
+
const TextBase: any = rocketstyle()({ name: 'DoubleTextBase', component: Base })
|
|
213
|
+
.styles(
|
|
214
|
+
(css: any) => css`
|
|
215
|
+
color: ${({ $rocketstyle }: any) => $rocketstyle.color};
|
|
216
|
+
`,
|
|
217
|
+
)
|
|
218
|
+
.theme({ color: 'rgb(0, 0, 0)' })
|
|
219
|
+
|
|
220
|
+
// First derivation — adds states (no theme override yet)
|
|
221
|
+
const TextStyled: any = TextBase.states({
|
|
222
|
+
muted: { color: 'rgb(128, 128, 128)' },
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
// Second derivation — mode-aware theme
|
|
226
|
+
const Text: any = TextStyled.theme((_t: any, m: any) => ({
|
|
227
|
+
color: m('rgb(255, 0, 0)', 'rgb(0, 0, 255)'),
|
|
228
|
+
}))
|
|
229
|
+
|
|
230
|
+
const { container, unmount } = mountInBrowser(
|
|
231
|
+
h(PyreonUI, { theme: {}, mode: modeSig }, h(Text, { id: 'dbl' })),
|
|
232
|
+
)
|
|
233
|
+
const el = container.querySelector<HTMLElement>('#dbl')!
|
|
234
|
+
expect(getComputedStyle(el).color).toBe('rgb(255, 0, 0)')
|
|
235
|
+
|
|
236
|
+
modeSig.set('dark')
|
|
237
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
238
|
+
await new Promise((r) => requestAnimationFrame(() => r(undefined)))
|
|
239
|
+
|
|
240
|
+
expect(getComputedStyle(el).color).toBe('rgb(0, 0, 255)')
|
|
241
|
+
unmount()
|
|
242
|
+
})
|
|
243
|
+
|
|
98
244
|
it('the `variant` prop layers on top of state', () => {
|
|
99
245
|
const Box: any = rocketstyle()({ name: 'VariantBox', component: Base })
|
|
100
246
|
.styles(
|
|
@@ -168,6 +314,81 @@ describe('@pyreon/rocketstyle in real browser', () => {
|
|
|
168
314
|
dark.unmount()
|
|
169
315
|
})
|
|
170
316
|
|
|
317
|
+
// Bug 5 repro: cache-key collision under `useBooleans: true`.
|
|
318
|
+
//
|
|
319
|
+
// The user-facing bug shape: `<Btn primary />` and `<Btn secondary />`
|
|
320
|
+
// both render with the FIRST cached variant's resolved styles. Pre-fix
|
|
321
|
+
// the dimension-prop memo built its key from raw `propsRec[dimName]`
|
|
322
|
+
// BEFORE _calculateStylingAttrs resolved the boolean shorthand, so all
|
|
323
|
+
// boolean variants on the same dimension had `propsRec.state === undefined`
|
|
324
|
+
// and collided. Real-app symptom: a button group where primary, secondary,
|
|
325
|
+
// and danger variants all render in the FIRST color the user clicked.
|
|
326
|
+
//
|
|
327
|
+
// Real-Chromium proof: mounts both variants in parallel and asserts
|
|
328
|
+
// `getComputedStyle().color` is different. happy-dom's class-based
|
|
329
|
+
// assertions wouldn't catch this — only real CSS resolution proves
|
|
330
|
+
// the styler classCache served different cached styles for each variant.
|
|
331
|
+
it('Bug 5 repro: useBooleans:true with multiple boolean variants render with distinct computed styles', () => {
|
|
332
|
+
const Btn: any = rocketstyle({ useBooleans: true })({
|
|
333
|
+
name: 'BoolBtn',
|
|
334
|
+
component: Base,
|
|
335
|
+
})
|
|
336
|
+
.styles(
|
|
337
|
+
(css: any) => css`
|
|
338
|
+
color: ${({ $rocketstyle }: any) => $rocketstyle.color};
|
|
339
|
+
`,
|
|
340
|
+
)
|
|
341
|
+
.theme({ color: 'rgb(0, 0, 0)' })
|
|
342
|
+
.states({
|
|
343
|
+
primary: { color: 'rgb(255, 0, 0)' },
|
|
344
|
+
secondary: { color: 'rgb(0, 255, 0)' },
|
|
345
|
+
danger: { color: 'rgb(0, 0, 255)' },
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
const primaryMount = mountInBrowser(h(Btn, { id: 'p', primary: true }))
|
|
349
|
+
const secondaryMount = mountInBrowser(h(Btn, { id: 's', secondary: true }))
|
|
350
|
+
const dangerMount = mountInBrowser(h(Btn, { id: 'd', danger: true }))
|
|
351
|
+
|
|
352
|
+
const primaryEl = primaryMount.container.querySelector<HTMLElement>('#p')!
|
|
353
|
+
const secondaryEl = secondaryMount.container.querySelector<HTMLElement>('#s')!
|
|
354
|
+
const dangerEl = dangerMount.container.querySelector<HTMLElement>('#d')!
|
|
355
|
+
|
|
356
|
+
// Without the fix: all three resolve to whichever variant was cached
|
|
357
|
+
// first under the colliding key (mode|undefined|...). With the fix:
|
|
358
|
+
// each gets its own cache entry keyed off the resolved state value.
|
|
359
|
+
expect(getComputedStyle(primaryEl).color).toBe('rgb(255, 0, 0)')
|
|
360
|
+
expect(getComputedStyle(secondaryEl).color).toBe('rgb(0, 255, 0)')
|
|
361
|
+
expect(getComputedStyle(dangerEl).color).toBe('rgb(0, 0, 255)')
|
|
362
|
+
|
|
363
|
+
// Sibling mount under the SAME PyreonUI provider — exercises the
|
|
364
|
+
// cross-instance shared memo (per-theme WeakMap entry). Pre-fix this
|
|
365
|
+
// was the actual real-app shape: button group children mounted under
|
|
366
|
+
// a single provider, all hitting the same colliding key.
|
|
367
|
+
const groupMount = mountInBrowser(
|
|
368
|
+
h(PyreonUI, { theme: {}, mode: 'light' },
|
|
369
|
+
h('div', { id: 'group' },
|
|
370
|
+
h(Btn, { id: 'g-primary', primary: true }),
|
|
371
|
+
h(Btn, { id: 'g-secondary', secondary: true }),
|
|
372
|
+
h(Btn, { id: 'g-danger', danger: true }),
|
|
373
|
+
),
|
|
374
|
+
),
|
|
375
|
+
)
|
|
376
|
+
expect(
|
|
377
|
+
getComputedStyle(groupMount.container.querySelector<HTMLElement>('#g-primary')!).color,
|
|
378
|
+
).toBe('rgb(255, 0, 0)')
|
|
379
|
+
expect(
|
|
380
|
+
getComputedStyle(groupMount.container.querySelector<HTMLElement>('#g-secondary')!).color,
|
|
381
|
+
).toBe('rgb(0, 255, 0)')
|
|
382
|
+
expect(
|
|
383
|
+
getComputedStyle(groupMount.container.querySelector<HTMLElement>('#g-danger')!).color,
|
|
384
|
+
).toBe('rgb(0, 0, 255)')
|
|
385
|
+
|
|
386
|
+
primaryMount.unmount()
|
|
387
|
+
secondaryMount.unmount()
|
|
388
|
+
dangerMount.unmount()
|
|
389
|
+
groupMount.unmount()
|
|
390
|
+
})
|
|
391
|
+
|
|
171
392
|
it('multiple instances share definition-scoped caches (no per-mount rebuild)', () => {
|
|
172
393
|
// Verifies the perf optimization: getDimensionsMap, reservedPropNames keys,
|
|
173
394
|
// and omit Sets are cached at definition time (WeakMap), not rebuilt per mount.
|
|
@@ -203,4 +424,58 @@ describe('@pyreon/rocketstyle in real browser', () => {
|
|
|
203
424
|
|
|
204
425
|
for (const inst of instances) inst.unmount()
|
|
205
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
|
+
})
|
|
206
481
|
})
|
|
@@ -214,6 +214,36 @@ describe('chaining methods', () => {
|
|
|
214
214
|
const result = WithAttrs.getDefaultAttrs({}, {}, 'light')
|
|
215
215
|
expect(result.label).toBe('default')
|
|
216
216
|
})
|
|
217
|
+
|
|
218
|
+
it('.getDefaultAttrs() passes isDark/isLight helpers matching the requested mode', () => {
|
|
219
|
+
// Regression: pre-fix the helpers were inverted in `getDefaultAttrs`
|
|
220
|
+
// (isDark: mode === 'light', isLight: mode === 'dark') so introspection
|
|
221
|
+
// callers (rocketstories-style story generators, devtools) saw the
|
|
222
|
+
// OPPOSITE of what the runtime renders. Runtime via `useTheme` derives
|
|
223
|
+
// helpers correctly from context (and handles `inversed` by flipping
|
|
224
|
+
// the mode at the Provider level — see context.test.ts), so the bug
|
|
225
|
+
// only surfaced for callers that read the helpers via `getDefaultAttrs`.
|
|
226
|
+
//
|
|
227
|
+
// Contract: `mode` is the EFFECTIVE mode (post-`inversed` resolution).
|
|
228
|
+
// Callers wanting "inversed light" pass `'dark'`; this function takes
|
|
229
|
+
// the resolved value, so testing both light/dark covers both the
|
|
230
|
+
// un-inversed and inversed cases.
|
|
231
|
+
const captured: Array<{ mode: any; isDark: any; isLight: any }> = []
|
|
232
|
+
const Probe = Button.attrs((_props: any, _theme: any, helpers: any) => {
|
|
233
|
+
captured.push({
|
|
234
|
+
mode: helpers.mode,
|
|
235
|
+
isDark: helpers.isDark,
|
|
236
|
+
isLight: helpers.isLight,
|
|
237
|
+
})
|
|
238
|
+
return {}
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
Probe.getDefaultAttrs({}, {}, 'light')
|
|
242
|
+
Probe.getDefaultAttrs({}, {}, 'dark')
|
|
243
|
+
|
|
244
|
+
expect(captured[0]).toEqual({ mode: 'light', isDark: false, isLight: true })
|
|
245
|
+
expect(captured[1]).toEqual({ mode: 'dark', isDark: true, isLight: false })
|
|
246
|
+
})
|
|
217
247
|
})
|
|
218
248
|
|
|
219
249
|
// --------------------------------------------------------
|
|
@@ -575,3 +605,107 @@ describe('theme and state injection', () => {
|
|
|
575
605
|
expect(vnode.$rocketstate.state).toBe('primary')
|
|
576
606
|
})
|
|
577
607
|
})
|
|
608
|
+
|
|
609
|
+
// --------------------------------------------------------
|
|
610
|
+
// component-swap reset (cloneAndEnhance)
|
|
611
|
+
// --------------------------------------------------------
|
|
612
|
+
// `.config({ component: NewBase })` swaps the underlying renderable. The prior
|
|
613
|
+
// .attrs() / .priorityAttrs() / .filterAttrs() / .compose() chains were
|
|
614
|
+
// tailored to the previous component's prop shape — applying them to a
|
|
615
|
+
// different component silently leaks invalid props through to the DOM (e.g.
|
|
616
|
+
// `disabled` on an `<a>`). vitus-labs's rocketstyle drops those chains on
|
|
617
|
+
// component swap; this regression test locks in matching behavior here.
|
|
618
|
+
describe('component-swap reset', () => {
|
|
619
|
+
it('drops .attrs() chain when component changes', () => {
|
|
620
|
+
const ButtonBase: any = ({ children, $rocketstyle, $rocketstate, ...rest }: any) => ({
|
|
621
|
+
type: 'button',
|
|
622
|
+
props: rest,
|
|
623
|
+
children,
|
|
624
|
+
})
|
|
625
|
+
ButtonBase.displayName = 'ButtonBase'
|
|
626
|
+
|
|
627
|
+
const AnchorBase: any = ({ children, $rocketstyle, $rocketstate, ...rest }: any) => ({
|
|
628
|
+
type: 'a',
|
|
629
|
+
props: rest,
|
|
630
|
+
children,
|
|
631
|
+
})
|
|
632
|
+
AnchorBase.displayName = 'AnchorBase'
|
|
633
|
+
|
|
634
|
+
const Button: any = rocketstyle()({
|
|
635
|
+
name: 'Button',
|
|
636
|
+
component: ButtonBase,
|
|
637
|
+
}).attrs((() => ({ 'data-button-attr': 'leaked' })) as any)
|
|
638
|
+
|
|
639
|
+
const Link: any = Button.config({ component: AnchorBase })
|
|
640
|
+
|
|
641
|
+
const result = renderProps(Link)
|
|
642
|
+
expect(result['data-button-attr']).toBeUndefined()
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
it('preserves .attrs() chain when component is not changed', () => {
|
|
646
|
+
const Base: any = ({ children, $rocketstyle, $rocketstate, ...rest }: any) => ({
|
|
647
|
+
type: 'div',
|
|
648
|
+
props: rest,
|
|
649
|
+
children,
|
|
650
|
+
})
|
|
651
|
+
Base.displayName = 'Base'
|
|
652
|
+
|
|
653
|
+
const Button: any = rocketstyle()({
|
|
654
|
+
name: 'Button',
|
|
655
|
+
component: Base,
|
|
656
|
+
}).attrs((() => ({ 'data-keep': 'yes' })) as any)
|
|
657
|
+
|
|
658
|
+
const Same: any = Button.config({ DEBUG: false })
|
|
659
|
+
|
|
660
|
+
const result = renderProps(Same)
|
|
661
|
+
expect(result['data-keep']).toBe('yes')
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
it('preserves .attrs() chain when same component is re-passed via config', () => {
|
|
665
|
+
const Base: any = ({ children, $rocketstyle, $rocketstate, ...rest }: any) => ({
|
|
666
|
+
type: 'div',
|
|
667
|
+
props: rest,
|
|
668
|
+
children,
|
|
669
|
+
})
|
|
670
|
+
Base.displayName = 'Base'
|
|
671
|
+
|
|
672
|
+
const Button: any = rocketstyle()({
|
|
673
|
+
name: 'Button',
|
|
674
|
+
component: Base,
|
|
675
|
+
}).attrs((() => ({ 'data-keep': 'yes' })) as any)
|
|
676
|
+
|
|
677
|
+
const Same: any = Button.config({ component: Base })
|
|
678
|
+
|
|
679
|
+
const result = renderProps(Same)
|
|
680
|
+
expect(result['data-keep']).toBe('yes')
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
it('lets fresh attrs after component swap apply to the new component', () => {
|
|
684
|
+
const ButtonBase: any = ({ children, $rocketstyle, $rocketstate, ...rest }: any) => ({
|
|
685
|
+
type: 'button',
|
|
686
|
+
props: rest,
|
|
687
|
+
children,
|
|
688
|
+
})
|
|
689
|
+
ButtonBase.displayName = 'ButtonBase'
|
|
690
|
+
|
|
691
|
+
const AnchorBase: any = ({ children, $rocketstyle, $rocketstate, ...rest }: any) => ({
|
|
692
|
+
type: 'a',
|
|
693
|
+
props: rest,
|
|
694
|
+
children,
|
|
695
|
+
})
|
|
696
|
+
AnchorBase.displayName = 'AnchorBase'
|
|
697
|
+
|
|
698
|
+
const Button: any = rocketstyle()({
|
|
699
|
+
name: 'Button',
|
|
700
|
+
component: ButtonBase,
|
|
701
|
+
}).attrs((() => ({ 'data-from-button': 'original' })) as any)
|
|
702
|
+
|
|
703
|
+
const Link: any = Button.config({ component: AnchorBase }).attrs(
|
|
704
|
+
(() => ({ 'data-from-link': 'fresh' })) as any,
|
|
705
|
+
)
|
|
706
|
+
|
|
707
|
+
const result = renderProps(Link)
|
|
708
|
+
expect(result['data-from-button']).toBeUndefined()
|
|
709
|
+
expect(result['data-from-link']).toBe('fresh')
|
|
710
|
+
})
|
|
711
|
+
})
|
|
@@ -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
|
}
|