@pyreon/rocketstyle 0.24.4 → 0.24.6

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.
Files changed (60) hide show
  1. package/package.json +8 -10
  2. package/src/__tests__/attrs-overloads.test.ts +0 -97
  3. package/src/__tests__/attrs.test.ts +0 -190
  4. package/src/__tests__/cache-key-boolean-collision.test.ts +0 -54
  5. package/src/__tests__/chaining.test.ts +0 -86
  6. package/src/__tests__/collection.test.ts +0 -35
  7. package/src/__tests__/compose.test.ts +0 -36
  8. package/src/__tests__/context.test.ts +0 -200
  9. package/src/__tests__/createLocalProvider.test.ts +0 -280
  10. package/src/__tests__/dimensions.test.ts +0 -183
  11. package/src/__tests__/e2e-styler.test.ts +0 -299
  12. package/src/__tests__/hooks.test.ts +0 -178
  13. package/src/__tests__/isRocketComponent.test.ts +0 -48
  14. package/src/__tests__/memo-cap.test.ts +0 -174
  15. package/src/__tests__/minimal-theme.test.ts +0 -62
  16. package/src/__tests__/misc.test.ts +0 -204
  17. package/src/__tests__/native-marker.test.ts +0 -9
  18. package/src/__tests__/providerConsumer.test.ts +0 -183
  19. package/src/__tests__/reactive-props-preservation.test.ts +0 -195
  20. package/src/__tests__/rocketstyle.browser.test.tsx +0 -481
  21. package/src/__tests__/rocketstyleIntegration.test.ts +0 -711
  22. package/src/__tests__/theme-integration.test.tsx +0 -254
  23. package/src/__tests__/themeUtils.test.ts +0 -463
  24. package/src/cache/LocalThemeManager.ts +0 -14
  25. package/src/cache/index.ts +0 -3
  26. package/src/constants/booleanTags.ts +0 -32
  27. package/src/constants/defaultDimensions.ts +0 -23
  28. package/src/constants/index.ts +0 -59
  29. package/src/context/context.ts +0 -70
  30. package/src/context/createLocalProvider.ts +0 -97
  31. package/src/context/localContext.ts +0 -37
  32. package/src/env.d.ts +0 -6
  33. package/src/hoc/index.ts +0 -3
  34. package/src/hoc/rocketstyleAttrsHoc.ts +0 -76
  35. package/src/hooks/index.ts +0 -4
  36. package/src/hooks/usePseudoState.ts +0 -79
  37. package/src/hooks/useTheme.ts +0 -48
  38. package/src/index.ts +0 -95
  39. package/src/init.ts +0 -93
  40. package/src/isRocketComponent.ts +0 -16
  41. package/src/rocketstyle.ts +0 -640
  42. package/src/types/attrs.ts +0 -23
  43. package/src/types/config.ts +0 -48
  44. package/src/types/configuration.ts +0 -69
  45. package/src/types/dimensions.ts +0 -109
  46. package/src/types/hoc.ts +0 -5
  47. package/src/types/pseudo.ts +0 -19
  48. package/src/types/rocketComponent.ts +0 -24
  49. package/src/types/rocketstyle.ts +0 -220
  50. package/src/types/styles.ts +0 -61
  51. package/src/types/theme.ts +0 -18
  52. package/src/types/utils.ts +0 -98
  53. package/src/utils/attrs.ts +0 -181
  54. package/src/utils/chaining.ts +0 -58
  55. package/src/utils/collection.ts +0 -9
  56. package/src/utils/compose.ts +0 -11
  57. package/src/utils/dimensions.ts +0 -126
  58. package/src/utils/statics.ts +0 -44
  59. package/src/utils/styles.ts +0 -18
  60. package/src/utils/theme.ts +0 -211
@@ -1,62 +0,0 @@
1
- // Locks in the "one-size app" contract for the rocketstyle Provider's
2
- // Theme type. Pre-fix, `rootSize: number` was REQUIRED in the type at
3
- // `rocketstyle/src/context/context.ts` — passing a minimal theme without
4
- // rootSize was a TypeScript error even though it works fine at runtime
5
- // (enrichTheme defaults rootSize to 16, value() defaults rootSize to 16,
6
- // makeItResponsive short-circuits when breakpoints are empty).
7
- //
8
- // User report: "[breakpoints/rootSize] are used too directly". Even in
9
- // places where runtime tolerates undefined, an over-constrained TYPE
10
- // forces downstream consumers to either lie (`as any`) or pass dummy
11
- // values they don't actually need.
12
- //
13
- // This spec is a TYPE-LEVEL assertion via `// @ts-expect-error` flips.
14
- // The flip-positive case (minimal theme) is the regression contract; if
15
- // someone re-adds the required `rootSize: number` constraint, this file
16
- // FAILS to compile because the @ts-expect-error directives become dead.
17
- import type { TProvider } from '../context/context'
18
-
19
- describe('rocketstyle Provider — minimal theme typing', () => {
20
- it('TYPE: theme with only colors (no rootSize, no breakpoints) is accepted', () => {
21
- // Should NOT be a type error — this is the "one-size app" shape.
22
- const props: TProvider = {
23
- theme: { colors: { primary: '#228be6' } },
24
- children: null,
25
- }
26
- expect(props.theme).toEqual({ colors: { primary: '#228be6' } })
27
- })
28
-
29
- it('TYPE: theme with breakpoints but no rootSize is accepted', () => {
30
- const props: TProvider = {
31
- theme: { breakpoints: { xs: 0, sm: 576 }, colors: { primary: '#228be6' } },
32
- children: null,
33
- }
34
- expect(props.theme?.breakpoints).toEqual({ xs: 0, sm: 576 })
35
- })
36
-
37
- it('TYPE: theme with rootSize but no breakpoints is accepted', () => {
38
- const props: TProvider = {
39
- theme: { rootSize: 16, colors: { primary: '#228be6' } },
40
- children: null,
41
- }
42
- expect(props.theme?.rootSize).toBe(16)
43
- })
44
-
45
- it('TYPE: full theme (rootSize + breakpoints) still accepted (backward compat)', () => {
46
- const props: TProvider = {
47
- theme: {
48
- rootSize: 16,
49
- breakpoints: { xs: 0, sm: 576, md: 768 },
50
- colors: { primary: '#228be6' },
51
- },
52
- children: null,
53
- }
54
- expect(props.theme?.rootSize).toBe(16)
55
- expect(props.theme?.breakpoints).toEqual({ xs: 0, sm: 576, md: 768 })
56
- })
57
-
58
- it('TYPE: omitting theme entirely is still accepted (was already optional)', () => {
59
- const props: TProvider = { children: null }
60
- expect(props.theme).toBeUndefined()
61
- })
62
- })
@@ -1,204 +0,0 @@
1
- import ThemeManager from '../cache/LocalThemeManager'
2
- import {
3
- ALL_RESERVED_KEYS,
4
- CONFIG_KEYS,
5
- MODE_DEFAULT,
6
- PSEUDO_KEYS,
7
- PSEUDO_META_KEYS,
8
- STATIC_KEYS,
9
- STYLING_KEYS,
10
- THEME_MODES,
11
- THEME_MODES_INVERSED,
12
- } from '../constants'
13
- import BOOLEAN_TAGS from '../constants/booleanTags'
14
- import DEFAULT_DIMENSIONS from '../constants/defaultDimensions'
15
- import { createStaticsChainingEnhancers, createStaticsEnhancers } from '../utils/statics'
16
- import { calculateStyles } from '../utils/styles'
17
-
18
- describe('createStaticsChainingEnhancers', () => {
19
- it('attaches chaining methods for dimension keys + static keys', () => {
20
- const context: Record<string, any> = {}
21
- const func = vi.fn()
22
-
23
- createStaticsChainingEnhancers({
24
- context,
25
- dimensionKeys: ['states', 'sizes'],
26
- func,
27
- options: {} as any,
28
- })
29
-
30
- expect(typeof context.states).toBe('function')
31
- expect(typeof context.sizes).toBe('function')
32
- expect(typeof context.theme).toBe('function')
33
- expect(typeof context.styles).toBe('function')
34
- expect(typeof context.compose).toBe('function')
35
- })
36
-
37
- it('calls func with options and key-value pair', () => {
38
- const context: Record<string, any> = {}
39
- const func = vi.fn()
40
- const options = { some: 'option' } as any
41
-
42
- createStaticsChainingEnhancers({
43
- context,
44
- dimensionKeys: ['states'],
45
- func,
46
- options,
47
- })
48
-
49
- context.states({ primary: { color: 'red' } })
50
- expect(func).toHaveBeenCalledWith(options, {
51
- states: { primary: { color: 'red' } },
52
- })
53
- })
54
- })
55
-
56
- describe('createStaticsEnhancers', () => {
57
- it('copies options to context', () => {
58
- const context: Record<string, any> = {}
59
- createStaticsEnhancers({ context, options: { foo: 'bar', baz: 42 } })
60
- expect(context.foo).toBe('bar')
61
- expect(context.baz).toBe(42)
62
- })
63
-
64
- it('does nothing when options is empty', () => {
65
- const context: Record<string, any> = { existing: true }
66
- createStaticsEnhancers({ context, options: {} })
67
- expect(context).toEqual({ existing: true })
68
- })
69
-
70
- it('does nothing when options is undefined', () => {
71
- const context: Record<string, any> = { existing: true }
72
- createStaticsEnhancers({ context, options: undefined as any })
73
- expect(context).toEqual({ existing: true })
74
- })
75
- })
76
-
77
- describe('calculateStyles', () => {
78
- it('returns empty array when styles is undefined', () => {
79
- expect(calculateStyles(undefined)).toEqual([])
80
- })
81
-
82
- it('evaluates each style callback', () => {
83
- const styleFn1 = () => 'style1'
84
- const styleFn2 = () => 'style2'
85
- const result = calculateStyles([styleFn1, styleFn2] as any)
86
- expect(result).toHaveLength(2)
87
- expect(result[0]).toBe('style1')
88
- expect(result[1]).toBe('style2')
89
- })
90
-
91
- it('returns empty array for empty styles array', () => {
92
- const result = calculateStyles([])
93
- expect(result).toEqual([])
94
- })
95
- })
96
-
97
- describe('ThemeManager', () => {
98
- it('creates instance with WeakMap caches', () => {
99
- const tm = new ThemeManager()
100
- expect(tm.baseTheme).toBeInstanceOf(WeakMap)
101
- expect(tm.dimensionsThemes).toBeInstanceOf(WeakMap)
102
- expect(tm.modeBaseTheme.light).toBeInstanceOf(WeakMap)
103
- expect(tm.modeBaseTheme.dark).toBeInstanceOf(WeakMap)
104
- expect(tm.modeDimensionTheme.light).toBeInstanceOf(WeakMap)
105
- expect(tm.modeDimensionTheme.dark).toBeInstanceOf(WeakMap)
106
- })
107
-
108
- it('caches and retrieves values', () => {
109
- const tm = new ThemeManager()
110
- const key = {}
111
- tm.baseTheme.set(key, { color: 'red' })
112
- expect(tm.baseTheme.get(key)).toEqual({ color: 'red' })
113
- })
114
-
115
- it('mode caches are independent', () => {
116
- const tm = new ThemeManager()
117
- const key = {}
118
- tm.modeBaseTheme.light.set(key, 'light-theme')
119
- tm.modeBaseTheme.dark.set(key, 'dark-theme')
120
- expect(tm.modeBaseTheme.light.get(key)).toBe('light-theme')
121
- expect(tm.modeBaseTheme.dark.get(key)).toBe('dark-theme')
122
- })
123
- })
124
-
125
- describe('constants', () => {
126
- it('MODE_DEFAULT is light', () => {
127
- expect(MODE_DEFAULT).toBe('light')
128
- })
129
-
130
- it('PSEUDO_KEYS', () => {
131
- expect(PSEUDO_KEYS).toEqual(['hover', 'active', 'focus', 'pressed'])
132
- })
133
-
134
- it('PSEUDO_META_KEYS', () => {
135
- expect(PSEUDO_META_KEYS).toEqual(['disabled', 'readOnly'])
136
- })
137
-
138
- it('THEME_MODES', () => {
139
- expect(THEME_MODES.light).toBe(true)
140
- expect(THEME_MODES.dark).toBe(true)
141
- })
142
-
143
- it('THEME_MODES_INVERSED', () => {
144
- expect(THEME_MODES_INVERSED.light).toBe('dark')
145
- expect(THEME_MODES_INVERSED.dark).toBe('light')
146
- })
147
-
148
- it('CONFIG_KEYS includes expected keys', () => {
149
- expect(CONFIG_KEYS).toContain('provider')
150
- expect(CONFIG_KEYS).toContain('consumer')
151
- expect(CONFIG_KEYS).toContain('name')
152
- expect(CONFIG_KEYS).toContain('component')
153
- expect(CONFIG_KEYS).toContain('inversed')
154
- expect(CONFIG_KEYS).toContain('passProps')
155
- expect(CONFIG_KEYS).toContain('styled')
156
- expect(CONFIG_KEYS).toContain('DEBUG')
157
- })
158
-
159
- it('STYLING_KEYS', () => {
160
- expect(STYLING_KEYS).toEqual(['theme', 'styles'])
161
- })
162
-
163
- it('STATIC_KEYS includes styling keys and compose', () => {
164
- expect(STATIC_KEYS).toContain('theme')
165
- expect(STATIC_KEYS).toContain('styles')
166
- expect(STATIC_KEYS).toContain('compose')
167
- })
168
-
169
- it('ALL_RESERVED_KEYS includes mode keys and others', () => {
170
- expect(ALL_RESERVED_KEYS).toContain('light')
171
- expect(ALL_RESERVED_KEYS).toContain('dark')
172
- expect(ALL_RESERVED_KEYS).toContain('attrs')
173
- expect(ALL_RESERVED_KEYS).toContain('theme')
174
- expect(ALL_RESERVED_KEYS).toContain('compose')
175
- })
176
- })
177
-
178
- describe('DEFAULT_DIMENSIONS', () => {
179
- it('has states, sizes, variants, multiple', () => {
180
- expect(DEFAULT_DIMENSIONS.states).toBe('state')
181
- expect(DEFAULT_DIMENSIONS.sizes).toBe('size')
182
- expect(DEFAULT_DIMENSIONS.variants).toBe('variant')
183
- expect(DEFAULT_DIMENSIONS.multiple).toEqual({
184
- propName: 'multiple',
185
- multi: true,
186
- })
187
- })
188
- })
189
-
190
- describe('BOOLEAN_TAGS', () => {
191
- it('is an array of HTML boolean attributes', () => {
192
- expect(Array.isArray(BOOLEAN_TAGS)).toBe(true)
193
- expect(BOOLEAN_TAGS).toContain('disabled')
194
- expect(BOOLEAN_TAGS).toContain('checked')
195
- expect(BOOLEAN_TAGS).toContain('readOnly')
196
- expect(BOOLEAN_TAGS).toContain('required')
197
- expect(BOOLEAN_TAGS).toContain('hidden')
198
- expect(BOOLEAN_TAGS).toContain('autoFocus')
199
- })
200
-
201
- it('has more than 20 entries', () => {
202
- expect(BOOLEAN_TAGS.length).toBeGreaterThan(20)
203
- })
204
- })
@@ -1,9 +0,0 @@
1
- import { isNativeCompat } from '@pyreon/core'
2
- import { describe, expect, it } from 'vitest'
3
- import RocketstyleProvider from '../context/context'
4
-
5
- describe('native-compat marker — @pyreon/rocketstyle', () => {
6
- it('Provider is marked native', () => {
7
- expect(isNativeCompat(RocketstyleProvider)).toBe(true)
8
- })
9
- })
@@ -1,183 +0,0 @@
1
- import { popContext, pushContext } from '@pyreon/core'
2
- import {
3
- BaseComponent,
4
- buildThemeContextMap,
5
- initTestConfig,
6
- withThemeContext,
7
- } from '@pyreon/test-utils'
8
- import rocketstyle from '../init'
9
-
10
- let cleanup: () => void
11
- beforeAll(() => {
12
- cleanup = initTestConfig()
13
- })
14
- afterAll(() => cleanup())
15
-
16
- /** Child component that reads consumer context */
17
- const ChildComponent: any = ({
18
- children,
19
- $rocketstyle,
20
- $rocketstate,
21
- parentHover,
22
- ...rest
23
- }: any) => ({
24
- type: 'div',
25
- props: { ...rest, 'data-parent-hover': parentHover ?? 'none' },
26
- children,
27
- })
28
- ChildComponent.displayName = 'ChildComponent'
29
-
30
- // --------------------------------------------------------
31
- // Provider/Consumer integration
32
- // --------------------------------------------------------
33
- describe('Provider/Consumer integration', () => {
34
- describe('provider component', () => {
35
- it('renders with provider: true', () => {
36
- const ParentButton: any = rocketstyle()({
37
- name: 'ProviderButton',
38
- component: BaseComponent,
39
- }).config({ provider: true })
40
-
41
- const result = withThemeContext(() => ParentButton({ children: 'Child' }))
42
- expect(result).toBeDefined()
43
- })
44
-
45
- it('detects pseudo-state via $rocketstate on provider', () => {
46
- const ParentButton: any = rocketstyle()({
47
- name: 'HoverProvider',
48
- component: BaseComponent,
49
- }).config({ provider: true })
50
-
51
- const result = withThemeContext(() => ParentButton({ children: 'Child' }))
52
- // Provider wraps with createLocalProvider which injects pseudo state
53
- // Initial state should be false
54
- expect(result.props['data-hover']).toBe('false')
55
- expect(result.props['data-focus']).toBe('false')
56
- expect(result.props['data-pressed']).toBe('false')
57
- })
58
- })
59
-
60
- describe('consumer component', () => {
61
- it('consumer receives pseudo-state from provider context', () => {
62
- const Parent: any = rocketstyle()({
63
- name: 'ParentProvider',
64
- component: BaseComponent,
65
- }).config({ provider: true })
66
-
67
- const Child: any = rocketstyle()({
68
- name: 'ChildConsumer',
69
- component: ChildComponent,
70
- }).config({
71
- consumer: (ctx: any) =>
72
- ctx((rawCtx: any) => ({
73
- parentHover: rawCtx?.pseudo?.hover ? 'yes' : 'no',
74
- })),
75
- })
76
-
77
- // Render parent, then render child within the same context
78
- withThemeContext(() => {
79
- const _parentResult = Parent({ children: null })
80
- // The parent pushes local context — child should see it
81
- const childResult = Child({})
82
- expect(childResult).toBeDefined()
83
- const childProps = childResult?.props ?? childResult
84
- expect(childProps['data-parent-hover']).toBe('no')
85
- })
86
- })
87
-
88
- it('consumer without provider returns default pseudo', () => {
89
- const Child: any = rocketstyle()({
90
- name: 'OrphanConsumer',
91
- component: ChildComponent,
92
- }).config({
93
- consumer: (ctx: any) =>
94
- ctx((rawCtx: any) => ({
95
- parentHover: rawCtx?.pseudo?.hover ? 'yes' : 'no',
96
- })),
97
- })
98
-
99
- const result = withThemeContext(() => Child({}))
100
- const props = result?.props ?? result
101
- expect(props['data-parent-hover']).toBe('no')
102
- })
103
-
104
- it('component without consumer ignores provider context', () => {
105
- const Parent: any = rocketstyle()({
106
- name: 'IgnoredProvider',
107
- component: BaseComponent,
108
- }).config({ provider: true })
109
-
110
- const Child: any = rocketstyle()({
111
- name: 'NoConsumer',
112
- component: BaseComponent,
113
- }).config({})
114
-
115
- withThemeContext(() => {
116
- Parent({ children: null })
117
- const childResult = Child({})
118
- expect(childResult).toBeDefined()
119
- })
120
- })
121
- })
122
-
123
- describe('theme mode', () => {
124
- it('light mode is default', () => {
125
- const Button: any = rocketstyle()({
126
- name: 'LightButton',
127
- component: BaseComponent,
128
- }).config({})
129
-
130
- const result = withThemeContext(() => Button({}))
131
- expect(result).toBeDefined()
132
- })
133
-
134
- it('dark mode is passed through Provider', () => {
135
- const Button: any = rocketstyle()({
136
- name: 'DarkButton',
137
- component: BaseComponent,
138
- }).config({})
139
-
140
- pushContext(
141
- buildThemeContextMap({ mode: 'dark', isDark: true, isLight: false }),
142
- )
143
- try {
144
- const result = Button({})
145
- expect(result).toBeDefined()
146
- } finally {
147
- popContext()
148
- }
149
- })
150
-
151
- it('inversed config flips the mode', () => {
152
- const Button: any = rocketstyle()({
153
- name: 'InversedButton',
154
- component: BaseComponent,
155
- }).config({ inversed: true })
156
-
157
- const result = withThemeContext(() => Button({}))
158
- expect(result).toBeDefined()
159
- })
160
- })
161
-
162
- describe('nested providers', () => {
163
- it('supports nested provider components', () => {
164
- const Outer: any = rocketstyle()({
165
- name: 'OuterProvider',
166
- component: BaseComponent,
167
- }).config({ provider: true })
168
-
169
- const Inner: any = rocketstyle()({
170
- name: 'InnerProvider',
171
- component: BaseComponent,
172
- }).config({ provider: true })
173
-
174
- withThemeContext(() => {
175
- const outerResult = Outer({ children: null })
176
- expect(outerResult).toBeDefined()
177
-
178
- const innerResult = Inner({ children: null })
179
- expect(innerResult).toBeDefined()
180
- })
181
- })
182
- })
183
- })
@@ -1,195 +0,0 @@
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
- })