From d51c217dff6110ccce6e5c9204a5e05680652f41 Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 13 Jul 2026 01:47:50 -0400 Subject: [PATCH] refactor(mix): reduce style pipeline overhead --- .../animation/style_animation_builder.dart | 27 +-- .../core/providers/widget_state_provider.dart | 8 + packages/mix/lib/src/core/style.dart | 25 ++- packages/mix/lib/src/core/style_builder.dart | 81 +++------ .../mix/test/src/core/style_builder_test.dart | 165 +++++++++++++++--- 5 files changed, 211 insertions(+), 95 deletions(-) diff --git a/packages/mix/lib/src/animation/style_animation_builder.dart b/packages/mix/lib/src/animation/style_animation_builder.dart index 5a833d5ccf..30d9dc4f05 100644 --- a/packages/mix/lib/src/animation/style_animation_builder.dart +++ b/packages/mix/lib/src/animation/style_animation_builder.dart @@ -32,17 +32,20 @@ class StyleAnimationBuilder> extends StatefulWidget { class _StyleAnimationBuilderState> extends State> with TickerProviderStateMixin { - late StyleAnimationDriver animationDriver; + StyleAnimationDriver? _animationDriver; @override void initState() { super.initState(); final spec = widget.spec; final config = spec.animation; - animationDriver = _createAnimationDriver(config: config, initialSpec: spec); + _animationDriver = _createAnimationDriver( + config: config, + initialSpec: spec, + ); } - StyleAnimationDriver _createAnimationDriver({ + StyleAnimationDriver? _createAnimationDriver({ required AnimationConfig? config, required StyleSpec initialSpec, }) { @@ -73,14 +76,13 @@ class _StyleAnimationBuilderState> initialSpec: initialSpec, context: context, ), - // ignore: avoid-undisposed-instances - null => NoAnimationDriver(vsync: this, initialSpec: initialSpec), + null => null, }; } @override void dispose() { - animationDriver.dispose(); + _animationDriver?.dispose(); super.dispose(); } @@ -92,22 +94,27 @@ class _StyleAnimationBuilderState> final oldConfig = oldWidget.spec.animation; if ((oldConfig.runtimeType == config.runtimeType) && config != null) { - animationDriver.updateDriver(config); + _animationDriver!.updateDriver(config); } else { - animationDriver.dispose(); - animationDriver = _createAnimationDriver( + _animationDriver?.dispose(); + _animationDriver = _createAnimationDriver( config: config ?? oldConfig, initialSpec: oldWidget.spec, ); } if (oldWidget.spec != widget.spec) { - animationDriver.didUpdateSpec(oldWidget.spec, widget.spec); + _animationDriver?.didUpdateSpec(oldWidget.spec, widget.spec); } } @override Widget build(BuildContext context) { + final animationDriver = _animationDriver; + if (animationDriver == null) { + return widget.builder(context, widget.spec); + } + return AnimatedBuilder( animation: animationDriver.animation, builder: (context, child) { diff --git a/packages/mix/lib/src/core/providers/widget_state_provider.dart b/packages/mix/lib/src/core/providers/widget_state_provider.dart index 7d61f5c65c..aceebe911a 100644 --- a/packages/mix/lib/src/core/providers/widget_state_provider.dart +++ b/packages/mix/lib/src/core/providers/widget_state_provider.dart @@ -42,6 +42,14 @@ class WidgetStateProvider extends InheritedModel { ); } + /// Whether a [WidgetStateProvider] exists above [context]. + /// + /// Unlike [of], this lookup does not register a dependency on any state + /// aspect. + static bool hasProvider(BuildContext context) { + return context.getInheritedWidgetOfExactType() != null; + } + /// Checks if a specific [WidgetState] is currently active. /// /// Returns false if no [WidgetStateProvider] is found in the widget tree. diff --git a/packages/mix/lib/src/core/style.dart b/packages/mix/lib/src/core/style.dart index fd619c1a67..357703db08 100644 --- a/packages/mix/lib/src/core/style.dart +++ b/packages/mix/lib/src/core/style.dart @@ -72,6 +72,24 @@ abstract class Style> extends Mix> .toSet(); } + /// Whether this style reacts to a state produced by automatic pointer + /// tracking. + @internal + bool get needsPointerTracking { + final variants = $variants; + if (variants == null) return false; + + for (final variantStyle in variants) { + final variant = variantStyle.variant; + if (variant is WidgetStateVariant && + (variant.state == .hovered || variant.state == .pressed)) { + return true; + } + } + + return false; + } + /// Merges all active variants with their nested variants recursively. /// /// This method evaluates which variants should be active based on the current @@ -88,8 +106,13 @@ abstract class Style> extends Mix> BuildContext context, { required Set namedVariants, }) { + final variants = $variants; + if (variants == null || variants.isEmpty) { + return this; + } + // Filter variants that should be active in this context - final activeVariants = ($variants ?? []) + final activeVariants = variants .where( (variantAttr) => switch (variantAttr.variant) { (ContextVariant variant) => variant.when(context), diff --git a/packages/mix/lib/src/core/style_builder.dart b/packages/mix/lib/src/core/style_builder.dart index ca5fab0eed..390688ef0b 100644 --- a/packages/mix/lib/src/core/style_builder.dart +++ b/packages/mix/lib/src/core/style_builder.dart @@ -46,45 +46,8 @@ class StyleBuilder> extends StatefulWidget { State> createState() => _StyleBuilderState(); } -class _StyleBuilderState> extends State> - with TickerProviderStateMixin { - late WidgetStatesController _controller; - - /// Tracks whether we created the controller internally (and thus own it) - bool _ownsController = false; - - @override - void initState() { - super.initState(); - _initController(); - } - - void _initController() { - if (widget.controller != null) { - _controller = widget.controller!; - _ownsController = false; - } else { - _controller = WidgetStatesController(); - _ownsController = true; - } - } - - void _handleControllerChange(StyleBuilder oldWidget) { - // Dispose old internal controller if we owned it - if (_ownsController) { - _controller.dispose(); - } - - // Set up new controller - if (widget.controller != null) { - _controller = widget.controller!; - _ownsController = false; - } else { - // Create internal controller, preserving state from old external controller - _controller = WidgetStatesController(oldWidget.controller?.value ?? {}); - _ownsController = true; - } - } +class _StyleBuilderState> extends State> { + WidgetStatesController? _preservedController; Style _buildStyle(BuildContext context) { final inheritedStyle = Style.maybeOf(context); @@ -105,31 +68,28 @@ class _StyleBuilderState> extends State> void didUpdateWidget(covariant StyleBuilder oldWidget) { super.didUpdateWidget(oldWidget); - // Handle controller changes - if (oldWidget.controller != widget.controller) { - _handleControllerChange(oldWidget); + if (oldWidget.controller == widget.controller) return; + + if (widget.controller != null) { + _preservedController?.dispose(); + _preservedController = null; + } else { + _preservedController = WidgetStatesController( + oldWidget.controller?.value ?? {}, + ); } } @override void dispose() { - // Only dispose controllers we created internally - if (_ownsController) { - _controller.dispose(); - } - + _preservedController?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final style = _buildStyle(context); - - // Calculate interactivity need early - final needsToTrackWidgetState = - widget.controller == null && style.widgetStates.isNotEmpty; - - final alreadyHasWidgetStateScope = WidgetStateProvider.of(context) != null; + final externalController = widget.controller; Widget current = Builder( builder: (context) { @@ -142,14 +102,15 @@ class _StyleBuilderState> extends State> }, ); - if (needsToTrackWidgetState && !alreadyHasWidgetStateScope) { - // If we need interactivity and no MixWidgetStateModel is present, - // wrap in MixInteractionDetector - current = MixInteractionDetector(controller: _controller, child: current); - } else if (widget.controller != null) { - // If we have an external controller, wrap with _ExternalControllerProvider + if (externalController != null) { current = _ExternalControllerProvider( - controller: _controller, + controller: externalController, + child: current, + ); + } else if (style.needsPointerTracking && + !WidgetStateProvider.hasProvider(context)) { + current = MixInteractionDetector( + controller: _preservedController, child: current, ); } diff --git a/packages/mix/test/src/core/style_builder_test.dart b/packages/mix/test/src/core/style_builder_test.dart index 1d4421e05a..62599dca54 100644 --- a/packages/mix/test/src/core/style_builder_test.dart +++ b/packages/mix/test/src/core/style_builder_test.dart @@ -72,35 +72,49 @@ void main() { ); // Verify that the animation wrapper is created - expect(find.byType(StyleAnimationBuilder), findsOneWidget); + final animationBuilder = find.byType(StyleAnimationBuilder); + expect(animationBuilder, findsOneWidget); + expect( + find.descendant( + of: animationBuilder, + matching: find.byType(AnimatedBuilder), + ), + findsOneWidget, + ); }); - testWidgets( - 'Animation driver is still present when animation config is null', - (tester) async { - final boxAttribute = BoxStyler() - .width(100) - .height(200) - .color(Colors.blue); + testWidgets('Null animation bypasses the animated rebuild path', ( + tester, + ) async { + final boxAttribute = BoxStyler() + .width(100) + .height(200) + .color(Colors.blue); - await tester.pumpWidget( - MaterialApp( - home: StyleBuilder( - style: boxAttribute, - builder: (context, spec) { - return Container( - decoration: spec.decoration, - constraints: spec.constraints, - ); - }, - ), + await tester.pumpWidget( + MaterialApp( + home: StyleBuilder( + style: boxAttribute, + builder: (context, spec) { + return Container( + decoration: spec.decoration, + constraints: spec.constraints, + ); + }, ), - ); + ), + ); - // StyleSpecBuilder always wraps with StyleAnimationBuilder. - expect(find.byType(StyleAnimationBuilder), findsOneWidget); - }, - ); + final animationBuilder = find.byType(StyleAnimationBuilder); + expect(animationBuilder, findsOneWidget); + expect( + find.descendant( + of: animationBuilder, + matching: find.byType(AnimatedBuilder), + ), + findsNothing, + ); + }); testWidgets( 'Animation creates new animated build rather than interpolating between style changes', @@ -839,6 +853,56 @@ void main() { ); }, ); + + testWidgets( + 'preserves state from inherited pointer style when controller is removed', + (tester) async { + final externalController = WidgetStatesController({ + WidgetState.hovered, + }); + addTearDown(externalController.dispose); + + WidgetStatesController? controller = externalController; + late void Function(VoidCallback) setState; + + await tester.pumpWidget( + MaterialApp( + home: StyleProvider( + style: BoxStyler() + .color(Colors.red) + .onHovered(BoxStyler().color(Colors.blue)), + child: StatefulBuilder( + builder: (context, stateSetter) { + setState = stateSetter; + return StyleBuilder( + controller: controller, + style: BoxStyler(), + builder: (context, spec) { + return Container(decoration: spec.decoration); + }, + ); + }, + ), + ), + ), + ); + + expect( + tester.widget(find.byType(Container)).decoration, + const BoxDecoration(color: Colors.blue), + ); + + setState(() { + controller = null; + }); + await tester.pump(); + + expect( + tester.widget(find.byType(Container)).decoration, + const BoxDecoration(color: Colors.blue), + ); + }, + ); }); testWidgets( @@ -865,6 +929,59 @@ void main() { }, ); + testWidgets( + 'does not install pointer tracking for non-pointer widget states', + (WidgetTester tester) async { + await tester.pumpWidget( + StyleBuilder( + style: BoxStyler().variant( + ContextVariant.widgetState(WidgetState.selected), + BoxStyler().color(Colors.blue), + ), + builder: (context, spec) => const SizedBox(), + ), + ); + + expect(find.byType(MixInteractionDetector), findsNothing); + }, + ); + + testWidgets('only rebuilds for widget states declared by the style', ( + WidgetTester tester, + ) async { + final controller = WidgetStatesController(); + addTearDown(controller.dispose); + var styleBuilds = 0; + + final styledChild = StyleBuilder( + style: BoxStyler().onPressed(BoxStyler().color(Colors.blue)), + builder: (context, spec) { + styleBuilds++; + return const SizedBox(); + }, + ); + + await tester.pumpWidget( + ListenableBuilder( + listenable: controller, + child: styledChild, + builder: (context, child) { + return WidgetStateProvider(states: controller.value, child: child!); + }, + ), + ); + + expect(styleBuilds, 1); + + controller.update(WidgetState.hovered, true); + await tester.pump(); + expect(styleBuilds, 1); + + controller.update(WidgetState.pressed, true); + await tester.pump(); + expect(styleBuilds, 2); + }); + testWidgets('should update the Style when the controller is updated', ( WidgetTester tester, ) async {