@pyreon/rocketstyle 0.15.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.
package/lib/index.d.ts CHANGED
@@ -87,11 +87,47 @@ type ExtractNullableKeys<T> = { [P in keyof T as IsAny<T[P]> extends true ? P :
87
87
  type SpreadTwo<L, R> = Id<Pick<L, Exclude<keyof L, keyof R>> & R>;
88
88
  type Spread<A extends readonly [...any]> = A extends [infer L, ...infer R] ? SpreadTwo<L, Spread<R>> : unknown;
89
89
  type MergeTypes<A extends readonly [...any]> = ExtractNullableKeys<Spread<A>>;
90
- type ExtractProps<TComponentOrTProps> = TComponentOrTProps extends ComponentFn<infer TProps> ? TProps : TComponentOrTProps;
90
+ /**
91
+ * Extracts the props type from a Pyreon component function — or passes
92
+ * through the input unchanged when it's already a props type.
93
+ *
94
+ * Multi-overload aware: matches up to 4 call signatures and produces the
95
+ * UNION of their first-argument types. A single-overload function still
96
+ * works (the union of 4 copies of the same props type dedupes back to
97
+ * the single shape).
98
+ *
99
+ * **Why this shape**. `T extends (props: infer P) => any ? P : never` only
100
+ * captures the LAST overload of a multi-overload function — TS's overload-
101
+ * resolution-against-conditional-types semantics. Iterator / List / Element
102
+ * are 3-overload primitives where the LAST overload (`ChildrenProps`) is the
103
+ * loosest; without overload-aware extraction, `ExtractProps<Iterator>`
104
+ * returned just `ChildrenProps` and lost both `SimpleProps<T>` and
105
+ * `ObjectProps<T>` — wrapping Iterator through `rocketstyle()` /
106
+ * `attrs()` silently downgraded the public prop surface.
107
+ *
108
+ * The pattern-match shape `T extends { (props: infer P1, ...args: any): any;
109
+ * (props: infer P2, ...args: any): any; ... }` is the canonical TS trick
110
+ * for extracting overload sets — see also `Parameters<T>` semantics.
111
+ *
112
+ * Mirrors vitus-labs PR #222.
113
+ */
114
+ type ExtractProps<TComponentOrTProps> = TComponentOrTProps extends {
115
+ (props: infer P1, ...args: any): any;
116
+ (props: infer P2, ...args: any): any;
117
+ (props: infer P3, ...args: any): any;
118
+ (props: infer P4, ...args: any): any;
119
+ } ? P1 | P2 | P3 | P4 : TComponentOrTProps extends {
120
+ (props: infer P1, ...args: any): any;
121
+ (props: infer P2, ...args: any): any;
122
+ (props: infer P3, ...args: any): any;
123
+ } ? P1 | P2 | P3 : TComponentOrTProps extends {
124
+ (props: infer P1, ...args: any): any;
125
+ (props: infer P2, ...args: any): any;
126
+ } ? P1 | P2 : TComponentOrTProps extends ComponentFn<infer TProps> ? TProps : TComponentOrTProps;
91
127
  //#endregion
92
128
  //#region src/types/styles.d.ts
93
129
  interface StylesDefault {}
94
- type Styles<S = unknown> = StylesDefault;
130
+ type Styles<_S = unknown> = StylesDefault;
95
131
  type Css = typeof config.css;
96
132
  /**
97
133
  * Props available inside `.styles()` interpolation functions.
@@ -226,12 +262,25 @@ type DefaultProps = Partial<PseudoProps> & {
226
262
  };
227
263
  //#endregion
228
264
  //#region src/types/attrs.d.ts
229
- type AttrsCb<A, T> = (props: Partial<A>, theme: T, helpers: {
265
+ /** Helpers object passed as the 3rd arg to every `.attrs(callback)`. */
266
+ type AttrsHelpers = {
230
267
  mode?: ThemeModeKeys;
231
268
  isDark?: boolean;
232
269
  isLight?: boolean;
233
270
  createElement: typeof render;
234
- }) => Partial<A>;
271
+ };
272
+ /**
273
+ * Callback signature for `.attrs((props, theme, helpers) => …)`.
274
+ *
275
+ * `Partial<A>` on the return is for the strict-typing form when callers
276
+ * pass `AttrsCb<DFP, Theme<T>>` directly. In the rocketstyle `.attrs()`
277
+ * callback overload itself we use a different shape that decouples the
278
+ * props arg (narrow, full DFP) from the return type (loose — only the
279
+ * user's explicit `<P>` generic is checked, with `Record<string,
280
+ * unknown>` allowing runtime extras like `_documentProps`). See
281
+ * `IRocketStyleComponent.attrs` for the call-site shape.
282
+ */
283
+ type AttrsCb<A, T> = (props: Partial<A>, theme: T, helpers: AttrsHelpers) => Partial<A>;
235
284
  //#endregion
236
285
  //#region src/types/hoc.d.ts
237
286
  type GenericHoc = (component: ElementType) => ElementType;
@@ -250,7 +299,7 @@ type RocketStyleComponent<OA extends TObj = {}, EA extends TObj = {}, T extends
250
299
  * @param DKP Dimensions key props.
251
300
  * @param DFP Calculated final component props
252
301
  */
253
- interface IRocketStyleComponent<OA extends TObj = {}, EA extends TObj = {}, T extends TObj = {}, CSS extends TObj = {}, S extends TObj = {}, HOC extends TObj = {}, D extends Dimensions = Dimensions, UB extends boolean = boolean, DKP extends TDKP = TDKP, DFP = MergeTypes<[OA, EA, DefaultProps, ExtractDimensionProps<D, DKP, UB>]>> {
302
+ interface IRocketStyleComponent<OA extends TObj = {}, EA extends TObj = {}, T extends TObj = {}, CSS extends TObj = {}, S extends TObj = {}, HOC extends TObj = {}, D extends Dimensions = Dimensions, UB extends boolean = boolean, DKP extends TDKP = TDKP, DFP = (OA extends infer O ? Omit<O, keyof EA & keyof O> & Partial<Pick<O, keyof EA & keyof O>> & MergeTypes<[Partial<Omit<EA, keyof O>>, DefaultProps, ExtractDimensionProps<D, DKP, UB>]> : never)> {
254
303
  (props: DFP): VNodeChild;
255
304
  config: <NC extends ElementType | unknown = unknown>({
256
305
  name,
@@ -261,10 +310,16 @@ interface IRocketStyleComponent<OA extends TObj = {}, EA extends TObj = {}, T ex
261
310
  inversed,
262
311
  passProps
263
312
  }: ConfigAttrs<NC, D, DKP, UB>) => NC extends ElementType ? RocketStyleComponent<ExtractProps<NC>, EA, T, CSS, S, HOC, D, UB, DKP> : RocketStyleComponent<OA, EA, T, CSS, S, HOC, D, UB, DKP>;
264
- attrs: <P extends TObj | unknown = unknown>(param: P extends TObj ? Partial<DFP & P> | ((props: Partial<DFP & P>, theme: Theme<T>, helpers: any) => Partial<P> & Record<string, unknown>) : Partial<DFP> | AttrsCb<DFP, Theme<T>>, config?: Partial<{
265
- priority: boolean;
266
- filter: P extends TObj ? Partial<keyof (EA & P)>[] : Partial<keyof EA>[];
267
- }>) => P extends TObj ? RocketStyleComponent<OA, MergeTypes<[EA, P]>, T, CSS, S, HOC, D, UB, DKP> : RocketStyleComponent<OA, EA, T, CSS, S, HOC, D, UB, DKP>;
313
+ attrs: {
314
+ <P extends TObj = {}>(param: (props: Partial<DFP & P>, theme: Theme<T>, helpers: AttrsHelpers) => Partial<P> & Record<string, unknown>, config?: Partial<{
315
+ priority: boolean;
316
+ filter: (keyof MergeTypes<[EA, P]>)[];
317
+ }>): RocketStyleComponent<OA, MergeTypes<[EA, P]>, T, CSS, S, HOC, D, UB, DKP>;
318
+ <P extends TObj = {}>(param: P & Partial<NoInfer<DFP>>, config?: Partial<{
319
+ priority: boolean;
320
+ filter: (keyof MergeTypes<[EA, P]>)[];
321
+ }>): RocketStyleComponent<OA, MergeTypes<[EA, P]>, T, CSS, S, HOC, D, UB, DKP>;
322
+ };
268
323
  theme: <P extends TObj = TObj>(param: Partial<P> | Partial<Styles<CSS>> | ThemeCb<P, Theme<T>>) => RocketStyleComponent<OA, EA, T, MergeTypes<[CSS, P]>, S, HOC, D, UB, DKP>;
269
324
  styles: (param: StylesCb<CSS>) => RocketStyleComponent<OA, EA, T, CSS, S, HOC, D, UB, DKP>;
270
325
  compose: <P extends ComposeParam>(param: P) => P extends TObj ? RocketStyleComponent<OA, EA, T, CSS, S, MergeTypes<[HOC, P]>, D, UB, DKP> : RocketStyleComponent<OA, EA, T, CSS, S, HOC, D, UB, DKP>;
@@ -337,5 +392,5 @@ declare const isRocketComponent: IsRocketComponent;
337
392
  */
338
393
  declare function resolveTheme<T = Record<string, unknown>>(value: (() => T) | T): T;
339
394
  //#endregion
340
- export { type AttrsCb, type ComponentFn, type ComposeParam, type ConfigAttrs, type ConsumerCb, type ConsumerCtxCBValue, type ConsumerCtxCb, type DefaultProps, type DimensionCallbackParam, type DimensionProps, type DimensionValue, type Dimensions, type ElementType, type ExtractDimensionProps, type ExtractDimensions, type ExtractProps, type GenericHoc, type IRocketStyleComponent, type IsRocketComponent, type MergeTypes, Provider, type RocketComponentType, type RocketProviderState, type RocketStyleComponent, type RocketStyleInterpolationProps, type Rocketstyle, type StylesCb, type StylesDefault, type TDKP, type TObj, type TProvider, type ThemeCb, type ThemeDefault, type ThemeMode, type ThemeModeCallback, type ThemeModeKeys, context, rocketstyle as default, rocketstyle, isRocketComponent, resolveTheme };
395
+ export { type AttrsCb, type AttrsHelpers, type ComponentFn, type ComposeParam, type ConfigAttrs, type ConsumerCb, type ConsumerCtxCBValue, type ConsumerCtxCb, type DefaultProps, type DimensionCallbackParam, type DimensionProps, type DimensionValue, type Dimensions, type ElementType, type ExtractDimensionProps, type ExtractDimensions, type ExtractProps, type GenericHoc, type IRocketStyleComponent, type IsRocketComponent, type MergeTypes, Provider, type RocketComponentType, type RocketProviderState, type RocketStyleComponent, type RocketStyleInterpolationProps, type Rocketstyle, type StylesCb, type StylesDefault, type TDKP, type TObj, type TProvider, type ThemeCb, type ThemeDefault, type ThemeMode, type ThemeModeCallback, type ThemeModeKeys, context, rocketstyle as default, rocketstyle, isRocketComponent, resolveTheme };
341
396
  //# sourceMappingURL=index2.d.ts.map
package/lib/index.js CHANGED
@@ -472,23 +472,38 @@ const getThemeByMode = (object, mode) => Object.keys(object).reduce((acc, key) =
472
472
  //#endregion
473
473
  //#region src/rocketstyle.ts
474
474
  const _countSink = globalThis;
475
- /** Clones the current configuration and merges new options, returning a fresh rocketComponent. */
476
- const cloneAndEnhance = (defaultOpts, opts) => rocketComponent({
477
- ...defaultOpts,
478
- attrs: chainOptions(opts.attrs, defaultOpts.attrs),
479
- filterAttrs: [...defaultOpts.filterAttrs ?? [], ...opts.filterAttrs ?? []],
480
- priorityAttrs: chainOptions(opts.priorityAttrs, defaultOpts.priorityAttrs),
481
- statics: {
482
- ...defaultOpts.statics,
483
- ...opts.statics
484
- },
485
- compose: {
486
- ...defaultOpts.compose,
487
- ...opts.compose
488
- },
489
- ...chainOrOptions(CONFIG_KEYS, opts, defaultOpts),
490
- ...chainReservedKeyOptions([...defaultOpts.dimensionKeys, ...STYLING_KEYS], opts, defaultOpts)
491
- });
475
+ /**
476
+ * Clones the current configuration and merges new options, returning a fresh
477
+ * rocketComponent.
478
+ *
479
+ * Component-swap reset: when `opts.component` is set AND differs from the
480
+ * current `defaultOpts.component`, the prior `attrs`, `priorityAttrs`,
481
+ * `filterAttrs`, and `compose` chains are dropped — they were tailored to the
482
+ * previous component's prop shape, and applying them to a different component
483
+ * silently leaks invalid props through to the DOM (e.g. `disabled` on an
484
+ * `<a>`). Callers who want to preserve them must re-chain explicitly:
485
+ *
486
+ * const NewBtn = Button.config({ component: 'a' }).attrs(sharedAttrs)
487
+ */
488
+ const cloneAndEnhance = (defaultOpts, opts) => {
489
+ const componentChanged = opts.component != null && opts.component !== defaultOpts.component;
490
+ return rocketComponent({
491
+ ...defaultOpts,
492
+ attrs: componentChanged ? chainOptions(opts.attrs, []) : chainOptions(opts.attrs, defaultOpts.attrs),
493
+ filterAttrs: componentChanged ? [...opts.filterAttrs ?? []] : [...defaultOpts.filterAttrs ?? [], ...opts.filterAttrs ?? []],
494
+ priorityAttrs: componentChanged ? chainOptions(opts.priorityAttrs, []) : chainOptions(opts.priorityAttrs, defaultOpts.priorityAttrs),
495
+ statics: {
496
+ ...defaultOpts.statics,
497
+ ...opts.statics
498
+ },
499
+ compose: componentChanged ? { ...opts.compose } : {
500
+ ...defaultOpts.compose,
501
+ ...opts.compose
502
+ },
503
+ ...chainOrOptions(CONFIG_KEYS, opts, defaultOpts),
504
+ ...chainReservedKeyOptions([...defaultOpts.dimensionKeys, ...STYLING_KEYS], opts, defaultOpts)
505
+ });
506
+ };
492
507
  const rocketComponent = (options) => {
493
508
  const { component, styles } = options;
494
509
  const { styled } = config;
@@ -548,11 +563,16 @@ const rocketComponent = (options) => {
548
563
  const _resolveRsEntry = () => {
549
564
  const theme = themeAttrs.theme;
550
565
  const mode = themeAttrs.mode;
551
- let key = mode;
552
566
  const propsRec = props;
567
+ const rocketstateRaw = _calculateStylingAttrs({
568
+ props: pickStyledAttrs(propsRec, reservedPropNames),
569
+ dimensions
570
+ });
571
+ let key = mode;
553
572
  for (const dimName in dimensions) {
554
- const v = propsRec[dimName];
555
- key += "|" + (typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? String(v) : v === void 0 ? "" : "~" + typeof v);
573
+ const v = rocketstateRaw[dimName];
574
+ if (Array.isArray(v)) key += "|" + (v.length === 0 ? "" : v.slice().sort().join(","));
575
+ else key += "|" + (typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? String(v) : v === void 0 ? "" : "~" + typeof v);
556
576
  }
557
577
  for (const k of ALL_PSEUDO_KEYS) {
558
578
  const propV = propsRec[k];
@@ -583,10 +603,6 @@ const rocketComponent = (options) => {
583
603
  if (process.env.NODE_ENV !== "production") _countSink.__pyreon_count__?.("rocketstyle.localThemeManager.hit");
584
604
  } else dimHelper.set(theme, getDimensionThemes(theme, options));
585
605
  const themes = dimHelper.get(theme);
586
- const rocketstateRaw = _calculateStylingAttrs({
587
- props: pickStyledAttrs(propsRec, reservedPropNames),
588
- dimensions
589
- });
590
606
  const modeBaseHelper = ThemeManager$1.modeBaseTheme[mode];
591
607
  if (modeBaseHelper.has(baseTheme)) {
592
608
  if (process.env.NODE_ENV !== "production") _countSink.__pyreon_count__?.("rocketstyle.localThemeManager.hit");
@@ -715,8 +731,8 @@ const rocketComponent = (options) => {
715
731
  {
716
732
  render,
717
733
  mode,
718
- isDark: mode === "light",
719
- isLight: mode === "dark"
734
+ isDark: mode === "dark",
735
+ isLight: mode === "light"
720
736
  }
721
737
  ])
722
738
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/rocketstyle",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Multi-dimensional style composition for Pyreon components",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,19 +42,19 @@
42
42
  "typecheck": "tsc --noEmit"
43
43
  },
44
44
  "devDependencies": {
45
- "@pyreon/test-utils": "^0.13.2",
46
- "@pyreon/typescript": "^0.15.0",
47
- "@pyreon/ui-core": "^0.15.0",
45
+ "@pyreon/test-utils": "^0.13.3",
46
+ "@pyreon/typescript": "^0.16.0",
47
+ "@pyreon/ui-core": "^0.16.0",
48
48
  "@vitest/browser-playwright": "^4.1.4",
49
49
  "@vitus-labs/tools-rolldown": "^2.3.0"
50
50
  },
51
- "peerDependencies": {
52
- "@pyreon/core": "^0.15.0",
53
- "@pyreon/reactivity": "^0.15.0",
54
- "@pyreon/styler": "^0.15.0",
55
- "@pyreon/ui-core": "^0.15.0"
56
- },
57
51
  "engines": {
58
52
  "node": ">= 22"
53
+ },
54
+ "dependencies": {
55
+ "@pyreon/core": "^0.16.0",
56
+ "@pyreon/reactivity": "^0.16.0",
57
+ "@pyreon/styler": "^0.16.0",
58
+ "@pyreon/ui-core": "^0.16.0"
59
59
  }
60
60
  }
@@ -0,0 +1,97 @@
1
+ import type { ComponentFn } from '@pyreon/core'
2
+ import rocketstyle from '../init'
3
+
4
+ // Type-level regression tests for the post-#225/#227 `.attrs()` overload
5
+ // split: (a) DFP widening makes `.attrs(obj)` keys optional at JSX call site,
6
+ // (b) callback overload preserves Pyreon's loose-return convention so
7
+ // `_documentProps` / `tag: 'a'` runtime extras still typecheck without
8
+ // per-callsite `as any` casts.
9
+ //
10
+ // These are not bisect-load-bearing at runtime — they're type-level
11
+ // assertions exercised by `tsc --noEmit`. Including them in the suite
12
+ // makes failures show up in the test report (vitest treats type errors
13
+ // as compile failures).
14
+ describe('attrs overloads — type-level contract', () => {
15
+ // A minimal base component standing in for Text / Button / etc.
16
+ // We only care about the type-level surface here.
17
+ type BaseProps = {
18
+ tag?: 'div' | 'span' | 'p' | 'h1' | 'h2' | 'h3'
19
+ role?: string
20
+ }
21
+ const Base: ComponentFn<BaseProps> = () => null
22
+
23
+ describe('object overload — keys become optional at JSX site (PR #225)', () => {
24
+ it('accepts object with default values', () => {
25
+ const Comp = rocketstyle()({ name: 'Comp', component: Base }).attrs({
26
+ tag: 'div',
27
+ })
28
+ // The component is callable — at the JSX call site, `tag` is now
29
+ // optional because `.attrs({ tag: 'div' })` provides a default.
30
+ // Pre-#225 the type would have required `tag` at the JSX site.
31
+ // We can't directly assert via `expectTypeOf` without the dep, but
32
+ // the smoke is: the chain compiles without errors.
33
+ expect(typeof Comp).toBe('function')
34
+ })
35
+
36
+ it('accepts new keys in attrs object', () => {
37
+ // The attrs object can introduce new keys beyond the base component's
38
+ // props (here: `customField`). The keys flow into the returned
39
+ // component's extended-attrs `EA` and become typed props.
40
+ const Comp = rocketstyle()({ name: 'Comp', component: Base }).attrs({
41
+ customField: 'hello',
42
+ })
43
+ expect(typeof Comp).toBe('function')
44
+ })
45
+ })
46
+
47
+ describe('callback overload — Pyreon convention for runtime extras', () => {
48
+ it('accepts callback returning fields outside the base prop union (no as-cast needed)', () => {
49
+ // This is the canonical document-primitive pattern: a Text-based
50
+ // rocketstyle that overrides `tag` to a value outside Text's strict
51
+ // `tag` union AND adds a runtime-only `_documentProps` marker. The
52
+ // callback's return type intentionally allows `Record<string, unknown>`
53
+ // for keys outside the user's explicit `<P>` generic, matching
54
+ // Pyreon's pre-#225 convention.
55
+ const Comp = rocketstyle()({ name: 'DocLink', component: Base }).attrs<{
56
+ href?: string
57
+ }>((props) => ({
58
+ tag: 'a', // 'a' is NOT in BaseProps['tag'] — falls through Record<string, unknown>
59
+ _documentProps: { href: props.href ?? '#' },
60
+ }))
61
+ expect(typeof Comp).toBe('function')
62
+ })
63
+
64
+ it('accepts callback returning literal values (contextual narrowing via <P>)', () => {
65
+ // When the user passes an explicit `<P>` generic, the callback's
66
+ // return is contextually typed against `Partial<P>`. Writing
67
+ // `tag: 'h1'` stays narrow at literal `'h1'` — no `as const` needed.
68
+ // Note: tag is in BaseProps['tag'] union so this typechecks against
69
+ // BOTH the wildcard arm AND the explicit P-key arm.
70
+ const Comp = rocketstyle()({ name: 'Heading', component: Base }).attrs<{
71
+ level?: number
72
+ }>((props) => ({
73
+ tag: `h${props.level ?? 1}` as 'h1' | 'h2' | 'h3',
74
+ }))
75
+ expect(typeof Comp).toBe('function')
76
+ })
77
+
78
+ it('callback receives full DFP-typed props for narrow reads', () => {
79
+ // The `props` arg passed to the callback IS strictly typed as
80
+ // `Partial<DFP & P>` — so reading `props.tag` narrows against the
81
+ // wrapped component's full surface. This is the "props narrow,
82
+ // return loose" asymmetry Pyreon settled on.
83
+ const Comp = rocketstyle()({ name: 'Probe', component: Base }).attrs<{
84
+ scale?: number
85
+ }>((props) => {
86
+ // Type check: `props.scale` is `number | undefined`, `props.tag`
87
+ // is the narrow BaseProps['tag'] union | undefined.
88
+ const _scale: number | undefined = props.scale
89
+ const _tag: 'div' | 'span' | 'p' | 'h1' | 'h2' | 'h3' | undefined = props.tag
90
+ expect(_scale).toBeUndefined()
91
+ expect(_tag).toBeUndefined()
92
+ return { scale: 1 }
93
+ })
94
+ expect(typeof Comp).toBe('function')
95
+ })
96
+ })
97
+ })
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Bug reproduction: under `useBooleans: true`, `_resolveRsEntry`'s cache
3
+ * key reads `propsRec[dimName]` directly (e.g. `propsRec.state`). Boolean
4
+ * shorthand props like `<X primary />` populate `propsRec.primary` (NOT
5
+ * `propsRec.state`), so the cache key for the `state` slot is `undefined`
6
+ * → `''` regardless of which boolean variant was passed.
7
+ *
8
+ * Result: `<X primary />` and `<X secondary />` produce identical cache
9
+ * keys and share the cached entry. The first-resolved variant's
10
+ * `$rocketstyle` wins for all subsequent renders.
11
+ */
12
+ import { initTestConfig, withThemeContext } from '@pyreon/test-utils'
13
+ import rocketstyle from '../init'
14
+
15
+ let cleanup: () => void
16
+ beforeAll(() => {
17
+ cleanup = initTestConfig()
18
+ })
19
+ afterAll(() => cleanup())
20
+
21
+ const ThemeCapture: any = ({ $rocketstyle, $rocketstate, ...rest }: any) => ({
22
+ type: 'div',
23
+ props: rest,
24
+ $rocketstyle: typeof $rocketstyle === 'function' ? $rocketstyle() : $rocketstyle,
25
+ $rocketstate: typeof $rocketstate === 'function' ? $rocketstate() : $rocketstate,
26
+ })
27
+ ThemeCapture.displayName = 'ThemeCapture'
28
+
29
+ describe('rocketstyle — cache-key collision under useBooleans:true', () => {
30
+ it('different boolean variants produce different $rocketstyle (NOT collide)', () => {
31
+ const Button: any = rocketstyle({ useBooleans: true })({
32
+ name: 'BoolButton',
33
+ component: ThemeCapture,
34
+ }).states(() => ({
35
+ primary: { color: 'red' },
36
+ secondary: { color: 'blue' },
37
+ }))
38
+
39
+ // Render with primary=true. Captures the $rocketstyle resolved
40
+ // for state='primary'.
41
+ const a = withThemeContext(() => Button({ primary: true }))
42
+
43
+ // Render with secondary=true. Should resolve to state='secondary'
44
+ // and produce DIFFERENT $rocketstyle.
45
+ const b = withThemeContext(() => Button({ secondary: true }))
46
+
47
+ // Bug: a.$rocketstyle === b.$rocketstyle (same cached entry).
48
+ // Fix: a.$rocketstyle.color === 'red', b.$rocketstyle.color === 'blue'.
49
+ expect(a.$rocketstate.state).toBe('primary')
50
+ expect(b.$rocketstate.state).toBe('secondary')
51
+ expect(a.$rocketstyle.color).toBe('red')
52
+ expect(b.$rocketstyle.color).toBe('blue')
53
+ })
54
+ })
@@ -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
+ })
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,
@@ -38,18 +38,42 @@ type CloneAndEnhance = (
38
38
  opts: Partial<ExtendedConfiguration>,
39
39
  ) => ReturnType<typeof rocketComponent>
40
40
 
41
- /** Clones the current configuration and merges new options, returning a fresh rocketComponent. */
42
- const cloneAndEnhance: CloneAndEnhance = (defaultOpts, opts) =>
43
- rocketComponent({
41
+ /**
42
+ * Clones the current configuration and merges new options, returning a fresh
43
+ * rocketComponent.
44
+ *
45
+ * Component-swap reset: when `opts.component` is set AND differs from the
46
+ * current `defaultOpts.component`, the prior `attrs`, `priorityAttrs`,
47
+ * `filterAttrs`, and `compose` chains are dropped — they were tailored to the
48
+ * previous component's prop shape, and applying them to a different component
49
+ * silently leaks invalid props through to the DOM (e.g. `disabled` on an
50
+ * `<a>`). Callers who want to preserve them must re-chain explicitly:
51
+ *
52
+ * const NewBtn = Button.config({ component: 'a' }).attrs(sharedAttrs)
53
+ */
54
+ const cloneAndEnhance: CloneAndEnhance = (defaultOpts, opts) => {
55
+ const componentChanged =
56
+ opts.component != null && opts.component !== defaultOpts.component
57
+
58
+ return rocketComponent({
44
59
  ...defaultOpts,
45
- attrs: chainOptions(opts.attrs, defaultOpts.attrs),
46
- filterAttrs: [...(defaultOpts.filterAttrs ?? []), ...(opts.filterAttrs ?? [])],
47
- priorityAttrs: chainOptions(opts.priorityAttrs, defaultOpts.priorityAttrs),
60
+ attrs: componentChanged
61
+ ? chainOptions(opts.attrs, [])
62
+ : chainOptions(opts.attrs, defaultOpts.attrs),
63
+ filterAttrs: componentChanged
64
+ ? [...(opts.filterAttrs ?? [])]
65
+ : [...(defaultOpts.filterAttrs ?? []), ...(opts.filterAttrs ?? [])],
66
+ priorityAttrs: componentChanged
67
+ ? chainOptions(opts.priorityAttrs, [])
68
+ : chainOptions(opts.priorityAttrs, defaultOpts.priorityAttrs),
48
69
  statics: { ...defaultOpts.statics, ...opts.statics },
49
- compose: { ...defaultOpts.compose, ...opts.compose },
70
+ compose: componentChanged
71
+ ? { ...opts.compose }
72
+ : { ...defaultOpts.compose, ...opts.compose },
50
73
  ...chainOrOptions(CONFIG_KEYS, opts, defaultOpts),
51
74
  ...chainReservedKeyOptions([...defaultOpts.dimensionKeys, ...STYLING_KEYS], opts, defaultOpts),
52
75
  } as Parameters<typeof rocketComponent>[0])
76
+ }
53
77
 
54
78
  // --------------------------------------------------------
55
79
  // rocketComponent
@@ -239,23 +263,46 @@ const rocketComponent: RocketComponent = (options) => {
239
263
  // Read reactive inputs (tracks theme + mode signals)
240
264
  const theme = themeAttrs.theme
241
265
  const mode = themeAttrs.mode
266
+ const propsRec = props as Record<string, unknown>
242
267
 
243
- // Build key: mode | dimensionProps | pseudoState. Reading dimension
268
+ // Resolve active dimensions FIRST so the cache key uses the RESOLVED
269
+ // dimension values, not the raw prop names. Under `useBooleans: true`
270
+ // the user writes `<X primary />` / `<X secondary />` — both map to
271
+ // `state="primary"` / `state="secondary"` after _calculateStylingAttrs
272
+ // resolves the boolean shorthand. Keying off `propsRec[dimName]` would
273
+ // read `undefined` for both (the dimension prop itself was never set)
274
+ // and collide every variant onto the first cached entry. Reading
275
+ // `rocketstateRaw[dimName]` gives the resolved string and partitions
276
+ // them correctly.
277
+ // Resolved from props (not localCtx which has pseudo getters).
278
+ const rocketstateRaw = _calculateStylingAttrs({
279
+ props: pickStyledAttrs(propsRec, reservedPropNames),
280
+ dimensions,
281
+ })
282
+
283
+ // Build key: mode | dimensionValues | pseudoState. Reading dimension
244
284
  // props + pseudo signals here tracks them in the surrounding computed
245
285
  // so any change re-runs us with a different key.
246
286
  let key = mode as string
247
- const propsRec = props as Record<string, unknown>
248
287
  for (const dimName in dimensions) {
249
- const v = propsRec[dimName]
250
- // String/number/boolean serialize directly. Anything else (including
251
- // undefined / objects) gets a typeof tag so we don't collide.
252
- key +=
253
- '|' +
254
- (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'
255
- ? String(v)
256
- : v === undefined
257
- ? ''
258
- : '~' + typeof v)
288
+ const v = rocketstateRaw[dimName]
289
+ // Multi-key dimensions (e.g. variant={['primary', 'rounded']}) are
290
+ // arrays. Sort + join so equivalent sets hash identically; without
291
+ // this both `['a','b']` and `['b','a']` would produce different keys.
292
+ if (Array.isArray(v)) {
293
+ key +=
294
+ '|' + (v.length === 0 ? '' : (v as unknown[]).slice().sort().join(','))
295
+ } else {
296
+ // String/number/boolean serialize directly. Anything else (including
297
+ // undefined / objects) gets a typeof tag so we don't collide.
298
+ key +=
299
+ '|' +
300
+ (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'
301
+ ? String(v)
302
+ : v === undefined
303
+ ? ''
304
+ : '~' + typeof v)
305
+ }
259
306
  }
260
307
  for (const k of ALL_PSEUDO_KEYS) {
261
308
  const propV = propsRec[k]
@@ -307,12 +354,6 @@ const rocketComponent: RocketComponent = (options) => {
307
354
  }
308
355
  const themes = dimHelper.get(theme)
309
356
 
310
- // Resolve active dimensions from props (not localCtx which has pseudo getters)
311
- const rocketstateRaw = _calculateStylingAttrs({
312
- props: pickStyledAttrs(propsRec, reservedPropNames),
313
- dimensions,
314
- })
315
-
316
357
  // Resolve mode-specific theme
317
358
  const modeBaseHelper = ThemeManager.modeBaseTheme[mode]
318
359
  if (modeBaseHelper.has(baseTheme)) {
@@ -529,8 +570,8 @@ const rocketComponent: RocketComponent = (options) => {
529
570
  {
530
571
  render,
531
572
  mode,
532
- isDark: mode === 'light',
533
- isLight: mode === 'dark',
573
+ isDark: mode === 'dark',
574
+ isLight: mode === 'light',
534
575
  },
535
576
  ]),
536
577
  })
@@ -1,13 +1,23 @@
1
1
  import type { render } from '@pyreon/ui-core'
2
2
  import type { ThemeModeKeys } from './theme'
3
3
 
4
- export type AttrsCb<A, T> = (
5
- props: Partial<A>,
6
- theme: T,
7
- helpers: {
8
- mode?: ThemeModeKeys
9
- isDark?: boolean
10
- isLight?: boolean
11
- createElement: typeof render
12
- },
13
- ) => Partial<A>
4
+ /** Helpers object passed as the 3rd arg to every `.attrs(callback)`. */
5
+ export type AttrsHelpers = {
6
+ mode?: ThemeModeKeys
7
+ isDark?: boolean
8
+ isLight?: boolean
9
+ createElement: typeof render
10
+ }
11
+
12
+ /**
13
+ * Callback signature for `.attrs((props, theme, helpers) => …)`.
14
+ *
15
+ * `Partial<A>` on the return is for the strict-typing form when callers
16
+ * pass `AttrsCb<DFP, Theme<T>>` directly. In the rocketstyle `.attrs()`
17
+ * callback overload itself we use a different shape that decouples the
18
+ * props arg (narrow, full DFP) from the return type (loose — only the
19
+ * user's explicit `<P>` generic is checked, with `Record<string,
20
+ * unknown>` allowing runtime extras like `_documentProps`). See
21
+ * `IRocketStyleComponent.attrs` for the call-site shape.
22
+ */
23
+ export type AttrsCb<A, T> = (props: Partial<A>, theme: T, helpers: AttrsHelpers) => Partial<A>
@@ -1,5 +1,5 @@
1
1
  import type { VNodeChild } from '@pyreon/core'
2
- import type { AttrsCb } from './attrs'
2
+ import type { AttrsHelpers } from './attrs'
3
3
  import type { ConfigAttrs } from './config'
4
4
  import type { DefaultProps } from './configuration'
5
5
  import type {
@@ -76,7 +76,27 @@ export interface IRocketStyleComponent<
76
76
  // dimension key props
77
77
  DKP extends TDKP = TDKP,
78
78
  // calculated final props
79
- DFP = MergeTypes<[OA, EA, DefaultProps, ExtractDimensionProps<D, DKP, UB>]>,
79
+ //
80
+ // `OA extends infer O` distributes over OA's union branches — future-proofs
81
+ // against the overload-aware `ExtractProps` (follow-up PR); for today's
82
+ // single-typed OA the distribution collapses to a single branch.
83
+ //
84
+ // **OA-overlapping EA keys are widened**. When `.attrs({ tag: 'a' })` sets
85
+ // a default for `tag` on a wrapper whose OA has `tag: HTMLTags`, we strip
86
+ // `tag` from OA and re-add it as `Partial<Pick<O, 'tag'>>` — taking O's
87
+ // WIDER type (`HTMLTags`), not EA's narrow literal `'a'`. The runtime
88
+ // default is `'a'`; the JSX call site still accepts any `HTMLTags` value
89
+ // (so `<Btn tag="span" />` is valid). `Partial<Omit<EA, keyof O>>` handles
90
+ // net-new EA-only keys (no OA conflict) and marks them optional too —
91
+ // every `.attrs()` value is semantically a default, never required of the
92
+ // consumer. Mirrors vitus-labs/ui-system PR #225.
93
+ DFP = OA extends infer O
94
+ ? Omit<O, keyof EA & keyof O> &
95
+ Partial<Pick<O, keyof EA & keyof O>> &
96
+ MergeTypes<
97
+ [Partial<Omit<EA, keyof O>>, DefaultProps, ExtractDimensionProps<D, DKP, UB>]
98
+ >
99
+ : never,
80
100
  > {
81
101
  // The component is callable — Pyreon components are plain functions
82
102
  (props: DFP): VNodeChild
@@ -95,23 +115,52 @@ export interface IRocketStyleComponent<
95
115
  : RocketStyleComponent<OA, EA, T, CSS, S, HOC, D, UB, DKP>
96
116
 
97
117
  // ATTRS chaining method
98
- attrs: <P extends TObj | unknown = unknown>(
99
- param: P extends TObj
100
- ?
101
- | Partial<DFP & P>
102
- | ((
103
- props: Partial<DFP & P>,
104
- theme: Theme<T>,
105
- helpers: any,
106
- ) => Partial<P> & Record<string, unknown>)
107
- : Partial<DFP> | AttrsCb<DFP, Theme<T>>,
108
- config?: Partial<{
109
- priority: boolean
110
- filter: P extends TObj ? Partial<keyof (EA & P)>[] : Partial<keyof EA>[]
111
- }>,
112
- ) => P extends TObj
113
- ? RocketStyleComponent<OA, MergeTypes<[EA, P]>, T, CSS, S, HOC, D, UB, DKP>
114
- : RocketStyleComponent<OA, EA, T, CSS, S, HOC, D, UB, DKP>
118
+ //
119
+ // Two overloads. TS resolves in declaration order; the callback form is
120
+ // listed FIRST so a function argument matches it before falling through to
121
+ // the object form. Mirrors vitus-labs/ui-system PR #227 Issue 2 (callback
122
+ // overload split).
123
+ //
124
+ // **Callback overload — asymmetric props/return**: the `props` arg is
125
+ // typed strictly as `Partial<DFP & P>` (so reading `props.x` narrows
126
+ // against the wrapped component's full surface), while the RETURN is
127
+ // `Partial<P> & Record<string, unknown>` — only the user's explicit `<P>`
128
+ // generic is checked, with a wildcard for runtime extras. This preserves
129
+ // Pyreon's documented convention where `.attrs(callback)` returns can
130
+ // carry runtime-only fields (`_documentProps` markers for the document-
131
+ // export pipeline, `tag: 'a'` overrides on Text-based components where
132
+ // the rendered DOM tag is outside the component's narrow `tag` union,
133
+ // etc.). The runtime is loose by design `.attrs(cb, { filter })`
134
+ // strips keys before forwarding to the DOM. Consumers wanting strict
135
+ // typed extras pass `<P>` with the precise keys they expect.
136
+ //
137
+ // **Object overload — `P & Partial<NoInfer<DFP>>`**: TS infers P from
138
+ // the param's keys. `NoInfer<DFP>` (TS 5.4+) prevents DFP from
139
+ // contributing to P inference. Combined with DFP widening above, this
140
+ // makes `.attrs({ tag: 'a' })` keys optional at the JSX call site —
141
+ // every `.attrs()` value is a default, not a required prop. Mirrors
142
+ // vitus-labs PR #225.
143
+ attrs: {
144
+ <P extends TObj = {}>(
145
+ param: (
146
+ props: Partial<DFP & P>,
147
+ theme: Theme<T>,
148
+ helpers: AttrsHelpers,
149
+ ) => Partial<P> & Record<string, unknown>,
150
+ config?: Partial<{
151
+ priority: boolean
152
+ filter: (keyof MergeTypes<[EA, P]>)[]
153
+ }>,
154
+ ): RocketStyleComponent<OA, MergeTypes<[EA, P]>, T, CSS, S, HOC, D, UB, DKP>
155
+
156
+ <P extends TObj = {}>(
157
+ param: P & Partial<NoInfer<DFP>>,
158
+ config?: Partial<{
159
+ priority: boolean
160
+ filter: (keyof MergeTypes<[EA, P]>)[]
161
+ }>,
162
+ ): RocketStyleComponent<OA, MergeTypes<[EA, P]>, T, CSS, S, HOC, D, UB, DKP>
163
+ }
115
164
 
116
165
  // THEME chaining method
117
166
  theme: <P extends TObj = TObj>(
@@ -4,7 +4,7 @@ import type { TObj } from './utils'
4
4
 
5
5
  export interface StylesDefault {}
6
6
 
7
- export type Styles<S = unknown> = StylesDefault
7
+ export type Styles<_S = unknown> = StylesDefault
8
8
 
9
9
  export type Css = typeof config.css
10
10
  export type Style = ReturnType<Css>
@@ -51,5 +51,48 @@ export type Spread<A extends readonly [...any]> = A extends [infer L, ...infer R
51
51
  export type MergeTypes<A extends readonly [...any]> = ExtractNullableKeys<Spread<A>>
52
52
 
53
53
  // ─── ExtractProps ─────────────────────────────────────────────
54
- export type ExtractProps<TComponentOrTProps> =
55
- TComponentOrTProps extends ComponentFn<infer TProps> ? TProps : TComponentOrTProps
54
+ /**
55
+ * Extracts the props type from a Pyreon component function — or passes
56
+ * through the input unchanged when it's already a props type.
57
+ *
58
+ * Multi-overload aware: matches up to 4 call signatures and produces the
59
+ * UNION of their first-argument types. A single-overload function still
60
+ * works (the union of 4 copies of the same props type dedupes back to
61
+ * the single shape).
62
+ *
63
+ * **Why this shape**. `T extends (props: infer P) => any ? P : never` only
64
+ * captures the LAST overload of a multi-overload function — TS's overload-
65
+ * resolution-against-conditional-types semantics. Iterator / List / Element
66
+ * are 3-overload primitives where the LAST overload (`ChildrenProps`) is the
67
+ * loosest; without overload-aware extraction, `ExtractProps<Iterator>`
68
+ * returned just `ChildrenProps` and lost both `SimpleProps<T>` and
69
+ * `ObjectProps<T>` — wrapping Iterator through `rocketstyle()` /
70
+ * `attrs()` silently downgraded the public prop surface.
71
+ *
72
+ * The pattern-match shape `T extends { (props: infer P1, ...args: any): any;
73
+ * (props: infer P2, ...args: any): any; ... }` is the canonical TS trick
74
+ * for extracting overload sets — see also `Parameters<T>` semantics.
75
+ *
76
+ * Mirrors vitus-labs PR #222.
77
+ */
78
+ export type ExtractProps<TComponentOrTProps> = TComponentOrTProps extends {
79
+ (props: infer P1, ...args: any): any
80
+ (props: infer P2, ...args: any): any
81
+ (props: infer P3, ...args: any): any
82
+ (props: infer P4, ...args: any): any
83
+ }
84
+ ? P1 | P2 | P3 | P4
85
+ : TComponentOrTProps extends {
86
+ (props: infer P1, ...args: any): any
87
+ (props: infer P2, ...args: any): any
88
+ (props: infer P3, ...args: any): any
89
+ }
90
+ ? P1 | P2 | P3
91
+ : TComponentOrTProps extends {
92
+ (props: infer P1, ...args: any): any
93
+ (props: infer P2, ...args: any): any
94
+ }
95
+ ? P1 | P2
96
+ : TComponentOrTProps extends ComponentFn<infer TProps>
97
+ ? TProps
98
+ : TComponentOrTProps