diff --git a/benchmark/add_unconsumed_props_benchmark.dart b/benchmark/add_unconsumed_props_benchmark.dart new file mode 100644 index 000000000..ad275f63b --- /dev/null +++ b/benchmark/add_unconsumed_props_benchmark.dart @@ -0,0 +1,333 @@ +import 'package:benchmark_harness/benchmark_harness.dart'; +import 'package:over_react/over_react.dart' as over_react; + +// ============================================================================ +// Benchmark Configuration +// ============================================================================ + +/// Configuration for an addUnconsumedProps benchmark. +class BenchmarkConfig { + final String name; + final int propsCount; + final int keysToOmitCount; + final int keySetsCount; + final int keysPerSetCount; + final Map Function()? customPropsGenerator; + + BenchmarkConfig({ + required this.name, + required this.propsCount, + this.keysToOmitCount = 0, + this.keySetsCount = 0, + this.keysPerSetCount = 0, + this.customPropsGenerator, + }); +} + +/// Configurable benchmark for addUnconsumedProps. +class AddUnconsumedPropsBenchmark extends BenchmarkBase { + final BenchmarkConfig config; + + AddUnconsumedPropsBenchmark(this.config) : super(config.name); + + late TestComponent2Component component; + late Map propsToUpdate; + + @override + void setup() { + // Generate props using custom generator or default + Map componentProps; + if (config.customPropsGenerator != null) { + componentProps = config.customPropsGenerator!(); + } else { + componentProps = over_react.JsBackedMap(); + for (var i = 0; i < config.propsCount; i++) { + componentProps['prop$i'] = 'value$i'; + } + } + + // Generate consumed props based on configuration + List? consumedPropsList; + + // Single set of keys to omit + if (config.keysToOmitCount > 0) { + final keysToOmit = []; + final propKeys = componentProps.keys.toList(); + for (var i = 0; i < config.keysToOmitCount && i < propKeys.length; i++) { + // Distribute omissions evenly across the props + final index = (i * propKeys.length / config.keysToOmitCount).floor(); + keysToOmit.add(propKeys[index].toString()); + } + consumedPropsList = [over_react.ConsumedProps([], keysToOmit)]; + } + + // Multiple key sets to omit + if (config.keySetsCount > 0 && config.keysPerSetCount > 0) { + consumedPropsList = []; + final propKeys = componentProps.keys.toList(); + + for (var setIdx = 0; setIdx < config.keySetsCount; setIdx++) { + final keySet = []; + for (var keyIdx = 0; keyIdx < config.keysPerSetCount && propKeys.isNotEmpty; keyIdx++) { + // Distribute keys across different regions of the prop list + final baseIndex = (setIdx * config.keysPerSetCount + keyIdx) % propKeys.length; + keySet.add(propKeys[baseIndex].toString()); + } + consumedPropsList.add(over_react.ConsumedProps([], keySet)); + } + } + + component = TestComponent2Component(testConsumedProps: consumedPropsList); + + // Set the component's props + component.props = componentProps; + } + + @override + void run() { + propsToUpdate = over_react.JsBackedMap(); + component.addUnconsumedProps(propsToUpdate); + } +} + +// ============================================================================ +// Custom Props Generators +// ============================================================================ + +Map _generateMixedProps() { + return over_react.JsBackedMap() + ..addAll({ + // Mix of different prop types + 'id': 'test-id', + 'className': 'test-class', + 'title': 'Test Title', + 'style': {'color': 'red'}, + 'onClick': () {}, + 'aria-label': 'Test Label', + 'data-test': 'test-value', + 'customProp1': 'custom1', + 'customProp2': 'custom2', + 'customProp3': 'custom3', + 'onCustomEvent': () {}, + }); +} + +Map _generateManyMixedProps() { + final props = _generateMixedProps(); + // Add more props + for (var i = 0; i < 40; i++) { + props['customProp${i + 10}'] = 'value$i'; + } + return props; +} + +// ============================================================================ +// Benchmark Configurations +// ============================================================================ + +final benchmarkConfigs = [ + // Small props map (10 props) - single consumed prop set + BenchmarkConfig(name: 'SmallPropsNoConsumed', propsCount: 10), + BenchmarkConfig(name: 'SmallPropsFewConsumed', propsCount: 10, keysToOmitCount: 3), + BenchmarkConfig(name: 'SmallPropsManyConsumed', propsCount: 10, keysToOmitCount: 7), + + // Medium props map (50 props) - single consumed prop set + BenchmarkConfig(name: 'MediumPropsNoConsumed', propsCount: 50), + BenchmarkConfig(name: 'MediumPropsFewConsumed', propsCount: 50, keysToOmitCount: 5), + BenchmarkConfig(name: 'MediumPropsManyConsumed', propsCount: 50, keysToOmitCount: 30), + + // Large props map (200 props) - single consumed prop set + BenchmarkConfig(name: 'LargePropsNoConsumed', propsCount: 200), + BenchmarkConfig(name: 'LargePropsFewConsumed', propsCount: 200, keysToOmitCount: 10), + BenchmarkConfig(name: 'LargePropsManyConsumed', propsCount: 200, keysToOmitCount: 100), + + // Mixed prop types (realistic scenario) + BenchmarkConfig( + name: 'MixedPropsFewConsumed', + propsCount: 12, // Will be overridden by customPropsGenerator + keysToOmitCount: 3, + customPropsGenerator: _generateMixedProps, + ), + BenchmarkConfig( + name: 'MixedPropsManyConsumed', + propsCount: 50, // Will be overridden by customPropsGenerator + keysToOmitCount: 10, + customPropsGenerator: _generateManyMixedProps, + ), + + // Edge cases + BenchmarkConfig(name: 'AllPropsConsumed', propsCount: 50, keysToOmitCount: 50), + BenchmarkConfig(name: 'EmptyProps', propsCount: 0), + + // ConsumedProps (keySets): Few prop mixins, few keys per mixin (2 mixins, 3 keys each) + BenchmarkConfig( + name: 'ConsumedPropsFewMixinsFewKeys', + propsCount: 50, + keySetsCount: 2, + keysPerSetCount: 3, + ), + + // ConsumedProps (keySets): Few prop mixins, many keys per mixin (2 mixins, 15 keys each) + BenchmarkConfig( + name: 'ConsumedPropsFewMixinsManyKeys', + propsCount: 50, + keySetsCount: 2, + keysPerSetCount: 15, + ), + + // ConsumedProps (keySets): Many prop mixins, few keys per mixin (10 mixins, 3 keys each) + BenchmarkConfig( + name: 'ConsumedPropsManyMixinsFewKeys', + propsCount: 50, + keySetsCount: 10, + keysPerSetCount: 3, + ), + + // ConsumedProps (keySets): Many prop mixins, many keys per mixin (10 mixins, 10 keys each) + BenchmarkConfig( + name: 'ConsumedPropsManyMixinsManyKeys', + propsCount: 100, + keySetsCount: 10, + keysPerSetCount: 10, + ), + + // ConsumedProps with large props map: Few mixins, few keys per mixin + BenchmarkConfig( + name: 'ConsumedPropsLargePropsFewMixinsFewKeys', + propsCount: 200, + keySetsCount: 3, + keysPerSetCount: 5, + ), + + // ConsumedProps with large props map: Many mixins, many keys per mixin + BenchmarkConfig( + name: 'ConsumedPropsLargePropsManyMixinsManyKeys', + propsCount: 200, + keySetsCount: 15, + keysPerSetCount: 12, + ), +]; + +// ============================================================================ +// Main runner +// ============================================================================ + +void main() { + print('Running addUnconsumedProps benchmarks...\n'); + + // Group benchmarks by category + final smallConfigs = benchmarkConfigs.where((c) => c.name.startsWith('Small')).toList(); + final mediumConfigs = benchmarkConfigs.where((c) => c.name.startsWith('Medium')).toList(); + final largeConfigs = benchmarkConfigs.where((c) => c.name.startsWith('Large')).toList(); + final mixedConfigs = benchmarkConfigs.where((c) => c.name.startsWith('Mixed')).toList(); + final consumedPropsConfigs = benchmarkConfigs.where((c) => c.name.startsWith('ConsumedProps')).toList(); + final edgeConfigs = + benchmarkConfigs.where((c) => c.name == 'AllPropsConsumed' || c.name == 'EmptyProps').toList(); + + print('=== Small Props Map (10 props) ==='); + for (final config in smallConfigs) { + AddUnconsumedPropsBenchmark(config).report(); + } + + print('\n=== Medium Props Map (50 props) ==='); + for (final config in mediumConfigs) { + AddUnconsumedPropsBenchmark(config).report(); + } + + print('\n=== Large Props Map (200 props) ==='); + for (final config in largeConfigs) { + AddUnconsumedPropsBenchmark(config).report(); + } + + print('\n=== Mixed Props (Realistic Scenario) ==='); + for (final config in mixedConfigs) { + AddUnconsumedPropsBenchmark(config).report(); + } + + print('\n=== Multiple Consumed Props (Prop Mixins) ==='); + for (final config in consumedPropsConfigs) { + AddUnconsumedPropsBenchmark(config).report(); + } + + print('\n=== Edge Cases ==='); + for (final config in edgeConfigs) { + AddUnconsumedPropsBenchmark(config).report(); + } +} + +over_react.UiFactory TestComponent = ([props]) => TestComponentProps(props); + +class TestComponentProps extends over_react.UiProps { + @override final over_react.ReactComponentFactoryProxy componentFactory = _TestComponentComponentFactory; + @override final Map props; + + TestComponentProps([Map? props]) : this.props = props ?? ({}); +} + +final _TestComponentComponentFactory = over_react.registerComponent(() => TestComponentComponent()); +class TestComponentComponent extends over_react.UiComponent { + @override + final List? consumedProps; + + TestComponentComponent({List? testConsumedProps}) : consumedProps = testConsumedProps; + + @override + render() => (over_react.Dom.div()..ref = 'foo')(); + + @override + TestComponentProps typedPropsFactory(Map propsMap) => TestComponentProps(propsMap); + + @override + void validateProps(Map appliedProps) { + super.validateProps(appliedProps); + + if (props['onValidateProps'] != null) props['onValidateProps'](appliedProps); + } +} + +over_react.UiFactory TestComponent2 = ([props]) => TestComponent2Props(props as over_react.JsBackedMap?); + +class TestComponent2Props extends over_react.UiProps { + @override final over_react.ReactComponentFactoryProxy componentFactory = TestComponent2ComponentFactory; + TestComponent2Props(over_react.JsBackedMap? backingMap) + : this._props = over_react.JsBackedMap() { + this._props = backingMap ?? over_react.JsBackedMap(); + } + + @override + over_react.JsBackedMap get props => _props; + over_react.JsBackedMap _props; + + @override + bool get $isClassGenerated => true; + + @override + String? get propKeyNamespace => null; +} + +final TestComponent2ComponentFactory = over_react.registerComponent2(() => TestComponent2Component()); +class TestComponent2Component extends over_react.UiComponent2 { + @override + final List? consumedProps; + + late TestComponent2Props _props; + + @override + TestComponent2Props get props => _props; + + @override + set props(Map value) => _props = typedPropsFactory(value); + + TestComponent2Component({List? testConsumedProps}) : + consumedProps = testConsumedProps; + + @override + render() => (over_react.Dom.div()..ref = 'foo')(); + + @override + TestComponent2Props typedPropsFactory(Map propsMap) => TestComponent2Props(propsMap as over_react.JsBackedMap); + + @override + TestComponent2Props typedPropsFactoryJs(Map propsMap) => TestComponent2Props(propsMap as over_react.JsBackedMap); +} + diff --git a/benchmark/add_unconsumed_props_benchmark.html b/benchmark/add_unconsumed_props_benchmark.html new file mode 100644 index 000000000..546b4e621 --- /dev/null +++ b/benchmark/add_unconsumed_props_benchmark.html @@ -0,0 +1,13 @@ + + + + + + forwardUnconsumedPropsV2 Benchmark + + + + + + + diff --git a/lib/src/component_declaration/component_base.dart b/lib/src/component_declaration/component_base.dart index f1e1a7b8b..3a16b0dd1 100644 --- a/lib/src/component_declaration/component_base.dart +++ b/lib/src/component_declaration/component_base.dart @@ -484,8 +484,8 @@ abstract class UiProps extends MapBase /// /// Related: `UiComponent2`'s `addUnconsumedProps` void addUnconsumedProps(Map props, Iterable consumedProps) { - final consumedPropKeys = consumedProps.map((consumedProps) => consumedProps.keys); - forwardUnconsumedPropsV2(props, propsToUpdate: this, keySetsToOmit: consumedPropKeys); + var consumedPropKeys = consumedProps.fold(HashSet(), (set, consumedProps) => set..addAll(consumedProps.keys)); + forwardUnconsumedPropsV2(props, propsToUpdate: this, keysToOmit: consumedPropKeys); } /// Copies DOM only key-value pairs from the provided [props] map into this map, @@ -509,8 +509,8 @@ abstract class UiProps extends MapBase /// /// Related: `UiComponent2`'s `addUnconsumedDomProps` void addUnconsumedDomProps(Map props, Iterable consumedProps) { - final consumedPropKeys = consumedProps.map((consumedProps) => consumedProps.keys); - forwardUnconsumedPropsV2(props, propsToUpdate: this, keySetsToOmit: consumedPropKeys, onlyCopyDomProps: true); + var consumedPropKeys = consumedProps.fold(HashSet(), (set, consumedProps) => set..addAll(consumedProps.keys)); + forwardUnconsumedPropsV2(props, propsToUpdate: this, keysToOmit: consumedPropKeys, onlyCopyDomProps: true); } /// Whether [UiProps] is in a testing environment. diff --git a/lib/src/component_declaration/component_base_2.dart b/lib/src/component_declaration/component_base_2.dart index b325faa21..120931786 100644 --- a/lib/src/component_declaration/component_base_2.dart +++ b/lib/src/component_declaration/component_base_2.dart @@ -15,6 +15,7 @@ // ignore_for_file: deprecated_member_use_from_same_package import 'dart:js'; +import 'dart:collection'; import 'package:meta/meta.dart'; import 'package:over_react/src/component/dummy_component2.dart'; @@ -357,6 +358,21 @@ abstract class UiComponent2 extends react.Component2 @toBeGenerated PropsMetaCollection get propsMeta => throw UngeneratedError(member: #propsMeta); + /// Cache the flattened consumed prop keys to avoid rebuilding on every call. + /// This is lazily initialized and computed only once per component instance. + HashSet? _cachedConsumedPropKeys; + + /// Returns the cached flattened set of consumed prop keys, computing it if necessary. + HashSet? get _consumedPropKeys { + if (_cachedConsumedPropKeys == null && consumedProps != null) { + _cachedConsumedPropKeys = consumedProps!.fold( + HashSet(), + (set, consumedProps) => set?..addAll(consumedProps.keys) + ); + } + return _cachedConsumedPropKeys; + } + /// A prop modifier that passes a reference of a component's `props` to be updated with any unconsumed props. /// /// Call within `modifyProps` like so: @@ -372,11 +388,7 @@ abstract class UiComponent2 extends react.Component2 /// /// > Related [addUnconsumedDomProps] void addUnconsumedProps(Map props) { - // TODO: cache this value to avoid unnecessary looping - var consumedPropKeys = consumedProps?.map((consumedProps) => consumedProps.keys) ?? const []; - - forwardUnconsumedProps(this.props, propsToUpdate: props, - keySetsToOmit: consumedPropKeys); + forwardUnconsumedPropsV2(this.props, propsToUpdate: props, keysToOmit: _consumedPropKeys); } /// A prop modifier that passes a reference of a component's `props` to be updated with any unconsumed `DomProps`. @@ -394,10 +406,7 @@ abstract class UiComponent2 extends react.Component2 /// /// > Related [addUnconsumedProps] void addUnconsumedDomProps(Map props) { - var consumedPropKeys = consumedProps?.map((consumedProps) => consumedProps.keys) ?? const []; - - forwardUnconsumedProps(this.props, propsToUpdate: props, keySetsToOmit: - consumedPropKeys, onlyCopyDomProps: true); + forwardUnconsumedPropsV2(this.props, propsToUpdate: props, keysToOmit: _consumedPropKeys, onlyCopyDomProps: true); } /// Returns a copy of this component's props with React props optionally omitted, and diff --git a/lib/src/util/map_util.dart b/lib/src/util/map_util.dart index 530553bb6..c43d455ae 100644 --- a/lib/src/util/map_util.dart +++ b/lib/src/util/map_util.dart @@ -122,7 +122,7 @@ void forwardUnconsumedProps(Map props, { } } - if (omitReactProps && const ['key', 'ref', 'children'].contains(key)) continue; + if (omitReactProps && const {'key', 'ref', 'children'}.contains(key)) continue; propsToUpdate[key] = props[key]; } @@ -142,38 +142,38 @@ void forwardUnconsumedPropsV2(Map props, { Iterable? keySetsToOmit, required Map propsToUpdate, }) { - for (final key in props.keys) { - if (keysToOmit != null && keysToOmit.contains(key)) continue; - - if (keySetsToOmit != null && keySetsToOmit.isNotEmpty) { - // If the passed in value of [keySetsToOmit] comes from - // [addUnconsumedProps], there should only be a single index. - // Consequently, this case exists to give the opportunity for the loop - // to continue without initiating another loop (which is less - // performant than `.first.contains()`). - // TODO: further optimize this by identifying the best looping / data structure - if (keySetsToOmit.first.contains(key)) continue; - - if (keySetsToOmit.length > 1) { - bool shouldContinue = false; - for (final keySet in keySetsToOmit) { - if (keySet.contains(key)) { - shouldContinue = true; - break; - } - } + for (final key in props.keys) { + if (keysToOmit != null && keysToOmit.contains(key)) continue; + + if (keySetsToOmit != null && keySetsToOmit.isNotEmpty) { + // If the passed in value of [keySetsToOmit] comes from + // [addUnconsumedProps], there should only be a single index. + // Consequently, this case exists to give the opportunity for the loop + // to continue without initiating another loop (which is less + // performant than `.first.contains()`). + // TODO: further optimize this by identifying the best looping / data structure + if (keySetsToOmit.first.contains(key)) continue; - if (shouldContinue) continue; + if (keySetsToOmit.length > 1) { + bool shouldContinue = false; + for (final keySet in keySetsToOmit) { + if (keySet.contains(key)) { + shouldContinue = true; + break; + } } - } - if (onlyCopyDomProps && !((key is String && (key.startsWith('aria-') || - key.startsWith('data-'))) || - _validDomProps.contains(key))) { - continue; + if (shouldContinue) continue; } + } + + if (onlyCopyDomProps && !((key is String && (key.startsWith('aria-') || + key.startsWith('data-'))) || + _validDomProps.contains(key))) { + continue; + } - if (omitReactProps && const ['key', 'ref', 'children'].contains(key)) continue; + if (omitReactProps && const {'key', 'ref', 'children'}.contains(key)) continue; propsToUpdate[key] = props[key]; }