diff --git a/rfc/710.0000-style-guide-package-private.md b/rfc/710.0000-style-guide-package-private.md new file mode 100644 index 0000000..f4c03cb --- /dev/null +++ b/rfc/710.0000-style-guide-package-private.md @@ -0,0 +1,208 @@ +--- +type: rfc +rfc: '710.0000' +title: Style guide changes for package private +description: Updates the Flutter style guide and breaking change policy for package-private APIs and @internal. +status: draft +created: 2026-10-06T00:00:00Z +updated: 2026-11-02T18:30:00Z +tags: + - 710-style-guide + - 030-breaking-changes +authors: + - https://github.com/chunhtai +--- + +# RFC 710.0000: Style guide changes for package private + +## Overview + +This document proposes updating the [Flutter repository style guide](https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md) and [Flutter breaking change policy](https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes) to discourage rather than ban package-private APIs across `flutter/flutter` and `flutter/packages`, and to adopt `@internal` when package-private APIs are needed. + +Declarations in `lib/src/` that are not exported by public barrel files (`lib/.dart`) and are marked `@internal` fall outside the public API contract; modifying or removing them is not a breaking change. + +Package-private APIs are permitted only for: + +* Web and I/O conditional imports. +* Experimental APIs that are annotated with `@internal` and gated behind a feature flag. +* Internal-facing APIs shared across files within the same layer, when library-private declarations are impractical. + +## Context + +The style guide bans package-private APIs beyond library-private (`_`) declarations and requires every file in `lib/src//` to be exported by `lib/.dart`. When an implementation detail is needed across multiple files within a layer, contributors are left with two poor options: + +* Keep declarations library-private (`_`), which forces all related code into a single large file. +* Expose implementation details as public APIs (such as `NavigatorState.finalizeRoute` shared between `navigator.dart` and `routes.dart`, or `HardwareKeyboard.syncKeyboardState` shared between `hardware_keyboard.dart` and `binding.dart` in `services`), which commits the framework to backward compatibility and discourages refactoring. + +In practice, the framework already omits `_`-prefixed files in `lib/src/` from barrel exports (such as `lib/src/widgets/_accessibility_evaluations.dart`, `lib/src/widgets/_window.dart`, and `lib/src/foundation/_features.dart`), and `flutter/packages` already uses `@internal` with `hide` (such as `GoRouterStateRegistry` in `go_router`). + +Relying on filename conventions alone risks accidental leaks. Because `lib/src/` files are re-exported by default, moving code or sharing a non-private class within a layer can inadvertently expose it to external consumers, making future edits breaking changes. Marking these declarations `@internal` triggers an [`invalid_export_of_internal_element`](https://dart.dev/tools/diagnostics/invalid_export_of_internal_element) analyzer error if exported from a public barrel file, and triggers [`invalid_use_of_internal_member`](https://dart.dev/tools/diagnostics/invalid_use_of_internal_member) if external consumers import `lib/src/` directly. + +## Proposal + +### Breaking change policy and customer tests + +* Modifying, renaming, or removing any declaration in `lib/src/` that is not exported by a public barrel file and is annotated with `@internal` is not a breaking change and requires no deprecation period or announcement, subject to the grace period below. +* Adding `@internal` to an existing declaration must be accompanied by a comment that ends with `This feature was made internal after v.`, where `` is the latest Flutter version tag when the PR is written, following the same convention as `@Deprecated` messages (for example, `// This feature was made internal after v3.50.0-0.3.pre.`). Until a stable release that includes the annotation has shipped, changes to the declaration follow the normal breaking change policy. Declarations that are `@internal` from the start have no grace period. +* Registered tests in [`flutter/tests`](https://github.com/flutter/tests) must not import `package:flutter/src/` or depend on `@internal` APIs. Breakages in customer tests caused by `package:flutter/src/` imports or `@internal` usage are not breaking changes, and contributors may disable or update offending customer tests when modifying `@internal` declarations that are past any grace period. + +### Package-private APIs (`@internal`) + +Package-private APIs remain discouraged in general, rather than banned. Unexported files and package-private declarations in `lib/src/` are permitted only in the following cases: + +* Experimental APIs that are not yet ready for public stability guarantees. These APIs must be both annotated with `@internal` and gated behind a feature flag. +* Internal-facing APIs shared across files within the same layer (`lib/src//`). Contributors should use true library-private (`_`) declarations first, and only use `@internal` package-private APIs when keeping the code in one file would put too many distinct concepts in a single file. + +#### Relationship to `@visibleForTesting` and `@Deprecated` + +* `@internal` is for package-private declarations omitted or hidden from public barrel exports. +* `@internal` APIs must be used internally. An `@internal` API that is referenced nowhere in the package's non-test code (outside `test/`, `integration_test/`, and other test directories) should be removed. References from tests do not count as uses; test-only access is what `@visibleForTesting` is for. +* `@internal` must not be used on APIs meant to be removed; use `@Deprecated` instead. +* `@visibleForTesting` remains discouraged on public declarations, because declarations exported by a public barrel file remain reachable by external consumers without importing `lib/src/`. + +#### Placement rules + +* `@internal` may only be applied to declarations inside `lib/src/`. +* For package-private members on a publicly exported class, prefer an `@internal` `extension` declared in the same file, excluded from `lib/.dart` with `hide`, and with both the `extension` and all its members annotated with `@internal` (an explicit exception to the style guide rule against `extension`s). + +For example, `lib/src/widgets/route_state.dart` can keep the implementation private while allowing another file in the layer to invoke it through an internal extension: + +```dart +import 'package:meta/meta.dart'; + +class RouteState { + void _finalizeRoute() { + // Implementation. + } +} + +@internal +extension RouteStateInternal on RouteState { + @internal + void finalizeRoute() => _finalizeRoute(); +} +``` + +The public barrel excludes the extension: + +```dart +export 'src/widgets/route_state.dart' hide RouteStateInternal; +``` + +An internal file in the same layer can still import the source file and use the extension: + +```dart +import 'route_state.dart'; + +void finish(RouteState state) { + state.finalizeRoute(); +} +``` + +* When a package-private constructor or class member is needed for inheritance or construction on a publicly exported class, all such constructors and members must be annotated with `@internal`, and the class must be marked `final` or `sealed` so external classes cannot `extends` or `implements` it. +* `@internal` types must not appear in the public signatures or supertypes (`extends`, `implements`, `with`, `on`) of publicly exported declarations, because the analyzer does not flag `@internal` types leaked indirectly through exported APIs. Expose a public type or supertype instead, using an `@internal` constructor if construction must remain package-private. +* In any file in `lib/src/` not exported by a barrel file, all public top-level declarations must be annotated with `@internal`, subject to the cross-platform API policy below. + +#### Cross-platform APIs + +**Open question:** Which approach should the guideline take for conditional-import implementations such as `_*_io.dart` and `_*_web.dart`? The preferred approach is option 2, which keeps platform-specific declarations internal and exposes the supported API through a public facade. + +1. **Exception:** Keep existing declarations as-is and treat conditional-import implementation files as an explicit exception to the rule requiring `@internal` on public top-level declarations in unexported files. +2. **Public facade (preferred):** Annotate declarations in the implementation files with `@internal` and expose the supported API through a public facade that delegates to the selected implementation. For example: + +```dart +// lib/src/widgets/device.dart +import '_device_stub.dart' + if (dart.library.io) '_device_io.dart' + if (dart.library.html) '_device_web.dart' as platform; + +class Device { + Device() : _implementation = platform.DeviceImplementation(); + + final platform.DeviceImplementation _implementation; + + String get name => _implementation.name; + + bool get isAvailable => _implementation.isAvailable; + + Future refresh() => _implementation.refresh(); +} +``` + +```dart +// lib/src/widgets/_device_stub.dart +import 'package:meta/meta.dart'; + +@internal +class DeviceImplementation { + String get name => 'Unknown device'; + + bool get isAvailable => false; + + Future refresh() async {} +} +``` + +```dart +// lib/src/widgets/_device_io.dart +import 'package:meta/meta.dart'; + +@internal +class DeviceImplementation { + String get name => 'Native device'; + + bool get isAvailable => true; + + Future refresh() async { + // Refresh native device state. + } +} +``` + +```dart +// lib/src/widgets/_device_web.dart +import 'package:meta/meta.dart'; + +@internal +class DeviceImplementation { + String get name => 'Web device'; + + bool get isAvailable => true; + + Future refresh() async { + // Refresh web device state. + } +} +``` + +The public barrel exports the facade, not the conditional implementations: + +```dart +export 'src/widgets/device.dart'; +``` + +Under the second approach, consumers use `Device`; the platform-specific implementations remain implementation details. + +#### Barrel exports and file naming + +* Drop the requirement that every file in `lib/src//` be exported by `lib/.dart` (superseding [flutter/flutter#108632](https://github.com/flutter/flutter/issues/108632)). +* Any file in `lib/src//` not exported by `lib/.dart` must have a `_` filename prefix (such as `lib/src/widgets/_accessibility_evaluations.dart`), and have all public top-level declarations annotated with `@internal`, subject to the cross-platform API policy above. +* When an `@internal` top-level declaration shares a file with public declarations, `lib/.dart` must exclude it with `hide` (such as `export 'src/widgets/foo.dart' hide InternalBar;`). Omitting `hide` in `lib/.dart` triggers [`invalid_export_of_internal_element`](https://dart.dev/tools/diagnostics/invalid_export_of_internal_element). (`hide` is not required on intra-`src/` re-exports.) +* Cross-layer imports in `lib/src/` must continue importing top-level barrel files (`package:flutter/.dart`) rather than another layer's `lib/src/` files, with `lib/src/foundation/_features.dart` as the only permitted exception for framework-wide feature flags. + +### TODOs + +The analyzer already catches direct exports of `@internal` declarations from barrel files with [`invalid_export_of_internal_element`](https://dart.dev/tools/diagnostics/invalid_export_of_internal_element), and external usage with [`invalid_use_of_internal_member`](https://dart.dev/tools/diagnostics/invalid_use_of_internal_member) and `implementation_imports`. The remaining work: + +1. Update the [Flutter repository style guide](https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md) and [breaking change policy](https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes) to match this proposal, replacing the current ban on package-private APIs and the requirement that every file in `lib/src//` be exported by `lib/.dart`. +2. Add custom analyzer lint rules for single-library repository rules: + * In `_`-prefixed files in `lib/src/`, all public top-level declarations must be annotated with `@internal` (subject to the cross-platform API policy above). + * `@internal` types must not appear in the public signatures or supertypes (`extends`, `implements`, `with`, `on`) of publicly exported declarations. + * Public members of an `@internal` `extension` must also be annotated with `@internal`. + * Any publicly exported class with `@internal` constructors or members must be marked `final` or `sealed`. + * Files in `lib/src//` must not import another layer's `lib/src/` files directly, except for `lib/src/foundation/_features.dart`. +3. Add CI checks for cross-file and barrel rules: + * Any file in `lib/src//` not exported by `lib/.dart` must have a `_` filename prefix, and `_`-prefixed files must not be exported by `lib/.dart`. + * Every `@internal` declaration must be referenced in the package's non-test code; references from tests do not count, and unused `@internal` declarations must be removed. +4. Migrate existing cross-platform code (conditional-import `_*_io.dart` and `_*_web.dart` implementations) to the public facade approach. +5. Audit existing files that contain too many distinct concepts, such as `lib/src/widgets/framework.dart` and `lib/src/widgets/basic.dart`, and split them into smaller files using `@internal` where cross-file access is needed.