@pyreon/rocketstyle 0.15.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.d.ts 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
@@ -239,7 +239,31 @@ const useThemeAttrs = ({ inversed }) => {
239
239
  //#region src/utils/attrs.ts
240
240
  const removeUndefinedProps = (props) => {
241
241
  const result = {};
242
- for (const key in props) if (props[key] !== void 0) result[key] = props[key];
242
+ const descriptors = Object.getOwnPropertyDescriptors(props);
243
+ for (const key of Object.keys(descriptors)) {
244
+ const d = descriptors[key];
245
+ if (d.get || d.value !== void 0) Object.defineProperty(result, key, d);
246
+ }
247
+ return result;
248
+ };
249
+ /**
250
+ * Like `Object.assign(target, ...sources)` but copies own property
251
+ * DESCRIPTORS instead of reading + writing values. Later sources
252
+ * override earlier ones (same semantics as spread / Object.assign).
253
+ *
254
+ * Required for reactive-prop preservation through the rocketstyle
255
+ * pipeline: a plain `{ ...A, ...B }` spread fires every getter on A
256
+ * and B and stores the resolved value, breaking the reactive
257
+ * subscription. This helper copies descriptors so getters survive
258
+ * the merge.
259
+ */
260
+ const mergeDescriptors = (...sources) => {
261
+ const result = {};
262
+ for (const source of sources) {
263
+ if (!source) continue;
264
+ const descriptors = Object.getOwnPropertyDescriptors(source);
265
+ for (const key of Object.keys(descriptors)) Object.defineProperty(result, key, descriptors[key]);
266
+ }
243
267
  return result;
244
268
  };
245
269
  /** Picks only the props whose keys exist in the dimension keywords lookup and have truthy values. */
@@ -301,15 +325,7 @@ const rocketStyleHOC = ({ inversed, attrs, priorityAttrs }) => {
301
325
  isLight: themeAttrs.isLight
302
326
  }];
303
327
  const prioritizedAttrs = calculatePriorityAttrs([filteredProps, ...callbackParams]);
304
- const finalAttrs = calculateAttrs([{
305
- ...prioritizedAttrs,
306
- ...filteredProps
307
- }, ...callbackParams]);
308
- return WrappedComponent({
309
- ...prioritizedAttrs,
310
- ...finalAttrs,
311
- ...filteredProps
312
- });
328
+ return WrappedComponent(mergeDescriptors(prioritizedAttrs, calculateAttrs([mergeDescriptors(prioritizedAttrs, filteredProps), ...callbackParams]), filteredProps));
313
329
  };
314
330
  return HOCComponent;
315
331
  };
@@ -472,23 +488,38 @@ const getThemeByMode = (object, mode) => Object.keys(object).reduce((acc, key) =
472
488
  //#endregion
473
489
  //#region src/rocketstyle.ts
474
490
  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
- });
491
+ /**
492
+ * Clones the current configuration and merges new options, returning a fresh
493
+ * rocketComponent.
494
+ *
495
+ * Component-swap reset: when `opts.component` is set AND differs from the
496
+ * current `defaultOpts.component`, the prior `attrs`, `priorityAttrs`,
497
+ * `filterAttrs`, and `compose` chains are dropped — they were tailored to the
498
+ * previous component's prop shape, and applying them to a different component
499
+ * silently leaks invalid props through to the DOM (e.g. `disabled` on an
500
+ * `<a>`). Callers who want to preserve them must re-chain explicitly:
501
+ *
502
+ * const NewBtn = Button.config({ component: 'a' }).attrs(sharedAttrs)
503
+ */
504
+ const cloneAndEnhance = (defaultOpts, opts) => {
505
+ const componentChanged = opts.component != null && opts.component !== defaultOpts.component;
506
+ return rocketComponent({
507
+ ...defaultOpts,
508
+ attrs: componentChanged ? chainOptions(opts.attrs, []) : chainOptions(opts.attrs, defaultOpts.attrs),
509
+ filterAttrs: componentChanged ? [...opts.filterAttrs ?? []] : [...defaultOpts.filterAttrs ?? [], ...opts.filterAttrs ?? []],
510
+ priorityAttrs: componentChanged ? chainOptions(opts.priorityAttrs, []) : chainOptions(opts.priorityAttrs, defaultOpts.priorityAttrs),
511
+ statics: {
512
+ ...defaultOpts.statics,
513
+ ...opts.statics
514
+ },
515
+ compose: componentChanged ? { ...opts.compose } : {
516
+ ...defaultOpts.compose,
517
+ ...opts.compose
518
+ },
519
+ ...chainOrOptions(CONFIG_KEYS, opts, defaultOpts),
520
+ ...chainReservedKeyOptions([...defaultOpts.dimensionKeys, ...STYLING_KEYS], opts, defaultOpts)
521
+ });
522
+ };
492
523
  const rocketComponent = (options) => {
493
524
  const { component, styles } = options;
494
525
  const { styled } = config;
@@ -548,11 +579,16 @@ const rocketComponent = (options) => {
548
579
  const _resolveRsEntry = () => {
549
580
  const theme = themeAttrs.theme;
550
581
  const mode = themeAttrs.mode;
551
- let key = mode;
552
582
  const propsRec = props;
583
+ const rocketstateRaw = _calculateStylingAttrs({
584
+ props: pickStyledAttrs(propsRec, reservedPropNames),
585
+ dimensions
586
+ });
587
+ let key = mode;
553
588
  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);
589
+ const v = rocketstateRaw[dimName];
590
+ if (Array.isArray(v)) key += "|" + (v.length === 0 ? "" : v.slice().sort().join(","));
591
+ else key += "|" + (typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? String(v) : v === void 0 ? "" : "~" + typeof v);
556
592
  }
557
593
  for (const k of ALL_PSEUDO_KEYS) {
558
594
  const propV = propsRec[k];
@@ -583,10 +619,6 @@ const rocketComponent = (options) => {
583
619
  if (process.env.NODE_ENV !== "production") _countSink.__pyreon_count__?.("rocketstyle.localThemeManager.hit");
584
620
  } else dimHelper.set(theme, getDimensionThemes(theme, options));
585
621
  const themes = dimHelper.get(theme);
586
- const rocketstateRaw = _calculateStylingAttrs({
587
- props: pickStyledAttrs(propsRec, reservedPropNames),
588
- dimensions
589
- });
590
622
  const modeBaseHelper = ThemeManager$1.modeBaseTheme[mode];
591
623
  if (modeBaseHelper.has(baseTheme)) {
592
624
  if (process.env.NODE_ENV !== "production") _countSink.__pyreon_count__?.("rocketstyle.localThemeManager.hit");
@@ -631,20 +663,34 @@ const rocketComponent = (options) => {
631
663
  omitSet = new Set([...RESERVED_STYLING_PROPS_KEYS, ...STATIC_OMIT_KEYS]);
632
664
  _omitSetCache.set(RESERVED_STYLING_PROPS_KEYS, omitSet);
633
665
  }
634
- const mergeProps = localCtx ? {
635
- ...localCtx,
636
- ...props
637
- } : props;
666
+ const mergeProps = localCtx ? mergeDescriptors(localCtx, props) : props;
638
667
  const finalProps = omit(mergeProps, omitSet);
639
668
  if (options.passProps) {
640
669
  const passed = pick(mergeProps, options.passProps);
641
- for (const k in passed) finalProps[k] = passed[k];
670
+ const passedDescriptors = Object.getOwnPropertyDescriptors(passed);
671
+ for (const k of Object.keys(passedDescriptors)) Object.defineProperty(finalProps, k, passedDescriptors[k]);
642
672
  }
643
- finalProps.ref = props.ref;
644
- finalProps.$rocketstyle = $rocketstyleAccessor;
645
- finalProps.$rocketstate = $rocketstateAccessor;
673
+ const refDescriptor = Object.getOwnPropertyDescriptor(props, "ref");
674
+ if (refDescriptor) Object.defineProperty(finalProps, "ref", refDescriptor);
675
+ Object.defineProperty(finalProps, "$rocketstyle", {
676
+ value: $rocketstyleAccessor,
677
+ writable: true,
678
+ enumerable: true,
679
+ configurable: true
680
+ });
681
+ Object.defineProperty(finalProps, "$rocketstate", {
682
+ value: $rocketstateAccessor,
683
+ writable: true,
684
+ enumerable: true,
685
+ configurable: true
686
+ });
646
687
  if (__DEV__) {
647
- finalProps["data-rocketstyle"] = componentName;
688
+ Object.defineProperty(finalProps, "data-rocketstyle", {
689
+ value: componentName,
690
+ writable: true,
691
+ enumerable: true,
692
+ configurable: true
693
+ });
648
694
  if (options.DEBUG) {
649
695
  const debugPayload = {
650
696
  component: componentName,
@@ -715,8 +761,8 @@ const rocketComponent = (options) => {
715
761
  {
716
762
  render,
717
763
  mode,
718
- isDark: mode === "light",
719
- isLight: mode === "dark"
764
+ isDark: mode === "dark",
765
+ isLight: mode === "light"
720
766
  }
721
767
  ])
722
768
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/rocketstyle",
3
- "version": "0.15.0",
3
+ "version": "0.18.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.5",
46
+ "@pyreon/typescript": "^0.18.0",
47
+ "@pyreon/ui-core": "^0.18.0",
48
48
  "@vitest/browser-playwright": "^4.1.4",
49
49
  "@vitus-labs/tools-rolldown": "^2.3.0"
50
50
  },
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.18.0",
56
+ "@pyreon/reactivity": "^0.18.0",
57
+ "@pyreon/styler": "^0.18.0",
58
+ "@pyreon/ui-core": "^0.18.0"
59
59
  }
60
60
  }
@@ -0,0 +1,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
+ })