While converting the boilerplate for OverReact component declaration from Dart 1 transformers to Dart 2 builders, we encountered several constraints that made us choose between dropping backwards compatibility (mainly support for props class inheritance), and a less-than-optimal boilerplate.
To make the Dart 2 transition as smooth as possible, we chose to keep the new boilerplate version as backwards-compatible as possible, while compromising the cleanliness of the boilerplate. In time, we found that this wasn't great from a user experience or from a tooling perspective.
Knowing this, and having dropped support for Dart 1, we now have the opportunity to implement an improved version of OverReact boilerplate that fixes issues introduced in the latest version, as well as other miscellaneous ones.
The current, Dart-2-only boilerplate generates public props classes:
// User-authored
@Props()
class _$FooProps extends BarProps {
String foo;
}
// Generated in .over_react.g.dart
class FooProps extends _$FooProps with _$FooPropsAccessorsMixin {
static const PropsMeta meta = ...;
...
}
In using the build packages's build_to: cache
to generate public APIs, command-line
tools like dartanalyzer
and dartdoc
don't function properly for packages that do this.
This pattern can also degrade the dev experience, by requiring a build before code is statically valid, and also requiring rebuilds in some cases to consume public API updates during their development (e.g., writing new component props classes).
The transitional (backwards-compatible with Dart 1) boilerplate, currently used in almost all repos, does not have these issues, as it requires users to stub in these public classes:
// User-authored
@Props()
class _$FooProps extends BarProps {
String foo;
}
// Also user-authored
class FooProps
extends _$FooProps
with
// ignore: mixin_of_non_class, undefined_class
_$FooPropsAccessorsMixin {
// ignore: const_initialized_with_non_constant_value, undefined_class, undefined_identifier
static const PropsMeta meta = _$metaForPanelTitleProps;
}
This is overly verbose, confusing, and error-prone. Authoring components should be simple and easy.
Props are declared as fields, and we generate the accessor (AKA getters/setters) implementations that are to be used when reading and writing props.
If the consumer authors the public-facing class, we have to do this in new generated subclasses to be able to override the field implementation.
// Source
class FooProps {
int foo;
}
// Generated
mixin $FooPropsAccessors on FooProps {
@override int get foo => props['foo'];
@override set foo(int value) => props['foo'] = value;
}
// This class is actually what's used under the hood, not FooProps.
class $FooProps = FooProps with $FooPropsAccessors;
However, if consumers were to extend from the authored class, they wouldn't inherit these generated fields.
class BarProps extends FooProps {
String bar;
}
test() {
// references `FooProps.foo`, not the `$FooProps.foo` getter as desired.
BarProps().foo;
}
-
We cannot use resolved AST to generate components because it slows down the build too much.
In other words, we have access to the structure of the code within a given file but not its full semantic meaning, and cannot resolve references it makes to code in other files.
For instance, we can look at a class and see the name of the class it extends from and the methods it declares, but we won't be able to know where the parent class comes from, what type(s) the parent implements, or which member(s) the parent declares.
-
User-authored code must reference generated code somehow to "wire it up".
Since generated code can be output only to new files, component registration / wiring of generated code requires either:
-
a centralized, generated registry that maps components to generated component code, and that must be generated for and consumed in that main() method of all consuming apps' entrypoints.
-
a user-authored entrypoint (field initializer, method invocation, constructor, etc.) that imports (or pulls in via a part) and references generated code (what we have now).
-
-
Keep component declarations as terse and user-friendly as possible.
-
Use
build_to: cache
(for more information, see: pkg:build docs).build_to:cache
should be used when generated code is dependent on the library's underlying implementation. This may not be strictly the case today, but if we commit tobuild_to: cache
, we will have more flexibility in the future to make improvements or fix bugs to OverReact code generation without requiring a (very expensive) breaking change.It would also result in improvements to the builder being propagated immediately as soon as they're consumed by wdesk, as opposed to having to regenerate code and release within every consumer library.
-
Make source code statically analyzable without running a build.
The build docs instruct not to use build_to: cache to generate public APIs, and command-line tools like
dartanalyzer
anddartdoc
don't function properly for packages that do this.Generating public APIs can also degrade the dev experience, by requiring a build before code is statically valid, and also requiring rebuilds in some cases to consume public API updates during their development (e.g., writing new component props classes).
-
Provide some means of sharing props/state declarations between components.
Being able to share props/state between multiple components is useful, especially when composing them together. We also have many legacy components that currently share props, and want to make it possible to upgrade them.
-
Provide a simple migration path for most components in our consumer ecosystems.
We can support new/old boilerplate at the same time, and slowly phase out the old as we migrate over to it using our
boilerplate_upgrade
codemod.For cases that don't migrate cleanly within the Workiva ecosystem, we can use the Wdesk versioning policy to replace them with APIs that use the new boilerplate in major versions or using versioned APIs.
-
Only support Component2 components.
The builder has different code paths for Component/Component2, and supporting an additional boilerplate for both would increase code complexity and effort needed to build/test it.
A utility called castUiFactory
has been added that prevent implicit cast errors
(which are no longer ignorable as of Dart 2.9) on factory declarations. All that needs to be done is to wrap the generated
factory with castUiFactory
, so that it can infer the typing from the left hand side and cast the factory (considered
"dynamic" before code generation is run) to the correct type.
@Factory()
UiFactory<FooProps> Foo =
- _$Foo; // ignore: undefined_identifier
+ castUiFactory(_$Foo); // ignore: undefined_identifier
@Factory()
, @Props()
and @Component()
annotations add additional
visual clutter to the boilerplate, and are redundant since the factory/
props/component declarations already have a consistent/restricted
structure and naming scheme that makes it clear to the builder parsing
logic that a component is being defined, and what each part is.
- @Factory()
UiFactory<FooProps> Foo =
castUiFactory(_$Foo); // ignore: undefined_identifier
- @Props()
class _$FooProps extends BarProps {
String foo;
}
- @Component2()
class FooComponent extends UiComponent2<FooProps> {
@override
render() => 'foo: ${props.foo}';
}
Annotations can still be used, opt-in, if custom configuration is needed.
@Props(keyNamespace: 'customNamespace.')
class _$FooProps extends BarProps {
String foo;
}
Right now, we have to add // ignore: uri_has_not_been_generated
to each
component library on the part/import that references generated code.
Ignoring this hint globally within analysis_options.yaml:
analyzer:
errors:
uri_has_not_been_generated: ignore
Allows individual ignores to be omitted, which will reduce clutter in the component boilerplate.
- // ignore: uri_has_not_been_generated
part 'foo.over_react.g.dart';
This warning is also ignored by default in workiva_analysis_options.
Constraints:
-
Props classes must directly subclass UiProps, only inheriting other props via mixins.
- This requires consumers to include every single mixin within their
with
clause, allowing the builder to mix in the generated code corresponding to those mixins.
- This requires consumers to include every single mixin within their
-
Props can only be declared in mixins.
- This ensures they can be inherited by other props classes (by mixing them in, since you can no longer inherit them via subclassing) and provides consistency around how props are declared.
- // ignore: uri_has_not_been_generated
part 'foo.over_react.g.dart';
- @Factory()
UiFactory<FooProps> Foo =
+ castUiFactory(_$Foo); // ignore: undefined_identifier
- @Props()
- class _$FooProps extends BarProps {
+ mixin FooPropsMixin on UiProps {
String foo;
}
+ class FooProps = UiProps with FooPropsMixin, BarPropsMixin;
- @Component2()
class FooComponent extends UiComponent2<FooProps> {
@override
render() => 'foo: ${props.foo}';
}
- // ignore: mixin_of_non_class, undefined_class
- class FooProps extends _$FooProps with _$FooPropsAccessorsMixin {
- // ignore: undefined_identifier, const_initialized_with_non_constant_value
- static const PropsMeta meta = _$metaForFooProps;
- }
Note how props previously within _$FooProps
were moved to FooPropsMixin
, which is now mixed into the concrete FooProps
class, along with another props mixi, BarPropsMixin
.
Generated code:
part of 'foo.dart';
//
// (Component and factory code is pretty much the same.)
//
mixin $FooPropsMixin on FooPropsMixin {
@override
String get foo => props['foo'];
@override
set foo(String value) => props['foo'] = value;
}
class _$FooPropsImpl extends FooProps
// These mixins are derived from the list of mixins in the source
with $FooPropsMixin, $BarPropsMixin {}
When no other mixins are used, you can omit the concrete props class and just use the mixin.
import 'package:over_react/over_react.dart';
part 'foo.over_react.g.dart';
UiFactory<FooProps> Foo =
castUiFactory(_$Foo); // ignore: undefined_identifier
mixin FooProps on UiProps {
String foo;
}
class FooComponent extends UiComponent2<FooProps> {
@override
render() => 'foo: ${props.foo}';
}
Props meta will be generated as an overridden getter on the component as opposed to the current static field, and will allow similar access of prop keys as before.
This eliminates the current meta
portion of the boilerplate which has
to reference more generated code.
Prop meta from all mixins can be accessed, allowing us to default
consumedProps
to all props statically accessible from that component.
Consumption:
@Props()
class FooProps extends UiProps with FooPropsMixin, BarPropsMixin {}
@PropsMixin()
mixin FooPropsMixin on UiProps {
@requiredProp
int foo;
}
@Component2()
class FooComponent extends UiComponent2<FooProps> {
@override
render() => [props.foo];
@override
get consumedProps => [
propsMeta.forMixin(FooPropsMixin),
];
test() {
print(propsMeta.keys); // ('foo', 'bar')
print(propsMeta.forMixin(FooPropsMixin).keys); // ('foo')
print(propsMeta.forMixin(BarPropsMixin).keys); // ('bar')
}
}
// Generated code
class _FooCopmonentImpl extends FooComponent {
// ...
@override
PropsMetaCollection get propsMeta => const PropsMetaCollection({
FooPropsMixin: $FooPropsMixin.meta,
BarPropsMixin: $BarPropsMixin.meta,
});
}
Over_react updates:
// A new field in component base class
class UiComponent2 ... {
/// A collection of metadata for the prop fields in all prop mixins
/// used by the props class of this component.
PropsMetaCollection get propsMeta => null;
}
A new class in over_react:
/// A collection of metadata for the prop fields in all prop mixins
/// used by a given component.
///
/// See [PropsMeta] for more info.
class PropsMetaCollection implements PropsMeta {
final Map<Type, PropsMeta> _metaByMixin;
const PropsMetaCollection(this._metaByMixin);
/// Returns the metadata for only the prop fields declared in [mixinType].
PropsMeta forMixin(Type mixinType) {
final meta = _metaByMixin[mixinType];
assert(meta != null,
'No meta found for $mixinType;'
'it likely isn\'t mixed in by the props class.')
return meta ?? const PropsMeta(fields: [], keys: []);
}
// PropsMeta overrides
@override
List<String> get keys =>
_metaByMixin.values.expand((meta) => meta.keys).toList();
@override
List<PropDescriptor> get fields =>
_metaByMixin.values.expand((meta) => meta.fields).toList();
@override
List<PropDescriptor> get props => fields;
}
Since props mixins can only be consumed by other generated code, the existing props map view consumption pattern, whereby props mixins are consumed in user-authored MapView subclasses, cannot be supported.
Instead, props map views will be declared similarly to a component, with a factory and props mixin/class, but no component.
import 'package:over_react/over_react.dart';
+ part 'foo.over_react.g.dart';
- class FooPropsMapView extends UiPropsMapView with SomeOtherPropsMixin {
- FooPropsMapView(Map backingMap) : super(backingMap);
- }
+ UiFactory<FooMapViewProps> FooMapView = $FooMapView; // ignore: undefined_identifier
+ class FooMapViewProps = UiProps with SomeOtherPropsMixin;
usage() {
- final mapView = FooPropsMapView(someExistingMap);
+ final mapView = FooMapView(someExistingMap);
}
Each component has a consumedProps
method, a list of props that are considered "consumed" by the component,
and thus should not get forwarded on to other components via addUnconsumedProps
/copyUnconsumedProps
, and should
be validated via propTypes
.
@Props()
class FooProps extends UiProps {
int foo;
int bar;
}
@Component()
class FooComponent extends UiComponent<FooProps> {
render() {
// {
// FooProps.foo: 1,
// FooProps.bar: 2,
// data-a-dom-prop: 3,
// onClick: 4,
// someArbitraryProp: 5,
// }
print(props);
// {
// data-a-dom-prop: 3,
// onClick: 4,
// someArbitraryProp: 5,
// }
print(copyUnconsumedProps());
}
}
In the legacy boilerplate, this only included props declared within the props class:
@Props()
class FooProps extends OtherPropsMixin {
int foo;
int bar;
}
@Component()
class FooComponent extends UiComponent<FooProps> {
render() {
// {
// FooProps.foo: 1,
// FooProps.bar: 2,
// data-a-dom-prop: 3,
// onClick: 4,
// someArbitraryProp: 5,
// OtherPropsMixin.other: 6,
// }
print(props);
// {
// data-a-dom-prop: 3,
// onClick: 4,
// someArbitraryProp: 5,
// OtherPropsMixin.other: 6,
// }
print(copyUnconsumedProps());
}
}
This was convenient most of the time, especially for simple components that didn't want to pass their props along.
If needed, you could override consumedProps
to explicitly include other props:
@override
get consumedProps => [
FooProps.meta,
OtherPropsMixin.meta,
];
With the new mixin-based syntax, props cannot be declared directly within props classes, so if we kept using the consumed props behavior from the legacy syntax, they wouldn't have any consumed props by default. (Unless we picked a mixin or something, which could get confusing)
This would mean that any props class that consumes the props of a single mixin would need to override consumedProps, whereas before they wouldn't have to.
// Before
@Props()
class FooProps extends UiProps with OtherPropsMixin {
int foo;
int bar;
}
@Component()
class FooComponent ... {
// no consumedProps override necessary
}
// After
mixin FooPropsMixin on UiProps {
int foo;
int bar;
}
class FooProps = UiProps with FooPropsMixin, OtherPropsMixin;
class FooComponent ... {
// consumedProps override necessary
@override
get consumedProps => propsMeta.forMixins({FooPropsMixin});
}
To help optimize this use-case, as well as to make whether props are consumed or not more consistent across different forms of the new syntax, we decided to consume props from all props mixins by default, if consumedProps is not overridden.
So, taking the above example again, the new behavior would be:
mixin FooPropsMixin on UiProps {
int foo;
int bar;
}
class FooProps = UiProps with FooPropsMixin, OtherPropsMixin;
class FooComponent extends UiComponent<FooProps> {
render() {
// {
// FooProps.foo: 1,
// FooProps.bar: 2,
// data-a-dom-prop: 3,
// onClick: 4,
// someArbitraryProp: 5,
// OtherPropsMixin.other: 6,
// }
print(props);
// {
// data-a-dom-prop: 3,
// onClick: 4,
// someArbitraryProp: 5,
// }
print(copyUnconsumedProps());
}
}
The old behavior is still achievable through overriding consumedProps
, and some cases will be easier than before thanks to propsMeta.
For example:
-
Consuming all props except for a few mixins:
Before:
@Props() class FooProps extends UiProps with AProps, BProps, CProps, NoConsumeProps { ... } @Component() class FooComponent extends UiComponent<FooProps> { @override consumedProps => [ FooProps.meta, AProps.meta, BProps.meta, CProps.meta, ]; ... }
After:
class FooProps = UiProps with FooPropsMixin AProps, BProps, CProps, NoConsumeProps; class FooComponent extends UiComponent<FooProps> { @override consumedProps => propsMeta.allExceptForMixins({NoConsumeProps}), ... }
We couldn't "consume" props from other classes by default, since we didn't have full knowledge of all the props classes and mixins inherited by a given props class's superclass (due to not having a resolved AST in our builder, for performance reasons).
However, in the new mixin-based syntax, props classes must explicitly mix in all props mixins they inherit from, so we're able to easily tell at build time what they all are, and thus don't have that same restriction.
Most code within over_react has been updated to use this new boilerplate, including:
- Files under
example/
- Files under
web/
(except forweb/component1/
—the new boilerplate is UiComponent2-only) - The Redux sample todo app under
app/over_react_redux/todo_client/
Includes all of the constraints listed in the Boilerplate Updates section, ignoring parts about backwards-compatibility.
-
Should be as visually uncluttered as possible.
-
Should not wrap excessively for longer component names.
-
Should be easy to transition between having and not having default props, and boilerplate shouldn't change shape drastically when doing so.
-
Function calls using generated functions should be avoided since they don't allow generic type inference of the
props
arg in the function closure.
import 'package:over_react/over_react.dart';
part 'foo.over_react.g.dart';
UiFactory<FooProps> Foo = uiFunction(
(props) {
return 'foo: ${props.foo}';
},
_$FooConfig, // ignore: undefined_identifier
);
mixin FooProps on UiProps {
String foo;
}
Here, uiFunction
gets a generic parameter of FooProps
inferred
from the LHS typing, allowing props to be statically typed as FooProps
.
The generated $FooConfig
is passed in as an argument, and serves
as the entrypoint to the generated code.
defaultProps
on function components is
already deprecated.
Instead, we use null-aware operators to default null values. This provides almost the
same behavior as defaultProps
, but with the restriction that a given prop
must either be nullable or have a default value, but not both.
UiFactory<FooProps> Foo = uiFunction(
(props) {
final foo = props.foo ?? 'default foo value';
return 'foo: $foo';
},
_$FooConfig, // ignore: undefined_identifier
);
Because functional components have no instance that track consumed props, the syntax for forwarding props changes within functional components.
UiProps
exposes 2 APIs getPropsToForward
& addPropsToForward
that can be used to forward props
that have not been used to a child component.
getPropsToForward
will return a Map
of props removing the props found in the exclude
argument.
exclude
is optional and will default to a Set
with the type that props
is statically typed as,
this only works with mixin .. on UiProps
types. If your function component uses a Props class
then
you must include an exclude
argument.
Component with a single props mixin:
mixin FooPropsMixin on UiProps {
String foo;
}
UiFactory<FooPropsMixin> Foo = uiFunction((props) {
return (Bar()
// Filter out props declared in FooPropsMixin
// (used as the default for `exclude` since that's what `props` is statically typed as)
// when forwarding to Bar.
..addAll(props.getPropsToForward())
)();
});
Component with a more than one props mixin:
mixin FooPropsMixin on UiProps {
String foo;
}
class FooProps = UiProps with BarProps, FooPropsMixin;
UiFactory<FooProps> Foo = uiFunction((props) {
return (Bar()
// Filter out props declared in FooPropsMixin when forwarding to Bar.
..addAll(props.getPropsToForward(exclude: {FooPropsMixin}))
)();
});
domOnly
- to forward DOM props only:
mixin FooPropsMixin on UiProps {
String foo;
}
UiFactory<FooPropsMixin> Foo = uiFunction((props) {
return (Dom.div()
// Forward only DOM based props & Filter out props declared in FooPropsMixin
// (used as the default for `exclude` since that's what `props` is statically typed as)
// when forwarding to Bar.
..addAll(props.getPropsToForward(domOnly: true))
)();
});
addPropsToForward
has the same function signature as getPropsToForward
but is meant to be used with the UiProps
method modifyProps
.
Component with a single props mixin:
mixin FooPropsMixin on UiProps {
String foo;
}
UiFactory<FooPropsMixin> Foo = uiFunction((props) {
return (Bar()
// Filter out props declared in FooPropsMixin
// (used as the default for `exclude` since that's what `props` is statically typed as)
// when forwarding to Bar.
..modifyProps(props.addPropsToForward())
)();
});
Component with a more than one props mixin:
mixin FooPropsMixin on UiProps {
String foo;
}
class FooProps = UiProps with BarProps, FooPropsMixin;
UiFactory<FooProps> Foo = uiFunction((props) {
return (Bar()
// Filter out props declared in FooPropsMixin when forwarding to Bar.
..modifyProps(props.addPropsToForward(exclude: {FooPropsMixin}))
)();
});
domOnly
- to forward DOM props only:
mixin FooPropsMixin on UiProps {
String foo;
}
UiFactory<FooPropsMixin> Foo = uiFunction((props) {
return (Dom.div()
// Forward only DOM based props & Filter out props declared in FooPropsMixin
// (used as the default for `exclude` since that's what `props` is statically typed as)
// when forwarding to Bar.
..modifyProps(props.addPropsToForward(domOnly: true))
)();
});
UiFactory<UiProps> Foo = uiFunction(
(props) {
return 'id: ${props.id}';
},
UiFactoryConfig(
displayName: 'Foo',
),
);
UiFactory<FooProps> Foo = uiFunction(
(props) {
return 'foo: ${props.foo}';
},
_$FooConfig, // ignore: undefined_identifier
getPropTypes: (keyFor) => {
keyFor((p) => p.foo): (props, info) {
if (props.foo == 'bar') {
return PropError('You can\'t foo a bar, silly');
}
}
},
);
getPropTypes
provides a way to set up prop validation within the
same variable initializer.
import 'package:over_react/over_react.dart';
part 'foo.over_react.g.dart';
mixin FooProps on UiProps {
String foo;
}
// Example function; this can look like anything and doesn't have to
// be declared in this file.
UiFactory<FooProps> createFooHoc(UiFactory otherFactory) {
Object closureVariable;
// ...
UiFactory<FooProps> FooHoc = uiFunction(
(props) {
return otherFactory()(
Dom.div()('closureVariable: ${closureVariable}'),
Dom.div()('prop foo: ${props.foo}'),
);
},
UiFactoryConfig(
displayName: 'FooHoc',
propsFactory: PropsFactory.fromUiFactory(Foo),
),
);
return FooHoc;
}
mixin FooProps on UiProps {
Ref forwardedRef;
Function doSomething;
}
UiFactory<FooProps> Foo = uiForwardRef(
(props, ref) {
return Fragment()(
Dom.div()('Some text.'),
(Dom.button()
..ref = ref
..onClick = props.doSomething
)('Click me!'),
);
},
_$FooConfig, // ignore: undefined_identifier
);
First, you must upgrade your components to UiComponent2
. Check out the UiComponent2
Migration Guide to learn about the benefits of UiComponent2
, the codemod script you can run, and other updates you may need to make manually.
To update your repository to the new boilerplate, there are two steps:
- Upgrade to the
mixin
based boilerplate. - Upgrade to use the new factory syntax.
If you are already using the mixin based boilerplate, skip to Upgrade to the New Factory Syntax.
You can use over_react_codemod's
boilerplate_upgrade
executable to make this step easier. This codemod goes
through the repository and updates the boilerplate as necessary. While
the codemod will handle many basic updates, it will still need to be
supplemented with some manual checks and refactoring.
If you are migrating a Workiva library, before running the codemod,
run semver_audit
inside your repository and save the report using the
following commands:
pub global activate semver_audit --hosted-url=https://pub.workiva.org
pub global run semver_audit generate 2> semver_report.json
This will allow the codemod to check whether or not components are public API.
If you are migrating a library outside of the Workiva ecosystem, run the command
below with the --treat-all-components-as-private
flag.
Then, run the codemod by following the directions within the executable from the root of your local copy of the repository.
When running the command pub global run over_react_codemod:boilerplate_upgrade
to update your components, there are two flags that can be used:
-
--treat-all-components-as-private
: assumes that all components are not publicly exported and thus can be upgraded to the new boilerplate. Without this flag, all components that are publicly exported (as determined by the semver report) will not be upgraded. -
--convert-classes-with-external-superclasses
: allows classes with external superclasses to be upgraded to the new boilerplate. Without this flag, all classes with external superclasses will not be upgraded.
Similar to step number 1, there is a codemod to assist with this. After activating over_react_codemod, within your project, run:
pub global run over_react_codemod:dart2_9_upgrade
This upgrade is considered very minor, and while manual intervention may be necessary, we are not aware of any edge cases that will be notably difficult.