From d7ebc6935b8e6a2d53752b27b6a73078f93edfc7 Mon Sep 17 00:00:00 2001 From: Greg Littlefield Date: Tue, 2 Dec 2025 16:48:27 -0700 Subject: [PATCH 01/10] Rename existing benchmark file --- .../{builder_parsing_benchmark.dart => builder_parsing.dart} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename benchmark/{builder_parsing_benchmark.dart => builder_parsing.dart} (100%) diff --git a/benchmark/builder_parsing_benchmark.dart b/benchmark/builder_parsing.dart similarity index 100% rename from benchmark/builder_parsing_benchmark.dart rename to benchmark/builder_parsing.dart From 0e6f9363ac4232ff31d0e400351a71a5bc4afe4f Mon Sep 17 00:00:00 2001 From: Greg Littlefield Date: Tue, 2 Dec 2025 16:55:55 -0700 Subject: [PATCH 02/10] Add dart2js output benchmarks --- benchmark/dart2js_output.dart | 262 ++++++++++++++++++ benchmark/dart2js_output/compile.dart | 173 ++++++++++++ .../dart2js_output/dart2js_normalize.dart | 45 +++ benchmark/dart2js_output/logging.dart | 33 +++ benchmark/dart2js_output/source.dart | 67 +++++ pubspec.yaml | 1 + 6 files changed, 581 insertions(+) create mode 100644 benchmark/dart2js_output.dart create mode 100644 benchmark/dart2js_output/compile.dart create mode 100644 benchmark/dart2js_output/dart2js_normalize.dart create mode 100644 benchmark/dart2js_output/logging.dart create mode 100644 benchmark/dart2js_output/source.dart diff --git a/benchmark/dart2js_output.dart b/benchmark/dart2js_output.dart new file mode 100644 index 000000000..24859ea48 --- /dev/null +++ b/benchmark/dart2js_output.dart @@ -0,0 +1,262 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:meta/meta.dart'; + +import 'dart2js_output/compile.dart'; +import 'dart2js_output/dart2js_normalize.dart'; +import 'dart2js_output/logging.dart'; +import 'dart2js_output/source.dart' as source; + +Future main(List args) async { + final runner = CommandRunner( + 'benchmark-dart2js-output', + 'Runs various dart2js output benchmarks and comparisons.' + ' Useful for debugging/validating how changes to over_react affect dart2js output.') + ..addCommand(CompareSizeCommand())..addCommand( + CompareCodeCommand())..addCommand(GetCodeCommand()); + + await runner.run(args); +} + +final _originMasterDep = jsonEncode({ + 'git': { + 'url': Directory.current.uri.toString(), + 'ref': 'origin/HEAD', + } +}); +final _localPathDep = jsonEncode({ + 'path': Directory.current.path, +}); + +abstract class BaseCommand extends Command { + BaseCommand() { + argParser.addFlag('verbose', defaultsTo: true, negatable: true); + } + + @override + @mustCallSuper + void run() { + initLogging(verbose: argResults!['verbose'] as bool); + } +} + +abstract class CompareCommand extends BaseCommand { + CompareCommand() { + argParser.addOption( + 'head', + help: 'Head over_react dependency to compare to the base.' + ' Defaults to the enclosing local working copy of over_react.', + defaultsTo: _localPathDep, + ); + argParser.addOption( + 'base', + help: 'Base over_react dependency to compare against.' + ' Defaults to origin/master.', + defaultsTo: _originMasterDep, + ); + } + + dynamic get _baseDep => jsonDecode(argResults!['base'] as String); + + dynamic get _headDep => jsonDecode(argResults!['head'] as String); +} + +class CompareSizeCommand extends CompareCommand { + @override + String get description => + 'Compares the optimized, minified size of dart2js output for a benchmark React component between two over_react versions.'; + + @override + String get name => 'compare-size'; + + @override + Future run() async { + super.run(); + final baseSize = getComponentAndUsageSize(overReactDep: _baseDep); + final headSize = getComponentAndUsageSize(overReactDep: _headDep); + print('Base size: ${await baseSize} bytes'); + print('Head size: ${await headSize} bytes'); + print('(Head size) - (base size):' + ' ${(await headSize) - (await baseSize)} bytes'); + } +} + +class CompareCodeCommand extends CompareCommand { + @override + String get name => 'compare-code'; + + @override + String get description => + 'Compares the optimized, non-minified dart2js output for a benchmark React component between two over_react versions.' + '\nOutputs in a Git diff format.' + '\nCompiled code is normalized before comparison for better diffing.'; + + @override + Future run() async { + super.run(); + final diff = await compareCodeAcrossVersions( + source.componentBenchmark( + componentCount: 1, + propsCount: 5, + ), + overReactDep1: _baseDep, + overReactDep2: _headDep, + ); + print(diff); + } +} + +class GetCodeCommand extends BaseCommand { + @override + String get name => 'get-code'; + + @override + String get description => + 'Displays the optimized, non-minified dart2js output for a benchmark React component.' + '\nOutputs in a Git diff format, showing output changes when adding a component.' + '\nCompiled code is normalized before comparison for better diffing.'; + + GetCodeCommand() { + argParser.addOption( + 'dependency', + help: 'over_react dependency to compile with.' + ' Defaults to the enclosing local working copy of over_react.', + defaultsTo: _localPathDep, + ); + } + + dynamic get _dep => jsonDecode(argResults!['dependency'] as String); + + @override + Future run() async { + super.run(); + print(await getCompiledComponentCode(overReactDep: _dep)); + } +} + +Future compareCodeAcrossVersions(String code, { + required dynamic overReactDep1, + required dynamic overReactDep2, +}) async { + final results1 = compileOverReactProgram( + webFilesByName: {'main.dart': code}, + overReactDep: overReactDep1, + minify: false, + ); + final results2 = compileOverReactProgram( + webFilesByName: {'main.dart': code}, + overReactDep: overReactDep2, + minify: false, + ); + + return gitDiffNoIndex( + createNormalizedDart2jsFile((await results1).getCompiledDart2jsFile()).path, + createNormalizedDart2jsFile((await results2).getCompiledDart2jsFile()).path, + ); +} + +Future getCompiledComponentCode({ + dynamic overReactDep, +}) async { + const baselineComponentCount = 2; + const propsCount = 3; + + final result = await compileOverReactProgram(webFilesByName: { + 'baseline.dart': source.componentBenchmark( + componentCount: baselineComponentCount, + propsCount: propsCount, + ), + 'additional.dart': source.componentBenchmark( + componentCount: baselineComponentCount + 1, + propsCount: propsCount, + ), + }, overReactDep: overReactDep, minify: false); + + final baselineCompiledFile = result.getCompiledDart2jsFile('baseline.dart'); + final additionalCompiledFile = + result.getCompiledDart2jsFile('additional.dart'); + + return gitDiffNoIndex( + createNormalizedDart2jsFile(baselineCompiledFile).path, + createNormalizedDart2jsFile(additionalCompiledFile).path, + ); +} + +File createNormalizedDart2jsFile(File f) { + return File(f.path + '.normalized.js') + ..writeAsStringSync(normalizeDart2jsContents(f.readAsStringSync())); +} + +Future gitDiffNoIndex(String file1, String file2, + {int contextLines = 1}) async { + final result = await Process.run('git', [ + 'diff', + '--no-index', + '-U$contextLines', + file1, + file2, + ]); + + if (result.exitCode == 0 || result.exitCode == 1) { + return result.stdout as String; + } + + throw Exception( + 'Error diffing files. Exit code: ${result.exitCode} stderr: $stderr'); +} + +/// Gets the total size of a single test component, plus usage that sets all props, +/// and render that reads all props. +/// +/// Since it contains this extra usage and render code, it's mainly useful when +/// comparing across versions, and shouldn't by itself be used as a number that +/// represents "the cost of declaring a component"." +Future getComponentAndUsageSize({ + dynamic overReactDep, +}) async { + const baselineComponentCount = 100; + const propsCount = 5; + + final result = await compileOverReactProgram(webFilesByName: { + 'baseline.dart': source.componentBenchmark( + componentCount: baselineComponentCount, + propsCount: propsCount, + ), + 'additional.dart': source.componentBenchmark( + componentCount: baselineComponentCount + 1, + propsCount: propsCount, + ), + }, overReactDep: overReactDep); + + final baselineFileSize = + result + .getCompiledDart2jsFile('baseline.dart') + .statSync() + .size; + final additionalFileSize = + result + .getCompiledDart2jsFile('additional.dart') + .statSync() + .size; + validateFileSize(baselineFileSize); + validateFileSize(additionalFileSize); + + return additionalFileSize - baselineFileSize; +} + +void validateFileSize(int actualSizeInBytes) { + // Arbitrary minimum expected size to help ensure the test setup is correct. + // + // Value derived from the compiled size of the following Dart program: + // import 'package:over_react/over_react.dart'; + // void main() { Dom.div()(); } + const minimumExpectedSizeInBytes = 144339; + + if (actualSizeInBytes < minimumExpectedSizeInBytes) { + throw Exception('Expected compiled size to be larger,' + ' at least $minimumExpectedSizeInBytes bytes.' + ' Was: $actualSizeInBytes bytes.'); + } +} diff --git a/benchmark/dart2js_output/compile.dart b/benchmark/dart2js_output/compile.dart new file mode 100644 index 000000000..276e74dfd --- /dev/null +++ b/benchmark/dart2js_output/compile.dart @@ -0,0 +1,173 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:logging/logging.dart'; +import 'package:path/path.dart' as p; + +final overReactPackageRoot = Directory.current; + +Future compileOverReactProgram({ + required Map webFilesByName, + dynamic overReactDep, + bool minify = true, +}) async { + overReactDep ??= { + 'path': overReactPackageRoot.path, + }; + + final logger = Logger('compileOverReactProgram'); + + final tempPath = Directory.systemTemp.createTempSync().path; + final packagePath = p.join(tempPath, 'package'); + final outputPath = p.join(tempPath, 'build_output'); + final packageDir = Directory(packagePath)..createSync(); + + logger.fine('Creating temporary package: $packagePath'); + + // YAML is a superset of JSON :) + File(p.join(packagePath, 'pubspec.yaml')).writeAsStringSync(jsonEncode({ + 'name': '_over_react_benchmark_test', + 'version': '0.0.0', + 'environment': { + 'sdk': '>=2.19.0 <4.0.0', + }, + 'dependencies': {'over_react': overReactDep}, + 'dev_dependencies': { + 'build_runner': 'any', + 'build_web_compilers': 'any', + } + })); + // YAML is a superset of JSON :) + File(p.join(packagePath, 'build.yaml')).writeAsStringSync(jsonEncode({ + 'targets': { + r'$default': { + 'builders': { + 'build_web_compilers:entrypoint': { + 'release_options': { + 'dart2js_args': [ + '--csp', + '-O3', + if (minify) '--minify' else '--no-minify', + '--verbose', + '--dump-info=binary', + ] + } + }, + // Need this to prevent dumped info from being output. + // See: https://github.com/dart-lang/build/issues/1622 + 'build_web_compilers:dart2js_archive_extractor': { + 'release_options': {'filter_outputs': false} + } + } + } + } + })); + + final webPath = p.join(packagePath, 'web'); + webFilesByName.forEach((name, contentsTemplate) { + final filePath = p.join(packagePath, 'web', name); + final partFilename = + p.basenameWithoutExtension(name) + '.over_react.g.dart'; + final contents = contentsTemplate.replaceAll('{{PART_PATH}}', partFilename); + + if (!p.isWithin(webPath, filePath)) { + throw ArgumentError.value( + name, 'Filename must be a relative path without any `..`.'); + } + File(filePath) + ..parent.createSync(recursive: true) + ..writeAsStringSync(contents); + }); + + const dartExecutable = 'dart'; + + logger.fine('Running pub get...'); + final pubGetResult = await Process.run( + dartExecutable, + ['pub', 'get'], + workingDirectory: packagePath, + runInShell: true, + ); + if (pubGetResult.exitCode != 0) { + throw Exception( + 'Unexpected `pub get` failure in temporary package: $packagePath\n' + '${pubGetResult.infoForErrorMessage}'); + } + + logger.fine('Running build...'); + + final buildResult = await Process.run( + dartExecutable, + [ + 'pub', + 'run', + 'build_runner', + 'build', + // Make sure to build in dart2js + '--release', + '--output', + outputPath, + 'web' + ], + workingDirectory: packagePath, + runInShell: true, + ); + logger.fine('Build complete; output to: $outputPath'); + File(p.join(outputPath, 'build_output.log')) + ..parent.createSync(recursive: true) + ..writeAsStringSync(buildResult.stdout.toString()); + if (buildResult.exitCode != 0) { + throw Exception( + 'Unexpected build failure in temporary package: $packagePath\n' + '${buildResult.infoForErrorMessage}'); + } + + await packageDir.delete(recursive: true); + logger.fine('Deleted temporary package.'); + + return BuildResult( + buildFolderPath: outputPath, + ); +} + +extension on ProcessResult { + String get infoForErrorMessage => 'Exit code: ${this.exitCode}.' + '\nstdout:\n${this.stdout}' + '\nstderr:\n${this.stderr}'; +} + +class BuildResult { + final String buildFolderPath; + + BuildResult({ + required this.buildFolderPath, + }); +} + +extension BuildResultUtils on BuildResult { + File getCompiledDart2jsFile([String? dartFilename]) { + final webFolder = Directory(p.join(buildFolderPath, 'web')); + + final File compiledFile; + if (dartFilename != null) { + compiledFile = File(p.join(webFolder.path, dartFilename + '.js')); + if (!compiledFile.existsSync()) { + throw Exception('Compiled file ${compiledFile.path} does not exist'); + } + } else { + final candidates = webFolder + .listSync() + .whereType() + .where((f) => f.path.endsWith('.dart.js')) + .toList(); + if (candidates.length != 1) { + throw Exception( + 'Expected a single dart2js output, but found ${candidates.length}:' + '${candidates.map((c) => '\n- ${c.path}')}'); + } + compiledFile = candidates.single; + } + + return compiledFile; + } +} diff --git a/benchmark/dart2js_output/dart2js_normalize.dart b/benchmark/dart2js_output/dart2js_normalize.dart new file mode 100644 index 000000000..1e97c6767 --- /dev/null +++ b/benchmark/dart2js_output/dart2js_normalize.dart @@ -0,0 +1,45 @@ +import 'dart:convert'; + +/// Normalizes compiled dart2js code so it can be diffed cleanly against other +/// compiled dart2js code. +/// +/// Useful for isolating changes introduced by a program +/// +/// This logic is a bit brittle, but the goal is to improve readability/diffing, +/// and works well enough without having to pull in a JS formatter or something. +String normalizeDart2jsContents(String contents) => contents + // Replace numbers and identifiers that change when compiling additional code + .replaceAll(RegExp(r'\b\d+\b'), '###') // number literals + .replaceAll(RegExp(r'\b_static(_\d+)?\b'), 'static###') + .replaceAll(RegExp(r'\bt\d+\b'), 't#') // local variables (e.g., `t1`) + .replaceAllMapped(RegExp(r'\b(B\.\w+_)[0-9a-zA-Z]+\b'), (match) { + return match.group(1)! + '####'; // compile-time constant declarations + }) + // Remove newlines in empty function bodies + .replaceAllMapped( + RegExp(r'(function [\w$]+\(\) {)\s+}'), (match) => '${match[1]}}') + // Break up long type inheritance lists onto separate lines + .replaceAllMapped(RegExp(r'(_inheritMany\()([^\n]+)(\]\);)'), (match) { + return '${match[1]}${match[2]!.replaceAll(', ', ',\n ')},\n ${match[3]}'; + }) + // Break up long type metadata list onto separate lines, and sort + .replaceAllMapped(RegExp(r'types: (\["[^\n]+~[^\n]+"\]),'), (match) { + try { + final parsed = jsonDecode(match.group(1)!) as List; + parsed.sort(); + return 'types: ${JsonEncoder.withIndent(' ').convert(parsed)},'; + } catch (_) {} + return match.group(0)!; + }) + // Try to format inlined JSON strings that contain type metadata + .replaceAllMapped(RegExp(r"JSON\.parse\('(\{[^\n]+\})'\)"), (match) { + final stringContents = match.group(1)!; + String formatted; + try { + formatted = const JsonEncoder.withIndent(' ') + .convert(jsonDecode(stringContents)); + } catch (_) { + formatted = stringContents.replaceAll(',"', ',\n"'); + } + return "#JSON_PARSE#'$formatted#/JSON_PARSE#"; + }); diff --git a/benchmark/dart2js_output/logging.dart b/benchmark/dart2js_output/logging.dart new file mode 100644 index 000000000..ff72756c9 --- /dev/null +++ b/benchmark/dart2js_output/logging.dart @@ -0,0 +1,33 @@ +import 'dart:io'; + +import 'package:io/ansi.dart' as ansi; +import 'package:logging/logging.dart'; + +void initLogging({bool verbose = true}) { + Logger.root.level = verbose ? Level.ALL : Level.INFO; + Logger.root.onRecord.listen((rec) { + String? Function(String) colorizer; + IOSink output; + + if (rec.level >= Level.SEVERE) { + colorizer = ansi.red.wrap; + output = stderr; + } else if (rec.level >= Level.WARNING) { + colorizer = ansi.yellow.wrap; + output = stderr; + } else { + colorizer = (string) => string; + output = stdout; + } + + if (rec.message != '') { + output.writeln(colorizer('[${rec.level}] ${rec.message}')); + } + if (rec.error != null) { + output.writeln(colorizer(rec.error.toString())); + } + if (verbose && rec.stackTrace != null) { + output.writeln(colorizer(rec.stackTrace.toString())); + } + }); +} diff --git a/benchmark/dart2js_output/source.dart b/benchmark/dart2js_output/source.dart new file mode 100644 index 000000000..f0be91105 --- /dev/null +++ b/benchmark/dart2js_output/source.dart @@ -0,0 +1,67 @@ +String componentBenchmark({ + required int componentCount, + required int propsCount, +}) { + final fileSource = StringBuffer(''' + import 'package:over_react/over_react.dart'; + part '{{PART_PATH}}';'''); + + final mainStatements = StringBuffer()..writeln(mainAntiTreeShakingStatements); + for (var i = 0; i < componentCount; i++) { + final componentName = 'Foo$i'; + final mixinName = '${componentName}Props'; + final propsName = mixinName; + final propTypesByName = { + for (final i in Iterable.generate(propsCount)) 'foo$i': 'String', + }; + final propNames = propTypesByName.keys; + + fileSource.writeln(''' + mixin $mixinName on UiProps { + ${propDeclarations(propTypesByName)} + } + UiFactory<$propsName> $componentName = uiFunction((props) { + ${propReadStatements(propNames)} + final consumedProps = props.staticMeta.forMixins({$mixinName}); + return (Dom.div() + ..addUnconsumedProps(props, consumedProps) + ..modifyProps(props.addPropsToForward()) + )(); + }, _\$${componentName}Config); + '''); + + mainStatements.writeln('($componentName()${ + // Write each prop + propNames.map((name) => '..$name = ""').join('')})();'); + } + + fileSource + ..writeln('void main() {') + ..write(mainStatements) + ..writeln('}'); + + return fileSource.toString(); +} + +const mainAntiTreeShakingStatements = ''' + (Dom.div()..id = '1')(); // Other props class, DomProps + ResizeSensor()(); // class component, legacy component, PropsMeta used in propTypes + ResizeSensor().getPropKey((p) => p.id); // getPropKey generated impls +'''; + +String propDeclarations(Map propTypesByName) { + return propTypesByName + .mapEntries((name, type) => 'late $type $name;') + .join('\n'); +} + +String propReadStatements(Iterable propNames) { + // Put inside an array, as opposed to separate print statements, so that each + // additional prop doesn't contain additional code outside of the read. + return 'print([${propNames.map((name) => 'props.$name').join(', ')}]);'; +} + +extension on Map { + Iterable mapEntries(T Function(K, V) mapper) => + entries.map((entry) => mapper(entry.key, entry.value)); +} diff --git a/pubspec.yaml b/pubspec.yaml index e8a0deb9b..a1e91d4b8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: redux_dev_tools: '>=0.6.0 <0.8.0' dev_dependencies: + args: ^2.4.2 benchmark_harness: ^2.2.1 build_resolvers: ^2.0.0 build_runner: ^2.0.0 From b1d8b85bc3e159530c675940323681a308c0d3dc Mon Sep 17 00:00:00 2001 From: Greg Littlefield Date: Tue, 2 Dec 2025 20:56:37 -0700 Subject: [PATCH 03/10] Improve output when there is no difference --- benchmark/dart2js_output.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/benchmark/dart2js_output.dart b/benchmark/dart2js_output.dart index 24859ea48..fe10e6b98 100644 --- a/benchmark/dart2js_output.dart +++ b/benchmark/dart2js_output.dart @@ -104,7 +104,11 @@ class CompareCodeCommand extends CompareCommand { overReactDep1: _baseDep, overReactDep2: _headDep, ); - print(diff); + if (diff.trim().isEmpty) { + print('(No difference in dart2js output between base and head)'); + } else { + print(diff); + } } } From ebcd9c2595269a5011864c9837ec32508e50fe6a Mon Sep 17 00:00:00 2001 From: Greg Littlefield Date: Tue, 2 Dec 2025 21:06:11 -0700 Subject: [PATCH 04/10] Colorize diffs in benchmark --- benchmark/dart2js_output.dart | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/benchmark/dart2js_output.dart b/benchmark/dart2js_output.dart index fe10e6b98..8ae1d864a 100644 --- a/benchmark/dart2js_output.dart +++ b/benchmark/dart2js_output.dart @@ -193,12 +193,17 @@ File createNormalizedDart2jsFile(File f) { ..writeAsStringSync(normalizeDart2jsContents(f.readAsStringSync())); } -Future gitDiffNoIndex(String file1, String file2, - {int contextLines = 1}) async { +Future gitDiffNoIndex( + String file1, + String file2, { + int contextLines = 1, + bool color = true, +}) async { final result = await Process.run('git', [ 'diff', '--no-index', '-U$contextLines', + if (color) '--color', file1, file2, ]); From c1ddc4f73f25d602d6236f6c1d2f6dbfcd9c43ad Mon Sep 17 00:00:00 2001 From: Greg Littlefield Date: Wed, 3 Dec 2025 10:35:18 -0700 Subject: [PATCH 05/10] Turn colorization back off, since it interferes with piping output --- benchmark/dart2js_output.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmark/dart2js_output.dart b/benchmark/dart2js_output.dart index 8ae1d864a..53ba6c456 100644 --- a/benchmark/dart2js_output.dart +++ b/benchmark/dart2js_output.dart @@ -197,7 +197,7 @@ Future gitDiffNoIndex( String file1, String file2, { int contextLines = 1, - bool color = true, + bool color = false, }) async { final result = await Process.run('git', [ 'diff', From 9a2681d87d39a74a78b0fda0aa9e1a41d26fd454 Mon Sep 17 00:00:00 2001 From: Greg Littlefield Date: Wed, 3 Dec 2025 11:35:38 -0700 Subject: [PATCH 06/10] Deep-sort lists and maps in dart2js normalization --- .../dart2js_output/dart2js_normalize.dart | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/benchmark/dart2js_output/dart2js_normalize.dart b/benchmark/dart2js_output/dart2js_normalize.dart index 1e97c6767..c3c875845 100644 --- a/benchmark/dart2js_output/dart2js_normalize.dart +++ b/benchmark/dart2js_output/dart2js_normalize.dart @@ -1,5 +1,8 @@ +import 'dart:collection'; import 'dart:convert'; +import 'package:collection/collection.dart'; + /// Normalizes compiled dart2js code so it can be diffed cleanly against other /// compiled dart2js code. /// @@ -26,8 +29,7 @@ String normalizeDart2jsContents(String contents) => contents .replaceAllMapped(RegExp(r'types: (\["[^\n]+~[^\n]+"\]),'), (match) { try { final parsed = jsonDecode(match.group(1)!) as List; - parsed.sort(); - return 'types: ${JsonEncoder.withIndent(' ').convert(parsed)},'; + return 'types: ${JsonEncoder.withIndent(' ').convert(deepSorted(parsed))},'; } catch (_) {} return match.group(0)!; }) @@ -37,9 +39,20 @@ String normalizeDart2jsContents(String contents) => contents String formatted; try { formatted = const JsonEncoder.withIndent(' ') - .convert(jsonDecode(stringContents)); + .convert(deepSorted(jsonDecode(stringContents))); } catch (_) { formatted = stringContents.replaceAll(',"', ',\n"'); } return "#JSON_PARSE#'$formatted#/JSON_PARSE#"; }); + +/// Returns a deep copy of [original], with all nested Lists and Maps sorted. +Object? deepSorted(Object? original) { + if (original is List) return original.map(deepSorted).toList()..sort(); + if (original is Map) { + return LinkedHashMap.fromEntries(original.entries + .sortedBy((entry) => entry.key.toString()) + .map((e) => MapEntry(e.key, deepSorted(e.value)))); + } + return original; +} From 57e39e333500b82054686a57475b129c74e8729e Mon Sep 17 00:00:00 2001 From: Greg Littlefield Date: Wed, 3 Dec 2025 11:36:46 -0700 Subject: [PATCH 07/10] Fix ResizeSensor/DomProps affecting component size diff --- benchmark/dart2js_output/source.dart | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/benchmark/dart2js_output/source.dart b/benchmark/dart2js_output/source.dart index f0be91105..d4e6604dd 100644 --- a/benchmark/dart2js_output/source.dart +++ b/benchmark/dart2js_output/source.dart @@ -6,7 +6,7 @@ String componentBenchmark({ import 'package:over_react/over_react.dart'; part '{{PART_PATH}}';'''); - final mainStatements = StringBuffer()..writeln(mainAntiTreeShakingStatements); + final mainStatements = StringBuffer(); for (var i = 0; i < componentCount; i++) { final componentName = 'Foo$i'; final mixinName = '${componentName}Props'; @@ -22,7 +22,9 @@ String componentBenchmark({ } UiFactory<$propsName> $componentName = uiFunction((props) { ${propReadStatements(propNames)} + // Prevent tree-shaking of metadata and getPropKey generated impls final consumedProps = props.staticMeta.forMixins({$mixinName}); + props.getPropKey((p) => p.id); return (Dom.div() ..addUnconsumedProps(props, consumedProps) ..modifyProps(props.addPropsToForward()) @@ -43,12 +45,6 @@ String componentBenchmark({ return fileSource.toString(); } -const mainAntiTreeShakingStatements = ''' - (Dom.div()..id = '1')(); // Other props class, DomProps - ResizeSensor()(); // class component, legacy component, PropsMeta used in propTypes - ResizeSensor().getPropKey((p) => p.id); // getPropKey generated impls -'''; - String propDeclarations(Map propTypesByName) { return propTypesByName .mapEntries((name, type) => 'late $type $name;') From dc9f72eaeaa012515f45d02258357490323efc7b Mon Sep 17 00:00:00 2001 From: Greg Littlefield Date: Wed, 3 Dec 2025 11:43:05 -0700 Subject: [PATCH 08/10] Conditionally color output when using a terminal --- benchmark/dart2js_output.dart | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/benchmark/dart2js_output.dart b/benchmark/dart2js_output.dart index 53ba6c456..475e55687 100644 --- a/benchmark/dart2js_output.dart +++ b/benchmark/dart2js_output.dart @@ -103,6 +103,7 @@ class CompareCodeCommand extends CompareCommand { ), overReactDep1: _baseDep, overReactDep2: _headDep, + color: stdioType(stdout) == StdioType.terminal, ); if (diff.trim().isEmpty) { print('(No difference in dart2js output between base and head)'); @@ -136,7 +137,10 @@ class GetCodeCommand extends BaseCommand { @override Future run() async { super.run(); - print(await getCompiledComponentCode(overReactDep: _dep)); + print(await getCompiledComponentCode( + overReactDep: _dep, + color: stdioType(stdout) == StdioType.terminal, + )); } } @@ -158,11 +162,13 @@ Future compareCodeAcrossVersions(String code, { return gitDiffNoIndex( createNormalizedDart2jsFile((await results1).getCompiledDart2jsFile()).path, createNormalizedDart2jsFile((await results2).getCompiledDart2jsFile()).path, + color: color, ); } Future getCompiledComponentCode({ dynamic overReactDep, + bool color = false, }) async { const baselineComponentCount = 2; const propsCount = 3; @@ -185,6 +191,7 @@ Future getCompiledComponentCode({ return gitDiffNoIndex( createNormalizedDart2jsFile(baselineCompiledFile).path, createNormalizedDart2jsFile(additionalCompiledFile).path, + color: color, ); } From ed81cef41ce899bfa4dbdf9e832d79c901a224c7 Mon Sep 17 00:00:00 2001 From: Greg Littlefield Date: Wed, 3 Dec 2025 11:43:17 -0700 Subject: [PATCH 09/10] Format --- benchmark/dart2js_output.dart | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/benchmark/dart2js_output.dart b/benchmark/dart2js_output.dart index 475e55687..b5dbf590b 100644 --- a/benchmark/dart2js_output.dart +++ b/benchmark/dart2js_output.dart @@ -14,8 +14,9 @@ Future main(List args) async { 'benchmark-dart2js-output', 'Runs various dart2js output benchmarks and comparisons.' ' Useful for debugging/validating how changes to over_react affect dart2js output.') - ..addCommand(CompareSizeCommand())..addCommand( - CompareCodeCommand())..addCommand(GetCodeCommand()); + ..addCommand(CompareSizeCommand()) + ..addCommand(CompareCodeCommand()) + ..addCommand(GetCodeCommand()); await runner.run(args); } @@ -90,8 +91,8 @@ class CompareCodeCommand extends CompareCommand { @override String get description => 'Compares the optimized, non-minified dart2js output for a benchmark React component between two over_react versions.' - '\nOutputs in a Git diff format.' - '\nCompiled code is normalized before comparison for better diffing.'; + '\nOutputs in a Git diff format.' + '\nCompiled code is normalized before comparison for better diffing.'; @override Future run() async { @@ -120,8 +121,8 @@ class GetCodeCommand extends BaseCommand { @override String get description => 'Displays the optimized, non-minified dart2js output for a benchmark React component.' - '\nOutputs in a Git diff format, showing output changes when adding a component.' - '\nCompiled code is normalized before comparison for better diffing.'; + '\nOutputs in a Git diff format, showing output changes when adding a component.' + '\nCompiled code is normalized before comparison for better diffing.'; GetCodeCommand() { argParser.addOption( @@ -144,9 +145,11 @@ class GetCodeCommand extends BaseCommand { } } -Future compareCodeAcrossVersions(String code, { +Future compareCodeAcrossVersions( + String code, { required dynamic overReactDep1, required dynamic overReactDep2, + bool color = false, }) async { final results1 = compileOverReactProgram( webFilesByName: {'main.dart': code}, @@ -186,7 +189,7 @@ Future getCompiledComponentCode({ final baselineCompiledFile = result.getCompiledDart2jsFile('baseline.dart'); final additionalCompiledFile = - result.getCompiledDart2jsFile('additional.dart'); + result.getCompiledDart2jsFile('additional.dart'); return gitDiffNoIndex( createNormalizedDart2jsFile(baselineCompiledFile).path, @@ -247,15 +250,9 @@ Future getComponentAndUsageSize({ }, overReactDep: overReactDep); final baselineFileSize = - result - .getCompiledDart2jsFile('baseline.dart') - .statSync() - .size; + result.getCompiledDart2jsFile('baseline.dart').statSync().size; final additionalFileSize = - result - .getCompiledDart2jsFile('additional.dart') - .statSync() - .size; + result.getCompiledDart2jsFile('additional.dart').statSync().size; validateFileSize(baselineFileSize); validateFileSize(additionalFileSize); From e56a07c8d6b7e9fecb63e3e2298ffafd35e532a4 Mon Sep 17 00:00:00 2001 From: Greg Littlefield Date: Wed, 3 Dec 2025 12:14:36 -0700 Subject: [PATCH 10/10] Revert "Fix ResizeSensor/DomProps affecting component size diff" This reverts commit 57e39e333500b82054686a57475b129c74e8729e. This didn't actually affect the size diff, since we compare the size of a single component, and not the size of the test case as a whole. As a result, all other code outside of the component is not factored into that size difference. --- benchmark/dart2js_output/source.dart | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/benchmark/dart2js_output/source.dart b/benchmark/dart2js_output/source.dart index d4e6604dd..f0be91105 100644 --- a/benchmark/dart2js_output/source.dart +++ b/benchmark/dart2js_output/source.dart @@ -6,7 +6,7 @@ String componentBenchmark({ import 'package:over_react/over_react.dart'; part '{{PART_PATH}}';'''); - final mainStatements = StringBuffer(); + final mainStatements = StringBuffer()..writeln(mainAntiTreeShakingStatements); for (var i = 0; i < componentCount; i++) { final componentName = 'Foo$i'; final mixinName = '${componentName}Props'; @@ -22,9 +22,7 @@ String componentBenchmark({ } UiFactory<$propsName> $componentName = uiFunction((props) { ${propReadStatements(propNames)} - // Prevent tree-shaking of metadata and getPropKey generated impls final consumedProps = props.staticMeta.forMixins({$mixinName}); - props.getPropKey((p) => p.id); return (Dom.div() ..addUnconsumedProps(props, consumedProps) ..modifyProps(props.addPropsToForward()) @@ -45,6 +43,12 @@ String componentBenchmark({ return fileSource.toString(); } +const mainAntiTreeShakingStatements = ''' + (Dom.div()..id = '1')(); // Other props class, DomProps + ResizeSensor()(); // class component, legacy component, PropsMeta used in propTypes + ResizeSensor().getPropKey((p) => p.id); // getPropKey generated impls +'''; + String propDeclarations(Map propTypesByName) { return propTypesByName .mapEntries((name, type) => 'late $type $name;')