From d89c0b95b74fc17bd6913eecf1fb21772ea9db87 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 25 Feb 2026 13:02:39 -0500 Subject: [PATCH 1/4] feat(mix): add token styler API and preserve builder merge order --- packages/mix/lib/src/core/style.dart | 146 ++++++++++-------- .../mix/lib/src/specs/box/box_style.g.dart | 12 ++ .../mix/lib/src/specs/flex/flex_style.g.dart | 12 ++ .../src/specs/flexbox/flexbox_style.g.dart | 12 ++ .../mix/lib/src/specs/icon/icon_style.g.dart | 12 ++ .../lib/src/specs/image/image_style.g.dart | 12 ++ .../lib/src/specs/stack/stack_style.g.dart | 12 ++ .../src/specs/stackbox/stackbox_style.g.dart | 12 ++ .../mix/lib/src/specs/text/text_style.g.dart | 12 ++ .../mix/lib/src/style/abstracts/styler.dart | 2 + .../src/style/mixins/token_style_mixin.dart | 14 ++ .../src/core/style_get_all_variants_test.dart | 90 ++++++++--- .../src/style/token_style_mixin_test.dart | 102 ++++++++++++ .../core/builders/styler_mixin_builder.dart | 16 ++ 14 files changed, 377 insertions(+), 89 deletions(-) create mode 100644 packages/mix/lib/src/style/mixins/token_style_mixin.dart create mode 100644 packages/mix/test/src/style/token_style_mixin_test.dart diff --git a/packages/mix/lib/src/core/style.dart b/packages/mix/lib/src/core/style.dart index e5e72f5aa1..1f6dbb4116 100644 --- a/packages/mix/lib/src/core/style.dart +++ b/packages/mix/lib/src/core/style.dart @@ -11,6 +11,8 @@ import 'spec.dart'; import 'style_spec.dart'; import 'widget_modifier.dart'; +export '../variants/variant.dart' show ContextVariantBuilder; + /// Marker interface for style-related elements. @internal sealed class StyleElement { @@ -22,6 +24,12 @@ sealed class StyleElement { /// Provides variant support, modifiers, and animation configuration for styled elements. abstract class Style> extends Mix> implements StyleElement { + static int _activeVariantResolutionDepth = 0; + + @internal + static bool get isResolvingActiveVariants => + _activeVariantResolutionDepth > 0; + final List>? $variants; final WidgetModifierConfig? $modifier; @@ -84,74 +92,84 @@ abstract class Style> extends Mix> BuildContext context, { required Set namedVariants, }) { - // Filter variants that should be active in this context - final activeVariants = ($variants ?? []) - .where( - (variantAttr) => switch (variantAttr.variant) { - (ContextVariant variant) => variant.when(context), - (NamedVariant variant) => namedVariants.contains(variant), - (ContextVariantBuilder _) => true, - }, - ) - .toList(); - - // Sort by priority: WidgetStateVariant gets applied last (highest priority) - activeVariants.sort( - (a, b) => Comparable.compare( - a.variant is WidgetStateVariant ? 1 : 0, - b.variant is WidgetStateVariant ? 1 : 0, - ), - ); - - // Extract the style from each active variant - final stylesToMerge = <(Style, bool)>[]; // (style, isFromStyleVariation) - - for (final variantAttr in activeVariants) { - final result = switch (variantAttr.variant) { - ContextVariantBuilder variant => ( - variant.build(context) as Style, - false, - ), - (ContextVariant() || NamedVariant()) => () { - // Check if the value is a StyleVariation - // ignore: avoid-unrelated-type-assertions - if (variantAttr.value is StyleVariation) { - // ignore: avoid-unrelated-type-casts - final styleVariation = variantAttr.value as StyleVariation; - // Only apply if this variant is active - if (namedVariants.contains(styleVariation.variantType)) { - return ( - styleVariation.styleBuilder(this, namedVariants, context), - true, - ); + _activeVariantResolutionDepth++; + try { + // Filter variants that should be active in this context. + final activeVariants = ($variants ?? []) + .where( + (variantAttr) => switch (variantAttr.variant) { + (ContextVariant variant) => variant.when(context), + (NamedVariant variant) => namedVariants.contains(variant), + (ContextVariantBuilder _) => true, + }, + ) + .toList(); + + // Preserve insertion order while still applying WidgetStateVariant last. + final prioritizedVariants = >[]; + final widgetStateVariants = >[]; + for (final variantAttr in activeVariants) { + if (variantAttr.variant is WidgetStateVariant) { + widgetStateVariants.add(variantAttr); + } else { + prioritizedVariants.add(variantAttr); + } + } + prioritizedVariants.addAll(widgetStateVariants); + + // Extract the style from each active variant + final stylesToMerge = + <(Style, bool)>[]; // (style, isFromStyleVariation) + + for (final variantAttr in prioritizedVariants) { + final result = switch (variantAttr.variant) { + ContextVariantBuilder variant => ( + variant.build(context) as Style, + false, + ), + (ContextVariant() || NamedVariant()) => () { + // Check if the value is a StyleVariation + // ignore: avoid-unrelated-type-assertions + if (variantAttr.value is StyleVariation) { + // ignore: avoid-unrelated-type-casts + final styleVariation = variantAttr.value as StyleVariation; + // Only apply if this variant is active + if (namedVariants.contains(styleVariation.variantType)) { + return ( + styleVariation.styleBuilder(this, namedVariants, context), + true, + ); + } } - } - return (variantAttr.value, false); - }(), - }; - stylesToMerge.add(result); - } + return (variantAttr.value, false); + }(), + }; + stylesToMerge.add(result); + } + + // Start with current style as base + Style mergedStyle = this; + + // Merge each variant style, recursively resolving nested variants + for (final (variantStyle, isFromStyleVariation) in stylesToMerge) { + final fullyResolvedStyle = isFromStyleVariation + // For StyleVariation results, we don't recursively resolve variants + // since StyleVariation.styleBuilder should handle its own variant logic + // and return a final style. This prevents infinite recursion. + ? variantStyle + // For regular variants, recursively resolve any nested variants + : variantStyle.mergeActiveVariants( + context, + namedVariants: namedVariants, + ); + mergedStyle = mergedStyle.merge(fullyResolvedStyle); + } - // Start with current style as base - Style mergedStyle = this; - - // Merge each variant style, recursively resolving nested variants - for (final (variantStyle, isFromStyleVariation) in stylesToMerge) { - final fullyResolvedStyle = isFromStyleVariation - // For StyleVariation results, we don't recursively resolve variants - // since StyleVariation.styleBuilder should handle its own variant logic - // and return a final style. This prevents infinite recursion. - ? variantStyle - // For regular variants, recursively resolve any nested variants - : variantStyle.mergeActiveVariants( - context, - namedVariants: namedVariants, - ); - mergedStyle = mergedStyle.merge(fullyResolvedStyle); + return mergedStyle; + } finally { + _activeVariantResolutionDepth--; } - - return mergedStyle; } /// Resolves this attribute to its concrete value using the provided [BuildContext]. diff --git a/packages/mix/lib/src/specs/box/box_style.g.dart b/packages/mix/lib/src/specs/box/box_style.g.dart index bd420e5a82..4cf54d5ee6 100644 --- a/packages/mix/lib/src/specs/box/box_style.g.dart +++ b/packages/mix/lib/src/specs/box/box_style.g.dart @@ -70,6 +70,18 @@ mixin _$BoxStylerMixin on Style, Diagnosticable { /// Merges with another [BoxStyler]. @override BoxStyler merge(BoxStyler? other) { + final hasContextVariantBuilders = + $variants?.any((v) => v.variant is ContextVariantBuilder) ?? false; + if (other != null && + !Style.isResolvingActiveVariants && + hasContextVariantBuilders && + other.$variants == null) { + final builder = ContextVariantBuilder((_) => other); + return merge( + BoxStyler(variants: [VariantStyle(builder, other)]), + ); + } + return BoxStyler.create( alignment: MixOps.merge($alignment, other?.$alignment), clipBehavior: MixOps.merge($clipBehavior, other?.$clipBehavior), diff --git a/packages/mix/lib/src/specs/flex/flex_style.g.dart b/packages/mix/lib/src/specs/flex/flex_style.g.dart index c2f03f1138..8e5d801eb4 100644 --- a/packages/mix/lib/src/specs/flex/flex_style.g.dart +++ b/packages/mix/lib/src/specs/flex/flex_style.g.dart @@ -80,6 +80,18 @@ mixin _$FlexStylerMixin on Style, Diagnosticable { /// Merges with another [FlexStyler]. @override FlexStyler merge(FlexStyler? other) { + final hasContextVariantBuilders = + $variants?.any((v) => v.variant is ContextVariantBuilder) ?? false; + if (other != null && + !Style.isResolvingActiveVariants && + hasContextVariantBuilders && + other.$variants == null) { + final builder = ContextVariantBuilder((_) => other); + return merge( + FlexStyler(variants: [VariantStyle(builder, other)]), + ); + } + return FlexStyler.create( clipBehavior: MixOps.merge($clipBehavior, other?.$clipBehavior), crossAxisAlignment: MixOps.merge( diff --git a/packages/mix/lib/src/specs/flexbox/flexbox_style.g.dart b/packages/mix/lib/src/specs/flexbox/flexbox_style.g.dart index 34f1256665..2bb7ce47b4 100644 --- a/packages/mix/lib/src/specs/flexbox/flexbox_style.g.dart +++ b/packages/mix/lib/src/specs/flexbox/flexbox_style.g.dart @@ -13,6 +13,18 @@ mixin _$FlexBoxStylerMixin on Style, Diagnosticable { /// Merges with another [FlexBoxStyler]. @override FlexBoxStyler merge(FlexBoxStyler? other) { + final hasContextVariantBuilders = + $variants?.any((v) => v.variant is ContextVariantBuilder) ?? false; + if (other != null && + !Style.isResolvingActiveVariants && + hasContextVariantBuilders && + other.$variants == null) { + final builder = ContextVariantBuilder((_) => other); + return merge( + FlexBoxStyler(variants: [VariantStyle(builder, other)]), + ); + } + return FlexBoxStyler.create( box: MixOps.merge($box, other?.$box), flex: MixOps.merge($flex, other?.$flex), diff --git a/packages/mix/lib/src/specs/icon/icon_style.g.dart b/packages/mix/lib/src/specs/icon/icon_style.g.dart index 2dde3e2593..26204514e1 100644 --- a/packages/mix/lib/src/specs/icon/icon_style.g.dart +++ b/packages/mix/lib/src/specs/icon/icon_style.g.dart @@ -104,6 +104,18 @@ mixin _$IconStylerMixin on Style, Diagnosticable { /// Merges with another [IconStyler]. @override IconStyler merge(IconStyler? other) { + final hasContextVariantBuilders = + $variants?.any((v) => v.variant is ContextVariantBuilder) ?? false; + if (other != null && + !Style.isResolvingActiveVariants && + hasContextVariantBuilders && + other.$variants == null) { + final builder = ContextVariantBuilder((_) => other); + return merge( + IconStyler(variants: [VariantStyle(builder, other)]), + ); + } + return IconStyler.create( applyTextScaling: MixOps.merge( $applyTextScaling, diff --git a/packages/mix/lib/src/specs/image/image_style.g.dart b/packages/mix/lib/src/specs/image/image_style.g.dart index 658aade81d..cf77e36056 100644 --- a/packages/mix/lib/src/specs/image/image_style.g.dart +++ b/packages/mix/lib/src/specs/image/image_style.g.dart @@ -116,6 +116,18 @@ mixin _$ImageStylerMixin on Style, Diagnosticable { /// Merges with another [ImageStyler]. @override ImageStyler merge(ImageStyler? other) { + final hasContextVariantBuilders = + $variants?.any((v) => v.variant is ContextVariantBuilder) ?? false; + if (other != null && + !Style.isResolvingActiveVariants && + hasContextVariantBuilders && + other.$variants == null) { + final builder = ContextVariantBuilder((_) => other); + return merge( + ImageStyler(variants: [VariantStyle(builder, other)]), + ); + } + return ImageStyler.create( alignment: MixOps.merge($alignment, other?.$alignment), centerSlice: MixOps.merge($centerSlice, other?.$centerSlice), diff --git a/packages/mix/lib/src/specs/stack/stack_style.g.dart b/packages/mix/lib/src/specs/stack/stack_style.g.dart index 61f8eb2216..08febb539d 100644 --- a/packages/mix/lib/src/specs/stack/stack_style.g.dart +++ b/packages/mix/lib/src/specs/stack/stack_style.g.dart @@ -50,6 +50,18 @@ mixin _$StackStylerMixin on Style, Diagnosticable { /// Merges with another [StackStyler]. @override StackStyler merge(StackStyler? other) { + final hasContextVariantBuilders = + $variants?.any((v) => v.variant is ContextVariantBuilder) ?? false; + if (other != null && + !Style.isResolvingActiveVariants && + hasContextVariantBuilders && + other.$variants == null) { + final builder = ContextVariantBuilder((_) => other); + return merge( + StackStyler(variants: [VariantStyle(builder, other)]), + ); + } + return StackStyler.create( alignment: MixOps.merge($alignment, other?.$alignment), clipBehavior: MixOps.merge($clipBehavior, other?.$clipBehavior), diff --git a/packages/mix/lib/src/specs/stackbox/stackbox_style.g.dart b/packages/mix/lib/src/specs/stackbox/stackbox_style.g.dart index b8c67e04e7..7f2aaeb992 100644 --- a/packages/mix/lib/src/specs/stackbox/stackbox_style.g.dart +++ b/packages/mix/lib/src/specs/stackbox/stackbox_style.g.dart @@ -13,6 +13,18 @@ mixin _$StackBoxStylerMixin on Style, Diagnosticable { /// Merges with another [StackBoxStyler]. @override StackBoxStyler merge(StackBoxStyler? other) { + final hasContextVariantBuilders = + $variants?.any((v) => v.variant is ContextVariantBuilder) ?? false; + if (other != null && + !Style.isResolvingActiveVariants && + hasContextVariantBuilders && + other.$variants == null) { + final builder = ContextVariantBuilder((_) => other); + return merge( + StackBoxStyler(variants: [VariantStyle(builder, other)]), + ); + } + return StackBoxStyler.create( box: MixOps.merge($box, other?.$box), stack: MixOps.merge($stack, other?.$stack), diff --git a/packages/mix/lib/src/specs/text/text_style.g.dart b/packages/mix/lib/src/specs/text/text_style.g.dart index cc31b169f0..31cf2680aa 100644 --- a/packages/mix/lib/src/specs/text/text_style.g.dart +++ b/packages/mix/lib/src/specs/text/text_style.g.dart @@ -105,6 +105,18 @@ mixin _$TextStylerMixin on Style, Diagnosticable { /// Merges with another [TextStyler]. @override TextStyler merge(TextStyler? other) { + final hasContextVariantBuilders = + $variants?.any((v) => v.variant is ContextVariantBuilder) ?? false; + if (other != null && + !Style.isResolvingActiveVariants && + hasContextVariantBuilders && + other.$variants == null) { + final builder = ContextVariantBuilder((_) => other); + return merge( + TextStyler(variants: [VariantStyle(builder, other)]), + ); + } + return TextStyler.create( locale: MixOps.merge($locale, other?.$locale), maxLines: MixOps.merge($maxLines, other?.$maxLines), diff --git a/packages/mix/lib/src/style/abstracts/styler.dart b/packages/mix/lib/src/style/abstracts/styler.dart index 36226cee5d..c22b0e665b 100644 --- a/packages/mix/lib/src/style/abstracts/styler.dart +++ b/packages/mix/lib/src/style/abstracts/styler.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import '../../core/spec.dart'; import '../../core/style.dart'; import '../mixins/animation_style_mixin.dart'; +import '../mixins/token_style_mixin.dart'; import '../mixins/variant_style_mixin.dart'; import '../mixins/widget_modifier_style_mixin.dart'; import '../mixins/widget_state_variant_mixin.dart'; @@ -13,6 +14,7 @@ abstract class MixStyler, SP extends Spec> Diagnosticable, WidgetModifierStyleMixin, VariantStyleMixin, + TokenStyleMixin, WidgetStateVariantMixin, AnimationStyleMixin { const MixStyler({ diff --git a/packages/mix/lib/src/style/mixins/token_style_mixin.dart b/packages/mix/lib/src/style/mixins/token_style_mixin.dart new file mode 100644 index 0000000000..318e65c4d8 --- /dev/null +++ b/packages/mix/lib/src/style/mixins/token_style_mixin.dart @@ -0,0 +1,14 @@ +import '../../core/spec.dart'; +import '../../core/style.dart'; +import '../../theme/tokens/mix_token.dart'; +import 'variant_style_mixin.dart'; + +mixin TokenStyleMixin, S extends Spec> + on VariantStyleMixin { + /// Applies style values produced from a resolved [MixToken]. + /// + /// The token is resolved at build time using the current [BuildContext]. + T useToken(MixToken token, T Function(U value) builder) { + return onBuilder((context) => builder(token.resolve(context))); + } +} diff --git a/packages/mix/test/src/core/style_get_all_variants_test.dart b/packages/mix/test/src/core/style_get_all_variants_test.dart index 68c127927f..cb1c09057e 100644 --- a/packages/mix/test/src/core/style_get_all_variants_test.dart +++ b/packages/mix/test/src/core/style_get_all_variants_test.dart @@ -599,27 +599,71 @@ void main() { expect((spec.resolvedValue as Map)['width'], 50.0); }); - test('sorting is stable for non-WidgetStateVariant elements', () { - // Create multiple non-WidgetState variants to test stable sort - final variants = [ - VariantStyle( - ContextVariant('context1', (context) => true), - _MockSpecAttribute(width: 100.0), - ), - VariantStyle( - const NamedVariant('named1'), - _MockSpecAttribute(width: 200.0), - ), - VariantStyle( - ContextVariant('context2', (context) => true), - _MockSpecAttribute(width: 300.0), - ), - VariantStyle( - const NamedVariant('named2'), - _MockSpecAttribute(width: 400.0), - ), + test('non-WidgetState variants preserve insertion order', () { + // This specific order is intentionally non-sequential and large enough + // to catch unstable ordering if equal-priority variants are re-sorted. + const order = [ + 48, + 42, + 46, + 5, + 20, + 24, + 36, + 28, + 23, + 32, + 13, + 0, + 30, + 16, + 31, + 10, + 11, + 25, + 9, + 1, + 41, + 33, + 26, + 2, + 29, + 6, + 35, + 39, + 18, + 22, + 14, + 37, + 19, + 40, + 38, + 44, + 49, + 15, + 43, + 34, + 3, + 12, + 7, + 8, + 45, + 27, + 21, + 47, + 17, + 4, ]; + final variants = order + .map( + (value) => VariantStyle( + ContextVariant('context$value', (context) => true), + _MockSpecAttribute(width: value.toDouble()), + ), + ) + .toList(); + final testAttribute = _MockSpecAttribute( width: 50.0, variants: variants, @@ -628,15 +672,11 @@ void main() { final context = MockBuildContext(); final result = testAttribute.mergeActiveVariants( context, - namedVariants: { - const NamedVariant('named1'), - const NamedVariant('named2'), - }, + namedVariants: {}, ); - // Should maintain original order, last applied is named2 (400) final spec = result.resolve(context); - expect((spec.resolvedValue as Map)['width'], 400.0); + expect((spec.resolvedValue as Map)['width'], 4.0); }); }); diff --git a/packages/mix/test/src/style/token_style_mixin_test.dart b/packages/mix/test/src/style/token_style_mixin_test.dart new file mode 100644 index 0000000000..edf2db6a20 --- /dev/null +++ b/packages/mix/test/src/style/token_style_mixin_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix/mix.dart'; + +extension TokenWidgetTester on WidgetTester { + Future pumpWithTokens( + Map tokens, { + required Widget child, + }) { + return pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MixScope(tokens: tokens, child: child), + ), + ); + } +} + +void main() { + group('TokenStyleMixin', () { + test('useToken registers a context variant builder', () { + const colorToken = ColorToken('test.color'); + final style = BoxStyler().useToken(colorToken, BoxStyler().color); + + expect(style.$variants, isNotNull); + expect(style.$variants, hasLength(1)); + expect(style.$variants!.first.variant, isA()); + }); + + testWidgets('resolves token value in widget tree', (tester) async { + const colorToken = ColorToken('test.primary'); + const tokenColor = Colors.blue; + + final style = BoxStyler().useToken(colorToken, BoxStyler().color); + + await tester.pumpWithTokens({ + colorToken: tokenColor, + }, child: Box(style: style)); + + final container = tester.widget(find.byType(Container)); + final decoration = container.decoration as BoxDecoration?; + expect(decoration?.color, tokenColor); + }); + + testWidgets('property after useToken takes precedence', (tester) async { + const colorToken = ColorToken('test.after.token'); + + final style = BoxStyler() + .useToken(colorToken, BoxStyler().color) + .color(Colors.red); + + await tester.pumpWithTokens({ + colorToken: Colors.green, + }, child: Box(style: style)); + + final container = tester.widget(find.byType(Container)); + final decoration = container.decoration as BoxDecoration?; + expect(decoration?.color, Colors.red); + }); + + testWidgets('property-token-property keeps last property winner', ( + tester, + ) async { + const colorToken = ColorToken('test.middle.token'); + + final style = BoxStyler() + .color(Colors.blue) + .useToken(colorToken, BoxStyler().color) + .color(Colors.red); + + await tester.pumpWithTokens({ + colorToken: Colors.green, + }, child: Box(style: style)); + + final container = tester.widget(find.byType(Container)); + final decoration = container.decoration as BoxDecoration?; + expect(decoration?.color, Colors.red); + }); + + testWidgets('post-token non-overlapping properties are preserved', ( + tester, + ) async { + const colorToken = ColorToken('test.chain.token'); + const testWidth = 123.0; + + final style = BoxStyler() + .useToken(colorToken, BoxStyler().color) + .width(testWidth) + .color(Colors.red); + + await tester.pumpWithTokens({ + colorToken: Colors.green, + }, child: Box(style: style)); + + final container = tester.widget(find.byType(Container)); + final decoration = container.decoration as BoxDecoration?; + expect(decoration?.color, Colors.red); + expect(container.constraints?.minWidth, testWidth); + expect(container.constraints?.maxWidth, testWidth); + }); + }); +} diff --git a/packages/mix_generator/lib/src/core/builders/styler_mixin_builder.dart b/packages/mix_generator/lib/src/core/builders/styler_mixin_builder.dart index 656f9570ac..0574aaa3cb 100644 --- a/packages/mix_generator/lib/src/core/builders/styler_mixin_builder.dart +++ b/packages/mix_generator/lib/src/core/builders/styler_mixin_builder.dart @@ -94,6 +94,22 @@ class StylerMixinBuilder { buffer.writeln(' /// Merges with another [$stylerName].'); buffer.writeln(' @override'); buffer.writeln(' $stylerName merge($stylerName? other) {'); + buffer.writeln(' final hasContextVariantBuilders ='); + buffer.writeln( + ' \$variants?.any((v) => v.variant is ContextVariantBuilder) ?? false;', + ); + buffer.writeln(' if (other != null &&'); + buffer.writeln(' !Style.isResolvingActiveVariants &&'); + buffer.writeln(' hasContextVariantBuilders &&'); + buffer.writeln(' other.\$variants == null) {'); + buffer.writeln(' final builder = ContextVariantBuilder<$stylerName>('); + buffer.writeln(' (_) => other,'); + buffer.writeln(' );'); + buffer.writeln( + ' return merge($stylerName(variants: [VariantStyle<$specName>(builder, other)]));', + ); + buffer.writeln(' }'); + buffer.writeln(); buffer.writeln(' return $stylerName.create('); // Field merge assignments From 605d25bdfb02702ff0491f6b428b5a26f7fc7280 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 25 Feb 2026 15:07:49 -0500 Subject: [PATCH 2/4] fix(mix): narrow active variant merge guard scope --- packages/mix/lib/src/core/style.dart | 157 +++++++++--------- .../src/style/token_style_mixin_test.dart | 21 +++ 2 files changed, 102 insertions(+), 76 deletions(-) diff --git a/packages/mix/lib/src/core/style.dart b/packages/mix/lib/src/core/style.dart index 1f6dbb4116..5e3ce56a4c 100644 --- a/packages/mix/lib/src/core/style.dart +++ b/packages/mix/lib/src/core/style.dart @@ -24,11 +24,20 @@ sealed class StyleElement { /// Provides variant support, modifiers, and animation configuration for styled elements. abstract class Style> extends Mix> implements StyleElement { - static int _activeVariantResolutionDepth = 0; + static int _activeVariantMergeDepth = 0; @internal - static bool get isResolvingActiveVariants => - _activeVariantResolutionDepth > 0; + static bool get isResolvingActiveVariants => _activeVariantMergeDepth > 0; + + @internal + static T _withActiveVariantMergeGuard(T Function() fn) { + _activeVariantMergeDepth++; + try { + return fn(); + } finally { + _activeVariantMergeDepth--; + } + } final List>? $variants; @@ -92,84 +101,80 @@ abstract class Style> extends Mix> BuildContext context, { required Set namedVariants, }) { - _activeVariantResolutionDepth++; - try { - // Filter variants that should be active in this context. - final activeVariants = ($variants ?? []) - .where( - (variantAttr) => switch (variantAttr.variant) { - (ContextVariant variant) => variant.when(context), - (NamedVariant variant) => namedVariants.contains(variant), - (ContextVariantBuilder _) => true, - }, - ) - .toList(); - - // Preserve insertion order while still applying WidgetStateVariant last. - final prioritizedVariants = >[]; - final widgetStateVariants = >[]; - for (final variantAttr in activeVariants) { - if (variantAttr.variant is WidgetStateVariant) { - widgetStateVariants.add(variantAttr); - } else { - prioritizedVariants.add(variantAttr); - } + // Filter variants that should be active in this context. + final activeVariants = ($variants ?? []) + .where( + (variantAttr) => switch (variantAttr.variant) { + (ContextVariant variant) => variant.when(context), + (NamedVariant variant) => namedVariants.contains(variant), + (ContextVariantBuilder _) => true, + }, + ) + .toList(); + + // Preserve insertion order while still applying WidgetStateVariant last. + final prioritizedVariants = >[]; + final widgetStateVariants = >[]; + for (final variantAttr in activeVariants) { + if (variantAttr.variant is WidgetStateVariant) { + widgetStateVariants.add(variantAttr); + } else { + prioritizedVariants.add(variantAttr); } - prioritizedVariants.addAll(widgetStateVariants); - - // Extract the style from each active variant - final stylesToMerge = - <(Style, bool)>[]; // (style, isFromStyleVariation) - - for (final variantAttr in prioritizedVariants) { - final result = switch (variantAttr.variant) { - ContextVariantBuilder variant => ( - variant.build(context) as Style, - false, - ), - (ContextVariant() || NamedVariant()) => () { - // Check if the value is a StyleVariation - // ignore: avoid-unrelated-type-assertions - if (variantAttr.value is StyleVariation) { - // ignore: avoid-unrelated-type-casts - final styleVariation = variantAttr.value as StyleVariation; - // Only apply if this variant is active - if (namedVariants.contains(styleVariation.variantType)) { - return ( - styleVariation.styleBuilder(this, namedVariants, context), - true, - ); - } + } + prioritizedVariants.addAll(widgetStateVariants); + + // Extract the style from each active variant + final stylesToMerge = <(Style, bool)>[]; // (style, isFromStyleVariation) + + for (final variantAttr in prioritizedVariants) { + final result = switch (variantAttr.variant) { + ContextVariantBuilder variant => ( + variant.build(context) as Style, + false, + ), + (ContextVariant() || NamedVariant()) => () { + // Check if the value is a StyleVariation + // ignore: avoid-unrelated-type-assertions + if (variantAttr.value is StyleVariation) { + // ignore: avoid-unrelated-type-casts + final styleVariation = variantAttr.value as StyleVariation; + // Only apply if this variant is active + if (namedVariants.contains(styleVariation.variantType)) { + return ( + styleVariation.styleBuilder(this, namedVariants, context), + true, + ); } + } - return (variantAttr.value, false); - }(), - }; - stylesToMerge.add(result); - } - - // Start with current style as base - Style mergedStyle = this; - - // Merge each variant style, recursively resolving nested variants - for (final (variantStyle, isFromStyleVariation) in stylesToMerge) { - final fullyResolvedStyle = isFromStyleVariation - // For StyleVariation results, we don't recursively resolve variants - // since StyleVariation.styleBuilder should handle its own variant logic - // and return a final style. This prevents infinite recursion. - ? variantStyle - // For regular variants, recursively resolve any nested variants - : variantStyle.mergeActiveVariants( - context, - namedVariants: namedVariants, - ); - mergedStyle = mergedStyle.merge(fullyResolvedStyle); - } + return (variantAttr.value, false); + }(), + }; + stylesToMerge.add(result); + } - return mergedStyle; - } finally { - _activeVariantResolutionDepth--; + // Start with current style as base + Style mergedStyle = this; + + // Merge each variant style, recursively resolving nested variants + for (final (variantStyle, isFromStyleVariation) in stylesToMerge) { + final fullyResolvedStyle = isFromStyleVariation + // For StyleVariation results, we don't recursively resolve variants + // since StyleVariation.styleBuilder should handle its own variant logic + // and return a final style. This prevents infinite recursion. + ? variantStyle + // For regular variants, recursively resolve any nested variants + : variantStyle.mergeActiveVariants( + context, + namedVariants: namedVariants, + ); + mergedStyle = _withActiveVariantMergeGuard( + () => mergedStyle.merge(fullyResolvedStyle), + ); } + + return mergedStyle; } /// Resolves this attribute to its concrete value using the provided [BuildContext]. diff --git a/packages/mix/test/src/style/token_style_mixin_test.dart b/packages/mix/test/src/style/token_style_mixin_test.dart index edf2db6a20..2f1eb08a00 100644 --- a/packages/mix/test/src/style/token_style_mixin_test.dart +++ b/packages/mix/test/src/style/token_style_mixin_test.dart @@ -98,5 +98,26 @@ void main() { expect(container.constraints?.minWidth, testWidth); expect(container.constraints?.maxWidth, testWidth); }); + + testWidgets( + 'nested onBuilder output preserves post-token property precedence', + (tester) async { + const colorToken = ColorToken('test.nested.builder.token'); + + final style = BoxStyler().onBuilder((context) { + return BoxStyler() + .useToken(colorToken, BoxStyler().color) + .color(Colors.red); + }); + + await tester.pumpWithTokens({ + colorToken: Colors.green, + }, child: Box(style: style)); + + final container = tester.widget(find.byType(Container)); + final decoration = container.decoration as BoxDecoration?; + expect(decoration?.color, Colors.red); + }, + ); }); } From f80a63ffbf335e2eaaecd12add028831c58b1127 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Wed, 25 Feb 2026 15:41:37 -0500 Subject: [PATCH 3/4] test(mix): add token accumulation edge-case coverage --- packages/mix/lib/src/core/style.dart | 1 - .../src/style/token_style_mixin_test.dart | 125 ++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/packages/mix/lib/src/core/style.dart b/packages/mix/lib/src/core/style.dart index 5e3ce56a4c..9970c81668 100644 --- a/packages/mix/lib/src/core/style.dart +++ b/packages/mix/lib/src/core/style.dart @@ -29,7 +29,6 @@ abstract class Style> extends Mix> @internal static bool get isResolvingActiveVariants => _activeVariantMergeDepth > 0; - @internal static T _withActiveVariantMergeGuard(T Function() fn) { _activeVariantMergeDepth++; try { diff --git a/packages/mix/test/src/style/token_style_mixin_test.dart b/packages/mix/test/src/style/token_style_mixin_test.dart index 2f1eb08a00..a576840a0f 100644 --- a/packages/mix/test/src/style/token_style_mixin_test.dart +++ b/packages/mix/test/src/style/token_style_mixin_test.dart @@ -99,6 +99,131 @@ void main() { expect(container.constraints?.maxWidth, testWidth); }); + testWidgets('explicit merge after useToken preserves declaration order', ( + tester, + ) async { + const colorToken = ColorToken('test.explicit.merge.token'); + + final style = BoxStyler() + .useToken(colorToken, BoxStyler().color) + .merge(BoxStyler().color(Colors.red)); + + await tester.pumpWithTokens({ + colorToken: Colors.green, + }, child: Box(style: style)); + + final container = tester.widget(find.byType(Container)); + final decoration = container.decoration as BoxDecoration?; + expect(decoration?.color, Colors.red); + }); + + testWidgets('post-token animation and modifiers are preserved', ( + tester, + ) async { + const colorToken = ColorToken('test.animation.modifier.token'); + const animation = CurveAnimationConfig( + duration: Duration(milliseconds: 120), + curve: Curves.linear, + ); + + final style = BoxStyler() + .useToken(colorToken, BoxStyler().color) + .animate(animation) + .wrap(WidgetModifierConfig.opacity(0.5)); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MixScope( + tokens: {colorToken: Colors.green}, + child: Builder( + builder: (context) { + final built = style.build(context); + expect(built.animation, animation); + expect(built.widgetModifiers, isNotNull); + expect(built.widgetModifiers, isNotEmpty); + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + }); + + testWidgets('useToken chain composes with onHovered priority', ( + tester, + ) async { + const colorToken = ColorToken('test.hovered.token'); + + final style = BoxStyler() + .useToken(colorToken, BoxStyler().color) + .color(Colors.red) + .onHovered(BoxStyler().color(Colors.blue)); + + Future pumpWithStates(Set states) { + return tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MixScope( + tokens: {colorToken: Colors.green}, + child: WidgetStateProvider( + states: states, + child: Box(style: style), + ), + ), + ), + ); + } + + await pumpWithStates({WidgetState.hovered}); + + var container = tester.widget(find.byType(Container)); + var decoration = container.decoration as BoxDecoration?; + expect(decoration?.color, Colors.blue); + + await pumpWithStates({}); + + container = tester.widget(find.byType(Container)); + decoration = container.decoration as BoxDecoration?; + expect(decoration?.color, Colors.red); + }); + + testWidgets('useToken chain composes with onDark priority', (tester) async { + const colorToken = ColorToken('test.dark.token'); + + final style = BoxStyler() + .useToken(colorToken, BoxStyler().color) + .color(Colors.red) + .onDark(BoxStyler().color(Colors.black)); + + Future pumpWithBrightness(Brightness brightness) { + return tester.pumpWidget( + MediaQuery( + data: MediaQueryData(platformBrightness: brightness), + child: Directionality( + textDirection: TextDirection.ltr, + child: MixScope( + tokens: {colorToken: Colors.green}, + child: Box(style: style), + ), + ), + ), + ); + } + + await pumpWithBrightness(Brightness.dark); + + var container = tester.widget(find.byType(Container)); + var decoration = container.decoration as BoxDecoration?; + expect(decoration?.color, Colors.black); + + await pumpWithBrightness(Brightness.light); + + container = tester.widget(find.byType(Container)); + decoration = container.decoration as BoxDecoration?; + expect(decoration?.color, Colors.red); + }); + testWidgets( 'nested onBuilder output preserves post-token property precedence', (tester) async { From 70a460f8a13316e455c4cc5b09e42768bed654a6 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 26 Feb 2026 10:03:29 -0500 Subject: [PATCH 4/4] refactor(mix): simplify deferred merge and stabilize variant ordering Centralize deferred merge logic in Style.deferMerge() with DeferredVariant, add cycle detection via _visitedStyles, flatten double MixOps.mergeVariants nesting, and reduce over-specified 50-element insertion-order test to 7 elements. --- packages/mix/lib/src/core/spec_utility.dart | 16 ++ packages/mix/lib/src/core/style.dart | 182 +++++++++++------- .../mix/lib/src/specs/box/box_style.g.dart | 45 +++-- .../mix/lib/src/specs/flex/flex_style.g.dart | 45 +++-- .../src/specs/flexbox/flexbox_style.g.dart | 28 +-- .../mix/lib/src/specs/icon/icon_style.g.dart | 53 +++-- .../lib/src/specs/image/image_style.g.dart | 57 ++++-- .../lib/src/specs/stack/stack_style.g.dart | 35 ++-- .../src/specs/stackbox/stackbox_style.g.dart | 28 +-- .../mix/lib/src/specs/text/text_style.g.dart | 55 ++++-- packages/mix/lib/src/variants/variant.dart | 20 ++ packages/mix/test/helpers/testing_utils.dart | 13 ++ .../test/src/core/style_defer_merge_test.dart | 164 ++++++++++++++++ .../src/core/style_get_all_variants_test.dart | 74 ++----- .../src/core/style_nested_variants_test.dart | 17 ++ .../src/style/token_style_mixin_test.dart | 114 +++++++++++ .../test/src/variants/variant_mixin_test.dart | 12 ++ .../core/builders/styler_mixin_builder.dart | 57 ++++-- .../builders/styler_mixin_builder_test.dart | 49 ++++- 19 files changed, 837 insertions(+), 227 deletions(-) create mode 100644 packages/mix/test/src/core/style_defer_merge_test.dart diff --git a/packages/mix/lib/src/core/spec_utility.dart b/packages/mix/lib/src/core/spec_utility.dart index c5d8e5e69f..58360171fb 100644 --- a/packages/mix/lib/src/core/spec_utility.dart +++ b/packages/mix/lib/src/core/spec_utility.dart @@ -43,6 +43,14 @@ abstract class StyleAttributeBuilder> extends Style @override List>? get $variants => style.$variants; + @override + bool get hasBasePayload => style.hasBasePayload; + + @override + Style copyWithVariants(List>? variants) { + return style.copyWithVariants(variants); + } + @override List get props => [style]; } @@ -83,6 +91,14 @@ abstract class StyleMutableBuilder> extends Style @override List>? get $variants => value.$variants; + @override + bool get hasBasePayload => value.hasBasePayload; + + @override + Style copyWithVariants(List>? variants) { + return value.copyWithVariants(variants); + } + @override List get props => [mutable]; } diff --git a/packages/mix/lib/src/core/style.dart b/packages/mix/lib/src/core/style.dart index 9970c81668..4eadb7ecbe 100644 --- a/packages/mix/lib/src/core/style.dart +++ b/packages/mix/lib/src/core/style.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import '../animation/animation_config.dart'; +import 'helpers.dart'; import '../modifiers/widget_modifier_config.dart'; import '../variants/variant.dart'; import 'internal/compare_mixin.dart'; @@ -25,6 +26,7 @@ sealed class StyleElement { abstract class Style> extends Mix> implements StyleElement { static int _activeVariantMergeDepth = 0; + static final _visitedStyles = {}; @internal static bool get isResolvingActiveVariants => _activeVariantMergeDepth > 0; @@ -51,6 +53,38 @@ abstract class Style> extends Mix> $animation = animation, $variants = variants; + @internal + bool get hasBasePayload; + + /// Copies current style payload while replacing only variants. + @internal + Style copyWithVariants(List>? variants); + + @internal + Style? deferMerge(covariant Style? other) { + if (other == null) return null; + if (isResolvingActiveVariants) return null; + + final hasBuilders = + $variants?.any((v) => v.variant is ContextVariantBuilder) ?? false; + if (!hasBuilders) return null; + + List>? deferredVariants; + if (other.hasBasePayload) { + final payload = other.copyWithVariants(null); + deferredVariants = [ + VariantStyle(DeferredVariant(payload), payload), + ]; + } + + return copyWithVariants( + MixOps.mergeVariants( + [...?$variants, ...?deferredVariants], + other.$variants, + ), + ); + } + /// Gets the closest [Style] from the widget tree. /// /// Throws a [FlutterError] if no [Style] is found in the widget tree. @@ -100,80 +134,90 @@ abstract class Style> extends Mix> BuildContext context, { required Set namedVariants, }) { - // Filter variants that should be active in this context. - final activeVariants = ($variants ?? []) - .where( - (variantAttr) => switch (variantAttr.variant) { - (ContextVariant variant) => variant.when(context), - (NamedVariant variant) => namedVariants.contains(variant), - (ContextVariantBuilder _) => true, - }, - ) - .toList(); - - // Preserve insertion order while still applying WidgetStateVariant last. - final prioritizedVariants = >[]; - final widgetStateVariants = >[]; - for (final variantAttr in activeVariants) { - if (variantAttr.variant is WidgetStateVariant) { - widgetStateVariants.add(variantAttr); - } else { - prioritizedVariants.add(variantAttr); + final styleId = identityHashCode(this); + if (!_visitedStyles.add(styleId)) return this; + + try { + // Filter variants that should be active in this context. + final activeVariants = ($variants ?? []) + .where( + (variantAttr) => switch (variantAttr.variant) { + (ContextVariant variant) => variant.when(context), + (NamedVariant variant) => namedVariants.contains(variant), + (ContextVariantBuilder _) => true, + (DeferredVariant _) => true, + }, + ) + .toList(); + + // Preserve insertion order while still applying WidgetStateVariant last. + final prioritizedVariants = >[]; + final widgetStateVariants = >[]; + for (final variantAttr in activeVariants) { + if (variantAttr.variant is WidgetStateVariant) { + widgetStateVariants.add(variantAttr); + } else { + prioritizedVariants.add(variantAttr); + } } - } - prioritizedVariants.addAll(widgetStateVariants); - - // Extract the style from each active variant - final stylesToMerge = <(Style, bool)>[]; // (style, isFromStyleVariation) - - for (final variantAttr in prioritizedVariants) { - final result = switch (variantAttr.variant) { - ContextVariantBuilder variant => ( - variant.build(context) as Style, - false, - ), - (ContextVariant() || NamedVariant()) => () { - // Check if the value is a StyleVariation - // ignore: avoid-unrelated-type-assertions - if (variantAttr.value is StyleVariation) { - // ignore: avoid-unrelated-type-casts - final styleVariation = variantAttr.value as StyleVariation; - // Only apply if this variant is active - if (namedVariants.contains(styleVariation.variantType)) { - return ( - styleVariation.styleBuilder(this, namedVariants, context), - true, - ); + prioritizedVariants.addAll(widgetStateVariants); + + // Extract the style from each active variant + final stylesToMerge = + <(Style, bool)>[]; // (style, isFromStyleVariation) + + for (final variantAttr in prioritizedVariants) { + final result = switch (variantAttr.variant) { + ContextVariantBuilder variant => ( + variant.build(context) as Style, + false, + ), + DeferredVariant variant => (variant.payload as Style, false), + (ContextVariant() || NamedVariant()) => () { + // Check if the value is a StyleVariation + // ignore: avoid-unrelated-type-assertions + if (variantAttr.value is StyleVariation) { + // ignore: avoid-unrelated-type-casts + final styleVariation = variantAttr.value as StyleVariation; + // Only apply if this variant is active + if (namedVariants.contains(styleVariation.variantType)) { + return ( + styleVariation.styleBuilder(this, namedVariants, context), + true, + ); + } } - } - return (variantAttr.value, false); - }(), - }; - stylesToMerge.add(result); - } + return (variantAttr.value, false); + }(), + }; + stylesToMerge.add(result); + } - // Start with current style as base - Style mergedStyle = this; - - // Merge each variant style, recursively resolving nested variants - for (final (variantStyle, isFromStyleVariation) in stylesToMerge) { - final fullyResolvedStyle = isFromStyleVariation - // For StyleVariation results, we don't recursively resolve variants - // since StyleVariation.styleBuilder should handle its own variant logic - // and return a final style. This prevents infinite recursion. - ? variantStyle - // For regular variants, recursively resolve any nested variants - : variantStyle.mergeActiveVariants( - context, - namedVariants: namedVariants, - ); - mergedStyle = _withActiveVariantMergeGuard( - () => mergedStyle.merge(fullyResolvedStyle), - ); - } + // Start with current style as base + Style mergedStyle = this; + + // Merge each variant style, recursively resolving nested variants + for (final (variantStyle, isFromStyleVariation) in stylesToMerge) { + final fullyResolvedStyle = isFromStyleVariation + // For StyleVariation results, we don't recursively resolve variants + // since StyleVariation.styleBuilder should handle its own variant logic + // and return a final style. This prevents infinite recursion. + ? variantStyle + // For regular variants, recursively resolve any nested variants + : variantStyle.mergeActiveVariants( + context, + namedVariants: namedVariants, + ); + mergedStyle = _withActiveVariantMergeGuard( + () => mergedStyle.merge(fullyResolvedStyle), + ); + } - return mergedStyle; + return mergedStyle; + } finally { + _visitedStyles.remove(styleId); + } } /// Resolves this attribute to its concrete value using the provided [BuildContext]. diff --git a/packages/mix/lib/src/specs/box/box_style.g.dart b/packages/mix/lib/src/specs/box/box_style.g.dart index 4cf54d5ee6..f668f22595 100644 --- a/packages/mix/lib/src/specs/box/box_style.g.dart +++ b/packages/mix/lib/src/specs/box/box_style.g.dart @@ -67,20 +67,43 @@ mixin _$BoxStylerMixin on Style, Diagnosticable { return merge(BoxStyler(modifier: value)); } + @override + bool get hasBasePayload => + $alignment != null || + $clipBehavior != null || + $constraints != null || + $decoration != null || + $foregroundDecoration != null || + $margin != null || + $padding != null || + $transform != null || + $transformAlignment != null || + $modifier != null || + $animation != null; + + @override + BoxStyler copyWithVariants(List>? variants) { + return BoxStyler.create( + alignment: $alignment, + clipBehavior: $clipBehavior, + constraints: $constraints, + decoration: $decoration, + foregroundDecoration: $foregroundDecoration, + margin: $margin, + padding: $padding, + transform: $transform, + transformAlignment: $transformAlignment, + variants: variants, + modifier: $modifier, + animation: $animation, + ); + } + /// Merges with another [BoxStyler]. @override BoxStyler merge(BoxStyler? other) { - final hasContextVariantBuilders = - $variants?.any((v) => v.variant is ContextVariantBuilder) ?? false; - if (other != null && - !Style.isResolvingActiveVariants && - hasContextVariantBuilders && - other.$variants == null) { - final builder = ContextVariantBuilder((_) => other); - return merge( - BoxStyler(variants: [VariantStyle(builder, other)]), - ); - } + final deferred = deferMerge(other); + if (deferred != null) return deferred as BoxStyler; return BoxStyler.create( alignment: MixOps.merge($alignment, other?.$alignment), diff --git a/packages/mix/lib/src/specs/flex/flex_style.g.dart b/packages/mix/lib/src/specs/flex/flex_style.g.dart index 8e5d801eb4..f5a77c0796 100644 --- a/packages/mix/lib/src/specs/flex/flex_style.g.dart +++ b/packages/mix/lib/src/specs/flex/flex_style.g.dart @@ -77,20 +77,43 @@ mixin _$FlexStylerMixin on Style, Diagnosticable { return merge(FlexStyler(modifier: value)); } + @override + bool get hasBasePayload => + $clipBehavior != null || + $crossAxisAlignment != null || + $direction != null || + $mainAxisAlignment != null || + $mainAxisSize != null || + $spacing != null || + $textBaseline != null || + $textDirection != null || + $verticalDirection != null || + $modifier != null || + $animation != null; + + @override + FlexStyler copyWithVariants(List>? variants) { + return FlexStyler.create( + clipBehavior: $clipBehavior, + crossAxisAlignment: $crossAxisAlignment, + direction: $direction, + mainAxisAlignment: $mainAxisAlignment, + mainAxisSize: $mainAxisSize, + spacing: $spacing, + textBaseline: $textBaseline, + textDirection: $textDirection, + verticalDirection: $verticalDirection, + variants: variants, + modifier: $modifier, + animation: $animation, + ); + } + /// Merges with another [FlexStyler]. @override FlexStyler merge(FlexStyler? other) { - final hasContextVariantBuilders = - $variants?.any((v) => v.variant is ContextVariantBuilder) ?? false; - if (other != null && - !Style.isResolvingActiveVariants && - hasContextVariantBuilders && - other.$variants == null) { - final builder = ContextVariantBuilder((_) => other); - return merge( - FlexStyler(variants: [VariantStyle(builder, other)]), - ); - } + final deferred = deferMerge(other); + if (deferred != null) return deferred as FlexStyler; return FlexStyler.create( clipBehavior: MixOps.merge($clipBehavior, other?.$clipBehavior), diff --git a/packages/mix/lib/src/specs/flexbox/flexbox_style.g.dart b/packages/mix/lib/src/specs/flexbox/flexbox_style.g.dart index 2bb7ce47b4..65cd3d1bd8 100644 --- a/packages/mix/lib/src/specs/flexbox/flexbox_style.g.dart +++ b/packages/mix/lib/src/specs/flexbox/flexbox_style.g.dart @@ -10,20 +10,26 @@ mixin _$FlexBoxStylerMixin on Style, Diagnosticable { Prop>? get $box; Prop>? get $flex; + @override + bool get hasBasePayload => + $box != null || $flex != null || $modifier != null || $animation != null; + + @override + FlexBoxStyler copyWithVariants(List>? variants) { + return FlexBoxStyler.create( + box: $box, + flex: $flex, + variants: variants, + modifier: $modifier, + animation: $animation, + ); + } + /// Merges with another [FlexBoxStyler]. @override FlexBoxStyler merge(FlexBoxStyler? other) { - final hasContextVariantBuilders = - $variants?.any((v) => v.variant is ContextVariantBuilder) ?? false; - if (other != null && - !Style.isResolvingActiveVariants && - hasContextVariantBuilders && - other.$variants == null) { - final builder = ContextVariantBuilder((_) => other); - return merge( - FlexBoxStyler(variants: [VariantStyle(builder, other)]), - ); - } + final deferred = deferMerge(other); + if (deferred != null) return deferred as FlexBoxStyler; return FlexBoxStyler.create( box: MixOps.merge($box, other?.$box), diff --git a/packages/mix/lib/src/specs/icon/icon_style.g.dart b/packages/mix/lib/src/specs/icon/icon_style.g.dart index 26204514e1..9ede0400e0 100644 --- a/packages/mix/lib/src/specs/icon/icon_style.g.dart +++ b/packages/mix/lib/src/specs/icon/icon_style.g.dart @@ -101,20 +101,51 @@ mixin _$IconStylerMixin on Style, Diagnosticable { return merge(IconStyler(modifier: value)); } + @override + bool get hasBasePayload => + $applyTextScaling != null || + $blendMode != null || + $color != null || + $fill != null || + $grade != null || + $icon != null || + $opacity != null || + $opticalSize != null || + $semanticsLabel != null || + $shadows != null || + $size != null || + $textDirection != null || + $weight != null || + $modifier != null || + $animation != null; + + @override + IconStyler copyWithVariants(List>? variants) { + return IconStyler.create( + applyTextScaling: $applyTextScaling, + blendMode: $blendMode, + color: $color, + fill: $fill, + grade: $grade, + icon: $icon, + opacity: $opacity, + opticalSize: $opticalSize, + semanticsLabel: $semanticsLabel, + shadows: $shadows, + size: $size, + textDirection: $textDirection, + weight: $weight, + variants: variants, + modifier: $modifier, + animation: $animation, + ); + } + /// Merges with another [IconStyler]. @override IconStyler merge(IconStyler? other) { - final hasContextVariantBuilders = - $variants?.any((v) => v.variant is ContextVariantBuilder) ?? false; - if (other != null && - !Style.isResolvingActiveVariants && - hasContextVariantBuilders && - other.$variants == null) { - final builder = ContextVariantBuilder((_) => other); - return merge( - IconStyler(variants: [VariantStyle(builder, other)]), - ); - } + final deferred = deferMerge(other); + if (deferred != null) return deferred as IconStyler; return IconStyler.create( applyTextScaling: MixOps.merge( diff --git a/packages/mix/lib/src/specs/image/image_style.g.dart b/packages/mix/lib/src/specs/image/image_style.g.dart index cf77e36056..c7b2900d76 100644 --- a/packages/mix/lib/src/specs/image/image_style.g.dart +++ b/packages/mix/lib/src/specs/image/image_style.g.dart @@ -113,20 +113,55 @@ mixin _$ImageStylerMixin on Style, Diagnosticable { return merge(ImageStyler(modifier: value)); } + @override + bool get hasBasePayload => + $alignment != null || + $centerSlice != null || + $color != null || + $colorBlendMode != null || + $excludeFromSemantics != null || + $filterQuality != null || + $fit != null || + $gaplessPlayback != null || + $height != null || + $image != null || + $isAntiAlias != null || + $matchTextDirection != null || + $repeat != null || + $semanticLabel != null || + $width != null || + $modifier != null || + $animation != null; + + @override + ImageStyler copyWithVariants(List>? variants) { + return ImageStyler.create( + alignment: $alignment, + centerSlice: $centerSlice, + color: $color, + colorBlendMode: $colorBlendMode, + excludeFromSemantics: $excludeFromSemantics, + filterQuality: $filterQuality, + fit: $fit, + gaplessPlayback: $gaplessPlayback, + height: $height, + image: $image, + isAntiAlias: $isAntiAlias, + matchTextDirection: $matchTextDirection, + repeat: $repeat, + semanticLabel: $semanticLabel, + width: $width, + variants: variants, + modifier: $modifier, + animation: $animation, + ); + } + /// Merges with another [ImageStyler]. @override ImageStyler merge(ImageStyler? other) { - final hasContextVariantBuilders = - $variants?.any((v) => v.variant is ContextVariantBuilder) ?? false; - if (other != null && - !Style.isResolvingActiveVariants && - hasContextVariantBuilders && - other.$variants == null) { - final builder = ContextVariantBuilder((_) => other); - return merge( - ImageStyler(variants: [VariantStyle(builder, other)]), - ); - } + final deferred = deferMerge(other); + if (deferred != null) return deferred as ImageStyler; return ImageStyler.create( alignment: MixOps.merge($alignment, other?.$alignment), diff --git a/packages/mix/lib/src/specs/stack/stack_style.g.dart b/packages/mix/lib/src/specs/stack/stack_style.g.dart index 08febb539d..523189f7b5 100644 --- a/packages/mix/lib/src/specs/stack/stack_style.g.dart +++ b/packages/mix/lib/src/specs/stack/stack_style.g.dart @@ -47,20 +47,33 @@ mixin _$StackStylerMixin on Style, Diagnosticable { return merge(StackStyler(modifier: value)); } + @override + bool get hasBasePayload => + $alignment != null || + $clipBehavior != null || + $fit != null || + $textDirection != null || + $modifier != null || + $animation != null; + + @override + StackStyler copyWithVariants(List>? variants) { + return StackStyler.create( + alignment: $alignment, + clipBehavior: $clipBehavior, + fit: $fit, + textDirection: $textDirection, + variants: variants, + modifier: $modifier, + animation: $animation, + ); + } + /// Merges with another [StackStyler]. @override StackStyler merge(StackStyler? other) { - final hasContextVariantBuilders = - $variants?.any((v) => v.variant is ContextVariantBuilder) ?? false; - if (other != null && - !Style.isResolvingActiveVariants && - hasContextVariantBuilders && - other.$variants == null) { - final builder = ContextVariantBuilder((_) => other); - return merge( - StackStyler(variants: [VariantStyle(builder, other)]), - ); - } + final deferred = deferMerge(other); + if (deferred != null) return deferred as StackStyler; return StackStyler.create( alignment: MixOps.merge($alignment, other?.$alignment), diff --git a/packages/mix/lib/src/specs/stackbox/stackbox_style.g.dart b/packages/mix/lib/src/specs/stackbox/stackbox_style.g.dart index 7f2aaeb992..5bc99512a9 100644 --- a/packages/mix/lib/src/specs/stackbox/stackbox_style.g.dart +++ b/packages/mix/lib/src/specs/stackbox/stackbox_style.g.dart @@ -10,20 +10,26 @@ mixin _$StackBoxStylerMixin on Style, Diagnosticable { Prop>? get $box; Prop>? get $stack; + @override + bool get hasBasePayload => + $box != null || $stack != null || $modifier != null || $animation != null; + + @override + StackBoxStyler copyWithVariants(List>? variants) { + return StackBoxStyler.create( + box: $box, + stack: $stack, + variants: variants, + modifier: $modifier, + animation: $animation, + ); + } + /// Merges with another [StackBoxStyler]. @override StackBoxStyler merge(StackBoxStyler? other) { - final hasContextVariantBuilders = - $variants?.any((v) => v.variant is ContextVariantBuilder) ?? false; - if (other != null && - !Style.isResolvingActiveVariants && - hasContextVariantBuilders && - other.$variants == null) { - final builder = ContextVariantBuilder((_) => other); - return merge( - StackBoxStyler(variants: [VariantStyle(builder, other)]), - ); - } + final deferred = deferMerge(other); + if (deferred != null) return deferred as StackBoxStyler; return StackBoxStyler.create( box: MixOps.merge($box, other?.$box), diff --git a/packages/mix/lib/src/specs/text/text_style.g.dart b/packages/mix/lib/src/specs/text/text_style.g.dart index 31cf2680aa..2bc0d556ab 100644 --- a/packages/mix/lib/src/specs/text/text_style.g.dart +++ b/packages/mix/lib/src/specs/text/text_style.g.dart @@ -102,20 +102,53 @@ mixin _$TextStylerMixin on Style, Diagnosticable { return merge(TextStyler(modifier: value)); } + @override + bool get hasBasePayload => + $locale != null || + $maxLines != null || + $overflow != null || + $selectionColor != null || + $semanticsLabel != null || + $softWrap != null || + $strutStyle != null || + $style != null || + $textAlign != null || + $textDirection != null || + $textDirectives != null || + $textHeightBehavior != null || + $textScaler != null || + $textWidthBasis != null || + $modifier != null || + $animation != null; + + @override + TextStyler copyWithVariants(List>? variants) { + return TextStyler.create( + locale: $locale, + maxLines: $maxLines, + overflow: $overflow, + selectionColor: $selectionColor, + semanticsLabel: $semanticsLabel, + softWrap: $softWrap, + strutStyle: $strutStyle, + style: $style, + textAlign: $textAlign, + textDirection: $textDirection, + textDirectives: $textDirectives, + textHeightBehavior: $textHeightBehavior, + textScaler: $textScaler, + textWidthBasis: $textWidthBasis, + variants: variants, + modifier: $modifier, + animation: $animation, + ); + } + /// Merges with another [TextStyler]. @override TextStyler merge(TextStyler? other) { - final hasContextVariantBuilders = - $variants?.any((v) => v.variant is ContextVariantBuilder) ?? false; - if (other != null && - !Style.isResolvingActiveVariants && - hasContextVariantBuilders && - other.$variants == null) { - final builder = ContextVariantBuilder((_) => other); - return merge( - TextStyler(variants: [VariantStyle(builder, other)]), - ); - } + final deferred = deferMerge(other); + if (deferred != null) return deferred as TextStyler; return TextStyler.create( locale: MixOps.merge($locale, other?.$locale), diff --git a/packages/mix/lib/src/variants/variant.dart b/packages/mix/lib/src/variants/variant.dart index 15addefac0..7bf0a8b1be 100644 --- a/packages/mix/lib/src/variants/variant.dart +++ b/packages/mix/lib/src/variants/variant.dart @@ -174,6 +174,26 @@ class ContextVariantBuilder> extends Variant { S build(BuildContext context) => fn(context); } +/// Internal variant carrying a deferred base-merge payload. +/// +/// Uses identity-based keys to prevent merge-key collisions when multiple +/// deferred merges are queued. +@internal +final class DeferredVariant> extends Variant { + final Style payload; + + const DeferredVariant(this.payload); + + @override + String get key => 'deferred_${identityHashCode(this)}'; + + @override + bool operator ==(Object other) => identical(this, other); + + @override + int get hashCode => identityHashCode(this); +} + // Helper functions for cleaner variant checking bool hasVariant(List activeVariants, NamedVariant variant) => activeVariants.contains(variant); diff --git a/packages/mix/test/helpers/testing_utils.dart b/packages/mix/test/helpers/testing_utils.dart index 64001885fb..fe81af2957 100644 --- a/packages/mix/test/helpers/testing_utils.dart +++ b/packages/mix/test/helpers/testing_utils.dart @@ -267,6 +267,19 @@ class MockStyle extends Style> { super.animation, }); + @override + bool get hasBasePayload => true; + + @override + MockStyle copyWithVariants(List>>? variants) { + return MockStyle( + value, + variants: variants, + modifier: $modifier, + animation: $animation, + ); + } + @override MockStyle merge(covariant MockStyle? other) { if (other == null) return this; diff --git a/packages/mix/test/src/core/style_defer_merge_test.dart b/packages/mix/test/src/core/style_defer_merge_test.dart new file mode 100644 index 0000000000..1013a73b9e --- /dev/null +++ b/packages/mix/test/src/core/style_defer_merge_test.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mix/mix.dart'; + +import '../../helpers/testing_utils.dart'; + +void main() { + group('Style.deferMerge', () { + test('returns null when no ContextVariantBuilder is present', () { + final style = BoxStyler().color(Colors.red); + final other = BoxStyler().paddingAll(8); + + expect(style.deferMerge(other), isNull); + }); + + test('returns null during active variant resolution', () { + final deferredResults = >>?>[]; + + final style = _ProbeStyle( + variants: [ + VariantStyle>>( + ContextVariantBuilder<_ProbeStyle>((_) => _ProbeStyle(width: 10)), + _ProbeStyle(), + ), + ], + onDeferChecked: deferredResults.add, + ); + + style.mergeActiveVariants(MockBuildContext(), namedVariants: const {}); + + expect(deferredResults, hasLength(1)); + expect(deferredResults.single, isNull); + }); + + test('deferred variants use identity-based keys', () { + final payload = BoxStyler().color(Colors.red); + final first = DeferredVariant(payload); + final second = DeferredVariant(payload); + + expect(first.key, isNot(second.key)); + expect(first, isNot(equals(second))); + expect(first.hashCode, isNot(second.hashCode)); + }); + + test('cycle detection prevents recursive variant overflow', () { + late BoxStyler recursiveStyle; + recursiveStyle = BoxStyler( + variants: [ + VariantStyle( + ContextVariantBuilder((_) => recursiveStyle), + BoxStyler(), + ), + ], + ); + + expect( + () => recursiveStyle.mergeActiveVariants( + MockBuildContext(), + namedVariants: const {}, + ), + returnsNormally, + ); + }); + + testWidgets( + 'multiple sequential merges after useToken keep deferred variants and last overlap wins', + (tester) async { + const colorToken = ColorToken('test.defer.merge.sequence'); + + final style = BoxStyler() + .useToken(colorToken, BoxStyler().color) + .merge(BoxStyler().paddingAll(8).color(Colors.red)) + .merge(BoxStyler().marginAll(4).color(Colors.blue)); + + final deferredCount = + style.$variants + ?.where((v) => v.variant is DeferredVariant) + .length ?? + 0; + expect(deferredCount, 2); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MixScope( + tokens: {colorToken: Colors.green}, + child: Builder( + builder: (context) { + final built = style.build(context); + final spec = built.spec; + final color = (spec.decoration as BoxDecoration?)?.color; + + expect(color, Colors.blue); + expect(spec.padding, const EdgeInsets.all(8)); + expect(spec.margin, const EdgeInsets.all(4)); + + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + }, + ); + }); +} + +class _ProbeStyle extends Style>> { + final double? width; + final void Function(Style>>? deferred)? + onDeferChecked; + + const _ProbeStyle({ + this.width, + this.onDeferChecked, + super.variants = const [], + super.modifier, + super.animation, + }); + + @override + bool get hasBasePayload => + width != null || $modifier != null || $animation != null; + + @override + _ProbeStyle copyWithVariants( + List>>>? variants, + ) { + return _ProbeStyle( + width: width, + onDeferChecked: onDeferChecked, + variants: variants, + modifier: $modifier, + animation: $animation, + ); + } + + @override + _ProbeStyle merge(covariant _ProbeStyle? other) { + final deferred = deferMerge(other); + onDeferChecked?.call(deferred); + if (deferred != null) return deferred as _ProbeStyle; + + return _ProbeStyle( + width: other?.width ?? width, + variants: MixOps.mergeVariants($variants, other?.$variants), + modifier: MixOps.mergeModifier($modifier, other?.$modifier), + animation: MixOps.mergeAnimation($animation, other?.$animation), + onDeferChecked: onDeferChecked, + ); + } + + @override + StyleSpec>> resolve(BuildContext context) { + return StyleSpec( + spec: MockSpec>(resolvedValue: {'width': width}), + animation: $animation, + widgetModifiers: $modifier?.resolve(context), + ); + } + + @override + List get props => [width, $variants, $modifier, $animation]; +} diff --git a/packages/mix/test/src/core/style_get_all_variants_test.dart b/packages/mix/test/src/core/style_get_all_variants_test.dart index cb1c09057e..f425eb05a9 100644 --- a/packages/mix/test/src/core/style_get_all_variants_test.dart +++ b/packages/mix/test/src/core/style_get_all_variants_test.dart @@ -600,60 +600,8 @@ void main() { }); test('non-WidgetState variants preserve insertion order', () { - // This specific order is intentionally non-sequential and large enough - // to catch unstable ordering if equal-priority variants are re-sorted. - const order = [ - 48, - 42, - 46, - 5, - 20, - 24, - 36, - 28, - 23, - 32, - 13, - 0, - 30, - 16, - 31, - 10, - 11, - 25, - 9, - 1, - 41, - 33, - 26, - 2, - 29, - 6, - 35, - 39, - 18, - 22, - 14, - 37, - 19, - 40, - 38, - 44, - 49, - 15, - 43, - 34, - 3, - 12, - 7, - 8, - 45, - 27, - 21, - 47, - 17, - 4, - ]; + // Non-sequential order to catch unstable sorting of equal-priority variants. + const order = [5, 3, 7, 1, 6, 2, 4]; final variants = order .map( @@ -675,6 +623,7 @@ void main() { namedVariants: {}, ); + // Last element in insertion order (4) wins via sequential merge. final spec = result.resolve(context); expect((spec.resolvedValue as Map)['width'], 4.0); }); @@ -780,6 +729,23 @@ class _MockSpecAttribute extends Style>> { super.animation, }); + @override + bool get hasBasePayload => + width != 0.0 || height != null || $modifier != null || $animation != null; + + @override + _MockSpecAttribute copyWithVariants( + List>>>? variants, + ) { + return _MockSpecAttribute( + width: width, + height: height, + variants: variants, + modifier: $modifier, + animation: $animation, + ); + } + @override StyleSpec>> resolve(BuildContext context) { return StyleSpec( diff --git a/packages/mix/test/src/core/style_nested_variants_test.dart b/packages/mix/test/src/core/style_nested_variants_test.dart index 824bdfdab4..a6a7b82611 100644 --- a/packages/mix/test/src/core/style_nested_variants_test.dart +++ b/packages/mix/test/src/core/style_nested_variants_test.dart @@ -308,6 +308,23 @@ class _MockSpecAttribute extends Style>> { super.animation, }); + @override + bool get hasBasePayload => + width != 0.0 || height != null || $modifier != null || $animation != null; + + @override + _MockSpecAttribute copyWithVariants( + List>>>? variants, + ) { + return _MockSpecAttribute( + width: width, + height: height, + variants: variants, + modifier: $modifier, + animation: $animation, + ); + } + @override StyleSpec>> resolve(BuildContext context) { return StyleSpec( diff --git a/packages/mix/test/src/style/token_style_mixin_test.dart b/packages/mix/test/src/style/token_style_mixin_test.dart index a576840a0f..fdbc8a6e96 100644 --- a/packages/mix/test/src/style/token_style_mixin_test.dart +++ b/packages/mix/test/src/style/token_style_mixin_test.dart @@ -117,6 +117,120 @@ void main() { expect(decoration?.color, Colors.red); }); + testWidgets( + 'explicit merge with base and onDark preserves light order and dark override', + (tester) async { + const colorToken = ColorToken('test.explicit.merge.dark.token'); + final merged = BoxStyler() + .color(Colors.red) + .onDark(BoxStyler().color(Colors.black)); + final style = BoxStyler() + .useToken(colorToken, BoxStyler().color) + .merge(merged); + + Future pumpWithBrightness(Brightness brightness) { + return tester.pumpWidget( + MediaQuery( + data: MediaQueryData(platformBrightness: brightness), + child: Directionality( + textDirection: TextDirection.ltr, + child: MixScope( + tokens: {colorToken: Colors.green}, + child: Box(style: style), + ), + ), + ), + ); + } + + await pumpWithBrightness(Brightness.light); + var container = tester.widget(find.byType(Container)); + var decoration = container.decoration as BoxDecoration?; + expect(decoration?.color, Colors.red); + + await pumpWithBrightness(Brightness.dark); + container = tester.widget(find.byType(Container)); + decoration = container.decoration as BoxDecoration?; + expect(decoration?.color, Colors.black); + }, + ); + + testWidgets( + 'explicit merge with base and onHovered preserves normal order and hovered override', + (tester) async { + const colorToken = ColorToken('test.explicit.merge.hovered.token'); + final merged = BoxStyler() + .color(Colors.red) + .onHovered(BoxStyler().color(Colors.blue)); + final style = BoxStyler() + .useToken(colorToken, BoxStyler().color) + .merge(merged); + + Future pumpWithStates(Set states) { + return tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: MixScope( + tokens: {colorToken: Colors.green}, + child: WidgetStateProvider( + states: states, + child: Box(style: style), + ), + ), + ), + ); + } + + await pumpWithStates({}); + var container = tester.widget(find.byType(Container)); + var decoration = container.decoration as BoxDecoration?; + expect(decoration?.color, Colors.red); + + await pumpWithStates({WidgetState.hovered}); + container = tester.widget(find.byType(Container)); + decoration = container.decoration as BoxDecoration?; + expect(decoration?.color, Colors.blue); + }, + ); + + testWidgets( + 'explicit merge with variants-only style keeps token fallback', + (tester) async { + const colorToken = ColorToken( + 'test.explicit.merge.variants.only.token', + ); + final merged = BoxStyler().onDark(BoxStyler().color(Colors.black)); + final style = BoxStyler() + .useToken(colorToken, BoxStyler().color) + .merge(merged); + + Future pumpWithBrightness(Brightness brightness) { + return tester.pumpWidget( + MediaQuery( + data: MediaQueryData(platformBrightness: brightness), + child: Directionality( + textDirection: TextDirection.ltr, + child: MixScope( + tokens: {colorToken: Colors.green}, + child: Box(style: style), + ), + ), + ), + ); + } + + await pumpWithBrightness(Brightness.light); + var container = tester.widget(find.byType(Container)); + var decoration = container.decoration as BoxDecoration?; + expect(decoration?.color, Colors.green); + + await pumpWithBrightness(Brightness.dark); + container = tester.widget(find.byType(Container)); + decoration = container.decoration as BoxDecoration?; + expect(decoration?.color, Colors.black); + }, + ); + testWidgets('post-token animation and modifiers are preserved', ( tester, ) async { diff --git a/packages/mix/test/src/variants/variant_mixin_test.dart b/packages/mix/test/src/variants/variant_mixin_test.dart index c08191b196..52c1166604 100644 --- a/packages/mix/test/src/variants/variant_mixin_test.dart +++ b/packages/mix/test/src/variants/variant_mixin_test.dart @@ -11,6 +11,18 @@ class TestVariantAttribute extends Style WidgetStateVariantMixin { const TestVariantAttribute({super.variants, super.modifier, super.animation}); + @override + bool get hasBasePayload => $modifier != null || $animation != null; + + @override + TestVariantAttribute copyWithVariants(List>? variants) { + return TestVariantAttribute( + variants: variants, + modifier: $modifier, + animation: $animation, + ); + } + @override TestVariantAttribute variant(Variant variant, TestVariantAttribute style) { return TestVariantAttribute( diff --git a/packages/mix_generator/lib/src/core/builders/styler_mixin_builder.dart b/packages/mix_generator/lib/src/core/builders/styler_mixin_builder.dart index 0574aaa3cb..9ac5021853 100644 --- a/packages/mix_generator/lib/src/core/builders/styler_mixin_builder.dart +++ b/packages/mix_generator/lib/src/core/builders/styler_mixin_builder.dart @@ -86,6 +86,43 @@ class StylerMixinBuilder { return buffer.toString(); } + String _buildDeferredMergeHooks() { + final buffer = StringBuffer(); + + final deferredChecks = [ + ...fields.map((field) => '${field.declaredName} != null'), + '\$modifier != null', + '\$animation != null', + ]; + + buffer.writeln(' @override'); + buffer.writeln(' bool get hasBasePayload =>'); + for (int i = 0; i < deferredChecks.length; i++) { + final isLast = i == deferredChecks.length - 1; + final suffix = isLast ? ';' : ' ||'; + buffer.writeln(' ${deferredChecks[i]}$suffix'); + } + buffer.writeln(); + + buffer.writeln(' @override'); + buffer.writeln( + ' $stylerName copyWithVariants(List>? variants) {', + ); + buffer.writeln(' return $stylerName.create('); + for (final field in fields) { + final fieldName = field.declaredName; + final name = field.name; + buffer.writeln(' $name: $fieldName,'); + } + buffer.writeln(' variants: variants,'); + buffer.writeln(' modifier: \$modifier,'); + buffer.writeln(' animation: \$animation,'); + buffer.writeln(' );'); + buffer.writeln(' }'); + + return buffer.toString(); + } + String _buildMerge() { if (!config.generateMerge) return ''; @@ -94,21 +131,8 @@ class StylerMixinBuilder { buffer.writeln(' /// Merges with another [$stylerName].'); buffer.writeln(' @override'); buffer.writeln(' $stylerName merge($stylerName? other) {'); - buffer.writeln(' final hasContextVariantBuilders ='); - buffer.writeln( - ' \$variants?.any((v) => v.variant is ContextVariantBuilder) ?? false;', - ); - buffer.writeln(' if (other != null &&'); - buffer.writeln(' !Style.isResolvingActiveVariants &&'); - buffer.writeln(' hasContextVariantBuilders &&'); - buffer.writeln(' other.\$variants == null) {'); - buffer.writeln(' final builder = ContextVariantBuilder<$stylerName>('); - buffer.writeln(' (_) => other,'); - buffer.writeln(' );'); - buffer.writeln( - ' return merge($stylerName(variants: [VariantStyle<$specName>(builder, other)]));', - ); - buffer.writeln(' }'); + buffer.writeln(' final deferred = deferMerge(other);'); + buffer.writeln(' if (deferred != null) return deferred as $stylerName;'); buffer.writeln(); buffer.writeln(' return $stylerName.create('); @@ -265,6 +289,9 @@ class StylerMixinBuilder { buffer.writeln(_buildBaseMethods()); } + // Generate deferred-merge hooks + buffer.writeln(_buildDeferredMergeHooks()); + // Generate merge if (config.generateMerge) { buffer.writeln(_buildMerge()); diff --git a/packages/mix_generator/test/core/builders/styler_mixin_builder_test.dart b/packages/mix_generator/test/core/builders/styler_mixin_builder_test.dart index 06b64d510e..38d83b4437 100644 --- a/packages/mix_generator/test/core/builders/styler_mixin_builder_test.dart +++ b/packages/mix_generator/test/core/builders/styler_mixin_builder_test.dart @@ -57,6 +57,11 @@ void main() { expect(code, contains('@override')); expect(code, contains('BoxStyler merge(BoxStyler? other)')); + expect(code, contains('final deferred = deferMerge(other);')); + expect( + code, + contains('if (deferred != null) return deferred as BoxStyler;'), + ); expect(code, contains('return BoxStyler.create(')); }); @@ -154,6 +159,40 @@ void main() { }); }); + group('deferred merge hooks', () { + test('generates hasBasePayload with modifier and animation checks', () { + final builder = StylerMixinBuilder( + stylerName: 'BoxStyler', + specName: 'BoxSpec', + fields: [], + config: defaultConfig, + ); + final code = builder.build(); + + expect(code, contains('bool get hasBasePayload =>')); + expect(code, contains('\$modifier != null ||')); + expect(code, contains('\$animation != null;')); + }); + + test('generates copyWithVariants', () { + final builder = StylerMixinBuilder( + stylerName: 'BoxStyler', + specName: 'BoxSpec', + fields: [], + config: defaultConfig, + ); + final code = builder.build(); + + expect( + code, + contains( + 'BoxStyler copyWithVariants(List>? variants)', + ), + ); + expect(code, contains('variants: variants,')); + }); + }); + group('base fields in props', () { test('includes animation, modifier, variants in props', () { final builder = StylerMixinBuilder( @@ -262,6 +301,7 @@ void main() { expect(code, isNot(contains('BoxStyler merge('))); // Other methods should still be generated + expect(code, contains('bool get hasBasePayload =>')); expect(code, contains('StyleSpec resolve(')); expect(code, contains('void debugFillProperties(')); expect(code, contains('List get props =>')); @@ -332,11 +372,18 @@ void main() { ); final code = builder.build(); - // Only mixin declaration and closing brace + // Required deferred-merge hooks are still generated. expect( code, contains('mixin _\$BoxStylerMixin on Style, Diagnosticable'), ); + expect(code, contains('bool get hasBasePayload =>')); + expect( + code, + contains( + 'BoxStyler copyWithVariants(List>? variants)', + ), + ); expect(code, isNot(contains('BoxStyler merge('))); expect(code, isNot(contains('StyleSpec resolve('))); expect(code, isNot(contains('void debugFillProperties(')));