clava 0.2.2 → 0.2.4

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/src/index.ts CHANGED
@@ -30,28 +30,54 @@ import {
30
30
  styleValueToJSXStyle,
31
31
  } from "./utils.ts";
32
32
 
33
- // Internal metadata stored on components but hidden from public types
33
+ // Internal compute path: pushes the variant classes contributed by this
34
+ // component (and its extends chain) into `classesOut` and merges any styles
35
+ // into `styleOut`. Base class is handled by callers via ComponentMeta.baseClass
36
+ // to avoid string-parsing round trips. Both outputs are mutated in place to
37
+ // avoid intermediate allocations.
38
+ type ComputeFn = (
39
+ resolved: Record<string, unknown>,
40
+ userVariantProps: Record<string, unknown>,
41
+ skipKeys: Set<string> | null,
42
+ skipValues: Record<string, Set<string>> | null,
43
+ classesOut: ClsxClassValue[],
44
+ styleOut: StyleValue,
45
+ ) => void;
46
+
47
+ // Internal metadata stored on components but hidden from public types.
34
48
  interface ComponentMeta {
35
49
  baseClass: string;
36
50
  staticDefaults: Record<string, unknown>;
37
- resolveDefaults: (
38
- childDefaults: Record<string, unknown>,
39
- userProps?: Record<string, unknown>,
40
- ) => Record<string, unknown>;
51
+ // Returns variants set via setDefaultVariants in the computed function chain.
52
+ // null when this component has no resolveDefaults work to do (no `computed`
53
+ // and no extends with work).
54
+ resolveDefaults:
55
+ | ((
56
+ childDefaults: Record<string, unknown>,
57
+ userProps?: Record<string, unknown>,
58
+ ) => Record<string, unknown>)
59
+ | null;
60
+ // Returns variant classes + style for this component, used by extending
61
+ // components. Top-level rendering also routes through this.
62
+ compute: ComputeFn;
63
+ // Reference identity is used to detect mixed-factory `extend`. When a
64
+ // component is extended by a parent from a different `create()` call, the
65
+ // parent applies this transform to the extend's contribution before joining,
66
+ // preserving each factory's transform boundary.
67
+ transformClass: (className: string) => string;
41
68
  }
42
69
 
43
70
  const META_KEY = "__meta";
44
71
 
45
- // Symbol property used to pass skip keys through the props object without
46
- // polluting the actual variant values. This allows the computed function to
47
- // see actual variant values while still skipping styling for overridden keys.
48
- const SKIP_STYLE_KEYS = Symbol("skipStyleKeys");
49
- const SKIP_STYLE_VARIANT_VALUES = Symbol("skipStyleVariantValues");
50
-
51
72
  // eslint-disable-next-line @typescript-eslint/unbound-method
52
73
  const hasOwn = Object.prototype.hasOwnProperty;
53
74
 
54
- // Dynamic property access on function requires cast through unknown
75
+ const EMPTY_DEFAULTS: Record<string, unknown> = Object.freeze({}) as Record<
76
+ string,
77
+ unknown
78
+ >;
79
+
80
+ // Dynamic property access on function requires cast through unknown.
55
81
  function getComponentMeta(component: AnyComponent): ComponentMeta | undefined {
56
82
  return (component as unknown as Record<string, unknown>)[META_KEY] as
57
83
  | ComponentMeta
@@ -62,19 +88,6 @@ function setComponentMeta(component: AnyComponent, meta: ComponentMeta): void {
62
88
  (component as unknown as Record<string, unknown>)[META_KEY] = meta;
63
89
  }
64
90
 
65
- /**
66
- * Mutates target by assigning all properties from source. Avoids object spread
67
- * overhead in hot paths where we're building up a result object.
68
- */
69
- function assign<T extends object>(target: T, source: T): void {
70
- for (const key in source) {
71
- if (!hasOwn.call(source, key)) continue;
72
- (target as Record<string, unknown>)[key] = (
73
- source as Record<string, unknown>
74
- )[key];
75
- }
76
- }
77
-
78
91
  export type {
79
92
  ClassValue,
80
93
  StyleValue,
@@ -280,28 +293,6 @@ function collectDisabledVariantValues(
280
293
  return values;
281
294
  }
282
295
 
283
- /**
284
- * Extracts classes from fullClass that are not in baseClass. Uses string
285
- * comparison optimization: if fullClass starts with baseClass, just take the
286
- * suffix.
287
- */
288
- function extractVariantClasses(fullClass: string, baseClass: string): string {
289
- if (!fullClass) return "";
290
- if (!baseClass) return fullClass;
291
-
292
- // Fast path: fullClass starts with baseClass (common case)
293
- if (fullClass.startsWith(baseClass)) {
294
- return fullClass.slice(baseClass.length).trim();
295
- }
296
-
297
- // Slow path: need to diff the class sets
298
- const baseClassSet = new Set(baseClass.split(" ").filter(Boolean));
299
- return fullClass
300
- .split(" ")
301
- .filter((c) => c && !baseClassSet.has(c))
302
- .join(" ");
303
- }
304
-
305
296
  interface NormalizedSource {
306
297
  keys: string[];
307
298
  variantKeys: string[];
@@ -480,75 +471,6 @@ function buildPrebuiltVariant(variantDef: unknown): PrebuiltVariant {
480
471
  };
481
472
  }
482
473
 
483
- /**
484
- * Creates the resolveDefaults function for a component. This function returns
485
- * only the variants set via setDefaultVariants in the computed function. Used
486
- * by child components to get parent's computed defaults.
487
- */
488
- function createResolveDefaults(
489
- config: CVConfig<Variants, ComputedVariants, AnyComponent[]>,
490
- staticDefaults: Record<string, unknown>,
491
- ): ComponentMeta["resolveDefaults"] {
492
- const computed = config.computed;
493
- const extend = config.extend;
494
- return (childDefaults, userProps = {}) => {
495
- // Merge: parent static < child static < user props
496
- // This is what parent's computed will see in `variants`
497
- const resolvedVariants: Record<string, unknown> = {};
498
- Object.assign(resolvedVariants, staticDefaults);
499
- for (const key in childDefaults) {
500
- if (!hasOwn.call(childDefaults, key)) continue;
501
- const v = childDefaults[key];
502
- if (v === undefined) continue;
503
- resolvedVariants[key] = v;
504
- }
505
- for (const key in userProps) {
506
- if (!hasOwn.call(userProps, key)) continue;
507
- const v = userProps[key];
508
- if (v === undefined) continue;
509
- resolvedVariants[key] = v;
510
- }
511
-
512
- // Track which keys are set via setDefaultVariants
513
- const computedDefaults: Record<string, unknown> = {};
514
-
515
- // Propagate to extended components so their computed functions can run
516
- if (extend) {
517
- for (const ext of extend) {
518
- const meta = getComponentMeta(ext);
519
- if (!meta) continue;
520
- const extDefaults = meta.resolveDefaults(childDefaults, userProps);
521
- for (const k in extDefaults) {
522
- if (hasOwn.call(extDefaults, k)) {
523
- computedDefaults[k] = extDefaults[k];
524
- }
525
- }
526
- }
527
- }
528
-
529
- if (computed) {
530
- computed({
531
- variants: resolvedVariants as VariantValues<Record<string, unknown>>,
532
- setVariants: () => {},
533
- setDefaultVariants: (newDefaults) => {
534
- for (const key in newDefaults) {
535
- if (!hasOwn.call(newDefaults, key)) continue;
536
- const value = (newDefaults as Record<string, unknown>)[key];
537
- if (userProps[key] !== undefined) continue;
538
- if (isVariantDisabled(config, key)) continue;
539
- if (isVariantValueDisabled(config, key, value)) continue;
540
- computedDefaults[key] = value;
541
- }
542
- },
543
- addClass: () => {},
544
- addStyle: () => {},
545
- });
546
- }
547
-
548
- return computedDefaults;
549
- };
550
- }
551
-
552
474
  /**
553
475
  * Creates the cv and cx functions.
554
476
  */
@@ -568,6 +490,7 @@ export function create({
568
490
 
569
491
  // ----- Pre-computed at creation time -----
570
492
  const variantKeys = collectVariantKeys(config);
493
+ const variantKeysLength = variantKeys.length;
571
494
  const disabledVariantKeys = collectDisabledVariantKeys(config);
572
495
  const disabledVariantValues = collectDisabledVariantValues(config);
573
496
  const hasDisabledVariantKeys = disabledVariantKeys.size > 0;
@@ -583,8 +506,7 @@ export function create({
583
506
  const computedVariantsCfg = config.computedVariants;
584
507
  const computed = config.computed;
585
508
  const baseStyle = config.style;
586
- const baseClass: ClassValue =
587
- config.class === undefined ? null : (config.class as ClassValue);
509
+ const hasBaseStyle = !!baseStyle;
588
510
 
589
511
  // Pre-build variant entries for fast iteration. For each variant key in
590
512
  // `variants`, we have a name and a PrebuiltVariant with normalized values.
@@ -664,22 +586,66 @@ export function create({
664
586
  }
665
587
 
666
588
  // Pre-build extended component info, so we don't have to call
667
- // `getComponentMeta` per render.
668
- const extEntries: AnyComponent[] = extend ? (extend as AnyComponent[]) : [];
589
+ // `getComponentMeta` per render. Extends from a different `create()`
590
+ // factory (different `transformClass` identity) need their contribution
591
+ // transformed by their own `transformClass` before being joined into our
592
+ // class string — otherwise our outer `transformClass(clsx(allClasses))`
593
+ // would be the only transform that runs, and the extend's factory would
594
+ // be silently bypassed for any base coming from `extend: [otherFactoryCv]`.
595
+ const extMetas: ComponentMeta[] = [];
669
596
  const extBaseClassesArr: string[] = [];
670
- const extMetas: (ComponentMeta | undefined)[] = [];
597
+ const extIsolated: boolean[] = [];
598
+ let hasIsolatedExt = false;
671
599
  if (extend) {
672
600
  for (const ext of extend) {
673
601
  const meta = getComponentMeta(ext);
602
+ if (!meta) continue;
674
603
  extMetas.push(meta);
675
- extBaseClassesArr.push(meta?.baseClass ?? "");
604
+ const isolated = meta.transformClass !== transformClass;
605
+ extIsolated.push(isolated);
606
+ if (isolated) {
607
+ hasIsolatedExt = true;
608
+ // Apply the extend's own transformClass to its base class so it
609
+ // survives our outer transform (which still applies on top, matching
610
+ // the original public-component round-trip behavior).
611
+ extBaseClassesArr.push(meta.transformClass(meta.baseClass));
612
+ } else {
613
+ extBaseClassesArr.push(meta.baseClass);
614
+ }
676
615
  }
677
616
  }
678
- const extCount = extEntries.length;
679
-
680
- // Inlined "filter disabled" - mutates `out` adding only allowed entries.
681
- // Most components have no disabled variants, in which case we can skip
682
- // the filter entirely.
617
+ const extCount = extMetas.length;
618
+
619
+ // Filter to only extends whose `resolveDefaults` actually does work
620
+ // (config.computed exists, transitively). Iterating these in
621
+ // `resolveVariantsHot` skips empty work.
622
+ const extMetasWithResolveDefaults: ComponentMeta[] = [];
623
+ for (let i = 0; i < extCount; i++) {
624
+ if (extMetas[i].resolveDefaults) {
625
+ extMetasWithResolveDefaults.push(extMetas[i]);
626
+ }
627
+ }
628
+ const extMetasWithResolveDefaultsCount = extMetasWithResolveDefaults.length;
629
+
630
+ // Pre-compute static skip key/value sets to pass to extends. These never
631
+ // change across calls — when caller passes no skip sets, we reuse the same
632
+ // object and avoid Set allocation.
633
+ let staticExtSkipKeys: Set<string> | null = null;
634
+ if (hasDisabledVariantKeys || computedVariantCount > 0) {
635
+ staticExtSkipKeys = new Set<string>();
636
+ for (const k of disabledVariantKeys) staticExtSkipKeys.add(k);
637
+ for (let i = 0; i < computedVariantCount; i++) {
638
+ staticExtSkipKeys.add(computedVariantNames[i]);
639
+ }
640
+ }
641
+ // Skip values are passed directly to extends. We can reuse the same object
642
+ // when no caller-provided values need merging.
643
+ const staticExtSkipValues: Record<string, Set<string>> | null =
644
+ hasDisabledVariantValues ? disabledVariantValues : null;
645
+
646
+ // Branches on `hasAnyDisabled` so the no-disabled path skips the per-key
647
+ // filter checks entirely — most components have no disabled variants and
648
+ // hit only the plain copy.
683
649
  function filterDisabledInto(
684
650
  input: Record<string, unknown>,
685
651
  out: Record<string, unknown>,
@@ -704,12 +670,82 @@ export function create({
704
670
  }
705
671
  }
706
672
 
707
- // Pre-create default-variants resolver which is referenced during the hot
708
- // path through extended components' meta. The closure captures
709
- // staticDefaults, extend, computed, etc.
710
- const resolveDefaultsFn = createResolveDefaults(config, staticDefaults);
673
+ // Pre-create resolveDefaults function used by parents during their
674
+ // `resolveVariantsHot`. Returns the variants set via setDefaultVariants in
675
+ // the computed function chain.
676
+ //
677
+ // When this component has no `computed` and no `extend` with work, the
678
+ // function is null — callers can skip iterating it entirely.
679
+ const resolveDefaultsFn: ComponentMeta["resolveDefaults"] =
680
+ computed || extMetasWithResolveDefaultsCount > 0
681
+ ? (
682
+ childDefaults: Record<string, unknown>,
683
+ userProps: Record<string, unknown> = EMPTY_DEFAULTS,
684
+ ) => {
685
+ // userProps is contractually variant-only (callers pre-filter
686
+ // when starting from a full props object).
687
+ const resolvedVariants: Record<string, unknown> = {};
688
+ Object.assign(resolvedVariants, staticDefaults);
689
+ for (const key in childDefaults) {
690
+ if (!hasOwn.call(childDefaults, key)) continue;
691
+ const v = childDefaults[key];
692
+ if (v === undefined) continue;
693
+ resolvedVariants[key] = v;
694
+ }
695
+ for (const key in userProps) {
696
+ if (!hasOwn.call(userProps, key)) continue;
697
+ const v = userProps[key];
698
+ if (v === undefined) continue;
699
+ resolvedVariants[key] = v;
700
+ }
701
+
702
+ const computedDefaults: Record<string, unknown> = {};
703
+
704
+ for (let i = 0; i < extMetasWithResolveDefaultsCount; i++) {
705
+ const extDefaults = extMetasWithResolveDefaults[i]
706
+ .resolveDefaults!(childDefaults, userProps);
707
+ for (const k in extDefaults) {
708
+ if (!hasOwn.call(extDefaults, k)) continue;
709
+ computedDefaults[k] = extDefaults[k];
710
+ }
711
+ }
712
+
713
+ if (computed) {
714
+ // Filter to own variant keys so `computed.ctx.variants` matches
715
+ // `VariantValues<V>` when this component is used as an extend by
716
+ // a parent that adds extra variant keys (those keys would
717
+ // otherwise leak through `userProps`).
718
+ const ownVariants: Record<string, unknown> = {};
719
+ for (let i = 0; i < variantKeysLength; i++) {
720
+ const k = variantKeys[i];
721
+ if (hasOwn.call(resolvedVariants, k)) {
722
+ ownVariants[k] = resolvedVariants[k];
723
+ }
724
+ }
725
+ computed({
726
+ variants: ownVariants as VariantValues<Record<string, unknown>>,
727
+ setVariants: noop,
728
+ setDefaultVariants: (newDefaults) => {
729
+ for (const key in newDefaults) {
730
+ if (!hasOwn.call(newDefaults, key)) continue;
731
+ const value = (newDefaults as Record<string, unknown>)[key];
732
+ if (userProps[key] !== undefined) continue;
733
+ if (isVariantDisabled(config, key)) continue;
734
+ if (isVariantValueDisabled(config, key, value)) continue;
735
+ computedDefaults[key] = value;
736
+ }
737
+ },
738
+ addClass: noop,
739
+ addStyle: noop,
740
+ });
741
+ }
742
+
743
+ return computedDefaults;
744
+ }
745
+ : null;
711
746
 
712
- // Resolve variants: defaults -> computed defaults from extended -> props.
747
+ // Hot path: resolve variants by merging static defaults + extends'
748
+ // computed defaults + user-provided props.
713
749
  function resolveVariantsHot(
714
750
  propsVariants: Record<string, unknown>,
715
751
  ): Record<string, unknown> {
@@ -717,22 +753,20 @@ export function create({
717
753
  const defaults: Record<string, unknown> = {};
718
754
  Object.assign(defaults, staticDefaults);
719
755
 
720
- // Apply computed defaults from extended components
721
- if (hasExtend) {
722
- for (let i = 0; i < extCount; i++) {
723
- const meta = extMetas[i];
724
- if (!meta) continue;
725
- const extComputed = meta.resolveDefaults(defaults, propsVariants);
726
- for (const k in extComputed) {
727
- if (hasOwn.call(extComputed, k)) {
728
- defaults[k] = extComputed[k];
729
- }
730
- }
756
+ // Apply computed defaults from extended components (only those that have
757
+ // actual work to do).
758
+ for (let i = 0; i < extMetasWithResolveDefaultsCount; i++) {
759
+ const meta = extMetasWithResolveDefaults[i];
760
+ const extComputed = meta.resolveDefaults!(defaults, propsVariants);
761
+ for (const k in extComputed) {
762
+ if (!hasOwn.call(extComputed, k)) continue;
763
+ defaults[k] = extComputed[k];
731
764
  }
732
765
  }
733
766
 
734
- // Now merge: defaults < propsVariants (filter undefined)
735
- // Apply propsVariants on top
767
+ // Apply propsVariants on top (filter undefined). propsVariants is
768
+ // contractually variant-only here — callers building from a full props
769
+ // object filter to variant keys before calling.
736
770
  for (const k in propsVariants) {
737
771
  if (!hasOwn.call(propsVariants, k)) continue;
738
772
  const v = propsVariants[k];
@@ -748,54 +782,70 @@ export function create({
748
782
  return result;
749
783
  }
750
784
 
751
- // Hot path: build a fresh result.
752
- const computeResult = (
753
- props: ComponentProps<MergedVariants> = {},
754
- ): { className: string; style: StyleValue } => {
755
- // Extract skip style keys from props (set by child's computedVariants)
756
- const skipStyleKeysIn = (props as Record<symbol, unknown>)[
757
- SKIP_STYLE_KEYS
758
- ] as Set<string> | undefined;
759
- const skipStyleVariantValuesIn = (props as Record<symbol, unknown>)[
760
- SKIP_STYLE_VARIANT_VALUES
761
- ] as Record<string, Set<string>> | undefined;
762
-
763
- // Extract variant props from input. Also remember the propsVariants for
764
- // computed-defaults application.
765
- const variantProps: Record<string, unknown> = {};
766
- for (let i = 0; i < variantKeys.length; i++) {
767
- const key = variantKeys[i];
768
- if (key in props) {
769
- variantProps[key] = (props as Record<string, unknown>)[key];
770
- }
771
- }
772
-
773
- // Resolve variants with defaults
774
- let resolvedVariants = resolveVariantsHot(variantProps);
775
-
776
- // Run computed function (may update variants and emit class/style)
777
- let computedClassesArr: ClassValue[] | null = null;
778
- let computedStyleObj: StyleValue | null = null;
785
+ // Core compute path. Called both for top-level rendering (via
786
+ // `computeResult`) and recursively when this component is used as an
787
+ // `extend` target by another component. Pushes variant classes (excluding
788
+ // base class) into `classesOut` and merges styles into `styleOut`.
789
+ const compute: ComputeFn = (
790
+ resolved,
791
+ userVariantProps,
792
+ skipKeys,
793
+ skipValues,
794
+ classesOut,
795
+ styleOut,
796
+ ) => {
797
+ // Run `computed` (if any). May modify resolved variants and emit classes
798
+ // and styles.
799
+ let workingResolved = resolved;
800
+ let cClasses: ClassValue[] | null = null;
801
+ let cStyle: StyleValue | null = null;
779
802
 
780
803
  if (computed) {
781
- const updatedVariants: Record<string, unknown> = {};
782
- Object.assign(updatedVariants, resolvedVariants);
783
- const cClasses: ClassValue[] = [];
784
- let cStyle: StyleValue | null = null;
804
+ // When this component is being extended, `resolved` is the parent's
805
+ // workingResolved (a superset of our variant keys). Filter to our own
806
+ // keys for `ctx.variants` so the user's `computed` callback sees the
807
+ // shape declared by `VariantValues<V>` and not foreign parent keys.
808
+ const ownVariants: Record<string, unknown> = {};
809
+ for (let i = 0; i < variantKeysLength; i++) {
810
+ const k = variantKeys[i];
811
+ if (hasOwn.call(resolved, k)) ownVariants[k] = resolved[k];
812
+ }
813
+ // Lazy-init updatedVariants — many computeds only inspect `variants`
814
+ // or call setDefaultVariants for keys the user already set, so the
815
+ // copy is unnecessary in the common case.
816
+ let updatedVariants: Record<string, unknown> | null = null;
817
+ const localCClasses: ClassValue[] = [];
818
+ let localCStyle: StyleValue | null = null;
819
+ const ensureUpdated = (): Record<string, unknown> => {
820
+ if (updatedVariants) return updatedVariants;
821
+ const u: Record<string, unknown> = {};
822
+ Object.assign(u, ownVariants);
823
+ updatedVariants = u;
824
+ return u;
825
+ };
785
826
  const ctx = {
786
- variants: resolvedVariants as VariantValues<Record<string, unknown>>,
827
+ variants: ownVariants as VariantValues<Record<string, unknown>>,
787
828
  setVariants: (
788
829
  newVariants: VariantValues<Record<string, unknown>>,
789
830
  ) => {
790
831
  if (!hasAnyDisabled) {
791
- Object.assign(updatedVariants, newVariants);
792
- } else {
793
- const filtered: Record<string, unknown> = {};
794
- filterDisabledInto(
795
- newVariants as Record<string, unknown>,
796
- filtered,
797
- );
798
- Object.assign(updatedVariants, filtered);
832
+ Object.assign(ensureUpdated(), newVariants);
833
+ return;
834
+ }
835
+ for (const key in newVariants) {
836
+ if (!hasOwn.call(newVariants, key)) continue;
837
+ if (disabledVariantKeys.has(key)) continue;
838
+ const value = (newVariants as Record<string, unknown>)[key];
839
+ if (hasDisabledVariantValues) {
840
+ const valueKey = getVariantValueKey(value);
841
+ if (
842
+ valueKey != null &&
843
+ disabledVariantValues[key]?.has(valueKey)
844
+ ) {
845
+ continue;
846
+ }
847
+ }
848
+ ensureUpdated()[key] = value;
799
849
  }
800
850
  },
801
851
  setDefaultVariants: (
@@ -803,7 +853,7 @@ export function create({
803
853
  ) => {
804
854
  for (const key in newDefaults) {
805
855
  if (!hasOwn.call(newDefaults, key)) continue;
806
- if (variantProps[key] !== undefined) continue;
856
+ if (userVariantProps[key] !== undefined) continue;
807
857
  const value = (newDefaults as Record<string, unknown>)[key];
808
858
  if (hasAnyDisabled) {
809
859
  if (disabledVariantKeys.has(key)) continue;
@@ -815,174 +865,140 @@ export function create({
815
865
  continue;
816
866
  }
817
867
  }
818
- updatedVariants[key] = value;
868
+ ensureUpdated()[key] = value;
819
869
  }
820
870
  },
821
871
  addClass: (className: ClassValue) => {
822
- cClasses.push(className);
872
+ localCClasses.push(className);
823
873
  },
824
874
  addStyle: (newStyle: StyleValue) => {
825
- if (!cStyle) cStyle = {};
826
- assign(cStyle, newStyle);
875
+ if (!localCStyle) localCStyle = {};
876
+ Object.assign(localCStyle, newStyle);
827
877
  },
828
878
  };
829
879
  const result = computed(ctx);
830
880
  if (result != null) {
831
881
  const r = extractClassAndStylePrebuilt(result);
832
- if (r.class != null) cClasses.push(r.class);
882
+ if (r.class != null) localCClasses.push(r.class);
833
883
  if (r.style) {
834
- if (!cStyle) cStyle = {};
835
- assign(cStyle, r.style);
884
+ if (!localCStyle) localCStyle = {};
885
+ Object.assign(localCStyle, r.style);
836
886
  }
837
887
  }
838
- if (hasAnyDisabled) {
839
- const filteredUpdated: Record<string, unknown> = {};
840
- filterDisabledInto(updatedVariants, filteredUpdated);
841
- resolvedVariants = filteredUpdated;
842
- } else {
843
- resolvedVariants = updatedVariants;
888
+ cClasses = localCClasses;
889
+ cStyle = localCStyle;
890
+ if (updatedVariants) {
891
+ if (hasAnyDisabled) {
892
+ const filteredUpdated: Record<string, unknown> = {};
893
+ filterDisabledInto(updatedVariants, filteredUpdated);
894
+ workingResolved = filteredUpdated;
895
+ } else {
896
+ workingResolved = updatedVariants;
897
+ }
844
898
  }
845
- computedClassesArr = cClasses;
846
- computedStyleObj = cStyle;
847
899
  }
848
900
 
849
- // Compute skip-style sets for the extended components and current
850
- // component. Only allocate when needed.
851
- const hasSkipKeys = !!skipStyleKeysIn || hasDisabledVariantKeys;
852
- let currentVariantKeys: Set<string> | null = null;
853
- if (hasSkipKeys) {
854
- currentVariantKeys = new Set<string>();
855
- if (skipStyleKeysIn) {
856
- for (const k of skipStyleKeysIn) currentVariantKeys.add(k);
857
- }
858
- for (const k of disabledVariantKeys) currentVariantKeys.add(k);
859
- }
860
- // computedVariantKeys is currentVariantKeys + computedVariants names
861
- let computedVariantKeysSet: Set<string> | null = null;
862
- if (hasExtend) {
863
- if (currentVariantKeys || computedVariantNames.length > 0) {
864
- computedVariantKeysSet = new Set<string>();
865
- if (currentVariantKeys) {
866
- for (const k of currentVariantKeys) computedVariantKeysSet.add(k);
867
- }
868
- for (let i = 0; i < computedVariantNames.length; i++) {
869
- computedVariantKeysSet.add(computedVariantNames[i]);
870
- }
871
- }
901
+ // Build skip sets to pass to extends. Reuse precomputed values when no
902
+ // caller-provided sets need merging.
903
+ let extSkipKeys: Set<string> | null;
904
+ if (skipKeys === null) {
905
+ extSkipKeys = staticExtSkipKeys;
906
+ } else if (staticExtSkipKeys === null) {
907
+ extSkipKeys = skipKeys;
908
+ } else {
909
+ extSkipKeys = new Set(skipKeys);
910
+ for (const k of staticExtSkipKeys) extSkipKeys.add(k);
872
911
  }
873
912
 
874
- // computedVariantValues = mergeDisabledVariantValues(skipIn, disabledValues)
875
- let computedVariantValues: Record<string, Set<string>> | null = null;
876
- const hasInValues = !!skipStyleVariantValuesIn;
877
- const hasDisabledValues = hasDisabledVariantValues;
878
- if (hasExtend && (hasInValues || hasDisabledValues)) {
879
- computedVariantValues = {};
880
- if (hasInValues) {
881
- for (const k in skipStyleVariantValuesIn) {
882
- if (!hasOwn.call(skipStyleVariantValuesIn, k)) continue;
883
- const set = new Set<string>();
884
- for (const v of skipStyleVariantValuesIn[k]) {
885
- set.add(v);
886
- }
887
- computedVariantValues[k] = set;
888
- }
913
+ let extSkipVals: Record<string, Set<string>> | null;
914
+ if (skipValues === null) {
915
+ extSkipVals = staticExtSkipValues;
916
+ } else if (staticExtSkipValues === null) {
917
+ extSkipVals = skipValues;
918
+ } else {
919
+ extSkipVals = {};
920
+ for (const k in skipValues) {
921
+ extSkipVals[k] = skipValues[k];
889
922
  }
890
- for (let i = 0; i < disabledVariantValueKeys.length; i++) {
891
- const k = disabledVariantValueKeys[i];
892
- let bucket = computedVariantValues[k];
893
- if (!bucket) {
894
- bucket = new Set<string>();
895
- computedVariantValues[k] = bucket;
923
+ for (const k in staticExtSkipValues) {
924
+ const existing = extSkipVals[k];
925
+ if (existing) {
926
+ const merged = new Set<string>(existing);
927
+ for (const v of staticExtSkipValues[k]) merged.add(v);
928
+ extSkipVals[k] = merged;
929
+ } else {
930
+ extSkipVals[k] = staticExtSkipValues[k];
896
931
  }
897
- for (const v of disabledVariantValues[k]) bucket.add(v);
898
932
  }
899
933
  }
900
934
 
901
- // ----- Build classes/styles in proper order -----
902
- // 1. Extended base classes & their styles (with skip applied)
903
- // 2. Current base class & base style
904
- // 3. Extended variant classes
905
- // 4. Current variants
906
- // 5. computed results
907
- // 6. props.class / props.className
908
- // 7. props.style
909
- const allClasses: ClassValue[] = [];
910
- const allStyle: StyleValue = {};
911
-
912
- // Process extended components
935
+ // Run extends' contributions first (their full classes + styles) so our
936
+ // own base style and variants apply on top, matching the original
937
+ // ext1 ext2 current ordering.
938
+ //
939
+ // `workingResolved` is passed as the extends' `userVariantProps`. This
940
+ // is deliberate — by the time `compute` runs, the resolveDefaults chain
941
+ // and our own `computed`'s `setDefaultVariants` have already produced
942
+ // the most-specific resolution for every key. Treating those values as
943
+ // "user-provided" makes extends' own `setDefaultVariants` skip them, so
944
+ // extends emit variant classes that match what we resolved (rather than
945
+ // re-running their own defaults and emitting a different class).
946
+ // Replacing this with the original `userVariantProps` looks cleaner but
947
+ // breaks "child computed setDefaultVariants overrides parent computed
948
+ // setDefaultVariants" in `tests/computed.test.ts` — extends would then
949
+ // overwrite values the descendant already resolved.
913
950
  if (hasExtend) {
914
- const hasComputedVariantKeysSet =
915
- !!computedVariantKeysSet && computedVariantKeysSet.size > 0;
916
- const hasComputedVariantValues =
917
- !!computedVariantValues &&
918
- Object.keys(computedVariantValues).length > 0;
919
- const hasSkipForExt =
920
- hasComputedVariantKeysSet || hasComputedVariantValues;
921
-
922
- const extVariantClasses: ClassValue[] = [];
923
-
924
951
  for (let i = 0; i < extCount; i++) {
925
- const ext = extEntries[i];
926
- const extBaseClass = extBaseClassesArr[i];
927
- let propsForExt: Record<string | symbol, unknown>;
928
- if (hasSkipForExt) {
929
- propsForExt = {};
930
- // Copy resolvedVariants
931
- for (const k in resolvedVariants) {
932
- if (hasOwn.call(resolvedVariants, k)) {
933
- propsForExt[k] = resolvedVariants[k];
952
+ if (hasIsolatedExt && extIsolated[i]) {
953
+ // Isolated extend (different factory): gather its variant classes
954
+ // into a scratch array, then push the joined string after applying
955
+ // its own transformClass. Our outer transform applies on top.
956
+ const extClasses: ClsxClassValue[] = [];
957
+ extMetas[i].compute(
958
+ workingResolved,
959
+ workingResolved,
960
+ extSkipKeys,
961
+ extSkipVals,
962
+ extClasses,
963
+ styleOut,
964
+ );
965
+ if (extClasses.length > 0) {
966
+ const joined = clsx(extClasses);
967
+ if (joined.length > 0) {
968
+ classesOut.push(
969
+ extMetas[i].transformClass(joined) as ClsxClassValue,
970
+ );
934
971
  }
935
972
  }
936
- if (hasComputedVariantKeysSet) {
937
- propsForExt[SKIP_STYLE_KEYS] = computedVariantKeysSet;
938
- }
939
- if (hasComputedVariantValues) {
940
- propsForExt[SKIP_STYLE_VARIANT_VALUES] = computedVariantValues;
941
- }
942
973
  } else {
943
- propsForExt = resolvedVariants as Record<string | symbol, unknown>;
974
+ extMetas[i].compute(
975
+ workingResolved,
976
+ workingResolved,
977
+ extSkipKeys,
978
+ extSkipVals,
979
+ classesOut,
980
+ styleOut,
981
+ );
944
982
  }
945
-
946
- const extResult = ext(
947
- propsForExt as ComponentProps<Record<string, unknown>>,
948
- );
949
- // ext may be a modal component (.html / .htmlObj), whose style is a
950
- // CSS string or hyphen-keyed object — normalize before merging.
951
- if (extResult.style != null) {
952
- assign(allStyle, normalizeStyle(extResult.style));
953
- }
954
-
955
- allClasses.push(extBaseClass);
956
- const fullClass =
957
- "className" in extResult ? extResult.className : extResult.class;
958
- const variantPortion = extractVariantClasses(fullClass, extBaseClass);
959
- if (variantPortion) extVariantClasses.push(variantPortion);
960
- }
961
-
962
- // 2. Current base class
963
- allClasses.push(baseClass);
964
- if (baseStyle) assign(allStyle, baseStyle);
965
-
966
- // 4. Extended variant classes
967
- for (let i = 0; i < extVariantClasses.length; i++) {
968
- allClasses.push(extVariantClasses[i]);
969
983
  }
970
- } else {
971
- // No extends: just current base
972
- allClasses.push(baseClass);
973
- if (baseStyle) assign(allStyle, baseStyle);
974
984
  }
975
985
 
976
- // 5. Current component's variants (skip keys overridden)
977
- // Walk pre-built variant entries
986
+ // Apply own base style (after extends' styles, matching original order).
987
+ if (hasBaseStyle) Object.assign(styleOut, baseStyle);
988
+
989
+ // Apply own variants. Skip keys/values come from caller (e.g., parent
990
+ // wants its own computedVariants to override this variant).
991
+ // `variantEntryNames` already excludes disabled keys (those with `null`
992
+ // value in config), so we don't re-check `disabledVariantKeys` here.
993
+ const ownSkipKeys = skipKeys;
994
+ const ownSkipValues = skipValues;
978
995
  for (let i = 0; i < variantEntryCount; i++) {
979
996
  const variantName = variantEntryNames[i];
980
- const variant = variantEntryDefs[i];
981
- if (currentVariantKeys && currentVariantKeys.has(variantName)) continue;
982
- const selectedValue = resolvedVariants[variantName];
997
+ if (ownSkipKeys && ownSkipKeys.has(variantName)) continue;
998
+ const selectedValue = workingResolved[variantName];
983
999
  if (selectedValue === undefined) continue;
984
1000
  const selectedKey = getVariantValueKey(selectedValue);
985
- // disabled values from current config:
1001
+ const variant = variantEntryDefs[i];
986
1002
  if (
987
1003
  variant.disabledValues &&
988
1004
  selectedKey != null &&
@@ -990,13 +1006,10 @@ export function create({
990
1006
  ) {
991
1007
  continue;
992
1008
  }
993
- // skipVariantValues comes from skipStyleVariantValuesIn (only relevant
994
- // if this is being called as an extended component). For top-level it
995
- // would be undefined.
996
1009
  if (
997
- skipStyleVariantValuesIn &&
1010
+ ownSkipValues &&
998
1011
  selectedKey != null &&
999
- skipStyleVariantValuesIn[variantName]?.has(selectedKey)
1012
+ ownSkipValues[variantName]?.has(selectedKey)
1000
1013
  ) {
1001
1014
  continue;
1002
1015
  }
@@ -1005,83 +1018,149 @@ export function create({
1005
1018
  if (selectedKey == null) continue;
1006
1019
  const v = variant.values[selectedKey];
1007
1020
  if (!v) continue;
1008
- if (v.class != null) allClasses.push(v.class);
1009
- if (v.style) assign(allStyle, v.style);
1010
- } else if (variant.shorthand) {
1011
- // shorthand: applies when selectedValue === true
1012
- if (selectedValue === true) {
1013
- const v = variant.shorthand;
1014
- if (v.class != null) allClasses.push(v.class);
1015
- if (v.style) assign(allStyle, v.style);
1016
- }
1021
+ if (v.class != null) classesOut.push(v.class as ClsxClassValue);
1022
+ if (v.style) Object.assign(styleOut, v.style);
1023
+ } else if (variant.shorthand && selectedValue === true) {
1024
+ const v = variant.shorthand;
1025
+ if (v.class != null) classesOut.push(v.class as ClsxClassValue);
1026
+ if (v.style) Object.assign(styleOut, v.style);
1017
1027
  }
1018
1028
  }
1019
1029
 
1020
- // computedVariants
1030
+ // Apply computedVariants.
1021
1031
  for (let i = 0; i < computedVariantCount; i++) {
1022
1032
  const variantName = computedVariantNames[i];
1023
- const fn = computedVariantFns[i];
1024
- if (currentVariantKeys && currentVariantKeys.has(variantName)) continue;
1025
- const selectedValue = resolvedVariants[variantName];
1033
+ if (ownSkipKeys && ownSkipKeys.has(variantName)) continue;
1034
+ const selectedValue = workingResolved[variantName];
1026
1035
  if (selectedValue === undefined) continue;
1027
1036
  const selectedKey = getVariantValueKey(selectedValue);
1028
1037
  if (
1029
- skipStyleVariantValuesIn &&
1038
+ ownSkipValues &&
1030
1039
  selectedKey != null &&
1031
- skipStyleVariantValuesIn[variantName]?.has(selectedKey)
1040
+ ownSkipValues[variantName]?.has(selectedKey)
1032
1041
  ) {
1033
1042
  continue;
1034
1043
  }
1044
+ const fn = computedVariantFns[i];
1035
1045
  const computedResult = fn(selectedValue);
1036
1046
  if (computedResult == null) continue;
1037
1047
  const r = extractClassAndStylePrebuilt(computedResult);
1038
- if (r.class != null) allClasses.push(r.class);
1039
- if (r.style) assign(allStyle, r.style);
1048
+ if (r.class != null) classesOut.push(r.class as ClsxClassValue);
1049
+ if (r.style) Object.assign(styleOut, r.style);
1040
1050
  }
1041
1051
 
1042
- // computed function results
1043
- if (computedClassesArr) {
1044
- for (let i = 0; i < computedClassesArr.length; i++) {
1045
- allClasses.push(computedClassesArr[i]);
1052
+ // Apply `computed` results — must come after own variants/computedVariants.
1053
+ if (cClasses) {
1054
+ for (let i = 0; i < cClasses.length; i++) {
1055
+ classesOut.push(cClasses[i] as ClsxClassValue);
1056
+ }
1057
+ }
1058
+ if (cStyle) Object.assign(styleOut, cStyle);
1059
+ };
1060
+
1061
+ // Top-level: resolves variants from user props, calls compute, then
1062
+ // assembles className and style with user-provided class/style overrides.
1063
+ const computeResult = (
1064
+ props: ComponentProps<MergedVariants> = EMPTY_DEFAULTS as ComponentProps<MergedVariants>,
1065
+ ): { className: string; style: StyleValue } => {
1066
+ const propsRecord = props as Record<string, unknown>;
1067
+
1068
+ // Inline resolve: avoids allocating a separate variantProps object for
1069
+ // the common case where no extends need a resolveDefaults pass.
1070
+ // resolveVariantsHot would also work here but assumes its input is
1071
+ // variant-only (it uses for-in for speed).
1072
+ let resolved: Record<string, unknown> = {};
1073
+ Object.assign(resolved, staticDefaults);
1074
+
1075
+ let userVariantProps: Record<string, unknown>;
1076
+ if (extMetasWithResolveDefaultsCount > 0) {
1077
+ // Some extends need a resolveDefaults pass. They expect a variant-only
1078
+ // object as `userProps`, so we extract one.
1079
+ const variantProps: Record<string, unknown> = {};
1080
+ for (let i = 0; i < variantKeysLength; i++) {
1081
+ const key = variantKeys[i];
1082
+ if (hasOwn.call(propsRecord, key)) {
1083
+ variantProps[key] = propsRecord[key];
1084
+ }
1085
+ }
1086
+ for (let i = 0; i < extMetasWithResolveDefaultsCount; i++) {
1087
+ const meta = extMetasWithResolveDefaults[i];
1088
+ const extComputed = meta.resolveDefaults!(resolved, variantProps);
1089
+ for (const k in extComputed) {
1090
+ if (!hasOwn.call(extComputed, k)) continue;
1091
+ resolved[k] = extComputed[k];
1092
+ }
1046
1093
  }
1094
+ for (const k in variantProps) {
1095
+ if (!hasOwn.call(variantProps, k)) continue;
1096
+ const v = variantProps[k];
1097
+ if (v === undefined) continue;
1098
+ resolved[k] = v;
1099
+ }
1100
+ userVariantProps = variantProps;
1101
+ } else {
1102
+ // Fast path: walk variantKeys directly against propsRecord. Use
1103
+ // hasOwn so a polluted Object.prototype can't introduce variant
1104
+ // values the user didn't pass.
1105
+ for (let i = 0; i < variantKeysLength; i++) {
1106
+ const key = variantKeys[i];
1107
+ if (!hasOwn.call(propsRecord, key)) continue;
1108
+ const v = propsRecord[key];
1109
+ if (v === undefined) continue;
1110
+ resolved[key] = v;
1111
+ }
1112
+ userVariantProps = propsRecord;
1113
+ }
1114
+
1115
+ if (hasAnyDisabled) {
1116
+ const filtered: Record<string, unknown> = {};
1117
+ filterDisabledInto(resolved, filtered);
1118
+ resolved = filtered;
1047
1119
  }
1048
- if (computedStyleObj) assign(allStyle, computedStyleObj);
1049
1120
 
1050
- // props.class / props.className
1051
- if ("class" in props)
1052
- allClasses.push((props as { class: ClassValue }).class);
1053
- if ("className" in props)
1054
- allClasses.push((props as { className: ClassValue }).className);
1121
+ // Build allClasses directly. computedBaseClass already has all extend
1122
+ // bases joined with config.class `compute` only adds variant classes
1123
+ // on top.
1124
+ const allClasses: ClsxClassValue[] = [computedBaseClass];
1125
+ const style: StyleValue = {};
1126
+ compute(resolved, userVariantProps, null, null, allClasses, style);
1055
1127
 
1056
- // props.style
1057
- const psv = (props as { style?: unknown }).style;
1128
+ // Apply user-provided class / className.
1129
+ if ("class" in propsRecord) {
1130
+ allClasses.push(propsRecord.class as ClsxClassValue);
1131
+ }
1132
+ if ("className" in propsRecord) {
1133
+ allClasses.push(propsRecord.className as ClsxClassValue);
1134
+ }
1135
+
1136
+ // Apply user-provided style.
1137
+ const psv = propsRecord.style;
1058
1138
  if (psv != null) {
1059
- // Fast path: if it's an object with no keys, skip
1060
1139
  if (typeof psv === "string") {
1061
1140
  if (psv.length > 0) {
1062
- assign(allStyle, htmlStyleToStyleValue(psv));
1141
+ Object.assign(style, htmlStyleToStyleValue(psv));
1063
1142
  }
1064
1143
  } else if (typeof psv === "object") {
1065
- // Could be HTMLObj or JSX form. Don't allocate when empty.
1144
+ // Don't allocate when empty.
1066
1145
  let hasAnyKey = false;
1067
1146
  for (const _ in psv) {
1068
1147
  hasAnyKey = true;
1069
1148
  break;
1070
1149
  }
1071
1150
  if (hasAnyKey) {
1072
- assign(allStyle, normalizeStyle(psv));
1151
+ Object.assign(style, normalizeStyle(psv));
1073
1152
  }
1074
1153
  }
1075
1154
  }
1076
1155
 
1077
1156
  return {
1078
- className: cx(...(allClasses as ClsxClassValue[])),
1079
- style: allStyle,
1157
+ className: transformClass(clsx(allClasses)),
1158
+ style,
1080
1159
  };
1081
1160
  };
1082
1161
 
1083
1162
  const getVariants = (variants?: VariantValues<MergedVariants>) => {
1084
- const variantProps = (variants ?? {}) as Record<string, unknown>;
1163
+ const variantProps = variants ?? EMPTY_DEFAULTS;
1085
1164
  let resolvedVariants = resolveVariantsHot(variantProps);
1086
1165
  // Run computed function to get variants set via setVariants and
1087
1166
  // setDefaultVariants
@@ -1095,13 +1174,22 @@ export function create({
1095
1174
  ) => {
1096
1175
  if (!hasAnyDisabled) {
1097
1176
  Object.assign(updatedVariants, newVariants);
1098
- } else {
1099
- const filtered: Record<string, unknown> = {};
1100
- filterDisabledInto(
1101
- newVariants as Record<string, unknown>,
1102
- filtered,
1103
- );
1104
- Object.assign(updatedVariants, filtered);
1177
+ return;
1178
+ }
1179
+ for (const key in newVariants) {
1180
+ if (!hasOwn.call(newVariants, key)) continue;
1181
+ if (disabledVariantKeys.has(key)) continue;
1182
+ const value = (newVariants as Record<string, unknown>)[key];
1183
+ if (hasDisabledVariantValues) {
1184
+ const valueKey = getVariantValueKey(value);
1185
+ if (
1186
+ valueKey != null &&
1187
+ disabledVariantValues[key]?.has(valueKey)
1188
+ ) {
1189
+ continue;
1190
+ }
1191
+ }
1192
+ updatedVariants[key] = value;
1105
1193
  }
1106
1194
  },
1107
1195
  setDefaultVariants: (
@@ -1124,8 +1212,8 @@ export function create({
1124
1212
  updatedVariants[key] = value;
1125
1213
  }
1126
1214
  },
1127
- addClass: () => {},
1128
- addStyle: () => {},
1215
+ addClass: noop,
1216
+ addStyle: noop,
1129
1217
  };
1130
1218
  computed(ctx);
1131
1219
  if (hasAnyDisabled) {
@@ -1139,10 +1227,13 @@ export function create({
1139
1227
  return resolvedVariants as VariantValues<MergedVariants>;
1140
1228
  };
1141
1229
 
1142
- // Compute base class (without variants) - includes extended base classes.
1143
- // Reuses `extBaseClassesArr` (built earlier) so we don't walk `extend` and
1144
- // call `getComponentMeta` a second time.
1145
- const computedBaseClass = cx(
1230
+ // Compute base class (without variants) includes extended base classes.
1231
+ // Plain `clsx` (no `transformClass`): `meta.baseClass` flows back into
1232
+ // parent extends as `clsx` input and then through the single
1233
+ // `transformClass(clsx(allClasses))` at render time, so applying it here
1234
+ // would compound (double for own-render, triple+ for extend chains) and
1235
+ // misbehave for non-idempotent transforms.
1236
+ const computedBaseClass = clsx(
1146
1237
  ...(extBaseClassesArr as ClsxClassValue[]),
1147
1238
  config.class as ClsxClassValue,
1148
1239
  );
@@ -1155,6 +1246,8 @@ export function create({
1155
1246
  baseClass: computedBaseClass,
1156
1247
  staticDefaults,
1157
1248
  resolveDefaults: resolveDefaultsFn,
1249
+ compute,
1250
+ transformClass,
1158
1251
  };
1159
1252
 
1160
1253
  const initComponent = <
@@ -1227,4 +1320,6 @@ export function create({
1227
1320
  return { cv, cx };
1228
1321
  }
1229
1322
 
1323
+ function noop() {}
1324
+
1230
1325
  export const { cv, cx } = create();