@pyreon/rocketstyle 0.14.0 → 0.16.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.
@@ -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.
@@ -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,11 +2,8 @@
2
2
  // literal-replaced so prod bundles tree-shake the dev branch to zero bytes.
3
3
  // Typed through a narrowing interface so downstream packages don't need
4
4
  // `vite/client` in their tsconfigs to type-check this file transitively.
5
- interface ViteMeta {
6
- readonly env?: { readonly DEV?: boolean }
7
- }
8
5
  /** Tree-shakeable dev-mode flag. `true` in dev, `false` (dead code eliminated) in prod. */
9
- export const __DEV__: boolean = (import.meta as ViteMeta).env?.DEV === true
6
+ export const __DEV__: boolean = process.env.NODE_ENV !== 'production'
10
7
 
11
8
  /** Default theme mode used when no mode is provided via context. */
12
9
  export const MODE_DEFAULT = 'light'
@@ -1,5 +1,5 @@
1
1
  import type { VNodeChild } from '@pyreon/core'
2
- import { useContext } from '@pyreon/core'
2
+ import { nativeCompat, useContext } from '@pyreon/core'
3
3
  import { Provider as CoreProvider, context } from '@pyreon/ui-core'
4
4
  import { MODE_DEFAULT, THEME_MODES_INVERSED } from '../constants'
5
5
 
@@ -49,6 +49,10 @@ const Provider = ({ provider = CoreProvider, inversed, ...props }: TProvider): V
49
49
  return result ?? null
50
50
  }
51
51
 
52
+ // Mark as native — reads useContext() and delegates to CoreProvider, both
53
+ // of which need Pyreon's setup frame.
54
+ nativeCompat(Provider)
55
+
52
56
  export { context }
53
57
 
54
58
  export default Provider
package/src/env.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Minimal process type — just enough for `process.env.NODE_ENV` checks.
3
+ * Avoids requiring @types/node in consumers that import pyreon source
4
+ * via the `"bun"` export condition.
5
+ */
6
+ declare var process: { env: { NODE_ENV?: string } }
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@ import type { Rocketstyle } from './init'
4
4
  import rocketstyle from './init'
5
5
  import type { IsRocketComponent } from './isRocketComponent'
6
6
  import isRocketComponent from './isRocketComponent'
7
- import type { AttrsCb } from './types/attrs'
7
+ import type { AttrsCb, AttrsHelpers } from './types/attrs'
8
8
  import type {
9
9
  ConfigAttrs,
10
10
  ConsumerCb,
@@ -37,6 +37,7 @@ import type { ComponentFn, ElementType, ExtractProps, MergeTypes, TObj } from '.
37
37
 
38
38
  export type {
39
39
  AttrsCb,
40
+ AttrsHelpers,
40
41
  ComponentFn,
41
42
  ComposeParam,
42
43
  ConfigAttrs,