From 245f8df70c873ea47a11d3e3f3a0878fc3e98e65 Mon Sep 17 00:00:00 2001 From: Kevin Moore Date: Tue, 3 Nov 2026 22:13:19 +0000 Subject: [PATCH 1/3] docs(rfc): add RFC 310.0000 - Size-Aware Design Systems and Tree-Shakable Theme Defaults - Documents breaking the `Theme.build` -> `InheritedCupertinoTheme` -> `Theme.of` -> `CupertinoBasedMaterialThemeData` -> `ColorScheme.fromSeed` retention cycle (`flutter/packages#13515`). - Documents migrating `ThemeData`'s 47 component theme properties to Type Flow Analysis (TFA)-foldable lazy backing fields (`flutter/packages#13516`) with zero breaking changes. - Reports controlled A/B binary size reductions across Native Linux x64 AOT (`libapp.so`: `-180.2 KB` / `-5.79%`), WebAssembly (`main.dart.wasm`: `-81.3 KB` raw / `-45.9 KB` code section), and Web JS (`main.dart.js`: `-34.2 KB`). - Outlines Phase 2 tree-shaking patterns (`!kIsWeb` operand order, `.adaptive` / variant enum switches, `PageTransitionsTheme`, `ServicesBinding`, and `Tween.lerp` dynamic operator dispatch) and deterministic presubmit symbol-retention regression tests. --- rfc/310.0000-size-aware-design-systems.md | 164 ++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 rfc/310.0000-size-aware-design-systems.md diff --git a/rfc/310.0000-size-aware-design-systems.md b/rfc/310.0000-size-aware-design-systems.md new file mode 100644 index 0000000..20e092b --- /dev/null +++ b/rfc/310.0000-size-aware-design-systems.md @@ -0,0 +1,164 @@ +--- +type: rfc +rfc: '310.0000' +title: Size-Aware Design Systems and Tree-Shakable Theme Defaults +description: Eliminates whole-program tree-shaking retention cycles in Material and Cupertino theming across native AOT, WebAssembly, and JavaScript, and introduces deterministic symbol-retention regression tests. +status: draft +created: 2026-11-03T22:08:00Z +updated: 2026-11-03T23:23:00Z +tags: + - 310-design-systems + - 130-widgets + - 420-web +authors: + - https://github.com/kevmoo +--- + +# RFC 310.0000: Size-Aware Design Systems and Tree-Shakable Theme Defaults + +## Overview + +Every Flutter app using `MaterialApp` pays a fixed binary-size tax for design-system subsystems and component themes it never uses. Because `Theme.build`, `Theme.of`, and `ThemeData` eagerly instantiate and cross-reference `CupertinoThemeData`, `ColorScheme.fromSeed`, and all 47 component theme classes (`DatePickerThemeData`, `DataTableThemeData`, `TimePickerThemeData`, etc.), Dart's whole-program tree-shakers cannot prune unused design-system code across native AOT, WebAssembly, or JavaScript builds. + +This RFC proposes: + +1. **Tree-shakable design-system boundaries**: Break the `Theme.of` -> `ColorScheme.fromSeed` retention cycle and migrate `ThemeData`'s 47 component themes to Type Flow Analysis (TFA)-foldable lazy backing fields with zero breaking changes. +2. **Follow-on framework and engine patterns**: Eliminate five additional categories of tree-shaking retention anti-patterns uncovered by basic-block coverage profiling. +3. **Size-aware testing and CI guardrails**: Add deterministic presubmit symbol-retention tests in `flutter/packages` and `flutter/flutter` alongside postsubmit Wasm size tracking. + +### Empirical impact of initial prototype changes + +Measured on [kevmoo/slide_puzzle](https://github.com/kevmoo/slide_puzzle) (a 15-puzzle app using `MaterialApp`, `ElevatedButton`, `IconButton`, `Checkbox`, and `TabBar` across 3 animated themes) with prototype PRs [flutter/packages#13515](https://github.com/flutter/packages/pull/13515) and [flutter/packages#13516](https://github.com/flutter/packages/pull/13516): + +| Target Platform & Compiler | Stock `material_ui` | + `Theme.of` Cycle Fix | + Lazy `ThemeData` Sub-Themes | Net Reduction | +| :--- | ---: | ---: | ---: | ---: | +| **Native Linux x64 AOT (`libapp.so`, `gen_snapshot`)** | `3,113,944 B` | `3,015,640 B` (`-98,304 B`) | `2,933,720 B` | **`-180,224 B` (`-5.79%`)** | +| **WebAssembly (`main.dart.wasm`, `dart2wasm`)** | `2,497,700 B` | `2,453,498 B` (`-44,202 B`) | `2,416,445 B` | **`-81,255 B` (`-3.25%`)** | +| **WebAssembly Code Section (`main.dart.wasm`)** | `1,003,395 B` (`8,801` fns) | `984,868 B` (`-18,527 B`) | `957,529 B` (`8,517` fns) | **`-45,866 B` (`-4.57%`, `-284` fns)** | +| **Web JavaScript (`main.dart.js`, `dart2js -O4`)** | `2,112,157 B` | `2,091,412 B` (`-20,745 B`) | `2,077,939 B` | **`-34,218 B` (`-1.62%`)** | + +## Goals + +* Eliminate unreferenced `package:material_color_utilities` and unused component `*ThemeData` classes from compiled binaries. +* Preserve 100% of public API signatures, non-nullable `ThemeData` getters, `ThemeData.lerp` behavior, and `ThemeData() == ThemeData(fooTheme: const FooThemeData())` equality invariants. +* Establish reusable coding patterns for `flutter/packages` and `flutter/flutter` that cooperate with Dart VM TFA (`pkg/vm/lib/transformations/type_flow/`) and `dart2js` closed-world analysis. +* Introduce deterministic presubmit symbol-retention tests that fail when an unreferenced subsystem is rooted by `MaterialApp` or `ThemeData`. + +### Non-goals + +* Changing public `ThemeData` property types from non-nullable (`CardThemeData`) to nullable (`CardThemeData?`). +* Requiring users to opt into tree-shaking via new flags or alternative `ThemeData` constructors. +* Enforcing hard byte-count thresholds on PR presubmits. + +## Background + +### Shared AOT and Wasm tree-shaking pipeline + +Both the native Dart VM AOT compiler (`gen_snapshot` in `pkg/vm/lib/kernel_front_end.dart`, targeting Android, iOS, macOS, Linux, and Windows) and `dart2wasm` (`pkg/dart2wasm/lib/compile.dart`) run the same whole-program Kernel TFA pass (`package:vm/transformations/type_flow/transformer.dart`), while `dart2js` runs closed-world type inference (`pkg/compiler/lib/src/universe/`) from the same reachability roots. Because 64-bit native machine code (`x86_64` and `arm64`) carries wider instruction encodings, object pool entries, stack maps, and pre-serialized `const` heap snapshots than stack-machine Wasm bytecode, pruning a reachable class graph in Kernel TFA yields an even larger byte reduction in native `libapp.so` / `App.framework` than in `.wasm`. + +### Coverage-guided discovery + +Basic-block coverage profiling (`wasm_walk`) of release `.wasm` builds across [kevmoo/slide_puzzle](https://github.com/kevmoo/slide_puzzle), [kevmoo/flutter-wasm-compare](https://github.com/kevmoo/flutter-wasm-compare), and [flutter_gallery](https://github.com/flutter-team-archive/gallery) showed that `51.0%` to `62.9%` of compiled code bytes remain unexecuted across all interactive states, with `72.0%` to `82.9%` of unhit bytes residing in `0%-hit` functions rooted by `Theme.of`, `ThemeData`, and five framework and engine initialization patterns. + +## Technical details + +### Breaking the Theme.of to ColorScheme.fromSeed cycle + +In stock `material_ui`, a cycle between `Theme.build` and `Theme.of` retains `package:material_color_utilities` (`146` functions) in every `MaterialApp`, even when the app supplies a `const ColorScheme` and never calls `ColorScheme.fromSeed`: + +```mermaid +flowchart LR + TB["Theme.build"] -->|"wraps child in"| CT["CupertinoTheme\n(InheritedCupertinoTheme)"] + CT -->|"marks allocated in TFA"| TO["Theme.of(context)"] + TO -->|"fallback branch"| CB["CupertinoBasedMaterialThemeData"] + CB -->|"calls"| CS["ColorScheme.fromSeed"] + CS -->|"retains 146 fns"| MCU["package:material_color_utilities\n(Hct, Cam16, TonalPalette)"] +``` + +Three changes in [flutter/packages#13515](https://github.com/flutter/packages/pull/13515) break this cycle: + +* **Lazy `InheritedCupertinoTheme` lookup in `Theme.of`**: Call `context.dependOnInheritedWidgetOfExactType()` only when `inheritedTheme == null`, avoiding both the unconditional TFA reachability root and a redundant `InheritedElement` registration on every `Theme.of(context)` call. +* **Preserve `NoDefaultCupertinoThemeData` in `MaterialBasedCupertinoThemeData`**: Replace `(materialTheme.cupertinoOverrideTheme ?? const CupertinoThemeData()).noDefault()` with `materialTheme.cupertinoOverrideTheme?.noDefault() ?? const NoDefaultCupertinoThemeData()` so `Theme.build` never allocates a base `CupertinoThemeData`. +* **Short-circuit `MaterialBasedCupertinoThemeData` in `CupertinoBasedMaterialThemeData`**: Pattern-match `MaterialBasedCupertinoThemeData() => themeData._materialTheme` in `CupertinoBasedMaterialThemeData` so TFA proves `themeData` is exclusively `MaterialBasedCupertinoThemeData` in Material-only apps and prunes `ColorScheme.fromSeed` (`-99.86%` of `material_color_utilities` in Wasm). + +### Lazy ThemeData component theme backing fields + +During the null-safety migration ([flutter/flutter#66858](https://github.com/flutter/flutter/pull/66858)), `ThemeData`'s 47 component theme getters were kept non-nullable (`final CardThemeData cardTheme;`) so widgets can read `Theme.of(context).cardTheme.color` without `?.`, forcing `factory ThemeData` to eagerly evaluate `cardTheme ??= const CardThemeData()` for all 47 sub-themes ([flutter/flutter#72643](https://github.com/flutter/flutter/issues/72643)). To let TFA prune unused `*ThemeData` classes without changing public getter types ([flutter/packages#13516](https://github.com/flutter/packages/pull/13516)), `ThemeData` stores each component theme in a private nullable field (`_fooTheme`) that defaults to `null`: + +```dart +class ThemeData with Diagnosticable { + const ThemeData.raw({required FooThemeData? fooTheme}) : _fooTheme = fooTheme; + + final FooThemeData? _fooTheme; + FooThemeData get fooTheme => _fooTheme ?? const FooThemeData(); + + static ThemeData lerp(ThemeData a, ThemeData b, double t) => ThemeData.raw( + fooTheme: a._fooTheme == null && b._fooTheme == null + ? null + : FooThemeData.lerp(a.fooTheme, b.fooTheme, t), + ); + + @override + bool operator ==(Object other) => + other is ThemeData && + ((other._fooTheme == null && _fooTheme == null) || other.fooTheme == fooTheme); + + @override + int get hashCode => Object.hashAll([ + if (_fooTheme != null && _fooTheme != const FooThemeData()) _fooTheme, + ]); +} +``` + +* **Factory construction (`factory ThemeData`)**: Passes `fooTheme: fooTheme` to `ThemeData.raw` without `??= const FooThemeData()`, so TFA infers static type `Null` for `_fooTheme` when omitted. +* **Interpolation (`ThemeData.lerp`)**: When `_fooTheme` is `Null`, TFA folds `a._fooTheme == null && b._fooTheme == null` to `true` and strips `FooThemeData.lerp` (shrinking `ThemeDataTween.lerp` in `AnimatedTheme` by `-78.3%`, from `9,559 B` to `2,070 B` in Wasm); when customized on one side, passing `a.fooTheme` and `b.fooTheme` interpolates against `const FooThemeData()`, preserving non-null defaults on `InputDecorationThemeData` and `ScrollbarThemeData`. +* **Equality (`ThemeData.operator ==`)**: TFA folds `other._fooTheme == null && _fooTheme == null` to `true` when unused (stripping `83` `*ThemeData.==` and `hashCode` methods), while `other.fooTheme == fooTheme` preserves `ThemeData() == ThemeData(fooTheme: const FooThemeData())`. +* **Hashing (`ThemeData.hashCode`)**: TFA folds `_fooTheme != null` to `false` when unused, and `_fooTheme != const FooThemeData()` hashes an explicit default instance identically to `null`. + +### Follow-on framework and engine patterns + +Auditing the remaining `547.8 KB` of unhit Wasm code in [kevmoo/slide_puzzle](https://github.com/kevmoo/slide_puzzle) identified five follow-on categories (`~89 KB` Wasm / `~190+ KB` native AOT): + +| Category | Pattern & Retention Root | Unhit Wasm ([kevmoo/slide_puzzle](https://github.com/kevmoo/slide_puzzle)) | Concrete Fix | +| :--- | :--- | ---: | :--- | +| **1. Non-folded `!kIsWeb` guards** | `theme_data.dart` writes `platform == TargetPlatform.android && !kIsWeb`, preventing CFE constant-folding and retaining `InkSparkle` + `SkwasmFragmentShader`. | `-3.0 KB` (`16` fns) | Put compile-time constants first (`!kIsWeb && platform == TargetPlatform.android`) so CFE folds `false && E1` before TFA. | +| **2. Private `enum` switch bleed** | `Checkbox` (`_CheckboxType`), `Slider` (`_SliderType`), and `IconButton` (`_IconButtonVariant`) switch on a private enum in `build`; TFA widens `Enum.index` to `int`, retaining `CupertinoCheckbox`, `CupertinoSlider`, and unused M3 variants. | `-6.7 KB` to `-9.3 KB` (`40–68` fns) | Redirect `.adaptive` and variant constructors to private subclasses (`= _AdaptiveCheckbox`, matching `AlertDialog.adaptive = _AdaptiveAlertDialog`). | +| **3. Cross-platform route & channel bleed** | `WidgetsBinding.initInstances` registers mobile `SystemChannels`; `PageTransitionsTheme._defaultBuilders` instantiates all platform builders; `Theme.build` calls `MaterialBasedCupertinoThemeData.resolveFrom`. | `-17.4 KB` (`126` fns; drops 100% of `cupertino_ui` in Material apps) | Guard mobile channels with `if (!kIsWeb)`, resolve default `PageTransitionsBuilder` lazily for the active platform, and defer `CupertinoThemeData.resolveFrom` until `CupertinoTheme.of(context)`. | +| **4. Eager binding & service init** | `ServicesBinding.initInstances` eagerly initializes `RestorationManager`, `TextInput.ensureInitialized()`, and `@Deprecated` `RawKeyboard`; `MaterialApp` mounts `HeroController` when no `Hero` widgets exist. | `-32.1 KB` (`212` fns) | Make `ServicesBinding._restorationManager` `late final`, defer `TextInput` initialization until `TextInput.attach`, and gate `HeroController` on `_HeroState` allocation. | +| **5. Dynamic selector tables** | `Tween.lerp` runs `(begin as dynamic) + ((end as dynamic) - (begin as dynamic)) * t`, retaining `+`, `-`, and `*` on `Matrix4`, `Quaternion`, and `Vector4`; `_JsonStringifier` calls `(object as dynamic).toJson()`, retaining `SemanticsNode.toJson`. | `-19.5 KB` (`86` fns) | Specialize `Tween.lerp` (or intersect `DynamicDispatchTable.build` with TFA receiver type cones), and rename debug-only `SemanticsNode.toJson` to `toDiagnosticsJson`. | + +## Testing + +### Presubmit symbol-retention tests + +Today, `flutter/packages` (`web_build_all_packages.yaml`) only checks exit code `0` on an `all_packages` bundle, and `flutter/flutter` (`_runWebTreeshakeTest` in `dev/bots/suite_runners/run_web_tests.dart`) only checks 5 `timeline.dart` classes under `dart2js --profile`: + +* **In `flutter/packages` (`packages/material_ui/test/tree_shaking_test.dart`)**: Compile a minimal `MaterialApp(home: Scaffold(body: Text('hello')))` fixture (`--wasm --no-strip-wasm` or `--analyze-size`) and assert that emitted symbol tables omit `package:material_color_utilities` (`Hct`, `Cam16`, `TonalPalette`), unused component themes (`DatePickerThemeData`, `TimePickerThemeData`, `DataTableThemeData`, `SliderThemeData`, `BottomSheetThemeData`) and their `.lerp()` methods, `CupertinoCheckbox` / `CupertinoAlertDialog`, and `InkSparkle` on Web. +* **In `flutter/flutter` (`dev/bots/suite_runners/run_web_tests.dart`)**: Extend `_runWebTreeshakeTest()` on `treeshaking_main.dart` to verify both `dart2js` and `dart2wasm` outputs against the same symbol non-retention list. + +### Postsubmit Wasm and native size tracking + +Today, `Linux web_size__compile_test` (`dev/devicelab/lib/tasks/perf_tests.dart`) builds without `--wasm`, recording `main.dart.js` and `canvaskit.wasm` but never `main.dart.wasm`: + +* **Add `--wasm` to `WebCompileTest` (`dev/devicelab/lib/tasks/perf_tests.dart`)**: Extend `Linux web_size__compile_test` on `hello_world`, `basic_material_app`, and `flutter_gallery` to build with `--wasm` and report `main.dart.wasm` (uncompressed and gzipped) and `skwasm.wasm` metrics to Cocoon / Skia Perf. +* **Track `basic_material_app` across native and Web**: Ensure `basic_material_app_*__compile` benchmarks track both `libapp.so` and `main.dart.wasm` as `packages/material_ui` and `packages/cupertino_ui` decouple into `flutter/packages`. + +## Rollout + +1. **Phase 1 (In review)**: Land [flutter/packages#13515](https://github.com/flutter/packages/pull/13515) (`Theme.of` cycle fix) and [flutter/packages#13516](https://github.com/flutter/packages/pull/13516) (lazy `ThemeData` component theme backing fields) in `flutter/packages`, and mirror both to `packages/flutter/lib/src/material/` in `flutter/flutter`. +2. **Phase 2 (CI guardrails)**: Land `packages/material_ui/test/tree_shaking_test.dart` in `flutter/packages` and extend `_runWebTreeshakeTest` and `WebCompileTest` (`--wasm`) in `flutter/flutter`. +3. **Phase 3 (Follow-on framework and engine PRs)**: Land the five follow-on categories as independent PRs, adding each newly tree-shaken symbol to the presubmit non-retention list. + +## Alternatives considered + +### Make ThemeData component theme getters nullable + +Making all 47 component theme getters on `ThemeData` return nullable types (`CardThemeData? get cardTheme`) was discussed in [flutter/flutter#72643](https://github.com/flutter/flutter/issues/72643). That approach was rejected because it breaks every widget and app reading `Theme.of(context).cardTheme.color` without `?.`, whereas private nullable backing fields (`_fooTheme`) achieve the same TFA dead-code elimination with zero API breakage. + +### Pass nullable backing fields directly to FooThemeData.lerp + +Passing `FooThemeData.lerp(a._fooTheme, b._fooTheme, t)` directly when one side is `null` was rejected because component themes like `InputDecorationThemeData` and `ScrollbarThemeData` define non-null field defaults in `const FooThemeData()` that are lost when interpolating from `null`. Guarding with `a._fooTheme == null && b._fooTheme == null ? null : FooThemeData.lerp(a.fooTheme, b.fooTheme, t)` folds the branch away when `_fooTheme` is unused while still interpolating against `const FooThemeData()` whenever either theme is customized. + +### Enforce hard byte-size thresholds in presubmit CI + +Failing PR presubmits when `main.dart.wasm` or `libapp.so` exceeds a fixed byte count was rejected because raw byte totals shift across Dart SDK rolls, compiler inlining heuristic changes, and unrelated corelib edits. Asserting symbol non-retention on presubmit verifies the tree-shaking invariant deterministically, while continuous byte-size tracking runs in postsubmit Skia Perf benchmarks. From ac5de47ff54fe7d19b41b99c0e0cb3e1b4333c5e Mon Sep 17 00:00:00 2001 From: Kevin Moore Date: Wed, 11 Nov 2026 04:26:18 +0000 Subject: [PATCH 2/3] docs(rfc): align RFC 310.0000 with template, scope to material_ui, and add DevTools benchmark - Align structure and tags (`310-design-systems`) with `000.0003-rfc-template.md`. - Scope proposal and CI guardrails exclusively to `package:material_ui` in `flutter/packages`, moving follow-up framework/engine/compiler items to `Non-goals`. - Add `flutter/devtools` (`packages/devtools_app`) Wasm (`-22 KB` / `-0.4%`) and `dart2js` (`+8 KB` / `+0.1%`) benchmark results and document the widget-coverage, `TextField`, and `debugFillProperties` boundaries. --- rfc/310.0000-size-aware-design-systems.md | 118 +++++++++++----------- 1 file changed, 57 insertions(+), 61 deletions(-) diff --git a/rfc/310.0000-size-aware-design-systems.md b/rfc/310.0000-size-aware-design-systems.md index 20e092b..7d93bda 100644 --- a/rfc/310.0000-size-aware-design-systems.md +++ b/rfc/310.0000-size-aware-design-systems.md @@ -2,14 +2,12 @@ type: rfc rfc: '310.0000' title: Size-Aware Design Systems and Tree-Shakable Theme Defaults -description: Eliminates whole-program tree-shaking retention cycles in Material and Cupertino theming across native AOT, WebAssembly, and JavaScript, and introduces deterministic symbol-retention regression tests. +description: Eliminates whole-program tree-shaking retention cycles in package:material_ui across native AOT and WebAssembly for apps using a subset of Material widgets, and introduces deterministic symbol-retention regression tests. status: draft created: 2026-11-03T22:08:00Z -updated: 2026-11-03T23:23:00Z +updated: 2026-11-11T03:45:00Z tags: - 310-design-systems - - 130-widgets - - 420-web authors: - https://github.com/kevmoo --- @@ -18,53 +16,44 @@ authors: ## Overview -Every Flutter app using `MaterialApp` pays a fixed binary-size tax for design-system subsystems and component themes it never uses. Because `Theme.build`, `Theme.of`, and `ThemeData` eagerly instantiate and cross-reference `CupertinoThemeData`, `ColorScheme.fromSeed`, and all 47 component theme classes (`DatePickerThemeData`, `DataTableThemeData`, `TimePickerThemeData`, etc.), Dart's whole-program tree-shakers cannot prune unused design-system code across native AOT, WebAssembly, or JavaScript builds. +In `package:material_ui`, `Theme.build`, `Theme.of`, and `ThemeData` eagerly instantiate and cross-reference `CupertinoThemeData`, `ColorScheme.fromSeed`, and all 47 component theme classes. Because of those reachability roots, Dart's whole-program tree-shakers cannot prune unused design-system code even when an application only mounts a few Material widgets. -This RFC proposes: +This RFC proposes decoupling the `Theme.of` -> `ColorScheme.fromSeed` fallback cycle ([flutter/packages#13515](https://github.com/flutter/packages/pull/13515)), migrating `ThemeData`'s 47 component themes to Type Flow Analysis (TFA)-foldable lazy backing fields ([flutter/packages#13516](https://github.com/flutter/packages/pull/13516)), and adding deterministic presubmit symbol-retention tests in `flutter/packages`. -1. **Tree-shakable design-system boundaries**: Break the `Theme.of` -> `ColorScheme.fromSeed` retention cycle and migrate `ThemeData`'s 47 component themes to Type Flow Analysis (TFA)-foldable lazy backing fields with zero breaking changes. -2. **Follow-on framework and engine patterns**: Eliminate five additional categories of tree-shaking retention anti-patterns uncovered by basic-block coverage profiling. -3. **Size-aware testing and CI guardrails**: Add deterministic presubmit symbol-retention tests in `flutter/packages` and `flutter/flutter` alongside postsubmit Wasm size tracking. - -### Empirical impact of initial prototype changes - -Measured on [kevmoo/slide_puzzle](https://github.com/kevmoo/slide_puzzle) (a 15-puzzle app using `MaterialApp`, `ElevatedButton`, `IconButton`, `Checkbox`, and `TabBar` across 3 animated themes) with prototype PRs [flutter/packages#13515](https://github.com/flutter/packages/pull/13515) and [flutter/packages#13516](https://github.com/flutter/packages/pull/13516): - -| Target Platform & Compiler | Stock `material_ui` | + `Theme.of` Cycle Fix | + Lazy `ThemeData` Sub-Themes | Net Reduction | -| :--- | ---: | ---: | ---: | ---: | -| **Native Linux x64 AOT (`libapp.so`, `gen_snapshot`)** | `3,113,944 B` | `3,015,640 B` (`-98,304 B`) | `2,933,720 B` | **`-180,224 B` (`-5.79%`)** | -| **WebAssembly (`main.dart.wasm`, `dart2wasm`)** | `2,497,700 B` | `2,453,498 B` (`-44,202 B`) | `2,416,445 B` | **`-81,255 B` (`-3.25%`)** | -| **WebAssembly Code Section (`main.dart.wasm`)** | `1,003,395 B` (`8,801` fns) | `984,868 B` (`-18,527 B`) | `957,529 B` (`8,517` fns) | **`-45,866 B` (`-4.57%`, `-284` fns)** | -| **Web JavaScript (`main.dart.js`, `dart2js -O4`)** | `2,112,157 B` | `2,091,412 B` (`-20,745 B`) | `2,077,939 B` | **`-34,218 B` (`-1.62%`)** | +**Who benefits and key trade-offs:** These changes primarily shrink small, focused, or embedded applications (such as mini-apps, landing pages, add-to-app modules, and games) that mount a narrow subset of Material widgets without `TextField` or `ColorScheme.fromSeed` (`-180 KB` / `-5.8%` on Linux x64 AOT and `-81 KB` / `-3.3%` on WebAssembly in [kevmoo/slide_puzzle](https://github.com/kevmoo/slide_puzzle)). Widget-dense applications that mount most of the Material catalog (such as [flutter/devtools](https://github.com/flutter/devtools)) see smaller WebAssembly savings (`-22 KB` / `-0.4%`) and currently incur a minor `dart2js` size increase (`+8 KB` / `+0.1%`). ## Goals -* Eliminate unreferenced `package:material_color_utilities` and unused component `*ThemeData` classes from compiled binaries. +* Prune unreferenced `package:material_color_utilities` and unused component `*ThemeData` classes from native AOT (`gen_snapshot`) and WebAssembly (`dart2wasm`) binaries in `package:material_ui` when those widgets and color solvers are unused. * Preserve 100% of public API signatures, non-nullable `ThemeData` getters, `ThemeData.lerp` behavior, and `ThemeData() == ThemeData(fooTheme: const FooThemeData())` equality invariants. -* Establish reusable coding patterns for `flutter/packages` and `flutter/flutter` that cooperate with Dart VM TFA (`pkg/vm/lib/transformations/type_flow/`) and `dart2js` closed-world analysis. -* Introduce deterministic presubmit symbol-retention tests that fail when an unreferenced subsystem is rooted by `MaterialApp` or `ThemeData`. +* Introduce deterministic presubmit symbol-retention tests in `flutter/packages` so tree-shaking boundaries cannot silently regress. ### Non-goals -* Changing public `ThemeData` property types from non-nullable (`CardThemeData`) to nullable (`CardThemeData?`). -* Requiring users to opt into tree-shaking via new flags or alternative `ThemeData` constructors. +* Backporting these changes to `packages/flutter/lib/src/material/` in the Flutter SDK, which is slated for deprecation in favor of `package:material_ui` ([flutter/flutter#101479](https://github.com/flutter/flutter/issues/101479)). +* Changing public `ThemeData` property types from non-nullable (`CardThemeData`) to nullable (`CardThemeData?`), or requiring opt-in flags or new `ThemeData` constructors. * Enforcing hard byte-count thresholds on PR presubmits. +* Follow-up tree-shaking opportunities identified during profiling (tracked for separate investigation rather than proposed in this RFC): + * **Decoupling `TextField` and `CupertinoBasedMaterialThemeData` from `ColorScheme.fromSeed`**: Avoiding the perceptual CAM16/HCT solver in `CupertinoBasedMaterialThemeData` (or gating `TextField`'s iOS/macOS `CupertinoTheme.of` lookup) so `TextField`-using apps can also drop `package:material_color_utilities`. + * **Eliminating the `dart2js` overhead on inspector-enabled apps**: Guarding `_fooTheme == null` inside `ThemeData.debugFillProperties` (and avoiding eager `ThemeData.fallback()` instantiation) so `dart2js` can prune unused `*ThemeData` classes alongside `dart2wasm`. + * **Other `material_ui` and `cupertino_ui` retention roots**: Placing `!kIsWeb` before runtime `TargetPlatform` checks so the Dart front-end constant-folds `InkSparkle` on Web, replacing private `enum` switches in `.adaptive` / M3 variants (`Checkbox`, `Slider`, `IconButton`) with private subclasses, and lazily resolving `PageTransitionsTheme` builders. + * **Core `flutter/flutter`, `web_ui`, and `dart2wasm` candidates**: Deferring eager `ServicesBinding` (`RestorationManager`, `TextInput`) and `HeroController` initialization, gating mobile-only `SystemChannels` on Web, and specializing `Tween.lerp` to avoid dynamic `+`/`-`/`*` dispatch tables. ## Background ### Shared AOT and Wasm tree-shaking pipeline -Both the native Dart VM AOT compiler (`gen_snapshot` in `pkg/vm/lib/kernel_front_end.dart`, targeting Android, iOS, macOS, Linux, and Windows) and `dart2wasm` (`pkg/dart2wasm/lib/compile.dart`) run the same whole-program Kernel TFA pass (`package:vm/transformations/type_flow/transformer.dart`), while `dart2js` runs closed-world type inference (`pkg/compiler/lib/src/universe/`) from the same reachability roots. Because 64-bit native machine code (`x86_64` and `arm64`) carries wider instruction encodings, object pool entries, stack maps, and pre-serialized `const` heap snapshots than stack-machine Wasm bytecode, pruning a reachable class graph in Kernel TFA yields an even larger byte reduction in native `libapp.so` / `App.framework` than in `.wasm`. +Both the native Dart VM AOT compiler (`gen_snapshot`, targeting Android, iOS, macOS, Linux, and Windows) and `dart2wasm` run the same whole-program Kernel Type Flow Analysis (TFA) pass prior to code generation, while `dart2js` runs closed-world AST resolution followed by global type inference. Because 64-bit native machine code carries wider instruction encodings, object pool entries, stack maps, and pre-serialized `const` heap snapshots than stack-machine Wasm bytecode, pruning a reachable class graph in Kernel TFA yields an even larger byte reduction in native `libapp.so` / `App.framework` than in `.wasm`. ### Coverage-guided discovery -Basic-block coverage profiling (`wasm_walk`) of release `.wasm` builds across [kevmoo/slide_puzzle](https://github.com/kevmoo/slide_puzzle), [kevmoo/flutter-wasm-compare](https://github.com/kevmoo/flutter-wasm-compare), and [flutter_gallery](https://github.com/flutter-team-archive/gallery) showed that `51.0%` to `62.9%` of compiled code bytes remain unexecuted across all interactive states, with `72.0%` to `82.9%` of unhit bytes residing in `0%-hit` functions rooted by `Theme.of`, `ThemeData`, and five framework and engine initialization patterns. +Basic-block coverage profiling of release WebAssembly builds across [kevmoo/slide_puzzle](https://github.com/kevmoo/slide_puzzle), [kevmoo/flutter-wasm-compare](https://github.com/kevmoo/flutter-wasm-compare), and [flutter_gallery](https://github.com/flutter-team-archive/gallery) showed that over half of compiled code bytes in minimal apps belong to functions that are never entered during interactive sessions. Within the design-system layer, tracing why those unexecuted functions survived compilation exposed two primary retention hubs in `package:material_ui`: `Theme.of` and `ThemeData`. ## Technical details ### Breaking the Theme.of to ColorScheme.fromSeed cycle -In stock `material_ui`, a cycle between `Theme.build` and `Theme.of` retains `package:material_color_utilities` (`146` functions) in every `MaterialApp`, even when the app supplies a `const ColorScheme` and never calls `ColorScheme.fromSeed`: +In stock `material_ui`, a cycle between `Theme.build` and `Theme.of` retains `package:material_color_utilities` in every `MaterialApp`, even when the app uses `ThemeData.light()` / `ThemeData.dark()` (`_defaultM3ColorScheme`) or a custom `const ColorScheme` and never calls `ColorScheme.fromSeed`: ```mermaid flowchart LR @@ -72,22 +61,22 @@ flowchart LR CT -->|"marks allocated in TFA"| TO["Theme.of(context)"] TO -->|"fallback branch"| CB["CupertinoBasedMaterialThemeData"] CB -->|"calls"| CS["ColorScheme.fromSeed"] - CS -->|"retains 146 fns"| MCU["package:material_color_utilities\n(Hct, Cam16, TonalPalette)"] + CS -->|"retains"| MCU["package:material_color_utilities"] ``` -Three changes in [flutter/packages#13515](https://github.com/flutter/packages/pull/13515) break this cycle: +Three changes in [flutter/packages#13515](https://github.com/flutter/packages/pull/13515) break this cycle when `CupertinoThemeData` is not independently instantiated: * **Lazy `InheritedCupertinoTheme` lookup in `Theme.of`**: Call `context.dependOnInheritedWidgetOfExactType()` only when `inheritedTheme == null`, avoiding both the unconditional TFA reachability root and a redundant `InheritedElement` registration on every `Theme.of(context)` call. * **Preserve `NoDefaultCupertinoThemeData` in `MaterialBasedCupertinoThemeData`**: Replace `(materialTheme.cupertinoOverrideTheme ?? const CupertinoThemeData()).noDefault()` with `materialTheme.cupertinoOverrideTheme?.noDefault() ?? const NoDefaultCupertinoThemeData()` so `Theme.build` never allocates a base `CupertinoThemeData`. -* **Short-circuit `MaterialBasedCupertinoThemeData` in `CupertinoBasedMaterialThemeData`**: Pattern-match `MaterialBasedCupertinoThemeData() => themeData._materialTheme` in `CupertinoBasedMaterialThemeData` so TFA proves `themeData` is exclusively `MaterialBasedCupertinoThemeData` in Material-only apps and prunes `ColorScheme.fromSeed` (`-99.86%` of `material_color_utilities` in Wasm). +* **Short-circuit `MaterialBasedCupertinoThemeData` in `CupertinoBasedMaterialThemeData`**: Pattern-match `MaterialBasedCupertinoThemeData() => themeData._materialTheme` in `CupertinoBasedMaterialThemeData` so TFA proves `themeData` is exclusively `MaterialBasedCupertinoThemeData` when no standalone `CupertinoThemeData` is allocated and prunes `ColorScheme.fromSeed`. ### Lazy ThemeData component theme backing fields -During the null-safety migration ([flutter/flutter#66858](https://github.com/flutter/flutter/pull/66858)), `ThemeData`'s 47 component theme getters were kept non-nullable (`final CardThemeData cardTheme;`) so widgets can read `Theme.of(context).cardTheme.color` without `?.`, forcing `factory ThemeData` to eagerly evaluate `cardTheme ??= const CardThemeData()` for all 47 sub-themes ([flutter/flutter#72643](https://github.com/flutter/flutter/issues/72643)). To let TFA prune unused `*ThemeData` classes without changing public getter types ([flutter/packages#13516](https://github.com/flutter/packages/pull/13516)), `ThemeData` stores each component theme in a private nullable field (`_fooTheme`) that defaults to `null`: +During the null-safety migration ([flutter/flutter#66858](https://github.com/flutter/flutter/pull/66858)) and subsequent component theme normalization ([flutter/flutter#91772](https://github.com/flutter/flutter/issues/91772)), `ThemeData`'s 47 component theme getters were kept non-nullable (`final CardThemeData cardTheme;`) so widgets can read `Theme.of(context).cardTheme.color` without `?.`, forcing `factory ThemeData` to eagerly evaluate `cardTheme ??= const CardThemeData()` for all 47 sub-themes. To let TFA prune unused `*ThemeData` classes without changing public getter types ([flutter/packages#13516](https://github.com/flutter/packages/pull/13516)), `ThemeData` stores each component theme in a private nullable field (`_fooTheme`) that defaults to `null`: ```dart class ThemeData with Diagnosticable { - const ThemeData.raw({required FooThemeData? fooTheme}) : _fooTheme = fooTheme; + const ThemeData.raw({required this._fooTheme}); final FooThemeData? _fooTheme; FooThemeData get fooTheme => _fooTheme ?? const FooThemeData(); @@ -100,8 +89,10 @@ class ThemeData with Diagnosticable { @override bool operator ==(Object other) => - other is ThemeData && - ((other._fooTheme == null && _fooTheme == null) || other.fooTheme == fooTheme); + identical(this, other) || + (other is ThemeData && + ((other._fooTheme == null && _fooTheme == null) || + other.fooTheme == fooTheme)); @override int get hashCode => Object.hashAll([ @@ -110,54 +101,59 @@ class ThemeData with Diagnosticable { } ``` -* **Factory construction (`factory ThemeData`)**: Passes `fooTheme: fooTheme` to `ThemeData.raw` without `??= const FooThemeData()`, so TFA infers static type `Null` for `_fooTheme` when omitted. -* **Interpolation (`ThemeData.lerp`)**: When `_fooTheme` is `Null`, TFA folds `a._fooTheme == null && b._fooTheme == null` to `true` and strips `FooThemeData.lerp` (shrinking `ThemeDataTween.lerp` in `AnimatedTheme` by `-78.3%`, from `9,559 B` to `2,070 B` in Wasm); when customized on one side, passing `a.fooTheme` and `b.fooTheme` interpolates against `const FooThemeData()`, preserving non-null defaults on `InputDecorationThemeData` and `ScrollbarThemeData`. -* **Equality (`ThemeData.operator ==`)**: TFA folds `other._fooTheme == null && _fooTheme == null` to `true` when unused (stripping `83` `*ThemeData.==` and `hashCode` methods), while `other.fooTheme == fooTheme` preserves `ThemeData() == ThemeData(fooTheme: const FooThemeData())`. -* **Hashing (`ThemeData.hashCode`)**: TFA folds `_fooTheme != null` to `false` when unused, and `_fooTheme != const FooThemeData()` hashes an explicit default instance identically to `null`. +When an application leaves a component theme unconfigured, `factory ThemeData` passes `null` through to `ThemeData.raw`, allowing TFA to infer static type `Null` for `_fooTheme`. Across `ThemeData.lerp` (or step-interpolation for themes like `InputDecorationThemeData` that lack `.lerp`), `operator ==`, and `hashCode`, TFA constant-folds the `_fooTheme == null` guards and strips the dead `FooThemeData` methods, while `dart2wasm` and `gen_snapshot` inline uncustomized getter reads directly to `const FooThemeData()`. When customized on either side, `lerp` and `operator ==` fall back through the public getter to `const FooThemeData()`, preserving default-equivalence (`ThemeData() == ThemeData(fooTheme: const FooThemeData())`) and non-null sub-theme defaults. + +### Measured impact, trade-offs, and limitations + +1. **Small widget-subset app ([kevmoo/slide_puzzle](https://github.com/kevmoo/slide_puzzle))** — uses a custom `const ColorScheme` and 4 Material widgets (`ElevatedButton`, `IconButton`, `Checkbox`, `TabBar`) across 3 animated themes: + + | Target Platform & Compiler | Stock `material_ui` | + `#13515` (`Theme.of`) | + `#13515` & `#13516` | Net Delta | + | :--- | ---: | ---: | ---: | ---: | + | **Native Linux x64 AOT (`libapp.so`, `gen_snapshot`)** | `3.11 MB` | `3.02 MB` (`-98 KB`) | `2.93 MB` | **`-180 KB` (`-5.8%`)** | + | **WebAssembly (`main.dart.wasm`, `dart2wasm`)** | `2.50 MB` | `2.45 MB` (`-44 KB`) | `2.42 MB` | **`-81 KB` (`-3.3%`)** | + | **Web JavaScript (`main.dart.js`, `dart2js -O4`)** | `2.11 MB` | `2.09 MB` (`-21 KB`) | `2.08 MB` | **`-34 KB` (`-1.6%`)** | + +2. **Large widget-dense app ([flutter/devtools](https://github.com/flutter/devtools) `packages/devtools_app`)** — multi-screen web app mounting `TextField` and 31 of the 47 Material component themes: -### Follow-on framework and engine patterns + | Target Platform & Compiler | Stock `material_ui` | + `#13515` (`Theme.of`) | + `#13515` & `#13516` | Net Delta | + | :--- | ---: | ---: | ---: | ---: | + | **WebAssembly (`main.dart.wasm`, `dart2wasm`)** | `5.75 MB` | `5.75 MB` (`-1 KB`) | `5.72 MB` | **`-22 KB` (`-0.4%`)** | + | **Web JavaScript (`main.dart.js`, `dart2js -O4`)** | `6.19 MB` | `6.19 MB` (`-3 KB`) | `6.20 MB` | **`+8 KB` (`+0.1%`)** | -Auditing the remaining `547.8 KB` of unhit Wasm code in [kevmoo/slide_puzzle](https://github.com/kevmoo/slide_puzzle) identified five follow-on categories (`~89 KB` Wasm / `~190+ KB` native AOT): +Comparing `slide_puzzle` and `DevTools` defines three clear boundaries of this design: -| Category | Pattern & Retention Root | Unhit Wasm ([kevmoo/slide_puzzle](https://github.com/kevmoo/slide_puzzle)) | Concrete Fix | -| :--- | :--- | ---: | :--- | -| **1. Non-folded `!kIsWeb` guards** | `theme_data.dart` writes `platform == TargetPlatform.android && !kIsWeb`, preventing CFE constant-folding and retaining `InkSparkle` + `SkwasmFragmentShader`. | `-3.0 KB` (`16` fns) | Put compile-time constants first (`!kIsWeb && platform == TargetPlatform.android`) so CFE folds `false && E1` before TFA. | -| **2. Private `enum` switch bleed** | `Checkbox` (`_CheckboxType`), `Slider` (`_SliderType`), and `IconButton` (`_IconButtonVariant`) switch on a private enum in `build`; TFA widens `Enum.index` to `int`, retaining `CupertinoCheckbox`, `CupertinoSlider`, and unused M3 variants. | `-6.7 KB` to `-9.3 KB` (`40–68` fns) | Redirect `.adaptive` and variant constructors to private subclasses (`= _AdaptiveCheckbox`, matching `AlertDialog.adaptive = _AdaptiveAlertDialog`). | -| **3. Cross-platform route & channel bleed** | `WidgetsBinding.initInstances` registers mobile `SystemChannels`; `PageTransitionsTheme._defaultBuilders` instantiates all platform builders; `Theme.build` calls `MaterialBasedCupertinoThemeData.resolveFrom`. | `-17.4 KB` (`126` fns; drops 100% of `cupertino_ui` in Material apps) | Guard mobile channels with `if (!kIsWeb)`, resolve default `PageTransitionsBuilder` lazily for the active platform, and defer `CupertinoThemeData.resolveFrom` until `CupertinoTheme.of(context)`. | -| **4. Eager binding & service init** | `ServicesBinding.initInstances` eagerly initializes `RestorationManager`, `TextInput.ensureInitialized()`, and `@Deprecated` `RawKeyboard`; `MaterialApp` mounts `HeroController` when no `Hero` widgets exist. | `-32.1 KB` (`212` fns) | Make `ServicesBinding._restorationManager` `late final`, defer `TextInput` initialization until `TextInput.attach`, and gate `HeroController` on `_HeroState` allocation. | -| **5. Dynamic selector tables** | `Tween.lerp` runs `(begin as dynamic) + ((end as dynamic) - (begin as dynamic)) * t`, retaining `+`, `-`, and `*` on `Matrix4`, `Quaternion`, and `Vector4`; `_JsonStringifier` calls `(object as dynamic).toJson()`, retaining `SemanticsNode.toJson`. | `-19.5 KB` (`86` fns) | Specialize `Tween.lerp` (or intersect `DynamicDispatchTable.build` with TFA receiver type cones), and rename debug-only `SemanticsNode.toJson` to `toDiagnosticsJson`. | +* **Savings scale inversely with widget catalog coverage**: Any widget an app mounts (`Card.build` -> `Theme.of(context).cardTheme`) evaluates `_cardTheme ?? const CardThemeData()`, keeping `CardThemeData` allocated (though uncustomized `.lerp`, `==`, and `hashCode` methods still drop in TFA). Whole `*ThemeData` libraries only drop for unmounted widgets (`~41` unused sub-themes in `slide_puzzle` vs. `16` unused sub-themes in `DevTools`). +* **Mounting `TextField` currently re-retains `ColorScheme.fromSeed`**: Even though `DevTools` uses `ThemeData.dark()` / `ThemeData.light()` (`_defaultM3ColorScheme`) rather than `ColorScheme.fromSeed`, `TextField` calls `CupertinoTheme.of(context)` inside its `switch (theme.platform)` iOS/macOS branches (which remain live on Web because `theme.platform` is a runtime property). Because `CupertinoTheme.of` falls back to `const CupertinoThemeData()`, TFA marks `CupertinoThemeData` allocated and retains `CupertinoBasedMaterialThemeData` -> `ColorScheme.fromSeed` (`~44 KB` saved on `slide_puzzle` vs. `~1 KB` on `DevTools`). +* **Why `dart2js` increases by `+8 KB` (`+0.1%`) on `DevTools`**: Unlike `dart2wasm` and `gen_snapshot` (which prune `16` unused `*_theme.dart` libraries on `DevTools` via interprocedural TFA), `dart2js` resolves AST references inside reachable methods *before* global type inference runs. Because `DevTools`'s widget inspector keeps `Diagnosticable.debugFillProperties` polymorphically live—and `ThemeData.debugFillProperties` currently calls `ThemeData.fallback()` and reads all 47 public getters unconditionally—`dart2js` retains all 47 `*ThemeData` classes while adding `+8 KB` (`+2 KB` gzipped) for the nullable backing getters and `lerp`/`==`/`hashCode` null guards. ## Testing -### Presubmit symbol-retention tests +### Presubmit symbol-retention tests in `flutter/packages` -Today, `flutter/packages` (`web_build_all_packages.yaml`) only checks exit code `0` on an `all_packages` bundle, and `flutter/flutter` (`_runWebTreeshakeTest` in `dev/bots/suite_runners/run_web_tests.dart`) only checks 5 `timeline.dart` classes under `dart2js --profile`: +Today, `.ci/targets/web_build_all_packages.yaml` in `flutter/packages` only checks exit code `0` on a combined `all_packages` bundle, so re-introducing a retention cycle in `package:material_ui` passes CI undetected: -* **In `flutter/packages` (`packages/material_ui/test/tree_shaking_test.dart`)**: Compile a minimal `MaterialApp(home: Scaffold(body: Text('hello')))` fixture (`--wasm --no-strip-wasm` or `--analyze-size`) and assert that emitted symbol tables omit `package:material_color_utilities` (`Hct`, `Cam16`, `TonalPalette`), unused component themes (`DatePickerThemeData`, `TimePickerThemeData`, `DataTableThemeData`, `SliderThemeData`, `BottomSheetThemeData`) and their `.lerp()` methods, `CupertinoCheckbox` / `CupertinoAlertDialog`, and `InkSparkle` on Web. -* **In `flutter/flutter` (`dev/bots/suite_runners/run_web_tests.dart`)**: Extend `_runWebTreeshakeTest()` on `treeshaking_main.dart` to verify both `dart2js` and `dart2wasm` outputs against the same symbol non-retention list. +* **Add `packages/material_ui/test/tree_shaking_test.dart`**: Compile a minimal `MaterialApp(home: Scaffold(body: Text('hello')))` fixture (`--wasm --no-strip-wasm` or `--analyze-size`) and assert that emitted symbol tables omit `package:material_color_utilities` when `ColorScheme.fromSeed` and `TextField` are unused, as well as unreferenced component `*ThemeData` classes and their `.lerp()` methods. ### Postsubmit Wasm and native size tracking -Today, `Linux web_size__compile_test` (`dev/devicelab/lib/tasks/perf_tests.dart`) builds without `--wasm`, recording `main.dart.js` and `canvaskit.wasm` but never `main.dart.wasm`: +Today, `Linux web_size__compile_test` (`dev/devicelab/lib/tasks/perf_tests.dart` in `flutter/flutter`) builds without `--wasm`, recording `main.dart.js` and `canvaskit.wasm` but never `main.dart.wasm`: -* **Add `--wasm` to `WebCompileTest` (`dev/devicelab/lib/tasks/perf_tests.dart`)**: Extend `Linux web_size__compile_test` on `hello_world`, `basic_material_app`, and `flutter_gallery` to build with `--wasm` and report `main.dart.wasm` (uncompressed and gzipped) and `skwasm.wasm` metrics to Cocoon / Skia Perf. -* **Track `basic_material_app` across native and Web**: Ensure `basic_material_app_*__compile` benchmarks track both `libapp.so` and `main.dart.wasm` as `packages/material_ui` and `packages/cupertino_ui` decouple into `flutter/packages`. +* **Track `material_ui` canary benchmarks across Wasm, JS, and native AOT**: As benchmark apps (`basic_material_app`) migrate from `package:flutter/material.dart` to `package:material_ui`, extend `WebCompileTest` to build with `--wasm` and report `main.dart.wasm` alongside `main.dart.js` and native `libapp.so` to Cocoon / Skia Perf so both Wasm wins and `dart2js` deltas are continuously tracked. ## Rollout -1. **Phase 1 (In review)**: Land [flutter/packages#13515](https://github.com/flutter/packages/pull/13515) (`Theme.of` cycle fix) and [flutter/packages#13516](https://github.com/flutter/packages/pull/13516) (lazy `ThemeData` component theme backing fields) in `flutter/packages`, and mirror both to `packages/flutter/lib/src/material/` in `flutter/flutter`. -2. **Phase 2 (CI guardrails)**: Land `packages/material_ui/test/tree_shaking_test.dart` in `flutter/packages` and extend `_runWebTreeshakeTest` and `WebCompileTest` (`--wasm`) in `flutter/flutter`. -3. **Phase 3 (Follow-on framework and engine PRs)**: Land the five follow-on categories as independent PRs, adding each newly tree-shaken symbol to the presubmit non-retention list. +1. **Phase 1 (In review)**: Land [flutter/packages#13515](https://github.com/flutter/packages/pull/13515) (`Theme.of` cycle fix) and [flutter/packages#13516](https://github.com/flutter/packages/pull/13516) (lazy `ThemeData` component theme backing fields) in `packages/material_ui` (`flutter/packages` only; apps on `package:flutter/material.dart` gain these savings when migrating to `package:material_ui`). +2. **Phase 2 (CI guardrails)**: Land `packages/material_ui/test/tree_shaking_test.dart` in `flutter/packages` and add `--wasm` size tracking for `material_ui` benchmark apps. -## Alternatives considered +## Alternatives Considered ### Make ThemeData component theme getters nullable -Making all 47 component theme getters on `ThemeData` return nullable types (`CardThemeData? get cardTheme`) was discussed in [flutter/flutter#72643](https://github.com/flutter/flutter/issues/72643). That approach was rejected because it breaks every widget and app reading `Theme.of(context).cardTheme.color` without `?.`, whereas private nullable backing fields (`_fooTheme`) achieve the same TFA dead-code elimination with zero API breakage. +Making all 47 component theme getters on `ThemeData` return nullable types (`CardThemeData? get cardTheme`) was rejected because it breaks every widget and app reading `Theme.of(context).cardTheme.color` without `?.`, whereas private nullable backing fields (`_fooTheme`) achieve the same TFA dead-code elimination with zero API breakage. ### Pass nullable backing fields directly to FooThemeData.lerp -Passing `FooThemeData.lerp(a._fooTheme, b._fooTheme, t)` directly when one side is `null` was rejected because component themes like `InputDecorationThemeData` and `ScrollbarThemeData` define non-null field defaults in `const FooThemeData()` that are lost when interpolating from `null`. Guarding with `a._fooTheme == null && b._fooTheme == null ? null : FooThemeData.lerp(a.fooTheme, b.fooTheme, t)` folds the branch away when `_fooTheme` is unused while still interpolating against `const FooThemeData()` whenever either theme is customized. +Passing `FooThemeData.lerp(a._fooTheme, b._fooTheme, t)` directly when one side is `null` was rejected because component themes like `ScrollbarThemeData` and `ChipThemeData` define non-null field defaults in `const FooThemeData()` that are lost when interpolating from `null`. Guarding with `a._fooTheme == null && b._fooTheme == null ? null : FooThemeData.lerp(a.fooTheme, b.fooTheme, t)` folds the branch away when `_fooTheme` is unused while still interpolating against `const FooThemeData()` whenever either theme is customized. ### Enforce hard byte-size thresholds in presubmit CI From f817cf16d90afb2b2236121cb4fabb59b66884a4 Mon Sep 17 00:00:00 2001 From: Kevin Moore Date: Wed, 11 Nov 2026 05:40:05 +0000 Subject: [PATCH 3/3] docs(rfc): link dart-lang/sdk#64908 and document dart2js lerp null-inference widening --- rfc/310.0000-size-aware-design-systems.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rfc/310.0000-size-aware-design-systems.md b/rfc/310.0000-size-aware-design-systems.md index 7d93bda..1f4c6bb 100644 --- a/rfc/310.0000-size-aware-design-systems.md +++ b/rfc/310.0000-size-aware-design-systems.md @@ -5,7 +5,7 @@ title: Size-Aware Design Systems and Tree-Shakable Theme Defaults description: Eliminates whole-program tree-shaking retention cycles in package:material_ui across native AOT and WebAssembly for apps using a subset of Material widgets, and introduces deterministic symbol-retention regression tests. status: draft created: 2026-11-03T22:08:00Z -updated: 2026-11-11T03:45:00Z +updated: 2026-11-11T05:38:00Z tags: - 310-design-systems authors: @@ -35,7 +35,7 @@ This RFC proposes decoupling the `Theme.of` -> `ColorScheme.fromSeed` fallback c * Enforcing hard byte-count thresholds on PR presubmits. * Follow-up tree-shaking opportunities identified during profiling (tracked for separate investigation rather than proposed in this RFC): * **Decoupling `TextField` and `CupertinoBasedMaterialThemeData` from `ColorScheme.fromSeed`**: Avoiding the perceptual CAM16/HCT solver in `CupertinoBasedMaterialThemeData` (or gating `TextField`'s iOS/macOS `CupertinoTheme.of` lookup) so `TextField`-using apps can also drop `package:material_color_utilities`. - * **Eliminating the `dart2js` overhead on inspector-enabled apps**: Guarding `_fooTheme == null` inside `ThemeData.debugFillProperties` (and avoiding eager `ThemeData.fallback()` instantiation) so `dart2js` can prune unused `*ThemeData` classes alongside `dart2wasm`. + * **Fixing `dart2js` field null-inference across `ThemeData.lerp` branches**: Resolving [dart-lang/sdk#64908](https://github.com/dart-lang/sdk/issues/64908) so `dart2js`'s `TypeGraphInferrer` stops widening `_fooTheme` from `[null]` to `[null|FooThemeData]` across the null-guarded `else` arm in `ThemeData.lerp` (an instance-dispatched prototype turns `DevTools`'s `+8 KB` JS delta into `-30 KB` and expands `slide_puzzle`'s JS savings from `-34 KB` to `-87 KB`). * **Other `material_ui` and `cupertino_ui` retention roots**: Placing `!kIsWeb` before runtime `TargetPlatform` checks so the Dart front-end constant-folds `InkSparkle` on Web, replacing private `enum` switches in `.adaptive` / M3 variants (`Checkbox`, `Slider`, `IconButton`) with private subclasses, and lazily resolving `PageTransitionsTheme` builders. * **Core `flutter/flutter`, `web_ui`, and `dart2wasm` candidates**: Deferring eager `ServicesBinding` (`RestorationManager`, `TextInput`) and `HeroController` initialization, gating mobile-only `SystemChannels` on Web, and specializing `Tween.lerp` to avoid dynamic `+`/`-`/`*` dispatch tables. @@ -124,7 +124,7 @@ Comparing `slide_puzzle` and `DevTools` defines three clear boundaries of this d * **Savings scale inversely with widget catalog coverage**: Any widget an app mounts (`Card.build` -> `Theme.of(context).cardTheme`) evaluates `_cardTheme ?? const CardThemeData()`, keeping `CardThemeData` allocated (though uncustomized `.lerp`, `==`, and `hashCode` methods still drop in TFA). Whole `*ThemeData` libraries only drop for unmounted widgets (`~41` unused sub-themes in `slide_puzzle` vs. `16` unused sub-themes in `DevTools`). * **Mounting `TextField` currently re-retains `ColorScheme.fromSeed`**: Even though `DevTools` uses `ThemeData.dark()` / `ThemeData.light()` (`_defaultM3ColorScheme`) rather than `ColorScheme.fromSeed`, `TextField` calls `CupertinoTheme.of(context)` inside its `switch (theme.platform)` iOS/macOS branches (which remain live on Web because `theme.platform` is a runtime property). Because `CupertinoTheme.of` falls back to `const CupertinoThemeData()`, TFA marks `CupertinoThemeData` allocated and retains `CupertinoBasedMaterialThemeData` -> `ColorScheme.fromSeed` (`~44 KB` saved on `slide_puzzle` vs. `~1 KB` on `DevTools`). -* **Why `dart2js` increases by `+8 KB` (`+0.1%`) on `DevTools`**: Unlike `dart2wasm` and `gen_snapshot` (which prune `16` unused `*_theme.dart` libraries on `DevTools` via interprocedural TFA), `dart2js` resolves AST references inside reachable methods *before* global type inference runs. Because `DevTools`'s widget inspector keeps `Diagnosticable.debugFillProperties` polymorphically live—and `ThemeData.debugFillProperties` currently calls `ThemeData.fallback()` and reads all 47 public getters unconditionally—`dart2js` retains all 47 `*ThemeData` classes while adding `+8 KB` (`+2 KB` gzipped) for the nullable backing getters and `lerp`/`==`/`hashCode` null guards. +* **Why `dart2js` increases by `+8 KB` on `DevTools`**: Unlike `dart2wasm` and `gen_snapshot` (which evaluate `a._fooTheme == null && b._fooTheme == null` monotonically from `Null` and prune the `else` branch), `dart2js`'s `TypeGraphInferrer` wires both arms of `ConditionalExpression` into a diamond `Phi` before solving types ([dart-lang/sdk#64908](https://github.com/dart-lang/sdk/issues/64908)). Because the `else` arm in `ThemeData.lerp` calls static `FooThemeData.lerp(...)` (or reads non-null `a.fooTheme`), its return type flows unconditionally into `ThemeData.raw` and widens every `_fooTheme` field from `[null]` to `[null|FooThemeData]`, retaining unused `*ThemeData` classes in `main.dart.js` while adding `+8 KB` (`+0.1%`, `+2 KB` gzipped) on `DevTools` for the backing getters and null guards. ## Testing